v0.1.10 - Add unified hardware-signer broker plan for Qubes OS sharing across qubes
This commit is contained in:
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user