16 KiB
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:
- Single-binary deployment. A user installs
nostr_terminaland getsnsignerfor free; no separate package manager step, no$PATHconfusion, no version skew. - Version pinning. The host always launches the exact
nsignerit was tested against. - Zero filesystem footprint preserved. Spawning
nsignerdoes not put a copy of the binary anywhere persistent; it runs out of an anonymous in-kernel memory object. - Same security boundary.
nsignercontinues 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. - Reusable across host programs. Any program in the user's
~/lt/*ecosystem can adopt the same helper.
Non-goals (explicit):
- Running
nsignerinside the host process. That collapses the security boundary and is rejected. Seedocuments/SECURITY.md"One process. One terminal. One human." - Hiding the
nsignerwindow. The TUI is the trust anchor; it must be visible and attended. - Making
nsignera 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 aunsigned chararray. 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 asld -r -b binaryon 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 ~1–3 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
-
Validate inputs: non-empty mnemonic, sane buffer sizes,
out_socket_namenon-NULL. -
Pick a socket name: random BIP-39 word pair if not provided. Reuses the same name-generation routine that
nsigneritself uses; consider extracting it into a shared headersrc/socket_name.halready exists (src/socket_name.c) and exposing the generator publicly. -
Create memfd:
memfd_create("nsigner", MFD_CLOEXEC). On older kernels (nomemfd_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.) -
Write the embedded blob to the memfd.
lseekback to zero is unnecessary becausefexecve/execveatdoes not care about offset. -
Create stdin pipe:
pipe2(stdin_pipe, O_CLOEXEC). Mark only the read end as inheritable in the child (clearFD_CLOEXECon the read end afterfork). -
Pick a terminal emulator. Detection order:
$NSIGNER_TERMINALenv override, thenterminal_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 weztermstart -- <argv>preserves stdin xterm-e <argv>preserves stdin (legacy reliable) gnome-terminalavoided 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.
-
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
-
fork(). In the child:dup2(stdin_pipe[0], 0)(replace stdin with the read end)close(stdin_pipe[1])- clear
FD_CLOEXECon the memfd so/proc/self/fd/<memfd>resolves afterexecvpruns the terminal — the terminal then runsnsignervia the inherited memfd path execvp(chosen_terminal, child_argv)
-
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_memzeroover 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
forksucceeded but a later step failed,waitpidthe child afterkill(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:
- 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 indocuments/SECURITY.md. - Confirm
MFD_CLOEXECactually closes on the outer exec. We need the memfd open acrossfork+execvp(terminal)but want it gone from the terminal emulator's children that aren'tnsigner. Tested behavior: clearFD_CLOEXEConly on this one FD beforeexecvpso the terminal inherits it; the terminal itself doesn't close arbitrary FDs in its child by default but a few do. Document the matrix. - 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. - Pre-approval defaults. The helper must NOT silently inject a default
--preapprovefor the host's uid. The host must declare every approval explicitly. This keeps thedocuments/SECURITY.md"deny by default" guarantee intact. - Mnemonic in pipe buffer. The kernel pipe buffer (4 KiB) holds the mnemonic for the moment between parent's
writeand child'sread. This is in-kernel anonymous memory, comparable to amlock'd page in lifetime, and is freed when both ends close. Acceptable. - Mnemonic in environment of the host. The helper does not put the mnemonic in
envp. It only goes through the pipe. - 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. - 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/xtermand warn against modifying via plugins/config that record stdin.
6. Documentation deliverables
documents/SPAWN.md: how host programs embednsigner_blob.oand usensigner_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"), spawnsnsignervia the helper, then issuesget_public_keyover 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:
- 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).
- Integration test under a headless terminal (
tests/test_spawn_integration.c):- use
xvfb-runplusxterm -e(orfoot --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-spawnrather than the defaultmake test.
- use
- Manual smoke checklist in
documents/SPAWN.mdcovering the four supported emulators.
8. Open questions to resolve before implementation
- Should the helper live in
client/(next tonsigner_client.{c,h}) or in a new top-levelembed/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
fexecveof memfd content; macOS would require a different path. The plan is Linux-only for now; document that. - Should
nsigneritself learn a--print-socket-name-on-startupflag (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.oproduces 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.cruns end-to-end and prints a valid pubkey.- No file is created on disk during a successful spawn (verified by
lsofsnapshot orinotifywaiton/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, andREADME.md. - Tests in
tests/test_spawn_unit.cpass on plain CI;tests/test_spawn_integration.cpasses underxvfb-runon the spawn-CI lane.
10. Relationship to other plans
- Builds on
plans/mnemonic_startup_input.md— the embedded helper consumes--mnemonic-stdin. - Compatible with
plans/deny_by_default_approvals.md— every host-issued approval still goes through--preapprove. - Compatible with
plans/caller_token_identity.md— host identity is conveyed viaunix_peerover the abstract socket like any other client. - Companion to
plans/auth_envelope_other_transports.md: a host that spawnsnsignermay also wish to forward authenticated requests on its behalf; out of scope here.