Files
n_signer/plans/unified_hw_qubes_broker.md

19 KiB

Plan: Unified hardware-signer broker for Qubes OS

Status: design / ready for review.

Related:


1. Goal

Use any hardware n_signer variant, plugged into the machine once, as a shared signer reachable from any qube and any application — without one qube/application capturing the USB device and locking out the rest.

This generalizes plans/kb2040_qubes_signing_bridge.md from a single device to a unified broker that covers every hardware variant in firmware/.


2. The core problem (why sharing is non-trivial on Qubes)

Two Qubes constraints combine to make "share one USB signer" hard:

  1. USB is routed at whole-device granularity. qvm-usb attach moves the entire USB device (all interfaces) to one qube. For composite devices (KB2040 HID+CDC, Feather CDC+WebUSB), attaching the signing interface to an app qube also detaches the HID interface from dom0's input proxy → media keys die globally.
  2. A USB endpoint is exclusively owned by one process in one qube. Two qubes cannot each open the CDC/WebUSB node at the same time. Whichever qube opens it captures it.

A third constraint applies specifically to the browser:

  1. Browser WebUSB / Web Serial can only open a device attached to the browser's own qube. A device owned by sys-usb is invisible to a browser in personal/work. So a browser using WebUSB is forced to capture the device — which is exactly the behavior the user wants to escape.

The only way to share is: no app qube opens the device directly. Keep the device in one owner qube, run a broker there that holds the single exclusive handle, and multiplex all callers over qrexec.


3. Chosen design: a unified broker in the USB-owner qube

One long-lived broker daemon runs in the owner qube (default sys-usb). It:

  • discovers and opens the hardware signer's serial/WebUSB endpoint by VID:PID (or BLE address, future),
  • holds the single exclusive handle for the device lifetime,
  • listens on a local UNIX socket (/run/nsigner-hw.sock),
  • accepts one framed JSON-RPC request per qrexec connection,
  • serializes concurrent callers with an internal lock/queue so frames never interleave on the wire,
  • forwards the frame to the device, relays the framed response back,
  • reopens the device on re-enumeration (unplug/replug, 1200-baud touch, CH340 re-enumeration).

The broker is hardware-agnostic at the JSON-RPC layer: every variant speaks the same algorithm-based API (README.md §4) over the same 4-byte big-endian length + UTF-8 JSON framing. Per-variant logic is isolated in a small transport adapter.

flowchart TD
    HW[Hardware signer: KB2040, Feather, CYD, Teensy, IR dongle]
    subgraph OWNER[owner qube: sys-usb default]
        ADAPT[transport adapter: open by VID:PID]
        BRK[broker: exclusive handle, serialize, reopen]
        SVC[qrexec service qubes.NsignerHwRpc]
    end
    subgraph DOM0[dom0]
        INPUT[input proxy: media keys for composite HID]
        POL[qrexec policy: ask plus deny-by-default]
    end
    subgraph Q[any caller qube]
        APP[CLI, nostr_terminal, NIP-07 extension native helper]
    end

    HW --> ADAPT
    ADAPT --> BRK
    HW -. composite HID .-> INPUT
    APP -->|qrexec framed JSON-RPC| POL --> SVC --> BRK --> ADAPT --> HW
    ADAPT --> BRK --> SVC --> POL --> APP

4. Unified transport adapter model

The broker core talks to a registry of adapters. Each adapter implements a tiny interface (open / read_frame / write_frame / close / status). Most variants collapse to "open a serial node by VID:PID":

Variant USB identity Node Adapter notes
KB2040 hidden signer composite HID + CDC, 239a:cafe /dev/ttyACM* CDC-ACM; HID stays on dom0 input proxy because device never leaves sys-usb
Feather S3 TFT TinyUSB composite CDC + WebUSB vendor, 303a:4001 /dev/ttyACM* CDC-ACM (preferred); WebUSB vendor endpoint is an alternative adapter, not needed when broker owns CDC
CYD ESP32-2432S028 CH340 serial, 1a86:7523 /dev/ttyUSB* serial; clear DTR/RTS on open to avoid ESP32 auto-reset (see firmware/README.md §CYD note)
Teensy 4.1 USB CDC /dev/ttyACM* CDC-ACM
IR air-gap dongle USB CDC dumb pipe /dev/ttyACM* CDC-ACM; dongle is a transparent byte pipe
BLE wearable (concept) BLE GATT n/a future adapter: BLE scan + GATT characteristic; stub for now

Adapter selection: broker config lists one or more (VID, PID) tuples (or a BLE address) and tries them in order until one opens. This lets the operator point the broker at whichever device is plugged in, without changing the broker core.

Reference logic to reuse: examples/kb2040_hidden_signer_client.py already does VID:PID discovery + framed read/write. The broker is essentially that logic plus a unix-socket server and a serialize lock.


5. The browser path (the crux of the capture problem)

Recommendation: the browser must NOT use WebUSB/Web Serial in the shared model. It should reach the broker via qrexec through a NIP-07 native-messaging extension.

Why this is the only sharing-compatible path:

  • WebUSB/Web Serial can only see a device qvm-usb-attached to the browser's own qube. That attach captures the whole device (and kills media keys for composite devices). It is the capture the user is trying to eliminate.
  • The NIP-07 extension already planned in plans/nsigner_browser_extension.md has a "native messaging bridge" transport. Point that native helper at qrexec-client-vm sys-usb qubes.NsignerHwRpc and the browser joins the shared model with zero USB capture.

Supported modes (both documented, qrexec is the default):

Mode How Sharing? Media keys (composite)?
qrexec / NIP-07 (recommended) browser extension native helper → qrexec-client-vm sys-usb qubes.NsignerHwRpc all qubes share preserved
WebUSB direct-attach (opt-out) qvm-usb attach personal <device>, browser opens WebUSB browser qube captures device media dies globally while attached

The direct-attach mode is documented as "this opts out of sharing; use only for isolated single-qube workflows." The default and recommended path is qrexec.

5.1 Concrete finding: nostr_login_lite is the capture problem

nostr_login_lite (sibling project at ~/lt/nostr_login_lite) is the concrete instance of the browser-capture problem. Its nsigner auth method opens the hardware signer directly via browser USB APIs — there is no intermediary:

Both APIs can only see USB devices routed to the qube the browser runs in. On a normal Linux host the browser sees every USB device; on Qubes the browser sees only devices qvm-usb attached to its qube. So when nostr_login_lite connects via the nsigner method, it forces the device to be attached to the browser's qube — which is exactly the capture this plan exists to eliminate. The library is doing the capturing; it is not a workaround for it.

Implication for this plan: nostr_login_lite needs a new transport — a nsigner-qrexec signer module that shells out to qrexec-client-vm sys-usb qubes.NsignerHwRpc with framed JSON-RPC, instead of opening USB directly. Its public RPC surface (getPublicKey, signEvent, nip04Encrypt/Decrypt, nip44Encrypt/Decrypt) is already transport-agnostic — the existing WebUSB and Web Serial classes are two transports implementing the same surface; a qrexec transport would be a third. This is a small, well-scoped addition to nostr_login_lite and is the bridge between this broker plan and the browser.


6. Owner-qube decision

Recommendation: sys-usb (default). Offer a dedicated nsigner-usb qube as a hardened variant.

sys-usb (default) dedicated nsigner-usb
Media-key input proxy unchanged — device stays in sys-usb, HID flows to dom0 as today must re-proxy HID from nsigner-usb to dom0 via qrexec input policy (larger change)
Isolation broker shares sys-usb's broader USB visibility broker in a minimal qube that owns only the signer
Setup complexity lowest higher (per-device auto-attach + input-policy migration)
Trust scope sys-usb can see sign requests; mitigated by on-device approval + dom0 ask smaller blast radius

Decision: default to sys-usb because (a) it preserves media keys for composite devices with no input-policy migration, (b) it matches the prior per-device plan, and (c) the user's chosen approval model (physical button every signature) is the real trust anchor, making sys-usb's visibility acceptable. Document nsigner-usb as an optional hardened path for users who want stronger isolation and are willing to migrate the input proxy (mainly relevant for composite devices).


7. Approval UX

User chose: physical button press on the device for every signature (highest assurance).

Implications:

  • Device must be in signer mode for sign_event to work (e.g. KB2040 PLAY+PREV chord). get_public_key works in either mode.
  • Each remote qrexec sign_event call blocks at the broker until the user physically approves at the hardware.
  • dom0 ask adds a second, per-call Qubes prompt identifying the calling qube — defense in depth. Keep it.
  • The broker must surface, to the caller qube:
    • 2015 "not in signer mode" (and any other device error) clearly and actionable,
    • a "waiting for physical approval" state so the caller knows why it is blocking (optional: a heartbeat/progress frame; v1 can simply block with a timeout).
  • Optional firmware enhancement (later): forward the source-qube name to the device so the OLED shows "approve kind 1 from qubes:personal?" — requires a firmware caller-field addition; not needed for v1.

8. Identity and enforcement layers

Unlike the software-signer bridge (plans/qrexec_persistent_bridge.md), there is no separate persistent nsigner process with a mnemonic — the hardware holds the keys and performs approval. So the enforcement stack is:

  1. dom0 qrexec policy (ask/deny, per calling qube) — first gate.
  2. Hardware physical approval — final gate, per signature.

The broker is a dumb relay: it does not run n_signer's policy/approval engine, because the hardware is the approval surface. The broker reads QREXEC_REMOTE_DOMAIN only to (optionally) log/forward the source qube for display; it is not an enforcement point.

Per-application granularity inside one qube (the auth-envelope story in plans/auth_envelope_other_transports.md) would require the firmware to verify kind-27235 envelopes — a future firmware enhancement, out of scope for v1.


9. Concurrency and re-enumeration

  • Concurrency: the broker holds one exclusive device handle. An internal mutex + request queue guarantees that concurrent qrexec calls never interleave frames on the wire. Calls are serviced one at a time; others wait.
  • Re-enumeration: unplug/replug, 1200-baud touch reset, or CH340 re-enumeration changes /dev/ttyACM* or /dev/ttyUSB*. The broker rediscovers by VID:PID and reopens transparently. A call in flight when the device drops returns a clear "device disconnected" error.
  • CYD auto-reset: opening /dev/ttyUSB* can reset the ESP32 via CH340 DTR/RTS. The broker clears DTR/RTS immediately after open. Document the 10 µF EN↔GND capacitor mod (firmware/README.md §CYD) as the hardware-level fix.

10. Components

A. Broker daemon (runs in owner qube)

  • Discovers/opens the device via the adapter registry (VID:PID list or BLE address).
  • Holds the single exclusive handle for the device lifetime.
  • Listens on /run/nsigner-hw.sock.
  • Accepts one framed JSON-RPC request per connection, forwards to device, returns framed response.
  • Serializes access with a mutex + queue.
  • Reopens on re-enumeration.
  • v1 implementation: Python (pragmatic, reuses examples/kb2040_hidden_signer_client.py), at packaging/qubes/hw_bridge/nsigner_hw_broker.py.
  • Long-term: a nsigner hw-broker C subcommand that ships in the static binary and reuses the existing framing code (mirrors the nsigner bridge subcommand in plans/qrexec_persistent_bridge.md §5.2).

B. qrexec service entrypoint (runs in owner qube)

packaging/qubes/rpc/qubes.NsignerHwRpc — a thin stateless relay:

  1. Read one framed request from qrexec stdin.
  2. Connect to /run/nsigner-hw.sock, relay the frame, read the framed reply.
  3. Write the framed reply to qrexec stdout.
  4. Exit.

Mirrors the shape of packaging/qubes/rpc/qubes.NsignerRpc. Distinct service name (qubes.NsignerHwRpc) so the hardware and software paths never collide.

C. dom0 policy

packaging/qubes/policy.d/41-nsigner-hw.policy:

qubes.NsignerHwRpc * @anyvm @tag:nsigner-hw-bridge ask default_target=sys-usb
qubes.NsignerHwRpc * @anyvm @anyvm deny
  • ask + deny-by-default mirrors 40-nsigner.policy.
  • Tag the owner qube: qvm-tags sys-usb add nsigner-hw-bridge.

D. Caller helper (any qube)

Extend documents/qubes_client_examples.md with qubes.NsignerHwRpc examples (shell + Python): framed get_public_key / sign_event over qrexec-client-vm sys-usb qubes.NsignerHwRpc.

Wire the NIP-07 extension's native-messaging helper (plans/nsigner_browser_extension.md §5) to call qrexec-client-vm sys-usb qubes.NsignerHwRpc. Document WebUSB direct-attach as an opt-out that breaks sharing.

F. Install scripts

  • install-hw-bridge.sh (owner-qube side): broker + service + udev rules + autostart, AppVM-persistent via /rw/config/rc.local + template package (pattern in documents/QUBES_OS.md §5.5).
  • install-hw-policy.sh (dom0 side): install 41-nsigner-hw.policy.

11. Packaging and persistence

sys-usb is usually an AppVM: root filesystem resets at reboot. Persist via:

  • broker + service installed into the template (or /rw/bind-mounted),
  • udev rules for the CDC/serial node permissions inside sys-usb (reuse the 99-rp2040.rules / 99-nsigner-webusb.rules approach in firmware/README.md),
  • /rw/config/rc.local starts the broker at boot,
  • qvm-tags sys-usb add nsigner-hw-bridge and dom0 policy persist in dom0.

12. Security model

  • Private keys never leave the hardware. The broker only relays opaque frames; it cannot extract keys.
  • Owner-qube trust scope: sys-usb can see what you ask to sign and could deny/forge requests. Mitigated by (a) on-device physical approval per signature, (b) dom0 ask per calling qube, (c) deny-by-default policy.
  • No mnemonic on disk/argv/env: the broker holds no key material at all — the hardware is the key store.
  • No off-host connectivity: qrexec is intra-host IPC; no network.
  • Hardened variant: a dedicated nsigner-usb qube shrinks the broker's blast radius at the cost of input-proxy migration for composite devices.

13. Risks and edge cases

  • Signer-mode requirement: remote sign_event fails with 2015 unless the device is in signer mode; broker returns a clear, actionable error.
  • Device re-enumeration: broker rediscover by VID:PID; in-flight call returns "device disconnected."
  • Concurrency: mutex + queue in broker; concurrent qube calls serialized.
  • CYD DTR/RTS reset: clear DTR/RTS on open; document 10 µF capacitor mod.
  • Composite HID: keep device in sys-usb; do not qvm-usb attach to app qubes or media dies.
  • Browser WebUSB capture: documented as opt-out; recommended path is qrexec/NIP-07.
  • Blocking approval UX: a sign call blocks until physical approval; broker should expose a timeout and a "waiting for approval" state so callers do not hang silently.
  • sys-usb AppVM persistence: broker install must survive reboot via template + /rw/config/rc.local.

14. Implementation checklist

Code:

  • Broker daemon packaging/qubes/hw_bridge/nsigner_hw_broker.py: adapter registry (VID:PID open), exclusive handle, unix socket, serialize lock/queue, reopen-on-reenumerate; reuse framing from examples/kb2040_hidden_signer_client.py.
  • Adapter config covering KB2040 239a:cafe, Feather 303a:4001, CYD 1a86:7523, Teensy CDC, IR dongle CDC; CYD adapter clears DTR/RTS on open.
  • (Optional, later) nsigner hw-broker C subcommand replacing the Python broker.

Packaging:

  • qrexec service packaging/qubes/rpc/qubes.NsignerHwRpc: relay one frame stdin→socket→stdout.
  • dom0 policy packaging/qubes/policy.d/41-nsigner-hw.policy: ask + deny-by-default, target sys-usb.
  • install-hw-bridge.sh (owner qube: broker + service + udev + autostart, AppVM-persistent).
  • install-hw-policy.sh (dom0).

Docs and callers:

  • Extend documents/qubes_client_examples.md with qubes.NsignerHwRpc shell + Python examples.
  • Document the browser qrexec/NIP-07 path and the WebUSB direct-attach opt-out.
  • Document owner-qube choice (sys-usb default, nsigner-usb hardened variant) and the composite-HID input-proxy tradeoff.

Verification runbook:

  • Media keys still work globally (composite device stays in sys-usb).
  • get_public_key from a caller qube succeeds.
  • sign_event from a caller qube blocks until physical approval, then succeeds.
  • sign_event with device not in signer mode returns clear 2015 error.
  • Deny from an untagged/unsupported qube.
  • Two qubes signing concurrently are serialized (no frame interleaving).
  • Survive unplug/replug: broker reopens, next call succeeds.
  • Browser via NIP-07 native helper → qrexec signs without capturing USB.