v0.0.1 - Reorganize ISO build tree, add docs/plans/scripts, and stage Phase 2 Slice B+C service integration artifacts
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 SSHauthorized_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:
-
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. -
The encryption target is the user's own pubkey. NIP-44 "self-encryption" uses
priv_main+pub_mainas both ends of the ECDH; the library supports this directly (both arguments innostr_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
dtag 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:
- 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.
- NIP-65 relay list (kind 10002). Once fetched, becomes the authoritative set for this user.
- 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.exampleon the boot cmdline. - 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:
- Explicit.
nostr-config save [slot-name]— user-initiated save of one slot or all. - 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.
- Periodic (opt-in). For users who want continuous sync, an opt-in
periodic=trueflag 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 latestcreated_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 saveinteractive 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
dtag (which is the human-readable slot name, e.g., "ssh/config"!), thecreated_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
dtag (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_atversion of a slot. Mitigation: on boot, ask multiple relays and take the newest. If relays disagree, log a warning. For high-stakes slots (likessh/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.
gitrepos, 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 byc-relayto decide which outbound peers to talk to.nostr/blossom-servers→ used byginxsomfor upstream mirroring.fips/peers→ used byfipsto 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:
-
Spec the event schema. Nail down kind, tags, manifest JSON schema. Write it as
stack/config-projection/schema.mdplus a JSON Schema file that config-loader and config-writer both validate against on read/write. -
Prototype
nostr-config-loaderas a one-shot CLI. Single fully static C99 binary linked againstlibnostr_core.aand built under the shared Alpine/musl toolchain (seeplans/tui_login.md §0.1). Output:nostr-config-loader_static_<arch>,lddreports "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
#dvalues; decrypt each withnostr_nip44_decrypt; write toslot.pathwithslot.mode. - Exits when done; leaves nothing on disk except the materialized slot files.
- Discovers user relays (bootstrap list → NIP-65 kind 10002 via
-
Prototype
nostr-config-writeras 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 viasynchronous_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.
- Reads manifest from
-
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 onsystemctl stop multi-user.targetpath (drop-in inshutdown.target.wants/). Saves before poweroff.nostr-config-save.timer(opt-in) — fires every 5 minutes when enabled via manifestperiodic=true.
-
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.
-
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-loaderruns, 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_atadvances when the slot set changes. - Verify that
journalctl -u nostr-config-loaderandjournalctl -u nostr-config-writercontain nonsec1or 64-char lowercase hex that looks like a privkey.
- Given a known test mnemonic and a pre-populated local c-relay seeded with canned slot events, after
-
Conflict UI. Simple interactive chooser for
nostr-config savewhen a conflict is detected (per §5.1). Non-interactive mode preserves remote and writes the local version to/run/nostr-config/conflicts/<slot>.local. -
Periodic save (opt-in). Timer unit, rate-limited. Explicit opt-in in the manifest.
-
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#dvalues. 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
dtags. Recommendation: manifest and slots encrypt or don't together. d-tag namespace collisions. We use prefixes likedotfiles/,ssh/,firefox/. Should we formalize an IANA-ish registry inside the repo? Probably yes:stack/config-projection/slot-registry.mdlists 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
vertag identifies schema version; a future loader refuses to materialize a manifest with an unsupportedversionand prompts for an explicit migration.