Files
n_os_tr/plans/n_signer_integration.md

509 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/`](../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`](identity_subsystem.md). The mnemonic-entry portion of [`plans/tui_login.md`](tui_login.md) is also superseded; the **post-unlock service menu** part of that doc remains valid (now hosted by [`stack/nostr-id-tui/`](../stack/nostr-id-tui/) on tty2).
>
> Companion docs: [`plans/N_OS_TR_AGENT_CHANGES.md`](N_OS_TR_AGENT_CHANGES.md) (operational guide for the v0.0.16 approval model), [`plans/binary_handling_strategy.md`](binary_handling_strategy.md) (how all binaries get into the ISO), [`plans/upstream_nsigner_tickets.md`](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/`](../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`](../includes/n_signer/) | Holds the mnemonic; serves the rest |
| **Service daemons** (long-running, started by service menu) | [`c-relay`](../includes/c-relay/), [`ginxsom`](../includes/ginxsom/), [`fips`](../includes/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`](../stack/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](identity_subsystem.md#21-the-role-table-schedule)) 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
```mermaid
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`](../iso/config/includes.chroot/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`](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`](../iso/config/includes.chroot/etc/systemd/system/nostr-id-tui.service).
```ini
[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`](../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`](../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](identity_subsystem.md#21-the-role-table-schedule)** 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`](../iso/config/includes.chroot/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:
```bash
#!/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`](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`](../iso/config/includes.chroot/etc/systemd/system/c-relay.service.d/n-os-tr-signer.conf):
```ini
[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`](../iso/config/includes.chroot/etc/systemd/system/ginxsom.service.d/n-os-tr-signer.conf):
```ini
[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`](../iso/config/includes.chroot/etc/systemd/system/fips.service.d/n-os-tr-signer.conf):
```ini
[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`](../iso/config/includes.chroot/etc/systemd/system/didactyl.service):
```ini
[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`](../iso/config/includes.chroot/usr/local/bin/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`](../../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`](../iso/config/includes.chroot/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:
```bash
# 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/`](../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`](../iso/config/includes.chroot/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`](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`](../iso/config/includes.chroot/usr/local/sbin/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`](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
- [`plans/identity_subsystem.md`](identity_subsystem.md) — already has deprecation banner. Update §2.1 to add didactyl at index 5.
- [`plans/iso_architecture.md`](iso_architecture.md) — boot diagram updated to show didactyl alongside the other daemons.
- [`plans/binary_handling_strategy.md`](binary_handling_strategy.md) — add didactyl, nostr_terminal, otp to the Tier 1 component list.
- [`plans/N_OS_TR_AGENT_CHANGES.md`](N_OS_TR_AGENT_CHANGES.md) — already up-to-date for v0.0.16; this plan integrates with it.
- [`README.md`](../README.md) — add `nt`, `otp`, `didactyl` to the components list; mention multi-VT briefly.
- [`docs/RUNNING.md`](../docs/RUNNING.md) — already has VT switching section; add CLI usage examples for `nt` and `otp`.
## 12. Summary diagram
```mermaid
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
```