Files
n_os_tr/plans/tui_login.md
Laan Tungir 6e2164abe5 [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
2026-04-29 13:09:28 -04:00

32 KiB
Raw Blame History

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 (the daemon the TUI talks to), plans/threat_model.md (memory-hygiene constraints F1F7), 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. 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.
  5. Statically linked against musl libc. Same rule as 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 and includes/c-relay already use:

  • Build inside Alpine Linux (alpine:3.19) 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.
  • Output is <name>_static_<arch> — the same naming convention as c_relay_static_x86_64, ginxsom-fcgi_static_x86_64, ginxsom-fcgi_static_arm64 (see includes/ginxsom/build_static.sh:66).
  • Wrapper script build_static.sh in each subtree drives the Docker build, handles uname -mlinux/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. 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 "deterministic build."

0.1.1 Concrete toolchain recipe

# 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. 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 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. 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.

/* 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).

/* 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.

/* 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

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). 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. Invalid checksum → clear all 12 slots and show "checksum invalid, try again".

Generate. Calls nostr_generate_mnemonic_and_keys. 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 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 → 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 and attempt nostr_nip46_client_session_init + nostr_nip46_client_ping. 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. 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.

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).

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 which is already vendored in nostr_core_lib.

5.1 Methods used by the TUI

load_mnemonic — primary.

// 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.

{"op":"load_bunker","url":"bunker://<pubkey>?relay=...&secret=..."}
{"ok":true,"main_npub":"npub1..."}

load_amnesia — ephemeral mode.

{"op":"load_amnesia"}
{"ok":true,"main_npub":"npub1..."}

status — TUI health check, safe to call from anyone.

{"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.

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

[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

[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

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. 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 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, 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.
  • ISO integration. 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. A hook.chroot 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. 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.
  • Config projection after identity is loaded — see 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 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/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. Header + implementation + unit test. Verify it compiles cleanly in the Alpine/musl builder.
  3. Upstream nostr_bip39_ui module into nostr_core_lib. Header + implementation (using the existing wordlist) + unit test.
  4. Upstream nostr_fips_ipv6 utility into nostr_core_lib. Header + implementation + unit test (matching tools.html).
  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 (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.