# KB2040 Hidden Signer — Qubes OS signing bridge

## Goal

On Qubes OS, let the KB2040 hardware signer provide **signing to any qube** while
**preserving global media/volume functionality** exactly as it works today.

The device must remain a single physical USB device plugged into the machine once,
with no per-use re-attaching, and no loss of media keys.

---

## The core problem (why this is non-trivial on Qubes)

The KB2040 is **one composite USB device** exposing two interfaces behind one USB address:

- **HID Consumer interface** — volume / media keys.
- **WebUSB vendor + CDC interface** — the signing JSON-RPC transport (VID:PID `239a:cafe`, `/dev/ttyACM*`).

Qubes routes USB at **whole-device** granularity, not per-interface. This creates a hard tension:

- Device left in `sys-usb` (default): HID is proxied to dom0 -> **media works everywhere**, but the CDC endpoint sits unused in `sys-usb` -> **signing unreachable from qubes**.
- Device `qvm-usb attach`ed to an app qube: that qube can open CDC and sign, but the HID interface leaves the dom0 input proxy -> **media dies globally**.

A USB request/response endpoint is also **exclusively owned** by one process in one qube,
so multiple qubes cannot each open the device simultaneously.

---

## The chosen solution: a signing broker inside `sys-usb`

Keep the device in `sys-usb` **permanently** (media untouched). Run a small broker
**inside `sys-usb`** that opens the CDC endpoint locally and serves all qubes over
qrexec using the existing framed JSON-RPC protocol.

Media uses the **HID interface** via dom0's input proxy.
Signing uses the **CDC interface** via the local broker.
Both interfaces stay on the same device in the same place — nothing is ever detached,
and the broker never competes with the input proxy because they touch different interfaces.

```mermaid
flowchart TD
    HW[KB2040 composite device]
    subgraph SYSUSB[sys-usb device stays here always]
        HIDIF[HID interface]
        CDCIF[CDC WebUSB interface]
        BRK[broker owns dev ttyACM serializes requests]
        SVC[qrexec service qubes.NsignerHwRpc]
    end
    subgraph DOM0[dom0]
        INPUT[input proxy media volume]
        POL[qrexec policy ask plus deny]
    end
    subgraph Q[any caller qube]
        APP[client or nostr-login-lite]
    end

    HW --> HIDIF
    HW --> CDCIF
    HIDIF -->|input proxy| INPUT
    APP -->|qrexec frame| POL --> SVC --> BRK --> CDCIF
    CDCIF --> BRK --> SVC --> POL --> APP
```

---

## Protocol reuse (already exists in repo)

The framing and JSON-RPC shapes are already proven and identical on both sides:

- Frame: `4-byte big-endian length + UTF-8 JSON`.
- Device methods validated on hardware: `get_status`, `set_mnemonic`, `set_auto_approve`, `get_public_key`, `sign_event`.
- Existing host reference logic: [`examples/kb2040_hidden_signer_client.py`](examples/kb2040_hidden_signer_client.py).
- Existing qrexec patterns to mirror:
  - service file [`packaging/qubes/rpc/qubes.NsignerRpc`](packaging/qubes/rpc/qubes.NsignerRpc)
  - dom0 policy [`packaging/qubes/policy.d/40-nsigner.policy`](packaging/qubes/policy.d/40-nsigner.policy)
  - caller examples [`documents/qubes_client_examples.md`](documents/qubes_client_examples.md)

> Note: the software `nsigner` qrexec service is a **separate** thing (software keys in a vault).
> The hardware bridge gets its **own** service name (`qubes.NsignerHwRpc`) and policy so the
> two paths never collide.

---

## Component design

### A. Broker daemon (runs in `sys-usb`)

Long-lived process that:

- Discovers/opens the KB2040 CDC endpoint (`/dev/ttyACM*` matching `239a:cafe`).
- Holds the single exclusive handle for the device lifetime.
- Listens on a local UNIX domain socket (e.g. `/run/nsigner-hw.sock`).
- Accepts one framed JSON-RPC request per connection, forwards it to the device, returns the framed response.
- **Serializes** access with an internal lock/queue so concurrent qube calls never interleave on the wire.
- Handles device re-enumeration (unplug/replug, 1200-baud resets) by reopening.
- Refactored from proven logic in [`examples/kb2040_hidden_signer_client.py`](examples/kb2040_hidden_signer_client.py).

### B. qrexec service entrypoint (runs in `sys-usb`)

Thin script `qubes.NsignerHwRpc` that:

- Reads one framed request from qrexec stdin.
- Connects to the broker UNIX socket, relays the frame, reads the framed reply.
- Writes the framed reply to qrexec stdout.
- Mirrors the trivial shape of [`packaging/qubes/rpc/qubes.NsignerRpc`](packaging/qubes/rpc/qubes.NsignerRpc).

### C. dom0 policy

New policy file `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 the existing model in [`40-nsigner.policy`](packaging/qubes/policy.d/40-nsigner.policy:4).
- Target tag points at the USB-owning qube (`sys-usb` by default).

### D. Caller helper (any qube)

Small client + docs (extend [`documents/qubes_client_examples.md`](documents/qubes_client_examples.md)) calling:

```
qrexec-client-vm sys-usb qubes.NsignerHwRpc
```

with framed `get_public_key` / `sign_event` requests.

### E. nostr-login-lite integration (optional, later)

A transport shim so the browser flow can reach signing through qrexec
(via a native messaging / local helper in the browser qube) instead of WebUSB,
since browser WebUSB cannot see a device owned by `sys-usb`.

---

## Approval-gate behavior (must decide explicitly)

Hardware `sign_event` is gated two ways today:

1. `s_mode != MODE_SIGNER` -> error `2015 "not in signer mode"` ([`kb2040_hidden_signer.ino:528`](firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino:528)).
2. Per-signature physical approval unless `auto_approve` is set ([`wait_for_user_approval`](firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino:370)).

Implications for remote qrexec signing:

- The device must be **in signer mode** (PLAY+PREV chord) for `sign_event` to work. `get_public_key` works in either mode.
- If `auto_approve` is off, each signature needs a **physical button press** on the device — good for security, but means remote calls block until you approve at the hardware.
- dom0 `ask` adds a second, per-call **Qubes prompt** identifying the calling qube.

Decision needed: is the intended UX
(a) **physical-approve every signature** (highest assurance), or
(b) **auto-approve on device + rely on dom0 `ask`** for per-qube consent (smoother)?
This affects whether the broker should surface signer-mode/approval state to callers.

---

## Security model

- **Private keys never leave the KB2040.** The broker only relays opaque frames; it cannot extract keys.
- `sys-usb` trust scope: it can *see* what you ask to sign and could deny/forge requests. Mitigated by on-device approval gate + dom0 `ask` + deny-by-default policy.
- For stronger isolation, a dedicated `nsigner-usb` qube can replace `sys-usb` as device owner, but then the **media input-proxy origin must move to that qube** (qrexec input policy), which is a larger change. Default plan keeps `sys-usb`.

---

## Risks / edge cases

- **Signer-mode requirement**: remote `sign_event` fails with `2015` unless device is in signer mode; broker should return a clear, actionable error.
- **Device re-enumeration**: 1200-baud touch / replug changes `/dev/ttyACM*`; broker must rediscover by VID:PID.
- **Concurrency**: two qubes signing at once must be serialized; lock + queue in broker.
- **sys-usb persistence**: `sys-usb` is usually an AppVM; broker install + udev + autostart must persist via `/rw/config/rc.local` and a template package (see [`documents/QUBES_OS.md:163`](documents/QUBES_OS.md:163) for the AppVM-persistence pattern already documented).
- **udev permissions** for the CDC node inside `sys-usb` (reuse the `99-rp2040.rules` approach already used on the dev host).

---

## Actionable TODO (for Code mode)

1. Create broker daemon (`packaging/qubes/hw_bridge/nsigner_hw_broker.py`): open `239a:cafe` CDC, UNIX socket, serialize, reopen-on-reenumerate; reuse framing from [`examples/kb2040_hidden_signer_client.py`](examples/kb2040_hidden_signer_client.py).
2. Create qrexec service entrypoint (`packaging/qubes/rpc/qubes.NsignerHwRpc`): relay one frame stdin->socket->stdout.
3. Create dom0 policy (`packaging/qubes/policy.d/41-nsigner-hw.policy`): `ask` + deny-by-default for `qubes.NsignerHwRpc`.
4. Create install scripts: `install-hw-bridge.sh` (sys-usb side: broker + service + udev + autostart, AppVM-persistent) and `install-hw-policy.sh` (dom0 side).
5. Add caller helper + docs section in [`documents/qubes_client_examples.md`](documents/qubes_client_examples.md) for `qubes.NsignerHwRpc`.
6. Decide + implement approval UX (physical-approve vs auto-approve+dom0-ask); surface signer-mode/approval errors clearly from broker.
7. Verification runbook: media still works globally; `get_public_key` from a caller qube; `sign_event` from a caller qube (with chosen approval flow); deny from untagged qube; survive replug.
8. (Optional, later) nostr-login-lite qrexec transport shim for the browser qube.

---

## Open items to confirm with user

- Device owner: `sys-usb` (default) vs dedicated `nsigner-usb` qube.
- Broker lifetime: persistent daemon (recommended) vs per-request spawn.
- Approval UX: physical-approve every signature vs auto-approve + dom0 `ask`.
- Whether browser (nostr-login-lite) qrexec integration is in scope now or later.
