Files
n_signer/plans/qrexec_persistent_bridge.md

13 KiB

Plan: qrexec transport to a persistent, in-RAM-mnemonic signer

Status: design / ready for review.

Related:


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 §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_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.

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 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():

  • When fd == STDIN_FILENO (the qrexec/stdio process), it reads QREXEC_REMOTE_DOMAIN and tags the caller qubes:<source-vm> (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).
  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,
  • 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 @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 §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:

  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:

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

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:


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.