Compare commits

...

2 Commits

12 changed files with 1858 additions and 110 deletions

View File

@@ -55,6 +55,7 @@ RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev
# Copy source files # Copy source files
COPY src/ /build/src/ COPY src/ /build/src/
COPY resources/tui_continuous/ /build/resources/tui_continuous/
# Build nsigner as a fully static binary # Build nsigner as a fully static binary
RUN ARCH="$(uname -m)"; \ RUN ARCH="$(uname -m)"; \
@@ -68,6 +69,7 @@ RUN ARCH="$(uname -m)"; \
-I/build/nostr_core_lib \ -I/build/nostr_core_lib \
-I/build/nostr_core_lib/nostr_core \ -I/build/nostr_core_lib/nostr_core \
-I/build/nostr_core_lib/cjson \ -I/build/nostr_core_lib/cjson \
-I/build/resources/tui_continuous \
/build/src/main.c \ /build/src/main.c \
/build/src/secure_mem.c \ /build/src/secure_mem.c \
/build/src/mnemonic.c \ /build/src/mnemonic.c \
@@ -81,6 +83,7 @@ RUN ARCH="$(uname -m)"; \
/build/src/key_store.c \ /build/src/key_store.c \
/build/src/socket_name.c \ /build/src/socket_name.c \
/build/src/auth_envelope.c \ /build/src/auth_envelope.c \
/build/resources/tui_continuous/tui_continuous.c \
"$NOSTR_LIB" \ "$NOSTR_LIB" \
-o /build/nsigner_static \ -o /build/nsigner_static \
$(pkg-config --static --libs libcurl openssl) \ $(pkg-config --static --libs libcurl openssl) \

View File

@@ -1,5 +1,5 @@
CC := gcc 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 LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
SRC_DIR := src SRC_DIR := src
@@ -23,7 +23,8 @@ SOURCES := \
$(SRC_DIR)/transport_frame.c \ $(SRC_DIR)/transport_frame.c \
$(SRC_DIR)/key_store.c \ $(SRC_DIR)/key_store.c \
$(SRC_DIR)/socket_name.c \ $(SRC_DIR)/socket_name.c \
$(SRC_DIR)/auth_envelope.c $(SRC_DIR)/auth_envelope.c \
resources/tui_continuous/tui_continuous.c
HEADERS := HEADERS :=
@@ -38,10 +39,11 @@ TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration
TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope
TEST_QREXEC_AUTH_TARGET := $(BUILD_DIR)/test_qrexec_auth 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_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client
EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_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 all: dev
@@ -66,7 +68,7 @@ static-arm64:
chmod +x ./build_static.sh chmod +x ./build_static.sh
./build_static.sh --arch arm64 ./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: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
./$(TEST_INTEGRATION_TARGET) ./$(TEST_INTEGRATION_TARGET)
@@ -74,6 +76,9 @@ test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
test-mnemonic: $(TEST_MNEMONIC_TARGET) test-mnemonic: $(TEST_MNEMONIC_TARGET)
./$(TEST_MNEMONIC_TARGET) ./$(TEST_MNEMONIC_TARGET)
test-mnemonic-input: $(TEST_MNEMONIC_INPUT_TARGET)
./$(TEST_MNEMONIC_INPUT_TARGET)
test-role: $(TEST_ROLE_TARGET) test-role: $(TEST_ROLE_TARGET)
./$(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) @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) $(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 $(TEST_ROLE_TARGET): $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c -o $(TEST_ROLE_TARGET) $(LDFLAGS) $(CC) $(CFLAGS) $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c -o $(TEST_ROLE_TARGET) $(LDFLAGS)

View File

@@ -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. 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) ### 3.2 Running phase (status display + signer)
After unlock, terminal becomes a live status and control console. Example layout: 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 - [`build_static.sh`](build_static.sh): musl-static build path
- [`Makefile`](Makefile): local build targets - [`Makefile`](Makefile): local build targets
- [`Dockerfile.alpine-musl`](Dockerfile.alpine-musl): reproducible Alpine musl toolchain - [`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 - [`increment_and_push.sh`](increment_and_push.sh): version/tag/release workflow
- [`src/main.c`](src/main.c): version macros (`NSIGNER_VERSION*`) - [`src/main.c`](src/main.c): version macros (`NSIGNER_VERSION*`)

View File

@@ -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). 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 ## 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 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 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. | | 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. Everything else — clients, other processes, other users, remote callers — is **untrusted by default** and must clear an approval check.

View File

@@ -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/<memfd>"|
| | --listen unix --name <chosen> |
| | --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_<chosen> 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 ~13 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 -- <argv>` | preserves stdin |
| `foot` | `-- <argv>` | preserves stdin |
| `alacritty` | `-e <argv>` | preserves stdin |
| `wezterm` | `start -- <argv>` | preserves stdin |
| `xterm` | `-e <argv>` | preserves stdin (legacy reliable) |
| `gnome-terminal` | **avoided** | dbus-launches, drops stdin/FDs |
| `konsole` | `-e <argv>` | 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/<memfd>"` (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 <chosen> --mnemonic-stdin`
- then each `--preapprove <spec>` 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/<memfd>` 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/<memfd>` 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.

View File

@@ -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 <phrase>` 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 <phrase>` argv flag
Listed only to make the rejection explicit and discoverable in `git log`:
- visible to every same-uid process via `/proc/<pid>/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/<pid>/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 <N>` | 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: <strerror>`
- `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.

View File

@@ -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

33
run.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_SCRIPT="$SCRIPT_DIR/build_static.sh"
BUILD_DIR="$SCRIPT_DIR/build"
HOST_UNAME="$(uname -m)"
case "$HOST_UNAME" in
x86_64)
OUTPUT_NAME="nsigner_static_x86_64"
;;
aarch64|arm64)
OUTPUT_NAME="nsigner_static_arm64"
;;
armv7l|armv7)
OUTPUT_NAME="nsigner_static_armv7"
;;
*)
echo "ERROR: Unsupported host architecture '$HOST_UNAME'"
exit 1
;;
esac
"$BUILD_SCRIPT"
BINARY_PATH="$BUILD_DIR/$OUTPUT_NAME"
if [[ ! -x "$BINARY_PATH" ]]; then
echo "ERROR: Compiled binary not found or not executable: $BINARY_PATH"
exit 1
fi
exec "$BINARY_PATH" "$@"

View File

@@ -270,10 +270,12 @@ typedef struct {
} policy_table_t; } policy_table_t;
/* Policy check result */ /* Policy check result */
#define POLICY_ALLOW 0 #define POLICY_ALLOW 0
#define POLICY_DENY -1 #define POLICY_ALLOW_SESSION_VERB 3
#define POLICY_PROMPT -2 /* would need user confirmation */ #define POLICY_ALLOW_SESSION_ALL 4
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */ #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 */ /* Initialize policy table */
void policy_table_init(policy_table_t *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 */ /* Configure non-interactive prompt fallback: -1 disabled, POLICY_ALLOW, or POLICY_DENY */
void server_set_noninteractive_prompt_default(int decision); 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 */ /* 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) */ /* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 0 #define NSIGNER_VERSION_MINOR 0
#define NSIGNER_VERSION_PATCH 25 #define NSIGNER_VERSION_PATCH 27
#define NSIGNER_VERSION "v0.0.25" #define NSIGNER_VERSION "v0.0.27"
/* NSIGNER_HEADERLESS_DECLS_END */ /* 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); int transport_recv_framed(int fd, char **out_payload, size_t max_size);
#include "auth_envelope.h" #include "auth_envelope.h"
#include "tui_continuous.h"
#include <nostr_core/nostr_common.h> #include <nostr_core/nostr_common.h>
#include <arpa/inet.h> #include <arpa/inet.h>
@@ -493,9 +509,12 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size);
#include <errno.h> #include <errno.h>
#include <poll.h> #include <poll.h>
#include <signal.h> #include <signal.h>
#include <stdarg.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <fcntl.h>
#include <limits.h>
#include <strings.h> #include <strings.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/types.h> #include <sys/types.h>
@@ -506,6 +525,7 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size);
#define NSIGNER_DEFAULT_SOCKET_NAME "nsigner" #define NSIGNER_DEFAULT_SOCKET_NAME "nsigner"
#define ACTIVITY_LOG_CAP 16 #define ACTIVITY_LOG_CAP 16
#define CONNECTION_INFO_CAP 8
typedef struct { typedef struct {
char lines[ACTIVITY_LOG_CAP][256]; char lines[ACTIVITY_LOG_CAP][256];
@@ -516,6 +536,66 @@ static volatile sig_atomic_t g_running = 1;
static activity_log_t g_activity_log; static activity_log_t g_activity_log;
static int g_auto_approve = 0; 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 char g_connection_info[CONNECTION_INFO_CAP][192];
static int g_connection_info_count = 0;
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 connection_info_clear(void) {
g_connection_info_count = 0;
memset(g_connection_info, 0, sizeof(g_connection_info));
}
static void connection_info_add(const char *fmt, ...) {
va_list ap;
if (fmt == NULL || g_connection_info_count >= CONNECTION_INFO_CAP) {
return;
}
va_start(ap, fmt);
(void)vsnprintf(g_connection_info[g_connection_info_count],
sizeof(g_connection_info[g_connection_info_count]),
fmt,
ap);
va_end(ap);
g_connection_info_count++;
}
static void handle_signal(int sig) { static void handle_signal(int sig) {
(void)sig; (void)sig;
g_running = 0; g_running = 0;
@@ -542,6 +622,126 @@ static int read_line_stdin(char *buf, size_t buf_sz) {
} }
static int read_cmd_output_local(const char *cmd, char **out_buf) {
FILE *fp;
char chunk[512];
char *buf = NULL;
size_t used = 0;
size_t cap = 0;
if (cmd == NULL || out_buf == NULL) {
return -1;
}
*out_buf = NULL;
fp = popen(cmd, "r");
if (fp == NULL) {
return -1;
}
while (fgets(chunk, sizeof(chunk), fp) != NULL) {
size_t n = strlen(chunk);
if (used + n + 1 > cap) {
size_t new_cap = (cap == 0) ? 2048 : cap * 2;
while (new_cap < used + n + 1) {
new_cap *= 2;
}
{
char *tmp = (char *)realloc(buf, new_cap);
if (tmp == NULL) {
free(buf);
(void)pclose(fp);
return -1;
}
buf = tmp;
cap = new_cap;
}
}
memcpy(buf + used, chunk, n);
used += n;
}
(void)pclose(fp);
if (buf == NULL) {
return -1;
}
buf[used] = '\0';
*out_buf = buf;
return 0;
}
static int lookup_local_fips_identity(char *out_ipv6,
size_t out_ipv6_sz,
char *out_npub,
size_t out_npub_sz) {
char *json = NULL;
cJSON *root = NULL;
cJSON *ipv6_item;
cJSON *npub_item;
if (out_ipv6 == NULL || out_ipv6_sz == 0 || out_npub == NULL || out_npub_sz == 0) {
return -1;
}
out_ipv6[0] = '\0';
out_npub[0] = '\0';
if (read_cmd_output_local("fipsctl show status 2>/dev/null", &json) != 0) {
return -1;
}
root = cJSON_Parse(json);
free(json);
if (root == NULL) {
return -1;
}
ipv6_item = cJSON_GetObjectItemCaseSensitive(root, "ipv6_addr");
npub_item = cJSON_GetObjectItemCaseSensitive(root, "npub");
if (cJSON_IsString(ipv6_item) && ipv6_item->valuestring != NULL) {
strncpy(out_ipv6, ipv6_item->valuestring, out_ipv6_sz - 1);
out_ipv6[out_ipv6_sz - 1] = '\0';
}
if (cJSON_IsString(npub_item) && npub_item->valuestring != NULL) {
strncpy(out_npub, npub_item->valuestring, out_npub_sz - 1);
out_npub[out_npub_sz - 1] = '\0';
}
cJSON_Delete(root);
return (out_ipv6[0] != '\0' && out_npub[0] != '\0') ? 0 : -1;
}
static int extract_listen_port(const char *listen_target, char *out_port, size_t out_port_sz) {
const char *port;
size_t i;
if (listen_target == NULL || out_port == NULL || out_port_sz == 0) {
return -1;
}
out_port[0] = '\0';
port = strrchr(listen_target, ':');
if (port == NULL || *(port + 1) == '\0') {
return -1;
}
port++;
for (i = 0; port[i] != '\0'; ++i) {
if (!isdigit((unsigned char)port[i])) {
return -1;
}
}
strncpy(out_port, port, out_port_sz - 1);
out_port[out_port_sz - 1] = '\0';
return 0;
}
static int connect_abstract_socket(const char *name) { static int connect_abstract_socket(const char *name) {
int fd; int fd;
struct sockaddr_un addr; struct sockaddr_un addr;
@@ -572,24 +772,27 @@ static int connect_abstract_socket(const char *name) {
} }
static void print_usage(const char *program_name) { static void print_usage(const char *program_name) {
printf("nsigner - single-binary signer program\n"); tui_print("nsigner - single-binary signer program");
printf("Usage:\n"); tui_print("Usage:");
printf(" %s [--socket-name|--name|-n <name>] [--listen|-l <unix|stdio|qrexec|tcp:HOST:PORT>]\n", program_name); tui_print(" %s [--socket-name|--name|-n <name>] [--listen|-l <unix|stdio|qrexec|tcp:HOST:PORT>]", program_name);
printf(" [--preapprove|-p <SPEC>]... [--auth|-a <off|optional|required>]\n"); tui_print(" [--preapprove|-p <SPEC>]... [--auth|-a <off|optional|required>]");
printf(" [--allow-all|-A]\n"); tui_print(" [--mnemonic-stdin|--mnemonic-fd <N>] [--allow-all|-A]");
printf(" Run signer server (unix mode has TUI)\n"); tui_print(" Run signer server (unix mode has TUI)");
printf(" %s [--socket-name|--name|-n <name>] client '<json>' Send JSON-RPC request\n", program_name); tui_print(" %s [--socket-name|--name|-n <name>] client '<json>' Send JSON-RPC request", program_name);
printf(" %s [--socket-name|--name|-n <name>] client - Read JSON-RPC request from stdin\n", program_name); tui_print(" %s [--socket-name|--name|-n <name>] client - Read JSON-RPC request from stdin", program_name);
printf(" %s list List running nsigner abstract sockets\n", program_name); tui_print(" %s list List running nsigner abstract sockets", program_name);
printf(" %s --help\n", program_name); tui_print(" %s --help", program_name);
printf(" %s --version\n", program_name); tui_print(" %s --version", program_name);
printf("\nOptions:\n"); tui_print("");
printf(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT\n"); tui_print("Options:");
printf(" --preapprove, -p SPEC\n"); tui_print(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT");
printf(" Pre-approve a caller for a role (repeatable)\n"); tui_print(" --preapprove, -p SPEC");
printf(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<n>\n"); tui_print(" Pre-approve a caller for a role (repeatable)");
printf(" --auth, -a MODE Auth envelope policy per listener: off|optional|required\n"); tui_print(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<n>");
printf(" --allow-all, -A Allow all policy prompts for this server session\n"); 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, static int extract_nsigner_socket_from_proc_line(const char *line,
@@ -803,50 +1006,161 @@ static void tcp_activity_stdout_cb(const char *message, void *user_data) {
fflush(stdout); 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, static void render_status(const role_table_t *role_table,
const mnemonic_state_t *mnemonic, const mnemonic_state_t *mnemonic,
int derived_count, int derived_count,
const char *socket_name) { const char *socket_name) {
int i; TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Main Menu" };
int shown; TuiMenu menu = { g_main_menu_items, (int)(sizeof(g_main_menu_items) / sizeof(g_main_menu_items[0])) };
TuiSize size = tui_terminal_size();
int left_col = tui_menu_left_col(&frame, size.width);
char status_buf[256];
printf("\n=== n_signer %s ===\n", NSIGNER_VERSION); tui_clear_continuous(size.height);
printf("session: %s (%d words)\n", tui_render_top_frame(&frame, size.width);
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);
printf("\nRoles\n-----\n"); tui_print("^*Connections^:");
if (g_connection_info_count == 0) {
tui_print("(none)");
} else {
for (int i = 0; i < g_connection_info_count; ++i) {
tui_print("%s", g_connection_info[i]);
}
}
tui_print("");
tui_render_menu(&menu, left_col);
tui_print("");
(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");
tui_print("%s", status_buf);
tui_print("^*Roles^:");
if (role_table == NULL || role_table->count == 0) { if (role_table == NULL || role_table->count == 0) {
printf("(none)\n"); tui_print("(none)");
} else { } else {
for (i = 0; i < role_table->count; ++i) { static const TuiColumn columns[] = {
const role_entry_t *r = &role_table->entries[i]; {"Role", 20, 0},
printf("%s purpose=%s curve=%s selector=%s\n", {"Purpose", 12, 0},
r->name, {"Curve", 12, 0},
role_purpose_to_str(r->purpose), {"Selector", 12, 0}
role_curve_to_str(r->curve), };
(r->selector_type == SELECTOR_NOSTR_INDEX) ? "nostr_index" : "role_path"); 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) { if (g_activity_log.count == 0) {
printf("(none)\n"); tui_print("(none)");
} else { } else {
shown = 0; int i;
int shown = 0;
for (i = g_activity_log.count - 1; i >= 0 && shown < ACTIVITY_LOG_CAP; --i, ++shown) { 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"); tui_anchor_prompt(0, left_col);
printf("q/x quit l lock/reunlock r refresh A toggle auto-approve(prompt): %s\n",
g_auto_approve ? "ON" : "OFF");
fflush(stdout); 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) { static int setup_default_role(role_table_t *role_table) {
role_entry_t role; role_entry_t role;
@@ -867,7 +1181,7 @@ static int setup_default_role(role_table_t *role_table) {
return role_table_add(role_table, &role); 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[MNEMONIC_MAX_LEN];
char phrase_copy[MNEMONIC_MAX_LEN]; char phrase_copy[MNEMONIC_MAX_LEN];
char mode[MNEMONIC_MAX_LEN]; char mode[MNEMONIC_MAX_LEN];
@@ -879,8 +1193,14 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
} }
while (invalid_attempts < max_invalid_attempts) { 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) { if (read_line_stdin(mode, sizeof(mode)) != 0) {
fprintf(stderr, "Failed to read mnemonic source choice\n"); fprintf(stderr, "Failed to read mnemonic source choice\n");
return -1; return -1;
@@ -918,13 +1238,24 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
strncpy(phrase_copy, phrase, sizeof(phrase_copy) - 1); strncpy(phrase_copy, phrase, sizeof(phrase_copy) - 1);
phrase_copy[sizeof(phrase_copy) - 1] = '\0'; 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); word = strtok_r(phrase_copy, " ", &ctx);
while (word != NULL) { while (word != NULL) {
printf("%2d. %s\n", idx, word); printf("%2d. %s\n", idx, word);
idx++; idx++;
word = strtok_r(NULL, " ", &ctx); word = strtok_r(NULL, " ", &ctx);
} }
tui_print("");
tui_print("Press Enter after writing down your mnemonic to continue.");
printf("> ");
fflush(stdout);
if (read_line_stdin(mode, sizeof(mode)) != 0) {
fprintf(stderr, "Failed to read continue input\n");
memset(phrase, 0, sizeof(phrase));
memset(phrase_copy, 0, sizeof(phrase_copy));
return -1;
}
if (mnemonic_load(mnemonic, phrase) != 0) { if (mnemonic_load(mnemonic, phrase) != 0) {
memset(phrase, 0, sizeof(phrase)); memset(phrase, 0, sizeof(phrase));
@@ -939,8 +1270,13 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
return 0; 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) { if (read_line_stdin(phrase, sizeof(phrase)) != 0) {
fprintf(stderr, "Failed to read mnemonic\n"); fprintf(stderr, "Failed to read mnemonic\n");
@@ -971,6 +1307,118 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
return -1; 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) { static void apply_test_overrides(policy_table_t *policy) {
const char *force_prompt; const char *force_prompt;
const char *noninteractive_prompt; const char *noninteractive_prompt;
@@ -1035,6 +1483,7 @@ int main(int argc, char *argv[]) {
int auth_mode = NSIGNER_AUTH_OFF; int auth_mode = NSIGNER_AUTH_OFF;
int auth_skew_seconds = AUTH_DEFAULT_SKEW_SECONDS; int auth_skew_seconds = AUTH_DEFAULT_SKEW_SECONDS;
int allow_all = 0; int allow_all = 0;
mnemonic_source_t mnemonic_source = { MNEMONIC_SOURCE_TUI, -1 };
while (argi < argc) { while (argi < argc) {
if (strcmp(argv[argi], "--socket-name") == 0 || if (strcmp(argv[argi], "--socket-name") == 0 ||
@@ -1101,6 +1550,38 @@ int main(int argc, char *argv[]) {
argi += 2; argi += 2;
continue; 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) { if (strcmp(argv[argi], "--allow-all") == 0 || strcmp(argv[argi], "-A") == 0) {
allow_all = 1; allow_all = 1;
argi += 1; argi += 1;
@@ -1141,11 +1622,30 @@ int main(int argc, char *argv[]) {
return 1; 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); printf("nsigner %s\n", NSIGNER_VERSION);
fflush(stdout); fflush(stdout);
g_startup_mnemonic_source_kind = mnemonic_source.kind;
mnemonic_init(&mnemonic); mnemonic_init(&mnemonic);
if (prompt_load_mnemonic(&mnemonic) != 0) { if (load_mnemonic(&mnemonic, &mnemonic_source) != 0) {
mnemonic_unload(&mnemonic); mnemonic_unload(&mnemonic);
return 1; return 1;
} }
@@ -1274,14 +1774,42 @@ int main(int argc, char *argv[]) {
(void)signal(SIGINT, handle_signal); (void)signal(SIGINT, handle_signal);
(void)signal(SIGTERM, handle_signal); (void)signal(SIGTERM, handle_signal);
if (listen_mode == NSIGNER_LISTEN_UNIX) { {
printf("System is ready and waiting for connections on @%s.\n", socket_name); char fips_ipv6[128];
} else if (listen_mode == NSIGNER_LISTEN_TCP) { char fips_npub[256];
printf("System is ready and waiting for connections on %s.\n", listen_target); char listen_port[16];
} else if (listen_mode == NSIGNER_LISTEN_QREXEC) { int have_fips_identity =
printf("System is ready and waiting for a qrexec request.\n"); (lookup_local_fips_identity(fips_ipv6, sizeof(fips_ipv6), fips_npub, sizeof(fips_npub)) == 0);
} else {
printf("System is ready and waiting for a stdio request.\n"); connection_info_clear();
if (listen_mode == NSIGNER_LISTEN_UNIX) {
connection_info_add("listen: unix @%s", socket_name);
connection_info_add("client: nsigner --socket-name %s client '<json>'", socket_name);
printf("System is ready and waiting for connections on @%s.\n", socket_name);
} else if (listen_mode == NSIGNER_LISTEN_TCP) {
connection_info_add("listen: %s", listen_target);
printf("System is ready and waiting for connections on %s.\n", listen_target);
} else if (listen_mode == NSIGNER_LISTEN_QREXEC) {
connection_info_add("listen: qrexec");
printf("System is ready and waiting for a qrexec request.\n");
} else {
connection_info_add("listen: stdio");
printf("System is ready and waiting for a stdio request.\n");
}
if (have_fips_identity) {
connection_info_add("fips ipv6: %s", fips_ipv6);
connection_info_add("fips npub: %s", fips_npub);
printf("fips ipv6: %s\n", fips_ipv6);
printf("fips npub: %s\n", fips_npub);
if (listen_mode == NSIGNER_LISTEN_TCP &&
extract_listen_port(listen_target, listen_port, sizeof(listen_port)) == 0) {
connection_info_add("fips address: http://%s.fips:%s", fips_npub, listen_port);
printf("fips address: http://%s.fips:%s\n", fips_npub, listen_port);
}
}
} }
fflush(stdout); fflush(stdout);
@@ -1323,59 +1851,84 @@ int main(int argc, char *argv[]) {
memset(&g_activity_log, 0, sizeof(g_activity_log)); memset(&g_activity_log, 0, sizeof(g_activity_log));
g_auto_approve = 0; g_auto_approve = 0;
server_set_prompt_always_allow(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].fd = server.listen_fd;
pfds[0].events = POLLIN; pfds[0].events = POLLIN;
pfds[1].fd = STDIN_FILENO; pfds[1].fd = STDIN_FILENO;
pfds[1].events = POLLIN; pfds[1].events = POLLIN;
while (g_running && server.running) { while (g_running && server.running) {
int prc = poll(pfds, 2, 200); int prc = poll(pfds, 2, 200);
if (prc < 0) { if (prc < 0) {
if (errno == EINTR) { if (errno == EINTR) {
continue; continue;
} }
break;
}
if (prc > 0 && (pfds[0].revents & POLLIN)) {
int hrc = server_handle_one(&server, activity_log_cb, NULL);
if (hrc < 0) {
break; break;
} }
render_status(&role_table, &mnemonic, derived_count, socket_name);
}
if (prc > 0 && (pfds[1].revents & POLLIN)) { if (tui_resize_pending()) {
char ch = '\0'; render_status(&role_table, &mnemonic, derived_count, socket_name);
if (read(STDIN_FILENO, &ch, 1) > 0) { }
char lower = (char)tolower((unsigned char)ch);
if (lower == 'q' || lower == 'x') { if (prc > 0 && (pfds[0].revents & POLLIN)) {
g_running = 0; int hrc = server_handle_one(&server, activity_log_cb, NULL);
} else if (lower == 'r') { if (hrc < 0) {
render_status(&role_table, &mnemonic, derived_count, socket_name); break;
} else if (ch == 'A') { }
g_auto_approve = g_auto_approve ? 0 : 1; render_status(&role_table, &mnemonic, derived_count, socket_name);
server_set_prompt_always_allow(g_auto_approve); }
render_status(&role_table, &mnemonic, derived_count, socket_name);
} else if (lower == 'l') { if (prc > 0 && (pfds[1].revents & POLLIN)) {
printf("\n[lock] Session locked. Re-enter mnemonic to unlock.\n"); char ch = '\0';
fflush(stdout); if (read(STDIN_FILENO, &ch, 1) > 0) {
crypto_wipe(&key_store); char lower = (char)tolower((unsigned char)ch);
mnemonic_unload(&mnemonic); if (lower == 'q' || lower == 'x') {
if (prompt_load_mnemonic(&mnemonic) != 0) {
g_running = 0; g_running = 0;
} else { } else if (lower == 'r') {
derived_count = crypto_derive_all(&key_store, &role_table, &mnemonic); render_status(&role_table, &mnemonic, derived_count, socket_name);
if (derived_count < 0) { } else if (ch == 'A') {
g_running = 0; 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); server_stop(&server);

View File

@@ -454,6 +454,19 @@ void server_stop(server_ctx_t *ctx);
/* Extract caller identity from connected fd */ /* Extract caller identity from connected fd */
int server_get_caller(int fd, caller_identity_t *out); 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 */ /* from socket_name.h */
@@ -502,6 +515,8 @@ static int g_prompt_always_allow = 0;
static int g_noninteractive_prompt_default = -1; static int g_noninteractive_prompt_default = -1;
static auth_nonce_cache_t g_auth_nonce_cache; static auth_nonce_cache_t g_auth_nonce_cache;
static int g_auth_nonce_cache_inited = 0; 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) { static int caller_id_extract_ipv6(const char *caller_id, char *out_ipv6, size_t out_sz) {
const char *start; 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, static int prompt_for_policy_decision(const caller_identity_t *caller,
const char *method, const char *method,
const char *role_name, const char *role_name,
@@ -685,6 +705,32 @@ static int prompt_for_policy_decision(const caller_identity_t *caller,
return POLICY_DENY; 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("\nApproval required\n");
printf("caller: %s\n", (caller != NULL) ? caller->caller_id : "unknown"); printf("caller: %s\n", (caller != NULL) ? caller->caller_id : "unknown");
if (caller != NULL && caller->kind == NSIGNER_LISTEN_TCP) { 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 npub[128];
char display_name[128]; char display_name[128];
npub[0] = '\0';
display_name[0] = '\0';
if (caller_id_extract_ipv6(caller->caller_id, ipv6, sizeof(ipv6)) == 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) { lookup_fips_peer_for_ipv6(ipv6, npub, sizeof(npub), display_name, sizeof(display_name)) == 0) {
if (display_name[0] != '\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("method: %s\n", (method != NULL) ? method : "unknown");
printf("role: %s\n", (role_name != NULL) ? role_name : "unknown"); printf("role: %s\n", (role_name != NULL) ? role_name : "unknown");
printf("purpose: %s\n", (purpose != NULL) ? purpose : "unknown"); printf("purpose: %s\n", (purpose != NULL) ? purpose : "unknown");

View File

@@ -172,5 +172,13 @@ else
echo "[warn] no @nsigner entries found" echo "[warn] no @nsigner entries found"
fi 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
echo "Smoke test complete." echo "Smoke test complete."

532
tests/test_mnemonic_input.c Normal file
View File

@@ -0,0 +1,532 @@
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/un.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#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;
}