[WARNING] No existing semantic tags found. Starting from v0.0.0

v0.0.1 - Reorganize ISO build tree, add docs/plans/scripts, and stage Phase 2 Slice B+C service integration artifacts
This commit is contained in:
Laan Tungir
2026-04-29 13:09:28 -04:00
parent dc7af63e6b
commit 6e2164abe5
151 changed files with 8865 additions and 1222 deletions

508
plans/tui_login.md Normal file
View File

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