v0.0.26 - Add mnemonic-stdin and mnemonic-fd startup input modes with tests/docs
This commit is contained in:
195
plans/mnemonic_startup_input.md
Normal file
195
plans/mnemonic_startup_input.md
Normal 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.
|
||||
Reference in New Issue
Block a user