Files
n_signer/plans/embedded_nsigner_spawn.md

16 KiB
Raw Blame History

Plan (future): Host programs that embed and spawn n_signer

Status: design only. Do not implement until plans/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 "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

+----------------------------------------------------------------+
| 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:

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:

HOST_OBJS += nsigner_blob.o nsigner_spawn.o

and links them into its final executable. The blob lives in .data (read-only on most toolchains) and adds roughly the size of the nsigner binary to the host (currently ~13 MB for the musl static build).

3.3 Symbol declaration

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

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) 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.
  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 §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 §2.3 with the host-program and terminal-emulator trust-chain rows.
  • Update 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, and 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