518 lines
31 KiB
Markdown
518 lines
31 KiB
Markdown
# 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](#3-the-three-concepts-identity-index-approval) and [§4 The deny-by-default rule](#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`](../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`](../README.md). Implementation roadmap: [`plans/nsigner.md`](../plans/nsigner.md). Approval model plan: [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md). Wire contract for clients: [`documents/CLIENT_IMPLEMENTATION.md`](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](#7-pre-approvals-the-os-distribution-pathway).
|
|
|
|
### 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`](../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.
|
|
|
|
```text
|
|
+----------------+ +-----------------------+ +---------------+
|
|
| 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](#92-pre-registration-of-role_path).
|
|
|
|
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](#7-pre-approvals-the-os-distribution-pathway).
|
|
|
|
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.
|
|
|
|
```text
|
|
+----------------------------------------------------------------+
|
|
| 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`](../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`](#7-pre-approvals-the-os-distribution-pathway).
|
|
|
|
### 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:
|
|
|
|
```ini
|
|
[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`](../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](#52-enforcement-check--is-this-operation-compatible-with-this-key).
|
|
- **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](#92-pre-registration-of-role_path).
|
|
|
|
### 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](#52-enforcement-check--is-this-operation-compatible-with-this-key) 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`](../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`](../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`](../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](#5-the-two-checks-every-request-must-pass)) are identical across transports.
|
|
|
|
### 11.1 Abstract namespace Unix sockets (default)
|
|
|
|
- Address: `@nsigner_<word1>_<word2>` per [`plans/nsigner.md:96`](../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/`](../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`](../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`](../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`](../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
|
|
|
|
- [`README.md`](../README.md) — authoritative behavior spec.
|
|
- [`plans/nsigner.md`](../plans/nsigner.md) — root design plan and decisions log.
|
|
- [`plans/deny_by_default_approvals.md`](../plans/deny_by_default_approvals.md) — the approval model plan this document tracks.
|
|
- [`documents/CLIENT_IMPLEMENTATION.md`](CLIENT_IMPLEMENTATION.md) — wire contract for clients.
|
|
- [`documents/QUBES_OS.md`](QUBES_OS.md) — Qubes RPC integration.
|
|
- [`documents/FIPS_DEPLOYMENT.md`](FIPS_DEPLOYMENT.md) — FIPS-mode deployment notes.
|
|
- [`plans/seed_phrase_uses.md`](../plans/seed_phrase_uses.md) — what one mnemonic can become.
|
|
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c), [`src/enforcement.c`](../src/enforcement.c) — the security-related code.
|