# Nostr Config Projection — "Nostr as your home directory"

> **Status:** Design. This document defines how n-OS-tr projects a user's configuration from Nostr events into the live filesystem at boot, and how it publishes changes back to Nostr at shutdown.
>
> Depends on: [`plans/identity_subsystem.md`](identity_subsystem.md) (must run first to load the main key), [`plans/threat_model.md`](threat_model.md) (defines what this subsystem is and is not allowed to do).

## 1. Concept

Your n-OS-tr configuration — dotfiles, preferences, bookmarks, per-app state — is **not** stored on the machine. It's stored as **replaceable Nostr events, signed by your main key (NIP-06 index 0), encrypted to yourself with [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md)**.

At boot, after identity is loaded, the **config-loader** queries a set of user relays for these events, decrypts them, and writes the content into the correct locations under `/home/$USER/...` on the ephemeral rootfs. At shutdown (or on explicit `nostr-config save`), the **config-writer** re-emits changed files as new signed replaceable events.

The consequence:

- **Same 12 words on any n-OS-tr machine = same environment.** Your `.bashrc`, your SSH `authorized_keys`, your Firefox bookmarks, your relay list, your app preferences all reappear.
- **No machine knows anything about you after shutdown.** The tmpfs is wiped; the Nostr events live on relays (encrypted) and on paper in your head (the mnemonic).
- **Only you can read your config.** NIP-44 encrypts to your own npub; relay operators see ciphertext only.

## 1.1 Crypto foundation: `nostr_core_lib`

Like [`identity_subsystem.md`](identity_subsystem.md#11-crypto-foundation-nostr_core_lib), the config-loader and config-writer are built on top of [`includes/nostr_core_lib`](../includes/nostr_core_lib/). Concretely:

| Capability                                  | `nostr_core_lib` symbol                                                                        | NIP        |
|---------------------------------------------|------------------------------------------------------------------------------------------------|------------|
| Create + sign replaceable slot events       | [`nostr_create_and_sign_event`](../includes/nostr_core_lib/nostr_core/nostr_core.h)            | NIP-01     |
| NIP-44 self-encrypt slot plaintext           | [`nostr_nip44_encrypt(priv, pub_self, plaintext, out, ...)`](../includes/nostr_core_lib/nostr_core/nip044.h:28) | NIP-44     |
| NIP-44 self-decrypt fetched ciphertext       | [`nostr_nip44_decrypt(priv, pub_self, ciphertext, out, ...)`](../includes/nostr_core_lib/nostr_core/nip044.h:62) | NIP-44     |
| Query relays for slot events (sync)         | [`synchronous_query_relays_with_progress`](../includes/nostr_core_lib/nostr_core/nostr_core.h) | NIP-01     |
| Publish slot events                          | [`synchronous_publish_event_with_progress`](../includes/nostr_core_lib/nostr_core/nostr_core.h)| NIP-01     |
| Relay pool with reconnection (streaming)     | [`nostr_relay_pool_create`](../includes/nostr_core_lib/nostr_core/nostr_core.h) + [`nostr_relay_pool_subscribe`](../includes/nostr_core_lib/nostr_core/nostr_core.h) | NIP-01     |
| NIP-42 auth (personal private relay)         | [`nostr_relay_pool_set_auth`](../includes/nostr_core_lib/nostr_core/nostr_core.h)              | NIP-42     |
| NIP-11 relay info (feature check)            | [`nostr_nip11_fetch_relay_info`](../includes/nostr_core_lib/nostr_core/nostr_core.h)           | NIP-11     |
| Relay-level event signing proxied via agent  | NIP-46 path through [`nostr_nip46_client_sign_event`](../includes/nostr_core_lib/nostr_core/nip046.h:158) — config-writer never holds the user's raw private key | NIP-46     |

Two important consequences of the `nostr_core_lib` choice:

1. **Config-writer does not need the user's private key.** It talks to identity-agent over NIP-46 (local Unix socket), and each slot event is signed by the agent on the writer's behalf. The raw main nsec stays inside the agent's `mlock()`-ed memory per [F3 / F4 of the threat model](threat_model.md#41-the-mnemonic-and-derived-keys).

2. **The encryption target is the user's own pubkey.** NIP-44 "self-encryption" uses `priv_main` + `pub_main` as both ends of the ECDH; the library supports this directly (both arguments in [`nostr_nip44_encrypt`](../includes/nostr_core_lib/nostr_core/nip044.h:28)). Decryption uses the same `(priv_main, pub_main)` pair.

## 2. The model

### 2.1 Slots

A **slot** is a single unit of user configuration. Each slot is:
- One replaceable Nostr event.
- Keyed by a stable `d` tag identifying the slot (the "address" in NIP-33 terms).
- Content is either plaintext (for public/non-secret things like a relay list that you *want* to be reuseable by other clients) or NIP-44 ciphertext encrypted to the user's own npub (the default for anything remotely personal).
- Signed by the user's main key (NIP-06 index 0).

### 2.2 Manifest event

The **manifest** is a single special slot that lists every other slot the user wants the OS to materialize. The manifest is itself a replaceable event with a well-known `d` tag:

```
d = "n-os-tr:manifest:v1"
```

Its content is a JSON document whose schema is:

```json
{
  "version": 1,
  "generated_at": 1714000000,
  "slots": [
    {
      "name": "dotfiles/bashrc",
      "path": "/home/user/.bashrc",
      "mode": "0644",
      "encrypted": true
    },
    {
      "name": "ssh/authorized_keys",
      "path": "/home/user/.ssh/authorized_keys",
      "mode": "0600",
      "encrypted": true
    },
    {
      "name": "nostr/relays",
      "path": "/home/user/.config/n-os-tr/relays.json",
      "mode": "0644",
      "encrypted": false
    }
  ]
}
```

The manifest event is also NIP-44-encrypted to self by default. A user who explicitly wants their manifest public (so other clients can share data through n-OS-tr's slot scheme) can opt into unencrypted manifest publishing.

### 2.3 Event kinds

| Purpose               | Kind  | NIP        | Notes                                                   |
|-----------------------|-------|------------|---------------------------------------------------------|
| Slot event (config)   | 30078 | [NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md) ("application-specific data") | Parameterized replaceable, user-chosen `d` tag. Matches existing convention for app state. |
| Manifest              | 30078 | NIP-78     | `d = "n-os-tr:manifest:v1"`.                             |
| Role table            | 30078 | NIP-78     | `d = "n-os-tr:roles:v1"`. Maps role name → NIP-06 index. See [`plans/identity_subsystem.md`](identity_subsystem.md#23-role-registration-protocol). |
| Relay list            | 10002 | [NIP-65](https://github.com/nostr-protocol/nips/blob/master/65.md) | Optional; if present, config-loader prefers NIP-65 for discovering user relays. |

Choosing kind **30078** (NIP-78 application-specific data) aligns with existing clients and avoids claiming a new kind. All slot events use the same kind; they're distinguished by their `d` tag.

### 2.4 Tag conventions

Every slot event carries these tags:

```
["d",        "<slot name>"]                      required, unique per slot
["client",   "n-os-tr"]                          identifies which app wrote it
["ver",      "<semver>"]                         optional, version of the slot schema
["sha256",   "<hex>"]                            optional, hash of plaintext content
["path",     "<target path>"]                    optional, overrides manifest path if present
["encrypted","nip44"]                            present if content is NIP-44 ciphertext
```

## 3. Boot flow

```mermaid
sequenceDiagram
    autonumber
    participant IA as identity-agent
    participant CL as config-loader
    participant Sig as signer socket
    participant R as user relays
    participant FS as tmpfs /home

    Note over IA: mnemonic loaded<br/>(see identity_subsystem.md)

    CL->>Sig: connect, get npub for "main"
    Sig-->>CL: npub1...

    CL->>R: REQ kind=10002 author=npub (NIP-65 relay list)
    R-->>CL: user relays (cacheable)

    CL->>R: REQ kind=30078 author=npub d="n-os-tr:manifest:v1"
    R-->>CL: manifest ciphertext event

    CL->>Sig: NIP-44 decrypt(manifest.content)
    Sig-->>CL: manifest JSON

    loop for each slot in manifest
        CL->>R: REQ kind=30078 author=npub d=slot.name
        R-->>CL: slot event (ciphertext or plaintext)
        alt ciphertext
            CL->>Sig: NIP-44 decrypt
            Sig-->>CL: plaintext
        end
        CL->>FS: write plaintext to slot.path with slot.mode
    end

    Note over CL,FS: home dir materialized
    CL->>IA: signal "ready"
    IA->>+Session: start user session
```

### 3.1 Relay discovery

Config-loader needs to know where to query. Options, in priority order:

1. **Built-in bootstrap relays.** A tiny hardcoded list baked into the image (overridable per user). Used to fetch NIP-65 and manifest on a first-ever session from this mnemonic.
2. **NIP-65 relay list (kind 10002).** Once fetched, becomes the authoritative set for this user.
3. **User-provided relay list via kernel cmdline.** For edge cases where bootstrap relays are not reachable (offline/air-gapped setups), the user may pass `nostr.relay=wss://my-relay.example` on the boot cmdline.
4. **Local c-relay running on this machine.** If the user is running their own personal c-relay and it already has their events cached, config-loader will query it first and fall back to the network only for misses.

### 3.2 Timing and ordering

- Config-loader is a systemd unit with `Requires=nostr-id.service After=nostr-id.service network-online.target`.
- User session targets (display manager, shell, GUI) `After=config-loader.service`.
- config-loader blocks session start until it has either materialized the manifest or timed out.
- Timeout defaults to 15 seconds. On timeout, user is prompted: "No relays reachable. Continue without config? (y/N)". The fallback is an empty home (every boot pretends to be first boot).

### 3.3 First-ever boot with this mnemonic

If no manifest event exists yet (the npub has never published one), config-loader treats that as the "new user" case:
- Creates an empty manifest.
- Proceeds to materialize nothing.
- User session starts with a blank home; any changes can be captured by the first save.

## 4. Save flow

```mermaid
sequenceDiagram
    autonumber
    participant User
    participant CW as config-writer
    participant Sig as signer socket
    participant FS as /home
    participant R as user relays

    User->>CW: trigger (explicit save, shutdown, periodic)

    CW->>FS: scan tracked paths for changes
    FS-->>CW: list of modified slots

    loop for each modified slot
        CW->>FS: read plaintext
        alt slot.encrypted
            CW->>Sig: NIP-44 encrypt(plaintext, to=self)
            Sig-->>CW: ciphertext
            CW->>Sig: sign event(kind=30078, d=slot, content=ciphertext, tags=...)
        else slot.encrypted == false
            CW->>Sig: sign event(kind=30078, d=slot, content=plaintext, tags=...)
        end
        Sig-->>CW: signed event
        CW->>R: publish
    end

    CW->>Sig: sign updated manifest (if slot list changed)
    CW->>R: publish manifest
```

### 4.1 When does save happen?

Three triggers, in increasing scope:

1. **Explicit.** `nostr-config save [slot-name]` — user-initiated save of one slot or all.
2. **Shutdown.** On SIGTERM from systemd (clean shutdown), config-writer does a full save pass before poweroff continues. Hard-capped by a timeout so a bad relay doesn't block shutdown.
3. **Periodic (opt-in).** For users who want continuous sync, an opt-in `periodic=true` flag in the manifest causes config-writer to run on a 5-minute timer. Off by default, because continuous publishing is visible metadata on the relay.

### 4.2 What gets saved

Config-writer is **not** a generic home-directory sync. It only touches files listed in the manifest. Anything outside the manifest is ephemeral by definition — which is exactly what we want.

Users who want to track a new file:

```bash
nostr-config add-slot --name "dotfiles/vimrc" --path "/home/user/.vimrc" --mode 0644 --encrypted
```

This updates the manifest and does an immediate save.

### 4.3 Detecting changes

Each slot event carries a `sha256` tag with the hash of the plaintext at publish time. Config-writer compares the current file hash to the last-saved hash; if different, the slot is saved. This avoids republishing unchanged files.

## 5. Conflict handling

Two n-OS-tr sessions, different machines, same mnemonic, both editing the same slot. Whose write wins?

Nostr's replaceable-event semantics say: most recent `created_at` wins. So:
- Each slot event is tagged with a monotonic `created_at`.
- On save, config-writer stamps the event with `now`.
- Relays dedupe on `(author, kind, d)` keeping the latest `created_at`.

### 5.1 Detecting conflicts

When materializing at boot, config-loader also fetches the *previous* event ID (if any) and records it in `/run/nostr-config/state.json`. At save time, if a slot's remote event has advanced beyond what the loader saw at boot, we have a conflict:

| Scenario                                  | Config-writer behavior                                                            |
|-------------------------------------------|-----------------------------------------------------------------------------------|
| Local modified, remote unchanged          | Normal save. Publish new event.                                                   |
| Local unchanged, remote advanced          | No action. The old data is already overwritten on relay; we don't downgrade.      |
| Local modified **and** remote advanced    | Conflict. User is prompted: show local diff vs remote diff, choose one or merge.  |
| Local modified, remote has multiple updates | Conflict with multi-option chooser.                                              |

For phase-1 we implement:
- Detect
- Back up local plaintext to `/run/nostr-config/conflicts/<slot>.local`
- Prefer remote by default in non-interactive modes (shutdown timeout)
- Prompt on `nostr-config save` interactive runs.

### 5.2 Manifest version tag

The manifest itself carries a `version` integer. Session start reads the version; at save, if the remote manifest version is higher than what we started with, refuse to overwrite without an explicit merge step.

## 6. Example slot table

Concrete projection from slots to paths that the default n-OS-tr user will likely want:

| Slot name                         | Target path                               | Encrypted | Notes                              |
|-----------------------------------|-------------------------------------------|-----------|------------------------------------|
| `dotfiles/bashrc`                 | `/home/user/.bashrc`                      | yes       |                                    |
| `dotfiles/bash_aliases`           | `/home/user/.bash_aliases`                | yes       |                                    |
| `dotfiles/vimrc`                  | `/home/user/.vimrc`                       | yes       |                                    |
| `dotfiles/gitconfig`              | `/home/user/.gitconfig`                   | yes       | Contains name/email                |
| `ssh/authorized_keys`             | `/home/user/.ssh/authorized_keys`         | yes       |                                    |
| `ssh/config`                      | `/home/user/.ssh/config`                  | yes       |                                    |
| `nostr/relays`                    | `/home/user/.config/n-os-tr/relays.json`  | no        | Can be NIP-65 if user prefers      |
| `nostr/blossom-servers`           | `/home/user/.config/n-os-tr/blossom.json` | no        | User's preferred Blossom endpoints |
| `nostr/follows`                   | derived from kind 3 (NIP-02) directly     | n/a       | Not stored via this mechanism      |
| `firefox/bookmarks.json`          | `/home/user/.mozilla/firefox/profile/bookmarks.json` | yes | Optional, app-specific             |
| `n-os-tr/roles`                   | `/run/nostr-id/roles.json`                | yes       | Managed by identity-agent, not user-edited |
| `motd`                            | `/etc/motd`                               | no        | "hello, $YOUR_NPUB"                |

Not every slot needs to be present. The manifest enumerates only what the user chose to save.

## 7. Privacy properties

- **Confidentiality.** All personal slots are NIP-44 ciphertext. The ciphertext is IND-CCA2 under NIP-44's construction (xchacha20-poly1305 with derived keys). Relay operators observe ciphertext.
- **Metadata exposure.** Relay operators still see: your npub, the kind (30078), the `d` tag (which is the human-readable slot name, e.g., "ssh/config"!), the `created_at`, and the event size.
  - Mitigation 1: run your own personal c-relay (NIP-06 index 2). Sync only there by default. Use remote relays as backup only.
  - Mitigation 2: optionally hash the `d` tag (`d = blake3(user_secret || slot_name)`). Debatable whether the loss of debuggability is worth the metadata win. Not v1.
- **Replay / rollback attacks.** A malicious relay could serve an old `created_at` version of a slot. Mitigation: on boot, ask multiple relays and take the newest. If relays disagree, log a warning. For high-stakes slots (like `ssh/authorized_keys`), require agreement across N>=2 relays.
- **Timing correlation.** Every n-OS-tr boot publishes a burst of events. An adversary observing relay traffic could correlate "these events came from the same boot." Mitigation: randomized delays between slot publishes; stagger manifest vs slot writes. Weak mitigation; users who need full anonymity should torify relay connections.

## 8. What this subsystem is NOT

- **Not a general filesystem-sync tool.** Only named slots are tracked. `git` repos, dev databases, photo libraries, anything large — all stay ephemeral.
- **Not a binary blob store.** Slots are meant to be small JSON/text config. For photos, app binaries, datasets, etc., use Blossom via [`ginxsom`](../includes/ginxsom/).
- **Not real-time sync.** Boot and shutdown by default. Periodic save is opt-in. Continuous multi-device sync is not a goal in v1.
- **Not a backup system.** If all your relays forget your events simultaneously, your config is gone. Run your own c-relay. Pin events you care about.
- **Not a place for secrets you wouldn't want a future-broken NIP-44 to reveal.** NIP-44 uses well-studied modern crypto, but any long-term encrypted-at-rest design ages. Do not use config slots to store, e.g., your Bitcoin seed. Slots are for *OS configuration*; secrets of last resort belong elsewhere (or nowhere).

## 9. Interaction with Phase 2 services

Today's services generate their own fresh keys via [`n-os-tr-firstboot`](../iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot). Phase 3 reworks this:

- Service keys come from identity-agent (see [`plans/identity_subsystem.md`](identity_subsystem.md#57-integration-with-current-services)).
- Service configuration comes from config-loader via slots:
  - `nostr/relays` → used by `c-relay` to decide which outbound peers to talk to.
  - `nostr/blossom-servers` → used by `ginxsom` for upstream mirroring.
  - `fips/peers` → used by `fips` to bootstrap.
- None of this fundamentally changes the services themselves; it just replaces the "where does config live" question.

## 10. Implementation plan (Phase 3b)

Follow Phase 3a (identity-agent). All steps build on the `nostr_core_lib` primitives in [§1.1](#11-crypto-foundation-nostr_core_lib):

1. **Spec the event schema.** Nail down kind, tags, manifest JSON schema. Write it as `stack/config-projection/schema.md` plus a JSON Schema file that config-loader and config-writer both validate against on read/write.

2. **Prototype `nostr-config-loader` as a one-shot CLI.** Single fully static C99 binary linked against [`libnostr_core.a`](../includes/nostr_core_lib/) and built under the shared Alpine/musl toolchain (see [`plans/tui_login.md §0.1`](tui_login.md#01-toolchain-alpine--musl-dev-built-in-docker)). Output: `nostr-config-loader_static_<arch>`, `ldd` reports "not a dynamic executable."
   - Discovers user relays (bootstrap list → NIP-65 kind 10002 via [`synchronous_query_relays_with_progress`](../includes/nostr_core_lib/nostr_core/nostr_core.h)).
   - Fetches manifest (kind 30078, `d="n-os-tr:manifest:v1"`).
   - Decrypts via [`nostr_nip44_decrypt(priv_main, pub_main, ...)`](../includes/nostr_core_lib/nostr_core/nip044.h:62) — main privkey fetched from identity-agent, kept in locked memory only for the duration of the call.
   - For each slot in the manifest: single batch REQ with multiple `#d` values; decrypt each with `nostr_nip44_decrypt`; write to `slot.path` with `slot.mode`.
   - Exits when done; leaves nothing on disk except the materialized slot files.

3. **Prototype `nostr-config-writer` as a one-shot CLI.**
   - Reads manifest from `/run/nostr-config/manifest.json` (cached by the loader at boot).
   - Detects changed files via sha256 diff against cached hashes.
   - For each changed slot: encrypts with [`nostr_nip44_encrypt`](../includes/nostr_core_lib/nostr_core/nip044.h:28), signs via identity-agent NIP-46 ([`nostr_nip46_client_sign_event`](../includes/nostr_core_lib/nostr_core/nip046.h:158)), publishes via [`synchronous_publish_event_with_progress`](../includes/nostr_core_lib/nostr_core/nostr_core.h).
   - Republishes the manifest if the slot set changed.
   - Respects a hard timeout so shutdown doesn't block on a bad relay.

4. **Wire into systemd.**
   - `nostr-config-loader.service` — `Type=oneshot`, `After=nostr-id.service network-online.target`, `Before=multi-user.target`. Materializes home before user login.
   - `nostr-config-writer.service` — `Type=oneshot`, runs on `systemctl stop multi-user.target` path (drop-in in `shutdown.target.wants/`). Saves before poweroff.
   - `nostr-config-save.timer` (opt-in) — fires every 5 minutes when enabled via manifest `periodic=true`.

5. **CLI: `nostr-config`.** Wraps the two binaries with convenience commands:
   - `nostr-config status` — report on manifest version, last save time, unsaved changes.
   - `nostr-config save [slot|--all]` — force save.
   - `nostr-config load [slot|--all]` — pull remote and overwrite local (with confirm).
   - `nostr-config add-slot --name --path --mode [--encrypted|--plain]` — edit manifest.
   - `nostr-config remove-slot <name>` — edit manifest.
   - `nostr-config diff <slot>` — show local vs remote.
   - `nostr-config list` — print the slot table.

6. **Smoke test.** Extend [`iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest`](../iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest):
   - Given a known test mnemonic and a pre-populated local c-relay seeded with canned slot events, after `nostr-config-loader` runs, verify specific files exist at specific paths with specific sha256 sums.
   - After modifying a file and running `nostr-config save`, verify the local c-relay has a new kind-30078 event whose NIP-44-decrypted content matches the file.
   - Verify the manifest event's `created_at` advances when the slot set changes.
   - Verify that `journalctl -u nostr-config-loader` and `journalctl -u nostr-config-writer` contain no `nsec1` or 64-char lowercase hex that looks like a privkey.

7. **Conflict UI.** Simple interactive chooser for `nostr-config save` when a conflict is detected (per §5.1). Non-interactive mode preserves remote and writes the local version to `/run/nostr-config/conflicts/<slot>.local`.

8. **Periodic save (opt-in).** Timer unit, rate-limited. Explicit opt-in in the manifest.

9. **Self-test on boot.** Before the user's first session, loader does a manifest round-trip against a local c-relay (if present) or drops into "offline mode" cleanly. If NIP-44 decrypt fails on a slot (e.g., wrong mnemonic), user is warned *before* anything is materialized.

## 11. Open questions

- **How many slots is "too many" for a 15-second boot?** Rough math: each slot ~1-2 KB, one REQ round-trip per slot on first boot. With 50 slots and a fast relay, ~2 seconds total. We should still cap slot count (e.g., 200) and advise users with huge configs to self-host a relay.
- **Batch fetches.** Can we issue a single REQ for all slots in the manifest and demux by `d`? Yes, NIP-01 filters allow multiple `#d` values. That's the intended optimization.
- **Storing arbitrarily-large files as slots.** Not supported (see §8). But it's tempting. The line to hold is: slots are config, Blossom is blobs.
- **Cross-device concurrent edits.** Phase-1 strategy is "last writer wins with conflict detection." More sophisticated CRDT-style merging is out of scope.
- **Encrypted manifest but unencrypted individual slots.** Is that a useful mode? Probably not — if the manifest is encrypted, we can list everyone's slots publicly just by guessing `d` tags. Recommendation: manifest and slots encrypt or don't together.
- **`d`-tag namespace collisions.** We use prefixes like `dotfiles/`, `ssh/`, `firefox/`. Should we formalize an IANA-ish registry inside the repo? Probably yes: `stack/config-projection/slot-registry.md` lists known slot names and their paths, so different n-OS-tr versions don't create divergent conventions for the same thing.
- **Schema migration.** What happens when we bump the manifest schema from v1 to v2? The `ver` tag identifies schema version; a future loader refuses to materialize a manifest with an unsupported `version` and prompts for an explicit migration.
