Files
n_os_tr/plans/nostr_config_projection.md
Laan Tungir 6e2164abe5 [WARNING] No existing semantic tags found. Starting from v0.0.0
v0.0.1 - Reorganize ISO build tree, add docs/plans/scripts, and stage Phase 2 Slice B+C service integration artifacts
2026-04-29 13:09:28 -04:00

25 KiB

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 (must run first to load the main key), plans/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.

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, the config-loader and config-writer are built on top of includes/nostr_core_lib. Concretely:

Capability nostr_core_lib symbol NIP
Create + sign replaceable slot events nostr_create_and_sign_event NIP-01
NIP-44 self-encrypt slot plaintext nostr_nip44_encrypt(priv, pub_self, plaintext, out, ...) NIP-44
NIP-44 self-decrypt fetched ciphertext nostr_nip44_decrypt(priv, pub_self, ciphertext, out, ...) NIP-44
Query relays for slot events (sync) synchronous_query_relays_with_progress NIP-01
Publish slot events synchronous_publish_event_with_progress NIP-01
Relay pool with reconnection (streaming) nostr_relay_pool_create + nostr_relay_pool_subscribe NIP-01
NIP-42 auth (personal private relay) nostr_relay_pool_set_auth NIP-42
NIP-11 relay info (feature check) nostr_nip11_fetch_relay_info NIP-11
Relay-level event signing proxied via agent NIP-46 path through nostr_nip46_client_sign_event — 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.

  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). 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:

{
  "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 ("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.
Relay list 10002 NIP-65 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

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

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:

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.
  • 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. Phase 3 reworks this:

  • Service keys come from identity-agent (see plans/identity_subsystem.md).
  • 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:

  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 and built under the shared Alpine/musl toolchain (see plans/tui_login.md §0.1). 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).
    • Fetches manifest (kind 30078, d="n-os-tr:manifest:v1").
    • Decrypts via nostr_nip44_decrypt(priv_main, pub_main, ...) — 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, signs via identity-agent NIP-46 (nostr_nip46_client_sign_event), publishes via synchronous_publish_event_with_progress.
    • 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.serviceType=oneshot, After=nostr-id.service network-online.target, Before=multi-user.target. Materializes home before user login.
    • nostr-config-writer.serviceType=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:

    • 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.