Files
n_os_tr/plans/identity_subsystem.md

27 KiB
Raw Permalink Blame History

⚠️ 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.md for the privacy contract this must satisfy, and plans/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.

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

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

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 ... (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 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 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).
  • 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/ginxsom, and includes/c-relay already use — see plans/tui_login.md §0.1 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. Reads mnemonic from stdin, calls nostr_derive_keys_from_mnemonic for the requested role index, prints the npub via nostr_key_to_bech32. No daemon yet; no IPC; pure unit-testable against 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:

  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 (return code surfaces an invalid mnemonic). Generation flow uses nostr_generate_mnemonic_and_keys. External-signer flow uses nostr_nip46_client_session_init with the user-supplied bunker URL.

  6. Retrofit Phase 2 services.

  7. Smoke tests. Extend 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.
  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.