[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

361
plans/identity_subsystem.md Normal file
View File

@@ -0,0 +1,361 @@
# n-OS-tr Identity Subsystem
> **Status:** Design. This document describes how n-OS-tr turns a single 12-word BIP-39 mnemonic into every key the OS and its applications need, and how the **identity agent** exposes those keys to the rest of the system.
>
> Companion docs: [`plans/threat_model.md`](threat_model.md) for the privacy contract this must satisfy, and [`plans/nostr_config_projection.md`](nostr_config_projection.md) for how the derived main key is used to fetch configuration.
## 1. Concept
n-OS-tr has one piece of root secret material: **a 12-word BIP-39 mnemonic**. Every key the system needs — your public identity, your local relay admin key, your mesh node identity, your blob-store key — is deterministically derived from that mnemonic via [NIP-06](https://github.com/nostr-protocol/nips/blob/master/06.md).
The user enters (or generates) the mnemonic at boot. A single long-running process — the **identity-agent** — holds the mnemonic in locked memory for the session, derives keys on demand, and signs events on behalf of apps via [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md). No app and no file ever sees the mnemonic or a raw private key.
On shutdown, the identity-agent zeroes the memory, and the ephemeral rootfs disappears. The mnemonic only ever existed in RAM.
## 1.1 Crypto foundation: `nostr_core_lib`
The identity-agent does **not** reimplement any Nostr cryptography. It is built on top of [`includes/nostr_core_lib`](../includes/nostr_core_lib/), a C library already vendored into this repository that provides:
| Capability needed by identity-agent | `nostr_core_lib` symbol / header | NIP |
|--------------------------------------------|----------------------------------------------------------------------------------------|---------|
| Keypair generation (entropy source) | [`nostr_generate_keypair`](../includes/nostr_core_lib/nostr_core/nip006.h:20) | n/a |
| BIP-39 mnemonic generation + NIP-06 derivation | [`nostr_generate_mnemonic_and_keys`](../includes/nostr_core_lib/nostr_core/nip006.h:21) | NIP-06 |
| NIP-06 derivation from existing mnemonic | [`nostr_derive_keys_from_mnemonic`](../includes/nostr_core_lib/nostr_core/nip006.h:24) | NIP-06 |
| Input type detection (hex vs nsec vs mnemonic) | [`nostr_detect_input_type`](../includes/nostr_core_lib/nostr_core/nip006.h:26) | n/a |
| bech32 encoding/decoding (nsec/npub) | [`nostr_key_to_bech32`](../includes/nostr_core_lib/nostr_core/nostr_core.h), [`nostr_decode_nsec`](../includes/nostr_core_lib/nostr_core/nip006.h:27) | NIP-19 |
| Event creation + signing | [`nostr_create_and_sign_event`](../includes/nostr_core_lib/nostr_core/nostr_core.h) | NIP-01 |
| NIP-44 encrypt / decrypt (for self-encrypted config) | [`nostr_nip44_encrypt`](../includes/nostr_core_lib/nostr_core/nip044.h:28), [`nostr_nip44_decrypt`](../includes/nostr_core_lib/nostr_core/nip044.h:62) | NIP-44 |
| NIP-46 signer session (serve requests) | [`nostr_nip46_signer_session_init`](../includes/nostr_core_lib/nostr_core/nip046.h:163), [`nostr_nip46_signer_handle_request`](../includes/nostr_core_lib/nostr_core/nip046.h:169) | NIP-46 |
| NIP-46 bunker URL publishing (external signer inbound path) | [`nostr_nip46_signer_create_bunker_url`](../includes/nostr_core_lib/nostr_core/nip046.h:172) | NIP-46 |
| NIP-46 client session (consume remote bunker) | [`nostr_nip46_client_session_init`](../includes/nostr_core_lib/nostr_core/nip046.h:145), [`nostr_nip46_client_sign_event`](../includes/nostr_core_lib/nostr_core/nip046.h:158) | NIP-46 |
| Relay pool + NIP-42 auth for signer relay | [`nostr_relay_pool_create`](../includes/nostr_core_lib/nostr_core/nostr_core.h), [`nostr_relay_pool_set_auth`](../includes/nostr_core_lib/nostr_core/nostr_core.h) | NIP-42 |
| Library-wide log callback (no on-disk logs) | [`nostr_set_log_callback`](../includes/nostr_core_lib/nostr_core/nostr_log.h) | n/a |
Consequence: the identity-agent is a thin C daemon that wires these primitives to systemd, to a local Unix-domain transport, and to the privacy-hardener's memory and shutdown constraints. The crypto has been written, reviewed, and unit-tested in [`includes/nostr_core_lib/tests`](../includes/nostr_core_lib/tests/). We do not touch it.
The Rust- and Python-binding libraries mentioned in §5.2 are thin FFI wrappers over the same library.
## 2. Derivation scheme
n-OS-tr uses the exact NIP-06 derivation exposed by `nostr_core_lib` via [`nostr_derive_keys_from_mnemonic(mnemonic, account, priv, pub)`](../includes/nostr_core_lib/nostr_core/nip006.h:24), where `account` is the role index. This matches both the nostr-tools JavaScript reference implementation in [`/home/user/lt/client/www/tools.html`](../../client/www/tools.html) (line 944) and the standard [NIP-06 specification](https://github.com/nostr-protocol/nips/blob/master/06.md).
The derivation path is:
```
m / 44' / 1237' / index' / 0 / 0
```
where:
- `44'` — BIP-44 purpose
- `1237'` — SLIP-0044 coin type for Nostr
- `index'` — the role index (see table below)
- `0 / 0` — change and address, always zero
Given a 12-word mnemonic, each integer `index` produces a distinct 32-byte secp256k1 private key, from which Nostr npub/nsec are encoded.
### 2.1 The role table (schedule)
Indices are assigned to fixed roles. Index 0 is stable forever; higher indices are allocated as roles are needed. New apps request a new index at *install time*, not at runtime, so the schedule is discoverable and forward-compatible.
| Index | Role name | Used by | Stability |
|-------|----------------|-------------------------------------------------------------------------|------------|
| `0` | `main` | User's public Nostr identity. Profile, posts, social graph, config sig. | Immutable. |
| `1` | `throwaway` | Test/experimental account. Safe to publish noise from. | Stable. |
| `2` | `c-relay-admin`| Admin key for the user's local [`c-relay`](../includes/c-relay/). | Stable. |
| `3` | `ginxsom` | Server key for the user's local [`ginxsom`](../includes/ginxsom/). | Stable. |
| `4` | `fips` | Mesh node identity for [`fips`](../includes/fips/). Yields fips IPv6. | Stable. |
| `5` | reserved | Future: personal Nostr DM relay / gossip cache. | Reserved. |
| `6` | reserved | Future: signer key for system audit events. | Reserved. |
| `7+` | unallocated | Assigned by future apps via the registration process in §2.3. | Dynamic. |
> **Index 0 stability:** Once a user has published anything under their main key, index 0 can never be renumbered. That's why `main` gets index 0 forever. All other role assignments can in principle be renumbered before first use, but once shipped in a released n-OS-tr image they must not be changed without a major version bump.
### 2.2 Deriving a public identifier from a key
Once a private key is derived at an index, standard Nostr encodings apply. In `nostr_core_lib` these are all available directly:
| Want | `nostr_core_lib` call |
|---------------------------|----------------------------------------------------------------------------------------|
| `npub` (bech32 pubkey) | `nostr_key_to_bech32(public_key, "npub", out)` |
| `nsec` (bech32 privkey) | `nostr_key_to_bech32(private_key, "nsec", out)` |
| `hex` pubkey | produced directly by [`nostr_derive_keys_from_mnemonic`](../includes/nostr_core_lib/nostr_core/nip006.h:24) as 32 bytes |
| `hex` privkey | produced directly by [`nostr_derive_keys_from_mnemonic`](../includes/nostr_core_lib/nostr_core/nip006.h:24) as 32 bytes |
| FIPS mesh IPv6 | `fd` + first 15 bytes of `sha256(pubkey_bytes)` — implemented locally in identity-agent; the definition matches [`tools.html`](../../client/www/tools.html:750) `pubkeyHexToFipsIpv6()` |
No identity-agent code ever touches raw secp256k1 arithmetic. Everything goes through the library.
### 2.3 Role registration protocol
When a new app wants its own Nostr identity, it does not pick an index itself. It calls the identity-agent:
```
identity-agent register --service=my-app --justification="needs a Nostr key for X"
```
The agent:
1. Looks up the currently allocated schedule (see §4).
2. Allocates the next unused index.
3. Returns the npub (never the nsec or the mnemonic).
4. Persists the name → index mapping **as a signed Nostr config event** under the main key (kind described in [`plans/nostr_config_projection.md`](nostr_config_projection.md)), so the mapping is itself part of the user's portable config.
Because the mapping is on Nostr, it travels with the user, not with the machine. Booting on a different n-OS-tr machine reconstructs the same role table.
## 3. Boot-time UX
When n-OS-tr boots, before any network or user session, it runs the identity-agent as a systemd service, which presents the first-screen flow on whatever console the device has (TTY, framebuffer, LCD HAT).
### 3.1 Three flows
```mermaid
flowchart TB
A[Boot] --> Q{Identity source?}
Q -->|Enter mnemonic| E[Enter 12 words]
Q -->|Generate new| G[Generate 12 words]
Q -->|Import file| F[Import from /media/*/mnemonic.txt]
Q -->|Connect bunker| B[NIP-46 bunker URL]
E --> V[BIP-39 checksum validate]
G --> D[Display once, require confirmation]
F --> W[Read once, wipe source file]
V --> L[Load mnemonic into locked memory]
D --> L
W --> L
B --> RS[Use remote signer,<br/>no local mnemonic]
L --> R[Ready: expose keys + signer]
RS --> R
```
### 3.2 Flow details
**Flow 1 — "I have a mnemonic, let me type it."**
- 12 words entered via keyboard (amd64 TTY) or Waveshare-LCD button sequence (Pi Zero).
- Each word is validated against the BIP-39 English wordlist as typed. Typos are rejected before the full phrase is submitted.
- Checksum validated on submit. If invalid, clear the buffer and prompt again.
- On success, loaded into `mlock()`-ed memory.
**Flow 2 — "Generate a new one for me."**
- Entropy sourced from `getrandom(2)` + hardware RNG where available.
- 12 words are displayed *once*, full-screen, with an emphatic "write these down now" message.
- User confirms by retyping (or on Pi: scrolling and clicking through) 3 random word positions (e.g., word 4, word 9, word 11) to ensure they actually recorded the phrase.
- Only after confirmation does boot proceed. If the user reboots without confirming, they have generated nothing of value — no identity has been "committed to" yet.
**Flow 3 — "Import from removable media."**
- At boot, identity-agent scans `/media/*/mnemonic.txt` for a single candidate file.
- Reads it, validates BIP-39 checksum.
- **Wipes the source file** (`shred -u` on ext4 or equivalent on FAT) so that unplugging the stick does not carry the plaintext mnemonic off the machine. User is informed that the stick no longer contains the mnemonic and they must have a backup.
- Loaded into locked memory.
**Flow 4 — "Use an external signer."**
- User provides a [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) bunker URL (hardware wallet, phone app, remote machine).
- identity-agent does not hold any mnemonic. All signing is forwarded to the external bunker.
- Key derivation for non-signing use (e.g., deriving a fips IPv6) proxies through the bunker's `nip04`/`nip44` primitives where possible. Roles that *require* a raw private key locally (e.g., running a local c-relay that stores its own nsec) are disabled or limited in this mode.
### 3.3 Per-flavor UX
| Image flavor | Mnemonic entry UX |
|------------------------------------|----------------------------------------------------------------------|
| **amd64 live ISO (text console)** | Full-screen TUI over `/dev/tty1`, keyboard entry, word auto-complete |
| **amd64 live ISO with GUI** | Boot-time TUI same as above; the GUI is launched *after* identity is loaded, never before |
| **Raspberry Pi Zero 2 W** | Waveshare 1.3" LCD HAT with button navigation: wheel to scroll wordlist, press to select; joystick for "next word"; confirmation via button combo. See [`plans/keyboard_gpio_matrix.md`](../../raspberry_pi_zero_nostr/plans/keyboard_gpio_matrix.md). |
| **Headless (SSH) setup** | Not supported by default. Mnemonic entry MUST be on the physical console to avoid keystroke exfil over the network. A future opt-in "paste via serial" path is possible but out of scope for now. |
## 4. The identity-agent daemon
A single long-running process is the authority for all identity operations. It is the only process that ever sees the mnemonic.
### 4.1 Responsibilities
1. Present the boot-time mnemonic UX (or delegate to a TUI binary it supervises).
2. Hold the mnemonic + derived keys in `mlock()`-ed, non-swappable memory.
3. Derive keys on demand by index.
4. Act as a [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) signer for apps (local, over Unix socket; optionally over fips mesh for mesh-local apps).
5. Track the role→index schedule; register new roles (§2.3).
6. On shutdown or SIGTERM, zero all key material before exit.
### 4.2 Non-responsibilities
- It does **not** publish events itself. It signs, apps publish.
- It does **not** manage relays. That's app-level.
- It does **not** touch config projection. That's the config-loader/writer (see [`plans/nostr_config_projection.md`](nostr_config_projection.md)). It only *signs* events on behalf of the config-writer.
- It does **not** store the mnemonic anywhere but RAM. No `~/.nostr`, no `/etc/n-os-tr/identity`, nothing.
### 4.3 Processes and isolation
```mermaid
flowchart LR
User[User at console] -- 12 words --> UX[identity-agent-ui<br/>TUI or LCD]
UX -- passes via pipe --> Agent[identity-agent<br/>daemon]
App1[c-relay] -- NIP-46 --> Signer[local signer socket<br/>/run/nostr-id/signer.sock]
App2[ginxsom] -- NIP-46 --> Signer
App3[fips] -- NIP-46 --> Signer
App4[config-loader] -- NIP-46 --> Signer
Signer --- Agent
subgraph mem[mlock-ed, non-swap memory]
Agent
end
```
The agent runs as its own user (e.g., `nostr-id`), with:
- `MemoryDenyWriteExecute=yes`
- `LockPersonality=yes`
- `NoNewPrivileges=yes`
- `ProtectSystem=strict`
- `ProtectHome=yes`
- `PrivateTmp=yes`
- `RestrictAddressFamilies=AF_UNIX AF_NETLINK`
- `SystemCallArchitectures=native`
- `SystemCallFilter=@system-service`
- `CapabilityBoundingSet=CAP_IPC_LOCK` (for `mlock`), nothing else.
The signer Unix socket is accessible only to a well-defined group (`nostr-signer`). Apps that want to sign add their service user to that group.
### 4.4 Deriving without revealing
Most apps do NOT need the raw private key. They need signed events. For them, the NIP-46 flow is:
```mermaid
sequenceDiagram
autonumber
participant App as c-relay
participant Sig as signer socket
participant Ag as identity-agent
App->>Sig: connect(/run/nostr-id/signer.sock)
App->>Sig: NIP-46 "describe" (role=c-relay-admin)
Sig->>Ag: resolve role, returns npub
Ag-->>Sig: npub for role
Sig-->>App: npub for role
App->>Sig: NIP-46 "sign_event" (kind=N, tags=..., content=...)
Sig->>Ag: derive sk for role, sign, zero sk
Ag-->>Sig: signed event JSON
Sig-->>App: signed event JSON
```
A small number of apps — those that can't use a remote signer (e.g., legacy tools) — can request the *raw nsec*. That call is gated:
- Only callable by specific whitelisted service users.
- Only for specific role indices.
- Rejected entirely when the user is operating in "external bunker" mode (§3.2 Flow 4).
## 5. Public interface
### 5.1 CLI: `nostr-id`
| Command | Description |
|----------------------------------|--------------------------------------------------------------------------|
| `nostr-id status` | Show whether the agent is running and how it was bootstrapped (entered / generated / imported / bunker). Never prints keys. |
| `nostr-id show npub <role>` | Print the npub for a role name. Uses the role→index table from config. |
| `nostr-id show hex <role>` | Print the hex pubkey for a role. |
| `nostr-id show fips-ipv6 <role>` | Print the FIPS mesh IPv6 for a role (usually `main`). |
| `nostr-id list` | Print the role table: name, index, npub. |
| `nostr-id register <service>` | Allocate a new role index for a service. Interactive confirmation. |
| `nostr-id derive <role>` | Print the raw nsec for a role. Gated (see §4.4); prompts for confirmation.|
| `nostr-id sign <file>` | Sign an unsigned event JSON from stdin/file for a role, print signed. |
| `nostr-id lock` | Zero the mnemonic from RAM *without* shutting down (panic button). |
| `nostr-id bunker-connect <url>` | Connect to an external NIP-46 bunker instead of a local mnemonic. |
The agent also exposes a DBus interface on the system bus under `org.n_os_tr.IdentityAgent` with matching methods, for GUI clients and other language bindings.
### 5.2 Library: `libnostr-id` (planned)
A small C library + Rust crate + Python binding that wraps the signer socket. Apps depend on this instead of implementing NIP-46 themselves.
### 5.3 Integration with current services
After Phase 3 lands, the services that exist today ([`fips`](../includes/fips/), [`c-relay`](../includes/c-relay/), [`ginxsom`](../includes/ginxsom/)) will drop their current "generate a fresh key on first boot" behavior and instead:
- On startup, call `nostr-id show npub <role>` + `nostr-id sign ...` (or `nostr-id derive <role>` for services that require raw keys).
- Their systemd units add `After=nostr-id.service` and `Requires=nostr-id.service`.
- The [`n-os-tr-firstboot`](../iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot) script becomes a much thinner shim: identity generation moves out of it entirely, into the agent.
## 6. Memory safety mechanics
Enforcing [F1F7 of the threat model](threat_model.md#41-the-mnemonic-and-derived-keys) requires deliberate engineering:
- **`mlock(2)`.** The agent locks pages containing mnemonic and derived keys so the kernel can't swap them. Requires `CAP_IPC_LOCK`.
- **Zeroize-on-drop.** Key material is wrapped in a type (Rust `zeroize::Zeroizing`, or C `volatile` + explicit_bzero) that guarantees the bytes are cleared when the owner is dropped.
- **No `core` dumps.** `prctl(PR_SET_DUMPABLE, 0)` on the agent process.
- **No `ptrace`.** `prctl(PR_SET_DUMPABLE, 0)` blocks `ptrace` too; additionally the agent's systemd unit sets `NoNewPrivileges=yes` and restricts `SYS_PTRACE` via seccomp.
- **No `/proc/pid/mem`.** Disabled by `PR_SET_DUMPABLE=0`.
- **No swap, ever.** Swap is disabled at the kernel level at image build time (see [F2](threat_model.md#41-the-mnemonic-and-derived-keys)).
- **Shutdown handler.** On SIGTERM the agent explicitly zeros every buffer, then exits. If the OS is going down, it does not wait for journald to flush.
## 7. Failure modes and corner cases
### 7.1 Invalid mnemonic entered
- UX rejects the phrase, clears the input buffer, prompts again.
- After N=3 failed attempts on the Pi LCD form factor, optionally offer to generate a new one (since the user clearly does not have their phrase).
### 7.2 User wants to switch identities mid-session
- `nostr-id lock` panic button zeros the current mnemonic.
- The session is now unusable for any signed action; user is prompted to re-enter a mnemonic or shut down.
- All apps connected to the signer socket receive a "signer unavailable" error; they are expected to fail closed.
### 7.3 Agent crashes
- The systemd unit restarts it, but the restart has no mnemonic loaded.
- All apps see "signer unavailable" until the user re-enters the mnemonic.
- The session is effectively over. We do not attempt to persist "oh just re-load from somewhere" — the mnemonic is user-only input.
### 7.4 Clock skew at first boot
- Nostr signatures don't depend on the wall clock, but relay event timestamps do.
- Identity-agent does not care about clock. Event-emitting apps do. Addressed separately by network-bootstrap via NTP.
### 7.5 User generates a new identity but never writes it down
- This is a user problem. The UX should make non-confirmation *very* obvious (the 3-random-word-position confirmation step in §3.2 Flow 2).
### 7.6 External NIP-46 bunker goes offline mid-session
- Signing fails. Apps see signer errors. If the bunker comes back, resume.
- If the bunker is permanently gone, the session is effectively read-only and the user must shut down.
## 8. Implementation plan (Phase 3a)
Each step is independently testable and each leans on an existing `nostr_core_lib` primitive rather than reimplementing crypto.
1. **Specify schedule.** Lock the index table in §2.1 behind a version number. Ship it inside a shared constants file (`stack/identity/schedule.toml` or equivalent) so all apps reference the same source of truth.
2. **Build `libnostr_core.a` under Alpine/musl.** Every identity-related binary is built following the same Alpine+musl discipline [`nostr_core_lib`](../includes/nostr_core_lib/), [`includes/ginxsom`](../includes/ginxsom/), and [`includes/c-relay`](../includes/c-relay/) already use — see [`plans/tui_login.md §0.1`](tui_login.md#01-toolchain-alpine--musl-dev-built-in-docker) for the full toolchain recipe. We reuse the shared `stack/build-common/Dockerfile.alpine-musl` landed in TUI step 1. No glibc, no `.deb`; the library is linked statically into each consumer binary produced from this builder.
3. **Prototype CLI `nostr-id`.** Single fully static C99 binary (`nostr-id_static_x86_64`) produced by the Alpine/musl builder, linking [`libnostr_core.a`](../includes/nostr_core_lib/). Reads mnemonic from stdin, calls [`nostr_derive_keys_from_mnemonic`](../includes/nostr_core_lib/nostr_core/nip006.h:24) for the requested role index, prints the npub via [`nostr_key_to_bech32`](../includes/nostr_core_lib/nostr_core/nostr_core.h). No daemon yet; no IPC; pure unit-testable against [`includes/nostr_core_lib/tests`](../includes/nostr_core_lib/tests/). `ldd` on the resulting binary must report "not a dynamic executable."
4. **Wrap as daemon.** Turn the CLI into a long-running agent:
- Holds mnemonic + all derived keys in `mlock()`-ed memory.
- Exposes a Unix-domain control socket at `/run/nostr-id/control.sock` for CLI commands.
- Runs the NIP-46 signer loop via [`nostr_nip46_signer_session_init`](../includes/nostr_core_lib/nostr_core/nip046.h:163) + [`nostr_nip46_signer_handle_request`](../includes/nostr_core_lib/nostr_core/nip046.h:169), receiving requests over a local Unix socket (or over a loopback relay for mesh-capable apps).
- Wires [`nostr_set_log_callback`](../includes/nostr_core_lib/nostr_core/nostr_log.h) to route library logs to journald (no on-disk log files, per [F10F11 in threat_model.md](threat_model.md#42-on-disk-state)).
- Handles SIGTERM by zeroing keys and exiting cleanly.
5. **Write TUI front end.** Curses-style boot-time entry for amd64 TTY. Accepts word-by-word mnemonic input, validates each word against the BIP-39 list, validates the checksum via [`nostr_derive_keys_from_mnemonic`](../includes/nostr_core_lib/nostr_core/nip006.h:24) (return code surfaces an invalid mnemonic). Generation flow uses [`nostr_generate_mnemonic_and_keys`](../includes/nostr_core_lib/nostr_core/nip006.h:21). External-signer flow uses [`nostr_nip46_client_session_init`](../includes/nostr_core_lib/nostr_core/nip046.h:145) with the user-supplied bunker URL.
6. **Retrofit Phase 2 services.**
- c-relay: switch [`iso/config/includes.chroot/etc/systemd/system/c-relay.service`](../iso/config/includes.chroot/etc/systemd/system/c-relay.service) to pull admin pubkey via `nostr-id show npub c-relay-admin` and admin privkey via `nostr-id derive c-relay-admin` (raw-key path, gated).
- ginxsom: same pattern, role `ginxsom` (NIP-06 index 3).
- fips: same pattern, role `fips` (NIP-06 index 4). Also receives its FIPS IPv6 from the agent.
- [`iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot`](../iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot) becomes much thinner — identity generation moves to the agent entirely.
7. **Smoke tests.** Extend [`iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest`](../iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest) to verify:
- Agent is running and the control socket is present.
- Given a fixed test mnemonic (`test vector v1`), the 5 role npubs match hard-coded expected values — this guards against accidental changes to derivation semantics and is easy to cross-check against `nostr_core_lib`'s own tests.
- No `nsec1`, no 64-char lowercase hex that looks like a privkey, appears in `journalctl` output.
- NIP-46 ping round-trips over the signer socket via [`nostr_nip46_client_ping`](../includes/nostr_core_lib/nostr_core/nip046.h:156).
8. **LCD front end for Pi.** Comes later, as part of the Pi-Zero-2-W image flavor. Consumes the same control socket as the amd64 TUI.
9. **Runtime self-test.** On boot, identity-agent runs a quick self-test (derive a known-vector mnemonic → compare output to embedded expected npub) before declaring itself ready. If the library is miscompiled or `libsecp256k1` is missing, this fails fast with a visible error instead of silently producing wrong keys.
## 9. Open questions
- **Mnemonic strength.** 12 words = 128 bits of entropy. Is this enough? BIP-39 also supports 15/18/21/24-word phrases for 160/192/224/256 bits. n-OS-tr defaults to 12 for UX; 24 could be opt-in. Decision: default 12, allow longer phrases without breaking derivation.
- **Per-relay ephemeral keys.** Do we want a fresh throwaway key *per relay connection* for metadata hygiene? That's beyond NIP-06 indexing; probably an app-level concern (client rotates its own subkeys off index 1 via BIP-32 sub-derivation). Revisit in a later doc.
- **Hardware wallets.** `nostr-id bunker-connect` is a generic path for external signers, but hardware-wallet-specific UX (pair, unlock, confirm-on-device) is richer. Defer to Phase 4.
- **Identity handover.** Is there a safe way to pass a live session from one n-OS-tr machine to another without the user re-typing the mnemonic? E.g., QR-code export from one machine to another over a camera. Non-goal for v1; note for later.
- **Amnesia mode.** Some users may want to boot *without* a mnemonic at all (guest session, hostile environment). That means generating a key that exists only for this boot and is never written anywhere. Flow is: `nostr-id amnesia` — create ephemeral main key, skip config projection, live session only. Worth shipping in v1.

382
plans/iso_architecture.md Normal file
View File

@@ -0,0 +1,382 @@
# n-OS-tr ISO Architecture — Design Notes
This document captures the key design decisions for the n-OS-tr Debian Live
ISO build. It is the companion to the phased todo list and is intended to
survive individual work sessions.
## 1. Big-picture goal
A single bootable/installable Debian Live ISO that, the moment it finishes
booting, is running:
- [`fips`](../includes/fips) — Rust mesh-routing daemon, npub-addressed IPv6 overlay
- [`c-relay`](../includes/c-relay) — C Nostr relay with event-based config and embedded admin
- [`ginxsom`](../includes/ginxsom) — C Blossom (blob) server as FastCGI behind nginx
- `nginx` — TLS front door, static blob serving, reverse proxy
No Docker. No `curl | bash` provisioning. All three apps run as native
systemd services directly on the host.
## 2. Repo layout (target)
```
/ project root
├── live-build/ upstream live-build vendored
├── lb-wrapper.sh thin wrapper for ./live-build/frontend/lb
├── iso/ the live-build config (promoted from tutorial2/)
│ ├── auto/
│ │ ├── config lb config invocation with our flags
│ │ ├── build optional
│ │ └── clean optional
│ ├── config/
│ │ ├── package-lists/
│ │ │ ├── base.list.chroot
│ │ │ ├── fips-deps.list.chroot
│ │ │ ├── tor.list.chroot (optional)
│ │ │ ├── gui.list.chroot (optional)
│ │ │ └── installer.list.chroot (optional)
│ │ ├── hooks/
│ │ │ └── live/
│ │ │ ├── 0010-fetch-artifacts.hook.chroot
│ │ │ └── 0020-enable-services.hook.chroot
│ │ ├── includes.chroot/
│ │ │ ├── etc/systemd/system/*.service
│ │ │ ├── etc/nginx/sites-available/n-os-tr.conf
│ │ │ ├── etc/nginx/fastcgi_params
│ │ │ ├── etc/nginx/mime.types
│ │ │ ├── usr/local/sbin/n-os-tr-firstboot
│ │ │ ├── var/www/html/index.html
│ │ │ └── etc/issue
│ │ └── SHA256SUMS pinned artifact digests
│ └── local-artifacts/ optional offline override (gitignored)
├── includes/ the three component projects
│ ├── c-relay/
│ ├── ginxsom/
│ └── fips/
├── plans/ this doc and future design notes
└── README.md
```
Legacy scripts ([`root.sh`](../root.sh), [`nginx.sh`](../nginx.sh),
[`strfry.sh`](../strfry.sh), [`deploy.sh`](../deploy.sh),
[`test.sh`](../test.sh), [`docker.sh`](../docker.sh),
[`n-os-tr.sh`](../n-os-tr.sh)) are obsolete for the ISO build. They can
move to `scratch/` for historical reference or be deleted.
## 3. Runtime architecture on the booted ISO
```mermaid
flowchart TB
subgraph Ext[External]
U1([Internet peer])
U2([LAN host])
U3([Tor client])
end
subgraph Host[n-OS-tr booted ISO]
direction TB
Ng[nginx<br/>:80 :443]
C[c-relay<br/>127.0.0.1:8888]
G[ginxsom FastCGI<br/>/tmp/ginxsom-fcgi.sock]
F[fips daemon<br/>fips0 TUN]
Fd[fips-dns<br/>:5354]
Blobs[/var/www/blobs/]
RelayDb[/var/lib/c-relay/]
GinxDb[/var/lib/ginxsom/]
FbUnit[n-os-tr-firstboot<br/>oneshot]
FbUnit -.generates keys.-> KeyStore[/var/lib/n-os-tr/]
KeyStore -.EnvironmentFile.-> G
KeyStore -.identity.-> F
Ng -->|wss /relay| C
Ng -->|GET /sha256| Blobs
Ng -->|FastCGI PUT DELETE LIST HEAD| G
G --> Blobs
G --> GinxDb
C --> RelayDb
end
U1 --> Ng
U2 --> Ng
U3 -.onion.-> Ng
U1 -.npub IPv6.-> F
```
Notes:
- `c-relay` generates its own admin keypair on first start and prints the
nsec to the journal once. This is c-relay's native behavior and we should
not override it.
- `ginxsom` needs `--server-privkey` from **somewhere**. Today
[`ginxsom.service`](../includes/ginxsom/ginxsom.service:16) hardcodes one.
We replace that with an `EnvironmentFile` populated by the first-boot unit.
- `fips` can run ephemeral by default; persistent mode drops a `fips.key`
next to [`fips.yaml`](../includes/fips/packaging/common/fips.yaml:1).
## 4. Boot sequence
```mermaid
sequenceDiagram
participant LB as live-boot / initramfs
participant Sd as systemd PID 1
participant Fb as n-os-tr-firstboot.service
participant Fp as fips.service
participant Cr as c-relay.service
participant Gx as ginxsom.service
participant Ng as nginx.service
LB->>Sd: hand off
Sd->>Fb: start oneshot (Before= the app units)
alt First boot
Fb->>Fb: mkdir /var/lib/n-os-tr<br/>generate ginxsom key<br/>generate fips key<br/>write EnvironmentFile<br/>touch .provisioned
else Already provisioned
Fb-->>Sd: exit 0 (ConditionPathExists trip)
end
Fb-->>Sd: done
par
Sd->>Fp: start (reads /etc/fips/fips.yaml)
and
Sd->>Cr: start (self-provisions admin key to /var/lib/c-relay/)
and
Sd->>Gx: start (spawn-fcgi, reads env file for privkey)
and
Sd->>Ng: start (serves /var/www/blobs, proxies to c-relay and ginxsom)
end
```
## 5. Phase 0.A — Gitea artifact fetch
### Problem
Three binaries/packages are built in Gitea and must land inside the chroot
during `lb build`. We will not commit binaries to this repo.
### Strategy
A chroot hook [`0010-fetch-artifacts.hook.chroot`](../iso/config/hooks/live/0010-fetch-artifacts.hook.chroot)
that:
1. Checks for [`iso/local-artifacts/`](../iso/local-artifacts) first (offline
dev builds). If present, copy from there and skip the network path.
2. Otherwise `curl`s each artifact from Gitea release URLs. Auth via
`GITEA_TOKEN` environment variable passed through to the hook via
live-build's `--bootstrap-options` or a `.env`-style sourced file in
`auto/config`.
3. Verifies each download against [`iso/SHA256SUMS`](../iso/SHA256SUMS).
Any mismatch fails the build.
4. Installs:
- `fips_*.deb``dpkg -i` (pulls in systemd units, `fipsctl`, `fipstop`)
- `c_relay_x86``/opt/c-relay/c_relay_x86`, chmod 0755, owned by
system user `c-relay`
- `ginxsom-fcgi_static_x86_64``/usr/local/bin/ginxsom/ginxsom-fcgi`
5. Removes the download staging directory and runs `apt-get clean`.
### Open questions (in todos)
- Are the Gitea repos/releases public or do they need a token?
- How do we pass the token through to `lb build` (it runs as root)?
- Do we build `fips` from source via `cargo-deb` inside the chroot as
a fallback, or only consume a prebuilt `.deb`?
## 6. Phase 2.B — nginx + TLS template
### Listening surface
| Path | Method | Handler | Rationale |
|---|---|---|---|
| `/` | GET | static index.html from `/var/www/html/` | Landing page |
| `/relay` | GET upgrade | `proxy_pass http://127.0.0.1:8888` with WebSocket upgrade headers | c-relay Nostr wss |
| `/admin/` | GET | `proxy_pass http://127.0.0.1:8888/api/` | c-relay embedded admin |
| `^/[a-f0-9]{64}$` | GET HEAD | `try_files /var/www/blobs/$uri =404` | Direct disk serve — [`ginxsom's`](../includes/ginxsom/README.md:33) core design |
| `/upload` | PUT HEAD | `fastcgi_pass unix:/tmp/ginxsom-fcgi.sock` | Authenticated upload |
| `^/[a-f0-9]{64}$` | DELETE | `fastcgi_pass unix:/tmp/ginxsom-fcgi.sock` | Authenticated delete |
| `/list/` | GET | `fastcgi_pass unix:/tmp/ginxsom-fcgi.sock` | Per-pubkey listing |
| `/mirror` | PUT | `fastcgi_pass unix:/tmp/ginxsom-fcgi.sock` | BUD-04 |
| `/report` | PUT | `fastcgi_pass unix:/tmp/ginxsom-fcgi.sock` | BUD-09 |
### TLS posture — chosen default
**Self-signed cert generated on first boot**, valid for the box's hostname
and any DNS names in `/etc/n-os-tr/tls-names`. Reason: the ISO has no idea
what domain name it will live under, and most first users will run on a
LAN or behind another reverse proxy. A self-signed cert means wss:// works
out of the box for clients that trust it.
We ship an enable-certbot helper script for users who have a real domain:
```
n-os-tr-certbot <domain>
```
This stops nginx, runs `certbot certonly --standalone`, rewrites the cert
paths in the nginx config, and restarts nginx.
### Key nginx template snippets
WebSocket proxy to c-relay (the Upgrade dance is mandatory):
```nginx
location /relay {
proxy_pass http://127.0.0.1:8888;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
```
Direct blob serve with FastCGI fallback for non-GET:
```nginx
# GET of a 64-hex path: serve straight from disk
location ~ "^/(?<hash>[a-f0-9]{64})$" {
root /var/www/blobs;
try_files /$hash @ginxsom;
}
location @ginxsom {
fastcgi_pass unix:/tmp/ginxsom-fcgi.sock;
include fastcgi_params;
}
```
Uploads:
```nginx
location /upload {
client_max_body_size 100m;
fastcgi_pass unix:/tmp/ginxsom-fcgi.sock;
include fastcgi_params;
}
```
## 7. Phase 3.C — First-boot provisioning
### Unit
```ini
# /etc/systemd/system/n-os-tr-firstboot.service
[Unit]
Description=n-OS-tr first-boot key provisioning
ConditionPathExists=!/var/lib/n-os-tr/.provisioned
Before=c-relay.service ginxsom.service fips.service nginx.service
RequiredBy=c-relay.service ginxsom.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/n-os-tr-firstboot
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
```
### The script
Outline of `/usr/local/sbin/n-os-tr-firstboot`:
```bash
#!/bin/bash
set -euo pipefail
STATE=/var/lib/n-os-tr
KEYS=$STATE/keys
ENV=$STATE/env
mkdir -p "$STATE" "$KEYS" /var/www/blobs /var/lib/c-relay /var/lib/ginxsom
chmod 0700 "$KEYS"
# 1. ginxsom server keypair
if [[ ! -f $KEYS/ginxsom.nsec ]]; then
nak key generate > "$KEYS/ginxsom.nsec"
chmod 0400 "$KEYS/ginxsom.nsec"
fi
GINX_SEC=$(nak decode "$(cat "$KEYS/ginxsom.nsec")" | jq -r .private_key)
# 2. fips identity (optional persistent mode)
# If operator wants persistent identity, uncomment:
# if [[ ! -f /etc/fips/fips.key ]]; then
# fips keygen > /etc/fips/fips.key
# chmod 0600 /etc/fips/fips.key
# fi
# 3. self-signed TLS cert if none
if [[ ! -f /etc/ssl/n-os-tr/fullchain.pem ]]; then
mkdir -p /etc/ssl/n-os-tr
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-subj "/CN=$(hostname)" \
-keyout /etc/ssl/n-os-tr/privkey.pem \
-out /etc/ssl/n-os-tr/fullchain.pem
fi
# 4. Write the environment file consumed by ginxsom.service
cat > "$ENV" <<EOF
GINXSOM_SERVER_PRIVKEY=$GINX_SEC
EOF
chmod 0400 "$ENV"
# 5. Ownership
chown -R www-data:www-data /var/www/blobs
chown -R c-relay:c-relay /var/lib/c-relay 2>/dev/null || true
touch "$STATE/.provisioned"
```
Notes:
- c-relay is intentionally **not** provisioned here. On first start it
generates its own admin keypair and prints the nsec to the journal. The
operator is expected to grab it from `journalctl -u c-relay`.
- The `ginxsom.service` we ship is a modified version that replaces the
hardcoded `--server-privkey` with `--server-privkey ${GINXSOM_SERVER_PRIVKEY}`
and adds `EnvironmentFile=-/var/lib/n-os-tr/env`.
### MOTD hint
`/etc/motd` will include:
```
Welcome to n-OS-tr.
Your Nostr relay admin key was generated on first boot. Retrieve it with:
journalctl -u c-relay | grep -i 'admin'
Your Blossom server pubkey:
cat /var/lib/n-os-tr/keys/ginxsom.nsec
Your FIPS identity:
fipsctl identity
```
## 8. Identity / persistence posture
Three supported modes, one default:
| Mode | Who it's for | What happens on reboot |
|---|---|---|
| **Ephemeral (default)** | Demos, privacy-maximal users, disposable relays | All keys regenerate, all data is lost |
| **Persistence partition** | Users running from USB who want continuity | Second partition labeled `persistence` with `persistence.conf` listing `/var/lib/n-os-tr`, `/var/lib/c-relay`, `/var/lib/ginxsom`, `/var/www/blobs` as `union` mounts. Keys and data survive reboots of that stick. |
| **Installed to disk** | Long-running nodes | Normal Debian behavior; user ran the debian-installer from the live session |
All three work from the same ISO image. The user chooses posture by how
they write the ISO and what partitions they create.
## 9. Known traps / risks
1. **c-relay has its own port** (8888) and its own `/api/` admin path,
while nginx also wants to serve `/admin/`. We either reverse-proxy
`/admin/``localhost:8888/api/` or expose c-relay's admin on a
separate port bound to localhost and forward it over SSH only.
2. **`ginxsom` expects spawn-fcgi** per the unit file. Make sure
`spawn-fcgi` is in `base.list.chroot`.
3. **libwebsockets ABI** — the c-relay binary is built static-musl per
[`STATIC_MUSL_GUIDE.md`](../includes/c-relay/STATIC_MUSL_GUIDE.md), so
we don't need to match the libwebsockets soname in Debian. Good.
4. **ginxsom static binary** is x86_64 only today; arm64 comes in Phase 6.
5. **`fips` Debian package** declares its own systemd units; don't
duplicate them via `includes.chroot` — install via `dpkg -i` and let
the postinst do the work.
6. **Live-build caches aggressively**; remember `lb clean --purge` between
structural changes.
## 10. Success criteria for the first real ISO
A single end-to-end demo, run from a booted QEMU VM:
- [ ] `systemctl status fips` → active, `fipsctl identity` prints an npub
- [ ] `systemctl status c-relay` → active, `journalctl -u c-relay` shows the admin nsec printed once
- [ ] `systemctl status ginxsom nginx` → both active
- [ ] `curl -k https://localhost/` returns the landing page
- [ ] `websocat wss://localhost/relay` accepts a NIP-01 `REQ` and returns an `EOSE`
- [ ] `curl -k -X PUT https://localhost/upload --data-binary @foo.txt` with a signed authorization header stores a blob, then `curl -k https://localhost/<sha256>` retrieves the same bytes

370
plans/network_boot.md Normal file
View File

@@ -0,0 +1,370 @@
# Network Booting the n-os-tr ISO from the Internet
## Status: **Paused / On Hold**
This document captures the design thinking, hardware validation, and implementation
plan for letting a client machine boot the [n-os-tr live ISO](../iso/) directly from
a URL on the internet — no local installation media required.
**Current state:** feasibility fully validated on the target hardware. Server-side
infrastructure not yet built. Resume here when ready.
---
## 1. The Goal
Make it possible to power on a bare-metal machine, press a key, and have it boot
straight into a running n-os-tr live environment whose kernel, initrd, and root
filesystem were downloaded over HTTPS from a server on the public internet at
boot time. No USB stick, no local disk, no LAN-side boot infrastructure required.
This complements — rather than replaces — the ISO file we already produce in
[`build.sh`](../build.sh). The ISO remains the primary deliverable; network boot
becomes a second distribution channel for that same set of artifacts.
---
## 2. Options Considered
Five distinct network-boot approaches were evaluated. They are listed in order
of "how close to the goal of booting from an internet URL with nothing on the LAN".
### Option 1: UEFI HTTP(S) Boot (firmware-native)
Modern UEFI 2.5+ firmware (≈2015 and newer) can itself fetch a `.efi` bootloader
over HTTP/HTTPS from any URL. No PXE server, no TFTP, no USB stick. You enter
the URL into the BIOS (or supply it via DHCP option 67) and the firmware does
the rest.
- **Pros:** zero LAN-side infrastructure, cleanest flow, one-time BIOS setup.
- **Cons:** requires UEFI 2.5+ with the feature exposed; older BIOSes don't have it;
many consumer BIOSes hide or restrict the option.
- **Selected as primary path for this project.** See §4 for hardware validation.
### Option 2: Classic PXE with iPXE chainload
BIOS uses its PXE Option ROM to DHCP on the LAN and TFTP-load a bootloader.
That bootloader is [iPXE](https://ipxe.org), which then has full HTTPS support
and fetches everything else from the internet.
- **Pros:** works on essentially any BIOS from ~2005 onward. Well-documented.
- **Cons:** requires a DHCP and TFTP server on the LAN. More infrastructure setup.
- **Secondary fallback** if HTTP Boot ever becomes unavailable.
### Option 3: iPXE on a USB stick (or CD)
Flash the ~1 MB [`ipxe.usb`](https://boot.ipxe.org/ipxe.usb) image to any USB drive
and boot from USB. iPXE launches, reaches the internet over HTTPS, and fetches
the ISO assets. Once the stick is made, it's effectively a permanent
"network boot" key — the ISO lives on the internet and can be updated server-side
without touching the stick.
- **Pros:** works on any machine that can boot USB (i.e. all of them). Zero
BIOS configuration needed. Zero LAN infrastructure needed.
- **Cons:** requires distributing a physical medium one time per machine.
- **Universal fallback** when neither HTTP Boot nor PXE are usable.
### Option 4: netboot.xyz
Hosted iPXE menu service at [netboot.xyz](https://netboot.xyz). Flash their USB
or point PXE at their URL; get a menu of hundreds of bootable OSes. Can be
extended with custom menu entries for your own ISO.
- **Pros:** no build step needed; community-maintained.
- **Cons:** dependency on third-party service; extra indirection for a
single-ISO use case.
- **Not chosen.** More value for general-purpose multi-distro booting than for
a single project's distribution channel.
### Option 5: GRUB with `http` + `loopback` modules
GRUB 2 can load `.iso` files over HTTP and loopback-mount them using the
`http` and `net` modules. Requires GRUB already installed somewhere
(local disk, USB, or chainloaded).
- **Pros:** familiar bootloader; loopback-mount of real ISOs.
- **Cons:** niche; less common tooling; redundant next to iPXE.
- **Not chosen.**
---
## 3. Decision Tree
```mermaid
flowchart TD
Start[Want to netboot ISO from the internet] --> Q1{UEFI HTTP Boot<br/>available?}
Q1 -->|Yes| UEFI[Configure URL in BIOS<br/>firmware fetches .efi]
Q1 -->|No| Q2{BIOS supports<br/>PXE?}
Q2 -->|Yes| Q3{Can you run a<br/>LAN DHCP+TFTP server?}
Q3 -->|Yes| PXE[Local DHCP+TFTP<br/>chainload iPXE to HTTPS]
Q3 -->|No| USB1[iPXE USB stick]
Q2 -->|No| USB2[iPXE USB stick]
UEFI --> ISO[ISO served from HTTPS]
PXE --> ISO
USB1 --> ISO
USB2 --> ISO
```
---
## 4. Hardware Validation (Target: ASRock B650I Lightning WiFi)
The target platform is the ASRock B650I Lightning WiFi motherboard (AMD AM5,
Ryzen 7000 series CPUs, 2.5 GbE Realtek RTL8125BG, Wi-Fi 6E).
Reference: [`B650I Lightning WiFi.pdf`](../B650I%20Lightning%20WiFi.pdf).
### 4.1 Firmware Facts
From the manual's Section 1.2 Specifications:
- **BIOS family:** AMI Aptio V UEFI ("AMI UEFI Legal BIOS with GUI support", p. 5)
- **Onboard LAN:** Dragon RTL8125BG 2.5 GbE (p. 3)
- **Wi-Fi:** 802.11ax Wi-Fi 6E module (p. 3)
- **BIOS Flashback button:** present (Section 2.13, pp. 4748) — allows BIOS
update with no CPU, RAM, or GPU installed.
### 4.2 BIOS Revision Tested
- **Current BIOS on the unit:** version `3.01` (released 2024-05-14,
AGESA 1.1.7.0)
- **Latest stable at time of writing:** `4.10` (2026-03-03, AGESA 1.3.0.0a)
- None of the release notes between `1.28` (initial) and `4.10` mention
Network Stack / HTTP Boot / PXE changes — the feature set for UEFI networking
has been stable across all revisions. Updating is therefore not expected to
add or remove HTTP Boot capability.
### 4.3 CSM (Legacy BIOS Compatibility)
- CSM cannot be persistently enabled on this board. Every attempt resets to
disabled on reboot. This is **normal and expected on AM5** — AMD removed
legacy Option ROM support at the silicon level in Ryzen 7000, so CSM is a
vestigial menu item only. The platform is UEFI-only by design.
- This is the correct state for UEFI HTTP Boot; no action required.
### 4.4 BIOS Menu Layout Discovered
The Network Stack menu layout on this specific BIOS revision is **partial**:
-`Advanced → IPv4 Network Configuration` (exposed)
-`Advanced → IPv6 Network Configuration` (exposed)
- ❌ Parent "Network Stack" toggle with `Ipv4 PXE Support` / `Ipv4 HTTP Support`
sub-options — **not visible in the UI on BIOS 3.01**.
- ✅ However, the `Advanced → Onboard Devices Configuration` submenu does exist
on this board (confirmed by the warning text on ASRock's BIOS download page
referencing `BIOS\Advanced\Onboard Devices Configuration\Display Priority`).
Typical options there: Change LEDs, Display Priority, HD Audio, Onboard LAN,
WAN, Bluetooth toggles — but **no Network Stack option inside** on 3.01.
### 4.5 HTTP Boot Status: **ENABLED AND ACTIVE**
Empirical probe via the F11 one-shot boot menu revealed two UEFI network
boot entries already present:
```
UEFI: PXE IPv4 Realtek PCIe GbE Family Controller
UEFI: HTTP IPv4 Realtek PCIe GbE Family Controller ← this one, confirmed present
```
On this BIOS revision ASRock ships Network Stack **auto-enabled**
with both PXE and HTTP IPv4 supported, even though the granular toggle for
HTTP Support is not surfaced in the Setup UI. No BIOS configuration change
is required to use HTTP Boot on this machine.
### 4.6 Wi-Fi Boot
- **Not supported.** The Wi-Fi 6E module is not exposed to the UEFI network
stack. Network boot requires wired Ethernet plugged in at POST time.
Once the live system is running, Wi-Fi works normally (via Linux drivers,
not firmware).
### 4.7 Summary Table
| Capability | Status on this board with BIOS 3.01 |
|---|---|
| UEFI HTTP Boot (IPv4) | ✅ Active and ready |
| UEFI HTTPS Boot | ⚠️ Likely requires certificate enrollment; not investigated |
| UEFI PXE (IPv4) | ✅ Active |
| Legacy CSM/PXE | ❌ Not possible on AM5 platform |
| Wi-Fi boot | ❌ Not supported by firmware |
| BIOS Flashback for recovery/upgrade | ✅ Available (no CPU/RAM/GPU required) |
**Bottom line:** the hardware is ideal for this use case. Zero firmware work
is needed; the only remaining task is building the server-side delivery chain.
---
## 5. Target Architecture
```mermaid
flowchart TD
subgraph Client [Client machine B650I or compatible]
A[Power on → F11 boot menu]
A2[Select UEFI HTTP IPv4]
end
subgraph Server [Public HTTPS server host TBD]
B[bootx64.efi<br/>~1MB iPXE UEFI binary<br/>with embedded script]
C[boot.ipxe<br/>boot script]
D[vmlinuz<br/>Linux kernel]
E[initrd.img<br/>Debian live initramfs]
F[filesystem.squashfs<br/>compressed root FS]
end
A --> A2
A2 -->|1. HTTP GET bootx64.efi| B
B -->|2. firmware launches iPXE| IPXE[iPXE running]
IPXE -->|3. HTTPS GET boot.ipxe| C
C -->|4. HTTPS GET kernel and initrd| D
C --> E
E -->|5. initramfs HTTPS GET fetch=squashfs| F
F --> Live[n-os-tr live system running]
```
### Protocol Notes
- **Step 1 (firmware → bootx64.efi): plain HTTP.** UEFI 2.5+ firmware generally
supports HTTPS boot only with CA certificates enrolled into the firmware
trust store — extra complexity, and on ASRock consumer boards this path is
not well-documented. Plain HTTP is acceptable for this one hop because:
1. The binary is ≤ 1 MB and trivial to re-serve.
2. iPXE binaries can be code-signed separately and/or hash-verified.
3. All subsequent traffic is HTTPS.
- **Steps 35 (iPXE and live-boot): HTTPS.** iPXE has modern TLS support and
`live-boot` uses standard libcurl/wget semantics via the `fetch=URL` kernel
parameter. No special configuration required to use HTTPS throughout.
### Artifact Inventory
| File | Size | Source | Hosted at (example) |
|---|---|---|---|
| `bootx64.efi` | ~1 MB | Built from [`iPXE`](https://github.com/ipxe/ipxe) with embedded script | `http://boot.example/bootx64.efi` |
| `boot.ipxe` | ~1 KB | Hand-written | `https://boot.example/n-os-tr/boot.ipxe` |
| `vmlinuz` | ~815 MB | `live-build` output at `binary/live/vmlinuz` | `https://boot.example/n-os-tr/vmlinuz` |
| `initrd.img` | ~3050 MB | `live-build` output at `binary/live/initrd.img` | `https://boot.example/n-os-tr/initrd.img` |
| `filesystem.squashfs` | ~12 GB | `live-build` output at `binary/live/filesystem.squashfs` | `https://boot.example/n-os-tr/filesystem.squashfs` |
---
## 6. Implementation Plan (When Resumed)
### Phase 1: Proof-of-Concept — Build iPXE
- [ ] Clone [`github.com/ipxe/ipxe`](https://github.com/ipxe/ipxe).
- [ ] Create an embedded script `myscript.ipxe` that DHCPs and chainloads
`https://boot.example/n-os-tr/boot.ipxe`.
- [ ] Enable in `src/config/general.h`:
- `DOWNLOAD_PROTO_HTTPS`
- `NET_PROTO_IPV6` (optional but recommended)
- [ ] Build: `make bin-x86_64-efi/ipxe.efi EMBED=myscript.ipxe`.
- [ ] Upload resulting `.efi` to the webserver as `bootx64.efi`.
### Phase 2: Choose and Configure the Hosting
- [ ] Decide where to host:
- Plain nginx/Apache on a VPS.
- The project's own [`includes/ginxsom/`](../includes/ginxsom/) Blossom
server — natural fit for the static squashfs given content-addressing.
- An object store (S3 / R2 / Backblaze) fronted by a CDN.
- [ ] Ensure the host serves `.efi` with an appropriate MIME type
(`application/octet-stream` or `application/efi`).
- [ ] Verify HTTP (not HTTPS) works for the `bootx64.efi` endpoint — some
hosts redirect HTTP→HTTPS by default and the UEFI firmware may not
follow redirects.
### Phase 3: Extend `build.sh` to Publish Artifacts
- [ ] After `lb build` completes, copy `binary/live/vmlinuz`, `initrd.img`,
and `filesystem.squashfs` out of the build tree.
- [ ] Push them to a versioned path on the webserver
(e.g. `/n-os-tr/v${VERSION}/...`).
- [ ] Update a `/n-os-tr/latest/` symlink or alias.
- [ ] Regenerate `boot.ipxe` to reference the correct versioned paths.
- [ ] Consider integrity: publish a SHA-256 manifest alongside the artifacts
so iPXE's `imgverify` / manual verification can catch tampering even
over plain HTTP.
### Phase 4: Write `boot.ipxe`
```ipxe
#!ipxe
set base-url https://boot.example/n-os-tr/latest
echo Loading n-os-tr from ${base-url}
kernel ${base-url}/vmlinuz boot=live components quiet splash \
fetch=${base-url}/filesystem.squashfs
initrd ${base-url}/initrd.img
boot
```
- [ ] The `fetch=URL` parameter is handled by Debian's
[`live-boot`](https://manpages.debian.org/testing/live-boot-doc/live-boot.7.en.html)
initramfs; it downloads the squashfs into RAM and pivots root to it.
- [ ] The `iso/` build already includes `live-boot` via
[`iso/config/package-lists/base.list.chroot`](../iso/config/package-lists/base.list.chroot) —
confirm at resume time.
### Phase 5: Optional — DHCP Option 67 for Hands-Free Boot
If the LAN's DHCP server is controllable, set DHCP option 67
(Boot-File-Name) so the client firmware auto-receives the URL instead of
prompting at boot.
- **ISC dhcpd:**
```conf
option arch code 93 = unsigned integer 16;
class "httpclients" {
match if substring (option vendor-class-identifier, 0, 10) = "HTTPClient";
option vendor-class-identifier "HTTPClient";
if option arch = 00:10 {
filename "http://boot.example/bootx64.efi";
}
}
```
- **dnsmasq:**
```conf
dhcp-vendorclass=set:httpboot,HTTPClient
dhcp-boot=tag:httpboot,http://boot.example/bootx64.efi
```
- **Result:** F11 → HTTP IPv4 → boots with zero user input.
### Phase 6: End-to-End Test
- [ ] Fresh boot of the B650I target with wired Ethernet connected.
- [ ] F11 → `UEFI: HTTP IPv4 Realtek …` → enter (or auto-receive) URL.
- [ ] Confirm iPXE banner appears.
- [ ] Confirm `boot.ipxe` is fetched and executed.
- [ ] Confirm kernel + initrd load.
- [ ] Confirm squashfs streams in and live system boots to login prompt.
- [ ] Confirm [`n-os-tr-firstboot`](../iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot)
and [`n-os-tr-smoketest`](../iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest)
behave identically to the USB-boot case.
---
## 7. Open Questions for When We Resume
1. **Where are we hosting the artifacts?** Public VPS with nginx, project-owned
ginxsom/Blossom, object store + CDN, or something else?
2. **Versioning strategy:** `/latest/` symlink, or require client to supply a
version in the URL? Rollback semantics?
3. **HTTPS Boot (instead of plain HTTP) for the first hop:** worth the
certificate-enrollment effort, or is integrity via out-of-band hash
pinning enough?
4. **Signed iPXE:** do we want to build a Secure Boot-signed iPXE so that
Secure Boot can remain enabled on clients? Shim + MOK, or use an existing
signed build?
5. **Multi-arch:** is arm64 (aarch64) netboot ever in scope, or x86_64 only?
(If yes, second iPXE build for `bin-arm64-efi/ipxe.efi`.)
6. **Observability:** do we want a log/telemetry endpoint so that network-
booted clients phone home on successful boot?
7. **Fallback distribution:** still ship the full `.iso` for USB installs?
(Answer assumed "yes" — network boot is additive, not a replacement.)
---
## 8. Known Limitations
- **Wired Ethernet required at boot time.** No Wi-Fi netboot on consumer UEFI.
- **Plain HTTP for the bootloader hop** unless certificate enrollment is done;
mitigate with hash verification.
- **Boot speed bounded by internet throughput** for the ~12 GB squashfs. A
cached/CDN-fronted origin is strongly recommended for realistic fleet use.
- **No offline fallback** by design — this is complementary to the regular USB
ISO, not a replacement.
- **BIOS 3.01 on the target doesn't expose a UI toggle** for `Ipv4 HTTP
Support` but the feature is active by default. If a future BIOS revision
changes this, revisit via F11 inspection as in §4.5.
---
## 9. References
- iPXE project: <https://ipxe.org>
- iPXE build targets: <https://ipxe.org/appnote/buildtargets>
- iPXE scripting reference: <https://ipxe.org/scripting>
- Debian `live-boot(7)`:
<https://manpages.debian.org/testing/live-boot-doc/live-boot.7.en.html>
- Debian Live Manual — runtime behaviours:
<https://live-team.pages.debian.net/live-manual/html/live-manual/customizing-run-time-behaviours.en.html>
- UEFI HTTP Boot overview (UEFI spec §24): <https://uefi.org/specifications>
- ASRock B650I Lightning WiFi BIOS downloads:
<https://www.asrock.com/mb/AMD/B650I%20Lightning%20WiFi/index.asp#BIOS>
- Project ISO architecture sibling plan: [`iso_architecture.md`](./iso_architecture.md)
- Project build entry point: [`build.sh`](../build.sh)
- Project live-build wrapper: [`lb-wrapper.sh`](../lb-wrapper.sh)
- Running / usage docs: [`docs/RUNNING.md`](../docs/RUNNING.md)

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.

View File

@@ -0,0 +1,644 @@
# Slice B + C: Add fips and c-relay to the n-OS-tr ISO
## Status: **Ready for implementation**
This document specifies the work required to enable the [fips](../includes/fips)
mesh-routing daemon and the [c-relay](../includes/c-relay) Nostr relay on the
live ISO built by [`build.sh`](../build.sh), replacing the current Slice A
(ginxsom + nginx only) with the full three-service stack planned in
[`iso_architecture.md`](./iso_architecture.md).
---
## 1. Design Decisions (captured from user)
| # | Topic | Decision |
|---|---|---|
| 1 | Source of c-relay binary | User-provided, already downloaded into [`includes/c-relay/`](../includes/c-relay) |
| 2 | Source of fips binaries | Build locally via `cargo deb` during `build.sh` |
| 3 | c-relay admin nsec strategy | **Ephemeral** — c-relay self-provisions per boot, admin nsec captured from journald |
| 4 | fips identity | **Ephemeral** — new npub per boot, no persistent key management for this slice |
| 5 | fips peer config | Connect to the same public bootstrap node used elsewhere in the repo: `npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98` at `217.77.8.91:2121` (UDP), per [`examples/sidecar-nostr-relay/.env`](../includes/fips/examples/sidecar-nostr-relay/.env:9) et al. |
| 6 | fips transport | **Ethernet** enabled, plus UDP for the static peer |
| 7 | fips-gateway | Disabled (default) |
| 8 | c-relay exposure | nginx reverse-proxies `wss://host/relay``ws://127.0.0.1:8888/`; nginx reverse-proxies `/admin/``http://127.0.0.1:8888/api/` |
| 9 | Scope | Full Slice B+C — services, nginx wiring, package deps, smoke test, MOTD, README updates |
| 10 | Key management / identity continuity | Out of scope for this slice — revisit in a later pass |
---
## 2. What Already Exists (Slice A baseline)
Confirmed present and working, per source inspection:
- [`iso/auto/config`](../iso/auto/config) — live-build config
- [`iso/config/package-lists/base.list.chroot`](../iso/config/package-lists/base.list.chroot) — nginx-light, spawn-fcgi, ca-certificates, curl, jq, vim, htop
- [`iso/config/includes.chroot/etc/systemd/system/ginxsom.service`](../iso/config/includes.chroot/etc/systemd/system/ginxsom.service)
- [`iso/config/includes.chroot/etc/systemd/system/n-os-tr-firstboot.service`](../iso/config/includes.chroot/etc/systemd/system/n-os-tr-firstboot.service)
- [`iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf`](../iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf) — `:443` TLS, Blossom routing, static landing page
- [`iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot`](../iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot) — generates ginxsom nsec + self-signed TLS
- [`iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest`](../iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest) — 5 PASS + 8 SKIP placeholder
- [`iso/config/includes.chroot/usr/local/bin/nak`](../iso/config/includes.chroot/usr/local/bin/nak) — Nostr CLI helper
- [`iso/config/includes.chroot/usr/local/bin/ginxsom/ginxsom-fcgi`](../iso/config/includes.chroot/usr/local/bin/ginxsom/ginxsom-fcgi) — Blossom FastCGI binary
- [`iso/config/hooks/live/0020-enable-services.hook.chroot`](../iso/config/hooks/live/0020-enable-services.hook.chroot) — enables nginx, ginxsom, firstboot
Slice A smoke test passes `5 PASS, 0 FAIL, 8 SKIP` on a freshly-booted USB.
---
## 3. What Needs to Change (target state)
```mermaid
flowchart LR
subgraph Build [Build-time build.sh]
BS[build.sh]
CDB[cargo deb in<br/>includes/fips]
FDeb[iso/local-artifacts/fips_0.3.0_amd64.deb]
CRX[includes/c-relay/c_relay_x86]
CRC[iso/local-artifacts/c_relay_x86]
BS --> CDB --> FDeb
BS --> CRC
end
subgraph Chroot [live-build chroot phase]
H1[0010-fetch-artifacts.hook.chroot]
FDeb --> H1
CRC --> H1
H1 -->|dpkg -i| FInst[/usr/bin/fips etc]
H1 -->|install -m 0755| CInst[/opt/c-relay/c_relay_x86]
H1 -->|adduser --system| CUsr[c-relay system user]
H1 -->|write fips.yaml| FCfg[/etc/fips/fips.yaml]
end
subgraph Boot [Runtime at boot]
Fb[n-os-tr-firstboot]
Fips[fips.service]
FDns[fips-dns.service]
CRel[c-relay.service]
Gx[ginxsom.service]
Ng[nginx.service]
Fb --> Gx
Fips --> FDns
Ng -->|wss /relay| CRel
Ng -->|http /admin/| CRel
end
```
### Summary of changes
| Area | Change |
|---|---|
| Build system | [`build.sh`](../build.sh) gains a pre-build step: build fips `.deb` via `cargo deb` and stage artifacts into [`iso/local-artifacts/`](../iso/local-artifacts) |
| Artifact layout | New directory [`iso/local-artifacts/`](../iso/local-artifacts) holds `fips_*_amd64.deb` and `c_relay_x86` — gitignored |
| Chroot hook (new) | [`iso/config/hooks/live/0010-fetch-artifacts.hook.chroot`](../iso/config/hooks/live/0010-fetch-artifacts.hook.chroot) installs those two artifacts into the chroot |
| Package list | [`iso/config/package-lists/base.list.chroot`](../iso/config/package-lists/base.list.chroot) adds runtime deps: `libdbus-1-3`, `iproute2`, `nftables` (optional for gateway mode later) |
| fips config | New [`iso/config/includes.chroot/etc/fips/fips.yaml`](../iso/config/includes.chroot/etc/fips/fips.yaml) overriding the package default with ethernet + static UDP peer enabled |
| c-relay service | New [`iso/config/includes.chroot/etc/systemd/system/c-relay.service`](../iso/config/includes.chroot/etc/systemd/system/c-relay.service) — adapted from [upstream unit](../includes/c-relay/systemd/c-relay.service) |
| c-relay user | Chroot hook creates `c-relay` system user + `/opt/c-relay/` working dir |
| nginx config | [`iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf`](../iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf) gains `/relay` wss upgrade and `/admin/` proxy locations |
| Service-enable hook | [`0020-enable-services.hook.chroot`](../iso/config/hooks/live/0020-enable-services.hook.chroot) also enables `fips.service`, `fips-dns.service`, `c-relay.service` |
| Smoke test | [`n-os-tr-smoketest`](../iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest) replaces the 8 SKIPs with real checks |
| MOTD / README | First-boot script and docs updated to surface how to find c-relay admin nsec and fips identity |
| `.gitignore` | Add `iso/local-artifacts/` |
---
## 4. Concrete Artifact Details
### 4.1 c-relay static binary
- **Source:** [`includes/c_relay_static_x86_64`](../includes/c_relay_static_x86_64) — user-downloaded static-musl build, already present at the top level of `includes/` (NOT inside `includes/c-relay/`). Filename confirmed by directory listing.
- **Staged as:** `iso/config/includes.chroot_before_packages/live-artifacts/c_relay_x86` (canonical name used by the chroot hook and systemd unit — we rename on copy so `c-relay.service` can reference a stable filename regardless of which upstream build variant the user drops in).
- **Target in chroot:** `/opt/c-relay/c_relay_x86`, mode `0755`, owner `c-relay:c-relay`.
- **Copied by:** `build.sh` copies [`includes/c_relay_static_x86_64`](../includes/c_relay_static_x86_64) → staging area → chroot hook installs to `/opt/c-relay/`.
### 4.2 `iso/local-artifacts/fips_0.3.0_amd64.deb`
- **Source:** built by `build.sh` via `cd includes/fips && packaging/debian/build-deb.sh`. Requires `cargo-deb` installed on the build host (`cargo install cargo-deb` if absent; `build.sh` will check and fail clearly).
- **Contents (per [`Cargo.toml`](../includes/fips/Cargo.toml:70-83)):**
- `/usr/bin/fips`, `/usr/bin/fipsctl`, `/usr/bin/fipstop`, `/usr/bin/fips-gateway`
- `/etc/fips/fips.yaml` (package default — will be overridden by our includes.chroot version)
- `/etc/fips/hosts`
- `/lib/systemd/system/fips.service`, `/lib/systemd/system/fips-dns.service`, `/lib/systemd/system/fips-gateway.service`
- `/usr/lib/fips/fips-dns-setup`, `/usr/lib/fips/fips-dns-teardown`
- `/usr/lib/tmpfiles.d/fips.conf`
- **Runtime deps (per Cargo.toml):** `libc6, systemd, libdbus-1-3`, recommends `bluez` (skipped — not needed for non-BLE).
- **Installed by:** chroot hook runs `dpkg -i /tmp/artifacts/fips_*_amd64.deb` then cleans up. The postinst enables `fips.service` and `fips-dns.service` automatically.
### 4.3 `iso/config/includes.chroot/etc/fips/fips.yaml`
Overrides the Debian-packaged default at `/etc/fips/fips.yaml`. The live-build
`includes.chroot` mechanism copies this on top after package install. Content:
```yaml
# n-OS-tr fips configuration.
# Ephemeral identity: new keypair every boot (default).
# To make identity persistent across reboots on a live USB with a
# persistence partition, set node.identity.persistent: true.
node:
identity:
# ephemeral by default
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
port: 5354
transports:
udp:
bind_addr: "0.0.0.0:2121"
tcp:
bind_addr: "0.0.0.0:8443"
ethernet:
# Ethernet transport auto-discovers LAN peers.
# interface: omitted → fips picks the first non-loopback interface.
# Override with an explicit interface name if your board uses
# something other than the default (e.g. enp5s0, eno1, eth0).
discovery: true
announce: true
auto_connect: true
accept_connections: true
# Static peer for internet bootstrap.
# Matches the same "fips-test-node" / "vps-chi" public node used across
# the repo's example configs (see includes/fips/examples/... and
# includes/fips/testing/sidecar/.env).
peers:
- npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
alias: "fips-test-node"
addresses:
- transport: udp
addr: "217.77.8.91:2121"
connect_policy: auto_connect
```
Notes:
- Omitting `ethernet.interface` relies on fips's auto-selection logic. If that
doesn't pick a sensible interface, add `interface: "eth0"` (verified post-boot
via `ip link show`).
- Keeping the `udp` bootstrap peer in addition to `ethernet` gives the mesh
internet reachability even when the LAN has no other fips peers.
### 4.4 `iso/config/includes.chroot/etc/systemd/system/c-relay.service`
Adapted from [upstream](../includes/c-relay/systemd/c-relay.service) with
minor tweaks for the live environment:
```ini
[Unit]
Description=C Nostr Relay (n-OS-tr slice C)
Documentation=file:///usr/share/doc/n-os-tr/README
After=network.target n-os-tr-firstboot.service
Wants=network-online.target
[Service]
Type=simple
User=c-relay
Group=c-relay
WorkingDirectory=/opt/c-relay
Environment=DEBUG_LEVEL=0
# c-relay self-provisions admin + relay keys on first start and prints
# the admin nsec to stdout (captured by journald).
ExecStart=/opt/c-relay/c_relay_x86 --debug-level=${DEBUG_LEVEL}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=c-relay
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/c-relay
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=multi-user.target
```
### 4.5 Updated nginx site — `/relay` wss + `/admin/` proxy
New locations appended to [`iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf`](../iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf) inside the existing `server { listen 443 ssl; ... }` block, before the ginxsom FastCGI blocks:
```nginx
# --- c-relay reverse proxy (slice C) ---------------------------------
# Nostr relay WebSocket: wss://host/relay → ws://127.0.0.1:8888/
# The client library expects a WebSocket upgrade on /relay; c-relay
# itself listens on "/" so we rewrite and proxy to its root.
location = /relay {
proxy_pass http://127.0.0.1:8888/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# NIP-11 relay info doc (available at wss://host/relay with
# Accept: application/nostr+json). Proxied to the same backend.
# If you want /relay (GET without upgrade) to return NIP-11 too,
# the location above already handles it — c-relay's NIP-11 handler
# triggers based on the Accept header, not the path.
# c-relay embedded web admin UI: https://host/admin/ → http://127.0.0.1:8888/api/
location /admin/ {
proxy_pass http://127.0.0.1:8888/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Notes:
- The `/admin/` path deliberately overlaps with a Blossom admin path, but
Blossom's admin routes go via the FastCGI `@ginxsom` named location and
c-relay's admin is explicitly reverse-proxied — nginx's longest-prefix
match means `/admin/` → c-relay wins for GET/POST, and ginxsom admin is
reached via a different path (if ginxsom has one). **Potential conflict
with Slice A** — need to confirm ginxsom doesn't rely on `/admin/`.
Current [n-os-tr.conf:53](../iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf:53) has `location /admin/ { try_files $uri @ginxsom; }` — this will need to be **removed or renamed** (e.g. `/blossom-admin/`) to avoid collision. See §6 Open Questions.
- The landing page at `/` (static `/var/www/html/index.html`) is unaffected.
### 4.6 New chroot hook — `0010-fetch-artifacts.hook.chroot`
```sh
#!/bin/sh
# Install c-relay and fips into the chroot from iso/local-artifacts/.
set -e
ART=/live-artifacts
if [ ! -d "$ART" ]; then
echo "[fetch-artifacts] no $ART; skipping" >&2
exit 0
fi
# --- c-relay -----------------------------------------------------------
if [ -f "$ART/c_relay_x86" ]; then
echo "[fetch-artifacts] installing c-relay binary"
# Create system user and directory
if ! getent passwd c-relay >/dev/null 2>&1; then
adduser --system --no-create-home --group --home /opt/c-relay c-relay
fi
mkdir -p /opt/c-relay
install -m 0755 -o c-relay -g c-relay \
"$ART/c_relay_x86" /opt/c-relay/c_relay_x86
chown -R c-relay:c-relay /opt/c-relay
else
echo "[fetch-artifacts] WARNING: $ART/c_relay_x86 not found" >&2
fi
# --- fips --------------------------------------------------------------
FIPS_DEB=$(ls "$ART"/fips_*_amd64.deb 2>/dev/null | head -1 || true)
if [ -n "$FIPS_DEB" ] && [ -f "$FIPS_DEB" ]; then
echo "[fetch-artifacts] installing fips from $FIPS_DEB"
dpkg -i "$FIPS_DEB" || apt-get -f install -y
else
echo "[fetch-artifacts] WARNING: no fips_*_amd64.deb in $ART" >&2
fi
```
How `$ART` gets populated in the chroot: live-build copies
[`iso/local-artifacts/`](../iso/local-artifacts) into
`/live-artifacts/` inside the chroot via an `includes.chroot_before_packages/`
entry that bind-mounts or symlinks the directory, OR more simply, we place
the artifacts directly under
`iso/config/includes.chroot_before_packages/live-artifacts/` so live-build
copies them before package install. See §5 step 2 for the exact location.
### 4.7 Updated `build.sh`
Inserted **before** the `sudo "$REPO_ROOT/lb-wrapper.sh" config` line:
```bash
# --- Stage Slice B artifacts ------------------------------------------------
ARTIFACTS="$WORKSPACE_ISO_DIR/config/includes.chroot_before_packages/live-artifacts"
mkdir -p "$ARTIFACTS"
# 1. c-relay: copy the user-provided binary from includes/c-relay/
CRELAY_SRC=""
for candidate in \
"$REPO_ROOT/includes/c-relay/c_relay_x86" \
"$REPO_ROOT/includes/c-relay/build/c_relay_x86" \
"$REPO_ROOT/includes/c-relay/c-relay" ; do
if [[ -x "$candidate" ]]; then CRELAY_SRC="$candidate"; break; fi
done
if [[ -z "$CRELAY_SRC" ]]; then
echo "[build.sh] ERROR: no c-relay binary found. Looked for:" >&2
echo " includes/c-relay/c_relay_x86" >&2
echo " includes/c-relay/build/c_relay_x86" >&2
echo " includes/c-relay/c-relay" >&2
exit 1
fi
cp "$CRELAY_SRC" "$ARTIFACTS/c_relay_x86"
chmod 0755 "$ARTIFACTS/c_relay_x86"
echo "[build.sh] staged c-relay from $CRELAY_SRC"
# 2. fips: build the .deb (requires cargo-deb)
if ! command -v cargo-deb >/dev/null 2>&1; then
echo "[build.sh] ERROR: cargo-deb not installed. Run: cargo install cargo-deb" >&2
exit 1
fi
pushd "$REPO_ROOT/includes/fips" >/dev/null
packaging/debian/build-deb.sh
popd >/dev/null
FIPS_DEB=$(ls "$REPO_ROOT/includes/fips/deploy"/fips_*_amd64.deb | sort | tail -1)
if [[ -z "$FIPS_DEB" || ! -f "$FIPS_DEB" ]]; then
echo "[build.sh] ERROR: fips .deb build produced no artifact" >&2
exit 1
fi
cp "$FIPS_DEB" "$ARTIFACTS/"
echo "[build.sh] staged fips .deb: $(basename "$FIPS_DEB")"
```
### 4.8 Updated package list — `base.list.chroot`
Add runtime deps needed by fips (already pulled in transitively by the .deb
but explicit is safer) and by the smoke test:
```
nginx-light
spawn-fcgi
libfcgi-bin
ca-certificates
openssl
curl
jq
vim
htop
# Slice B/C additions:
libdbus-1-3
iproute2
nftables
```
### 4.9 Updated `0020-enable-services.hook.chroot`
```sh
#!/bin/sh
set -e
# Nginx
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/n-os-tr.conf /etc/nginx/sites-enabled/n-os-tr.conf
systemctl enable nginx.service
# Slice A
systemctl enable ginxsom.service
systemctl enable n-os-tr-firstboot.service
# Slice B (fips) — units installed by the .deb postinst already enable
# them, but we're explicit for idempotence:
systemctl enable fips.service || true
systemctl enable fips-dns.service || true
# Slice C (c-relay)
systemctl enable c-relay.service
```
### 4.10 Updated smoke test
Replace the fips and c-relay SKIP blocks in [`n-os-tr-smoketest`](../iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest) with real checks:
```bash
# -------------------- fips mesh daemon -----------------------------
section "fips mesh daemon"
check "fips.service active" systemctl is-active fips.service
check "fips-dns.service active" systemctl is-active fips-dns.service
check "fips0 interface present" ip link show fips0
# fipsctl identity should return an npub-shaped string
npub=$(fipsctl identity 2>/dev/null | head -1 | tr -d '[:space:]')
case "$npub" in
npub1*) pass "fipsctl identity returns npub" "${npub:0:20}..." ;;
*) fail "fipsctl identity returns npub" "got: ${npub:-<empty>}" ;;
esac
# fips UDP transport should be listening
check "fips UDP bound on :2121" bash -c 'ss -ulnp 2>/dev/null | grep -q ":2121"'
# -------------------- c-relay nostr relay --------------------------
section "c-relay nostr relay"
check "c-relay.service active" systemctl is-active c-relay.service
check "c-relay listening on :8888" bash -c 'ss -tlnp 2>/dev/null | grep -q ":8888"'
# The admin nsec should have been logged during first startup
if journalctl -u c-relay.service --no-pager 2>/dev/null | \
grep -q "Admin Private Key:"; then
pass "admin nsec logged in journal" "found"
else
fail "admin nsec logged in journal" "grep miss"
fi
# wss end-to-end via nginx, using the shipped nak:
if timeout 10 nak req --relay wss://localhost/relay -k 1 -l 1 \
>/dev/null 2>&1; then
pass "wss://localhost/relay answers REQ" "ok"
else
fail "wss://localhost/relay answers REQ" "timeout or error"
fi
# NIP-11 relay info doc
nip11=$(curl -sk -H 'Accept: application/nostr+json' \
https://localhost/relay 2>/dev/null)
if echo "$nip11" | jq -e '.name' >/dev/null 2>&1; then
pass "NIP-11 info doc reachable" "name=$(echo "$nip11" | jq -r .name)"
else
fail "NIP-11 info doc reachable" "no JSON .name"
fi
# c-relay embedded admin UI via nginx
status=$(curl -sk -o /dev/null -w '%{http_code}' https://localhost/admin/ 2>/dev/null)
case "$status" in
200|302|401) pass "admin UI reachable via nginx" "HTTP $status" ;;
*) fail "admin UI reachable via nginx" "HTTP $status" ;;
esac
```
Target final summary: `~15 PASS / 0 FAIL / 0 SKIP`.
### 4.11 MOTD and README updates
The [`n-os-tr-firstboot`](../iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot) script's MOTD block gains additional hints:
```
n-OS-tr — Nostr-addressed mesh node (preview)
Ginxsom (Blossom) server pubkey (hex):
$GINXSOM_SERVER_PUBKEY
retrieve nsec: cat /var/lib/n-os-tr/keys/ginxsom.nsec
c-relay Nostr relay:
wss://$(hostname)/relay (admin nsec: journalctl -u c-relay | grep 'Admin Private Key')
https://$(hostname)/admin/ (web admin UI)
fips mesh daemon:
fipsctl identity (show npub)
fipsctl peers (list peers)
Quick health check: n-os-tr-smoketest
```
The [`iso/config/includes.chroot/usr/share/doc/n-os-tr/README`](../iso/config/includes.chroot/usr/share/doc/n-os-tr/README) gets corresponding additions.
---
## 5. Build & Deploy Sequence
1. **Place c-relay binary** at `includes/c-relay/c_relay_x86` (or one of the
fallback paths `build.sh` checks). Ensure it's `chmod +x` and runs standalone.
2. **Install `cargo-deb`** on the build host: `cargo install cargo-deb`.
3. **Edit** all files listed in §3 according to §4.
4. **Move artifacts staging location:**
- New directory: `iso/config/includes.chroot_before_packages/live-artifacts/`
— gitignored.
- `build.sh` writes c-relay binary and fips .deb here.
- `0010-fetch-artifacts.hook.chroot` reads from `/live-artifacts/` inside
the chroot (live-build copies the `includes.chroot_before_packages` tree
into the chroot root before packages install).
5. **Add** `iso/config/includes.chroot_before_packages/live-artifacts/` to
[`.gitignore`](../.gitignore).
6. **Full clean build:** `sudo ./build.sh --clean` (needed because package
list changed).
7. **Burn** the resulting `iso/live-image-amd64.hybrid.iso` to USB with `dd`.
8. **Boot** on the B650I machine.
9. **Verify:** run `n-os-tr-smoketest` — expect all checks to PASS.
---
## 6. Open Questions / Known Conflicts
### 6.1 nginx `/admin/` path collision
Slice A's [n-os-tr.conf:53](../iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf:53)
routes `/admin/` to ginxsom. Slice C wants `/admin/` for c-relay.
**Resolution proposed:** change Slice A's Blossom admin to `/blossom/` (if
ginxsom supports path rewriting) or `/blossom-admin/`. Need to check
ginxsom's source to see whether its admin endpoints are hardcoded to `/admin/`
or whether the FastCGI layer can remap them.
### 6.2 Fips ethernet auto-interface selection
If the B650I's Ethernet shows up as `enp5s0` or similar and fips's
"auto-pick first non-loopback" heuristic grabs the wrong interface (e.g. Wi-Fi
— though we explicitly said runtime Wi-Fi is fine, we don't want fips
scanning it), we may need to hardcode `ethernet.interface: "enp5s0"` in
[fips.yaml](../iso/config/includes.chroot/etc/fips/fips.yaml). **Resolution:**
check `ip link show` on first boot; update if needed.
### 6.3 Stray file `=4` in [`includes/c-relay/`](../includes/c-relay)
Shown in the directory listing but not the c-relay binary (the real binary
is [`includes/c_relay_static_x86_64`](../includes/c_relay_static_x86_64)
at the top level of `includes/`). Likely a shell-redirect typo artifact
(`command >=4 file` or similar). Safe to delete during cleanup; does not
affect the build.
### 6.4 c-relay writable database location
c-relay writes `.nrdb` files to its `WorkingDirectory` (`/opt/c-relay/`). In
a live session with ephemeral rootfs this works — the writes go to the
tmpfs union layer. For persistence-partition boots we'd want `/opt/c-relay/`
on the persistence mount. **Not addressed in this slice** per decision #10.
### 6.5 fips running as root
The fips Debian package runs fips as root (needs TUN + raw sockets). The
hardening in [`fips.service`](../includes/fips/packaging/debian/fips.service:17-22)
is meaningful but fips itself is privileged. Acceptable for this slice.
### 6.6 Reverse proxy's X-Forwarded-Proto and c-relay's NIP-42
NIP-42 requires the relay to know its own URL (for the `["relay", "url"]` tag
in the AUTH challenge). c-relay has [logic](../includes/c-relay/src/nip042.c:103)
that falls back to `ws://127.0.0.1:PORT` — this means NIP-42 challenges will
reference the internal address, not `wss://host/relay`. Clients may then
fail auth. **Mitigation:** c-relay has a configurable `relay_url` config
key (set via admin event). Post-boot procedure documented in updated README
for users who want NIP-42 to work correctly. **Not blocking for basic
operation** — the relay accepts unauthenticated connections by default.
---
## 7. Success Criteria
From a booted USB on fresh hardware with wired Ethernet:
- [ ] `systemctl status fips-dns ginxsom c-relay nginx n-os-tr-firstboot` — all active
- [ ] `fipsctl identity` — prints a valid npub
- [ ] `fipsctl peers` — at minimum lists `fips-test-node` (may show as "connecting" if UDP blocked)
- [ ] `ip link show fips0` — fips0 TUN interface up
- [ ] `journalctl -u c-relay | grep 'Admin Private Key'` — returns the nsec (once)
- [ ] `curl -k https://localhost/` — landing page
- [ ] `curl -k -I https://localhost/admin/` — returns 200 or 302 (c-relay UI)
- [ ] `nak req --relay wss://localhost/relay -k 1 -l 1` — returns `["EOSE",...]` after an empty result
- [ ] `curl -ksI https://localhost/0000000000000000000000000000000000000000000000000000000000000000` — 404 (ginxsom still works)
- [ ] `n-os-tr-smoketest``15 PASS / 0 FAIL / 0 SKIP`
---
## 8. Rollback
If something goes sideways during development:
- The entire Slice A → Slice B+C diff is confined to these files/directories:
```
build.sh (additive step)
iso/config/hooks/live/0010-fetch-artifacts.hook.chroot (new)
iso/config/hooks/live/0020-enable-services.hook.chroot (edited)
iso/config/package-lists/base.list.chroot (edited)
iso/config/includes.chroot/etc/fips/ (new dir)
iso/config/includes.chroot/etc/systemd/system/c-relay.service (new)
iso/config/includes.chroot/etc/nginx/sites-available/n-os-tr.conf (edited)
iso/config/includes.chroot/usr/local/sbin/n-os-tr-firstboot (edited motd)
iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest (edited)
iso/config/includes.chroot/usr/share/doc/n-os-tr/README (edited)
iso/config/includes.chroot_before_packages/ (new, gitignored)
.gitignore (edited)
```
- A revert is a `git checkout` of those paths plus removal of
`iso/config/includes.chroot_before_packages/`.
---
## 9. Out of Scope (deferred to later slices)
- Persistent identity (ginxsom nsec, c-relay admin nsec, fips npub survive reboots).
- Gitea release fetch for artifacts (current plan is local-only).
- c-relay `relay_url` auto-configuration for NIP-42 (manual admin event for now).
- `/admin/` conflict resolution if ginxsom truly hardcodes that path.
- fips-gateway service (disabled; users can enable manually for LAN translation).
- Tor transport for fips (transport is available but not wired into the config).
- Installer variant (live-only for now).
- arm64 build.
- Signed / reproducible builds.
- Hardening tests: SELinux, AppArmor profiles.
---
## 10. References
- [`plans/iso_architecture.md`](./iso_architecture.md) — overall target design
- [`plans/network_boot.md`](./network_boot.md) — sibling plan, paused
- [`build.sh`](../build.sh) / [`lb-wrapper.sh`](../lb-wrapper.sh) — build entry
- [`iso/auto/config`](../iso/auto/config) — live-build options
- [`includes/fips/packaging/debian/build-deb.sh`](../includes/fips/packaging/debian/build-deb.sh)
- [`includes/fips/packaging/common/fips.yaml`](../includes/fips/packaging/common/fips.yaml)
- [`includes/fips/packaging/debian/fips.service`](../includes/fips/packaging/debian/fips.service)
- [`includes/c-relay/systemd/c-relay.service`](../includes/c-relay/systemd/c-relay.service)
- [`includes/c-relay/README.md`](../includes/c-relay/README.md)
- [`includes/c-relay/API.md`](../includes/c-relay/API.md)
- [Live-build manual — includes.chroot_before_packages](https://live-team.pages.debian.net/live-manual/html/live-manual/customizing-contents.en.html)

157
plans/threat_model.md Normal file
View File

@@ -0,0 +1,157 @@
# n-OS-tr Threat Model and Privacy Contract
> **Status:** Draft. This document is the authoritative definition of what privacy properties n-OS-tr promises its users. Every future feature, package, service, and configuration change **must** be checked against this document. If a feature would violate a guarantee listed here, the feature does not ship. If a subsystem cannot be implemented without violating a guarantee, the guarantee wins.
>
> Scope: this document covers the *core* of n-OS-tr — the boot flow, identity subsystem, config projection, and the ephemeral rootfs contract. It does not attempt to extend privacy guarantees to *arbitrary user-installed software* beyond what the OS can enforce at the boundary.
## 1. What n-OS-tr is trying to protect
The single-sentence product promise is:
> After you power off an n-OS-tr machine, it should be indistinguishable — to any reasonable examiner with access to the machine and its boot media afterwards — from a machine that has never been booted at all.
That sentence implies several concrete properties:
- **No persistent local identity.** The machine does not retain your npub, nsec, or any derived key after shutdown.
- **No persistent local state.** Dotfiles, preferences, history, caches, cookies, and credentials do not survive shutdown.
- **No persistent cross-boot fingerprint.** `machine-id`, MAC address, hostname, and similar identifiers do not carry information between boots.
- **No secondary leakage.** Logs, swap, crash dumps, and journald do not contain your identity or activity after shutdown.
- **Identity portability.** The only source of truth for "who you are" is the 12-word mnemonic you hold externally (in memory, on paper, etc.). Reconstructing your environment on any other n-OS-tr machine produces the same identity, keys, and configuration.
## 2. Attacker model
This document defines which adversaries n-OS-tr attempts to defend against. Attackers not listed here are explicitly out of scope.
### 2.1 Adversaries we defend against
| Adversary | Scenario | Defense |
|-------------------------------------------|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------|
| **Subsequent user of the same machine** | Someone boots the same laptop/PC after you and inspects local storage. | Root is tmpfs overlay. Nothing was written to the disk. |
| **Forensic analysis of the boot media** | Someone obtains the USB/SD after shutdown and mounts it read-write on their own machine. | Boot media is read-only during operation. Identity is not on it. |
| **Relay operator passively collecting events** | A relay operator sees all events you publish or fetch. | Private config is NIP-44-encrypted to yourself; only metadata is visible. |
| **Passive local network observer** | Someone watching the local Wi-Fi / Ethernet segment. | fips mesh + TLS for local services. Local relay preferred over remote. |
| **Hostile Wi-Fi / captive portal** | A network that tries to fingerprint, MITM, or redirect your traffic. | MAC randomization, no credential caching, DoH/DoT for upstream DNS. |
| **Accidental self-doxxing via shell history / logs** | You type your nsec into a terminal for debugging, or a service logs a key. | Volatile journald, `HISTFILE=/dev/null` by default, hardened stdout/stderr handling. |
| **Cross-machine correlation via fingerprints** | An observer tries to correlate two sessions on different machines as "the same user." | Fresh `machine-id`, fresh MAC, fresh hostname, no persistent UUIDs. |
### 2.2 Adversaries we do **not** defend against
These are listed explicitly so that we don't pretend. Users relying on n-OS-tr for privacy should understand these limits.
| Non-defense | Why not |
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| **Compromised hardware with a hypervisor, TPM attack, or firmware implant** | We cannot defend a user whose laptop has been physically modified or whose firmware runs hostile code. |
| **Adversary physically present while the machine is booted** | If RAM contents and display can be captured live, the session's keys and plaintext are readable. |
| **Screen/keylogger implanted on the laptop** | Out of scope. We cannot enforce the integrity of I/O peripherals. |
| **Correlation by content / writing style** | Posting the same text from two different keys leaks the linkage. Stylometric defenses are out of scope. |
| **Relay operator collusion across many relays** | If every relay you use colludes, metadata correlation (timing, sizes, IPs) is possible. |
| **RF side channels, TEMPEST, acoustic attacks** | Out of scope. |
| **Nation-state adversary with global passive capability** | We aim for "meaningfully private against reasonable adversaries," not "NSA-proof." |
| **User leaking their own mnemonic** | If the user writes the 12 words on a sticky note attached to the monitor, no technical defense helps. |
| **Browser/app fingerprinting performed by software the user runs** | We provide a clean environment; we do not intercept or rewrite traffic from arbitrary user apps. |
## 3. Trust boundaries
Every subsystem in n-OS-tr trusts specific inputs and distrusts others. Listing them explicitly helps avoid implicit trust creep.
| Subsystem | Trusts | Does not trust |
|------------------------|--------------------------------------------------------------------|---------------------------------------------------------------|
| **boot loader + kernel** | The ISO/IMG that was flashed. Verified at build time by hash. | Anything on removable media other than the boot media itself. |
| **privacy-hardener** | Its own unit ordering, sysctl config, hook scripts baked into image. | Anything read from the network during or after hardening. |
| **identity-agent** | Keyboard input from the user; an imported mnemonic file if explicitly selected. | Files on persistent media by default. Network input. Any other process except its own supervised children. |
| **config-loader** | Events signed by the user's main key at NIP-06 index 0. | Events from any other key. Relay operators. Event metadata is not assumed confidential. |
| **config-writer** | User-initiated file changes; explicit "publish on shutdown" opt-in. | Relay uptime; concurrent edits from other clients without version tags. |
| **shutdown-wiper** | Its own deterministic cleanup script. | External signals ("clean shutdown") that cannot be verified. |
| **user services** (fips, c-relay, ginxsom, nginx) | Keys derived by identity-agent at fixed indices. Local network namespace. | Each other. Run with minimum privileges, distinct users, and hardened systemd units. |
## 4. Forbidden behaviors
This is the acceptance-criteria list. Every release must satisfy all of these. Deviations require a documented exception in this file, not a silent change.
### 4.1 The mnemonic and derived keys
- **F1.** The 12-word mnemonic MUST NOT be written to any block-backed storage (ext4, FAT, SD/USB block device) at any time.
- **F2.** The mnemonic MUST NOT be written to swap. Swap MUST be disabled by default.
- **F3.** Derived private keys (indices 0..N) MUST reside only in the identity-agent's process memory. They MUST NOT be persisted to disk.
- **F4.** Memory holding the mnemonic or any derived private key MUST be `mlock()`-ed (or equivalent) so that it cannot be paged out.
- **F5.** The shell MUST NOT record commands to a file. `HISTFILE=/dev/null` is the default; no `~/.bash_history`, no `~/.zsh_history`.
- **F6.** No service and no library may log or echo a private key, even at debug levels. Tests in the CI pipeline grep the shipped image for `nsec1` / `priv` patterns in logs and fail the build if any are found. *(CI rule to add — not yet present.)*
- **F7.** On clean shutdown, identity-agent MUST zero the memory regions holding the mnemonic and derived keys.
### 4.2 On-disk state
- **F8.** The root filesystem MUST be an overlayfs with a `tmpfs` upper layer. Writes MUST NOT reach the boot media during normal operation.
- **F9.** `/var/log`, `/var/lib`, `/home`, `/tmp`, `/run` MUST be tmpfs-backed.
- **F10.** systemd-journald MUST be configured `Storage=volatile` with RAM cap. No on-disk journal persistence is permitted by default.
- **F11.** The image MUST NOT ship with `rsyslog` or any syslog daemon that writes to `/var/log/*.log`.
- **F12.** Bash/zsh histories, `.viminfo`, `.lesshst`, and similar per-user history files are suppressed or redirected to `/dev/null` via shell profile defaults.
- **F13.** APT, pip, npm, cargo caches are either disabled or tmpfs-backed; they MUST NOT persist to the boot media.
### 4.3 Cross-boot fingerprints
- **F14.** `/etc/machine-id` MUST be regenerated at boot (e.g., empty at image-build time, filled by `systemd-machine-id-setup` on first boot of each session). It MUST NOT be the same across two boots.
- **F15.** MAC addresses on all network interfaces MUST be randomized at each boot by default. Opt-out available per-interface via a user-signed config event, not via persistent local state.
- **F16.** Hostname is set to a randomized or generic default; no user-chosen hostname persists unless explicitly set via Nostr config (and therefore tied to the mnemonic, not the machine).
- **F17.** NetworkManager / `wpa_supplicant` MUST NOT cache Wi-Fi credentials, SSIDs, or PSKs to the boot media. Wi-Fi credentials, if stored at all, live on Nostr (NIP-44 encrypted) and are scoped to the user's identity, not the machine.
- **F18.** Bluetooth pairing keys (`/var/lib/bluetooth/*`) MUST NOT persist.
- **F19.** The Linux kernel random number generator SHOULD be seeded from hardware RNG plus `getrandom()` at boot. No `/var/lib/systemd/random-seed` on the boot media.
### 4.4 Networking
- **F20.** DNS resolution SHOULD default to DNS-over-TLS or DNS-over-HTTPS to prevent local operator snooping. Fallback to plain DNS only when explicitly permitted by user config.
- **F21.** No service SHOULD bind on 0.0.0.0 / `::` by default unless explicitly required (e.g., fips ethernet transport listens on 0.0.0.0 by design, relay admin does not).
- **F22.** There SHOULD be a clearly documented way to operate fully offline — with network disabled — while still loading cached Nostr config from a local c-relay or local file.
### 4.5 Shutdown
- **F23.** Shutdown MUST run a wiper hook that:
- Kills the identity-agent (triggering its own memory-zero routine),
- Flushes/zeros known sensitive memory ranges (e.g., `/run/ginxsom/*.sock`, `/run/fips/*`),
- Unmounts tmpfs filesystems before the journald shutdown message is emitted.
- **F24.** Shutdown MUST NOT write any new files to the boot media.
- **F25.** Emergency shutdown (power loss, crash) MUST still leave the boot media indistinguishable from its pre-boot state, because nothing was ever written during normal operation (F8).
### 4.6 Image build and reproducibility
- **F26.** The image build MUST be deterministic given a fixed commit hash of this repo and its submodules. Two builds from the same inputs produce byte-identical ISOs (modulo embedded timestamps, which are pinned via `SOURCE_DATE_EPOCH`).
- **F27.** No built-in user account may have a password. If a `live` user is created by live-build, it is configured for auto-login on TTY only, with NOPASSWD sudo explicitly scoped (or disabled entirely in favor of root TTY + identity-agent-gated sudo).
- **F28.** The image MUST NOT phone home at build time or at runtime for telemetry, update checks, or crash reports. Zero outbound connections initiated by the OS itself (as distinct from services explicitly started by the user).
- **F29.** The image MUST NOT ship SSH host keys. If SSH is enabled, host keys are generated at first boot and exist only in tmpfs; reboot yields fresh host keys.
- **F30.** Every n-OS-tr C binary we ship (identity-agent, TUI, config-loader, config-writer, smoketest helpers, anything we write ourselves) MUST be **C99, built inside Alpine Docker, statically linked against musl libc**. `ldd <binary>` on a shipped artifact MUST print "not a dynamic executable". Rationale: smaller runtime trust surface (no `.so` resolution at runtime), identical behavior across amd64 and arm64 flavors, and alignment with [`includes/nostr_core_lib`](../includes/nostr_core_lib/) / [`includes/ginxsom`](../includes/ginxsom/) / [`includes/c-relay`](../includes/c-relay/). See [`plans/tui_login.md §0.1`](tui_login.md#01-toolchain-alpine--musl-dev-built-in-docker).
- **F31.** No Python, Node.js, or other interpreter runtime may be installed in the image on the critical boot path (identity entry, config projection, privacy-hardener). Interpreters MAY be available as user-installed software after a session is live, but the OS itself boots without them.
## 5. Shutdown contract
On `systemctl poweroff`, the following MUST hold before the kernel halts:
| Invariant | Enforcement |
|--------------------------------------------------------------------|-------------------------------------------------|
| No plaintext mnemonic in any mapped memory page | identity-agent zero-on-exit + shutdown-wiper |
| No derived private key in any mapped memory page | identity-agent zero-on-exit |
| No writes to the boot media since boot | Read-only mount + overlayfs topology (F8) |
| No journal entries written to disk | `Storage=volatile` (F10) |
| No residual Unix sockets with auth material in `/run/*` | shutdown-wiper removes + zeroes |
| No dumpable core files, no crash reports queued | `ulimit -c 0` default + no `systemd-coredump` on disk |
| No Wi-Fi state files in NM profiles / `/etc/NetworkManager` | Verified by CI grep (F17) |
## 6. Out-of-scope but worth documenting
- **The mnemonic input channel.** On the amd64 ISO, the user types the phrase into the keyboard attached to the machine. A keylogger on that machine defeats this. On the Pi Zero 2 W flavor, entry may be via the Waveshare LCD HAT buttons, reducing (not eliminating) shoulder-surfing risk. Neither is a substitute for physical security.
- **Randomness quality.** If a machine has no hardware RNG and a broken `/dev/urandom` seed, BIP-39 *generation* (not derivation) could produce weak mnemonics. Recommendation: use `nostr-id generate --dice` or an external hardware source when generating new identities on machines you don't fully trust.
- **Software supply chain.** We inherit Debian's package supply chain. A compromised upstream package compromises the image. Reproducible builds (F26) partially mitigate this but do not eliminate it.
## 7. What this document is **not**
- Not a design doc for *how* each property is enforced. See [`plans/identity_subsystem.md`](identity_subsystem.md), [`plans/nostr_config_projection.md`](nostr_config_projection.md), and future `plans/privacy_hardener.md` for mechanism-level detail.
- Not a conformance checklist for applications the user installs themselves. A user running Firefox can still be fingerprinted by Firefox — that's Firefox's problem, not the OS's.
- Not a promise that the current Phase 2 Slice B+C ISO already satisfies everything here. Most of these guarantees become real in **Phase 3** (identity + config + hardener). Phase 2 is the plumbing; Phase 3 is the promise.
## 8. Revision policy
Any PR that weakens a guarantee (removes an F-item, adds an exception, broadens an allowed behavior) must:
1. Update this document to reflect the new guarantee.
2. Update the corresponding smoke-test check, so behavior drift is caught by CI.
3. Include an explicit rationale in the PR description.
Any PR that strengthens a guarantee is always welcome and should update this document to match.

508
plans/tui_login.md Normal file
View File

@@ -0,0 +1,508 @@
# TUI Login — Boot-Time Identity Entry
> **Status:** Design. This document specifies the n-OS-tr boot-time TUI (`nostr-id-tui`): a pure-C99 ncurses program that owns `tty1` before any login prompt, lets the user generate/enter/import a 12-word BIP-39 mnemonic, hands the mnemonic to the identity-agent, and exits.
>
> Depends on: [`plans/identity_subsystem.md`](identity_subsystem.md) (the daemon the TUI talks to), [`plans/threat_model.md`](threat_model.md) (memory-hygiene constraints F1F7), [`includes/nostr_core_lib`](../includes/nostr_core_lib/) (all Nostr crypto).
## 0. Rules the doc follows (and everything in this repo follows)
1. **C99 only.** `-std=c99 -Wall -Wextra -Wpedantic`. No Python, no Node.js at runtime, no Rust, no C++. Same rule as [`nostr_core_lib`](../includes/nostr_core_lib/). This keeps the image small, static, reproducible, and portable to Pi Zero class hardware.
2. **Nostr crypto and BIP-39 primitives come from `nostr_core_lib`.** If we need a new primitive and it's reusable outside n-OS-tr, we **upstream it into `nostr_core_lib`**, not add it here.
3. **Policy, systemd wiring, and product-specific UX live in this repo.** Role table, screen flow, service units, live-build hooks — all here.
4. **No on-disk state** ever, per [threat_model.md F1F13](threat_model.md#4-forbidden-behaviors).
5. **Statically linked against musl libc.** Same rule as [`includes/nostr_core_lib`](../includes/nostr_core_lib/). See §0.1 for the toolchain and rationale. Output is one fully static ELF with no runtime `.so` dependencies — not glibc, not `libncurses.so`, not `libssl.so`. The binary runs on any Linux kernel of compatible architecture without caring about the host's userspace.
## 0.1 Toolchain: Alpine + musl-dev, built in Docker
Every C99 binary we produce — `nostr-id-tui`, the identity-agent, `nostr-id` CLI, config-loader, config-writer — follows the same build pattern that [`includes/ginxsom`](../includes/ginxsom/) and [`includes/c-relay`](../includes/c-relay/) already use:
- **Build inside Alpine Linux** ([`alpine:3.19`](https://alpinelinux.org/)) via a Dockerfile named `Dockerfile.alpine-musl`.
- **Install dependencies as musl-dev packages** via `apk add`, using the `-static` variants where required (e.g. `ncurses-static`, `openssl-libs-static`, `zlib-static`).
- **Compile with `gcc -std=c99 -static`** against [`libnostr_core.a`](../includes/nostr_core_lib/).
- **Output is `<name>_static_<arch>`** — the same naming convention as [`c_relay_static_x86_64`](../includes/c_relay_static_x86_64), `ginxsom-fcgi_static_x86_64`, `ginxsom-fcgi_static_arm64` (see [`includes/ginxsom/build_static.sh:66`](../includes/ginxsom/build_static.sh:66)).
- **Wrapper script `build_static.sh`** in each subtree drives the Docker build, handles `uname -m``linux/amd64` | `linux/arm64` platform detection, and extracts the binary into `build/`.
Why this matters, beyond matching what already works:
- **Zero glibc entanglement.** We never ask "which Debian version, which glibc?" The binary is self-contained.
- **Same binary runs on amd64 ISO and on Pi Zero 2 W arm64.** We build both flavors with `docker build --platform linux/arm64 ...` and get functionally identical behavior, which is essential for the "one product, two image flavors" story.
- **No version drift with [`nostr_core_lib`](../includes/nostr_core_lib/).** Building in the same Alpine container the library itself is tested against eliminates an entire class of "worked on my machine" bugs.
- **Threat-model alignment.** A dynamically linked binary's runtime behavior depends on whatever `.so` files happen to be on the host when it runs. A static musl binary doesn't. That's a smaller trust surface, which matches [`threat_model.md §4.6 F26`](threat_model.md#46-image-build-and-reproducibility) "deterministic build."
### 0.1.1 Concrete toolchain recipe
```dockerfile
# Dockerfile.alpine-musl for any n-OS-tr C binary
FROM alpine:3.19 AS builder
RUN apk add --no-cache \
build-base \
musl-dev \
git \
cmake \
autoconf \
automake \
libtool \
pkgconf \
# nostr_core_lib deps
openssl-dev openssl-libs-static \
zlib-dev zlib-static \
curl-dev curl-static \
libsecp256k1-dev \
# tui-specific
ncurses-dev ncurses-static
WORKDIR /src
COPY . /src
# Build libnostr_core.a (via its own build.sh)
RUN cd includes/nostr_core_lib && ./build.sh x64
# Build the TUI against it
RUN gcc -std=c99 -Wall -Wextra -Wpedantic -static \
-I includes/nostr_core_lib/nostr_core \
-I includes/nostr_core_lib/cjson \
stack/nostr-id-tui/src/*.c \
includes/nostr_core_lib/libnostr_core_x64.a \
-lncurses -ltinfo -lssl -lcrypto -lcurl -lsecp256k1 -lz -lm \
-o /out/nostr-id-tui_static_x86_64
```
### 0.1.2 ncurses terminfo: pick a lane in v1
Even a statically linked ncurses binary loads terminfo entries from disk at runtime (`/usr/share/terminfo/l/linux`, etc.). Two options for v1:
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| **Ship `ncurses-term` in the ISO.** Add to [`base.list.chroot`](../iso/config/package-lists/base.list.chroot). | Simplest. ~2 MB. Works with every `TERM` value. | Terminfo files on disk. | **v1 default.** |
| **Compile in fallback entries.** Use `ncurses --enable-termcap --with-fallbacks=linux,vt102,vt220` so those entries are baked into the static binary. | Binary is fully self-contained. | Needs ncurses rebuild + more build-time config. | Size-optimization pass for later. |
For v1 we go Option 1; it's compatible, ships in days, and the size cost is negligible. Revisit Option 2 if we ever want truly zero-filesystem-read boot behavior.
## 1. What the TUI does
On a clean boot of an n-OS-tr machine, after the privacy-hardener has run but before any login prompt is displayed:
1. Take over `tty1`.
2. Present four options: **Enter 12 words**, **Generate new identity**, **Import from removable media**, **Connect to a remote signer (NIP-46 bunker)**.
3. Drive the chosen flow to completion.
4. Hand the resulting mnemonic (or the bunker URL) to `identity-agent` via a local Unix socket.
5. Exit cleanly, allowing systemd to start `getty@tty1` with the identity already loaded.
After the TUI exits, all downstream services (c-relay, fips, ginxsom, config-loader) come up against a deterministic identity derived from the user's mnemonic. See [`identity_subsystem.md` §3](identity_subsystem.md#3-boot-time-ux) for the broader flow diagram.
## 2. Ownership and placement
### 2.1 Code that lives in `nostr_core_lib` (upstream additions)
The TUI design requires three small, genuinely generic utility modules. These are **new upstream contributions to [`nostr_core_lib`](../includes/nostr_core_lib/)**. They are not n-OS-tr-specific; any C Nostr app that needs to handle a mnemonic or a private key will want them.
#### 2.1.1 `nostr_core/nostr_secure_memory.h`
Memory-locked, zero-on-drop byte buffers for secrets.
```c
/* Locked byte buffer. Contents are mlock()-ed; destroy() explicit_bzero()'s
* before unlocking. Use for mnemonics, private keys, decrypted plaintext. */
typedef struct nostr_secure_buf {
uint8_t *bytes;
size_t len;
size_t cap; /* rounded up to a page for mlock */
} nostr_secure_buf_t;
int nostr_secure_buf_init(nostr_secure_buf_t *buf, size_t cap); /* mmap + mlock */
void nostr_secure_buf_destroy(nostr_secure_buf_t *buf); /* bzero + munlock + munmap */
int nostr_secure_buf_append(nostr_secure_buf_t *buf, const void *src, size_t n);
void nostr_secure_buf_reset(nostr_secure_buf_t *buf); /* bzero, keep cap */
/* Also: process-level hardening helpers */
int nostr_disable_core_dumps(void); /* prctl(PR_SET_DUMPABLE, 0) + RLIMIT_CORE=0 */
int nostr_lock_all_memory(void); /* mlockall(MCL_CURRENT|MCL_FUTURE) */
```
Implementation uses `mlock(2)` + `explicit_bzero(3)` on Linux, and a documented best-effort fallback on macOS. `nostr_core_lib` already runs as C99 on both platforms; this fits cleanly.
#### 2.1.2 `nostr_core/nostr_bip39_ui.h`
Headless helpers for building any mnemonic entry UI (ncurses, GTK, web, LCD — anything).
```c
/* Given a typed prefix (lowercase, 1-8 chars), fill `out[]` with up to
* `max` candidate word pointers from the BIP-39 English wordlist. Returns
* the number filled. */
int nostr_bip39_prefix_candidates(const char *prefix,
const char **out,
int max);
/* Quick validity check — returns 1 if `word` is in the BIP-39 English list. */
int nostr_bip39_is_word(const char *word);
/* Validate a full mnemonic's checksum. Returns NOSTR_SUCCESS iff the
* mnemonic parses and its checksum is correct. Does NOT derive any keys. */
int nostr_bip39_validate(const char *mnemonic_space_separated);
/* For the "confirm you wrote it down" flow:
* Given a mnemonic and a count (e.g., 3), fill `idx_out[]` with that
* many distinct random word positions (0..11 for a 12-word mnemonic),
* and `word_out[]` with pointers into the mnemonic for those positions.
* Caller provides the RNG as a simple callback for determinism in tests. */
typedef int (*nostr_rand_u32_fn)(void *user, uint32_t *out);
int nostr_bip39_random_confirm_positions(const char *mnemonic,
int count,
int *idx_out,
const char *(*word_out)[],
nostr_rand_u32_fn rng,
void *rng_user);
```
The English wordlist is already present inside `nostr_core_lib`'s NIP-06 implementation; we just expose it.
#### 2.1.3 `nostr_core/nostr_fips_ipv6.h`
Single utility function so every component that wants to know "what's the fips IPv6 for this pubkey" uses the same canonical computation.
```c
/* Compute the fips mesh IPv6 address for a 32-byte Nostr pubkey.
* Output buffer must be at least 40 bytes (colon-hex ipv6 fits in 39+NUL).
* Matches tools.html pubkeyHexToFipsIpv6() exactly. */
int nostr_fips_ipv6_from_pubkey(const uint8_t pubkey[32],
char *out,
size_t out_len);
```
### 2.2 Code that lives in this repo (`n_os_tr`)
```
stack/nostr-id-tui/ # new subtree
├── CMakeLists.txt # C99 build, links libnostr_core.a + libncurses.a
├── include/
│ └── n_os_tr_tui.h # internal header
├── src/
│ ├── main.c # entry point, arg parsing, mode dispatch
│ ├── screen_menu.c # main menu (enter / generate / import / bunker)
│ ├── screen_enter.c # 12-word entry with per-word autocomplete
│ ├── screen_generate.c # generate + display + 3-position confirm
│ ├── screen_import.c # /media/*/mnemonic.txt discovery + wipe
│ ├── screen_bunker.c # NIP-46 bunker URL entry
│ ├── screen_error.c # fatal error screen
│ ├── curses_helpers.c # small ncurses wrappers (input w/o history)
│ ├── agent_client.c # speak nostr-id control socket (see §5)
│ └── memhygiene.c # thin wrappers over nostr_secure_memory
└── tests/
├── test_screen_enter.c # feeds canned keystrokes, asserts transitions
├── test_screen_generate.c
├── test_agent_client.c # mock control-socket server
└── test_integration.sh # qemu serial-console scripted boot
```
Also in this repo:
- `iso/config/includes.chroot/etc/systemd/system/nostr-id-tui.service` — the unit that owns `tty1`.
- `iso/config/includes.chroot/etc/systemd/system/getty@tty1.service.d/nostr-id-tui-ordering.conf` — delays getty until TUI exits.
- `iso/config/hooks/live/0030-mask-getty-tty1-until-identity.hook.chroot` — masks `getty@tty1` during TUI flow.
- Build-system glue in `build.sh` to stage the compiled binary into the chroot.
## 3. Screen flow
### 3.1 State diagram
```mermaid
stateDiagram-v2
[*] --> Menu
Menu --> Enter: 1 / E
Menu --> Generate: 2 / G
Menu --> Import: 3 / I
Menu --> Bunker: 4 / B
Menu --> Amnesia: 5 / A (advanced)
Enter --> EnterValid: BIP-39 checksum ok
Enter --> Enter: checksum fail, clear buffer, retry
EnterValid --> SendAgent
Generate --> ShowWords
ShowWords --> ConfirmPositions: user presses Continue
ConfirmPositions --> SendAgent: user correctly retypes 3 words
ConfirmPositions --> ShowWords: wrong, re-display
Import --> ImportConfirm: file found
Import --> Menu: no file found (error)
ImportConfirm --> WipeSource: user confirms
WipeSource --> SendAgent
Bunker --> BunkerConnect: URL parsed
BunkerConnect --> SendAgent: NIP-46 connect ok
BunkerConnect --> Bunker: connect failed, retry
Amnesia --> SendAgentAmnesia
SendAgent --> Exit: agent returns ok
SendAgent --> FatalError: agent returns error
SendAgentAmnesia --> Exit
FatalError --> [*]
Exit --> [*]
```
### 3.2 Each screen in detail
**Menu.** Four options (plus an advanced amnesia mode behind a keystroke), plus version/build info and a "Ctrl-L to redraw" hint. Keyboard-only; mouse not required.
**Enter.** A grid of 12 slots (e.g. 4 rows × 3 cols). The cursor sits on slot 1. The user types a prefix; autocomplete offers candidate words below (using [`nostr_bip39_prefix_candidates`](../includes/nostr_core_lib/nostr_core/)). Tab completes to the longest common prefix or the single candidate. Space or Enter commits the current slot and moves to the next. Backspace edits the current slot. When all 12 slots are filled, `Ctrl-Enter` (or menu → confirm) validates via [`nostr_bip39_validate`](../includes/nostr_core_lib/nostr_core/). Invalid checksum → clear all 12 slots and show "checksum invalid, try again".
**Generate.** Calls [`nostr_generate_mnemonic_and_keys`](../includes/nostr_core_lib/nostr_core/nip006.h:21). Shows all 12 words in a 3-row × 4-column grid, numbered 1..12. Big red banner: "WRITE THESE WORDS DOWN NOW. They will not be shown again." User presses Continue.
**Confirm positions.** Calls [`nostr_bip39_random_confirm_positions`](../includes/nostr_core_lib/nostr_core/) to pick 3 random positions. Asks the user to retype the word at each position. Any mismatch resets to the generate screen (they clearly didn't record it). All three correct → commit.
**Import.** Scans `/media/*/mnemonic.txt`. If a single candidate is found, prompts: "Mnemonic file found at `<path>`. Accept and securely wipe the file? (y/N)". User confirms → read the file into a secure buffer → validate via [`nostr_bip39_validate`](../includes/nostr_core_lib/nostr_core/) → open the file, zero it, truncate, `fsync`, unlink, `fsync` the dirfd → commit.
**Bunker.** A single text field for a `bunker://` URL (NIP-46). On submit, parse via [`nostr_nip46_parse_bunker_url`](../includes/nostr_core_lib/nostr_core/nip046.h:101) and attempt [`nostr_nip46_client_session_init`](../includes/nostr_core_lib/nostr_core/nip046.h:145) + [`nostr_nip46_client_ping`](../includes/nostr_core_lib/nostr_core/nip046.h:156). On success, send the session to the agent; on failure, show the error and return to the bunker URL input.
**Amnesia.** No persistence, no Nostr config projection, ephemeral main key generated via [`nostr_generate_keypair`](../includes/nostr_core_lib/nostr_core/nip006.h:20). Big red banner: "AMNESIA MODE. This identity dies at shutdown."
**FatalError.** A single full-screen error. No recovery — require the user to reboot. This is only reached if ncurses init failed, the agent control socket is unavailable, or memory locking failed (which means the threat-model invariants cannot be enforced).
### 3.3 Anti-shoulder-surf tradeoffs
- During **Enter** flow, the current word being typed is visible (for usability). Previously committed words display as `****` — the full 12 are never visible together.
- During **Generate**, all 12 words are visible simultaneously because the user must copy them down. This is the inverse tradeoff and we accept it.
- During **Confirm**, only three words are ever on screen. Retyped words are not echoed with autocomplete (the user must actually know their phrase).
## 4. Memory hygiene
All bullet points trace back to [threat_model.md §4.1](threat_model.md#41-the-mnemonic-and-derived-keys).
| Invariant | Implementation |
|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------|
| No disk storage of the mnemonic | All buffers are `nostr_secure_buf_t` (§2.1.1). Nothing goes to `stdio.h` file streams. |
| No swap | Process calls `nostr_lock_all_memory()` at startup. Image has swap disabled image-wide anyway (F2). |
| No core dump | `nostr_disable_core_dumps()` at startup. |
| Shell history suppression | TUI is exec'd by systemd, not a shell. Not applicable here. |
| No ncurses internal leaks | Instead of `wgetstr()`/`wgetnstr()`, use `wgetch()` in a loop into our own `nostr_secure_buf_t`. Never pass the mnemonic into any ncurses buffer. |
| `TIOCSTI` history / paste bracketing | Disable line-mode: `cbreak()` + `noecho()` set once on startup. |
| No dumped signals | Install `SIGQUIT` / `SIGBUS` / `SIGSEGV` handlers that zero all secure buffers before `_exit()`. |
| Exit wipe | `atexit(3)` handler zeros secure buffers and calls `endwin()` cleanly. |
| Zero on success path too | After handing the mnemonic to the agent, the TUI's own copy is zeroed before `exit(0)`. |
## 5. Control socket protocol
The TUI talks to `identity-agent` over a single Unix domain socket:
```
/run/nostr-id/control.sock
```
Same socket path as the rest of `nostr-id` (see [identity_subsystem.md §5.1](identity_subsystem.md#51-cli-nostr-id)).
Wire format: one JSON object per request followed by one JSON object response, terminated by `\n`. The JSON doc is small enough that we can use a tiny hand-written parser or [`cjson`](../includes/nostr_core_lib/cjson/) which is already vendored in `nostr_core_lib`.
### 5.1 Methods used by the TUI
**`load_mnemonic`** — primary.
```json
// request
{"op":"load_mnemonic","mnemonic":"<12 words space-separated>"}
// response
{"ok":true,"main_npub":"npub1..."}
// or
{"ok":false,"error":"checksum_invalid"}
```
The agent validates, derives all declared role keys, holds them in `mlock`-ed memory, and returns the main npub for display. The mnemonic string is zeroed in the wire buffer after the agent acks.
**`load_bunker`** — external signer flow.
```json
{"op":"load_bunker","url":"bunker://<pubkey>?relay=...&secret=..."}
{"ok":true,"main_npub":"npub1..."}
```
**`load_amnesia`** — ephemeral mode.
```json
{"op":"load_amnesia"}
{"ok":true,"main_npub":"npub1..."}
```
**`status`** — TUI health check, safe to call from anyone.
```json
{"op":"status"}
{"state":"awaiting_identity"|"loaded"|"shutdown"}
```
### 5.2 Authentication
The socket lives at `/run/nostr-id/control.sock` mode `0660`, owned by `root:nostr-signer`. The TUI process runs as the agent's dedicated service user (`nostr-id`) or as root on tty1 and is a member of `nostr-signer`. Any other process that wants to talk to the agent must be added to that group — governed per [identity_subsystem.md §4.3](identity_subsystem.md#43-processes-and-isolation).
No further auth: any process with socket access can load a mnemonic. The TUI lives on `tty1` before login; the only "process" that gets to speak on the socket before the user has typed a mnemonic is the TUI itself.
## 6. systemd integration
Three files land in the ISO chroot.
### 6.1 `etc/systemd/system/nostr-id-tui.service`
```ini
[Unit]
Description=n-OS-tr boot-time identity TUI
Documentation=file:///usr/share/doc/n-os-tr/README
After=nostr-id.service systemd-user-sessions.service
Wants=nostr-id.service
Before=getty@tty1.service
ConditionPathExists=!/var/lib/n-os-tr/.identity-loaded
[Service]
Type=oneshot
RemainAfterExit=no
ExecStart=/usr/local/sbin/nostr-id-tui
StandardInput=tty
StandardOutput=tty
StandardError=journal
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes
KillMode=process
TimeoutStartSec=infinity
# memory hygiene (mirrors the binary's runtime asserts)
LockPersonality=yes
NoNewPrivileges=yes
MemoryDenyWriteExecute=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_UNIX
SystemCallArchitectures=native
CapabilityBoundingSet=CAP_IPC_LOCK
AmbientCapabilities=CAP_IPC_LOCK
[Install]
WantedBy=multi-user.target
```
### 6.2 `etc/systemd/system/getty@tty1.service.d/nostr-id-tui-ordering.conf`
```ini
[Unit]
# getty on tty1 must not race the identity TUI
After=nostr-id-tui.service
Requires=nostr-id-tui.service
```
### 6.3 `etc/systemd/system/nostr-id-tui.service.d/zz-override.conf`
For the Pi LCD variant (shipped only in the Pi image), this drop-in overrides `ExecStart` to launch a different binary that uses the LCD-button backend. Same control-socket protocol, different screen layer.
### 6.4 Flow
```mermaid
sequenceDiagram
participant SD as systemd
participant IA as nostr-id.service
participant TUI as nostr-id-tui.service
participant GT as getty@tty1.service
SD->>IA: start
IA-->>SD: active (listening on control.sock)
SD->>TUI: start (owns tty1)
Note over TUI: user interacts
TUI->>IA: load_mnemonic / load_bunker / load_amnesia
IA-->>TUI: ok
TUI->>SD: exit 0
SD->>GT: start (now allowed by ordering)
GT->>User: "n-os-tr login:"
```
## 7. Per-flavor abstraction
The TUI's screen logic is separated from its rendering layer so that the same state machines can drive different front-ends:
- `screen_*.c` files contain **state machines only**: given an input event, what's the next state?
- An `ui_backend_t` interface abstracts drawing: `draw_menu(choices)`, `draw_text_grid(words)`, `read_input()`, `redraw()`, etc.
- For amd64 we implement `ui_backend_curses.c` using ncurses.
- For the Pi flavor, a future `ui_backend_lcd.c` implements the same interface over the [Waveshare 1.3" LCD HAT](../../raspberry_pi_zero_nostr/plans/waveshare_1.3inch_lcd_hat.md). Same state machines, same tests — different backend.
This is the single biggest design choice of the TUI: **no screen file may call ncurses directly.** All rendering goes through the backend interface. This is why the Pi LCD port later will be a pure rendering change, not a logic rewrite.
## 8. Test plan
### 8.1 Unit (C, built alongside the binary)
- `test_screen_enter` — feeds a scripted sequence of keystrokes through the enter state machine with a mock backend; asserts the final mnemonic buffer state and agent-client invocation.
- `test_screen_generate` — with a deterministic RNG (via `nostr_rand_u32_fn` callback), asserts that the generated mnemonic is valid per [`nostr_bip39_validate`](../includes/nostr_core_lib/nostr_core/) and that the confirm-positions flow accepts the correct three words and rejects wrong ones.
- `test_agent_client` — runs a local mock control-socket server; asserts request/response formatting, timeout, error propagation.
### 8.2 Integration (VM, scripted)
`tests/test_integration.sh` drives a QEMU VM via serial console:
1. Build the ISO with a baked-in test mode flag that replaces `tty1` input with a scripted pty.
2. Boot the ISO in QEMU with `-nographic`.
3. Script feeds a canned 12-word mnemonic via expect.
4. Assert the TUI exits, the identity-agent reports the expected main npub over the control socket, and the smoke test reports all-green.
Test mnemonic used across the repo: a single well-known BIP-39 test vector (e.g. the Trezor test vector "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") whose derived keys are fixed and known. The smoke test hardcodes the expected npub for each of our declared roles under this mnemonic.
### 8.3 Non-goals
- We do not test ncurses itself. We test our backend interface and the state machines through a mock backend.
- We do not test the LCD backend until the Pi flavor ships. The interface guarantees that porting the tests is cheap.
## 9. Failure modes (summary)
| Failure | Behavior |
|---------------------------------------------|-----------------------------------------------------------------------------------------|
| Can't `mlockall` | Exit with FatalError. Threat-model invariants cannot be enforced. |
| ncurses init fails / bad `TERM` | Exit with FatalError on stderr; systemd keeps TUI inactive and tty1 remains claimed. |
| Agent control socket not present | Retry every 200 ms for 10 s; then FatalError. |
| Agent reports `checksum_invalid` | Clear Enter state, return to Enter screen with an error line. |
| User mistypes 3 confirm words | Return to generate screen (they haven't recorded it); do not retry with same words. |
| Bunker URL unreachable | Error line, return to bunker screen. |
| User mashes Ctrl-C | Ignore; the TUI is the only user-facing input path, and aborting means no identity. |
| Kernel signals (SIGTERM during shutdown) | Signal handler zeros buffers, calls `endwin()`, exits. |
## 10. Packaging and build
- **Build environment.** Alpine 3.19 + `musl-dev` inside Docker. See §0.1 for the toolchain recipe and the `Dockerfile.alpine-musl` template.
- **Source tree.** `stack/nostr-id-tui/` with:
- `Dockerfile.alpine-musl` — the actual builder image.
- `build_static.sh` — wrapper script that runs `docker build --platform linux/amd64|linux/arm64`, extracts the binary, and places it under `build/`.
- `CMakeLists.txt` (*optional*, used inside the Alpine container) — C99 targets, links [`libnostr_core.a`](../includes/nostr_core_lib/), pulls `libncurses.a`/`libtinfo.a` (static) and `libsecp256k1.a`/`libssl.a`/`libcrypto.a`/`libcurl.a`/`libz.a` from Alpine's `-static` packages.
- **Outputs.** `stack/nostr-id-tui/build/nostr-id-tui_static_x86_64` and `_static_arm64`. Fully static ELFs; `ldd` reports "not a dynamic executable."
- **Runtime ISO deps.**
- `ncurses-term` added to [`iso/config/package-lists/base.list.chroot`](../iso/config/package-lists/base.list.chroot) so terminfo files are on the image.
- No other runtime deps (no `libc.so`, no `libncurses.so`).
- **ISO integration.** [`build.sh`](../build.sh) gains a step that runs `stack/nostr-id-tui/build_static.sh` for the target arch, then stages the output binary into `iso/config/includes.chroot_before_packages/live-artifacts/nostr-id-tui`, following the same pattern as the [fips deb staging](slice_bc_fips_crelay.md#47-updated-buildsh). A [`hook.chroot`](../iso/config/hooks/live/) installs it to `/usr/local/sbin/nostr-id-tui` with mode 0755 and owner `root:nostr-signer`.
- **Reproducibility.** The Dockerfile pins `alpine:3.19` (a content-addressed tag). Given a fixed repo commit and a fixed base image digest, byte-identical output is expected.
## 11. What this doc does NOT cover
- The identity-agent daemon internals — see [`identity_subsystem.md`](identity_subsystem.md). This doc treats the agent as a black box reachable over `/run/nostr-id/control.sock`.
- The role → index schedule — see [`identity_subsystem.md §2.1`](identity_subsystem.md#21-the-role-table-schedule).
- Config projection after identity is loaded — see [`nostr_config_projection.md`](nostr_config_projection.md).
- GUI variants — out of scope until we decide on a GUI at all. The `ui_backend_t` abstraction leaves the door open for a GUI backend later, but no GUI is planned for v1.
## 12. Open questions
- **Should the TUI support serial console in v1?** A serial `TERM=vt100` works for ncurses but weakens the "physical keyboard only" stance. Suggest: off by default; opt-in via kernel cmdline for specialized deployments.
- **Where does `TERM` default come from?** systemd's `TTYPath=/dev/tty1` service sets `TERM=linux`; for serial we need `TERM=vt220`. Keep this in the service file, not the binary.
- **Do we want a "word list display" in the Generate screen that includes the index and checksum-friendly prefixes alongside the word?** Nice to have for users copying to paper. Leaves more on screen at once. Decide during UX polish.
- **Is 15/18/21/24-word BIP-39 input supported?** The [`identity_subsystem.md` open questions](identity_subsystem.md#9-open-questions) say 12 is default; longer phrases opt-in. TUI should accept all lengths if the user types more slots. Slot grid becomes a dynamic 3/4/5/6/8-row table accordingly. v1 can ship with 12-only and add more later.
## 13. Implementation order (Phase 3a.1)
Proposed checklist — small steps, each independently verifiable. Note the toolchain work lands first so everything built on top is musl-static from the beginning.
1. **Add the Alpine/musl builder for n-OS-tr C binaries.** Copy [`includes/ginxsom/Dockerfile.alpine-musl`](../includes/ginxsom/Dockerfile.alpine-musl) + [`includes/ginxsom/build_static.sh`](../includes/ginxsom/build_static.sh) as the template and land `stack/build-common/Dockerfile.alpine-musl` + `stack/build-common/build_static.sh` for reuse across nostr-id-tui, identity-agent, nostr-id CLI, config-loader, config-writer.
2. **Upstream `nostr_secure_memory` module into [`nostr_core_lib`](../includes/nostr_core_lib/).** Header + implementation + unit test. Verify it compiles cleanly in the Alpine/musl builder.
3. **Upstream `nostr_bip39_ui` module into [`nostr_core_lib`](../includes/nostr_core_lib/).** Header + implementation (using the existing wordlist) + unit test.
4. **Upstream `nostr_fips_ipv6` utility into [`nostr_core_lib`](../includes/nostr_core_lib/).** Header + implementation + unit test (matching [`tools.html`](../../client/www/tools.html:750)).
5. **Define the `ui_backend_t` interface in `stack/nostr-id-tui/include/`.**
6. **Implement state machines** (`screen_menu.c`, `screen_enter.c`, `screen_generate.c`). Run unit tests in the Alpine/musl builder with a mock backend before any ncurses work.
7. **Implement `ui_backend_curses.c`.** First time we pull `ncurses-dev`/`ncurses-static` into the Dockerfile.
8. **Implement `agent_client.c`** — Unix socket + cjson wire.
9. **Tie it together in `main.c`, bundle signal handlers, atexit wipes.** First successful build of a static `nostr-id-tui_static_x86_64` should happen here.
10. **Write the three systemd units + live-build hook.** Verify the staged binary lands at `/usr/local/sbin/nostr-id-tui` in the chroot.
11. **ISO build integration.** `build.sh` invokes `stack/nostr-id-tui/build_static.sh` and stages the resulting binary. Boot the ISO in QEMU; confirm the TUI comes up on `tty1`.
12. **VM integration test.** Script a QEMU serial-console boot that feeds the Trezor test vector mnemonic; assert the identity-agent reports the matching main npub.
13. **Smoketest extension.** Add TUI-related checks to [`n-os-tr-smoketest`](../iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketest) (binary is static, `ldd` says "not a dynamic executable", terminfo entries present).
14. **arm64 cross-build.** Add `docker build --platform linux/arm64 ...` to `build_static.sh` so the same source produces `nostr-id-tui_static_arm64` for the Pi flavor. Binary itself shouldn't require any changes — that's the point of the musl-static discipline.