Compare commits
2 Commits
v0.0.26
...
44372c0108
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44372c0108 | ||
|
|
5c214f3614 |
@@ -55,6 +55,7 @@ RUN if [ "$(uname -m)" = "aarch64" ] && ! command -v aarch64-linux-gnu-gcc >/dev
|
||||
|
||||
# Copy source files
|
||||
COPY src/ /build/src/
|
||||
COPY resources/tui_continuous/ /build/resources/tui_continuous/
|
||||
|
||||
# Build nsigner as a fully static binary
|
||||
RUN ARCH="$(uname -m)"; \
|
||||
@@ -68,6 +69,7 @@ RUN ARCH="$(uname -m)"; \
|
||||
-I/build/nostr_core_lib \
|
||||
-I/build/nostr_core_lib/nostr_core \
|
||||
-I/build/nostr_core_lib/cjson \
|
||||
-I/build/resources/tui_continuous \
|
||||
/build/src/main.c \
|
||||
/build/src/secure_mem.c \
|
||||
/build/src/mnemonic.c \
|
||||
@@ -81,6 +83,7 @@ RUN ARCH="$(uname -m)"; \
|
||||
/build/src/key_store.c \
|
||||
/build/src/socket_name.c \
|
||||
/build/src/auth_envelope.c \
|
||||
/build/resources/tui_continuous/tui_continuous.c \
|
||||
"$NOSTR_LIB" \
|
||||
-o /build/nsigner_static \
|
||||
$(pkg-config --static --libs libcurl openssl) \
|
||||
|
||||
17
Makefile
17
Makefile
@@ -1,5 +1,5 @@
|
||||
CC := gcc
|
||||
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson
|
||||
CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous
|
||||
LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1
|
||||
|
||||
SRC_DIR := src
|
||||
@@ -23,7 +23,8 @@ SOURCES := \
|
||||
$(SRC_DIR)/transport_frame.c \
|
||||
$(SRC_DIR)/key_store.c \
|
||||
$(SRC_DIR)/socket_name.c \
|
||||
$(SRC_DIR)/auth_envelope.c
|
||||
$(SRC_DIR)/auth_envelope.c \
|
||||
resources/tui_continuous/tui_continuous.c
|
||||
|
||||
HEADERS :=
|
||||
|
||||
@@ -38,10 +39,11 @@ TEST_INTEGRATION_TARGET := $(BUILD_DIR)/test_integration
|
||||
TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name
|
||||
TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope
|
||||
TEST_QREXEC_AUTH_TARGET := $(BUILD_DIR)/test_qrexec_auth
|
||||
TEST_MNEMONIC_INPUT_TARGET := $(BUILD_DIR)/test_mnemonic_input
|
||||
EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client
|
||||
EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client
|
||||
|
||||
.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth examples test-client clean
|
||||
.PHONY: all lib dev static static-debug static-arm64 test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth examples test-client clean
|
||||
|
||||
all: dev
|
||||
|
||||
@@ -66,7 +68,7 @@ static-arm64:
|
||||
chmod +x ./build_static.sh
|
||||
./build_static.sh --arch arm64
|
||||
|
||||
test: lib test-mnemonic test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-client
|
||||
test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-client
|
||||
|
||||
test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
||||
./$(TEST_INTEGRATION_TARGET)
|
||||
@@ -74,6 +76,9 @@ test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV)
|
||||
test-mnemonic: $(TEST_MNEMONIC_TARGET)
|
||||
./$(TEST_MNEMONIC_TARGET)
|
||||
|
||||
test-mnemonic-input: $(TEST_MNEMONIC_INPUT_TARGET)
|
||||
./$(TEST_MNEMONIC_INPUT_TARGET)
|
||||
|
||||
test-role: $(TEST_ROLE_TARGET)
|
||||
./$(TEST_ROLE_TARGET)
|
||||
|
||||
@@ -106,6 +111,10 @@ $(TEST_MNEMONIC_TARGET): $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_mnemonic.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MNEMONIC_TARGET) $(LDFLAGS)
|
||||
|
||||
$(TEST_MNEMONIC_INPUT_TARGET): $(TEST_DIR)/test_mnemonic_input.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) -I$(CLIENT_DIR) $(TEST_DIR)/test_mnemonic_input.c -o $(TEST_MNEMONIC_INPUT_TARGET) $(LDFLAGS)
|
||||
|
||||
$(TEST_ROLE_TARGET): $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(CC) $(CFLAGS) $(TEST_DIR)/test_role_table.c $(SRC_DIR)/role_table.c -o $(TEST_ROLE_TARGET) $(LDFLAGS)
|
||||
|
||||
@@ -80,6 +80,13 @@ When started, `n_signer` immediately enters terminal input mode:
|
||||
|
||||
No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash.
|
||||
|
||||
For parent-process launchers, startup can also be non-interactive:
|
||||
|
||||
- `--mnemonic-stdin`: read one mnemonic line from stdin at startup, then continue normally.
|
||||
- `--mnemonic-fd N`: read one mnemonic line from inherited file descriptor `N` at startup.
|
||||
|
||||
These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. See [`plans/mnemonic_startup_input.md`](plans/mnemonic_startup_input.md) for the full behavior contract.
|
||||
|
||||
### 3.2 Running phase (status display + signer)
|
||||
|
||||
After unlock, terminal becomes a live status and control console. Example layout:
|
||||
@@ -366,6 +373,7 @@ Build outputs a single executable artifact.
|
||||
- [`build_static.sh`](build_static.sh): musl-static build path
|
||||
- [`Makefile`](Makefile): local build targets
|
||||
- [`Dockerfile.alpine-musl`](Dockerfile.alpine-musl): reproducible Alpine musl toolchain
|
||||
- [`resources/tui_continuous/tui_continuous.h`](resources/tui_continuous/tui_continuous.h) + [`resources/tui_continuous/tui_continuous.c`](resources/tui_continuous/tui_continuous.c): vendored continuous-TUI component (from `~/lt/aesthetics`, API baseline `TUI_CONTINUOUS_VERSION 0.0.9`)
|
||||
- [`increment_and_push.sh`](increment_and_push.sh): version/tag/release workflow
|
||||
- [`src/main.c`](src/main.c): version macros (`NSIGNER_VERSION*`)
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ The security posture is intentionally minimalist:
|
||||
|
||||
Authoritative behavior reference: [`README.md`](../README.md). Implementation roadmap: [`plans/nsigner.md`](../plans/nsigner.md). Approval model plan: [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md). Wire contract for clients: [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md).
|
||||
|
||||
Interactive terminal presentation now follows the continuous-TUI conventions from `~/lt/aesthetics/TUI.md`, implemented via vendored [`resources/tui_continuous/tui_continuous.h`](../resources/tui_continuous/tui_continuous.h) / [`resources/tui_continuous/tui_continuous.c`](../resources/tui_continuous/tui_continuous.c) (baseline `TUI_CONTINUOUS_VERSION 0.0.9`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Threat model
|
||||
@@ -49,6 +51,7 @@ Authoritative behavior reference: [`README.md`](../README.md). Implementation ro
|
||||
| The kernel and `mlock` / `getrandom` syscalls | Used for in-memory protection and entropy. |
|
||||
| The static binary itself | Built reproducibly via [`build_static.sh`](../build_static.sh) and shipped as one musl-static artifact. |
|
||||
| The launcher that started `n_signer` | Whoever ran the process chose the `--preapprove` flags. In interactive use that's the human; in `n_OS_tr` boot it's the OS init system. The launcher's integrity is part of the trust chain. |
|
||||
| Parent process (when using `--mnemonic-stdin` / `--mnemonic-fd`) | In non-interactive startup mode, the parent provides mnemonic bytes over stdin/inherited FD. That parent is now explicitly in the trust chain for mnemonic handling correctness and secrecy. |
|
||||
|
||||
Everything else — clients, other processes, other users, remote callers — is **untrusted by default** and must clear an approval check.
|
||||
|
||||
|
||||
253
plans/embedded_nsigner_spawn.md
Normal file
253
plans/embedded_nsigner_spawn.md
Normal 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 ~1–3 MB for the musl static build).
|
||||
|
||||
### 3.3 Symbol declaration
|
||||
|
||||
```c
|
||||
extern const unsigned char _binary_nsigner_start[];
|
||||
extern const unsigned char _binary_nsigner_end[];
|
||||
/* size = _binary_nsigner_end - _binary_nsigner_start */
|
||||
```
|
||||
|
||||
## 4. Runtime spawn helper API
|
||||
|
||||
New file pair: `client/nsigner_spawn.h` and `client/nsigner_spawn.c`.
|
||||
|
||||
### 4.1 Public API sketch
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
/* required */
|
||||
const char *mnemonic; /* will be written to child stdin pipe and then wiped */
|
||||
size_t mnemonic_len;
|
||||
|
||||
/* optional */
|
||||
const char *const *preapprove_specs; /* array of "caller=...,nostr_index=N" strings */
|
||||
size_t preapprove_count;
|
||||
|
||||
const char *socket_name; /* if NULL, helper picks a random BIP-39 pair */
|
||||
const char *terminal_hint; /* "kitty"|"foot"|"alacritty"|"xterm"|... or NULL = autodetect */
|
||||
|
||||
/* outputs */
|
||||
char *out_socket_name; /* caller-supplied buffer */
|
||||
size_t out_socket_name_size;
|
||||
|
||||
pid_t *out_terminal_pid; /* may be NULL */
|
||||
} nsigner_spawn_opts_t;
|
||||
|
||||
/* Returns 0 on success, negative on error. On success, the embedded nsigner
|
||||
* binary has been written to a memfd, the terminal window has been launched,
|
||||
* the mnemonic has been written into the child's stdin pipe, and the local
|
||||
* `mnemonic` buffer has been zeroized.
|
||||
*
|
||||
* After return, the host can connect to the socket using
|
||||
* client/nsigner_client.{c,h}
|
||||
* with the chosen socket name in opts->out_socket_name.
|
||||
*/
|
||||
int nsigner_spawn_embedded(nsigner_spawn_opts_t *opts);
|
||||
```
|
||||
|
||||
Optional readiness primitive (post-MVP): a `nsigner_spawn_wait_ready()` call that polls the abstract socket until it accepts a connection or until a timeout, so the host doesn't race the child's bind step.
|
||||
|
||||
### 4.2 Internal pipeline
|
||||
|
||||
1. Validate inputs: non-empty mnemonic, sane buffer sizes, `out_socket_name` non-NULL.
|
||||
2. Pick a socket name: random BIP-39 word pair if not provided. Reuses the same name-generation routine that `nsigner` itself uses; consider extracting it into a shared header `src/socket_name.h` already exists ([`src/socket_name.c`](../src/socket_name.c:1)) and exposing the generator publicly.
|
||||
3. Create memfd: `memfd_create("nsigner", MFD_CLOEXEC)`. On older kernels (no `memfd_create`), return an error rather than falling back to disk — the whole point is no filesystem footprint. (Linux 3.17+ has memfd; the project's deployment targets all qualify.)
|
||||
4. Write the embedded blob to the memfd. `lseek` back to zero is unnecessary because `fexecve`/`execveat` does not care about offset.
|
||||
5. Create stdin pipe: `pipe2(stdin_pipe, O_CLOEXEC)`. Mark only the read end as inheritable in the child (clear `FD_CLOEXEC` on the read end after `fork`).
|
||||
6. Pick a terminal emulator. Detection order: `$NSIGNER_TERMINAL` env override, then `terminal_hint`, then a built-in priority list:
|
||||
|
||||
| Emulator | Run-this flag | Notes |
|
||||
|---|---|---|
|
||||
| `kitty` | `--detach -- <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.
|
||||
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.
|
||||
101
plans/tui_continuous_integration.md
Normal file
101
plans/tui_continuous_integration.md
Normal 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
33
run.sh
Executable 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" "$@"
|
||||
673
src/main.c
673
src/main.c
@@ -271,6 +271,8 @@ typedef struct {
|
||||
|
||||
/* Policy check result */
|
||||
#define POLICY_ALLOW 0
|
||||
#define POLICY_ALLOW_SESSION_VERB 3
|
||||
#define POLICY_ALLOW_SESSION_ALL 4
|
||||
#define POLICY_DENY -1
|
||||
#define POLICY_PROMPT -2 /* would need user confirmation */
|
||||
#define POLICY_NO_MATCH -3 /* no policy entry matched (fail-closed = deny) */
|
||||
@@ -455,6 +457,19 @@ void server_set_prompt_always_allow(int enabled);
|
||||
/* Configure non-interactive prompt fallback: -1 disabled, POLICY_ALLOW, or POLICY_DENY */
|
||||
void server_set_noninteractive_prompt_default(int decision);
|
||||
|
||||
typedef struct {
|
||||
const caller_identity_t *caller;
|
||||
const char *method;
|
||||
const char *role_name;
|
||||
const char *purpose;
|
||||
int pending_derivation;
|
||||
const char *fips_peer_npub;
|
||||
const char *fips_peer_name;
|
||||
} server_approval_request_t;
|
||||
|
||||
typedef int (*server_approval_cb)(const server_approval_request_t *req, void *user_data);
|
||||
void server_set_approval_cb(server_approval_cb cb, void *user_data);
|
||||
|
||||
/* from socket_name.h */
|
||||
|
||||
|
||||
@@ -476,8 +491,8 @@ int socket_name_random(char *out, size_t out_len);
|
||||
/* Version information (auto-updated by build/version tooling) */
|
||||
#define NSIGNER_VERSION_MAJOR 0
|
||||
#define NSIGNER_VERSION_MINOR 0
|
||||
#define NSIGNER_VERSION_PATCH 25
|
||||
#define NSIGNER_VERSION "v0.0.25"
|
||||
#define NSIGNER_VERSION_PATCH 27
|
||||
#define NSIGNER_VERSION "v0.0.27"
|
||||
|
||||
|
||||
/* NSIGNER_HEADERLESS_DECLS_END */
|
||||
@@ -486,6 +501,7 @@ int transport_send_framed(int fd, const char *payload);
|
||||
int transport_recv_framed(int fd, char **out_payload, size_t max_size);
|
||||
|
||||
#include "auth_envelope.h"
|
||||
#include "tui_continuous.h"
|
||||
#include <nostr_core/nostr_common.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 <poll.h>
|
||||
#include <signal.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <strings.h>
|
||||
#include <sys/socket.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 ACTIVITY_LOG_CAP 16
|
||||
#define CONNECTION_INFO_CAP 8
|
||||
|
||||
typedef struct {
|
||||
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 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) {
|
||||
(void)sig;
|
||||
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) {
|
||||
int fd;
|
||||
struct sockaddr_un addr;
|
||||
@@ -572,24 +772,27 @@ static int connect_abstract_socket(const char *name) {
|
||||
}
|
||||
|
||||
static void print_usage(const char *program_name) {
|
||||
printf("nsigner - single-binary signer program\n");
|
||||
printf("Usage:\n");
|
||||
printf(" %s [--socket-name|--name|-n <name>] [--listen|-l <unix|stdio|qrexec|tcp:HOST:PORT>]\n", program_name);
|
||||
printf(" [--preapprove|-p <SPEC>]... [--auth|-a <off|optional|required>]\n");
|
||||
printf(" [--allow-all|-A]\n");
|
||||
printf(" Run signer server (unix mode has TUI)\n");
|
||||
printf(" %s [--socket-name|--name|-n <name>] client '<json>' Send JSON-RPC request\n", program_name);
|
||||
printf(" %s [--socket-name|--name|-n <name>] client - Read JSON-RPC request from stdin\n", program_name);
|
||||
printf(" %s list List running nsigner abstract sockets\n", program_name);
|
||||
printf(" %s --help\n", program_name);
|
||||
printf(" %s --version\n", program_name);
|
||||
printf("\nOptions:\n");
|
||||
printf(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT\n");
|
||||
printf(" --preapprove, -p SPEC\n");
|
||||
printf(" Pre-approve a caller for a role (repeatable)\n");
|
||||
printf(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<n>\n");
|
||||
printf(" --auth, -a MODE Auth envelope policy per listener: off|optional|required\n");
|
||||
printf(" --allow-all, -A Allow all policy prompts for this server session\n");
|
||||
tui_print("nsigner - single-binary signer program");
|
||||
tui_print("Usage:");
|
||||
tui_print(" %s [--socket-name|--name|-n <name>] [--listen|-l <unix|stdio|qrexec|tcp:HOST:PORT>]", program_name);
|
||||
tui_print(" [--preapprove|-p <SPEC>]... [--auth|-a <off|optional|required>]");
|
||||
tui_print(" [--mnemonic-stdin|--mnemonic-fd <N>] [--allow-all|-A]");
|
||||
tui_print(" Run signer server (unix mode has TUI)");
|
||||
tui_print(" %s [--socket-name|--name|-n <name>] client '<json>' Send JSON-RPC request", program_name);
|
||||
tui_print(" %s [--socket-name|--name|-n <name>] client - Read JSON-RPC request from stdin", program_name);
|
||||
tui_print(" %s list List running nsigner abstract sockets", program_name);
|
||||
tui_print(" %s --help", program_name);
|
||||
tui_print(" %s --version", program_name);
|
||||
tui_print("");
|
||||
tui_print("Options:");
|
||||
tui_print(" --listen, -l MODE Listener transport: unix|stdio|qrexec|tcp:HOST:PORT");
|
||||
tui_print(" --preapprove, -p SPEC");
|
||||
tui_print(" Pre-approve a caller for a role (repeatable)");
|
||||
tui_print(" SPEC: caller=<id>,role=<name> or caller=<id>,nostr_index=<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,
|
||||
@@ -803,50 +1006,161 @@ static void tcp_activity_stdout_cb(const char *message, void *user_data) {
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static void role_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
|
||||
const role_table_view_data_t *view = (const role_table_view_data_t *)user_data;
|
||||
const role_entry_t *r;
|
||||
|
||||
if (out == NULL || out_size == 0 || view == NULL || view->role_table == NULL ||
|
||||
row < 0 || row >= view->role_table->count) {
|
||||
return;
|
||||
}
|
||||
|
||||
r = &view->role_table->entries[row];
|
||||
switch (col) {
|
||||
case 0:
|
||||
(void)snprintf(out, out_size, "%s", r->name);
|
||||
break;
|
||||
case 1:
|
||||
(void)snprintf(out, out_size, "%s", role_purpose_to_str(r->purpose));
|
||||
break;
|
||||
case 2:
|
||||
(void)snprintf(out, out_size, "%s", role_curve_to_str(r->curve));
|
||||
break;
|
||||
case 3:
|
||||
(void)snprintf(out, out_size, "%s", (r->selector_type == SELECTOR_NOSTR_INDEX) ? "nostr_index" : "role_path");
|
||||
break;
|
||||
default:
|
||||
out[0] = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void render_status(const role_table_t *role_table,
|
||||
const mnemonic_state_t *mnemonic,
|
||||
int derived_count,
|
||||
const char *socket_name) {
|
||||
int i;
|
||||
int shown;
|
||||
TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Main Menu" };
|
||||
TuiMenu menu = { g_main_menu_items, (int)(sizeof(g_main_menu_items) / sizeof(g_main_menu_items[0])) };
|
||||
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);
|
||||
printf("session: %s (%d words)\n",
|
||||
tui_clear_continuous(size.height);
|
||||
tui_render_top_frame(&frame, size.width);
|
||||
|
||||
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);
|
||||
printf("signer : %s\n", (socket_name != NULL) ? socket_name : "(none)");
|
||||
printf("derived: %d\n", derived_count);
|
||||
|
||||
printf("\nRoles\n-----\n");
|
||||
if (role_table == NULL || role_table->count == 0) {
|
||||
printf("(none)\n");
|
||||
} else {
|
||||
for (i = 0; i < role_table->count; ++i) {
|
||||
const role_entry_t *r = &role_table->entries[i];
|
||||
printf("%s purpose=%s curve=%s selector=%s\n",
|
||||
r->name,
|
||||
role_purpose_to_str(r->purpose),
|
||||
role_curve_to_str(r->curve),
|
||||
(r->selector_type == SELECTOR_NOSTR_INDEX) ? "nostr_index" : "role_path");
|
||||
}
|
||||
}
|
||||
|
||||
printf("\nActivity (latest first)\n-----------------------\n");
|
||||
if (g_activity_log.count == 0) {
|
||||
printf("(none)\n");
|
||||
} else {
|
||||
shown = 0;
|
||||
for (i = g_activity_log.count - 1; i >= 0 && shown < ACTIVITY_LOG_CAP; --i, ++shown) {
|
||||
printf("%s\n", g_activity_log.lines[i % ACTIVITY_LOG_CAP]);
|
||||
}
|
||||
}
|
||||
|
||||
printf("\nHotkeys\n-------\n");
|
||||
printf("q/x quit l lock/reunlock r refresh A toggle auto-approve(prompt): %s\n",
|
||||
(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) {
|
||||
tui_print("(none)");
|
||||
} else {
|
||||
static const TuiColumn columns[] = {
|
||||
{"Role", 20, 0},
|
||||
{"Purpose", 12, 0},
|
||||
{"Curve", 12, 0},
|
||||
{"Selector", 12, 0}
|
||||
};
|
||||
role_table_view_data_t view;
|
||||
TuiTable table;
|
||||
|
||||
view.role_table = role_table;
|
||||
table.columns = columns;
|
||||
table.column_count = (int)(sizeof(columns) / sizeof(columns[0]));
|
||||
table.row_count = role_table->count;
|
||||
table.user_data = &view;
|
||||
table.get_cell = role_table_get_cell;
|
||||
table.is_default = NULL;
|
||||
table.prefix_len = NULL;
|
||||
tui_render_table(&table);
|
||||
}
|
||||
|
||||
tui_print("");
|
||||
tui_print("^*Activity (latest first)^:");
|
||||
if (g_activity_log.count == 0) {
|
||||
tui_print("(none)");
|
||||
} else {
|
||||
int i;
|
||||
int shown = 0;
|
||||
for (i = g_activity_log.count - 1; i >= 0 && shown < ACTIVITY_LOG_CAP; --i, ++shown) {
|
||||
tui_print("%s", g_activity_log.lines[i % ACTIVITY_LOG_CAP]);
|
||||
}
|
||||
}
|
||||
|
||||
tui_anchor_prompt(0, left_col);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static int tui_approval_cb(const server_approval_request_t *req, void *user_data) {
|
||||
main_tui_context_t *ctx = (main_tui_context_t *)user_data;
|
||||
TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Approval" };
|
||||
char choice[32];
|
||||
int decision = POLICY_DENY;
|
||||
|
||||
tui_render_content_screen(&frame, "Approval required");
|
||||
tui_print("caller: %s", (req != NULL && req->caller != NULL) ? req->caller->caller_id : "unknown");
|
||||
if (req != NULL && req->fips_peer_npub != NULL) {
|
||||
if (req->fips_peer_name != NULL && req->fips_peer_name[0] != '\0') {
|
||||
tui_print("fips peer: %s (%s)", req->fips_peer_npub, req->fips_peer_name);
|
||||
} else {
|
||||
tui_print("fips peer: %s", req->fips_peer_npub);
|
||||
}
|
||||
}
|
||||
tui_print("method: %s", (req != NULL && req->method != NULL) ? req->method : "unknown");
|
||||
tui_print("role: %s", (req != NULL && req->role_name != NULL) ? req->role_name : "unknown");
|
||||
tui_print("purpose: %s", (req != NULL && req->purpose != NULL) ? req->purpose : "unknown");
|
||||
if (req != NULL && req->pending_derivation) {
|
||||
tui_print("** NEW IDENTITY — will be derived if approved **");
|
||||
}
|
||||
tui_print("");
|
||||
tui_print("^_y^: allow once");
|
||||
tui_print("^_n^: deny");
|
||||
tui_print("^_e^: allow this caller+role+verb for session");
|
||||
tui_print("^_a^: allow this caller+role for session (all verbs)");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
|
||||
if (read_line_stdin(choice, sizeof(choice)) == 0) {
|
||||
char ch = (char)tolower((unsigned char)choice[0]);
|
||||
if (ch == 'a') {
|
||||
decision = POLICY_ALLOW_SESSION_ALL;
|
||||
} else if (ch == 'e') {
|
||||
decision = POLICY_ALLOW_SESSION_VERB;
|
||||
} else if (ch == 'y') {
|
||||
decision = POLICY_ALLOW;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx != NULL) {
|
||||
render_status(ctx->role_table,
|
||||
ctx->mnemonic,
|
||||
(ctx->derived_count != NULL) ? *ctx->derived_count : 0,
|
||||
ctx->socket_name);
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
static int setup_default_role(role_table_t *role_table) {
|
||||
role_entry_t role;
|
||||
|
||||
@@ -867,7 +1181,7 @@ static int setup_default_role(role_table_t *role_table) {
|
||||
return role_table_add(role_table, &role);
|
||||
}
|
||||
|
||||
static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
|
||||
static int prompt_load_mnemonic_tui(mnemonic_state_t *mnemonic) {
|
||||
char phrase[MNEMONIC_MAX_LEN];
|
||||
char phrase_copy[MNEMONIC_MAX_LEN];
|
||||
char mode[MNEMONIC_MAX_LEN];
|
||||
@@ -879,8 +1193,14 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
|
||||
}
|
||||
|
||||
while (invalid_attempts < max_invalid_attempts) {
|
||||
printf("Mnemonic source: [E]nter existing or [G]enerate new (default E; you can also paste mnemonic here): ");
|
||||
{
|
||||
TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Unlock" };
|
||||
tui_render_content_screen(&frame, "Load mnemonic");
|
||||
tui_print("Mnemonic source: [E]nter existing or [G]enerate new");
|
||||
tui_print("Default is E; you can also paste full mnemonic here.");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
}
|
||||
if (read_line_stdin(mode, sizeof(mode)) != 0) {
|
||||
fprintf(stderr, "Failed to read mnemonic source choice\n");
|
||||
return -1;
|
||||
@@ -918,13 +1238,24 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
|
||||
strncpy(phrase_copy, phrase, sizeof(phrase_copy) - 1);
|
||||
phrase_copy[sizeof(phrase_copy) - 1] = '\0';
|
||||
|
||||
printf("\nGenerated mnemonic (WRITE THIS DOWN - it will not be shown again):\n");
|
||||
tui_print("");
|
||||
tui_print("Generated mnemonic (WRITE THIS DOWN - it will not be shown again):");
|
||||
word = strtok_r(phrase_copy, " ", &ctx);
|
||||
while (word != NULL) {
|
||||
printf("%2d. %s\n", idx, word);
|
||||
idx++;
|
||||
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) {
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
@@ -939,8 +1270,13 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
printf("Enter mnemonic (12/15/18/21/24 words): ");
|
||||
{
|
||||
TuiFrame frame = { "n_signer", NSIGNER_VERSION, "> Unlock" };
|
||||
tui_render_content_screen(&frame, "Enter mnemonic");
|
||||
tui_print("Enter mnemonic (12/15/18/21/24 words):");
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
if (read_line_stdin(phrase, sizeof(phrase)) != 0) {
|
||||
fprintf(stderr, "Failed to read mnemonic\n");
|
||||
@@ -971,6 +1307,118 @@ static int prompt_load_mnemonic(mnemonic_state_t *mnemonic) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int read_mnemonic_from_fd(int fd, char *out, size_t out_sz) {
|
||||
size_t len = 0;
|
||||
int saw_newline = 0;
|
||||
|
||||
if (fd < 0 || out == NULL || out_sz < 2) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
unsigned char ch;
|
||||
ssize_t n = read(fd, &ch, 1);
|
||||
if (n == 0) {
|
||||
break;
|
||||
}
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (ch == '\n') {
|
||||
saw_newline = 1;
|
||||
break;
|
||||
}
|
||||
if (ch == '\0') {
|
||||
return -2;
|
||||
}
|
||||
if (len + 1 >= out_sz) {
|
||||
return -3;
|
||||
}
|
||||
|
||||
out[len++] = (char)ch;
|
||||
}
|
||||
|
||||
out[len] = '\0';
|
||||
|
||||
if (len > 0 && out[len - 1] == '\r') {
|
||||
out[len - 1] = '\0';
|
||||
}
|
||||
|
||||
(void)saw_newline;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int replace_stdin_with_devnull(void) {
|
||||
int null_fd = open("/dev/null", O_RDONLY);
|
||||
int rc = 0;
|
||||
|
||||
if (null_fd < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (dup2(null_fd, STDIN_FILENO) < 0) {
|
||||
rc = -1;
|
||||
}
|
||||
|
||||
close(null_fd);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int load_mnemonic(mnemonic_state_t *mnemonic, const mnemonic_source_t *source) {
|
||||
char phrase[MNEMONIC_MAX_LEN];
|
||||
int rc = -1;
|
||||
|
||||
if (mnemonic == NULL || source == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (source->kind == MNEMONIC_SOURCE_TUI) {
|
||||
return prompt_load_mnemonic_tui(mnemonic);
|
||||
}
|
||||
|
||||
memset(phrase, 0, sizeof(phrase));
|
||||
rc = read_mnemonic_from_fd(source->fd, phrase, sizeof(phrase));
|
||||
if (rc != 0) {
|
||||
if (rc == -2) {
|
||||
fprintf(stderr, "nsigner: mnemonic input contains embedded NUL byte\n");
|
||||
} else if (rc == -3) {
|
||||
fprintf(stderr, "nsigner: mnemonic input exceeded maximum length\n");
|
||||
} else {
|
||||
fprintf(stderr, "nsigner: failed to read mnemonic from fd %d: %s\n", source->fd, strerror(errno));
|
||||
}
|
||||
if (source->fd >= 0) {
|
||||
close(source->fd);
|
||||
}
|
||||
secure_memzero(phrase, sizeof(phrase));
|
||||
return -1;
|
||||
}
|
||||
|
||||
rc = mnemonic_load(mnemonic, phrase);
|
||||
secure_memzero(phrase, sizeof(phrase));
|
||||
if (source->fd >= 0) {
|
||||
close(source->fd);
|
||||
}
|
||||
if (rc != 0) {
|
||||
fprintf(stderr, "nsigner: mnemonic validation failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (source->kind == MNEMONIC_SOURCE_STDIN || source->fd == STDIN_FILENO) {
|
||||
if (replace_stdin_with_devnull() != 0) {
|
||||
fprintf(stderr, "nsigner: failed to rebind stdin to /dev/null: %s\n", strerror(errno));
|
||||
mnemonic_unload(mnemonic);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
printf("Seed phrase is valid and accepted.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void apply_test_overrides(policy_table_t *policy) {
|
||||
const char *force_prompt;
|
||||
const char *noninteractive_prompt;
|
||||
@@ -1035,6 +1483,7 @@ int main(int argc, char *argv[]) {
|
||||
int auth_mode = NSIGNER_AUTH_OFF;
|
||||
int auth_skew_seconds = AUTH_DEFAULT_SKEW_SECONDS;
|
||||
int allow_all = 0;
|
||||
mnemonic_source_t mnemonic_source = { MNEMONIC_SOURCE_TUI, -1 };
|
||||
|
||||
while (argi < argc) {
|
||||
if (strcmp(argv[argi], "--socket-name") == 0 ||
|
||||
@@ -1101,6 +1550,38 @@ int main(int argc, char *argv[]) {
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(argv[argi], "--mnemonic-stdin") == 0) {
|
||||
if (mnemonic_source.kind != MNEMONIC_SOURCE_TUI) {
|
||||
fprintf(stderr, "nsigner: --mnemonic-stdin and --mnemonic-fd are mutually exclusive\n");
|
||||
return 1;
|
||||
}
|
||||
mnemonic_source.kind = MNEMONIC_SOURCE_STDIN;
|
||||
mnemonic_source.fd = STDIN_FILENO;
|
||||
argi += 1;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(argv[argi], "--mnemonic-fd") == 0) {
|
||||
char *endp = NULL;
|
||||
long parsed_fd;
|
||||
if (argi + 1 >= argc) {
|
||||
fprintf(stderr, "Missing value for %s\n", argv[argi]);
|
||||
return 1;
|
||||
}
|
||||
if (mnemonic_source.kind != MNEMONIC_SOURCE_TUI) {
|
||||
fprintf(stderr, "nsigner: --mnemonic-stdin and --mnemonic-fd are mutually exclusive\n");
|
||||
return 1;
|
||||
}
|
||||
errno = 0;
|
||||
parsed_fd = strtol(argv[argi + 1], &endp, 10);
|
||||
if (errno != 0 || endp == argv[argi + 1] || *endp != '\0' || parsed_fd < 0 || parsed_fd > INT_MAX) {
|
||||
fprintf(stderr, "nsigner: --mnemonic-fd %s: invalid FD value\n", argv[argi + 1]);
|
||||
return 1;
|
||||
}
|
||||
mnemonic_source.kind = MNEMONIC_SOURCE_FD;
|
||||
mnemonic_source.fd = (int)parsed_fd;
|
||||
argi += 2;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(argv[argi], "--allow-all") == 0 || strcmp(argv[argi], "-A") == 0) {
|
||||
allow_all = 1;
|
||||
argi += 1;
|
||||
@@ -1141,11 +1622,30 @@ int main(int argc, char *argv[]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (mnemonic_source.kind == MNEMONIC_SOURCE_STDIN &&
|
||||
(listen_mode == NSIGNER_LISTEN_STDIO || listen_mode == NSIGNER_LISTEN_QREXEC)) {
|
||||
fprintf(stderr, "nsigner: --mnemonic-stdin requires --listen unix or --listen tcp:...\n");
|
||||
return 1;
|
||||
}
|
||||
if (mnemonic_source.kind == MNEMONIC_SOURCE_FD) {
|
||||
if (mnemonic_source.fd == STDOUT_FILENO) {
|
||||
fprintf(stderr, "nsigner: --mnemonic-fd 1 is not allowed\n");
|
||||
return 1;
|
||||
}
|
||||
if (mnemonic_source.fd == STDIN_FILENO &&
|
||||
(listen_mode == NSIGNER_LISTEN_STDIO || listen_mode == NSIGNER_LISTEN_QREXEC)) {
|
||||
fprintf(stderr, "nsigner: --mnemonic-fd 0 cannot be used with --listen stdio or --listen qrexec\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
printf("nsigner %s\n", NSIGNER_VERSION);
|
||||
fflush(stdout);
|
||||
|
||||
g_startup_mnemonic_source_kind = mnemonic_source.kind;
|
||||
|
||||
mnemonic_init(&mnemonic);
|
||||
if (prompt_load_mnemonic(&mnemonic) != 0) {
|
||||
if (load_mnemonic(&mnemonic, &mnemonic_source) != 0) {
|
||||
mnemonic_unload(&mnemonic);
|
||||
return 1;
|
||||
}
|
||||
@@ -1274,15 +1774,43 @@ int main(int argc, char *argv[]) {
|
||||
(void)signal(SIGINT, handle_signal);
|
||||
(void)signal(SIGTERM, handle_signal);
|
||||
|
||||
{
|
||||
char fips_ipv6[128];
|
||||
char fips_npub[256];
|
||||
char listen_port[16];
|
||||
int have_fips_identity =
|
||||
(lookup_local_fips_identity(fips_ipv6, sizeof(fips_ipv6), fips_npub, sizeof(fips_npub)) == 0);
|
||||
|
||||
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);
|
||||
|
||||
if (listen_mode == NSIGNER_LISTEN_STDIO || listen_mode == NSIGNER_LISTEN_QREXEC) {
|
||||
@@ -1323,6 +1851,14 @@ int main(int argc, char *argv[]) {
|
||||
memset(&g_activity_log, 0, sizeof(g_activity_log));
|
||||
g_auto_approve = 0;
|
||||
server_set_prompt_always_allow(0);
|
||||
{
|
||||
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;
|
||||
@@ -1339,6 +1875,10 @@ int main(int argc, char *argv[]) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (tui_resize_pending()) {
|
||||
render_status(&role_table, &mnemonic, derived_count, socket_name);
|
||||
}
|
||||
|
||||
if (prc > 0 && (pfds[0].revents & POLLIN)) {
|
||||
int hrc = server_handle_one(&server, activity_log_cb, NULL);
|
||||
if (hrc < 0) {
|
||||
@@ -1360,11 +1900,21 @@ int main(int argc, char *argv[]) {
|
||||
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);
|
||||
if (prompt_load_mnemonic(&mnemonic) != 0) {
|
||||
|
||||
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);
|
||||
@@ -1378,6 +1928,9 @@ int main(int argc, char *argv[]) {
|
||||
}
|
||||
}
|
||||
|
||||
server_set_approval_cb(NULL, NULL);
|
||||
}
|
||||
|
||||
server_stop(&server);
|
||||
crypto_wipe(&key_store);
|
||||
nostr_cleanup();
|
||||
|
||||
50
src/server.c
50
src/server.c
@@ -454,6 +454,19 @@ void server_stop(server_ctx_t *ctx);
|
||||
/* Extract caller identity from connected fd */
|
||||
int server_get_caller(int fd, caller_identity_t *out);
|
||||
|
||||
typedef struct {
|
||||
const caller_identity_t *caller;
|
||||
const char *method;
|
||||
const char *role_name;
|
||||
const char *purpose;
|
||||
int pending_derivation;
|
||||
const char *fips_peer_npub;
|
||||
const char *fips_peer_name;
|
||||
} server_approval_request_t;
|
||||
|
||||
typedef int (*server_approval_cb)(const server_approval_request_t *req, void *user_data);
|
||||
void server_set_approval_cb(server_approval_cb cb, void *user_data);
|
||||
|
||||
|
||||
/* from socket_name.h */
|
||||
|
||||
@@ -502,6 +515,8 @@ static int g_prompt_always_allow = 0;
|
||||
static int g_noninteractive_prompt_default = -1;
|
||||
static auth_nonce_cache_t g_auth_nonce_cache;
|
||||
static int g_auth_nonce_cache_inited = 0;
|
||||
static server_approval_cb g_approval_cb = NULL;
|
||||
static void *g_approval_cb_user_data = NULL;
|
||||
|
||||
static int caller_id_extract_ipv6(const char *caller_id, char *out_ipv6, size_t out_sz) {
|
||||
const char *start;
|
||||
@@ -666,6 +681,11 @@ void server_set_noninteractive_prompt_default(int decision) {
|
||||
}
|
||||
}
|
||||
|
||||
void server_set_approval_cb(server_approval_cb cb, void *user_data) {
|
||||
g_approval_cb = cb;
|
||||
g_approval_cb_user_data = user_data;
|
||||
}
|
||||
|
||||
static int prompt_for_policy_decision(const caller_identity_t *caller,
|
||||
const char *method,
|
||||
const char *role_name,
|
||||
@@ -685,6 +705,32 @@ static int prompt_for_policy_decision(const caller_identity_t *caller,
|
||||
return POLICY_DENY;
|
||||
}
|
||||
|
||||
if (g_approval_cb != NULL) {
|
||||
server_approval_request_t req;
|
||||
char ipv6[INET6_ADDRSTRLEN];
|
||||
char npub[128];
|
||||
char display_name[128];
|
||||
|
||||
req.caller = caller;
|
||||
req.method = method;
|
||||
req.role_name = role_name;
|
||||
req.purpose = purpose;
|
||||
req.pending_derivation = pending_derivation;
|
||||
req.fips_peer_npub = NULL;
|
||||
req.fips_peer_name = NULL;
|
||||
|
||||
npub[0] = '\0';
|
||||
display_name[0] = '\0';
|
||||
if (caller != NULL && caller->kind == NSIGNER_LISTEN_TCP &&
|
||||
caller_id_extract_ipv6(caller->caller_id, ipv6, sizeof(ipv6)) == 0 &&
|
||||
lookup_fips_peer_for_ipv6(ipv6, npub, sizeof(npub), display_name, sizeof(display_name)) == 0) {
|
||||
req.fips_peer_npub = (npub[0] != '\0') ? npub : NULL;
|
||||
req.fips_peer_name = (display_name[0] != '\0') ? display_name : NULL;
|
||||
}
|
||||
|
||||
return g_approval_cb(&req, g_approval_cb_user_data);
|
||||
}
|
||||
|
||||
printf("\nApproval required\n");
|
||||
printf("caller: %s\n", (caller != NULL) ? caller->caller_id : "unknown");
|
||||
if (caller != NULL && caller->kind == NSIGNER_LISTEN_TCP) {
|
||||
@@ -692,6 +738,9 @@ static int prompt_for_policy_decision(const caller_identity_t *caller,
|
||||
char npub[128];
|
||||
char display_name[128];
|
||||
|
||||
npub[0] = '\0';
|
||||
display_name[0] = '\0';
|
||||
|
||||
if (caller_id_extract_ipv6(caller->caller_id, ipv6, sizeof(ipv6)) == 0 &&
|
||||
lookup_fips_peer_for_ipv6(ipv6, npub, sizeof(npub), display_name, sizeof(display_name)) == 0) {
|
||||
if (display_name[0] != '\0') {
|
||||
@@ -701,6 +750,7 @@ static int prompt_for_policy_decision(const caller_identity_t *caller,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printf("method: %s\n", (method != NULL) ? method : "unknown");
|
||||
printf("role: %s\n", (role_name != NULL) ? role_name : "unknown");
|
||||
printf("purpose: %s\n", (purpose != NULL) ? purpose : "unknown");
|
||||
|
||||
@@ -172,5 +172,13 @@ else
|
||||
echo "[warn] no @nsigner entries found"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[extra] mnemonic startup input tests"
|
||||
if [[ -x ./build/test_mnemonic_input ]]; then
|
||||
./build/test_mnemonic_input || true
|
||||
else
|
||||
echo "[warn] build/test_mnemonic_input not found (run: make test-mnemonic-input)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Smoke test complete."
|
||||
532
tests/test_mnemonic_input.c
Normal file
532
tests/test_mnemonic_input.c
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user