v0.1.10 - Add unified hardware-signer broker plan for Qubes OS sharing across qubes

This commit is contained in:
Laan Tungir
2026-08-04 09:31:52 -04:00
parent d7eb6b5ec0
commit 86a97aee01
2 changed files with 293 additions and 2 deletions
+291
View File
@@ -0,0 +1,291 @@
# Plan: Unified hardware-signer broker for Qubes OS
Status: design / ready for review.
Related:
- [`plans/kb2040_qubes_signing_bridge.md`](kb2040_qubes_signing_bridge.md) — prior per-device bridge design (KB2040 only)
- [`plans/qrexec_persistent_bridge.md`](qrexec_persistent_bridge.md) — the analogous bridge for the *software* signer
- [`plans/auth_envelope_other_transports.md`](auth_envelope_other_transports.md) — per-program identity inside one qube
- [`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) — NIP-07 extension that should target this broker
- [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) — AppVM-persistence pattern, usbguard notes
- [`firmware/README.md`](../firmware/README.md) — per-variant USB identities and validation flows
- [`examples/kb2040_hidden_signer_client.py`](../examples/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`](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:
3. **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`](../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**.
```mermaid
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`](../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`](../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`](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:
- [`src/signers/nsigner-webusb.js`](../nostr_login_lite/src/signers/nsigner-webusb.js:11) calls `navigator.usb.requestDevice(...)` then `device.open()` / `claimInterface()` — raw WebUSB.
- [`src/signers/nsigner-webserial.js`](../nostr_login_lite/src/signers/nsigner-webserial.js:10) calls `navigator.serial.requestPort()` then `port.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 attach`ed 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`](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`](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`](../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`](../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`](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`](../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`](../packaging/qubes/policy.d/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`](../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`](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`](../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`](../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`](../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`](../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.
+2 -2
View File
@@ -762,8 +762,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 1
#define NSIGNER_VERSION_PATCH 9
#define NSIGNER_VERSION "v0.1.9"
#define NSIGNER_VERSION_PATCH 10
#define NSIGNER_VERSION "v0.1.10"
/* NSIGNER_HEADERLESS_DECLS_END */