# Plan: Where else does the Schnorr auth envelope earn its keep?

Status: **draft v1 — design exploration, for review**

> **Origin.** [`plans/caller_token_identity.md`](caller_token_identity.md) introduced a per-request, BIP-340-Schnorr-signed *auth envelope* (Nostr kind 27235) that gives the TCP transport an application-layer identity. The TCP plan was deliberate about scope: AF_UNIX and qrexec keep their kernel/hypervisor identities, stdio inherits them, and the envelope is gated to TCP only. This document asks the next obvious question — **for which other transports is the same envelope actually useful, and where is it noise or worse?** — and proposes a small, conservative extension.

The thesis up front: **the envelope is a tool for binding a stable program identity to a stream that the OS/hypervisor cannot itself attribute to a program**. Anywhere the OS already names the caller better than a pubkey could, the envelope adds attack surface for no gain. Anywhere the OS names a *machine* (uid, qube name) but cannot tell us *which program inside that machine*, the envelope earns its keep.

---

## 1. The framework: a 2x2 of what the substrate vouches for

Every transport `n_signer` supports today, or might support tomorrow, can be classified along two axes:

| | Substrate names a **principal** (uid, qube, account) | Substrate does **not** name a principal |
|---|---|---|
| **Substrate names a specific program** | A. Both. Strongest. | (impossible — naming a program implies naming its principal) |
| **Substrate names only a machine/account, not a program** | B. Principal-only. The "which program" gap. | C. Anonymous pipe. |

- **A** is hypothetical for our context. Not even AF_UNIX gives us this — `SO_PEERCRED.pid` is UX, not identity, because PIDs are reusable and the kernel does not bind PID to executable.
- **B** is where AF_UNIX, qrexec, and stdio-via-qrexec actually live today. We learn `uid:1000` or `qubes:foo`, but two different binaries running as the same uid in the same qube look identical.
- **C** is plain TCP, anonymous pipes, USB serial, Bluetooth — substrates that say only "some bytes arrived from somewhere."

**The auth envelope's job is to upgrade B → A and C → A** by letting the program-with-a-keypair speak for itself, on top of whatever the substrate already provides. It does *not* replace substrate identity; it composes with it.

This re-framing is the whole answer to the user's question. Every transport below is just a worked example of which cell it lives in.

---

## 2. Per-transport verdict

| Transport | Substrate identity | Cell | Envelope verdict |
|---|---|---|---|
| AF_UNIX (current) | `SO_PEERCRED.uid` (kernel-attested) | B | **Optional, off by default.** Adds program-level identity inside one uid; useful for multi-program desktops, redundant for single-user vault qubes. |
| qrexec (current) | `QREXEC_REMOTE_DOMAIN` (Xen-attested) | B | **Optional, off by default, but the most interesting case.** Solves the "many tools in one caller qube share one approval row" problem and the cross-qube state continuity question. See §3. |
| stdio (current, interactive) | inherited uid of launching shell | B | **Off.** The user is at the terminal; substrate identity is the human, and the envelope cannot improve on that. |
| stdio (current, qrexec-launched) | `QREXEC_REMOTE_DOMAIN` from env | B | **Same as qrexec row.** stdio over qrexec is qrexec by another name. |
| stdio (anonymous pipe, e.g. systemd socket-activated, or shelled out from a daemon) | none | C | **Required if used in production.** No principal at all otherwise. See §4. |
| TCP (implemented v2) | peer addr (forgeable beyond loopback) | C | **Required, on every request.** Already shipped; this plan does not modify it. |
| FIPS / serial-over-USB (planned) | device path; no principal | C | **Required.** Same reasoning as TCP — substrate provides confidentiality of the wire (in FIPS's case) but no caller identity. |
| Bluetooth / NFC (hypothetical) | MAC address (forgeable) | C | **Required.** |

The verdict column collapses to three modes — **off**, **optional** (accepted if present, ignored if absent), **required** (rejected if absent) — selectable per listener.

---

## 3. The qrexec story (the user's actual question)

Qubes is the case where the envelope buys the most, and where it most clearly solves a problem the user has named: *"keeping state between qubes is difficult."* Two distinct sub-problems hide under that phrase. The envelope addresses one of them cleanly and the other partially.

### 3.1 Sub-problem 1: many programs, one approval row

A typical caller qube — say `personal` — runs nostr_terminal, a browser extension, a CLI scratch tool, and a backup script. Today they all show up to the signer as `qubes:personal`. The user approves once, and from then on **every program in that qube is a signer for that nostr_index**. There is no way to express "the browser extension may sign kind-1 events but the CLI scratch tool may not." Approval granularity is one-per-qube, which is too coarse for any caller qube that holds more than one tool.

With the envelope optional-on for qrexec:

- nostr_terminal in `personal` signs envelopes with its caller pubkey `Pk_term`. Browser extension signs with `Pk_ext`. The signer records *two distinct caller_ids* — `qubes:personal+pubkey:<Pk_term>` and `qubes:personal+pubkey:<Pk_ext>` (composite form, see §6) — and prompts once per program.
- Programs that send no envelope keep the legacy `qubes:personal` identity and the legacy approval row. Backwards compatible.
- The user gets per-program revocation: removing the approval for `Pk_ext` does not affect nostr_terminal, even though both run inside `personal`.

This is the same connection-churn fix the TCP plan delivered, applied to a different ambiguity: not "same network address, different process" but "same qube, different program." The fix is identical because the underlying gap is identical — the substrate names something coarser than the unit we want to authorize.

### 3.2 Sub-problem 2: state continuity between qubes

In Qubes, programs that want shared identity across qubes — say a tool the user runs in both `personal` and `work`, or a tool installed in a disposable that is recreated each launch — cannot use any of the usual "save a token to disk" tricks because the qubes are separate state domains. The qrexec policy can route the call but cannot share secrets.

The envelope does not magically share state across qubes — but **it makes the question well-formed**. Two patterns become available:

- **Per-qube installation, distinct identity.** Tool `T` installed in `personal` generates its own keypair on first run, stored in that qube. Tool `T` in `work` generates a different keypair. The signer sees two callers; the user approves each. This is exactly what the security model wants — the qubes are *different trust domains*, so they should have different identities, full stop.
- **Per-tool identity, shared across qubes via deliberate seed copy.** A power user who genuinely wants "the same identity for nostr_terminal in `personal` and `work`" can copy nostr_terminal's seed (or derived caller key) between the two qubes themselves, using Qubes' usual file-copy primitives. The signer then sees one caller identity from two qubes. Whether that is a good idea is a user-policy decision; the protocol simply tolerates it.

The disposable-VM case is the most interesting. A disposable that re-rolls its caller keypair on every boot will re-prompt the signer every boot — which is correct behavior. *"This is a fresh untrusted environment"* is exactly what a disposable is. If the user wants disposable-but-recognized, they are asking for the per-tool seed pattern above and have to accept the tradeoff that the seed material outlives the disposable.

The envelope does not solve cross-qube state by itself. What it does is **give the user a stable, signer-visible name to make their state-management decisions against** — instead of forcing every cross-qube identity question through the qube-name primitive, which is too coarse to express the question.

### 3.3 Why "optional, off by default" rather than "required"

Required envelopes for qrexec would break every existing caller (shell scripts, the existing client examples in [`documents/qubes_client_examples.md`](../documents/qubes_client_examples.md)) and force every Qubes caller to grow Schnorr-signing capability before it can talk to the signer. That is too high a cost for a transport whose substrate identity is already strong.

Optional means:

- Old callers keep working unchanged. Their `caller_id` stays `qubes:<vm>`.
- New callers that *want* per-program approvals send envelopes and get `qubes:<vm>+pubkey:<hex>`.
- Operators who want to enforce envelope-on-qrexec for a hardened deployment can flip a flag (`--qrexec-require-auth`).

The default-off posture matches the TCP plan's principle: *give the weakest substrate the strongest application-layer identity.* qrexec is not the weakest; it is the second-strongest. It earns the upgrade only where the operator opts in.

---

## 4. The stdio story

stdio mode in `n_signer` has three real-world spawning patterns:

1. **Interactive: a user runs `nsigner --listen stdio` in a terminal and pipes a request in.** Substrate identity is the user's own uid. The user is physically present at the prompt anyway. **Envelope adds nothing.** Off.

2. **qrexec service: stdio with `QREXEC_REMOTE_DOMAIN` set.** Already covered as the qrexec row in §2. Same verdict as qrexec.

3. **Spawned by another program over an anonymous pipe.** Daemon `D` execs `nsigner --listen stdio` and pipes JSON-RPC over the inherited fds. The signer sees only `STDIN_FILENO`; there is no `SO_PEERCRED` (no socket), no `QREXEC_REMOTE_DOMAIN`, no peer address. Today the signer falls back to its own uid as caller_id, which is to say *the signer is its own caller* — meaningless as identity. **This is cell C.** If anyone is using this pattern, the envelope is the only available identity primitive.

The third pattern is rare but not hypothetical. systemd socket-activated services, container init wrappers, and "supervisor that spawns one signer process per request" deployments all fit. Today they are silently insecure (any program in the same uid can pipe a request in and be `caller_id = uid:1000`, indistinguishable from any other). With envelope-required for anonymous-pipe stdio, the daemon must hold a keypair and identify itself, and the signer can record per-daemon approvals.

The detection rule is mechanical: if the listen mode is stdio AND `QREXEC_REMOTE_DOMAIN` is unset AND `SO_PEERCRED` is unavailable on stdin (it is not a socket), classify the stream as anonymous-pipe stdio and require the envelope. Two existing pieces of code already participate in this branch: [`server_get_caller`](../src/server.c:1181) for the QREXEC env check, and the listen-mode plumbing in [`src/main.c:1043`](../src/main.c:1043).

---

## 5. The AF_UNIX story (briefly)

AF_UNIX is the strongest substrate today and the one most likely to be misjudged. It is tempting to say "uid is enough." For single-user vault qubes it is. For shared workstations or for the per-program-revocation use case described in §3.1, it is not.

The right disposition for AF_UNIX is identical to qrexec: **optional envelope, off by default, on-flag to require**. Same composite caller_id form. The only difference is that the prompt UX should still surface `pid=N (exe=/path)` from `SO_PEERCRED` as decoration alongside the npub, because on a local machine the user can `ps -p N` to verify in a way they cannot for a remote pubkey.

This was [`plans/caller_token_identity.md`](caller_token_identity.md) §13 open question 5 ("should AF_UNIX accept an envelope as additional identity?") with the v2 default of "no." This document **revisits that default** and proposes "yes, but optional." See §6 and §8.

---

## 6. Composite caller_id form

When envelope is **optional and present** on a non-TCP transport, the caller_id needs to combine substrate identity and pubkey identity. The proposed form:

```
qubes:personal+pubkey:abc123…def
uid:1000+pubkey:abc123…def
anon-stdio+pubkey:abc123…def      ; for case §4.3
```

Properties:

- **Lexicographic prefix matches substrate-only callers.** A policy entry for `qubes:personal` does **not** match `qubes:personal+pubkey:…` — they are distinct strings, distinct rows. This is intentional: opting into envelope auth means opting into a new, separate identity, not "same identity but with extra proof."
- **The plus character is illegal in every existing caller_id form** (uids are digits, pubkeys are hex, qube names are alphanumeric+hyphen). Using `+` as separator avoids ambiguity.
- **Width.** `caller_id` was widened to 80 bytes in v2 of the TCP plan ([`plans/caller_token_identity.md:181`](caller_token_identity.md:181)). The composite form needs `len("qubes:") + 64 + len("+pubkey:") + 64 + 1` ≈ 144 bytes. Bumps to 160 to round.

Approvals attach to the composite string. A user who approves the composite form is approving "this program *running in* this qube," not "this program anywhere" and not "this qube's anything." That is the correct semantics for the per-program-revocation use case.

For TCP — where substrate identity does not exist — the form remains the bare `pubkey:<hex>` from v2. No change.

---

## 7. Wire-protocol changes (small)

The existing envelope already carries everything needed; only the verification gate changes per transport.

### 7.1 Per-listener auth posture

Three settings, one per listener:

| Posture | Behavior on receipt |
|---|---|
| `off` | `auth` field, if present, is **ignored**. caller_id is substrate-only. Default for AF_UNIX, qrexec, interactive stdio. |
| `optional` | If `auth` is present and verifies → composite caller_id. If `auth` is absent → substrate-only caller_id. If `auth` is present and fails verification → reject. Operator opt-in for AF_UNIX/qrexec/stdio. |
| `required` | `auth` must be present and verify. Default for TCP, FIPS-serial, anonymous-pipe stdio. |

CLI plumbing extends what the TCP plan already added:

```
--listen unix --auth off            # default
--listen unix --auth optional
--listen qrexec --auth optional
--listen stdio --auth required      # for anon-pipe deployments
--listen tcp:... --auth required    # only legal value for tcp; kept explicit
```

The auth-skew-seconds and nonce cache are listener-context state; they already exist in `server_ctx_t` from v2 and are reused unmodified.

### 7.2 The envelope itself: zero changes

Same kind 27235, same three required tags, same JCS body hash, same Schnorr verification primitive [`nostr_verify_event_signature`](../resources/nostr_core_lib/nostr_core/nip001.h:20). A client that signs envelopes for TCP today can sign them for qrexec tomorrow with no code change. This is the single most important property of the proposal — it preserves the v2 envelope as a portable, transport-independent artifact.

### 7.3 Replay cache scoping

One subtle question: is the nonce cache per-listener or process-global? Process-global is simpler and strictly stricter (an envelope replayed across two listeners is also rejected). Per-listener is more permissive but has no real attack-model justification. **Default: process-global**, unchanged from v2.

---

## 8. Decision matrix and recommended defaults

| Transport | Default posture | Operator can flip to | Rationale |
|---|---|---|---|
| AF_UNIX | off | optional, required | uid is strong; envelope is for power users who need per-program granularity. |
| qrexec | off | optional, required | qube is strong; envelope solves §3.1 for users who hit it. |
| stdio (interactive) | off | (locked off) | Envelope cannot improve on a human at the terminal. |
| stdio (qrexec-launched) | inherits qrexec posture | (inherits) | Same substrate. |
| stdio (anonymous pipe) | required | (locked required) | No substrate identity exists; refusing is the only safe default. |
| TCP | required | (locked required) | Already v2 default. Unchanged. |
| FIPS / USB-serial (planned) | required | (locked required) | Same as TCP — wire identity is fictional. |

"Locked" rows reflect transports where flipping the posture would re-introduce the gap the envelope was designed to close, and the CLI rejects the change.

---

## 9. Implementation order

Each step is independently mergeable, gated behind the per-listener `--auth` flag default of `off` for the new transports so existing callers see no behavior change.

1. **Refactor: lift the v2 auth gate out of the TCP-specific branch in [`server_handle_one`](../src/server.c:1192) into a transport-agnostic helper** parameterized by posture. No semantic change yet; TCP listener still passes `required`.
2. **Add the `--auth {off|optional|required}` CLI flag** with per-listener storage on `server_ctx_t`. Default off for unix/qrexec, required for tcp, off-locked for interactive stdio, required-locked for anonymous-pipe stdio.
3. **Add anonymous-pipe-stdio detection.** In [`server_get_caller`](../src/server.c:1181), when listen mode is stdio and `QREXEC_REMOTE_DOMAIN` is unset, probe `SO_PEERCRED` on stdin; on failure mark the stream as anonymous-pipe and require auth.
4. **Composite caller_id format** in [`caller_identity_t`](../src/server.c:404). Widen to 160. Add a small helper `caller_id_compose(buf, sz, substrate_kind, substrate_value, pubkey_hex)`.
5. **`--preapprove` extension.** Accept the composite form (`caller=qubes:personal+pubkey:<hex>`, `caller=uid:1000+pubkey:<hex>`). Existing forms stay valid.
6. **Tests.**
   - Optional-mode AF_UNIX accepting both with-envelope and without-envelope requests, producing two distinct caller_ids.
   - Optional-mode qrexec with two distinct pubkeys from one source qube → two prompts, two approvals, independent revocation.
   - Anonymous-pipe-stdio rejects no-envelope requests with code 2014.
   - TCP behavior unchanged (regression check on v2).
7. **Documentation.**
   - [`documents/SECURITY.md`](../documents/SECURITY.md) §3 identity table: add the composite kinds; clarify that envelope is now a portable identity layer, not a TCP-only feature.
   - [`documents/QUBES_OS.md`](../documents/QUBES_OS.md): add a §3.4 on per-program identity within a qube and the disposable-VM tradeoff.
   - [`documents/CLIENT_IMPLEMENTATION.md`](../documents/CLIENT_IMPLEMENTATION.md): note that any client may attach an envelope on any transport; the signer's posture decides whether it is honored or required.

## 10. What this plan deliberately does not do

- **Does not change TCP behavior.** The v2 contract on TCP — required envelope, every request — is preserved exactly.
- **Does not introduce a new envelope format, kind, or canonicalization.** Reuses kind 27235 and the JCS body-hash rule from v2 verbatim.
- **Does not propose persistent state** anywhere. Approvals stay in RAM; no caller registry on disk; the §1 "crash equals total wipe" property of [`documents/SECURITY.md:19`](../documents/SECURITY.md:19) is unchanged.
- **Does not invent an answer to cross-qube state continuity.** It articulates the question and gives the user a primitive (a stable pubkey-named caller) against which to make their own state decisions.
- **Does not add transport encryption** for any substrate. Confidentiality is the substrate's job (FIPS, qrexec, AF_UNIX with abstract namespace, loopback). The envelope is auth, not encryption.

## 11. Open questions for review

1. **Is the composite caller_id form (`substrate+pubkey:...`) the right disposition, or should the envelope, when present, *override* substrate identity entirely (replacing rather than composing)?** The override form is simpler — one identity, not two — but it discards information the user might want (the npub is verified; the qube name is hypervisor-attested; both data points are useful at the prompt). Default: compose.
2. **Should anonymous-pipe stdio be required or rejected outright?** A reasonable case can be made that `nsigner --listen stdio` over a non-tty, non-socket fd is just *misuse* and should refuse to start rather than silently demand envelopes. Default: require envelope; allow operator to disable the listen mode entirely if undesired.
3. **Should the `--auth optional` setting on AF_UNIX/qrexec be a per-listener flag (today's proposal) or per-method (e.g. envelope required for `sign_event` but not for `get_public_key`)?** Per-method adds policy surface for limited gain; substrate identity is already the per-method gate via the existing role table. Default: per-listener only.
4. **Does the v2 nonce cache size of 1024 still suffice when up to four listeners may share it?** Yes — the bound is "envelopes per skew window," not "listeners." Confirmed sufficient. No change.
5. **Should `qubes:<vm>+pubkey:<hex>` and `pubkey:<hex>` (TCP) for the same pubkey be considered the same caller for `--preapprove` convenience?** No. They are different identities by construction (different substrates, different threat models). An operator who wants both pre-approved writes both lines. Default: keep them distinct.

---

## Appendix A — Mermaid: revised per-transport decision tree

```mermaid
flowchart TD
    A[Request arrives on a listener] --> B{listener auth posture}
    B -- off --> C[ignore any auth field]
    C --> D[caller_id = substrate-only]
    B -- optional --> E{auth field present?}
    E -- no --> D
    E -- yes --> F[verify envelope per v2 rules]
    F -- fail --> G[reject 2010-2017]
    F -- ok --> H[caller_id = substrate + pubkey:hex]
    B -- required --> I{auth field present?}
    I -- no --> J[reject 2014 auth_envelope_required]
    I -- yes --> F
    D --> K[normal policy_check]
    H --> K
    K --> L{verdict}
    L -- ALLOW --> M[dispatch RPC]
    L -- DENY --> N[2001 policy_denied]
    L -- PROMPT --> O[approve / save / register caller_id]
    O --> M
```

## Appendix B — Worked example: per-program identity inside a qube

Caller qube `personal` runs two tools:

- nostr_terminal, holding caller key `Pk_term`.
- A custom shell script, no keypair.

Operator starts the signer with:

```
nsigner --listen qrexec --auth optional \
  --preapprove caller=qubes:personal+pubkey:Pk_term,role=main
```

First request from nostr_terminal carries an envelope signed by `Pk_term`. Signer verifies, builds caller_id `qubes:personal+pubkey:Pk_term`, hits the pre-approval, signs without prompting.

First request from the shell script carries no envelope. Signer skips verification, builds caller_id `qubes:personal`, finds no approval, prompts the user. User can approve `[a]` for the shell script independently of nostr_terminal.

Later, the user wants to revoke the shell script's access (a future TUI hotkey). They remove the `qubes:personal` row. nostr_terminal continues to work because its row `qubes:personal+pubkey:Pk_term` is untouched. **Per-program revocation, achieved without any change to qrexec policy or the qube boundary.**

This is the concrete answer to the user's "is this useful for other transports" question — yes, in qrexec specifically, where it converts qube-granularity approvals into program-granularity approvals while leaving the qube boundary itself unchanged.
