19 KiB
Plan: Unified hardware-signer broker for Qubes OS
Status: design / ready for review.
Related:
plans/kb2040_qubes_signing_bridge.md— prior per-device bridge design (KB2040 only)plans/qrexec_persistent_bridge.md— the analogous bridge for the software signerplans/auth_envelope_other_transports.md— per-program identity inside one qubeplans/nsigner_browser_extension.md— NIP-07 extension that should target this brokerdocuments/QUBES_OS.md— AppVM-persistence pattern, usbguard notesfirmware/README.md— per-variant USB identities and validation flowsexamples/kb2040_hidden_signer_client.py— proven host-side framing logic to reuse
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:
- USB is routed at whole-device granularity.
qvm-usb attachmoves 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. - 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:
- Browser WebUSB / Web Serial can only open a device attached to the browser's own qube. A device owned by
sys-usbis invisible to a browser inpersonal/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.mdhas a "native messaging bridge" transport. Point that native helper atqrexec-client-vm sys-usb qubes.NsignerHwRpcand 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:
src/signers/nsigner-webusb.jscallsnavigator.usb.requestDevice(...)thendevice.open()/claimInterface()— raw WebUSB.src/signers/nsigner-webserial.jscallsnavigator.serial.requestPort()thenport.open({baudRate:115200,...})— raw Web Serial.
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_eventto work (e.g. KB2040 PLAY+PREV chord).get_public_keyworks in either mode. - Each remote qrexec
sign_eventcall blocks at the broker until the user physically approves at the hardware. - dom0
askadds 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:
- dom0 qrexec policy (
ask/deny, per calling qube) — first gate. - 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), atpackaging/qubes/hw_bridge/nsigner_hw_broker.py. - Long-term: a
nsigner hw-brokerC subcommand that ships in the static binary and reuses the existing framing code (mirrors thensigner bridgesubcommand inplans/qrexec_persistent_bridge.md§5.2).
B. qrexec service entrypoint (runs in owner qube)
packaging/qubes/rpc/qubes.NsignerHwRpc — a thin stateless relay:
- Read one framed request from qrexec stdin.
- Connect to
/run/nsigner-hw.sock, relay the frame, read the framed reply. - Write the framed reply to qrexec stdout.
- 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 mirrors40-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.
E. Browser integration (recommended path)
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 indocuments/QUBES_OS.md§5.5).install-hw-policy.sh(dom0 side): install41-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.rulesapproach infirmware/README.md), /rw/config/rc.localstarts the broker at boot,qvm-tags sys-usb add nsigner-hw-bridgeand 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
askper 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-usbqube 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_eventfails with2015unless 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 attachto 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 fromexamples/kb2040_hidden_signer_client.py. - Adapter config covering KB2040
239a:cafe, Feather303a:4001, CYD1a86:7523, Teensy CDC, IR dongle CDC; CYD adapter clears DTR/RTS on open. - (Optional, later)
nsigner hw-brokerC 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, targetsys-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.mdwithqubes.NsignerHwRpcshell + 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_keyfrom a caller qube succeeds.sign_eventfrom a caller qube blocks until physical approval, then succeeds.sign_eventwith device not in signer mode returns clear2015error.- 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.