479 lines
20 KiB
Markdown
479 lines
20 KiB
Markdown
# 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. 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 from `getrandom(2)`, display it numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then continue. There is no confirmation step.
|
|
2. Build in-memory role/selector state from the mnemonic.
|
|
3. Pick the abstract socket name (random BIP-39 pair, or `--socket-name` / `--name` / `-n` override).
|
|
4. Initialize transport endpoints and bind the socket.
|
|
5. 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 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:
|
|
|
|
```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 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:
|
|
|
|
```text
|
|
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
|
|
|
|
- `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" }] }
|
|
```
|
|
|
|
Implemented signer verbs in this build:
|
|
|
|
- `get_public_key`
|
|
- `sign_event`
|
|
- `nip04_encrypt` / `nip04_decrypt`
|
|
- `nip44_encrypt` / `nip44_decrypt`
|
|
|
|
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.
|
|
|
|
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, `nsigner` retries with a fresh pair up to 8 times before erroring out with a hint to use an explicit name override.
|
|
|
|
Discovery:
|
|
|
|
- `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`.
|
|
- `nsigner --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
|
|
- `nsigner --listen qrexec` is the same stdio framing mode, but caller identity can be derived from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
|
|
- `nsigner --listen tcp:IPv4:PORT` or `tcp:[IPv6]:PORT` enables TCP listening for non-AF_UNIX clients (for example `tcp:127.0.0.1:8080`, `tcp:[::]:8080`, or `tcp:[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 the `qubes.NsignerRpc` service entrypoint. See [§8.3](#83-qubes-os-qube) and [`plans/qrexec_persistent_bridge.md`](plans/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 as `qubes:<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`](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`](plans/qrexec_persistent_bridge.md) for the full design.
|
|
|
|
#### Qrexec bridge setup
|
|
|
|
**In the signer qube** (`nostr_signer`):
|
|
|
|
```bash
|
|
# 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**:
|
|
|
|
```bash
|
|
# 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**:
|
|
|
|
```bash
|
|
# 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/`](packaging/qubes/). See also [`documents/QUBES_OS.md`](documents/QUBES_OS.md) and [`documents/qubes_client_examples.md`](documents/qubes_client_examples.md).
|
|
|
|
## 9. Usage
|
|
|
|
### 9.1 Run the program
|
|
|
|
```bash
|
|
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):
|
|
|
|
```bash
|
|
nsigner --name my_test_signer
|
|
```
|
|
|
|
Qubes/qrexec service mode (single framed request over stdin/stdout):
|
|
|
|
```bash
|
|
nsigner --listen qrexec
|
|
```
|
|
|
|
Generic stdio transport mode (single framed request over stdin/stdout):
|
|
|
|
```bash
|
|
nsigner --listen stdio
|
|
```
|
|
|
|
TCP transport mode (no TUI; serves requests until terminated):
|
|
|
|
```bash
|
|
nsigner --listen tcp:[::]:8080
|
|
```
|
|
|
|
Qrexec bridge mode (stateless relay to a persistent signer's unix socket; used as the `qubes.NsignerRpc` service):
|
|
|
|
```bash
|
|
nsigner bridge --to nsigner
|
|
```
|
|
|
|
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
nsigner -n nsigner_hairy_dog client '{"id":"2","method":"sign_event","params":["<event_json>",{"role":"main"}]}'
|
|
```
|
|
|
|
### 9.3 List running signers
|
|
|
|
```bash
|
|
nsigner list
|
|
```
|
|
|
|
Prints the abstract socket names of any currently running `nsigner` instances, e.g.:
|
|
|
|
```text
|
|
@nsigner_hairy_dog
|
|
@nsigner_brave_canyon
|
|
```
|
|
|
|
### 9.4 Example session
|
|
|
|
Terminal A:
|
|
|
|
```text
|
|
$ 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:
|
|
|
|
```text
|
|
$ 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`](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*`)
|
|
|
|
Local dev build:
|
|
|
|
```bash
|
|
make dev
|
|
./build/nsigner --version
|
|
```
|
|
|
|
Static build:
|
|
|
|
```bash
|
|
./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`](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. See [`firmware/README.md`](firmware/README.md) and [`plans/feather_tinyusb_composite.md`](plans/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]:port` mode.** 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 to `nsigner` without 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 remote `nsigner` over FIPS or any other carrier. See the discussion in [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md) section 9 ("Next hardening steps").
|
|
- **NIP-46 bunker / relay transport.** Tracked in [`plans/nip46_bunker_mode.md`](plans/nip46_bunker_mode.md).
|
|
- **Browser extension.** Tracked in [`plans/nsigner_browser_extension.md`](plans/nsigner_browser_extension.md). NIP-07 surface forwarding to a running `nsigner` instance.
|
|
|
|
## 12. Document map
|
|
|
|
- [`README.md`](README.md): authoritative behavior specification for the foreground single-program model
|
|
- [`documents/CLIENT_IMPLEMENTATION.md`](documents/CLIENT_IMPLEMENTATION.md): client integration contract and framing behavior
|
|
- [`documents/QUBES_OS.md`](documents/QUBES_OS.md): Qubes OS deployment/integration checklist for dedicated signer qubes
|
|
- [`documents/FIPS_DEPLOYMENT.md`](documents/FIPS_DEPLOYMENT.md): Tier-1 FIPS deployment runbook using loopback TCP listener
|
|
- [`plans/qrexec_persistent_bridge.md`](plans/qrexec_persistent_bridge.md): design for the qrexec → unix-socket bridge transport (persistent signer, no mnemonic on disk)
|
|
- [`packaging/qubes/`](packaging/qubes/): qrexec service script, dom0 policy, and setup scripts for Qubes deployment
|
|
- [`examples/n_signer_qube_example_qrexec.js`](examples/n_signer_qube_example_qrexec.js): JavaScript caller example using qrexec (no network, no auth envelope)
|
|
- [`examples/n_signer_qube_example_fips.js`](examples/n_signer_qube_example_fips.js): JavaScript caller example using FIPS/TCP with auth envelope
|
|
- [`examples/get_pubkey_tcp.c`](examples/get_pubkey_tcp.c): C caller example using TCP transport with auth envelope
|
|
- [`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/feather_s3_tft`](firmware/feather_s3_tft): Feather ESP32-S3 Reverse TFT firmware (TinyUSB composite CDC + WebUSB signer PoC)
|
|
- [`plans/feather_tinyusb_composite.md`](plans/feather_tinyusb_composite.md): firmware Phase 7b plan and outcome (TinyUSB composite USB transport)
|
|
- [`plans/nsigner_browser_extension.md`](plans/nsigner_browser_extension.md): browser extension exposing NIP-07 over `nsigner`
|
|
- [`plans/nip46_bunker_mode.md`](plans/nip46_bunker_mode.md): deferred NIP-46 relay-mode signer transport
|
|
- [`firmware/README.md`](firmware/README.md): firmware-side notes for MCU transport/UI integration
|