Files
n_signer/documents/SECURITY.md

31 KiB

SECURITY.md

This document explains how n_signer enforces security, written so someone new can understand the model end-to-end without reading the source.

If you only read one section, read §3 The three concepts and §4 The deny-by-default rule.

This document describes the security model as implemented in the current release. Design rationale is in plans/deny_by_default_approvals.md.


1. What n_signer is, in security terms

n_signer is a single foreground program that holds a BIP-39 mnemonic in locked memory and signs requests on behalf of local clients. It is not a daemon, not a service, and not a key-management database.

The security posture is intentionally minimalist:

  • One process. One terminal. One human. The terminal is the trust anchor and the only out-of-band approval surface.
  • No persistence. Nothing about the running session is written to disk: no config, no logs, no PID files, no socket pathnames, no state recovery.
  • Crash equals total wipe. All sensitive state — mnemonic, derived keys, the policy table, activity buffer — exists only in process RAM (mlock'd where applicable) and is unrecoverable after the process ends.
  • Always-attended operation. Every first request from a previously unknown caller requires an explicit human approval at the terminal, unless the caller was pre-approved at startup by the OS distribution. Human presence is part of the threat model, not an inconvenience.

Authoritative behavior reference: README.md. Implementation roadmap: plans/nsigner.md. Approval model plan: plans/deny_by_default_approvals.md. Wire contract for clients: documents/CLIENT_IMPLEMENTATION.md.


2. Threat model

2.1 What n_signer is designed to defend against

  • Untrusted local clients asking the signer to perform operations they should not be allowed to perform.
  • Drive-by signing — an unattended program quietly producing signatures the user did not consent to.
  • Accidental misuse — a buggy client connecting to the wrong signer instance, or asking for an identity it should not be using.
  • Post-mortem key recovery — someone obtaining the disk after a crash, kill, or shutdown and recovering signing keys.

2.2 What n_signer does not defend against

  • A compromised kernel or hypervisor (mlock cannot save you).
  • Physical access while the program is running and the terminal is unlocked.
  • A malicious user who knows the mnemonic — n_signer is a runtime gatekeeper, not a vault.
  • Side channels (timing, power, microarchitectural) that target the underlying crypto libraries.
  • Network adversaries when transports without authentication are explicitly enabled (e.g. tcp_local).
  • An OS distribution that ships a malicious --preapprove list in its system unit files. The OS is in scope of trust if it pre-approves anything; see §7.

2.3 Trust anchors

Anchor Why it is trusted
The terminal n_signer is attached to The user types the mnemonic into it and answers approval prompts on it. If the terminal is compromised, the session is compromised.
The kernel and mlock / getrandom syscalls Used for in-memory protection and entropy.
The static binary itself Built reproducibly via build_static.sh and shipped as one musl-static artifact.
The launcher that started n_signer Whoever ran the process chose the --preapprove flags. In interactive use that's the human; in n_OS_tr boot it's the OS init system. The launcher's integrity is part of the trust chain.

Everything else — clients, other processes, other users, remote callers — is untrusted by default and must clear an approval check.


3. The three concepts: identity, index, approval

This is the entire user-facing security model. There are exactly three things to know.

   +----------------+         +-----------------------+         +---------------+
   |  IDENTITY      |         |  INDEX (or PATH)      |         |  APPROVAL     |
   |  who is asking |  +----  |  which key to use     |  ----+  |  is this OK?  |
   +----------------+  |      +-----------------------+      |  +---------------+
                       |                                     |
                       |       request resolves to           |
                       +-------> (identity, index/path) -----+
                                                             |
                                                             v
                                                +----------------------------+
                                                |  Approved entry exists?    |
                                                +----------------------------+
                                                   |yes              |no
                                                   v                 v
                                          +-----------------+  +----------------+
                                          | Sign / encrypt  |  | Prompt human   |
                                          +-----------------+  +----------------+
                                                                       |
                                                          [y]/[a]      |     [n]
                                                          allow now    |     deny
                                                          /save        |
                                                          v            v
                                                  +-----------------+ +----------------+
                                                  | Sign / encrypt  | | policy_denied  |
                                                  +-----------------+ +----------------+

Identity — who is asking

An identity is a tagged caller record built from the transport. Different transports produce different identity tags; the signer never invents identity, it always reads it from the kernel or framework.

Kind What it carries Source of truth
unix_peer uid, pid from SO_PEERCRED on an abstract Unix socket. Kernel — the client cannot forge it.
qubes source qube name from QREXEC_REMOTE_DOMAIN. Qubes RPC framework.
qubes_pubkey source qube + verified auth-envelope pubkey (qubes:<vm>+pubkey:<hex>). Qubes RPC framework + application-layer signature verification.
tcp_local Loopback address of caller. TCP socket peer address — opt-in transport.
tcp_remote Verified auth-envelope pubkey (pubkey:<hex>). Application-layer authentication.
fips Peer npub from a NIP-46 style flow (planned). Application-layer authentication.
usb_serial Device path plus an asserted caller (planned). Trust-on-first-use.

Identity quality varies. unix_peer and qubes are vouched for by the kernel or hypervisor — strong. qubes_pubkey is stronger still for multi-program qubes because it combines hypervisor-vouched source-qube identity with a per-program cryptographic identity. tcp_local and tcp_remote rely on transport/app-layer checks and are only as strong as deployment choices.

Index (or path) — which key

The signer can derive many keys from one mnemonic. Clients select one in two equivalent ways:

  • nostr_index = N — shorthand for the Nostr derivation m/44'/1237'/N'/0/0 per NIP-06.
  • role_path = "<full BIP-32 path>" — used for non-Nostr key trees (Bitcoin, SSH, etc.). Pre-registered only; see §9.2.

The three legacy selector words from the wire protocol — role, nostr_index, role_path — all resolve to a single internal record. From the user's perspective, what matters is "which Nostr identity (by index)" or "which advanced derivation (by path)."

Approval — is this OK?

An approval is an in-memory entry that says: "this identity may use this index/path." Approvals come from exactly two sources:

  1. The human at the prompt. When a request arrives that has no matching approval, the signer blocks the request and asks the user. On [a] ("allow this caller for this index/path for the rest of the session") the signer adds a session approval. On [y] it allows once without saving. On [n] it denies.
  2. Pre-approvals. When the OS distribution starts n_signer with --preapprove caller=...,nostr_index=... flags, those become approvals that exist before any request arrives. They never trigger prompts. See §7.

Approvals are stored in an in-memory table and vanish on process exit. There is no persistent approval database.


4. The deny-by-default rule

n_signer denies any request whose (identity, index) pair has no approval. Period.

There is no special case for "same-uid" callers. There is no implicit grant for the user running their own client. Every first request from every caller goes through an approval — either granted by the user at the prompt, or pre-approved by the OS distribution at startup.

This is the single most important property:

  • It removes the assumption that "same-uid means trusted" — an assumption that was always more convenience than security.
  • It collapses several mechanisms (policy, role pre-registration, the [a] flag) into one (the in-memory approval table).
  • It makes the audit trail trivial: every signed operation maps to either a pre-approval declaration or a logged human approval.

5. The two checks every request must pass

Every signing request runs through this short pipeline. Failure at any step returns an error response and is logged.

   +----------------------------------------------------------------+
   | 1. CLIENT                                                      |
   |    sends length-prefixed JSON-RPC request                      |
   +----------------------------------------------------------------+
                                |
                                v
   +----------------------------------------------------------------+
   | 2. TRANSPORT                                                   |
   |    - reads framed bytes off the socket                         |
   |    - builds caller_identity_t (uid / qubes vm / tcp peer / ...)|
   +----------------------------------------------------------------+
                                |
                                v
   +----------------------------------------------------------------+
   | 3. RESOLVE INDEX                                               |
   |    - parse selector options from request                       |
   |    - resolve to a specific (index or path)                     |
   |    - reject ambiguous selectors                                |
   +----------------------------------------------------------------+
                                |
                                v
   +----------------------------------------------------------------+
   | 4. APPROVAL CHECK                                              |
   |    - is there an approval for (this identity, this index)?     |
   |        yes  -> ALLOW                                           |
   |        no   -> PROMPT human (or DENY if non-interactive)       |
   |    - on [a], append a session approval and continue            |
   |    - on [y], continue this request only                        |
   |    - on [n] / EOF, deny                                        |
   +----------------------------------------------------------------+
                                |
                                v
   +----------------------------------------------------------------+
   | 5. ENFORCEMENT (verb x purpose x curve)                        |
   |    - is this verb compatible with the key's declared purpose?  |
   |    - reject with purpose_mismatch / curve_mismatch otherwise   |
   +----------------------------------------------------------------+
                                |
                                v
   +----------------------------------------------------------------+
   | 6. KEY DERIVATION + SIGN                                       |
   |    - derive key from mnemonic at the resolved path             |
   |    - perform crypto operation                                  |
   |    - return signed result                                      |
   +----------------------------------------------------------------+
                                |
                                v
   +----------------------------------------------------------------+
   | 7. RESPONSE FRAMED + SENT                                      |
   |    - activity log line appended with the approval source label |
   +----------------------------------------------------------------+

There are two independent gates: the approval check (does this caller have permission to use this key?) and the enforcement check (is this operation compatible with this key?). Both must pass.

5.1 Approval check — does this caller have permission?

Implemented as a lookup in the in-memory approval table. First match wins; the catch-all is * → deny. No approval entry, no signature.

The approval check answers who and which key. It does not check what the request will do with the key — that is the enforcement check.

5.2 Enforcement check — is this operation compatible with this key?

The signer enforces a strict (verb, purpose, curve) matrix:

Verb Required purpose Required curve
sign_event nostr secp256k1
get_public_key nostr secp256k1
nip04_encrypt / nip04_decrypt nostr secp256k1
nip44_encrypt / nip44_decrypt nostr secp256k1
Any other verb rejected rejected

A pre-approval to use a Bitcoin-purposed key for sign_event does not override the enforcement matrix. The approval grants access to the key; enforcement still gates the verb. Fail-closed: unknown verbs are rejected, never passed through.

This is the layer that prevents (for example) a bitcoin/secp256k1 key from being used to sign a Nostr event even if some pre-approval entry mistakenly named it. The key's purpose is part of its identity; you cannot reuse it across domains.

Test coverage: tests/test_enforcement.c.


6. The interactive approval prompt

When a request has no matching approval and n_signer is running attached to a terminal, the signer blocks the request, prints a prompt, and waits for a local keystroke.

Target prompt format:

Approval required
caller:    qubes:nostr-relay
verb:      sign_event
index:     nostr_index=0
purpose:   nostr / secp256k1

[y] allow once
[a] allow this caller for index 0 for the rest of the session
[n] deny

The keys mean:

  • [y] — Allow this single request. No approval is saved; the next request from the same caller will prompt again.
  • [a] — Allow, and add a session approval so the same (caller, index) pair is silently allowed for the rest of the session.
  • [n] — Deny this request. Returns policy_denied to the client.
  • EOF / non-interactive — Treated as deny. There is no implicit "allow when nobody is at the terminal" mode. OS distributions that need silent operation use --preapprove.

6.1 What [a] does and does not do

  • It approves the specific caller (e.g. qubes:nostr-relay).
  • It approves the specific index/path that triggered the prompt.
  • It does not approve other callers.
  • It does not approve other indices for the same caller — a different nostr_index from the same caller will prompt again.
  • It does not persist past the process lifetime.

6.2 New-identity warning

When a request specifies an unregistered nostr_index, the prompt says so plainly:

Approval required
caller:    qubes:nostr-relay
verb:      get_public_key
index:     nostr_index=7  (NEW IDENTITY — will be derived if approved)

[y] allow once    [a] allow caller for this new identity    [n] deny

A new identity is just a new derivation of the same mnemonic. It is not a separate key file or a separate seed; it is m/44'/1237'/7'/0/0 of the existing mnemonic. The user should still see explicitly that they are creating a previously-unused identity.

6.3 Race-condition behavior

Requests are served one at a time. If a malicious client tries to flood requests during a prompt, only the request being prompted is held; subsequent requests queue or are dropped per the transport's framing. The prompt always names the request being approved. It is not possible for a queued request to "ride along" on an approval intended for an earlier request.


7. Pre-approvals: the OS distribution pathway

For deployments where multiple system services need keys at boot — n_OS_tr and similar — the OS distribution starts n_signer with one --preapprove flag per service:

[Service]
ExecStart=/usr/bin/n_signer \
  --preapprove caller=qubes:nostr-relay,role=main \
  --preapprove caller=qubes:dm-handler,nostr_index=1 \
  --preapprove caller=qubes:contacts,nostr_index=2 \
  --preapprove caller=qubes:zaps,nostr_index=3

At startup, n_signer:

  1. Parses each flag into an approval entry with prompt mode NEVER.
  2. Auto-derives any keys those entries depend on (so services don't see "ready" until keys exist).
  3. Writes one log line per pre-approval to stderr so the OS unit's journal records what was authorized.
  4. Begins listening normally. Pre-approved requests are served silently; everything else still hits the prompt or the catch-all deny.

7.1 What pre-approvals can do

  • Allow a specific named caller to use a specific named key for any verb that key's purpose/curve permits.
  • Auto-create a Nostr identity at a specified nostr_index if it does not yet exist.
  • Be inspected: anyone reading the systemd unit (or ps output) can see exactly what was authorized.

7.2 What pre-approvals cannot do

  • Cannot deny. Pre-approvals only widen authorization, never narrow it. The user at the terminal is always the source of denial. (The plan deliberately rejects "preapprove deny" entries; see plans/deny_by_default_approvals.md §C.3.)
  • Cannot wildcard the caller. Each entry must name exactly one caller. There is no qubes:* rule. The OS distribution must know who its services are.
  • Cannot bypass enforcement. A pre-approval to use a Nostr key for a hypothetical Bitcoin verb still fails the enforcement matrix in §5.2.
  • Cannot pre-register role_path derivations. Only role=<name> (an existing role) and nostr_index=<n> are allowed in pre-approval specs. Free-form role_path derivations remain pre-registration-only by code path. See §9.2.

7.3 Trust implications

Pre-approvals shift the trust boundary. Whoever wrote the systemd unit now decides what the signer will allow without asking. Concretely:

  • The unit file's integrity matters. If an attacker can rewrite it, they can pre-approve themselves.
  • The OS launcher's identity-stamping must be trustworthy. If something else can spoof a qubes:nostr-relay tag, it inherits that pre-approval.
  • The user retains final authority at the running terminal. Anything not pre-approved still goes through the human-approved prompt.

In n_OS_tr these properties are part of the OS itself: the unit files are owned by root, the qrexec framework stamps caller identity at the hypervisor level, and the user's terminal is the ultimate arbiter. The trust chain is short and visible.


8. The audit log

Every served (or denied) request appends a line to the in-memory activity buffer that the running TUI displays. Each line includes the source label that explains why the verdict was reached.

Target line format:

[2026-05-04 16:03:11] qubes:nostr-relay  sign_event(nostr_index=0)  ALLOWED:preapprove
[2026-05-04 16:03:14] uid:1000           sign_event(nostr_index=0)  ALLOWED:session-grant
[2026-05-04 16:03:18] tcp:[::1]:54122    get_public_key(nostr_index=7) ALLOWED:prompt
[2026-05-04 16:03:21] uid:1001           sign_event(nostr_index=0)  DENIED:no-match

Source labels:

Label Meaning
:preapprove Approval came from a --preapprove startup flag.
:session-grant Approval was added earlier this session by a prompt [a].
:prompt Approval was given just now by the user pressing [y] or [a] at the prompt.
:no-match No approval existed and (in non-interactive mode) the request was denied.

This makes forensic review fast: every signed operation maps to either a pre-approval (audit the unit file), a session grant (audit the session's earlier prompt activity), or a fresh prompt approval (audit human attention at that moment).


9. The role abstraction (internal plumbing)

Internally n_signer uses a structure called a role to bundle three things together:

  • A derivation path (BIP-32).
  • A purpose label (nostr, bitcoin, ssh, age).
  • A curve label (secp256k1, ed25519, x25519).

Roles are the unit of enforcement (the matrix in §5.2 is keyed on role purpose and curve) and the unit of audit (every log line names the role used).

9.1 Why roles exist as plumbing rather than user-facing concept

  • A future where one mnemonic produces Nostr, Bitcoin, SSH, and age keys requires something to track per-key purpose. Roles are that thing. Keeping them in the codebase preserves the option without forcing the user to think about them today.
  • Two different identities can use the same role (the same key). The role is the audit unit shared between them, so the activity log is consistent regardless of caller.
  • Enforcement against a role's declared purpose/curve is a defense-in-depth: a Nostr key cannot be coerced into producing a Bitcoin signature even if some approval entry mistakenly named it.

A user reading prompts and the activity log will see "index 0" and "index 7" most of the time, not "role main" or "role nostr_idx_7." Roles surface as labels only in advanced views and the source code.

9.2 Pre-registration of role_path

The role_path selector lets a client specify a BIP-32 path directly, including paths outside the Nostr derivation scheme. These remain pre-registration-only:

  • The signer ships with an empty role_path table.
  • Operators who want non-Nostr keys must register them at startup (mechanism TBD).
  • The interactive prompt does not auto-derive role_path keys, even with [a]. The narrowing in plans/deny_by_default_approvals.md §5 explicitly applies only to nostr_index.

The reasoning is that role_path is free-form. A malicious or buggy client could request paths that overlap with sensitive domains (bitcoin wallet roots, SSH host keys, age recipients). Auto-derivation in those domains would be a footgun. nostr_index is locked to a specific scheme with locked purpose and curve, so auto-derivation there is safe to gate on a single human keystroke.

9.3 Role table size limits

ROLE_TABLE_MAX_ENTRIES caps the number of distinct roles per session. The plan raises this to 256. Overflow is reported as a clear role_table_full error rather than silent truncation or memory unsafety.


10. Memory safety and zeroization

10.1 Locked memory

  • The mnemonic and derived private keys live in mlock'd allocations managed by src/secure_mem.c.
  • These pages are pinned in RAM (no swap to disk) for the process lifetime.
  • On normal shutdown (q, SIGINT, SIGTERM), buffers are explicitly zeroized before unmap.

10.2 Crash semantics

If the process dies abnormally, the kernel reclaims its pages. Anything previously mlock'd is not flushed to disk because it never could be. There is no swap residue to scrape, no core dump containing keys (if your environment disables core dumps for setuid-equivalent processes — verify locally), and no recovery path.

This is the meaning of "crash equals total wipe is a security feature" in plans/nsigner.md:154.

10.3 No persistence by design

There is no file under ~/.nsigner, no /var/lib/nsigner, no ~/.config/nsigner, no approval database. The signer is a process whose authority begins when you type the mnemonic and ends when you quit.

If you want something durable: store the mnemonic on paper or hardware, not in n_signer.

The only file inputs are --preapprove flags from the launcher's command line, evaluated once at startup and never re-read. There is no live config reload.


11. Transports and their identity quality

Different transports give different identity quality. The wire format (4-byte length prefix + JSON-RPC) and the security checks (§5) are identical across transports.

11.1 Abstract namespace Unix sockets (default)

  • Address: @nsigner_<word1>_<word2> per plans/nsigner.md:96.
  • Identity: kernel-vouched uid, pid. Cannot be forged by the client.
  • Risk surface: any local process on the same host. Deny-by-default + per-(caller, index) approvals handle this.

11.2 Stdio / qrexec mode

  • One framed request, one framed response, then exit.
  • Identity defaults to qubes:<vm-name> from QREXEC_REMOTE_DOMAIN, vouched for by Qubes.
  • With --listen qrexec --auth optional|required, requests carrying a valid auth envelope are upgraded to qubes:<vm-name>+pubkey:<hex>.
  • Risk surface: only what Qubes RPC policy permits. See packaging/qubes/.

11.3 TCP (advanced / opt-in)

  • Loopback and remote TCP callers must present a valid signed auth envelope.
  • Identity is normalized to caller pubkey:<hex> after signature verification; TCP peer address is context only.
  • The auth gate runs before policy: missing/invalid envelopes are rejected with 2010..2017 auth errors.
  • Prompt-driven approval still applies after auth; auth answers "who is calling", policy answers "is this caller allowed".

11.4 Future transports

The roadmap (plans/nsigner.md:178) describes additional transports (USB serial, FIPS-style relay). The contract is:

  • The transport produces a caller_identity_t.
  • The same approval / enforcement / dispatch stack runs unchanged.
  • New transports add new identity kinds, not new bypass paths.

12. Operational guidance

12.1 Running interactively (typical desktop use)

  • Start n_signer only on a terminal you control. No --preapprove flags.
  • Type or generate the mnemonic. Generated mnemonics are shown once with no confirmation step (plans/nsigner.md:84) — write them down before pressing a key.
  • Stay near the terminal for the first couple of minutes after launch. New clients will prompt for approval. Press [a] to grant for the session.
  • Once your usual clients are approved, prompts stop appearing.

12.2 Running as a system signer (e.g. n_OS_tr)

  • Configure pre-approvals in the systemd (or equivalent) unit file. One --preapprove flag per service.
  • Verify each pre-approval's caller identity exactly matches what the launcher actually produces. Mismatches cause silent deny (:no-match in the log).
  • Treat the unit file as security-sensitive. Restrict write access to root or its equivalent.
  • Keep a terminal attached or piped to where prompts can still be seen. Anything not pre-approved still prompts.

12.3 Stopping

  • Press q for clean shutdown.
  • Ctrl+C and SIGTERM are equivalent — they zeroize and exit.
  • Closing the terminal is also equivalent. There is no "detach" mode by design.

12.4 Auditing

  • The activity buffer in the running TUI is the session log. It does not survive the process.
  • For persistent audit, run n_signer under a logging supervisor that captures stderr/stdout, knowing the activity buffer reflects post-approval decisions only.

12.5 Rotating

  • "Rotate" in n_signer means: quit, change mnemonic source, restart. There is no in-place rotation because there is no persistent state to migrate.

13. Relationship to the current codebase

All stages from plans/deny_by_default_approvals.md are implemented in the current codebase. This document therefore describes shipped behavior, not a future target state.


14. Things that look like bugs but are security features

Symptom Reason
First request from a new client prompts even though it's the same uid Deny-by-default. Same-uid trust is gone. Press [a] once and you won't see prompts from this client again this session.
Killing the process loses all approvals and key derivations Crash-equals-wipe. Restart with the same mnemonic to rebuild deterministically; re-approve clients.
purpose_mismatch when calling sign_event against a non-Nostr key Roles are scoped by purpose. Approvals do not bypass enforcement.
unknown_role for an unregistered role_path role_path keys are pre-registration-only by design. Use nostr_index for ad-hoc Nostr identities.
Pre-approval entry doesn't match incoming requests Caller identity must match exactly — no wildcards. Verify the launcher tags requests the way the unit file expects.
role=unknown in the activity log (current code) Selector did not resolve. Future versions will prompt to create the identity; current code rejects with unknown_role after policy passes.

15. Quick reference — the security guarantee in one paragraph

n_signer will never sign with a key whose use has not been explicitly approved — either by a human keystroke at the running terminal or by a --preapprove flag declared at startup. It will never use a key outside its declared (purpose, curve) scope. It will never expose a private key to a client. It will never write key material to disk. Every request is checked twice: once for approval, once for enforcement. Default policy is deny. Approvals vanish on process exit.

If any of these statements becomes false in code, that is a security bug worth filing immediately.


16. References