# Plan: Deny-by-default approvals + on-demand role derivation Status: **implemented** This plan replaces several entangled mechanisms (same-uid auto-allow, role pre-registration as a hard rule, global `[a]` flag) with a single, coherent approval model. It also addresses the `n_OS_tr` system-services-at-boot scenario. If implemented, this plan supersedes: - The same-uid auto-allow rule in [`policy_init_default()`](../src/policy.c:551). - The "role_path / nostr_index must be pre-registered" rule recorded in [`plans/nsigner.md:159`](nsigner.md:159) (the rule is *narrowed*, not removed — see §5). - The global `g_prompt_always_allow` flag in [`src/server.c:469`](../src/server.c:469) as the meaning of prompt-`[a]`. The running-TUI `[a]` hotkey is unaffected (or removed; see §11). --- ## 1. Goals 1. **Deny-by-default.** No client can sign or get a public key without an explicit human approval at the terminal **or** an explicit pre-approval declared at startup. 2. **One approval mechanism.** Both the runtime prompt (human approves a request) and the boot-time pre-approval (OS-level config) populate the **same** in-memory policy table. There is exactly one path through the dispatcher that determines whether to sign. 3. **On-demand role creation.** When a request specifies an unregistered `nostr_index`, the prompt path may auto-derive a fresh role at that index *only after* the human (or a startup pre-approval) consents. Pre-registration of `role_path` keys remains required. 4. **Boot-time ergonomics for `n_OS_tr`.** OS-level system services that need keys at boot can be pre-approved by the OS distribution via CLI flags, with no prompts and no on-disk runtime config files. 5. **Auditable approval source.** The activity log distinguishes between approvals granted at runtime by the user, approvals loaded from startup pre-approvals, and approvals already in place from prior session activity. ## 2. Non-goals - This plan does **not** add per-verb scoping at the prompt. `[a]` means "this caller may use this role for all verbs the role's purpose/curve permits." Per-verb scoping is a future iteration. - This plan does **not** add persistent (across-restart) policy storage. Approvals still vanish on process exit. - This plan does **not** introduce capability tokens or any new authentication primitive. Identity remains transport-derived. - This plan does **not** change the wire protocol. Clients see the same JSON-RPC request/response shape. ## 3. The new model in one paragraph The signer maintains an in-memory policy table whose entries are tuples of `(caller_id, role, prompt_mode)`. At startup, the table is empty except for entries declared via `--preapprove` CLI flags (set by `n_OS_tr` system unit files for boot-required services). Every request without a matching policy entry triggers an interactive prompt at the terminal. If the user picks `[a]`, a new policy entry for `(this caller, this role)` is appended to the table and any future request matching that tuple is served silently. If the request specifies an unregistered `nostr_index`, the consent path also derives a fresh role at that index. The `*` catch-all is `PROMPT_DENY` for any caller that hasn't been explicitly added. ## 4. Concept renaming and positioning The user-facing language collapses to three concepts: | User-facing term | Internal mapping | |---|---| | **Identity** | `caller_identity_t` (uid, qubes vm name, tcp peer, etc.) | | **Index / Path** | `nostr_index` shorthand or full `role_path` for advanced use | | **Approval** | A policy table entry with `prompt_mode = PROMPT_NEVER` | The word **role** is retained internally (the role table, the [`role_entry_t`](../src/role_table.c:103) struct, derivation paths) but de-emphasized in user-facing prompts and docs. A user reading a prompt sees: ``` Approval required caller: qubes:nostr-relay verb: sign_event index: nostr_index=0 (resolves to the default identity) ``` instead of `role: main`. Internally the resolved role is still recorded for audit. ## 5. The narrowed pre-registration rule The original rule was: **all** selectors must resolve to a pre-registered role. The new rule is: > Pre-registration is required for **`role_path`** selectors. For **`nostr_index`** selectors, an unregistered index is a valid prompt trigger, and the role for that index is derived on the fly **after** explicit consent (prompt or `--preapprove`). Rationale: - `nostr_index` paths follow a fixed scheme (`m/44'/1237'/'/0/0`) with locked purpose (`nostr`) and curve (`secp256k1`). Auto-derivation cannot accidentally produce a key that mixes purposes. The risk is solely "this is a fresh nostr identity"; the human at the prompt can answer that. - `role_path` is free-form and can address any BIP-32 path, including paths that overlap with bitcoin/SSH/age domains. Auto-derivation here would let a malicious or buggy client coerce the signer into producing keys whose purpose the human can't easily verify. We keep that locked. - This narrowing is the smallest crack we can open in the original rule that still solves the user's `nostr_index: 1` use case. [`plans/nsigner.md:159`](nsigner.md:159) ("`role_path` must be pre-registered") remains true. The `nostr_index` clause becomes new and explicit. ## 6. The four pieces of work ### Piece A — Deny-by-default policy #### A.1 Default policy table [`policy_init_default()`](../src/policy.c:551) currently inserts two entries: 1. `caller = "uid:"` → `PROMPT_NEVER` (auto-allow same-uid) 2. `caller = "*"` → `PROMPT_DENY` The new default inserts only: 1. `caller = "*"` → `PROMPT_PROMPT` There is no same-uid special case. Even the user's own client triggers a prompt the first time it connects. If `--preapprove` flags were given at startup, those entries are appended **before** the catch-all `*` so they win first-match. See §C. #### A.2 Removal of `g_prompt_always_allow` as a *prompt outcome* The flag is currently set to 1 by both: - The interactive prompt's `[a]` outcome ([`src/server.c:684`](../src/server.c:684)) - The running-TUI `[a]` hotkey ([`src/main.c:974`](../src/main.c:974), [`src/main.c:1239`](../src/main.c:1239)) The prompt's `[a]` no longer flips the flag. Instead it appends a per-`(caller, role)` policy entry. See §B. The running-TUI `[a]` hotkey can either be: - Removed, since it is now redundant with explicit per-caller entries, **or** - Retained as a debugging/panic mode under a non-default name, e.g. `[A]` (capital) for "auto-approve everything regardless of caller; for development only." The plan recommends retaining it as a deliberately ugly, capital-letter hotkey so its destructive scope is visible. Default-off; activates only on explicit press. #### A.3 Wire-level effect A previously-unknown caller making a first request observes: 1. Length-prefixed request lands. 2. Selector resolves (or fails — see §B). 3. Policy lookup returns `POLICY_PROMPT` because no matching entry exists. 4. Server blocks the request, prints the prompt to the controlling terminal, waits for keystroke. 5. On `[a]`, server appends `(caller, role) → PROMPT_NEVER` and serves the request. 6. On `[y]`, server serves once without appending. 7. On `[n]`, server returns `policy_denied` without appending. 8. On EOF / non-interactive: server returns `policy_denied`. There is no implicit allow path for non-interactive sessions; OS distributions must use `--preapprove`. ### Piece B — On-demand role derivation at the prompt #### B.1 Failure path today When `selector_resolve()` returns `SELECTOR_ERR_NOT_FOUND` for an unregistered `nostr_index`, the request currently dies in the dispatcher with `unknown_role` (error 1002). The user observes nothing in the terminal. #### B.2 New failure path The server's request-handling loop checks selector resolution **before** invoking the dispatcher. The new control flow: ``` 1. Receive request, parse method + selector. 2. Try selector_resolve(): a. SELECTOR_OK -> proceed to step 3 with the resolved role. b. SELECTOR_ERR_NOT_FOUND on nostr_index -> proceed to step 3 with a PENDING_DERIVATION marker holding the requested index. c. SELECTOR_ERR_NOT_FOUND on role_path -> reject with unknown_role. d. SELECTOR_ERR_AMBIGUOUS -> reject with ambiguous_role_selector. e. SELECTOR_ERR_NO_DEFAULT -> reject with internal_error (this should never happen if main role is always present). 3. Determine policy: ALLOW / DENY / PROMPT. - For PENDING_DERIVATION cases, the prompt displays the requested index and clearly states "this will create a new identity." 4. On PROMPT, ask human; on [a] or [y], proceed; on [n], reject. 5. On approval to proceed: a. If PENDING_DERIVATION, register a role for that index in the role_table now. Synthesize a name like nostr_idx_7. Trigger key derivation for the new role. b. If [a] (not [y]), append a session policy entry for (caller, resolved-role-name) with PROMPT_NEVER. 6. Invoke the dispatcher with the resolved role. 7. Send response, append activity log line including approval source. ``` #### B.3 Auto-registered role naming A role auto-registered for `nostr_index = N` is named `nostr_idx_N` by the signer. - Predictable, so humans reading the activity log can correlate. - Will not collide with the built-in `main` (which is `nostr_idx_0` by index but `main` by name; the role table allows lookups by either). - Does not require user input at the prompt. Naming UX is a future iteration. #### B.4 Role-table sizing [`ROLE_TABLE_MAX_ENTRIES`](../src/role_table.c) is currently small. With on-demand derivation a single session might accumulate dozens of `nostr_idx_*` entries. Bump the constant to an explicit budget (suggest 256). On overflow, return a clear error like `role_table_full` rather than crashing. #### B.5 Persistence Same as everything else in n_signer: derived roles vanish on process exit. The next session re-derives `nostr_idx_7` from the same mnemonic, getting the same key. This is normal and intentional. ### Piece C — `--preapprove` CLI flag #### C.1 Flag syntax ``` --preapprove ``` `SPEC` is a comma-separated list of `key=value` pairs. Required keys: - `caller=` — exact caller identity to match. Format depends on transport: - `uid:1000` - `qubes:work-vm` - `tcp:[fdb8::1]:0` (port 0 means "any port") - `qubes:*` is **not** supported. Wildcards are intentionally restricted to the catch-all entry only. - One of: - `role=` — match the role by name. Useful when the OS knows it wants `main`. - `nostr_index=` — match by index. Equivalent to `role=nostr_idx_` after auto-derivation. Optional keys (deferred for now, but reserved): - `verbs=` — comma-separated verb list. Default is `*`. - `purposes=` — comma-separated purpose list. Default is `*`. Multiple `--preapprove` flags are allowed; each becomes a separate policy entry. #### C.2 Example: `n_OS_tr` boot `n_OS_tr`'s systemd unit file: ```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 `--preapprove` flag into a `policy_entry_t` with `prompt = PROMPT_NEVER`. 2. For each entry that references an unregistered `nostr_index`, auto-derives the role *immediately at startup* (not lazily — we want all key derivation to happen before services start signaling readiness). 3. Inserts each policy entry into the table **before** the catch-all `*` deny. 4. Logs each pre-approval to stderr at boot so the systemd unit's journal records what was preapproved. When `qubes:nostr-relay` connects and asks for `sign_event` against `role=main`, `policy_check()` matches the preapprove entry, returns `POLICY_ALLOW`, and the request is served with no prompt. When some other caller (say, a misconfigured systemd service) connects, the catch-all matches and the request is denied (or prompts, in interactive mode — see §C.4). #### C.3 What `--preapprove` cannot do - **Cannot deny.** Deny entries can only come from defaults or the prompt's `[n]` (if we ever add caller-deny). Pre-approve only widens. - **Cannot match wildcards on caller.** Each entry must specify exactly one caller_id. The OS distribution is responsible for knowing exactly who its services are. - **Cannot bypass purpose/curve enforcement.** A pre-approval to use `role=main` for `verb=sign_btc_tx` (a hypothetical bitcoin verb) still fails enforcement because `main`'s purpose is `nostr`. The pre-approval grants access to the role; enforcement still gates the verb. #### C.4 Interaction with interactive mode The same binary supports both: - `n_signer` with no `--preapprove` flags → pure deny-by-default, every first request prompts. - `n_signer --preapprove ...` → starts with the listed entries already in the policy table. Both modes use exactly the same code path. There is no separate "system mode" toggle. #### C.5 Auditability When a pre-approval matches, the activity log line includes the source: ``` qubes:nostr-relay sign_event(role=main) ALLOWED:preapprove ``` So an operator reviewing the log can tell which decisions came from boot config vs. runtime user approvals. #### C.6 Validation Bad `--preapprove` syntax fails fast at startup with a clear error message. n_signer refuses to start. This avoids the "I thought I configured this but typoed it" silent-degrade problem. ### Piece D — Audit-log labeling of approval source #### D.1 The four label values | Label | Meaning | |---|---| | `:preapprove` | Matched a policy entry installed via `--preapprove` at startup. | | `:session-grant` | Matched a policy entry created earlier in this session by a prompt `[a]`. | | `:prompt` | Just-now approved by the user pressing `[y]` or `[a]` at the prompt. | | `:auto-allow` | Matched the running-TUI `[a]` panic mode (if retained). | A line in the activity log under the new model: ``` [2026-05-04 16:03:11] qubes:nostr-relay sign_event(role=main) ALLOWED:preapprove [2026-05-04 16:03:14] uid:1000 sign_event(role=main) ALLOWED:session-grant [2026-05-04 16:03:18] tcp:[::1]:54122 get_public_key(role=nostr_idx_7) ALLOWED:prompt [2026-05-04 16:03:21] uid:1001 sign_event(role=main) DENIED:no-match ``` #### D.2 Implementation The label is a small enum threaded from `policy_check()` (which returns the matching entry's source tag) and from `prompt_for_policy_decision()` (which returns `:prompt`). `server_handle_one()` includes the label in the activity-log line. #### D.3 Visibility The label appears in: - The TUI's running activity buffer. - The structured activity event passed to the `server_activity_cb` callback (so external observers — e.g. a future syslog feeder — can also see the source). #### D.4 No on-disk persistence The activity buffer remains in-memory only. If `n_OS_tr` wants persistent audit, it forwards the activity callback to its own logging subsystem outside of n_signer's process. ## 7. Code change inventory | Module | Change | |---|---| | [`src/policy.c`](../src/policy.c) | `policy_init_default()` collapses to one entry. `policy_check()` extends to return the matching entry's source label. New helper `policy_table_add_at_position()` to insert before the catch-all. | | [`src/server.c`](../src/server.c) | `prompt_for_policy_decision()` rewritten: appends a policy entry on `[a]`, no longer flips the global flag. New pre-derivation control flow in `server_handle_one()`. New CLI parser for `--preapprove`. | | [`src/role_table.c`](../src/role_table.c) | Bump `ROLE_TABLE_MAX_ENTRIES` to 256. New `role_table_register_nostr_index(int n)` helper that creates a `nostr_idx_` entry and triggers derivation. | | [`src/key_store.c`](../src/key_store.c) | Add a one-role derivation path so newly-registered roles can be derived without re-deriving the whole table. | | [`src/dispatcher.c`](../src/dispatcher.c) | No semantic change — dispatcher continues to assume the role exists. The server makes that true before invoking. | | [`src/main.c`](../src/main.c) | CLI parsing for `--preapprove`. Remove or restrict the running-TUI `[a]` hotkey. Log preapproves to stderr at boot. | | [`tests/test_policy.c`](../tests/test_policy.c) | New tests: deny-by-default, prompt-`[a]` appends entry, preapprove flag parsing, source-label propagation. | | [`tests/test_dispatcher.c`](../tests/test_dispatcher.c) | New tests: unregistered nostr_index path with consent-required gating. | | [`tests/test_integration.c`](../tests/test_integration.c) | New scenario: `--preapprove` allows scripted client; without it, scripted client gets denied. | | [`documents/SECURITY.md`](../documents/SECURITY.md) | Rewritten in identity / index / approval terms (separate task after this plan ships). | | [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) | Add a note about possible `policy_denied` on first contact in interactive mode; pointer to `--preapprove` for system services. | | [`plans/nsigner.md`](nsigner.md) | Decisions log entry referencing this plan. The `role_path` pre-registration rule remains; the `nostr_index` clause becomes new. | ## 8. Test plan Tests must cover at minimum: 1. **Default deny.** A startup with no `--preapprove` flags receives a request from any caller and (in non-interactive mode) returns `policy_denied`. 2. **Prompt `[a]` appends entry.** Inject a non-interactive prompt-default of `ALLOW`, observe a policy entry added, verify a second matching request is served without re-prompting. 3. **Prompt `[y]` does not append entry.** Same fixture, second request still triggers the prompt path. 4. **`--preapprove` with `role=main`.** Request from named caller succeeds without prompt; request from any other caller is denied. 5. **`--preapprove` with `nostr_index=N` for unregistered N.** Role is auto-derived at startup; request succeeds; activity log shows `:preapprove`. 6. **`--preapprove` cannot bypass enforcement.** Pre-approve `(caller=X, role=main)` and request a verb that violates purpose/curve. Response is `purpose_mismatch` or `curve_mismatch`. 7. **Unknown `role_path` still rejected.** Even with consent, an unregistered `role_path` returns `unknown_role`. (The narrowing is `nostr_index`-only.) 8. **Audit-log labels.** Each path produces the expected source tag. 9. **Multi-instance isolation.** Two n_signers with different `--preapprove` lists serve different requests; cross-instance requests are denied as expected. 10. **Role table overflow.** Force more than `ROLE_TABLE_MAX_ENTRIES` derivations and observe `role_table_full` instead of corruption. ## 9. Backwards-compatibility implications - **Existing scripts that rely on same-uid auto-allow break.** They now hit the prompt (interactive) or get denied (non-interactive). Migration path: add `--preapprove caller=uid:1000,role=main` to the startup invocation. We document this prominently. - **Interactive desktop users see a prompt the first time their client connects, where today they don't.** This is the intended behavior change. We document it in the README. - **Wire protocol unchanged.** No client-side changes required. - **Existing `[a]` running-TUI hotkey may change semantics or disappear.** This is the most disruptive UI change for someone who actually uses the panic-allow today. Document the change in the release notes. ## 10. Rollout staging This is too much to land in one commit. Suggested order: 1. **Stage 1 — deny-by-default + prompt `[a]` appends entry.** Removes the same-uid auto-allow, makes the prompt the source of session-grants. No CLI flag yet. Headless scripts break; we accept that for now and document the workaround as "use stage 2." 2. **Stage 2 — `--preapprove` CLI flag.** Restores headless ergonomics for OS distributions like `n_OS_tr`. 3. **Stage 3 — on-demand role derivation at the prompt.** Closes the user-visible `(unknown)` failure mode. 4. **Stage 4 — audit-log labels and structured activity output.** Forensics + observability. 5. **Stage 5 — `documents/SECURITY.md` rewrite.** After the code is settled. Each stage is a self-contained release. ## 11. Open decisions to confirm before coding - **Q1.** Keep the running-TUI `[a]` hotkey as a panic-allow under a different visual treatment, or remove it entirely? Plan recommends keep, capitalized, default-off. - **Q2.** Is `nostr_idx_` an acceptable auto-naming convention, or do we want to ask the user for a name in the prompt? Plan recommends auto-name now, prompt-name later as iteration. - **Q3.** Should the prompt's "this will create a new identity" warning differ visually from a normal approval prompt? Plan recommends a clearly distinct extra line in the prompt body. - **Q4.** What is the ceiling for `ROLE_TABLE_MAX_ENTRIES`? Plan recommends 256 with a fail-loud overflow. - **Q5.** Do we want a TUI panel showing the active session approvals (helpful for the user to see what they've granted)? Plan recommends yes, smallest possible: caller + role + source label. Could be deferred to a later stage. ## 12. After this plan lands: deferred work - Per-verb scoping at the prompt (the user wants `[a]` to mean "all verbs for this role" but a future `[c]` to mean "let me pick"). Deferred per the user's instruction. - Approval revocation hotkeys (`[k]` to clear all session-grants, `[K]` to clear one). - Approvals visible-and-editable as a TUI panel. - Persistent (cross-restart) approvals — explicitly out of scope; n_signer remains memory-only. - `role_path` auto-derivation under prompt — explicitly out of scope; the security argument in §5 stands. - Capability tokens for cross-machine transports (FIPS, USB serial) — separate plan, separate threat model. ## 13. References - [`README.md`](../README.md) — authoritative behavior spec. - [`plans/nsigner.md`](nsigner.md) — root design plan; pre-registration rule at line 159. - [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md) — wire contract. - [`documents/SECURITY.md`](../documents/SECURITY.md) — security model document; will be rewritten when this plan lands. - [`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) — code that changes.