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_allowflag insrc/server.c:469as the meaning of prompt-[a]. The running-TUI[a]hotkey is unaffected (or removed; see §11).
1. Goals
- 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.
- 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.
- 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 ofrole_pathkeys remains required. - 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. - 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_pathselectors. Fornostr_indexselectors, 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_indexpaths 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_pathis 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: 1use 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:
caller = "uid:<owner>"→PROMPT_NEVER(auto-allow same-uid)caller = "*"→PROMPT_DENY
The new default inserts only:
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) - The running-TUI
[a]hotkey (src/main.c:974,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:
- Length-prefixed request lands.
- Selector resolves (or fails — see §B).
- Policy lookup returns
POLICY_PROMPTbecause no matching entry exists. - Server blocks the request, prints the prompt to the controlling terminal, waits for keystroke.
- On
[a], server appends(caller, role) → PROMPT_NEVERand serves the request. - On
[y], server serves once without appending. - On
[n], server returnspolicy_deniedwithout appending. - 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 isnostr_idx_0by index butmainby 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:1000qubes:work-vmtcp:[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 wantsmain.nostr_index=<n>— match by index. Equivalent torole=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:
- Parses each
--preapproveflag into apolicy_entry_twithprompt = PROMPT_NEVER. - 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). - Inserts each policy entry into the table before the catch-all
*deny. - 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=mainforverb=sign_btc_tx(a hypothetical bitcoin verb) still fails enforcement becausemain's purpose isnostr. 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_signerwith no--preapproveflags → 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_cbcallback (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:
- Default deny. A startup with no
--preapproveflags receives a request from any caller and (in non-interactive mode) returnspolicy_denied. - Prompt
[a]appends entry. Inject a non-interactive prompt-default ofALLOW, observe a policy entry added, verify a second matching request is served without re-prompting. - Prompt
[y]does not append entry. Same fixture, second request still triggers the prompt path. --preapprovewithrole=main. Request from named caller succeeds without prompt; request from any other caller is denied.--preapprovewithnostr_index=Nfor unregistered N. Role is auto-derived at startup; request succeeds; activity log shows:preapprove.--preapprovecannot bypass enforcement. Pre-approve(caller=X, role=main)and request a verb that violates purpose/curve. Response ispurpose_mismatchorcurve_mismatch.- Unknown
role_pathstill rejected. Even with consent, an unregisteredrole_pathreturnsunknown_role. (The narrowing isnostr_index-only.) - Audit-log labels. Each path produces the expected source tag.
- Multi-instance isolation. Two n_signers with different
--preapprovelists serve different requests; cross-instance requests are denied as expected. - Role table overflow. Force more than
ROLE_TABLE_MAX_ENTRIESderivations and observerole_table_fullinstead 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=mainto 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:
- 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." - Stage 2 —
--preapproveCLI flag. Restores headless ergonomics for OS distributions liken_OS_tr. - Stage 3 — on-demand role derivation at the prompt. Closes the user-visible
(unknown)failure mode. - Stage 4 — audit-log labels and structured activity output. Forensics + observability.
- Stage 5 —
documents/SECURITY.mdrewrite. 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_pathauto-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— authoritative behavior spec.plans/nsigner.md— root design plan; pre-registration rule at line 159.documents/CLIENT_IMPLEMENTATION.md— wire contract.documents/SECURITY.md— security model document; will be rewritten when this plan lands.src/policy.c,src/server.c,src/dispatcher.c,src/role_table.c,src/selector.c— code that changes.