From 5c214f361414c77497ac33168b2665df2cd07794 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Wed, 6 May 2026 17:38:50 -0400 Subject: [PATCH] v0.0.26 - Add mnemonic-stdin and mnemonic-fd startup input modes with tests/docs --- Dockerfile.alpine-musl | 3 + Makefile | 17 +- README.md | 8 + documents/SECURITY.md | 3 + plans/embedded_nsigner_spawn.md | 253 +++++++++++++ plans/mnemonic_startup_input.md | 195 ++++++++++ plans/tui_continuous_integration.md | 101 +++++ src/main.c | 550 +++++++++++++++++++++++----- src/server.c | 50 +++ tests/test.sh | 8 + tests/test_mnemonic_input.c | 532 +++++++++++++++++++++++++++ 11 files changed, 1618 insertions(+), 102 deletions(-) create mode 100644 plans/embedded_nsigner_spawn.md create mode 100644 plans/mnemonic_startup_input.md create mode 100644 plans/tui_continuous_integration.md create mode 100644 tests/test_mnemonic_input.c diff --git a/Dockerfile.alpine-musl b/Dockerfile.alpine-musl index ae815c9..32d941c 100644 --- a/Dockerfile.alpine-musl +++ b/Dockerfile.alpine-musl @@ -55,6 +55,7 @@ RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev # Copy source files COPY src/ /build/src/ +COPY resources/tui_continuous/ /build/resources/tui_continuous/ # Build nsigner as a fully static binary RUN ARCH="$(uname -m)"; \ @@ -68,6 +69,7 @@ RUN ARCH="$(uname -m)"; \ -I/build/nostr_core_lib \ -I/build/nostr_core_lib/nostr_core \ -I/build/nostr_core_lib/cjson \ + -I/build/resources/tui_continuous \ /build/src/main.c \ /build/src/secure_mem.c \ /build/src/mnemonic.c \ @@ -81,6 +83,7 @@ RUN ARCH="$(uname -m)"; \ /build/src/key_store.c \ /build/src/socket_name.c \ /build/src/auth_envelope.c \ + /build/resources/tui_continuous/tui_continuous.c \ "$NOSTR_LIB" \ -o /build/nsigner_static \ $(pkg-config --static --libs libcurl openssl) \ diff --git a/Makefile b/Makefile index 20e90ad..5ef90c7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ CC := gcc -CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson +CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1 SRC_DIR := src @@ -23,7 +23,8 @@ SOURCES := \ $(SRC_DIR)/transport_frame.c \ $(SRC_DIR)/key_store.c \ $(SRC_DIR)/socket_name.c \ - $(SRC_DIR)/auth_envelope.c + $(SRC_DIR)/auth_envelope.c \ + resources/tui_continuous/tui_continuous.c HEADERS := @@ -38,10 +39,11 @@ TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope TEST_QREXEC_AUTH_TARGET := $(BUILD_DIR)/test_qrexec_auth +TEST_MNEMONIC_INPUT_TARGET := $(BUILD_DIR)/test_mnemonic_input EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client -.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth examples test-client clean +.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth examples test-client clean all: dev @@ -66,7 +68,7 @@ static-arm64: chmod +x ./build_static.sh ./build_static.sh --arch arm64 -test: lib test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-client +test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-client test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV) ./$(TEST_INTEGRATION_TARGET) @@ -74,6 +76,9 @@ test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV) test-mnemonic: $(TEST_MNEMONIC_TARGET) ./$(TEST_MNEMONIC_TARGET) +test-mnemonic-input: $(TEST_MNEMONIC_INPUT_TARGET) + ./$(TEST_MNEMONIC_INPUT_TARGET) + test-role: $(TEST_ROLE_TARGET) ./$(TEST_ROLE_TARGET) @@ -106,6 +111,10 @@ $(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC @mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS) +$(TEST_MNEMONIC_INPUT_TARGET): $(TEST_DIR)/test_mnemonic_input.c + @mkdir -p $(BUILD_DIR) + $(CC) $(CFLAGS) -I$(CLIENT_DIR) $(TEST_DIR)/test_mnemonic_input.c -o $(TEST_MNEMONIC_INPUT_TARGET) $(LDFLAGS) + $(TEST_ROLE_TARGET): $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c @mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c -o $(TEST_ROLE_TARGET) $(LDFLAGS) diff --git a/README.md b/README.md index bd2d47b..1710405 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,13 @@ When started, `n_signer` immediately enters terminal input mode: No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash. +For parent-process launchers, startup can also be non-interactive: + +- `--mnemonic-stdin`: read one mnemonic line from stdin at startup, then continue normally. +- `--mnemonic-fd N`: read one mnemonic line from inherited file descriptor `N` at startup. + +These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. See [`plans/mnemonic_startup_input.md`](plans/mnemonic_startup_input.md) for the full behavior contract. + ### 3.2 Running phase (status display + signer) After unlock, terminal becomes a live status and control console. Example layout: @@ -366,6 +373,7 @@ Build outputs a single executable artifact. - [`build_static.sh`](build_static.sh): musl-static build path - [`Makefile`](Makefile): local build targets - [`Dockerfile.alpine-musl`](Dockerfile.alpine-musl): reproducible Alpine musl toolchain +- [`resources/tui_continuous/tui_continuous.h`](resources/tui_continuous/tui_continuous.h) + [`resources/tui_continuous/tui_continuous.c`](resources/tui_continuous/tui_continuous.c): vendored continuous-TUI component (from `~/lt/aesthetics`, API baseline `TUI_CONTINUOUS_VERSION 0.0.9`) - [`increment_and_push.sh`](increment_and_push.sh): version/tag/release workflow - [`src/main.c`](src/main.c): version macros (`NSIGNER_VERSION*`) diff --git a/documents/SECURITY.md b/documents/SECURITY.md index d493a6a..ef6c03c 100644 --- a/documents/SECURITY.md +++ b/documents/SECURITY.md @@ -21,6 +21,8 @@ The security posture is intentionally minimalist: Authoritative behavior reference: [`README.md`](../README.md). Implementation roadmap: [`plans/nsigner.md`](../plans/nsigner.md). Approval model plan: [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md). Wire contract for clients: [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md). +Interactive terminal presentation now follows the continuous-TUI conventions from `~/lt/aesthetics/TUI.md`, implemented via vendored [`resources/tui_continuous/tui_continuous.h`](../resources/tui_continuous/tui_continuous.h) / [`resources/tui_continuous/tui_continuous.c`](../resources/tui_continuous/tui_continuous.c) (baseline `TUI_CONTINUOUS_VERSION 0.0.9`). + --- ## 2. Threat model @@ -49,6 +51,7 @@ Authoritative behavior reference: [`README.md`](../README.md). Implementation ro | The kernel and `mlock` / `getrandom` syscalls | Used for in-memory protection and entropy. | | The static binary itself | Built reproducibly via [`build_static.sh`](../build_static.sh) and shipped as one musl-static artifact. | | The launcher that started `n_signer` | Whoever ran the process chose the `--preapprove` flags. In interactive use that's the human; in `n_OS_tr` boot it's the OS init system. The launcher's integrity is part of the trust chain. | +| Parent process (when using `--mnemonic-stdin` / `--mnemonic-fd`) | In non-interactive startup mode, the parent provides mnemonic bytes over stdin/inherited FD. That parent is now explicitly in the trust chain for mnemonic handling correctness and secrecy. | Everything else — clients, other processes, other users, remote callers — is **untrusted by default** and must clear an approval check. diff --git a/plans/embedded_nsigner_spawn.md b/plans/embedded_nsigner_spawn.md new file mode 100644 index 0000000..4700ae0 --- /dev/null +++ b/plans/embedded_nsigner_spawn.md @@ -0,0 +1,253 @@ +# Plan (future): Host programs that embed and spawn `n_signer` + +Status: design only. Do not implement until [`plans/mnemonic_startup_input.md`](mnemonic_startup_input.md) has landed and been used in at least one host program. + +This plan describes how a host program (for example `~/lt/nostr_terminal`, future Bitcoin or SSH frontends) can embed the `nsigner` binary into itself and launch it in a new visible terminal window with the mnemonic pre-loaded and pre-approvals declared, without any new on-disk artifact for `nsigner`. + +## 1. Why embed at all + +Goals motivating this plan: + +1. **Single-binary deployment.** A user installs `nostr_terminal` and gets `nsigner` for free; no separate package manager step, no `$PATH` confusion, no version skew. +2. **Version pinning.** The host always launches the exact `nsigner` it was tested against. +3. **Zero filesystem footprint preserved.** Spawning `nsigner` does not put a copy of the binary anywhere persistent; it runs out of an anonymous in-kernel memory object. +4. **Same security boundary.** `nsigner` continues to run as a separate process with its own address space, mlock'd pages, and approval prompt loop. The host does not become the signer; it merely launches it. +5. **Reusable across host programs.** Any program in the user's `~/lt/*` ecosystem can adopt the same helper. + +Non-goals (explicit): + +- Running `nsigner` *inside* the host process. That collapses the security boundary and is rejected. See [`documents/SECURITY.md`](../documents/SECURITY.md) "One process. One terminal. One human." +- Hiding the `nsigner` window. The TUI is the trust anchor; it must be visible and attended. +- Making `nsigner` a daemon. Same reason. + +## 2. Architecture overview + +```text ++----------------------------------------------------------------+ +| host program (e.g. nostr_terminal) | +| | +| +-- embedded blob ---------------------------------+ | +| | _binary_nsigner_start ... _binary_nsigner_end | | +| | (the full statically-linked nsigner ELF) | | +| +--------------------------------------------------+ | +| | +| user types mnemonic into host's TUI | +| | | +| v | +| nsigner_spawn_embedded(...) | +| | | +| | 1. memfd_create(MFD_CLOEXEC) | +| | 2. write(memfd, blob, blob_len) | +| | 3. pipe2(stdin_pipe, O_CLOEXEC) | +| | 4. fork() terminal emulator wrapper | +| | child: dup2 stdin_pipe[0] -> 0 | +| | execvp("kitty","-e","/proc/self/fd/"| +| | --listen unix --name | +| | --mnemonic-stdin | +| | --preapprove ...) | +| | 5. parent: write(stdin_pipe[1], mnemonic); close + wipe | +| | 6. return chosen socket name to caller | ++----------------------------------------------------------------+ + | + v (new graphical terminal window) ++----------------------------------------------------------------+ +| nsigner (separate process) | +| - reads mnemonic from FD 0, closes FD 0 | +| - binds @nsigner_ abstract unix socket | +| - shows status TUI | +| - applies pre-approvals from --preapprove | ++----------------------------------------------------------------+ + ^ + | (host can now connect) ++----------------------------------------------------------------+ +| host re-uses client/nsigner_client.{c,h} to issue requests | ++----------------------------------------------------------------+ +``` + +## 3. Build-time integration + +### 3.1 Producing the blob + +Add a Makefile rule (in `n_signer`'s Makefile) that exposes the binary as a relocatable object usable by other projects: + +```make +nsigner_blob.o: nsigner + $(LD) -r -b binary -o $@ $< + # results in symbols: + # _binary_nsigner_start + # _binary_nsigner_end + # _binary_nsigner_size +``` + +Alternatives considered: + +- `xxd -i nsigner > nsigner_blob.h` — produces a C header with a `unsigned char` array. Works, but adds compile time linear in blob size and blocks ccache usefulness. +- `objcopy -I binary -O elf64-x86-64 -B i386 nsigner nsigner_blob.o` — same effect as `ld -r -b binary` on most toolchains. Either is fine. + +### 3.2 Embedding into the host + +The host program adds these lines to its build: + +```make +HOST_OBJS += nsigner_blob.o nsigner_spawn.o +``` + +and links them into its final executable. The blob lives in `.data` (read-only on most toolchains) and adds roughly the size of the `nsigner` binary to the host (currently ~1–3 MB for the musl static build). + +### 3.3 Symbol declaration + +```c +extern const unsigned char _binary_nsigner_start[]; +extern const unsigned char _binary_nsigner_end[]; +/* size = _binary_nsigner_end - _binary_nsigner_start */ +``` + +## 4. Runtime spawn helper API + +New file pair: `client/nsigner_spawn.h` and `client/nsigner_spawn.c`. + +### 4.1 Public API sketch + +```c +typedef struct { + /* required */ + const char *mnemonic; /* will be written to child stdin pipe and then wiped */ + size_t mnemonic_len; + + /* optional */ + const char *const *preapprove_specs; /* array of "caller=...,nostr_index=N" strings */ + size_t preapprove_count; + + const char *socket_name; /* if NULL, helper picks a random BIP-39 pair */ + const char *terminal_hint; /* "kitty"|"foot"|"alacritty"|"xterm"|... or NULL = autodetect */ + + /* outputs */ + char *out_socket_name; /* caller-supplied buffer */ + size_t out_socket_name_size; + + pid_t *out_terminal_pid; /* may be NULL */ +} nsigner_spawn_opts_t; + +/* Returns 0 on success, negative on error. On success, the embedded nsigner + * binary has been written to a memfd, the terminal window has been launched, + * the mnemonic has been written into the child's stdin pipe, and the local + * `mnemonic` buffer has been zeroized. + * + * After return, the host can connect to the socket using + * client/nsigner_client.{c,h} + * with the chosen socket name in opts->out_socket_name. + */ +int nsigner_spawn_embedded(nsigner_spawn_opts_t *opts); +``` + +Optional readiness primitive (post-MVP): a `nsigner_spawn_wait_ready()` call that polls the abstract socket until it accepts a connection or until a timeout, so the host doesn't race the child's bind step. + +### 4.2 Internal pipeline + +1. Validate inputs: non-empty mnemonic, sane buffer sizes, `out_socket_name` non-NULL. +2. Pick a socket name: random BIP-39 word pair if not provided. Reuses the same name-generation routine that `nsigner` itself uses; consider extracting it into a shared header `src/socket_name.h` already exists ([`src/socket_name.c`](../src/socket_name.c:1)) and exposing the generator publicly. +3. Create memfd: `memfd_create("nsigner", MFD_CLOEXEC)`. On older kernels (no `memfd_create`), return an error rather than falling back to disk — the whole point is no filesystem footprint. (Linux 3.17+ has memfd; the project's deployment targets all qualify.) +4. Write the embedded blob to the memfd. `lseek` back to zero is unnecessary because `fexecve`/`execveat` does not care about offset. +5. Create stdin pipe: `pipe2(stdin_pipe, O_CLOEXEC)`. Mark only the read end as inheritable in the child (clear `FD_CLOEXEC` on the read end after `fork`). +6. Pick a terminal emulator. Detection order: `$NSIGNER_TERMINAL` env override, then `terminal_hint`, then a built-in priority list: + + | Emulator | Run-this flag | Notes | + |---|---|---| + | `kitty` | `--detach -- ` | preserves stdin | + | `foot` | `-- ` | preserves stdin | + | `alacritty` | `-e ` | preserves stdin | + | `wezterm` | `start -- ` | preserves stdin | + | `xterm` | `-e ` | preserves stdin (legacy reliable) | + | `gnome-terminal` | **avoided** | dbus-launches, drops stdin/FDs | + | `konsole` | `-e ` | mostly preserves stdin | + + If none found, return an error and let the host print a hint to install one. +7. Build child argv: + - `[0] = chosen_terminal` + - `[1..]` = the terminal's "exec" flag(s) and separator + - then `[N+0] = "/proc/self/fd/"` (path the kernel exposes for the memfd, valid only inside the child's process tree — no on-disk file is created) + - then `--listen unix --name --mnemonic-stdin` + - then each `--preapprove ` pair +8. `fork()`. In the child: + - `dup2(stdin_pipe[0], 0)` (replace stdin with the read end) + - `close(stdin_pipe[1])` + - clear `FD_CLOEXEC` on the memfd so `/proc/self/fd/` resolves after `execvp` runs the terminal — the terminal then runs `nsigner` via the inherited memfd path + - `execvp(chosen_terminal, child_argv)` +9. In the parent: + - `close(stdin_pipe[0])` + - `write(stdin_pipe[1], mnemonic, mnemonic_len)` + - `write(stdin_pipe[1], "\n", 1)` + - `close(stdin_pipe[1])` + - `secure_memzero` over the host's mnemonic buffer + - close the memfd in the parent (the child kernel reference keeps it alive) + - copy the chosen socket name into `out_socket_name` + - return 0 + +### 4.3 Failure modes and cleanup + +Every failure path must: + +- close any FDs already opened (memfd, both pipe ends) +- zeroize the mnemonic buffer if it was already copied locally +- not leave a child process behind: if `fork` succeeded but a later step failed, `waitpid` the child after `kill(SIGTERM)` + +A short error-string accessor (`const char *nsigner_spawn_strerror(int)`) keeps callers from needing to know the internal codes. + +## 5. Security review (to perform before implementation) + +These items are flagged now so the implementation phase doesn't drift: + +1. **Confirm memfd contents are not exposed to other processes.** `/proc/self/fd/` is per-process; another process cannot dereference another's `/proc/self/fd/N`. Document that fact in the code comments and in [`documents/SECURITY.md`](../documents/SECURITY.md). +2. **Confirm `MFD_CLOEXEC` actually closes on the *outer* exec.** We need the memfd open across `fork`+`execvp(terminal)` but want it gone from the terminal emulator's children that aren't `nsigner`. Tested behavior: clear `FD_CLOEXEC` only on this one FD before `execvp` so the terminal inherits it; the terminal itself doesn't close arbitrary FDs in its child by default but a few do. Document the matrix. +3. **Sealing.** Optionally `fcntl(memfd, F_ADD_SEALS, F_SEAL_WRITE | F_SEAL_SHRINK | F_SEAL_GROW)` before exec, so even a compromised child cannot rewrite the binary it is about to execute. +4. **Pre-approval defaults.** The helper must NOT silently inject a default `--preapprove` for the host's uid. The host must declare every approval explicitly. This keeps the `documents/SECURITY.md` "deny by default" guarantee intact. +5. **Mnemonic in pipe buffer.** The kernel pipe buffer (4 KiB) holds the mnemonic for the moment between parent's `write` and child's `read`. This is in-kernel anonymous memory, comparable to a `mlock`'d page in lifetime, and is freed when both ends close. Acceptable. +6. **Mnemonic in environment of the host.** The helper does not put the mnemonic in `envp`. It only goes through the pipe. +7. **Host trust elevation.** When the host calls this helper, the host program joins the trust chain (it already had the mnemonic). Document that in [`documents/SECURITY.md`](../documents/SECURITY.md) §2.3 trust-anchors table. +8. **Terminal emulator trust.** The chosen terminal emulator is now part of the trust chain (it sees stdin and could capture it). Document; recommend `kitty`/`foot`/`alacritty`/`xterm` and warn against modifying via plugins/config that record stdin. + +## 6. Documentation deliverables + +- `documents/SPAWN.md`: how host programs embed `nsigner_blob.o` and use `nsigner_spawn.{c,h}`. +- Update [`documents/SECURITY.md`](../documents/SECURITY.md) §2.3 with the host-program and terminal-emulator trust-chain rows. +- Update [`README.md`](../README.md) §3.1 with a one-paragraph "Running embedded inside a host program" note pointing here. +- New worked example `examples/spawn_and_sign.c`: takes a mnemonic from its own argv (for the example only — clearly labeled "EXAMPLE: argv mnemonic, not for production"), spawns `nsigner` via the helper, then issues `get_public_key` over the resulting socket and prints the result. This demonstrates the full pattern in ~80 lines. + +## 7. Testing strategy + +Tests are inherently more involved because they require spawning a real terminal emulator. Recommended approach: + +1. **Unit tests for the helper** (`tests/test_spawn_unit.c`): + - argv builder produces the expected list given a fixture options struct. + - terminal autodetect returns the correct emulator given a fake `$PATH`. + - the memfd write produces a payload whose first 4 bytes are `\x7fELF` (sanity). +2. **Integration test under a headless terminal** (`tests/test_spawn_integration.c`): + - use `xvfb-run` plus `xterm -e` (or `foot --headless`-ish modes if available) so CI can spawn a window without a display server. + - the test passes a known mnemonic, waits for the abstract socket to appear, calls `get_public_key`, asserts the returned pubkey matches the BIP-39 vector for that mnemonic. + - this test is gated on a build flag because not every CI environment has a usable headless emulator; `make test-spawn` rather than the default `make test`. +3. **Manual smoke checklist** in `documents/SPAWN.md` covering the four supported emulators. + +## 8. Open questions to resolve before implementation + +- Should the helper live in `client/` (next to `nsigner_client.{c,h}`) or in a new top-level `embed/` directory? Recommendation: `client/` keeps related host-side code together. +- Should the embedded blob be compressed (e.g. zstd-decompress on the fly into the memfd)? Probably not for the first version — extra dependency, marginal size benefit on already-small statically-linked `nsigner`. +- How does this interact with code signing on platforms that enforce it (Windows, macOS, signed Linux distros)? Linux generally allows `fexecve` of memfd content; macOS would require a different path. The plan is Linux-only for now; document that. +- Should `nsigner` itself learn a `--print-socket-name-on-startup` flag (one line to a designated FD) so the spawn helper doesn't have to choose the name? Useful future addition; keep it as a follow-up after the memfd path proves out. + +## 9. Acceptance criteria (when the time comes to implement) + +- [ ] `make nsigner_blob.o` produces a relocatable object usable by host projects. +- [ ] `client/nsigner_spawn.{c,h}` compiles cleanly and links into a host program with no extra dependencies. +- [ ] `examples/spawn_and_sign.c` runs end-to-end and prints a valid pubkey. +- [ ] No file is created on disk during a successful spawn (verified by `lsof` snapshot or `inotifywait` on `/tmp`, `/run/user/$UID`, `$HOME`). +- [ ] Host's mnemonic buffer is wiped on the path to `execvp`. +- [ ] Pre-approvals declared by the host appear in the spawned `nsigner`'s policy table without prompting. +- [ ] Documentation updates landed in `documents/SPAWN.md`, [`documents/SECURITY.md`](../documents/SECURITY.md), and [`README.md`](../README.md). +- [ ] Tests in `tests/test_spawn_unit.c` pass on plain CI; `tests/test_spawn_integration.c` passes under `xvfb-run` on the spawn-CI lane. + +## 10. Relationship to other plans + +- Builds on [`plans/mnemonic_startup_input.md`](mnemonic_startup_input.md) — the embedded helper consumes `--mnemonic-stdin`. +- Compatible with [`plans/deny_by_default_approvals.md`](deny_by_default_approvals.md) — every host-issued approval still goes through `--preapprove`. +- Compatible with [`plans/caller_token_identity.md`](caller_token_identity.md) — host identity is conveyed via `unix_peer` over the abstract socket like any other client. +- Companion to [`plans/auth_envelope_other_transports.md`](auth_envelope_other_transports.md): a host that spawns `nsigner` may also wish to forward authenticated requests on its behalf; out of scope here. diff --git a/plans/mnemonic_startup_input.md b/plans/mnemonic_startup_input.md new file mode 100644 index 0000000..b13e72b --- /dev/null +++ b/plans/mnemonic_startup_input.md @@ -0,0 +1,195 @@ +# Plan: Non-interactive mnemonic input at startup (`--mnemonic-stdin`, `--mnemonic-fd`) + +Status: ready to implement. +Related: [`documents/SECURITY.md`](../documents/SECURITY.md), [`src/main.c`](../src/main.c:1006), [`src/mnemonic.c`](../src/mnemonic.c:1). +Companion plan (future work): [`plans/embedded_nsigner_spawn.md`](embedded_nsigner_spawn.md). + +## 1. Goal + +Allow `n_signer` to receive its mnemonic from a parent process at startup, without the human re-typing it, while preserving the project's existing security posture: + +- nothing on the filesystem +- nothing in argv (no `--mnemonic ` flag) +- nothing in environment (no `NSIGNER_MNEMONIC` env var) for production use +- the mnemonic source is wiped from process memory immediately after a successful load + +Two new CLI flags are added: + +- `--mnemonic-stdin` — read one line from stdin during the unlock phase, treat that line as the mnemonic, then continue to the normal status display. Stdin is closed (or replaced with `/dev/null`) before the running phase begins so the listener loop and TUI hotkeys keep working. +- `--mnemonic-fd N` — read one line from file descriptor `N`, then close it. Intended to be wired by parent processes to a `pipe2(O_CLOEXEC)` write end or a `memfd_create(2)` region. **No filesystem path is involved** — `N` is an inherited FD number, equivalent to stdin in lifecycle but separable so it can coexist with `--listen stdio`. + +The existing interactive TUI prompt remains the default when neither flag is given. + +## 2. Why no `--mnemonic ` argv flag + +Listed only to make the rejection explicit and discoverable in `git log`: + +- visible to every same-uid process via `/proc//cmdline` +- captured by `ps auxww` +- captured by shell history (`bash`/`zsh` `HISTFILE`) +- captured by audit subsystems (`auditd`, sysmon-like agents) +- captured by container/VM platform telemetry that records launch commands + +Stdin and inherited FDs do not appear in any of those surfaces. They are the right primitive. + +Likewise no `NSIGNER_MNEMONIC` environment variable, because envp is exposed via `/proc//environ` to same-uid readers. Any future env-based override should be opt-in for testing only and named `NSIGNER_TEST_*` like the existing test hooks. + +## 3. CLI surface changes + +### 3.1 New flags (in [`src/main.c`](../src/main.c:1187)) + +| Flag | Argument | Behavior | +|---|---|---| +| `--mnemonic-stdin` | none | read mnemonic from FD 0, then close FD 0 and reopen as `/dev/null` | +| `--mnemonic-fd ` | int | read mnemonic from FD `N`, then close FD `N` | + +Both flags are mutually exclusive with each other. + +### 3.2 Compatibility with `--listen` modes + +| Listen mode | `--mnemonic-stdin` | `--mnemonic-fd` | +|---|---|---| +| `unix` (default) | OK | OK | +| `tcp:HOST:PORT` | OK | OK | +| `stdio` | **rejected** (stdin is the request transport) | OK (FD must not be 0/1) | +| `qrexec` | **rejected** (stdio is the request transport) | OK (FD must not be 0/1) | + +If both `--mnemonic-stdin` and `--listen stdio`/`qrexec` are passed, `n_signer` exits with a clear error before doing anything else. + +`--mnemonic-fd` rejects `N == 0` if `--listen stdio`/`qrexec` is also passed, and rejects `N == 1` always (stdout is reserved for response framing in stdio/qrexec modes and for the TUI in unix/tcp modes). + +### 3.3 Updated `print_usage()` + +Two new lines added under the existing options section: + +``` + --mnemonic-stdin Read mnemonic from stdin (one line). Default unlock prompt is skipped. + --mnemonic-fd N Read mnemonic from inherited FD N (anonymous, no filesystem). Same-line semantics. +``` + +### 3.4 `--help` discoverability + +The current `print_usage()` block in [`src/main.c`](../src/main.c:615) already lists the other flags in tabular form; the two new lines slot in next to `--auth`. No structural change. + +## 4. Behavioral spec + +### 4.1 `prompt_load_mnemonic` becomes `load_mnemonic` + +The existing function [`prompt_load_mnemonic()`](../src/main.c:1006) is renamed/refactored to `load_mnemonic(mnemonic_state_t*, mnemonic_source_t)` where: + +```c +typedef enum { + MNEMONIC_SRC_TUI_PROMPT = 0, /* current default behavior */ + MNEMONIC_SRC_STDIN, /* --mnemonic-stdin */ + MNEMONIC_SRC_FD, /* --mnemonic-fd N */ +} mnemonic_source_kind_t; + +typedef struct { + mnemonic_source_kind_t kind; + int fd; /* used for SRC_STDIN (=0) and SRC_FD */ +} mnemonic_source_t; +``` + +For `SRC_STDIN` and `SRC_FD`: + +1. Read up to `MNEMONIC_MAX_LEN` bytes from the FD using a small bounded read loop (`read(2)`), stopping at the first `\n` or EOF. +2. Trim a single trailing `\n` and any single trailing `\r` (handle Windows-style line endings just in case). +3. Reject input containing any embedded `\0` (defensive; mnemonic is text). +4. Reject input that ends without seeing EOF or `\n` after `MNEMONIC_MAX_LEN` bytes (caller is misbehaving). +5. Pass the buffer to [`mnemonic_load()`](../src/mnemonic.c:1) which already validates word count. +6. Zeroize the local read buffer with `secure_memzero` regardless of success/failure. +7. On success, close the source FD. For `SRC_STDIN`, also `dup2()` `/dev/null` onto FD 0 so subsequent `read()`s on FD 0 don't accidentally consume bytes meant for nothing. +8. On failure (read error, validation error), exit non-zero. There is **no retry loop** in non-interactive mode — the parent should fix its input and re-spawn; silently retrying could mask a serious bug like leaked mnemonic-passing logic. + +For `SRC_TUI_PROMPT`: unchanged, current behavior preserved verbatim, including the existing 10-attempt retry loop. + +### 4.2 Lock/unlock keystroke (`l` hotkey) + +Currently the `l` hotkey calls [`prompt_load_mnemonic()`](../src/main.c:1527) again interactively. After this change: + +- If the original startup used the TUI prompt: `l` re-runs the TUI prompt (unchanged). +- If the original startup used `--mnemonic-stdin` or `--mnemonic-fd`: the source FD has already been closed and zeroized, so re-locking would have no source to re-read from. Two options: + - **Option A (chosen):** the `l` hotkey falls back to the TUI prompt on re-unlock, regardless of how the original unlock happened. The user is at the terminal, so the TUI prompt is always available. + - Option B: disable `l` entirely if non-interactive source was used. Rejected — too restrictive. + +Implementation: track the original source kind in a `static` in main.c; when the user presses `l` after non-interactive startup, log a one-line notice ("re-unlock via terminal prompt") and call the TUI variant. + +### 4.3 Trim/normalize rules + +The mnemonic line is treated as ASCII text. Allowed transformations before validation: + +- strip a single trailing `\r\n` or `\n` +- strip leading and trailing ASCII whitespace (space, tab) +- collapse internal runs of whitespace? **No** — `mnemonic_load()` already tokenizes on whitespace; passing the trimmed string through unchanged is simpler and matches typed-mnemonic behavior. + +### 4.4 Error messages + +All errors go to stderr (not the TUI), prefixed `nsigner:`, and exit with status 1: + +- `nsigner: --mnemonic-stdin and --mnemonic-fd are mutually exclusive` +- `nsigner: --mnemonic-stdin requires --listen unix or --listen tcp:...` +- `nsigner: --mnemonic-fd N: invalid FD value` +- `nsigner: --mnemonic-fd N: read failed: ` +- `nsigner: mnemonic input exceeded maximum length` +- `nsigner: mnemonic validation failed` + +The actual mnemonic content is **never** printed in any error message. The generic "validation failed" message is intentional. + +## 5. Security review checklist + +Items the implementer should verify before merging: + +1. The stack buffer used for the read is wiped with `secure_memzero` on every exit path, including early-error returns. +2. If an `mlock`-backed temporary buffer is used instead of a stack buffer, it must be `secure_buf_alloc`'d and `secure_buf_free`'d. +3. The source FD is `close(2)`'d after read, before `mnemonic_load` returns control to `main()`. Close happens even on failure paths. +4. For `--mnemonic-stdin`, FD 0 is replaced with `/dev/null` (`open("/dev/null", O_RDONLY)` then `dup2`) so later code paths cannot read leftover bytes. +5. The new flags are documented in [`README.md`](../README.md) §3.1 (Startup phase) with an explicit note that they are intended for parent-process spawning and that the parent is responsible for FD hygiene. +6. The new flags are documented in [`documents/SECURITY.md`](../documents/SECURITY.md) under §2 ("trust anchors") — the parent process now joins the trust chain when these flags are used. +7. No new path is added that prints the mnemonic to logs, the activity buffer, or any debug output. +8. `man`-style banner / help output never echoes the mnemonic. + +## 6. Testing plan + +New tests: + +- `tests/test_mnemonic_input.c` (new file) covering: + 1. `--mnemonic-stdin` happy path: spawn `nsigner` with a pipe, write a valid 12-word mnemonic + `\n`, expect successful unlock. + 2. `--mnemonic-stdin` invalid mnemonic: write garbage, expect non-zero exit. + 3. `--mnemonic-stdin` no newline before EOF: write valid mnemonic without `\n`, close pipe, expect success. + 4. `--mnemonic-stdin` line too long: write 1024 bytes, expect non-zero exit with the length error. + 5. `--mnemonic-fd 3` happy path: parent opens an `O_CLOEXEC=0` pipe, dups its write end as FD 3 in the child via `posix_spawn_file_actions_*`, writes mnemonic, expects success. + 6. Mutually-exclusive flag combinations: `--mnemonic-stdin --mnemonic-fd 3`, `--mnemonic-stdin --listen stdio`, `--mnemonic-fd 0 --listen stdio` — all expected to exit 1 with the matching error string. + 7. Lock-then-reunlock after `--mnemonic-stdin` startup: verify the TUI prompt path still works (using `NSIGNER_TEST_HOTKEYS` and `NSIGNER_TEST_NONINTERACTIVE_PROMPT` test hooks). + +Existing tests must continue to pass: + +- [`tests/test_mnemonic.c`](../tests/test_mnemonic.c) (mnemonic library unit tests — unchanged surface) +- [`tests/test_integration.c`](../tests/test_integration.c) (default TUI-prompt path) +- [`tests/test.sh`](../tests/test.sh) wrapper. + +Add a row to `Makefile` `test` target for the new test binary. + +## 7. Documentation updates + +- [`README.md`](../README.md) §3.1: add a paragraph at the end noting the two new flags and pointing to this plan. +- [`documents/SECURITY.md`](../documents/SECURITY.md) §2.3: add a row to the trust-anchors table for "the parent process when `--mnemonic-stdin`/`--mnemonic-fd` is used". +- [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md): no changes (client wire protocol unaffected). +- New section in [`README.md`](../README.md) under "Operating modes" pointing readers to the future spawning plan once that lands. + +## 8. Out of scope (handled by companion plan) + +- Embedding the `nsigner` binary into a host program via `objcopy` blob + `memfd_create` + `fexecve`. See [`plans/embedded_nsigner_spawn.md`](embedded_nsigner_spawn.md). +- Reusable C client helper that picks a terminal emulator and wires the pipe. Same companion plan. +- `examples/spawn_and_sign.c`. Same companion plan. + +## 9. Acceptance criteria + +- [ ] `nsigner --mnemonic-stdin` reads one line, validates, and proceeds to status display without prompting. +- [ ] `nsigner --mnemonic-fd 3` (with FD 3 inherited from a parent test harness) does the same. +- [ ] Invalid mnemonics exit non-zero with the generic validation message; mnemonic content is never printed. +- [ ] Mutually exclusive flag combinations are rejected at argument-parse time. +- [ ] `--listen stdio` and `--mnemonic-stdin` are mutually exclusive. +- [ ] After non-interactive unlock, pressing `l` falls back to the TUI prompt for re-unlock and the running phase otherwise behaves identically. +- [ ] The mnemonic source FD is closed and the source buffer is zeroized on every path. +- [ ] All existing tests still pass; new tests in `tests/test_mnemonic_input.c` pass. +- [ ] [`README.md`](../README.md) and [`documents/SECURITY.md`](../documents/SECURITY.md) are updated. diff --git a/plans/tui_continuous_integration.md b/plans/tui_continuous_integration.md new file mode 100644 index 0000000..d395dc7 --- /dev/null +++ b/plans/tui_continuous_integration.md @@ -0,0 +1,101 @@ +# tui_continuous integration plan for n_signer + +## Goal + +Adopt the `tui_continuous` style from `/home/user/lt/aesthetics` with minimal-risk restructuring: + +- keep caller-owned poll loop and line-input model +- avoid ncurses/event-loop inversion +- preserve non-interactive behavior and test compatibility + +## Scope + +### 1) Vendor and wire library + +- Vendor `tui_continuous.c/.h` into this repository under a stable path (planned: `resources/tui_continuous/`). +- Add include path and source wiring to: + - `Makefile` dev build + - targets that link `src/main.c` (notably integration path) + - static build flow (`build_static.sh`, Alpine musl path) + +### 2) Main-screen TUI adapter in `src/main.c` + +- Introduce a small adapter layer that owns: + - persistent `TuiFrame` (`app_name=nsigner`, `app_version=NSIGNER_VERSION`, breadcrumb) + - hotkey menu definitions matching existing behavior (`q/x`, `l`, `r`, `A`) + - status text composition (`locked/unlocked`, words, socket, derived, auto-approve) + +### 3) Replace legacy `render_status()` with `tui_continuous` + +- Use `tui_render_screen()` for top framing/menu/status strip. +- Render roles with `tui_render_table()` using columns: + - role name + - purpose + - curve + - selector +- Render activity log (latest first) via `tui_print()`. +- Keep prompt anchoring stable with `tui_anchor_prompt()`. + +### 4) Resize behavior + +- Install SIGWINCH handler with `tui_install_resize_handler()` before unix interactive loop. +- On each poll tick, if `tui_resize_pending()` is set, repaint current view. + +### 5) Approval flow refactor (`src/server.c` + `src/main.c`) + +- Add server approval callback API in `server.c`: + - callback typedef + - registration function (`server_set_approval_cb(...)`) + - compact request payload struct (caller/method/role/purpose/derivation + optional peer display) +- Keep default callback behavior equivalent to existing stdio prompt output for non-TUI and tests. +- In `main.c` unix interactive mode, register a TUI-aware callback that: + - paints an approval content screen + - shows `[y]/[n]/[e]/[a]` options + - reads line input (fgets-compatible behavior) + - returns policy decision enums + - repaints main status screen after decision + +### 6) Mnemonic prompt styling + +- Keep `fgets` input path intact for paste reliability. +- Re-style startup and lock/reunlock prompts using `tui_render_content_screen()` + `tui_print()`. + +### 7) Output safety boundaries + +- Keep machine-readable output untouched (client JSON-RPC responses remain raw). +- Restrict style updates for non-interactive surfaces to human-facing usage/banner text only. + +### 8) Validation + +- Run/build validation for: + - normal dev build + - static build flow + - existing prompt-related test paths (`NSIGNER_TEST_FORCE_PROMPT`, `NSIGNER_TEST_NONINTERACTIVE_PROMPT`) +- Manual visual checks: + - narrow/normal/wide terminal widths + - live resize behavior + - approval prompt arriving during idle and proper redraw afterwards + +### 9) Documentation updates + +- Update `README.md` and security docs where relevant to mention: + - adopted TUI style source (`aesthetics/TUI.md`) + - vendored `tui_continuous` component/version + +## Execution order + +1. Vendor library and wire build +2. Implement main screen adapter + rendering +3. Add resize repaint +4. Refactor approval callback plumbing +5. Add TUI approval view in main +6. Re-style mnemonic prompts +7. Validate tests/builds + manual UX checks +8. Update docs + +## Non-goals + +- No ncurses integration +- No event loop ownership changes +- No changes to transport semantics (`unix`, `stdio`, `qrexec`, `tcp`) +- No machine-readable output format changes diff --git a/src/main.c b/src/main.c index 06dc0d8..3c4918c 100644 --- a/src/main.c +++ b/src/main.c @@ -270,10 +270,12 @@ typedef struct { } policy_table_t; /* Policy check result */ -#define POLICY_ALLOW 0 -#define POLICY_DENY -1 -#define POLICY_PROMPT -2 /* would need user confirmation */ -#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */ +#define POLICY_ALLOW 0 +#define POLICY_ALLOW_SESSION_VERB 3 +#define POLICY_ALLOW_SESSION_ALL 4 +#define POLICY_DENY -1 +#define POLICY_PROMPT -2 /* would need user confirmation */ +#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */ /* Initialize policy table */ void policy_table_init(policy_table_t *table); @@ -455,6 +457,19 @@ void server_set_prompt_always_allow(int enabled); /* Configure non-interactive prompt fallback: -1 disabled, POLICY_ALLOW, or POLICY_DENY */ void server_set_noninteractive_prompt_default(int decision); +typedef struct { + const caller_identity_t *caller; + const char *method; + const char *role_name; + const char *purpose; + int pending_derivation; + const char *fips_peer_npub; + const char *fips_peer_name; +} server_approval_request_t; + +typedef int (*server_approval_cb)(const server_approval_request_t *req, void *user_data); +void server_set_approval_cb(server_approval_cb cb, void *user_data); + /* from socket_name.h */ @@ -476,8 +491,8 @@ int socket_name_random(char *out, size_t out_len); /* Version information (auto-updated by build/version tooling) */ #define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MINOR 0 -#define NSIGNER_VERSION_PATCH 25 -#define NSIGNER_VERSION "v0.0.25" +#define NSIGNER_VERSION_PATCH 26 +#define NSIGNER_VERSION "v0.0.26" /* NSIGNER_HEADERLESS_DECLS_END */ @@ -486,6 +501,7 @@ int transport_send_framed(int fd, const char *payload); int transport_recv_framed(int fd, char **out_payload, size_t max_size); #include "auth_envelope.h" +#include "tui_continuous.h" #include #include @@ -496,6 +512,8 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size); #include #include #include +#include +#include #include #include #include @@ -516,6 +534,42 @@ static volatile sig_atomic_t g_running = 1; static activity_log_t g_activity_log; static int g_auto_approve = 0; +typedef enum { + MNEMONIC_SOURCE_TUI = 0, + MNEMONIC_SOURCE_STDIN, + MNEMONIC_SOURCE_FD +} mnemonic_source_kind_t; + +typedef struct { + mnemonic_source_kind_t kind; + int fd; +} mnemonic_source_t; + +static mnemonic_source_kind_t g_startup_mnemonic_source_kind = MNEMONIC_SOURCE_TUI; + +static const TuiMenuItem g_main_menu_items[] = { + {"^_q^:/x quit", 'q'}, + {"^_l^: lock/reunlock", 'l'}, + {"^_r^: refresh", 'r'}, + {"^_A^: toggle auto-approve", 'a'} +}; + +typedef struct { + const role_table_t *role_table; +} role_table_view_data_t; + +typedef struct { + const role_table_t *role_table; + const mnemonic_state_t *mnemonic; + int *derived_count; + const char *socket_name; +} main_tui_context_t; + +static void render_status(const role_table_t *role_table, + const mnemonic_state_t *mnemonic, + int derived_count, + const char *socket_name); + static void handle_signal(int sig) { (void)sig; g_running = 0; @@ -572,24 +626,27 @@ static int connect_abstract_socket(const char *name) { } static void print_usage(const char *program_name) { - printf("nsigner - single-binary signer program\n"); - printf("Usage:\n"); - printf(" %s [--socket-name|--name|-n ] [--listen|-l ]\n", program_name); - printf(" [--preapprove|-p ]... [--auth|-a ]\n"); - printf(" [--allow-all|-A]\n"); - printf(" Run signer server (unix mode has TUI)\n"); - printf(" %s [--socket-name|--name|-n ] client '' Send JSON-RPC request\n", program_name); - printf(" %s [--socket-name|--name|-n ] client - Read JSON-RPC request from stdin\n", program_name); - printf(" %s list List running nsigner abstract sockets\n", program_name); - printf(" %s --help\n", program_name); - printf(" %s --version\n", program_name); - printf("\nOptions:\n"); - printf(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT\n"); - printf(" --preapprove, -p SPEC\n"); - printf(" Pre-approve a caller for a role (repeatable)\n"); - printf(" SPEC: caller=,role= or caller=,nostr_index=\n"); - printf(" --auth, -a MODE Auth envelope policy per listener: off|optional|required\n"); - printf(" --allow-all, -A Allow all policy prompts for this server session\n"); + tui_print("nsigner - single-binary signer program"); + tui_print("Usage:"); + tui_print(" %s [--socket-name|--name|-n ] [--listen|-l ]", program_name); + tui_print(" [--preapprove|-p ]... [--auth|-a ]"); + tui_print(" [--mnemonic-stdin|--mnemonic-fd ] [--allow-all|-A]"); + tui_print(" Run signer server (unix mode has TUI)"); + tui_print(" %s [--socket-name|--name|-n ] client '' Send JSON-RPC request", program_name); + tui_print(" %s [--socket-name|--name|-n ] client - Read JSON-RPC request from stdin", program_name); + tui_print(" %s list List running nsigner abstract sockets", program_name); + tui_print(" %s --help", program_name); + tui_print(" %s --version", program_name); + tui_print(""); + tui_print("Options:"); + tui_print(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT"); + tui_print(" --preapprove, -p SPEC"); + tui_print(" Pre-approve a caller for a role (repeatable)"); + tui_print(" SPEC: caller=,role= or caller=,nostr_index="); + tui_print(" --auth, -a MODE Auth envelope policy per listener: off|optional|required"); + tui_print(" --mnemonic-stdin Read mnemonic from stdin (one line) at startup"); + tui_print(" --mnemonic-fd N Read mnemonic from inherited fd N (one line) at startup"); + tui_print(" --allow-all, -A Allow all policy prompts for this server session"); } static int extract_nsigner_socket_from_proc_line(const char *line, @@ -803,50 +860,146 @@ static void tcp_activity_stdout_cb(const char *message, void *user_data) { fflush(stdout); } +static void role_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) { + const role_table_view_data_t *view = (const role_table_view_data_t *)user_data; + const role_entry_t *r; + + if (out == NULL || out_size == 0 || view == NULL || view->role_table == NULL || + row < 0 || row >= view->role_table->count) { + return; + } + + r = &view->role_table->entries[row]; + switch (col) { + case 0: + (void)snprintf(out, out_size, "%s", r->name); + break; + case 1: + (void)snprintf(out, out_size, "%s", role_purpose_to_str(r->purpose)); + break; + case 2: + (void)snprintf(out, out_size, "%s", role_curve_to_str(r->curve)); + break; + case 3: + (void)snprintf(out, out_size, "%s", (r->selector_type == SELECTOR_NOSTR_INDEX) ? "nostr_index" : "role_path"); + break; + default: + out[0] = '\0'; + break; + } +} + static void render_status(const role_table_t *role_table, const mnemonic_state_t *mnemonic, int derived_count, const char *socket_name) { - int i; - int shown; + TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Main Menu" }; + TuiMenu menu = { g_main_menu_items, (int)(sizeof(g_main_menu_items) / sizeof(g_main_menu_items[0])) }; + char status_buf[256]; + TuiStatus status; - printf("\n=== n_signer %s ===\n", NSIGNER_VERSION); - printf("session: %s (%d words)\n", - mnemonic_is_loaded(mnemonic) ? "unlocked" : "locked", - (mnemonic != NULL) ? mnemonic->word_count : 0); - printf("signer : %s\n", (socket_name != NULL) ? socket_name : "(none)"); - printf("derived: %d\n", derived_count); + (void)snprintf(status_buf, + sizeof(status_buf), + "session=%s (%d words) signer=%s derived=%d auto-approve=%s", + mnemonic_is_loaded(mnemonic) ? "unlocked" : "locked", + (mnemonic != NULL) ? mnemonic->word_count : 0, + (socket_name != NULL) ? socket_name : "(none)", + derived_count, + g_auto_approve ? "ON" : "OFF"); + status.text = status_buf; - printf("\nRoles\n-----\n"); + tui_render_screen(&frame, &menu, &status); + + tui_print("^*Roles^:"); if (role_table == NULL || role_table->count == 0) { - printf("(none)\n"); + tui_print("(none)"); } else { - for (i = 0; i < role_table->count; ++i) { - const role_entry_t *r = &role_table->entries[i]; - printf("%s purpose=%s curve=%s selector=%s\n", - r->name, - role_purpose_to_str(r->purpose), - role_curve_to_str(r->curve), - (r->selector_type == SELECTOR_NOSTR_INDEX) ? "nostr_index" : "role_path"); - } + static const TuiColumn columns[] = { + {"Role", 20, 0}, + {"Purpose", 12, 0}, + {"Curve", 12, 0}, + {"Selector", 12, 0} + }; + role_table_view_data_t view; + TuiTable table; + + view.role_table = role_table; + table.columns = columns; + table.column_count = (int)(sizeof(columns) / sizeof(columns[0])); + table.row_count = role_table->count; + table.user_data = &view; + table.get_cell = role_table_get_cell; + table.is_default = NULL; + table.prefix_len = NULL; + tui_render_table(&table); } - printf("\nActivity (latest first)\n-----------------------\n"); + tui_print(""); + tui_print("^*Activity (latest first)^:"); if (g_activity_log.count == 0) { - printf("(none)\n"); + tui_print("(none)"); } else { - shown = 0; + int i; + int shown = 0; for (i = g_activity_log.count - 1; i >= 0 && shown < ACTIVITY_LOG_CAP; --i, ++shown) { - printf("%s\n", g_activity_log.lines[i % ACTIVITY_LOG_CAP]); + tui_print("%s", g_activity_log.lines[i % ACTIVITY_LOG_CAP]); } } - printf("\nHotkeys\n-------\n"); - printf("q/x quit l lock/reunlock r refresh A toggle auto-approve(prompt): %s\n", - g_auto_approve ? "ON" : "OFF"); + tui_anchor_prompt(0, tui_menu_left_col(&frame, tui_terminal_size().width)); fflush(stdout); } +static int tui_approval_cb(const server_approval_request_t *req, void *user_data) { + main_tui_context_t *ctx = (main_tui_context_t *)user_data; + TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Approval" }; + char choice[32]; + int decision = POLICY_DENY; + + tui_render_content_screen(&frame, "Approval required"); + tui_print("caller: %s", (req != NULL && req->caller != NULL) ? req->caller->caller_id : "unknown"); + if (req != NULL && req->fips_peer_npub != NULL) { + if (req->fips_peer_name != NULL && req->fips_peer_name[0] != '\0') { + tui_print("fips peer: %s (%s)", req->fips_peer_npub, req->fips_peer_name); + } else { + tui_print("fips peer: %s", req->fips_peer_npub); + } + } + tui_print("method: %s", (req != NULL && req->method != NULL) ? req->method : "unknown"); + tui_print("role: %s", (req != NULL && req->role_name != NULL) ? req->role_name : "unknown"); + tui_print("purpose: %s", (req != NULL && req->purpose != NULL) ? req->purpose : "unknown"); + if (req != NULL && req->pending_derivation) { + tui_print("** NEW IDENTITY — will be derived if approved **"); + } + tui_print(""); + tui_print("^_y^: allow once"); + tui_print("^_n^: deny"); + tui_print("^_e^: allow this caller+role+verb for session"); + tui_print("^_a^: allow this caller+role for session (all verbs)"); + printf("> "); + fflush(stdout); + + if (read_line_stdin(choice, sizeof(choice)) == 0) { + char ch = (char)tolower((unsigned char)choice[0]); + if (ch == 'a') { + decision = POLICY_ALLOW_SESSION_ALL; + } else if (ch == 'e') { + decision = POLICY_ALLOW_SESSION_VERB; + } else if (ch == 'y') { + decision = POLICY_ALLOW; + } + } + + if (ctx != NULL) { + render_status(ctx->role_table, + ctx->mnemonic, + (ctx->derived_count != NULL) ? *ctx->derived_count : 0, + ctx->socket_name); + } + + return decision; +} + static int setup_default_role(role_table_t *role_table) { role_entry_t role; @@ -867,7 +1020,7 @@ static int setup_default_role(role_table_t *role_table) { return role_table_add(role_table, &role); } -static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) { +static int prompt_load_mnemonic_tui(mnemonic_state_t *mnemonic) { char phrase[MNEMONIC_MAX_LEN]; char phrase_copy[MNEMONIC_MAX_LEN]; char mode[MNEMONIC_MAX_LEN]; @@ -879,8 +1032,14 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) { } while (invalid_attempts < max_invalid_attempts) { - printf("Mnemonic source: [E]nter existing or [G]enerate new (default E; you can also paste mnemonic here): "); - fflush(stdout); + { + TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Unlock" }; + tui_render_content_screen(&frame, "Load mnemonic"); + tui_print("Mnemonic source: [E]nter existing or [G]enerate new"); + tui_print("Default is E; you can also paste full mnemonic here."); + printf("> "); + fflush(stdout); + } if (read_line_stdin(mode, sizeof(mode)) != 0) { fprintf(stderr, "Failed to read mnemonic source choice\n"); return -1; @@ -918,7 +1077,8 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) { strncpy(phrase_copy, phrase, sizeof(phrase_copy) - 1); phrase_copy[sizeof(phrase_copy) - 1] = '\0'; - printf("\nGenerated mnemonic (WRITE THIS DOWN - it will not be shown again):\n"); + tui_print(""); + tui_print("Generated mnemonic (WRITE THIS DOWN - it will not be shown again):"); word = strtok_r(phrase_copy, " ", &ctx); while (word != NULL) { printf("%2d. %s\n", idx, word); @@ -939,8 +1099,13 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) { return 0; } - printf("Enter mnemonic (12/15/18/21/24 words): "); - fflush(stdout); + { + TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Unlock" }; + tui_render_content_screen(&frame, "Enter mnemonic"); + tui_print("Enter mnemonic (12/15/18/21/24 words):"); + printf("> "); + fflush(stdout); + } if (read_line_stdin(phrase, sizeof(phrase)) != 0) { fprintf(stderr, "Failed to read mnemonic\n"); @@ -971,6 +1136,118 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) { return -1; } +static int read_mnemonic_from_fd(int fd, char *out, size_t out_sz) { + size_t len = 0; + int saw_newline = 0; + + if (fd < 0 || out == NULL || out_sz < 2) { + return -1; + } + + for (;;) { + unsigned char ch; + ssize_t n = read(fd, &ch, 1); + if (n == 0) { + break; + } + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + + if (ch == '\n') { + saw_newline = 1; + break; + } + if (ch == '\0') { + return -2; + } + if (len + 1 >= out_sz) { + return -3; + } + + out[len++] = (char)ch; + } + + out[len] = '\0'; + + if (len > 0 && out[len - 1] == '\r') { + out[len - 1] = '\0'; + } + + (void)saw_newline; + return 0; +} + +static int replace_stdin_with_devnull(void) { + int null_fd = open("/dev/null", O_RDONLY); + int rc = 0; + + if (null_fd < 0) { + return -1; + } + + if (dup2(null_fd, STDIN_FILENO) < 0) { + rc = -1; + } + + close(null_fd); + return rc; +} + +static int load_mnemonic(mnemonic_state_t *mnemonic, const mnemonic_source_t *source) { + char phrase[MNEMONIC_MAX_LEN]; + int rc = -1; + + if (mnemonic == NULL || source == NULL) { + return -1; + } + + if (source->kind == MNEMONIC_SOURCE_TUI) { + return prompt_load_mnemonic_tui(mnemonic); + } + + memset(phrase, 0, sizeof(phrase)); + rc = read_mnemonic_from_fd(source->fd, phrase, sizeof(phrase)); + if (rc != 0) { + if (rc == -2) { + fprintf(stderr, "nsigner: mnemonic input contains embedded NUL byte\n"); + } else if (rc == -3) { + fprintf(stderr, "nsigner: mnemonic input exceeded maximum length\n"); + } else { + fprintf(stderr, "nsigner: failed to read mnemonic from fd %d: %s\n", source->fd, strerror(errno)); + } + if (source->fd >= 0) { + close(source->fd); + } + secure_memzero(phrase, sizeof(phrase)); + return -1; + } + + rc = mnemonic_load(mnemonic, phrase); + secure_memzero(phrase, sizeof(phrase)); + if (source->fd >= 0) { + close(source->fd); + } + if (rc != 0) { + fprintf(stderr, "nsigner: mnemonic validation failed\n"); + return -1; + } + + if (source->kind == MNEMONIC_SOURCE_STDIN || source->fd == STDIN_FILENO) { + if (replace_stdin_with_devnull() != 0) { + fprintf(stderr, "nsigner: failed to rebind stdin to /dev/null: %s\n", strerror(errno)); + mnemonic_unload(mnemonic); + return -1; + } + } + + printf("Seed phrase is valid and accepted.\n"); + return 0; +} + static void apply_test_overrides(policy_table_t *policy) { const char *force_prompt; const char *noninteractive_prompt; @@ -1035,6 +1312,7 @@ int main(int argc, char *argv[]) { int auth_mode = NSIGNER_AUTH_OFF; int auth_skew_seconds = AUTH_DEFAULT_SKEW_SECONDS; int allow_all = 0; + mnemonic_source_t mnemonic_source = { MNEMONIC_SOURCE_TUI, -1 }; while (argi < argc) { if (strcmp(argv[argi], "--socket-name") == 0 || @@ -1101,6 +1379,38 @@ int main(int argc, char *argv[]) { argi += 2; continue; } + if (strcmp(argv[argi], "--mnemonic-stdin") == 0) { + if (mnemonic_source.kind != MNEMONIC_SOURCE_TUI) { + fprintf(stderr, "nsigner: --mnemonic-stdin and --mnemonic-fd are mutually exclusive\n"); + return 1; + } + mnemonic_source.kind = MNEMONIC_SOURCE_STDIN; + mnemonic_source.fd = STDIN_FILENO; + argi += 1; + continue; + } + if (strcmp(argv[argi], "--mnemonic-fd") == 0) { + char *endp = NULL; + long parsed_fd; + if (argi + 1 >= argc) { + fprintf(stderr, "Missing value for %s\n", argv[argi]); + return 1; + } + if (mnemonic_source.kind != MNEMONIC_SOURCE_TUI) { + fprintf(stderr, "nsigner: --mnemonic-stdin and --mnemonic-fd are mutually exclusive\n"); + return 1; + } + errno = 0; + parsed_fd = strtol(argv[argi + 1], &endp, 10); + if (errno != 0 || endp == argv[argi + 1] || *endp != '\0' || parsed_fd < 0 || parsed_fd > INT_MAX) { + fprintf(stderr, "nsigner: --mnemonic-fd %s: invalid FD value\n", argv[argi + 1]); + return 1; + } + mnemonic_source.kind = MNEMONIC_SOURCE_FD; + mnemonic_source.fd = (int)parsed_fd; + argi += 2; + continue; + } if (strcmp(argv[argi], "--allow-all") == 0 || strcmp(argv[argi], "-A") == 0) { allow_all = 1; argi += 1; @@ -1141,11 +1451,30 @@ int main(int argc, char *argv[]) { return 1; } + if (mnemonic_source.kind == MNEMONIC_SOURCE_STDIN && + (listen_mode == NSIGNER_LISTEN_STDIO || listen_mode == NSIGNER_LISTEN_QREXEC)) { + fprintf(stderr, "nsigner: --mnemonic-stdin requires --listen unix or --listen tcp:...\n"); + return 1; + } + if (mnemonic_source.kind == MNEMONIC_SOURCE_FD) { + if (mnemonic_source.fd == STDOUT_FILENO) { + fprintf(stderr, "nsigner: --mnemonic-fd 1 is not allowed\n"); + return 1; + } + if (mnemonic_source.fd == STDIN_FILENO && + (listen_mode == NSIGNER_LISTEN_STDIO || listen_mode == NSIGNER_LISTEN_QREXEC)) { + fprintf(stderr, "nsigner: --mnemonic-fd 0 cannot be used with --listen stdio or --listen qrexec\n"); + return 1; + } + } + printf("nsigner %s\n", NSIGNER_VERSION); fflush(stdout); + g_startup_mnemonic_source_kind = mnemonic_source.kind; + mnemonic_init(&mnemonic); - if (prompt_load_mnemonic(&mnemonic) != 0) { + if (load_mnemonic(&mnemonic, &mnemonic_source) != 0) { mnemonic_unload(&mnemonic); return 1; } @@ -1323,59 +1652,84 @@ int main(int argc, char *argv[]) { memset(&g_activity_log, 0, sizeof(g_activity_log)); g_auto_approve = 0; server_set_prompt_always_allow(0); - render_status(&role_table, &mnemonic, derived_count, socket_name); + { + main_tui_context_t main_tui_ctx; + main_tui_ctx.role_table = &role_table; + main_tui_ctx.mnemonic = &mnemonic; + main_tui_ctx.derived_count = &derived_count; + main_tui_ctx.socket_name = socket_name; + server_set_approval_cb(tui_approval_cb, &main_tui_ctx); + tui_install_resize_handler(); + render_status(&role_table, &mnemonic, derived_count, socket_name); - pfds[0].fd = server.listen_fd; - pfds[0].events = POLLIN; - pfds[1].fd = STDIN_FILENO; - pfds[1].events = POLLIN; + pfds[0].fd = server.listen_fd; + pfds[0].events = POLLIN; + pfds[1].fd = STDIN_FILENO; + pfds[1].events = POLLIN; - while (g_running && server.running) { - int prc = poll(pfds, 2, 200); - if (prc < 0) { - if (errno == EINTR) { - continue; - } - break; - } - - if (prc > 0 && (pfds[0].revents & POLLIN)) { - int hrc = server_handle_one(&server, activity_log_cb, NULL); - if (hrc < 0) { + while (g_running && server.running) { + int prc = poll(pfds, 2, 200); + if (prc < 0) { + if (errno == EINTR) { + continue; + } break; } - render_status(&role_table, &mnemonic, derived_count, socket_name); - } - if (prc > 0 && (pfds[1].revents & POLLIN)) { - char ch = '\0'; - if (read(STDIN_FILENO, &ch, 1) > 0) { - char lower = (char)tolower((unsigned char)ch); - if (lower == 'q' || lower == 'x') { - g_running = 0; - } else if (lower == 'r') { - render_status(&role_table, &mnemonic, derived_count, socket_name); - } else if (ch == 'A') { - g_auto_approve = g_auto_approve ? 0 : 1; - server_set_prompt_always_allow(g_auto_approve); - render_status(&role_table, &mnemonic, derived_count, socket_name); - } else if (lower == 'l') { - printf("\n[lock] Session locked. Re-enter mnemonic to unlock.\n"); - fflush(stdout); - crypto_wipe(&key_store); - mnemonic_unload(&mnemonic); - if (prompt_load_mnemonic(&mnemonic) != 0) { + if (tui_resize_pending()) { + render_status(&role_table, &mnemonic, derived_count, socket_name); + } + + if (prc > 0 && (pfds[0].revents & POLLIN)) { + int hrc = server_handle_one(&server, activity_log_cb, NULL); + if (hrc < 0) { + break; + } + render_status(&role_table, &mnemonic, derived_count, socket_name); + } + + if (prc > 0 && (pfds[1].revents & POLLIN)) { + char ch = '\0'; + if (read(STDIN_FILENO, &ch, 1) > 0) { + char lower = (char)tolower((unsigned char)ch); + if (lower == 'q' || lower == 'x') { g_running = 0; - } else { - derived_count = crypto_derive_all(&key_store, &role_table, &mnemonic); - if (derived_count < 0) { - g_running = 0; + } else if (lower == 'r') { + render_status(&role_table, &mnemonic, derived_count, socket_name); + } else if (ch == 'A') { + g_auto_approve = g_auto_approve ? 0 : 1; + server_set_prompt_always_allow(g_auto_approve); + render_status(&role_table, &mnemonic, derived_count, socket_name); + } else if (lower == 'l') { + mnemonic_source_t relock_source; + + printf("\n[lock] Session locked. Re-enter mnemonic to unlock.\n"); + fflush(stdout); + crypto_wipe(&key_store); + mnemonic_unload(&mnemonic); + + relock_source.kind = MNEMONIC_SOURCE_TUI; + relock_source.fd = -1; + if (g_startup_mnemonic_source_kind != MNEMONIC_SOURCE_TUI) { + printf("[lock] Re-unlock uses terminal prompt in this session.\n"); + fflush(stdout); } + + if (load_mnemonic(&mnemonic, &relock_source) != 0) { + g_running = 0; + } else { + derived_count = crypto_derive_all(&key_store, &role_table, &mnemonic); + if (derived_count < 0) { + g_running = 0; + } + } + render_status(&role_table, &mnemonic, derived_count, socket_name); } - render_status(&role_table, &mnemonic, derived_count, socket_name); } } } + + server_set_approval_cb(NULL, NULL); } server_stop(&server); diff --git a/src/server.c b/src/server.c index 2b7c342..f097142 100644 --- a/src/server.c +++ b/src/server.c @@ -454,6 +454,19 @@ void server_stop(server_ctx_t *ctx); /* Extract caller identity from connected fd */ int server_get_caller(int fd, caller_identity_t *out); +typedef struct { + const caller_identity_t *caller; + const char *method; + const char *role_name; + const char *purpose; + int pending_derivation; + const char *fips_peer_npub; + const char *fips_peer_name; +} server_approval_request_t; + +typedef int (*server_approval_cb)(const server_approval_request_t *req, void *user_data); +void server_set_approval_cb(server_approval_cb cb, void *user_data); + /* from socket_name.h */ @@ -502,6 +515,8 @@ static int g_prompt_always_allow = 0; static int g_noninteractive_prompt_default = -1; static auth_nonce_cache_t g_auth_nonce_cache; static int g_auth_nonce_cache_inited = 0; +static server_approval_cb g_approval_cb = NULL; +static void *g_approval_cb_user_data = NULL; static int caller_id_extract_ipv6(const char *caller_id, char *out_ipv6, size_t out_sz) { const char *start; @@ -666,6 +681,11 @@ void server_set_noninteractive_prompt_default(int decision) { } } +void server_set_approval_cb(server_approval_cb cb, void *user_data) { + g_approval_cb = cb; + g_approval_cb_user_data = user_data; +} + static int prompt_for_policy_decision(const caller_identity_t *caller, const char *method, const char *role_name, @@ -685,6 +705,32 @@ static int prompt_for_policy_decision(const caller_identity_t *caller, return POLICY_DENY; } + if (g_approval_cb != NULL) { + server_approval_request_t req; + char ipv6[INET6_ADDRSTRLEN]; + char npub[128]; + char display_name[128]; + + req.caller = caller; + req.method = method; + req.role_name = role_name; + req.purpose = purpose; + req.pending_derivation = pending_derivation; + req.fips_peer_npub = NULL; + req.fips_peer_name = NULL; + + npub[0] = '\0'; + display_name[0] = '\0'; + if (caller != NULL && caller->kind == NSIGNER_LISTEN_TCP && + caller_id_extract_ipv6(caller->caller_id, ipv6, sizeof(ipv6)) == 0 && + lookup_fips_peer_for_ipv6(ipv6, npub, sizeof(npub), display_name, sizeof(display_name)) == 0) { + req.fips_peer_npub = (npub[0] != '\0') ? npub : NULL; + req.fips_peer_name = (display_name[0] != '\0') ? display_name : NULL; + } + + return g_approval_cb(&req, g_approval_cb_user_data); + } + printf("\nApproval required\n"); printf("caller: %s\n", (caller != NULL) ? caller->caller_id : "unknown"); if (caller != NULL && caller->kind == NSIGNER_LISTEN_TCP) { @@ -692,6 +738,9 @@ static int prompt_for_policy_decision(const caller_identity_t *caller, char npub[128]; char display_name[128]; + npub[0] = '\0'; + display_name[0] = '\0'; + if (caller_id_extract_ipv6(caller->caller_id, ipv6, sizeof(ipv6)) == 0 && lookup_fips_peer_for_ipv6(ipv6, npub, sizeof(npub), display_name, sizeof(display_name)) == 0) { if (display_name[0] != '\0') { @@ -701,6 +750,7 @@ static int prompt_for_policy_decision(const caller_identity_t *caller, } } } + printf("method: %s\n", (method != NULL) ? method : "unknown"); printf("role: %s\n", (role_name != NULL) ? role_name : "unknown"); printf("purpose: %s\n", (purpose != NULL) ? purpose : "unknown"); diff --git a/tests/test.sh b/tests/test.sh index 2d23b0b..6f9884e 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -172,5 +172,13 @@ else echo "[warn] no @nsigner entries found" fi +echo +echo "[extra] mnemonic startup input tests" +if [[ -x ./build/test_mnemonic_input ]]; then + ./build/test_mnemonic_input || true +else + echo "[warn] build/test_mnemonic_input not found (run: make test-mnemonic-input)" +fi + echo echo "Smoke test complete." \ No newline at end of file diff --git a/tests/test_mnemonic_input.c b/tests/test_mnemonic_input.c new file mode 100644 index 0000000..ef41786 --- /dev/null +++ b/tests/test_mnemonic_input.c @@ -0,0 +1,532 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define VALID_MNEMONIC "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +#define LONG_INPUT_LEN 1024 + +static int g_failures = 0; + +static void check_condition(const char *name, int condition) { + if (condition) { + printf("PASS: %s\n", name); + } else { + printf("FAIL: %s\n", name); + g_failures++; + } +} + +static int sleep_ms(int ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (long)(ms % 1000) * 1000000L; + return nanosleep(&ts, NULL); +} + +static int write_full(int fd, const void *buf, size_t len) { + const unsigned char *p = (const unsigned char *)buf; + size_t off = 0; + + while (off < len) { + ssize_t n = write(fd, p + off, len - off); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + off += (size_t)n; + } + + return 0; +} + +static int connect_socket_retry(const char *name, int timeout_ms) { + int elapsed = 0; + + while (elapsed < timeout_ms) { + int fd; + struct sockaddr_un addr; + socklen_t addr_len; + + fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + return -1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + addr.sun_path[0] = '\0'; + strncpy(&addr.sun_path[1], name, sizeof(addr.sun_path) - 2); + addr.sun_path[sizeof(addr.sun_path) - 1] = '\0'; + addr_len = (socklen_t)(sizeof(sa_family_t) + 1 + strlen(name)); + + if (connect(fd, (struct sockaddr *)&addr, addr_len) == 0) { + return fd; + } + + close(fd); + sleep_ms(100); + elapsed += 100; + } + + return -1; +} + +static pid_t spawn_with_stdin_and_fd(const char *const argv[], + const char *stdin_data, + int close_stdin_after_write, + const char *fd_data, + int fd_num, + int close_fd_after_write, + int *stdin_write_end, + int *fd_write_end, + int *capture_read_end) { + int stdin_pipe[2] = {-1, -1}; + int fd_pipe[2] = {-1, -1}; + int capture_pipe[2] = {-1, -1}; + pid_t child; + + if (pipe(stdin_pipe) != 0) { + return -1; + } + if (pipe(capture_pipe) != 0) { + close(stdin_pipe[0]); + close(stdin_pipe[1]); + return -1; + } + if (fd_data != NULL) { + if (pipe(fd_pipe) != 0) { + close(stdin_pipe[0]); + close(stdin_pipe[1]); + close(capture_pipe[0]); + close(capture_pipe[1]); + return -1; + } + } + + child = fork(); + if (child < 0) { + close(stdin_pipe[0]); + close(stdin_pipe[1]); + close(capture_pipe[0]); + close(capture_pipe[1]); + if (fd_pipe[0] >= 0) close(fd_pipe[0]); + if (fd_pipe[1] >= 0) close(fd_pipe[1]); + return -1; + } + + if (child == 0) { + dup2(stdin_pipe[0], STDIN_FILENO); + dup2(capture_pipe[1], STDOUT_FILENO); + dup2(capture_pipe[1], STDERR_FILENO); + + close(stdin_pipe[0]); + close(stdin_pipe[1]); + close(capture_pipe[0]); + close(capture_pipe[1]); + + if (fd_data != NULL) { + dup2(fd_pipe[0], fd_num); + close(fd_pipe[0]); + close(fd_pipe[1]); + } + + execv("./build/nsigner", (char *const *)argv); + _exit(127); + } + + close(stdin_pipe[0]); + close(capture_pipe[1]); + + if (fd_data != NULL) { + close(fd_pipe[0]); + } + + if (stdin_data != NULL) { + (void)write_full(stdin_pipe[1], stdin_data, strlen(stdin_data)); + if (close_stdin_after_write) { + close(stdin_pipe[1]); + stdin_pipe[1] = -1; + } + } + + if (fd_data != NULL) { + (void)write_full(fd_pipe[1], fd_data, strlen(fd_data)); + if (close_fd_after_write) { + close(fd_pipe[1]); + fd_pipe[1] = -1; + } + } + + if (stdin_write_end != NULL) { + *stdin_write_end = stdin_pipe[1]; + } else if (stdin_pipe[1] >= 0) { + close(stdin_pipe[1]); + } + + if (fd_write_end != NULL) { + *fd_write_end = fd_pipe[1]; + } else if (fd_pipe[1] >= 0) { + close(fd_pipe[1]); + } + + if (capture_read_end != NULL) { + *capture_read_end = capture_pipe[0]; + } else { + close(capture_pipe[0]); + } + + return child; +} + +static int read_capture_nonblocking(int fd, char *buf, size_t buf_sz) { + ssize_t n; + if (buf == NULL || buf_sz == 0) { + return -1; + } + n = read(fd, buf, buf_sz - 1); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + buf[0] = '\0'; + return 0; + } + return -1; + } + buf[n] = '\0'; + return 0; +} + +static int wait_exit_timeout(pid_t pid, int timeout_ms, int *status) { + int elapsed = 0; + while (elapsed < timeout_ms) { + pid_t rc = waitpid(pid, status, WNOHANG); + if (rc == pid) { + return 0; + } + if (rc < 0) { + return -1; + } + sleep_ms(50); + elapsed += 50; + } + return -1; +} + +static void test_stdin_happy_path(void) { + const char *const argv[] = { + "./build/nsigner", "--socket-name", "nsigner_test_mi_stdin_ok", "--mnemonic-stdin", NULL + }; + int cap_fd = -1; + int status = 0; + int fd; + pid_t child = spawn_with_stdin_and_fd(argv, + VALID_MNEMONIC "\n", + 1, + NULL, + -1, + 0, + NULL, + NULL, + &cap_fd); + + check_condition("spawn --mnemonic-stdin happy-path child", child > 0); + if (child <= 0) { + return; + } + + fd = connect_socket_retry("nsigner_test_mi_stdin_ok", 5000); + check_condition("--mnemonic-stdin reaches running server", fd >= 0); + if (fd >= 0) { + close(fd); + } + + (void)kill(child, SIGTERM); + (void)waitpid(child, &status, 0); + if (cap_fd >= 0) { + close(cap_fd); + } +} + +static void test_stdin_eof_without_newline(void) { + const char *const argv[] = { + "./build/nsigner", "--socket-name", "nsigner_test_mi_stdin_eof", "--mnemonic-stdin", NULL + }; + int cap_fd = -1; + int status = 0; + int fd; + pid_t child = spawn_with_stdin_and_fd(argv, + VALID_MNEMONIC, + 1, + NULL, + -1, + 0, + NULL, + NULL, + &cap_fd); + + check_condition("spawn --mnemonic-stdin EOF-without-newline child", child > 0); + if (child <= 0) { + return; + } + + fd = connect_socket_retry("nsigner_test_mi_stdin_eof", 5000); + check_condition("--mnemonic-stdin EOF without newline accepted", fd >= 0); + if (fd >= 0) { + close(fd); + } + + (void)kill(child, SIGTERM); + (void)waitpid(child, &status, 0); + if (cap_fd >= 0) { + close(cap_fd); + } +} + +static void test_stdin_invalid_mnemonic(void) { + const char *const argv[] = { + "./build/nsigner", "--mnemonic-stdin", NULL + }; + int cap_fd = -1; + int status = 0; + char cap[512]; + pid_t child = spawn_with_stdin_and_fd(argv, + "not a valid mnemonic\n", + 1, + NULL, + -1, + 0, + NULL, + NULL, + &cap_fd); + + check_condition("spawn --mnemonic-stdin invalid child", child > 0); + if (child <= 0) { + return; + } + + check_condition("--mnemonic-stdin invalid exits", wait_exit_timeout(child, 2000, &status) == 0); + check_condition("--mnemonic-stdin invalid exits non-zero", WIFEXITED(status) && WEXITSTATUS(status) != 0); + + if (cap_fd >= 0) { + (void)read_capture_nonblocking(cap_fd, cap, sizeof(cap)); + check_condition("--mnemonic-stdin invalid emits validation failure", strstr(cap, "mnemonic validation failed") != NULL); + close(cap_fd); + } +} + +static void test_stdin_overflow(void) { + const char *const argv[] = { + "./build/nsigner", "--mnemonic-stdin", NULL + }; + char long_input[LONG_INPUT_LEN + 2]; + int i; + int cap_fd = -1; + int status = 0; + char cap[512]; + pid_t child; + + for (i = 0; i < LONG_INPUT_LEN; ++i) { + long_input[i] = 'a'; + } + long_input[LONG_INPUT_LEN] = '\n'; + long_input[LONG_INPUT_LEN + 1] = '\0'; + + child = spawn_with_stdin_and_fd(argv, + long_input, + 1, + NULL, + -1, + 0, + NULL, + NULL, + &cap_fd); + + check_condition("spawn --mnemonic-stdin overflow child", child > 0); + if (child <= 0) { + return; + } + + check_condition("--mnemonic-stdin overflow exits", wait_exit_timeout(child, 2000, &status) == 0); + check_condition("--mnemonic-stdin overflow exits non-zero", WIFEXITED(status) && WEXITSTATUS(status) != 0); + + if (cap_fd >= 0) { + (void)read_capture_nonblocking(cap_fd, cap, sizeof(cap)); + check_condition("--mnemonic-stdin overflow emits length error", strstr(cap, "exceeded maximum length") != NULL); + close(cap_fd); + } +} + +static void test_fd_happy_path(void) { + const char *const argv[] = { + "./build/nsigner", "--socket-name", "nsigner_test_mi_fd_ok", "--mnemonic-fd", "3", NULL + }; + int cap_fd = -1; + int status = 0; + int fd; + pid_t child = spawn_with_stdin_and_fd(argv, + "", + 0, + VALID_MNEMONIC "\n", + 3, + 1, + NULL, + NULL, + &cap_fd); + + check_condition("spawn --mnemonic-fd happy-path child", child > 0); + if (child <= 0) { + return; + } + + fd = connect_socket_retry("nsigner_test_mi_fd_ok", 5000); + check_condition("--mnemonic-fd reaches running server", fd >= 0); + if (fd >= 0) { + close(fd); + } + + (void)kill(child, SIGTERM); + (void)waitpid(child, &status, 0); + if (cap_fd >= 0) { + close(cap_fd); + } +} + +static void test_mutual_exclusion_and_validation_errors(void) { + struct { + const char *name; + const char *const *argv; + const char *expect_substr; + } cases[] = { + { + "mutual exclusion stdin+fd", + (const char *const[]){"./build/nsigner", "--mnemonic-stdin", "--mnemonic-fd", "3", NULL}, + "mutually exclusive" + }, + { + "stdin with stdio listener", + (const char *const[]){"./build/nsigner", "--listen", "stdio", "--mnemonic-stdin", NULL}, + "requires --listen unix or --listen tcp" + }, + { + "mnemonic-fd 1 rejected", + (const char *const[]){"./build/nsigner", "--mnemonic-fd", "1", NULL}, + "--mnemonic-fd 1 is not allowed" + }, + { + "mnemonic-fd 0 rejected with stdio", + (const char *const[]){"./build/nsigner", "--listen", "stdio", "--mnemonic-fd", "0", NULL}, + "cannot be used with --listen stdio" + } + }; + + size_t i; + for (i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + int cap_fd = -1; + int status = 0; + char cap[512]; + pid_t child = spawn_with_stdin_and_fd(cases[i].argv, + "", + 1, + NULL, + -1, + 0, + NULL, + NULL, + &cap_fd); + + check_condition(cases[i].name, child > 0); + if (child <= 0) { + continue; + } + + check_condition("expected parse-time failure exits", wait_exit_timeout(child, 2000, &status) == 0); + check_condition("expected parse-time failure non-zero", WIFEXITED(status) && WEXITSTATUS(status) != 0); + + if (cap_fd >= 0) { + (void)read_capture_nonblocking(cap_fd, cap, sizeof(cap)); + check_condition("expected parse-time error message", strstr(cap, cases[i].expect_substr) != NULL); + close(cap_fd); + } + } +} + +static void test_lock_reunlock_falls_back_to_tui_after_fd_startup(void) { + const char *const argv[] = { + "./build/nsigner", "--socket-name", "nsigner_test_mi_lock", "--mnemonic-fd", "3", NULL + }; + int stdin_write = -1; + int cap_fd = -1; + int status = 0; + int fd; + pid_t child = spawn_with_stdin_and_fd(argv, + "", + 0, + VALID_MNEMONIC "\n", + 3, + 1, + &stdin_write, + NULL, + &cap_fd); + + check_condition("spawn --mnemonic-fd lock test child", child > 0); + if (child <= 0) { + return; + } + + fd = connect_socket_retry("nsigner_test_mi_lock", 5000); + check_condition("--mnemonic-fd lock test initial running server", fd >= 0); + if (fd >= 0) { + close(fd); + } + + (void)write_full(stdin_write, "l\n" VALID_MNEMONIC "\n", strlen("l\n" VALID_MNEMONIC "\n")); + sleep_ms(800); + + fd = connect_socket_retry("nsigner_test_mi_lock", 2000); + check_condition("lock+reunlock via TUI keeps server alive", fd >= 0); + if (fd >= 0) { + close(fd); + } + + if (stdin_write >= 0) { + close(stdin_write); + } + + (void)kill(child, SIGTERM); + (void)waitpid(child, &status, 0); + if (cap_fd >= 0) { + close(cap_fd); + } +} + +int main(void) { + (void)signal(SIGPIPE, SIG_IGN); + + test_stdin_happy_path(); + test_stdin_eof_without_newline(); + test_stdin_invalid_mnemonic(); + test_stdin_overflow(); + test_fd_happy_path(); + test_mutual_exclusion_and_validation_errors(); + test_lock_reunlock_falls_back_to_tui_after_fd_startup(); + + if (g_failures == 0) { + printf("ALL TESTS PASSED\n"); + return 0; + } + + printf("TESTS FAILED: %d\n", g_failures); + return 1; +}