This commit is contained in:
Laan Tungir
2026-05-02 12:31:26 -04:00
commit 268b33b6d3
37 changed files with 7947 additions and 0 deletions

325
README.md Normal file
View File

@@ -0,0 +1,325 @@
# 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:
1. Prompt for mnemonic (terminal echo disabled).
2. Parse and validate mnemonic.
3. Build in-memory role/selector state.
4. Initialize transport endpoints.
5. Switch to running status display.
No startup files are read or written.
### 3.2 Running phase (status display + signer)
After unlock, terminal becomes a live status and control console. Example layout:
```text
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 add role
r refresh
l lock 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:
```text
Approval required
caller: uid:1000
method: sign_event
selector: role=ops
purpose/curve: nostr/secp256k1
[y] allow once [n] deny [a] always allow for this session
```
No response is emitted to caller until the local user decides.
### 3.4 Shutdown / lock
- `q` quits the process: all session state is destroyed.
- `l` locks 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`](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.
```jsonc
{ "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" }] }
```
Selector resolution order:
1. `role`
2. `nostr_index`
3. `role_path`
4. default role `main`
Conflicting selectors are rejected (`ambiguous_role_selector`).
Representative error codes:
- `invalid_request`
- `method_not_found`
- `ambiguous_role_selector`
- `unknown_role`
- `purpose_mismatch`
- `curve_mismatch`
- `unauthorized`
- `approval_denied`
- `internal_error`
## 6. Purpose and curve enforcement
Selector resolution chooses *which* role. Enforcement decides *whether the requested method is valid* for that role.
Example:
- `sign_event` requires `purpose="nostr"` and `curve="secp256k1"`.
- If caller selects a Bitcoin-role key for `sign_event`, request fails with `purpose_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 (for example `@nsigner`).
Properties:
- no pathname in filesystem
- endpoint lifetime bound to process/kernel namespace
- no stale socket files
- caller identity via peer credentials (`SO_PEERCRED`)
### 7.2 ESP32 MCU: USB-CDC serial
On MCU targets, the same core modules run with a different transport adapter: USB-CDC serial framing instead of AF_UNIX.
Core 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 (for example UART/OLED/buttons or USB-CDC host interaction).
### 8.3 Qubes OS qube
Qubes deployment runs `n_signer` in a dedicated signer qube as a foreground process under explicit user session control. Transport binding is platform-specific, but lifecycle and memory-only state model are unchanged.
## 9. Usage
### 9.1 Run the program
```bash
nsigner
```
Program starts in attached foreground mode and prompts for mnemonic.
### 9.2 Send a request (client mode)
From another terminal:
```bash
nsigner client '{"id":"1","method":"get_public_key","params":[]}'
```
Example signing request:
```bash
nsigner client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
```
### 9.3 Example session
Terminal A:
```text
$ nsigner
[unlock] enter mnemonic:
[ok] session unlocked
[listen] unix-abstract:@nsigner
[prompt] caller=uid:1000 method=sign_event role=main -> allow? (y/n)
```
Terminal B:
```text
$ nsigner 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`](build_static.sh): musl-static build path
- [`Makefile`](Makefile): local build targets
- [`Dockerfile.alpine-musl`](Dockerfile.alpine-musl): reproducible Alpine musl toolchain
- [`increment_and_push.sh`](increment_and_push.sh): version/tag/release workflow
- [`src/main.h`](src/main.h): version macros
Local dev build:
```bash
make dev
./build/nsigner_dev --version
```
Static build:
```bash
./build_static.sh
./build/nsigner_static_x86_64 --version
```
## 11. Document map
- [`README.md`](README.md): authoritative behavior specification for the foreground single-program model
- [`plans/nsigner.md`](plans/nsigner.md): implementation plan and sequencing
- [`plans/seed_phrase_uses.md`](plans/seed_phrase_uses.md): seed phrase domain/use catalog and caveats
- [`firmware/README.md`](firmware/README.md): firmware-side notes for MCU transport/UI integration