Files
n_signer/plans/deny_by_default_approvals.md

22 KiB

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().
  • The "role_path / nostr_index must be pre-registered" rule recorded in plans/nsigner.md:159 (the rule is narrowed, not removed — see §5).
  • The global g_prompt_always_allow flag in 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 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'/<n>'/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 ("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() currently inserts two entries:

  1. caller = "uid:<owner>"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 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 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>

SPEC is a comma-separated list of key=value pairs. Required keys:

  • caller=<id> — 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=<name> — match the role by name. Useful when the OS knows it wants main.
    • nostr_index=<n> — match by index. Equivalent to role=nostr_idx_<n> after auto-derivation.

Optional keys (deferred for now, but reserved):

  • verbs=<list> — comma-separated verb list. Default is *.
  • purposes=<list> — 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:

[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 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 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 Bump ROLE_TABLE_MAX_ENTRIES to 256. New role_table_register_nostr_index(int n) helper that creates a nostr_idx_<n> entry and triggers derivation.
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 No semantic change — dispatcher continues to assume the role exists. The server makes that true before invoking.
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 New tests: deny-by-default, prompt-[a] appends entry, preapprove flag parsing, source-label propagation.
tests/test_dispatcher.c New tests: unregistered nostr_index path with consent-required gating.
tests/test_integration.c New scenario: --preapprove allows scripted client; without it, scripted client gets denied.
documents/SECURITY.md Rewritten in identity / index / approval terms (separate task after this plan ships).
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 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_<n> 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