# 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'/<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`](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:<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 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>
```

`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:

```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_<n>` 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_<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

- [`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.
