Files
n_os_tr/plans/n_signer_integration.md

26 KiB
Raw Permalink Blame History

Integrating n_signer into n-OS-tr

Status: Design (revised for n_signer v0.0.16 --preapprove model). Defines how the vendored includes/n_signer/ program becomes the boot-time mnemonic-entry + signer for n-OS-tr, replacing the previously-planned identity-agent daemon, and how the user gets a working multi-terminal experience without going GUI.

Supersedes the daemon design in plans/identity_subsystem.md. The mnemonic-entry portion of plans/tui_login.md is also superseded; the post-unlock service menu part of that doc remains valid (now hosted by stack/nostr-id-tui/ on tty2).

Companion docs: plans/N_OS_TR_AGENT_CHANGES.md (operational guide for the v0.0.16 approval model), plans/binary_handling_strategy.md (how all binaries get into the ISO), plans/upstream_nsigner_tickets.md (open upstream features still needed).

1. Decisions locked in

  1. n_signer replaces the planned identity-agent. nsigner already provides everything the design called for: mlock'd RAM-only key material, NIP-06 role derivation, NIP-46-style JSON-RPC signing methods, abstract-namespace Unix socket transport, zero-on-exit. We are not building a parallel daemon; we delete that work item.
  2. TTY1 is the signer terminal. nsigner runs as a foreground program owning /dev/tty1. Mnemonic entry, status display, and approval prompts all live there.
  3. TTY2 is the post-unlock service-selection menu. Trimmed stack/nostr-id-tui/ on tty2 lets the user toggle service daemons.
  4. TTY3..TTY6 are normal login shells. User logs in as the nostr user, runs nt, otp, and any other CLIs from those shells.
  5. No GUI in v1. Virtual consoles + Ctrl+Alt+Fn give multi-terminal for free.
  6. n_signer v0.0.16+ --preapprove is the policy mechanism. No separate policy file, no --role-schedule flag — preapproved caller/role pairs are passed as repeatable CLI flags on the nsigner ExecStart line.
  7. @n_os_tr is the pinned abstract socket name. All consumers hard-code it.
  8. Service daemons get raw nsec at startup; user CLIs sign via NIP-46. Two patterns coexist (see §7).

2. The components in scope

This integration covers eight components from the n-OS-tr ecosystem. They split into three categories by lifecycle:

Category Components What they need from nsigner
The signer itself n_signer Holds the mnemonic; serves the rest
Service daemons (long-running, started by service menu) c-relay, ginxsom, fips, didactyl Raw nsec at startup (NIP-44 ECDH, Noise handshakes)
User CLIs (launched by user from shell) nt (nostr_terminal), otp, future tools NIP-46 sign_event over @n_os_tr
Boot-time TUIs (n-OS-tr-native) nostr-id-tui (services menu) NIP-46 get_public_key(nostr_index=0) to display the user's npub

The role index assignment (plans/identity_subsystem.md §2.1) updated:

Index Role Used by Category
0 main User's primary identity (nt, otp, future user tools) User
1 throwaway Test/experimental User
2 c-relay-admin c-relay daemon Service
3 ginxsom ginxsom daemon Service
4 fips fips mesh daemon Service
5 didactyl didactyl agent daemon Service
6+ reserved/dynamic future apps via registration

3. Boot-time UX

sequenceDiagram
    participant Kernel
    participant Sd as systemd
    participant Fb as n-os-tr-firstboot
    participant TTY1 as nsigner @ tty1
    participant TTY2 as nostr-id-tui @ tty2
    participant TTY3 as getty @ tty3..6
    participant Daemon as c-relay / ginxsom / fips / didactyl
    participant CLI as nt / otp on user shell

    Kernel->>Sd: hand off
    Sd->>Fb: oneshot (no key generation)
    Fb-->>Sd: done

    Sd->>TTY1: start nsigner.service<br/>with --preapprove flags
    Sd->>TTY2: start nostr-id-tui-services.service
    Sd->>TTY3: getty units (lazy-start on switch)

    Note over TTY1: User sees: Enter mnemonic /<br/>Generate new
    Note over TTY2: User sees: Waiting for signer<br/>polling @n_os_tr

    TTY1->>TTY1: user types mnemonic
    TTY1->>TTY1: derive role keys, bind @n_os_tr<br/>load preapprove rules

    TTY2->>TTY1: get_public_key nostr_index=0<br/>(preapproved for uid:0)
    TTY1-->>TTY2: npub1xyz...
    TTY2->>TTY2: show service menu

    TTY2->>Daemon: systemctl start <daemon>
    Daemon->>TTY1: get_secret_key nostr_index=N<br/>(preapproved for the daemon's uid)
    TTY1-->>Daemon: hex privkey
    Daemon->>Daemon: hold key in mlock'd RAM,<br/>do its job

    Note over TTY3: user logs in on tty3
    Note over CLI: user runs nt or otp
    CLI->>TTY1: sign_event nostr_index=0<br/>(preapproved for uid:1000)
    TTY1-->>CLI: signed event

3.1 Concrete sequence

  1. Boot completes. tty1 shows the nsigner mnemonic prompt. tty2 shows "waiting for signer" and starts polling @n_os_tr. tty3..tty6 are dormant.
  2. User stays on tty1, types or generates the 12-word mnemonic. nsigner derives all role keys (indices 05 are pre-registered via the --preapprove ...,nostr_index=N startup flags), binds @n_os_tr, and the preapprove table is now active.
  3. tty2's polling loop's first get_public_key(nostr_index=0) request hits — preapproved for uid:0 (root) — returns the npub immediately, no prompt on tty1.
  4. tty2 displays the npub for confirmation, then enters the service menu.
  5. User toggles services on. nostr-id-tui does systemctl start c-relay.service. c-relay's ExecStartPre calls n-os-tr-get-key c-relay-admin which talks to @n_os_tr, asks for get_secret_key(nostr_index=2) — preapproved for the c-relay system user's uid — and exports the hex key into the service's environment.
  6. Daemon runs. Same pattern for ginxsom, fips, didactyl.
  7. User Ctrl+Alt+F3, logs in as nostr (uid 1000), runs nt. nt connects to @n_os_tr, calls sign_event(nostr_index=0). Preapproved for uid:1000. Event is signed, no prompt on tty1.

3.2 Knowing which TTY you're on (visual indicators)

Linux gives you 6 virtual consoles for free, but stock Debian gives no obvious indicator of which one you're currently looking at. We address this with two layers, both essentially free:

Layer 1 — Distinct banner per TTY.

TTY Banner contents Owner
tty1 🔐 N-OS-TR SIGNER · TTY1 in red, full-width nsigner (via wrapper or upstream --banner-prefix)
tty2 ⚙️ N-OS-TR SERVICES · TTY2 in blue, full-width trimmed nostr-id-tui
tty3..tty6 n-OS-tr · SHELL · tty\l baked into /etc/issue; shell PS1=[tty\l \w]\$ getty + /etc/profile.d/n-os-tr-tty.sh

Layer 2 — Background color tint per TTY. Set via setterm in each unit's ExecStartPre.

TTY Tint
tty1 dark red
tty2 dark blue
tty3..tty6 default

The combination — color + banner + shell prompt — means at a glance the user can tell signer (red) from service menu (blue) from shell (black with [ttyN] prompt). Color-blind-friendly: shapes (🔐 vs ⚙️) and prompts also differ.

Open question (T9 in upstream_nsigner_tickets.md). Adding "N-OS-TR SIGNER · TTY1" to nsigner's hardcoded banner needs upstream --banner-prefix STRING flag, OR a wrapper script, OR a build-time source patch. Lean: upstream.

3.3 Recovery / unhappy-path UX

  • Bad mnemonic. nsigner clears its buffer and reprompts on tty1. tty2 keeps polling.
  • User quits nsigner (q hotkey). Process exits, abstract socket disappears, key material is gone. systemd Restart=always brings up a fresh nsigner and the cycle starts over. tty2's polling loop catches the new instance; if the npub differs (different mnemonic), tty2 re-confirms before re-entering service menu.
  • A daemon crashes. nsigner is unaffected. systemd's restart policy handles the daemon; on restart it re-fetches its key via n-os-tr-get-key.
  • A user CLI gets denied. If the user runs nt from a uid not covered by a --preapprove, the first sign_event call returns policy_denied. The CLI surfaces "approval needed; check tty1" and the user walks to tty1, sees the prompt, presses [a] for session-grant.

4. nsigner.service — the core configuration

The service unit on tty1 that orchestrates everything. Replaces the existing nostr-id-tui.service.

[Unit]
Description=n-OS-tr signer (n_signer foreground)
Documentation=file:///usr/share/doc/n-os-tr/README
After=systemd-user-sessions.service n-os-tr-firstboot.service
Wants=n-os-tr-firstboot.service
Conflicts=getty@tty1.service
Before=getty@tty1.service

[Service]
Type=simple
ExecStartPre=/usr/bin/setterm -term linux -background red -store
ExecStart=/usr/local/sbin/nsigner \
    --socket-name n_os_tr \
    \
    # tty2 service-menu polls for the user's npub. Root uid.
    --preapprove caller=uid:0,nostr_index=0 \
    \
    # User CLIs (nt, otp) signing under the user identity.
    # The 'nostr' user is uid 1000 in the live image.
    --preapprove caller=uid:1000,nostr_index=0 \
    \
    # Service daemons. Each runs as its own dedicated system user.
    --preapprove caller=uid:%U_C_RELAY%,nostr_index=2 \
    --preapprove caller=uid:%U_GINXSOM%,nostr_index=3 \
    --preapprove caller=uid:%U_FIPS%,nostr_index=4 \
    --preapprove caller=uid:%U_DIDACTYL%,nostr_index=5

Restart=always
RestartSec=1
StandardInput=tty
StandardOutput=tty
StandardError=journal
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes

# Memory hygiene (mirrors what n_signer enforces in-process)
LockPersonality=yes
NoNewPrivileges=yes
MemoryDenyWriteExecute=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RestrictAddressFamilies=AF_UNIX
SystemCallArchitectures=native
CapabilityBoundingSet=CAP_IPC_LOCK
AmbientCapabilities=CAP_IPC_LOCK

[Install]
WantedBy=multi-user.target

About the %U_*% placeholders. systemd doesn't expand variables inside ExecStart. We have two options:

  • Option 4a (recommended): generate the unit at first boot with the actual uids, after the system users are created. A small wrapper script reads /etc/passwd, looks up c-relay, ginxsom, etc., and writes the final unit file. Done once, at firstboot.
  • Option 4b: Allocate fixed system uids in iso/config/hooks/live/0020-enable-services.hook.chroot (e.g., c-relay is always uid 901, ginxsom always 902, etc.) and hard-code those in the unit. Simpler but less flexible.

Lean: Option 4a. The wrapper lives at iso/config/includes.chroot/usr/local/sbin/n-os-tr-render-nsigner-unit and runs as part of n-os-tr-firstboot.

4.1 Why every preapprove uses nostr_index=N

We use nostr_index=N (not role=<name>) for every preapprove rule. Reasons:

  1. Indices are canonical. They're the actual NIP-06 derivation parameter; role names are just labels.
  2. No role-schedule file needed. v0.0.16's --preapprove ...,nostr_index=N auto-creates the role at parse time. We get role registration for free as a side effect of the preapprove rules. This closes upstream ticket T1.
  3. The role-name list lives in plans/identity_subsystem.md §2.1 as the human-readable mapping. Code uses indices.

The auto-created role names from nostr_index=N are:

  • index 0 → role main
  • index N>0 → role nostr_idx_N (e.g., nostr_idx_2, nostr_idx_5)

So when c-relay calls n-os-tr-get-key 2, the helper translates that to {"nostr_index": 2} in the wire request, the response carries the key. The "c-relay-admin" name from our role schedule is purely documentation; it doesn't appear on the wire.

5. The key-fetch helper for service daemons

/usr/local/bin/n-os-tr-get-key is the bridge between v0.0.16's RPC and a daemon's ExecStartPre. It already exists in the repo. Updated for v0.0.16 wire contract:

#!/bin/bash
# n-os-tr-get-key: request a role's secret key from nsigner via @n_os_tr
# Usage: n-os-tr-get-key <nostr_index>
# Outputs: 64-char hex private key on stdout.
# Exit 1 on failure (signer down, denied, role not preapproved, ...).
set -euo pipefail

INDEX="${1:?usage: n-os-tr-get-key <nostr_index>}"
SOCKET="n_os_tr"
TIMEOUT="${NSIGNER_KEY_TIMEOUT:-30}"

# Wait for socket. If user never unlocks signer, this times out and we fail closed.
ELAPSED=0
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
    if socat -u OPEN:/dev/null ABSTRACT-CONNECT:"$SOCKET" 2>/dev/null; then
        break
    fi
    sleep 1
    ELAPSED=$((ELAPSED + 1))
done
[ "$ELAPSED" -ge "$TIMEOUT" ] && {
    echo "n-os-tr-get-key: timeout waiting for @${SOCKET}" >&2; exit 1; }

# Build framed request (4-byte big-endian length + JSON payload)
PAYLOAD='{"id":"1","method":"get_secret_key","params":[{"nostr_index":'"$INDEX"'}]}'
PAYLOAD_LEN=${#PAYLOAD}
FRAME=$(printf '%08x' "$PAYLOAD_LEN" | xxd -r -p; printf '%s' "$PAYLOAD")
RESPONSE=$(printf '%s' "$FRAME" | socat -t5 - ABSTRACT-CONNECT:"$SOCKET" | tail -c +5)

# Check for error response
if echo "$RESPONSE" | grep -q '"error"'; then
    ERR=$(echo "$RESPONSE" | grep -o '"message":"[^"]*"' | cut -d'"' -f4)
    echo "n-os-tr-get-key: signer error: ${ERR:-unknown}" >&2; exit 1
fi

# Extract 64-char hex from result field
KEY=$(echo "$RESPONSE" | grep -oE '"result":"[a-f0-9]{64}"' | grep -oE '[a-f0-9]{64}' | head -1)
[ -z "$KEY" ] || [ "${#KEY}" -ne 64 ] && {
    echo "n-os-tr-get-key: invalid response" >&2; exit 1; }

echo "$KEY"

Critical caveat. get_secret_key is not yet implemented in upstream n_signer as of v0.0.16. This is open ticket T3 in plans/upstream_nsigner_tickets.md. Until it lands, the helper above will receive method_not_found and the daemons will fail closed. See §9 for the path forward.

6. Per-daemon systemd drop-ins

Each service daemon gets a drop-in that adds the signer-aware behavior. These already exist for c-relay, ginxsom, fips, nginx; we add didactyl.

6.1 c-relay

/etc/systemd/system/c-relay.service.d/n-os-tr-signer.conf:

[Unit]
After=nsigner.service
Wants=nsigner.service

[Service]
Environment=NSIGNER_SOCKET=@n_os_tr
ExecStartPre=/usr/local/bin/n-os-tr-await-signer
# Get the relay-admin key, export to env. The key never touches disk.
ExecStartPre=/bin/bash -c 'echo "C_RELAY_ADMIN_PRIVKEY=$(/usr/local/bin/n-os-tr-get-key 2)" > /run/c-relay.env && chmod 0400 /run/c-relay.env'
EnvironmentFile=/run/c-relay.env
ExecStartPost=/bin/rm -f /run/c-relay.env

The transient /run/c-relay.env exists for milliseconds between ExecStartPre and ExecStartPost. systemd reads EnvironmentFile= between the two.

6.2 ginxsom

Same pattern, nostr_index=3. Ginxsom takes --server-privkey as a CLI arg, so the drop-in also rewrites the ExecStart:

/etc/systemd/system/ginxsom.service.d/n-os-tr-signer.conf:

[Unit]
After=nsigner.service
Wants=nsigner.service

[Service]
Environment=NSIGNER_SOCKET=@n_os_tr
ExecStartPre=/usr/local/bin/n-os-tr-await-signer
ExecStartPre=/bin/bash -c 'echo "GINXSOM_SERVER_PRIVKEY=$(/usr/local/bin/n-os-tr-get-key 3)" > /run/ginxsom.env && chmod 0400 /run/ginxsom.env'
EnvironmentFile=/run/ginxsom.env
# Inherit the upstream ExecStart but add the privkey as an env-substituted arg
ExecStart=
ExecStart=/usr/local/bin/ginxsom/ginxsom-fcgi --server-privkey ${GINXSOM_SERVER_PRIVKEY}
ExecStartPost=/bin/rm -f /run/ginxsom.env

6.3 fips

nostr_index=4. fips reads its identity from a config field. We render the YAML at startup:

/etc/systemd/system/fips.service.d/n-os-tr-signer.conf:

[Unit]
After=nsigner.service
Wants=nsigner.service

[Service]
Environment=NSIGNER_SOCKET=@n_os_tr
ExecStartPre=/usr/local/bin/n-os-tr-await-signer
# Render fips config with the live nsec from nsigner. /run is tmpfs.
ExecStartPre=/bin/bash -c '\
  KEY=$(/usr/local/bin/n-os-tr-get-key 4) && \
  install -d -m 0700 /run/fips && \
  cp /etc/fips/fips.yaml /run/fips/fips.yaml && \
  echo "node:" >> /run/fips/fips.yaml && \
  echo "  identity:" >> /run/fips/fips.yaml && \
  echo "    nsec: $KEY" >> /run/fips/fips.yaml && \
  chmod 0400 /run/fips/fips.yaml'
ExecStart=
ExecStart=/usr/local/bin/fips --config /run/fips/fips.yaml

6.4 didactyl (new)

nostr_index=5. Didactyl reads its key from a genesis.jsonc config file:

/etc/systemd/system/didactyl.service:

[Unit]
Description=Didactyl Nostr agent
After=nsigner.service network-online.target
Wants=nsigner.service network-online.target

[Service]
Type=simple
User=didactyl
Group=didactyl
Environment=NSIGNER_SOCKET=@n_os_tr
ExecStartPre=/usr/local/bin/n-os-tr-await-signer
ExecStartPre=/bin/bash -c '\
  KEY_HEX=$(/usr/local/bin/n-os-tr-get-key 5) && \
  install -d -m 0700 -o didactyl -g didactyl /run/didactyl && \
  /usr/local/bin/n-os-tr-render-didactyl-genesis "$KEY_HEX" > /run/didactyl/genesis.jsonc && \
  chmod 0400 /run/didactyl/genesis.jsonc && \
  chown didactyl:didactyl /run/didactyl/genesis.jsonc'
ExecStart=/usr/local/bin/didactyl_static_x86_64 --config /run/didactyl/genesis.jsonc
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

The render helper n-os-tr-render-didactyl-genesis takes the hex key, derives npub/nsec from it (using nak or a small helper), and emits the JSON didactyl expects (see didactyl/README.md for schema).

6.5 nginx

No change from current — already gated by n-os-tr-await-signer so it doesn't start before identity is loaded.

7. User CLIs: nt, otp, future tools

User CLIs are different from service daemons:

  • They're launched by the user, from a shell, on tty3+
  • They run as the user's uid (e.g., uid:1000 for the nostr user)
  • They want to sign Nostr events, not run forever
  • They should NOT need raw nsec — they should use NIP-46 sign_event cleanly

7.1 Configuration: tell each CLI where the signer is

Both nt and otp (and future user CLIs) need a way to discover the signer. We standardize on:

  • Environment variable NOSTR_SIGNER_SOCKET=@n_os_tr exported in the user's shell (set in /etc/profile.d/n-os-tr-tty.sh).
  • Login flow: when nt starts and finds NOSTR_SIGNER_SOCKET set, it skips its built-in mnemonic prompt and uses the signer instead. (This is a per-CLI feature that needs implementing — see §9.)

7.2 Preapprove for the user's uid

The nsigner.service unit's preapprove flags include caller=uid:1000,nostr_index=0, granting the user (uid 1000) auto-allow access to sign_event for role main. So:

# On tty3, logged in as nostr user:
$ nt
[nt] using signer @n_os_tr (uid:1000 → nostr_index=0)
[nt] hello, npub1xyz...
> publish "hello world"
[nt] signed via nsigner, published to relays

No prompt fires on tty1; the request is preapproved.

If the user wants more granular control (e.g., always-prompt for otp because the messages are sensitive), they can either:

  • Remove the caller=uid:1000,nostr_index=0 preapprove flag from nsigner.service (every CLI signing requires tty1 approval), or
  • Run with a different uid for the sensitive CLI (run otp as uid:1001, no preapprove for that uid)

7.3 What needs to change in nt and otp

Both currently expect the user to enter a mnemonic/nsec in their own UI. To use n_signer instead, each CLI needs to:

  1. Detect NOSTR_SIGNER_SOCKET env var and a --use-signer flag (or treat env-var presence as opt-in)
  2. Implement a NIP-46 over abstract-Unix-socket client (the same JSON-RPC framing nsigner uses)
  3. Replace local mnemonic/nsec storage with calls to get_public_key and sign_event
  4. Fall back to local mnemonic if signer isn't present (preserves existing behavior outside n-OS-tr)

This is upstream work in nt and otp, not in this repo. We file tickets against those projects:

Open question (T11). File feature requests in nostr_terminal and otp for "signer integration" — accept NOSTR_SIGNER_SOCKET env var, speak nsigner's framed JSON-RPC, fall back gracefully if absent. Track outside this plan.

8. The n-os-tr service menu (tty2)

stack/nostr-id-tui/ trimmed down to just the post-unlock service menu. Polls @n_os_tr for the main npub, then lets the user toggle service daemons on/off.

The service list (in /etc/n-os-tr/services.conf) now includes didactyl:

# /etc/n-os-tr/services.conf — services toggleable from tty2
c-relay:c-relay.service:Nostr relay (your own NIP-01 relay)
ginxsom:ginxsom.service:Blossom blob server (BUD-* protocols)
fips:fips.service:FIPS mesh routing (npub-addressed IPv6)
didactyl:didactyl.service:Didactyl AI agent
nginx:nginx.service:HTTPS / reverse proxy

(Format: display-name:unit-name:description.)

9. Required upstream work

To make the integration fully functional we still need the following, tracked in plans/upstream_nsigner_tickets.md:

Ticket Status What Blocks
T1: --role-schedule FILE Closed Resolved by --preapprove ...,nostr_index=N auto-creating roles nothing
T2: --policy-file PATH Closed Resolved by --preapprove CLI flag nothing
T3: get_secret_key method Open RPC method to expose raw nsec for a role index, gated by policy All four service daemons
T9: --banner-prefix STRING Open, low-priority Inject "N-OS-TR SIGNER · TTY1" into nsigner banner UX nice-to-have
T11: nt and otp signer integration Open Upstream changes in those projects to use NIP-46 over @n_os_tr User CLI signing

T3 is the critical-path blocker. Until it lands, we can ship the ISO with:

  • nsigner working (mnemonic entry, status display, prompts)
  • tty2 service menu working (uses get_public_key which exists)
  • User CLIs working if nt/otp are updated to use sign_event (T11)
  • Service daemons broken — they can't get raw keys

Mitigation while T3 is pending: keep n-os-tr-firstboot's old key-generation path as a feature-flag fallback. If n-os-tr-get-key returns method_not_found, the daemon's ExecStartPre falls back to the old behavior (random nsec generated at firstboot, stored in /var/lib/n-os-tr/keys/). This is insecure by our threat model — the keys touch disk — but it lets the rest of the system work for testing while we land T3 upstream.

10. Threat-model implications

Strengthens plans/threat_model.md:

  • F1 (no on-disk mnemonic): preserved. nsigner mlocks; mnemonic never persisted.
  • F2 (no swap): preserved. nsigner calls mlock; image disables swap kernel-wide.
  • F4 (no key material in journald): preserved. nsigner doesn't log keys; the transient /run/<service>.env files are short-lived tmpfs.
  • F12 (no detached daemon for keys): preserved. nsigner is foreground, attached to tty1, dies cleanly.

New attack surfaces:

  • The @n_os_tr socket itself. Gated by SO_PEERCRED; preapprove rules pin specific uids. Anyone running outside the preapprove list still hits the prompt path on tty1.
  • The --preapprove CLI args. Visible in ps//proc. Contents are public (uid + role index, no secrets), so this is benign.
  • /run/<service>.env transient files. Exist for milliseconds during ExecStartPre→ExecStart→ExecStartPost. On tmpfs (RAM, no swap). Mode 0400. Cleared by ExecStartPost. Acceptable.

11. Documents that need updating after this lands

12. Summary diagram

flowchart TB
    subgraph TTYs[Linux virtual consoles]
        T1[tty1 RED: nsigner<br/>mnemonic + status + prompts]
        T2[tty2 BLUE: nostr-id-tui<br/>service menu]
        T3[tty3..tty6: shell<br/>uid:1000 nostr user]
    end

    Sock[@n_os_tr<br/>abstract Unix socket, pinned name<br/>--preapprove rules loaded at startup]

    subgraph Daemons[Service daemons - long-running]
        Cr[c-relay<br/>uid:c-relay<br/>nostr_index=2]
        Gx[ginxsom<br/>uid:ginxsom<br/>nostr_index=3]
        Fp[fips<br/>uid:fips<br/>nostr_index=4]
        Dd[didactyl<br/>uid:didactyl<br/>nostr_index=5]
    end

    subgraph CLIs[User CLIs - launched from shell]
        Nt[nt<br/>nostr terminal]
        Otp[otp<br/>one-time pad]
    end

    T1 -- binds --> Sock
    T2 -- get_public_key nostr_index=0<br/>caller=uid:0 preapproved --> Sock
    Cr -- get_secret_key nostr_index=2<br/>caller=uid:c-relay preapproved --> Sock
    Gx -- get_secret_key nostr_index=3 --> Sock
    Fp -- get_secret_key nostr_index=4 --> Sock
    Dd -- get_secret_key nostr_index=5 --> Sock
    Nt -- sign_event nostr_index=0<br/>caller=uid:1000 preapproved --> Sock
    Otp -- sign_event nostr_index=0 --> Sock

    Sock -.prompt for unknown callers.-> T1