v0.0.25 - Add --allow-all flag and short aliases for CLI options
This commit is contained in:
271
plans/auth_envelope_other_transports.md
Normal file
271
plans/auth_envelope_other_transports.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user