[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
This commit is contained in:
Laan Tungir
2026-04-29 13:09:28 -04:00
parent dc7af63e6b
commit 6e2164abe5
151 changed files with 8865 additions and 1222 deletions

View File

@@ -0,0 +1,367 @@
# 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.