Files
n_signer/plans/mnemonic_startup_input.md

196 lines
12 KiB
Markdown

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