diff --git a/plans/unified_hw_qubes_broker.md b/plans/unified_hw_qubes_broker.md new file mode 100644 index 0000000..d0c383b --- /dev/null +++ b/plans/unified_hw_qubes_broker.md @@ -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 `, 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. diff --git a/src/main.c b/src/main.c index 8c4e88e..c1dedc0 100644 --- a/src/main.c +++ b/src/main.c @@ -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 */