n_signer
n_signer is a single statically-linked program that holds signing key material in locked memory and signs on request.
It runs in the foreground, attached to your terminal. The terminal is the trust anchor and control surface. Nothing touches disk at runtime. If the process crashes or exits, all in-memory state is gone.
This is a program, not a daemon:
- no hidden background process
- no detached service lifecycle
- no runtime config or state files
- no persistence to recover after compromise
1. What it is
n_signer is one musl-static binary that combines:
- mnemonic handling
- role selection and derivation
- purpose/curve enforcement
- request dispatch
- interactive terminal UI
- transport adapter(s)
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
2. Security model
2.1 Zero filesystem footprint
At runtime, n_signer writes nothing to disk:
- no config files
- no logs
- no PID files
- no lock files
- no socket pathname artifacts
On Linux desktop, local IPC uses abstract namespace Unix sockets (@name semantics) that exist only in kernel memory and disappear with process/kernel namespace lifetime.
2.2 Crash = total wipe
All sensitive and operational state exists only in-process RAM (mlock'd where applicable):
- mnemonic-derived key material
- role table
- policy/approval decisions for the live session
- activity display buffer
If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design. There is no persistence layer to scrape post-crash.
2.3 Single binary, no external dependencies
Runtime target is one statically-linked musl executable:
- no shared libraries required at runtime
- no interpreter/runtime VM dependency
- no sidecar services
- no helper daemon binaries
This reduces deployment variability and shrinks the runtime trust surface.
2.4 Always-attended operation
n_signer is intentionally human-attended. It stays attached to a terminal and can require explicit keystroke approval for unknown callers or sensitive actions. Human presence is part of the security model.
3. How it works
3.1 Startup phase (TUI input mode)
When started, n_signer immediately enters terminal input mode:
- Choose mnemonic source:
[E]nter existing mnemonic(default) or[G]enerate new mnemonic.- On
E: prompt for mnemonic with terminal echo disabled, then validate. - On
G: generate a fresh 12-word BIP-39 mnemonic fromgetrandom(2), display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step.
- On
- Build in-memory role/selector state from the mnemonic.
- Pick the abstract socket name (random BIP-39 pair, or
--socket-name/--name/-noverride). - Initialize transport endpoints and bind the socket.
- Switch to running status display, with the signer name and socket address shown in the banner.
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 descriptorNat startup.
These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. See 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:
n_signer v0.x | foreground session active
transport: unix-abstract:@nsigner
session: unlocked (RAM-only)
Roles
-----
main purpose=nostr curve=secp256k1 selector=role:main
ops purpose=nostr curve=secp256k1 selector=nostr_index:7
backup purpose=bitcoin curve=secp256k1 selector=role_path:m/84'/0'/0'/0/5
Pending approvals
-----------------
(none)
Activity (latest first)
-----------------------
16:03:11 allow caller=uid:1000 method=get_public_key role=main
16:02:44 prompt caller=uid:1000 method=sign_event role=ops
16:02:46 allow caller=uid:1000 method=sign_event role=ops
15:59:10 deny caller=uid:1001 method=sign_event error=unauthorized
Hotkeys
-------
a toggle auto-approve(prompt) for this session
r refresh
l lock/reunlock session
q quit
3.3 Approval prompts
When a request needs confirmation, n_signer interrupts the status view with a prompt and waits for a local keystroke.
Example:
Approval required
caller: uid:1000
method: sign_event
selector: role=ops
purpose/curve: nostr/secp256k1
[y] allow once [n] deny [a] always allow this session
No response is emitted to caller until the local user decides.
3.4 Shutdown / lock
qquits the process: all session state is destroyed.llocks the session in-place: signing stops until mnemonic is re-entered.- terminal close or process termination has the same effect as quit: total state wipe.
4. Mnemonic-rooted role model
n_signer derives many role-scoped keys from one mnemonic root. Requests select a role using explicit selectors, then enforcement checks operation compatibility.
4.1 Nostr shorthand: nostr_index
Nostr shorthand keeps the explicit index selector:
m/44'/1237'/<nostr_index>'/0/0
Use nostr_index only for Nostr-indexed roles.
4.2 Full derivation path: role_path
For non-Nostr or advanced layouts, caller may use full role_path selector.
Security rule: role_path must match a pre-registered role entry. Unregistered ad-hoc derivation requests are rejected.
4.3 What memorizing your seed phrase gets you
A single memorized mnemonic can deterministically recover multiple key domains through role definitions, not just one identity.
Examples include Nostr roles, Bitcoin branches, and future application-specific paths. See plans/seed_phrase_uses.md for the maintained use-case catalog and caveats.
5. Wire contract (JSON-RPC)
Request shape is JSON-RPC with NIP-46-style methods and optional trailing selector options.
{ "id": "1", "method": "get_public_key", "params": [] }
{ "id": "2", "method": "sign_event", "params": ["<event_json>", { "role": "main" }] }
{ "id": "3", "method": "sign_event", "params": ["<event_json>", { "nostr_index": 7 }] }
{ "id": "4", "method": "sign_event", "params": ["<event_json>", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] }
Implemented signer verbs in this build:
get_public_keysign_eventnip04_encrypt/nip04_decryptnip44_encrypt/nip44_decrypt
Selector resolution order:
rolenostr_indexrole_path- default role
main
Conflicting selectors are rejected (ambiguous_role_selector).
Representative error codes:
invalid_requestmethod_not_foundambiguous_role_selectorunknown_rolepurpose_mismatchcurve_mismatchunauthorizedapproval_deniedinternal_error
6. Purpose and curve enforcement
Selector resolution chooses which role. Enforcement decides whether the requested method is valid for that role.
Example:
sign_eventrequirespurpose="nostr"andcurve="secp256k1".- If caller selects a Bitcoin-role key for
sign_event, request fails withpurpose_mismatch.
This prevents cross-protocol misuse inside one mnemonic-rooted signer process.
7. Transport
7.1 Linux desktop: abstract namespace Unix socket
Primary local transport is AF_UNIX abstract namespace.
Each running nsigner process binds to a unique abstract name of the form @nsigner_<word1>_<word2>, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. @nsigner_hairy_dog). This lets multiple signers coexist on one host.
Properties:
- no pathname in filesystem
- endpoint lifetime bound to process/kernel namespace
- no stale socket files
- caller identity via peer credentials (
SO_PEERCRED) - per-launch random name avoids collisions between concurrent instances and leaks no seed-derived identifier
Naming rules:
- Default: random pick at startup, displayed in the TUI banner.
- Override:
--socket-name <name>(alias:--name <name>/-n <name>) forces a specific name (useful for scripts and tests). - Collision: if the chosen random name is already bound by another process,
nsignerretries with a fresh pair up to 8 times before erroring out with a hint to use an explicit name override.
Discovery:
nsigner listenumerates currently boundnsigner_*abstract sockets by reading/proc/net/unix.nsigner --listen stdioruns one framed JSON-RPC request/response over stdin/stdout.nsigner --listen qrexecis the same stdio framing mode, but caller identity can be derived fromQREXEC_REMOTE_DOMAIN(displayed asqubes:<source-vm>).nsigner --listen tcp:IPv4:PORTortcp:[IPv6]:PORTenables TCP listening for non-AF_UNIX clients (for exampletcp:127.0.0.1:8080,tcp:[::]:8080, ortcp:[fd00::1234]:8080).nsigner bridge --to <socket-name>is a stateless relay for Qubes qrexec: reads one framed request from stdin, forwards it to a persistent signer's abstract unix socket, and relays the response to stdout. Used as thequbes.NsignerRpcservice entrypoint. See §8.3 andplans/qrexec_persistent_bridge.md.--bridge-source-trusted(unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed{"qrexec_source":"<vm>"}preamble before the request, and the caller identity is composed asqubes:<vm>— matching the native qrexec identity path. This enables a persistent signer (mnemonic in mlock'd RAM) to receive qrexec-routed requests without spawning a fresh process per call.
7.2 ESP32 MCU: TinyUSB composite (CDC + WebUSB)
On Feather S3 TFT, MCU targets run the same core signer modules behind a TinyUSB composite transport:
- CDC-ACM framed JSON-RPC for serial tooling (
/dev/ttyACM*) - Vendor/WebUSB framed JSON-RPC for browser tooling
Core signer logic stays the same; only transport/UI bindings change.
7.3 NIP-46 relay flow (both platforms)
NIP-46 request/response flow can be bridged on both desktop and MCU transports. The JSON-RPC signer contract remains consistent while the outer carrier differs.
7.4 Caller verification
Every transport must provide concrete caller identity before policy evaluation.
- Linux AF_UNIX: map peer credentials to caller identity.
- ESP32 USB bridge: map authenticated host bridge identity to caller identity.
- Relay session: bind remote peer/session identity before allowing signer verbs.
Identity verification and interactive approval are separate layers. Passing identity checks does not bypass prompt requirements.
8. Platform targets
8.1 Linux desktop (primary)
Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
8.2 ESP32 / MCU
MCU target reuses mnemonic/role/selector/enforcement/dispatcher core and swaps transport/UI for constrained hardware. The Feather path currently uses TinyUSB composite USB (CDC + WebUSB) plus TFT/buttons for attended approvals.
8.3 Qubes OS qube
Qubes deployment runs n_signer in a dedicated signer qube (e.g. nostr_signer) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation).
Two transport paths are supported:
FIPS/TCP — the signer listens on tcp:[::]:8080 and FIPS carries traffic between qubes as an IPv6 mesh substrate. See documents/FIPS_DEPLOYMENT.md.
Qubes qrexec bridge (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless nsigner bridge relay (the qubes.NsignerRpc qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is qubes:<source-vm> (from QREXEC_REMOTE_DOMAIN), relayed via a trusted preamble. See plans/qrexec_persistent_bridge.md for the full design.
Qrexec bridge setup
In the signer qube (nostr_signer):
# Install nsigner and the qrexec service
bash setup_signer_qube.sh # from packaging/qubes/
# Start the persistent signer (mnemonic entered at terminal, in mlock'd RAM)
~/.local/bin/nsigner --listen unix --socket-name nsigner --bridge-source-trusted
In dom0:
# Install policy and tag the signer qube
bash setup_dom0.sh nostr_signer # from packaging/qubes/
The dom0 policy allows trusted caller qubes without a popup (memory isolation is the real security boundary) and asks for confirmation from any other qube. The signer's own approval prompt at the nostr_signer terminal is the operation-level gate.
From a caller qube:
# JavaScript example (uses qrexec-client-vm, no auth envelope needed)
node examples/n_signer_qube_example_qrexec.js nostr_signer
Setup scripts and policy are in packaging/qubes/. See also documents/QUBES_OS.md and documents/qubes_client_examples.md.
9. Usage
9.1 Run the program
nsigner
Program starts in attached foreground mode and prompts for mnemonic. After mnemonic acceptance, the TUI banner shows the randomly assigned signer name and its abstract socket address.
To force a specific socket name (e.g. for scripted clients):
nsigner --name my_test_signer
Qubes/qrexec service mode (single framed request over stdin/stdout):
nsigner --listen qrexec
Generic stdio transport mode (single framed request over stdin/stdout):
nsigner --listen stdio
TCP transport mode (no TUI; serves requests until terminated):
nsigner --listen tcp:[::]:8080
Qrexec bridge mode (stateless relay to a persistent signer's unix socket; used as the qubes.NsignerRpc service):
nsigner bridge --to nsigner
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
9.2 Send a request (client mode)
From another terminal, target the signer by its socket name:
nsigner --socket-name nsigner_hairy_dog client '{"id":"1","method":"get_public_key","params":[]}'
If only one signer is running you can omit the override and the client will use the default discovery rule.
Example signing request:
nsigner -n nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
9.3 List running signers
nsigner list
Prints the abstract socket names of any currently running nsigner instances, e.g.:
@nsigner_hairy_dog
@nsigner_brave_canyon
9.4 Example session
Terminal A:
$ nsigner
[unlock] enter mnemonic:
[ok] session unlocked
signer name : hairy dog
socket : @nsigner_hairy_dog
[listen] unix-abstract:@nsigner_hairy_dog
[prompt] caller=uid:1000 method=sign_event role=main -> allow? (y/n)
Terminal B:
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
{"id":"2","result":"<signed_event_json>"}
10. Build and versioning
Build outputs a single executable artifact.
build_static.sh: musl-static build pathMakefile: local build targetsDockerfile.alpine-musl: reproducible Alpine musl toolchainresources/tui_continuous/tui_continuous.h+resources/tui_continuous/tui_continuous.c: vendored continuous-TUI component (from~/lt/aesthetics, API baselineTUI_CONTINUOUS_VERSION 0.0.9)increment_and_push.sh: version/tag/release workflowsrc/main.c: version macros (NSIGNER_VERSION*)
Local dev build:
make dev
./build/nsigner --version
Static build:
./build_static.sh
./build/nsigner_static_x86_64 --version
11. Implemented adjuncts and future work
Implemented PoC
- MCU / USB signer (Feather ESP32-S3 Reverse TFT). Working PoC in
firmware/feather_s3_tft. Single TinyUSB composite USB device exposes both CDC-ACM and WebUSB Vendor interfaces. Same dispatcher serves both transports with auth envelope verification, on-device TFT prompts, and physical button approval. Seefirmware/README.mdandplans/feather_tinyusb_composite.md.
Future work (deferred)
These items are designed and worth doing, but are not in the current implementation scope. They are listed here so they are not lost.
--listen http:[addr]:portmode. Today the TCP listener speaks 4-byte big-endian length-prefixed framed JSON-RPC, which is correct for low-overhead local IPC but is not directly reachable from web browsers (fetch,XMLHttpRequest,curl). A small additional listener that wraps the same dispatcher in minimal HTTP/1.1 (POST /rpc,Content-Type: application/json,Content-Length-framed body, JSON response) would let standard HTTP clients talk tonsignerwithout any custom framing code. The existing auth envelope (kind:27235) and JSON-RPC contract are unchanged; only the outer framing differs. CORS allow on the response would let browser extensions and (with TLS) HTTPS pages reach a remotensignerover FIPS or any other carrier. See the discussion indocuments/FIPS_DEPLOYMENT.mdsection 9 ("Next hardening steps").- NIP-46 bunker / relay transport. Tracked in
plans/nip46_bunker_mode.md. - Browser extension. Tracked in
plans/nsigner_browser_extension.md. NIP-07 surface forwarding to a runningnsignerinstance.
12. Document map
README.md: authoritative behavior specification for the foreground single-program modeldocuments/CLIENT_IMPLEMENTATION.md: client integration contract and framing behaviordocuments/QUBES_OS.md: Qubes OS deployment/integration checklist for dedicated signer qubesdocuments/FIPS_DEPLOYMENT.md: Tier-1 FIPS deployment runbook using loopback TCP listenerplans/qrexec_persistent_bridge.md: design for the qrexec → unix-socket bridge transport (persistent signer, no mnemonic on disk)packaging/qubes/: qrexec service script, dom0 policy, and setup scripts for Qubes deploymentexamples/n_signer_qube_example_qrexec.js: JavaScript caller example using qrexec (no network, no auth envelope)examples/n_signer_qube_example_fips.js: JavaScript caller example using FIPS/TCP with auth envelopeexamples/get_pubkey_tcp.c: C caller example using TCP transport with auth envelopeplans/nsigner.md: implementation plan and sequencingplans/seed_phrase_uses.md: seed phrase domain/use catalog and caveatsfirmware/feather_s3_tft: Feather ESP32-S3 Reverse TFT firmware (TinyUSB composite CDC + WebUSB signer PoC)plans/feather_tinyusb_composite.md: firmware Phase 7b plan and outcome (TinyUSB composite USB transport)plans/nsigner_browser_extension.md: browser extension exposing NIP-07 overnsignerplans/nip46_bunker_mode.md: deferred NIP-46 relay-mode signer transportfirmware/README.md: firmware-side notes for MCU transport/UI integration