v0.0.34 - Add qrexec bridge subcommand and --bridge-source-trusted flag for persistent-signer qrexec transport
This commit is contained in:
214
plans/qrexec_persistent_bridge.md
Normal file
214
plans/qrexec_persistent_bridge.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Plan: qrexec transport to a persistent, in-RAM-mnemonic signer
|
||||
|
||||
Status: design / ready for review.
|
||||
|
||||
Related:
|
||||
- [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) — current qrexec design (per-invocation process)
|
||||
- [`documents/SECURITY.md`](../documents/SECURITY.md) — no-persistence, mlock-only mnemonic model
|
||||
- [`documents/FIPS_DEPLOYMENT.md`](../documents/FIPS_DEPLOYMENT.md) — the TCP/FIPS transport we already validated
|
||||
- [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc) — service entrypoint
|
||||
- [`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy) — dom0 policy
|
||||
- [`src/server.c`](../src/server.c:1279) — caller identity extraction
|
||||
- [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) — JS caller example
|
||||
|
||||
---
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
We want caller qubes to reach the signer in the `nostr_signer` qube **without FIPS**, using Qubes' native qrexec IPC. Advantages over the FIPS/TCP path:
|
||||
|
||||
- **No network.** Traffic never leaves the physical machine; no mesh, no IPv6 routing, no chance of an off-host connection.
|
||||
- **Stronger identity.** The caller is identified by `QREXEC_REMOTE_DOMAIN` — a hypervisor-vouched source-qube name, not an app-layer key. This is the `qubes` identity kind already described in [`documents/SECURITY.md`](../documents/SECURITY.md) §3.
|
||||
- **dom0 as the first enforcement boundary.** The qrexec policy decides which qubes may even reach the service, before n_signer's own approval layer runs.
|
||||
|
||||
---
|
||||
|
||||
## 2. The core tension (why the shipped design is wrong for us)
|
||||
|
||||
The current shipped design ([`documents/QUBES_OS.md`](../documents/QUBES_OS.md) §3.2, [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc)) runs, per request:
|
||||
|
||||
```
|
||||
exec nsigner --listen qrexec
|
||||
```
|
||||
|
||||
qrexec spawns a **fresh process for every request**. But n_signer's security model ([`documents/SECURITY.md`](../documents/SECURITY.md) §1) requires the mnemonic to be:
|
||||
|
||||
- entered once, at a terminal, by a human,
|
||||
- held only in `mlock`'d RAM,
|
||||
- never written to disk, argv, or env.
|
||||
|
||||
A fresh per-request process has **no mnemonic**. The only ways to give it one per-invocation all violate the model:
|
||||
|
||||
- ❌ mnemonic file on disk — defeats "nothing on disk."
|
||||
- ❌ `--mnemonic <phrase>` argv — rejected by design ([`plans/mnemonic_startup_input.md`](mnemonic_startup_input.md) §2).
|
||||
- ❌ `NSIGNER_MNEMONIC` env — rejected by design.
|
||||
- ❌ TUI prompt per request — impossible; qrexec has no interactive terminal, and it would prompt on every single call.
|
||||
|
||||
Therefore the per-invocation qrexec design is fundamentally incompatible with the persistent-mnemonic model. **We reject it.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Chosen design: a stateless qrexec → unix-socket bridge
|
||||
|
||||
Keep exactly one **persistent** signer process, started by the human at the terminal (mnemonic in RAM, as today). The qrexec service becomes a **thin, stateless relay** that forwards one framed request to the running signer's abstract unix socket and relays one framed response back.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Caller qube app] -->|qrexec-client-vm qubes.NsignerRpc| B[dom0 policy check]
|
||||
B -->|allowed| C[qubes.NsignerRpc bridge in nostr_signer]
|
||||
C -->|connect abstract unix socket| D[persistent nsigner --listen unix]
|
||||
D -->|mnemonic in mlock RAM| E[sign / get_public_key]
|
||||
E --> D
|
||||
D --> C
|
||||
C --> A
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- The **bridge holds no secrets.** It is a dumb pipe: read framed bytes from qrexec stdin, write them to the unix socket, copy the socket's framed response back to qrexec stdout, exit.
|
||||
- The **signer stays persistent** with the mnemonic in RAM, started once by the human exactly as it is today for the FIPS/TCP path.
|
||||
- **No mnemonic on disk, in argv, or in env.** The model in [`documents/SECURITY.md`](../documents/SECURITY.md) is preserved verbatim.
|
||||
|
||||
---
|
||||
|
||||
## 4. The identity-propagation problem (must-fix)
|
||||
|
||||
This is the crux and the main reason this needs a plan rather than a one-line script.
|
||||
|
||||
n_signer extracts the caller identity in [`server_get_caller()`](../src/server.c:1269):
|
||||
|
||||
- When `fd == STDIN_FILENO` (the qrexec/stdio process), it reads `QREXEC_REMOTE_DOMAIN` and tags the caller `qubes:<source-vm>` ([`src/server.c:1279`](../src/server.c:1279)).
|
||||
- Otherwise (a real socket fd) it uses `SO_PEERCRED` (unix) or `getpeername` (tcp).
|
||||
|
||||
If the bridge simply connects to the signer's unix socket, the signer sees the **bridge process** via `SO_PEERCRED` — i.e. `uid:<bridge-uid>`, **not** `qubes:<source-vm>`. The hypervisor-vouched source-qube identity is lost, and every caller collapses into one indistinguishable local uid. That destroys the entire point of using qrexec for identity.
|
||||
|
||||
Three candidate solutions:
|
||||
|
||||
### Option A — signer runs in qrexec mode, bridge injects the env var (rejected)
|
||||
|
||||
Have the bridge itself be `nsigner --listen qrexec`, but somehow attach to the persistent mnemonic. There is no mechanism for one nsigner process to borrow another's mlock'd mnemonic. Rejected — it is just the per-invocation design again.
|
||||
|
||||
### Option B — bridge forwards source-qube as an application-layer field (chosen, needs small code change)
|
||||
|
||||
The bridge reads `QREXEC_REMOTE_DOMAIN` (which qrexec sets in the bridge's environment) and passes it to the signer as a trusted out-of-band field on the local socket. Concretely:
|
||||
|
||||
1. Add a new listen sub-mode or startup flag to the persistent signer that marks its unix socket as a **trusted bridge socket** — e.g. `--listen unix --bridge-source-trusted`. Only bind this on an abstract socket the operator controls.
|
||||
2. Extend the wire handling so that on a bridge-trusted socket, the signer accepts a per-connection preamble line carrying the source-qube name (e.g. a first framed control message `{"qrexec_source":"<vm>"}`), and composes the caller id as `qubes:<vm>` exactly as the native qrexec path does today ([`src/server.c:1286`](../src/server.c:1286)).
|
||||
3. The trust argument: the bridge socket is an **abstract unix socket inside the signer qube**, reachable only by processes already inside that qube. The qrexec framework is the thing that authenticated the source qube and set `QREXEC_REMOTE_DOMAIN`; the bridge merely relays that framework-vouched value. This keeps identity quality equivalent to the native qrexec path.
|
||||
|
||||
This is the smallest change that preserves `qubes:<source-vm>` identity. It requires:
|
||||
|
||||
- a new startup flag on the signer,
|
||||
- a small preamble-parsing branch in [`src/server.c`](../src/server.c),
|
||||
- the bridge script to send the preamble before relaying the request.
|
||||
|
||||
### Option C — signer binds the qrexec service directly, no separate bridge (rejected for now)
|
||||
|
||||
Have qrexec hand the connection's stdin/stdout **directly** to the persistent signer via a passed FD, so the signer's existing `fd == STDIN_FILENO` path fires and reads `QREXEC_REMOTE_DOMAIN` natively. This is the cleanest in theory but qrexec's execution model spawns a new process per call; wiring an existing long-lived process to receive per-call qrexec FDs would require a socket-activation-style shim (e.g. `systemd`-socket or a small accept-loop that re-execs). Deferred as a future refinement — Option B gets us working sooner with a clear trust story.
|
||||
|
||||
**Decision: implement Option B.** Document the trust boundary explicitly.
|
||||
|
||||
---
|
||||
|
||||
## 5. Components and changes
|
||||
|
||||
### 5.1 Persistent signer (nostr_signer qube) — started by human
|
||||
|
||||
Unchanged startup ergonomics from the FIPS path, minus TCP, plus the bridge flag:
|
||||
|
||||
```bash
|
||||
~/.local/bin/nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
```
|
||||
|
||||
- Mnemonic entered interactively at the terminal (mlock RAM), exactly as today.
|
||||
- Binds abstract unix socket `@nsigner` inside the qube only.
|
||||
- `--allow-all` is **not** used; native deny-by-default + prompt behavior is preserved. First request from `qubes:<vm>` for an index prompts the human at the signer terminal, per [`documents/SECURITY.md`](../documents/SECURITY.md) §4. This is the intended trust anchor.
|
||||
|
||||
Requires the code change in §4 Option B.
|
||||
|
||||
### 5.2 qrexec bridge service (nostr_signer qube)
|
||||
|
||||
New/updated [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc) — a stateless relay, no secrets, no mnemonic:
|
||||
|
||||
Responsibilities:
|
||||
1. Read `QREXEC_REMOTE_DOMAIN` from env.
|
||||
2. Connect to abstract unix socket `@nsigner`.
|
||||
3. Send the trusted-source preamble (`{"qrexec_source":"<vm>"}` framed).
|
||||
4. Copy one framed request from stdin → socket.
|
||||
5. Copy one framed response from socket → stdout.
|
||||
6. Exit.
|
||||
|
||||
Implementation language: a small C helper (preferred, ships in the static binary as a `nsigner bridge` subcommand) or a vetted `python3` script. A `nsigner bridge` subcommand is cleanest — it reuses the existing framing code and avoids a python dependency in the signer qube.
|
||||
|
||||
> **Proposed:** add `nsigner bridge --to @nsigner` subcommand that does exactly the relay above, so the qrexec service is `exec nsigner bridge --to @nsigner`.
|
||||
|
||||
### 5.3 dom0 policy — unchanged shape
|
||||
|
||||
[`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy):
|
||||
|
||||
```
|
||||
qubes.NsignerRpc * @anyvm @tag:nsigner-signer ask default_target=nostr_signer
|
||||
qubes.NsignerRpc * @anyvm @anyvm deny
|
||||
```
|
||||
|
||||
- `ask` gives a dom0-level confirmation on first use (defense in depth on top of the signer's own prompt).
|
||||
- Tag the signer qube: `qvm-tags nostr_signer add nsigner-signer`.
|
||||
- Consider `allow` instead of `ask` for specific trusted caller qubes once the flow is validated, e.g.:
|
||||
`qubes.NsignerRpc * ai @tag:nsigner-signer allow target=nostr_signer`
|
||||
|
||||
### 5.4 Caller-side client (any caller qube)
|
||||
|
||||
[`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) already does the right thing: it spawns `qrexec-client-vm <target> qubes.NsignerRpc`, sends one framed request, reads one framed response. **No auth envelope is needed** — identity comes from `QREXEC_REMOTE_DOMAIN`. The example needs no change once the bridge is in place.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security review against n_signer intent
|
||||
|
||||
| Requirement (from SECURITY.md / QUBES_OS.md) | Preserved? | How |
|
||||
|---|---|---|
|
||||
| Mnemonic only in mlock'd RAM, entered at terminal | ✅ | Persistent signer started by human; bridge holds no key material |
|
||||
| Nothing on disk / argv / env for the mnemonic | ✅ | Bridge never touches the mnemonic; no mnemonic file |
|
||||
| One process, one terminal, one human | ✅ | Exactly one persistent signer with its TUI; bridge is a dumb relay, not a signer |
|
||||
| Deny-by-default, human approval for new callers | ✅ | `--allow-all` dropped; native prompt fires at the signer terminal for each new `qubes:<vm>`+index |
|
||||
| Hypervisor-vouched caller identity | ✅ | `QREXEC_REMOTE_DOMAIN` relayed via trusted bridge socket, tagged `qubes:<vm>` |
|
||||
| dom0 as first enforcement boundary | ✅ | qrexec policy `ask`/`deny` runs before the signer sees anything |
|
||||
| Crash = total wipe | ✅ | Unchanged; only the persistent signer holds state |
|
||||
| No off-host connectivity | ✅ | qrexec is intra-host IPC; no network at all |
|
||||
|
||||
Explicit trust boundary added by this design (must be documented in SECURITY.md if implemented):
|
||||
|
||||
> The bridge socket `@nsigner` inside the signer qube is trusted to relay the `QREXEC_REMOTE_DOMAIN` value faithfully. Any process already inside the signer qube could connect to it and assert an arbitrary source-qube name. This is acceptable because (a) the signer qube is a dedicated, minimal, trusted qube, and (b) a process already inside the signer qube is inside the trust boundary anyway. The identity quality equals the native qrexec path.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation checklist
|
||||
|
||||
Code (in `n_signer`):
|
||||
- [ ] Add `nsigner bridge --to <abstract-socket>` subcommand (stateless framed relay + source preamble).
|
||||
- [ ] Add `--bridge-source-trusted` flag to the unix listener; parse the `{"qrexec_source":"<vm>"}` preamble and compose `qubes:<vm>` caller id.
|
||||
- [ ] Preserve deny-by-default + prompt for bridge-originated callers (no `--allow-all`).
|
||||
- [ ] Unit/integration test: bridge relay round-trip with a faked `QREXEC_REMOTE_DOMAIN`.
|
||||
|
||||
Packaging:
|
||||
- [ ] Update [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc) to `exec nsigner bridge --to @nsigner` (no mnemonic file).
|
||||
- [ ] Keep [`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy) target = `nostr_signer`.
|
||||
|
||||
Operator runbook:
|
||||
- [ ] nostr_signer: install service, start persistent signer with `--listen unix --socket-name nsigner --bridge-source-trusted`, enter mnemonic.
|
||||
- [ ] dom0: install policy, `qvm-tags nostr_signer add nsigner-signer`.
|
||||
- [ ] caller qube: run [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js).
|
||||
|
||||
Docs:
|
||||
- [ ] Update [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) §3.2 to describe the persistent-signer + bridge model, superseding the per-invocation design.
|
||||
- [ ] Add the §6 trust-boundary note to [`documents/SECURITY.md`](../documents/SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
## 8. Interim (no-code) fallback for testing today
|
||||
|
||||
Until the `bridge` subcommand and `--bridge-source-trusted` flag land, we can validate the transport path (but **not** proper identity) with a temporary python bridge that forwards to a persistent `nsigner --listen unix --socket-name nsigner --allow-all`. This proves qrexec plumbing end-to-end. It must not be used in production because:
|
||||
|
||||
- `--allow-all` disables the human approval anchor, and
|
||||
- the caller collapses to the bridge's local uid (no `qubes:<vm>` identity).
|
||||
|
||||
This interim path is for smoke-testing the qrexec/dom0 policy wiring only.
|
||||
Reference in New Issue
Block a user