13 KiB
Plan: qrexec transport to a persistent, in-RAM-mnemonic signer
Status: design / ready for review.
Related:
documents/QUBES_OS.md— current qrexec design (per-invocation process)documents/SECURITY.md— no-persistence, mlock-only mnemonic modeldocuments/FIPS_DEPLOYMENT.md— the TCP/FIPS transport we already validatedpackaging/qubes/rpc/qubes.NsignerRpc— service entrypointpackaging/qubes/policy.d/40-nsigner.policy— dom0 policysrc/server.c— caller identity extractionexamples/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 thequbesidentity kind already described indocuments/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 §3.2, 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 §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§2). - ❌
NSIGNER_MNEMONICenv — 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.
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.mdis 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():
- When
fd == STDIN_FILENO(the qrexec/stdio process), it readsQREXEC_REMOTE_DOMAINand tags the callerqubes:<source-vm>(src/server.c:1279). - Otherwise (a real socket fd) it uses
SO_PEERCRED(unix) orgetpeername(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:
- 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. - 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 asqubes:<vm>exactly as the native qrexec path does today (src/server.c:1286). - 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, - 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:
~/.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
@nsignerinside the qube only. --allow-allis not used; native deny-by-default + prompt behavior is preserved. First request fromqubes:<vm>for an index prompts the human at the signer terminal, perdocuments/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 — a stateless relay, no secrets, no mnemonic:
Responsibilities:
- Read
QREXEC_REMOTE_DOMAINfrom env. - Connect to abstract unix socket
@nsigner. - Send the trusted-source preamble (
{"qrexec_source":"<vm>"}framed). - Copy one framed request from stdin → socket.
- Copy one framed response from socket → stdout.
- 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 @nsignersubcommand that does exactly the relay above, so the qrexec service isexec nsigner bridge --to @nsigner.
5.3 dom0 policy — unchanged shape
packaging/qubes/policy.d/40-nsigner.policy:
qubes.NsignerRpc * @anyvm @tag:nsigner-signer ask default_target=nostr_signer
qubes.NsignerRpc * @anyvm @anyvm deny
askgives 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
allowinstead ofaskfor 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 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
@nsignerinside the signer qube is trusted to relay theQREXEC_REMOTE_DOMAINvalue 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-trustedflag to the unix listener; parse the{"qrexec_source":"<vm>"}preamble and composequbes:<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.NsignerRpctoexec nsigner bridge --to @nsigner(no mnemonic file). - Keep
packaging/qubes/policy.d/40-nsigner.policytarget =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.
Docs:
- Update
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.
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-alldisables 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.