27 KiB
⚠️ SUPERSEDED. The daemon design in §4 of this document is replaced by
plans/n_signer_integration.md. The role schedule in §2.1 remains canonical. See the integration plan for the current architecture.
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.mdfor the privacy contract this must satisfy, andplans/nostr_config_projection.mdfor 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.
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. 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, 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 |
n/a |
| BIP-39 mnemonic generation + NIP-06 derivation | nostr_generate_mnemonic_and_keys |
NIP-06 |
| NIP-06 derivation from existing mnemonic | nostr_derive_keys_from_mnemonic |
NIP-06 |
| Input type detection (hex vs nsec vs mnemonic) | nostr_detect_input_type |
n/a |
| bech32 encoding/decoding (nsec/npub) | nostr_key_to_bech32, nostr_decode_nsec |
NIP-19 |
| Event creation + signing | nostr_create_and_sign_event |
NIP-01 |
| NIP-44 encrypt / decrypt (for self-encrypted config) | nostr_nip44_encrypt, nostr_nip44_decrypt |
NIP-44 |
| NIP-46 signer session (serve requests) | nostr_nip46_signer_session_init, nostr_nip46_signer_handle_request |
NIP-46 |
| NIP-46 bunker URL publishing (external signer inbound path) | nostr_nip46_signer_create_bunker_url |
NIP-46 |
| NIP-46 client session (consume remote bunker) | nostr_nip46_client_session_init, nostr_nip46_client_sign_event |
NIP-46 |
| Relay pool + NIP-42 auth for signer relay | nostr_relay_pool_create, nostr_relay_pool_set_auth |
NIP-42 |
| Library-wide log callback (no on-disk logs) | nostr_set_log_callback |
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. 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), where account is the role index. This matches both the nostr-tools JavaScript reference implementation in /home/user/lt/client/www/tools.html (line 944) and the standard NIP-06 specification.
The derivation path is:
m / 44' / 1237' / index' / 0 / 0
where:
44'— BIP-44 purpose1237'— SLIP-0044 coin type for Nostrindex'— 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 |
identity/master |
User's primary Nostr identity. Profile, posts, social graph, config sig. | Immutable. |
1 |
c-relay admin |
Admin key for the user's local c-relay. |
Stable. |
2 |
ginxsom server |
Server key for the user's local ginxsom. |
Stable. |
3 |
fips |
Mesh node identity for fips. Yields fips IPv6. |
Stable. |
4 |
(reserved) |
Reserved for future allocation. | Reserved. |
5 |
didactyl agent |
Nostr-native AI agent identity for didactyl. |
Stable. |
6+ |
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
maingets 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 as 32 bytes |
hex privkey |
produced directly by nostr_derive_keys_from_mnemonic as 32 bytes |
| FIPS mesh IPv6 | fd + first 15 bytes of sha256(pubkey_bytes) — implemented locally in identity-agent; the definition matches tools.html 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:
- Looks up the currently allocated schedule (see §4).
- Allocates the next unused index.
- Returns the npub (never the nsec or the mnemonic).
- Persists the name → index mapping as a signed Nostr config event under the main key (kind described in
plans/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
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.txtfor a single candidate file. - Reads it, validates BIP-39 checksum.
- Wipes the source file (
shred -uon 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 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/nip44primitives 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. |
| 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
- Present the boot-time mnemonic UX (or delegate to a TUI binary it supervises).
- Hold the mnemonic + derived keys in
mlock()-ed, non-swappable memory. - Derive keys on demand by index.
- Act as a NIP-46 signer for apps (local, over Unix socket; optionally over fips mesh for mesh-local apps).
- Track the role→index schedule; register new roles (§2.3).
- 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). 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
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=yesLockPersonality=yesNoNewPrivileges=yesProtectSystem=strictProtectHome=yesPrivateTmp=yesRestrictAddressFamilies=AF_UNIX AF_NETLINKSystemCallArchitectures=nativeSystemCallFilter=@system-serviceCapabilityBoundingSet=CAP_IPC_LOCK(formlock), 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:
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, c-relay, 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 ...(ornostr-id derive <role>for services that require raw keys). - Their systemd units add
After=nostr-id.serviceandRequires=nostr-id.service. - The
n-os-tr-firstbootscript becomes a much thinner shim: identity generation moves out of it entirely, into the agent.
6. Memory safety mechanics
Enforcing F1–F7 of the threat model requires deliberate engineering:
mlock(2). The agent locks pages containing mnemonic and derived keys so the kernel can't swap them. RequiresCAP_IPC_LOCK.- Zeroize-on-drop. Key material is wrapped in a type (Rust
zeroize::Zeroizing, or Cvolatile+ explicit_bzero) that guarantees the bytes are cleared when the owner is dropped. - No
coredumps.prctl(PR_SET_DUMPABLE, 0)on the agent process. - No
ptrace.prctl(PR_SET_DUMPABLE, 0)blocksptracetoo; additionally the agent's systemd unit setsNoNewPrivileges=yesand restrictsSYS_PTRACEvia seccomp. - No
/proc/pid/mem. Disabled byPR_SET_DUMPABLE=0. - No swap, ever. Swap is disabled at the kernel level at image build time (see F2).
- 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 lockpanic 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.
-
Specify schedule. Lock the index table in §2.1 behind a version number. Ship it inside a shared constants file (
stack/identity/schedule.tomlor equivalent) so all apps reference the same source of truth. -
Build
libnostr_core.aunder Alpine/musl. Every identity-related binary is built following the same Alpine+musl disciplinenostr_core_lib,includes/ginxsom, andincludes/c-relayalready use — seeplans/tui_login.md §0.1for the full toolchain recipe. We reuse the sharedstack/build-common/Dockerfile.alpine-musllanded in TUI step 1. No glibc, no.deb; the library is linked statically into each consumer binary produced from this builder. -
Prototype CLI
nostr-id. Single fully static C99 binary (nostr-id_static_x86_64) produced by the Alpine/musl builder, linkinglibnostr_core.a. Reads mnemonic from stdin, callsnostr_derive_keys_from_mnemonicfor the requested role index, prints the npub vianostr_key_to_bech32. No daemon yet; no IPC; pure unit-testable againstincludes/nostr_core_lib/tests.lddon the resulting binary must report "not a dynamic executable." -
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.sockfor CLI commands. - Runs the NIP-46 signer loop via
nostr_nip46_signer_session_init+nostr_nip46_signer_handle_request, receiving requests over a local Unix socket (or over a loopback relay for mesh-capable apps). - Wires
nostr_set_log_callbackto route library logs to journald (no on-disk log files, per F10–F11 in threat_model.md). - Handles SIGTERM by zeroing keys and exiting cleanly.
- Holds mnemonic + all derived keys in
-
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(return code surfaces an invalid mnemonic). Generation flow usesnostr_generate_mnemonic_and_keys. External-signer flow usesnostr_nip46_client_session_initwith the user-supplied bunker URL. -
Retrofit Phase 2 services.
- c-relay: switch
iso/config/includes.chroot/etc/systemd/system/c-relay.serviceto pull admin pubkey vianostr-id show npub c-relay-adminand admin privkey vianostr-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-firstbootbecomes much thinner — identity generation moves to the agent entirely.
- c-relay: switch
-
Smoke tests. Extend
iso/config/includes.chroot/usr/local/bin/n-os-tr-smoketestto 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 againstnostr_core_lib's own tests. - No
nsec1, no 64-char lowercase hex that looks like a privkey, appears injournalctloutput. - NIP-46 ping round-trips over the signer socket via
nostr_nip46_client_ping.
-
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.
-
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
libsecp256k1is 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-connectis 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.