v0.0.34 - Add qrexec bridge subcommand and --bridge-source-trusted flag for persistent-signer qrexec transport
This commit is contained in:
194
plans/kb2040_qubes_signing_bridge.md
Normal file
194
plans/kb2040_qubes_signing_bridge.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 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.
|
||||
176
plans/kb2040_real_nip06_crypto.md
Normal file
176
plans/kb2040_real_nip06_crypto.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# KB2040 Hidden Signer — Real NIP-06 Derivation + Schnorr Signing (Option B)
|
||||
|
||||
## Goal
|
||||
|
||||
Replace placeholder [`pseudo_pubkey_hex()`](firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino:344)
|
||||
so that:
|
||||
|
||||
- `get_public_key` returns a **distinct, real** secp256k1 x-only pubkey per
|
||||
`nostr_index` (host shows 5+ accounts again instead of 1 deduped entry).
|
||||
- `sign_event` returns a **real NIP-01 event id** + **valid BIP-340 Schnorr
|
||||
signature** that verifies against the pubkey.
|
||||
|
||||
## Decision: use `resources/nostr_core_lib` exclusively
|
||||
|
||||
Audit of what nostr_core_lib provides **on its own** (pure C, NO mbedTLS):
|
||||
|
||||
| Need | In nostr_core_lib? | Reference |
|
||||
|---|---|---|
|
||||
| SHA-256 (+ streaming) | YES | [`nostr_sha256`](resources/nostr_core_lib/nostr_core/utils.c:298) |
|
||||
| SHA-512 | YES | [`nostr_sha512`](resources/nostr_core_lib/nostr_core/utils.c:584) |
|
||||
| HMAC-SHA256 / HMAC-SHA512 | YES | [`utils.c`](resources/nostr_core_lib/nostr_core/utils.c:474) |
|
||||
| PBKDF2-HMAC-SHA512 (2048) | YES | [`nostr_pbkdf2_hmac_sha512`](resources/nostr_core_lib/nostr_core/utils.c:814) |
|
||||
| BIP-39 mnemonic to seed | YES | [`nostr_bip39_mnemonic_to_seed`](resources/nostr_core_lib/nostr_core/utils.c:1402) |
|
||||
| BIP-32 master + path derive | YES | [`utils.c`](resources/nostr_core_lib/nostr_core/utils.c:1445) |
|
||||
| NIP-06 keys-from-mnemonic m/44'/1237'/account'/0/0 | YES | [`nostr_derive_keys_from_mnemonic`](resources/nostr_core_lib/nostr_core/nip006.c:59) |
|
||||
| NIP-01 create+sign event (id + schnorr) | YES | [`nostr_create_and_sign_event`](resources/nostr_core_lib/nostr_core/nip001.h:16) |
|
||||
| RNG abstraction (needs platform impl) | YES | [`nostr_platform_random`](resources/nostr_core_lib/nostr_core/nostr_platform.h:14) |
|
||||
|
||||
### The ONE thing nostr_core_lib does NOT contain
|
||||
|
||||
The actual **elliptic-curve engine (bitcoin-core libsecp256k1)**.
|
||||
[`nostr_secp256k1.c`](resources/nostr_core_lib/nostr_core/crypto/nostr_secp256k1.c:1)
|
||||
is only a thin wrapper that includes `secp256k1.h` and calls
|
||||
`secp256k1_context_create`, `secp256k1_keypair_create`,
|
||||
`secp256k1_schnorrsig_sign32`, `secp256k1_ec_seckey_tweak_add`, etc. No EC point
|
||||
math is vendored inside nostr_core_lib — confirmed by `REQUIRES secp256k1` in
|
||||
[`CMakeLists.txt`](resources/nostr_core_lib/CMakeLists.txt:27).
|
||||
|
||||
**Conclusion:** "use nostr_core_lib exclusively" covers ALL hashing, BIP-39/32,
|
||||
NIP-06 derivation, and NIP-01 signing. But the library still needs
|
||||
**libsecp256k1 linked in** as its crypto backend (exactly as the ESP build did
|
||||
via the IDF `secp256k1` component). On Arduino RP2040 we must supply
|
||||
libsecp256k1 ourselves — nostr_core_lib cannot run without it.
|
||||
|
||||
## CONFIRMED: where libsecp256k1 already lives (provenance)
|
||||
|
||||
The earlier ESP-IDF firmware did NOT download secp256k1 via `idf_component.yml`.
|
||||
It was a **vendored local ESP-IDF component**, already present in this repo:
|
||||
|
||||
- Component sources: [`firmware/feather_s3_tft/components/secp256k1`](firmware/feather_s3_tft/components/secp256k1)
|
||||
(full bitcoin-core libsecp256k1 checkout incl. include/ and src/).
|
||||
- Headers: [`firmware/feather_s3_tft/components/secp256k1/include/secp256k1.h`](firmware/feather_s3_tft/components/secp256k1/include/secp256k1.h),
|
||||
plus `secp256k1_schnorrsig.h`, `secp256k1_extrakeys.h`, `secp256k1_ecdh.h`.
|
||||
- ESP build wiring: [`REQUIRES secp256k1`](firmware/feather_s3_tft/main/CMakeLists.txt:25)
|
||||
and [`REQUIRES secp256k1`](firmware/cyd_esp32_2432s028/main/CMakeLists.txt:33).
|
||||
- ESP compile config (the flags we must mirror on RP2040):
|
||||
[`components/secp256k1/CMakeLists.txt`](firmware/feather_s3_tft/components/secp256k1/CMakeLists.txt:1)
|
||||
builds only `src/secp256k1.c`, `src/precomputed_ecmult.c`,
|
||||
`src/precomputed_ecmult_gen.c` with these defines:
|
||||
`SECP256K1_BUILD=1`, `SECP256K1_WIDEMUL_INT64=1`, `ECMULT_WINDOW_SIZE=15`,
|
||||
`COMB_BLOCKS=11`, `COMB_TEETH=6`, `ENABLE_MODULE_ECDH=1`,
|
||||
`ENABLE_MODULE_EXTRAKEYS=1`, `ENABLE_MODULE_SCHNORRSIG=1`.
|
||||
|
||||
**Implication:** we already own the exact EC backend source on this machine. The
|
||||
KB2040 task is to compile those same `.c` files (3 sources + the module wiring)
|
||||
under `arduino-cli` with equivalent defines — no new download required, and the
|
||||
RP2040 (32-bit, like Xtensa LX6) can reuse the same `WIDEMUL_INT64` path.
|
||||
|
||||
## Build approach (Arduino RP2040 / arduino-cli)
|
||||
|
||||
`arduino-cli` auto-compiles `.c/.cpp` files placed in the sketch dir and in a
|
||||
`src/` subfolder, and respects `#include` paths relative to the sketch. Key
|
||||
constraint: Arduino has NO `CMakeLists.txt`, so secp256k1's compile-time `-D`
|
||||
defines must be supplied via a vendored config header that is `#include`d before
|
||||
the secp sources (secp256k1 supports `USE_EXTERNAL_DEFAULT_CALLBACKS` and a
|
||||
`libsecp256k1-config.h` style header).
|
||||
|
||||
Steps:
|
||||
|
||||
1. Create a sketch-local sources dir `firmware/kb2040_hidden_signer/src/` and add
|
||||
the nostr_core subset:
|
||||
- `utils.c` (hash/HMAC/PBKDF2/BIP-39/BIP-32)
|
||||
- `nip006.c`, `nip001.c`, `nip019.c`, `nostr_common.c`,
|
||||
`crypto/nostr_secp256k1.c`
|
||||
- `cJSON.c` only if the NIP-01 cJSON signer path is used (see Open Items).
|
||||
2. Vendor secp256k1 from the existing repo copy at
|
||||
[`firmware/feather_s3_tft/components/secp256k1`](firmware/feather_s3_tft/components/secp256k1)
|
||||
into the sketch tree:
|
||||
- sources: `src/secp256k1.c`, `src/precomputed_ecmult.c`,
|
||||
`src/precomputed_ecmult_gen.c`
|
||||
- headers: put `include/` on the include path
|
||||
- add a config header replicating the ESP defines (since no CMake `-D`).
|
||||
3. Add `platform/rp2040.c` implementing
|
||||
[`nostr_platform_random()`](resources/nostr_core_lib/nostr_core/nostr_platform.h:14)
|
||||
via Arduino-Pico RNG (`get_rand_32()` / ROSC entropy), and feed real entropy
|
||||
into the context-randomize step (the stub at
|
||||
[`nostr_secp256k1_context_create`](resources/nostr_core_lib/nostr_core/crypto/nostr_secp256k1.c:38)
|
||||
currently uses a fixed pattern).
|
||||
4. Resolve the mbedTLS question: the ESP CMake listed `REQUIRES mbedtls`, but
|
||||
nostr_core_lib has its own SHA/HMAC/PBKDF2 in `utils.c`. Confirm the compiled
|
||||
subset does NOT include mbedtls headers; if any module does, swap to the
|
||||
nostr_core equivalents so the Arduino build has zero mbedtls dependency.
|
||||
5. Call `nostr_init()` / `nostr_secp256k1_context_create()` once in `setup()`.
|
||||
|
||||
## RPC wiring changes in the sketch
|
||||
|
||||
- On mnemonic load ([`signer_apply_seed`](firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino:360)):
|
||||
validate via `nostr_bip39_mnemonic_validate`, keep caching the mnemonic string
|
||||
for per-index derivation.
|
||||
- `get_public_key`: call
|
||||
[`nostr_derive_keys_from_mnemonic(mnemonic, nostr_index, priv, pub)`](resources/nostr_core_lib/nostr_core/nip006.c:59)
|
||||
(note signature: `int account`, writes 32-byte x-only pub to `public_key+...`
|
||||
internally), then hex-encode the 32-byte x-only pub.
|
||||
- `sign_event`: derive priv for `nostr_index`, then either reuse
|
||||
[`nostr_create_and_sign_event`](resources/nostr_core_lib/nostr_core/nip001.h:16)
|
||||
(cJSON-based, computes id + schnorr) or compute id32 (SHA-256 of canonical
|
||||
array) + schnorr directly via the wrapper. Return `{...event, id, pubkey, sig}`.
|
||||
- Remove [`pseudo_pubkey_hex()`](firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino:344)
|
||||
and the string-splice id/sig hack in
|
||||
[`sign_event`](firmware/kb2040_hidden_signer/kb2040_hidden_signer.ino:513).
|
||||
- Zeroize derived private keys after each operation.
|
||||
|
||||
## Verify
|
||||
|
||||
1. `nostr-login-lite`: 5+ distinct npubs discovered (no dedup collapse).
|
||||
2. Cross-check one pubkey vs host NIP-06 derivation of the same mnemonic/index.
|
||||
3. Sign a NIP-42 auth event; confirm the signature verifies and login completes.
|
||||
4. Re-test the WebUSB demo for dual-transport parity (CDC + vendor).
|
||||
|
||||
## Risk / size notes
|
||||
|
||||
- libsecp256k1 context needs RAM; RP2040 has 264 KB. `ECMULT_WINDOW_SIZE=15`
|
||||
matches the ESP build; lower it if RAM is tight. Precomputed tables live in
|
||||
flash (KB2040 has 8 MB), so flash is not the constraint.
|
||||
- PBKDF2 2048 iters HMAC-SHA512 on 125 MHz RP2040 is fast (<100 ms).
|
||||
- cJSON uses malloc — ensure heap headroom, or bypass cJSON for the compact id
|
||||
serialization to save RAM.
|
||||
- Mirror the ESP `secure_memzero` pattern for secret cleanup.
|
||||
|
||||
## Open items to confirm with user
|
||||
|
||||
1. **secp256k1 source location:** copy the 3 secp `.c` sources + `include/` from
|
||||
[`firmware/feather_s3_tft/components/secp256k1`](firmware/feather_s3_tft/components/secp256k1)
|
||||
into `firmware/kb2040_hidden_signer/src/secp256k1/`, OR reference them via a
|
||||
relative include path. (Recommend copying so the sketch is self-contained for
|
||||
`arduino-cli`.)
|
||||
2. **cJSON on-device?** nostr_core_lib's NIP-01 signer uses cJSON. Use it
|
||||
(simplest, matches library) or hand-serialize the id to save RAM?
|
||||
3. **Derivation caching:** cache the mnemonic string and derive on demand
|
||||
(recommended, via `nostr_derive_keys_from_mnemonic`) vs cache `seed[64]`.
|
||||
|
||||
---
|
||||
|
||||
## Actionable TODO (for Code mode)
|
||||
|
||||
1. Vendor secp256k1: copy `src/secp256k1.c`, `src/precomputed_ecmult.c`,
|
||||
`src/precomputed_ecmult_gen.c` + `include/` from
|
||||
`firmware/feather_s3_tft/components/secp256k1` into
|
||||
`firmware/kb2040_hidden_signer/src/secp256k1/`.
|
||||
2. Add `secp256k1` config header replicating ESP defines
|
||||
(`WIDEMUL_INT64`, `ECMULT_WINDOW_SIZE=15`, `COMB_BLOCKS=11`, `COMB_TEETH=6`,
|
||||
`ENABLE_MODULE_EXTRAKEYS`, `ENABLE_MODULE_SCHNORRSIG`, `ENABLE_MODULE_ECDH`).
|
||||
3. Vendor nostr_core subset into `firmware/kb2040_hidden_signer/src/nostr_core/`
|
||||
(`utils.c`, `nip006.c`, `nip001.c`, `nip019.c`, `nostr_common.c`,
|
||||
`crypto/nostr_secp256k1.c`) and confirm no mbedtls includes remain.
|
||||
4. Add `src/platform/rp2040.c` implementing `nostr_platform_random()` from
|
||||
Arduino-Pico RNG; feed real entropy into context randomize.
|
||||
5. Init `nostr_secp256k1_context_create()` in `setup()`.
|
||||
6. Replace `get_public_key` body with per-index
|
||||
`nostr_derive_keys_from_mnemonic` + x-only hex encode.
|
||||
7. Replace `sign_event` body with real NIP-01 id + BIP-340 schnorr; delete
|
||||
`pseudo_pubkey_hex()` and the string-splice hack; zeroize privkeys.
|
||||
8. `arduino-cli compile` (TinyUSB FQBN); fix include/define errors; check RAM/flash.
|
||||
9. Flash to KB2040 (correct ttyACM port).
|
||||
10. Verify in nostr-login-lite: 5+ distinct npubs; signature verifies; login OK.
|
||||
11. Verify WebUSB demo still works (dual-transport parity).
|
||||
197
plans/migrate_client_to_nostr_core_lib.md
Normal file
197
plans/migrate_client_to_nostr_core_lib.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Migrate `client/` onto `nostr_core_lib`'s nsigner client
|
||||
|
||||
> Status: Planning. This plan supersedes the hand-rolled `client/nsigner_client.{c,h}` with the shared, transport-pluggable client now living in `nostr_core_lib` (commit `e842de3`).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace n_signer's own [`client/nsigner_client.{c,h}`](../client/nsigner_client.h) (stack-allocated, unix-only, raw-JSON-string responses, depends on `src/auth_envelope.c`) with the shared client stack now in [`resources/nostr_core_lib`](../resources/nostr_core_lib):
|
||||
|
||||
- [`nostr_core/nsigner_transport.{h,c}`](../resources/nostr_core_lib/nostr_core/nsigner_transport.h) — pluggable transport (unix / tcp / serial / fds) + discovery
|
||||
- [`nostr_core/nsigner_client.{h,c}`](../resources/nostr_core_lib/nostr_core/nsigner_client.h) — heap-allocated client, framed I/O, kind-27235 auth envelope (self-contained), cJSON result parsing, RPC error mapping
|
||||
- [`nostr_core/nostr_signer.{h,c}`](../resources/nostr_core_lib/nostr_core/nostr_signer.h) — high-level `nostr_signer_t` abstraction (local + remote backends, 6 verbs)
|
||||
|
||||
After migration there is **one** wire-contract implementation (in `nostr_core_lib`), and n_signer's `client/` directory either shrinks to a thin compatibility shim or is removed entirely.
|
||||
|
||||
## 2. Current state — what exists today
|
||||
|
||||
### 2.1 Old client (`client/`)
|
||||
|
||||
[`client/nsigner_client.h`](../client/nsigner_client.h) exposes a **stack-allocated** struct and these verbs:
|
||||
|
||||
```c
|
||||
typedef struct { int fd; int timeout_ms; unsigned char auth_privkey[32]; int auth_enabled; char auth_label[64]; char last_error[128]; } nsigner_client_t;
|
||||
void nsigner_client_init(nsigner_client_t *client);
|
||||
void nsigner_client_close(nsigner_client_t *client);
|
||||
int nsigner_client_connect_unix(nsigner_client_t *client, const char *socket_name, int timeout_ms);
|
||||
int nsigner_client_set_auth(nsigner_client_t *client, const unsigned char privkey[32], const char *label);
|
||||
const char *nsigner_client_last_error(const nsigner_client_t *client);
|
||||
int nsigner_client_request_raw(nsigner_client_t *client, const char *request_json, char **out_response_json);
|
||||
int nsigner_client_request(nsigner_client_t *client, const char *id, const char *method, const cJSON *params, char **out_response_json);
|
||||
int nsigner_client_get_public_key(nsigner_client_t *client, const char *id, const char *selector, char **out_response_json);
|
||||
int nsigner_client_sign_event(nsigner_client_t *client, const char *id, const char *event_json, const char *role, char **out_response_json);
|
||||
```
|
||||
|
||||
Characteristics:
|
||||
- Unix-abstract only; no TCP/serial/fds.
|
||||
- Returns **raw JSON response strings** (caller parses).
|
||||
- Auth envelope built via n_signer's own [`src/auth_envelope.c`](../src/auth_envelope.c) (`auth_envelope_build_for_request`).
|
||||
- Framing code is duplicated (same 4-byte BE length prefix as the new lib client).
|
||||
|
||||
### 2.2 Consumers of the old client
|
||||
|
||||
| Consumer | File | What it uses |
|
||||
|---|---|---|
|
||||
| Example: get_public_key | [`examples/get_public_key_client.c`](../examples/get_public_key_client.c) | `init` / `connect_unix` / `get_public_key` / `close` |
|
||||
| Example: sign_event | [`examples/sign_event_client.c`](../examples/sign_event_client.c) | `init` / `connect_unix` / `sign_event` / `close` |
|
||||
| Integration test | [`tests/test_integration.c`](../tests/test_integration.c) | `init` / `connect_unix` / `get_public_key` / `sign_event` / `close` (embeds headerless decls, lines 3–451) |
|
||||
| Build | [`Makefile`](../Makefile) lines 141–163 | Compiles `client/nsigner_client.c` + `src/auth_envelope.c` into 3 targets |
|
||||
|
||||
### 2.3 NOT a consumer
|
||||
|
||||
- The **`nsigner ... client '<json>'`** subcommand in [`src/main.c:906`](../src/main.c:906) (`client_main`) does **not** use `client/nsigner_client.c`. It has its own `connect_abstract_socket` + `transport_send_framed/recv_framed` (from `src/transport_frame.c`). This is a raw pass-through and is unaffected by this migration.
|
||||
|
||||
### 2.4 New client in `nostr_core_lib` (already linked)
|
||||
|
||||
The static lib `resources/nostr_core_lib/libnostr_core_x64.a` already exports the new symbols (`nsigner_client_new`, `nsigner_transport_open_unix`, `nostr_signer_nsigner_unix`, etc.) and was built with `-DNOSTR_ENABLE_NSIGNER_CLIENT=1`. n_signer's [`Makefile`](../Makefile) already links this `.a`. So the new client is **available for use right now** — no lib rebuild needed for the migration.
|
||||
|
||||
## 3. API mapping (old → new)
|
||||
|
||||
| Old (`client/`) | New (`nostr_core_lib`) | Notes |
|
||||
|---|---|---|
|
||||
| `nsigner_client_t` (stack) | `nsigner_client_t*` (heap) or `nostr_signer_t*` (heap) | New is opaque + heap-allocated |
|
||||
| `nsigner_client_init(&c)` | `nsigner_client_new(transport)` or `nostr_signer_nsigner_unix(name, role, timeout_ms)` | New takes a transport or factory args |
|
||||
| `nsigner_client_connect_unix(&c, name, ms)` | `nsigner_transport_open_unix(name, ms)` then `nsigner_client_new(transport)` | Or use `nostr_signer_nsigner_unix` one-shot |
|
||||
| `nsigner_client_close(&c)` | `nsigner_client_free(client)` or `nostr_signer_free(signer)` | New frees the transport too |
|
||||
| `nsigner_client_set_auth(&c, key, label)` | `nsigner_client_set_auth(client, key, label)` or `nostr_signer_nsigner_set_auth(signer, key, label)` | Same semantics |
|
||||
| `nsigner_client_request_raw(&c, json, &resp)` | `transport->send_framed` + `transport->recv_framed` (low-level) | No direct equivalent at client level; use `nsigner_client_call` instead |
|
||||
| `nsigner_client_request(&c, id, method, params, &resp_json)` | `nsigner_client_call(client, method, params, &out_result_cJSON)` | New returns parsed cJSON `result`, auto-generates id, handles error mapping. No raw JSON string out. |
|
||||
| `nsigner_client_get_public_key(&c, id, selector, &resp)` | `nostr_signer_get_public_key(signer, out_hex)` **or** `nsigner_client_call(client, "get_public_key", params, &result)` | High-level returns hex string directly |
|
||||
| `nsigner_client_sign_event(&c, id, event_json, role, &resp)` | `nostr_signer_sign_event(signer, unsigned_event_cJSON, &signed_out)` **or** `nsigner_client_call(client, "sign_event", params, &result)` | High-level takes cJSON event, returns cJSON signed event |
|
||||
| `nsigner_client_last_error(&c)` | `nsigner_client_last_error(client)` | Same; buffer is 256 vs 128 |
|
||||
|
||||
### Key behavioral differences
|
||||
1. **Heap vs stack**: new client is `malloc`'d; must pair `*_new` with `*_free`.
|
||||
2. **cJSON results vs raw strings**: `nsigner_client_call` returns a parsed cJSON `result` object (caller `cJSON_Delete`s it). The old `nsigner_client_request` returned a raw JSON string. Callers that did `strstr(resp, "\"id\":\"1\"")` must switch to cJSON parsing.
|
||||
3. **Auto-generated ids**: new client generates its own numeric ids; old client took caller-supplied ids. The id is still echoed in responses but callers can't control it.
|
||||
4. **Auth envelope self-contained**: new client builds the kind-27235 envelope using `nostr_create_and_sign_event` from the lib (not n_signer's `src/auth_envelope.c`). This is the single-source-of-truth win.
|
||||
5. **Transport pluggability**: new client works over unix/tcp/serial/fds; old was unix-only.
|
||||
|
||||
## 4. Design decision: two migration levels
|
||||
|
||||
### Level A — Thin shim (minimal disruption)
|
||||
Keep `client/nsigner_client.h` API **byte-for-byte**, reimplement `client/nsigner_client.c` as a wrapper over `nsigner_transport_open_unix` + `nsigner_client_new` + `nsigner_client_call`. Internally convert cJSON results back to raw JSON strings for the `*_request` / `*_get_public_key` / `*_sign_event` verbs. Consumers (examples, tests, Makefile) stay unchanged.
|
||||
|
||||
- **Pro**: zero changes to examples/tests/Makefile; lowest risk.
|
||||
- **Con**: keeps a redundant wrapper layer; doesn't demonstrate the new high-level `nostr_signer_t` API; raw-string conversion is wasteful.
|
||||
|
||||
### Level B — Full migration (recommended)
|
||||
Delete `client/nsigner_client.{c,h}`. Rewrite the 3 consumers to use the new API directly:
|
||||
- Examples → use `nostr_signer_nsigner_unix` + `nostr_signer_get_public_key` / `nostr_signer_sign_event` (cleanest, shows the abstraction).
|
||||
- `test_integration.c` → use `nsigner_transport_open_unix` + `nsigner_client_new` + `nsigner_client_call` (needs cJSON result parsing instead of `strstr`).
|
||||
- `Makefile` → drop `client/nsigner_client.c` and `src/auth_envelope.c` from the 3 targets; they now get the client from the static lib.
|
||||
- Update `client/README.md` to point at `nostr_core_lib`'s client, or remove the dir.
|
||||
|
||||
- **Pro**: single source of truth; removes ~370 lines of duplicated framing/auth; unlocks tcp/serial/fds for n_signer's own examples; demonstrates the intended API.
|
||||
- **Con**: touches 4 files + Makefile; `test_integration.c` needs cJSON-based assertions.
|
||||
|
||||
**Recommendation: Level B**, because the whole point of the `nostr_core_lib` integration (per [`plans/nsigner_integration_plan.md`](../resources/nostr_core_lib/plans/nsigner_integration_plan.md) Phase 7) is to retire the per-project hand-rolled clients. Level A just defers the cleanup.
|
||||
|
||||
## 5. Step-by-step plan (Level B)
|
||||
|
||||
### Step 1 — Rewrite `examples/get_public_key_client.c`
|
||||
Switch from `client/nsigner_client.h` to `nostr_core/nostr_signer.h` + `nostr_core/nsigner_transport.h`. Use the high-level `nostr_signer_t`:
|
||||
```c
|
||||
#include "nostr_signer.h"
|
||||
nostr_signer_t *signer = nostr_signer_nsigner_unix(socket_name, NULL, 5000);
|
||||
char pubkey_hex[65];
|
||||
nostr_signer_get_public_key(signer, pubkey_hex);
|
||||
printf("%s\n", pubkey_hex); /* or build a JSON response string */
|
||||
nostr_signer_free(signer);
|
||||
```
|
||||
Decision needed: keep the example printing a raw JSON-RPC response string (for backward-compatible CLI output) or print just the pubkey? The old example printed the full `{"id":...,"result":"..."}`. To preserve CLI compatibility, either (a) build the JSON string manually, or (b) use `nsigner_client_call` and re-serialize the cJSON result. **Suggest (b)** so the example shows the low-level client too.
|
||||
|
||||
### Step 2 — Rewrite `examples/sign_event_client.c`
|
||||
Same pattern. Use `nostr_signer_sign_event(signer, unsigned_event_cJSON, &signed_out)` and print `cJSON_PrintUnformatted(signed_out)`. Or use `nsigner_client_call` with `method="sign_event"` and params `[event_json_str, {"role":"main"}]`, then print the cJSON result.
|
||||
|
||||
### Step 3 — Rewrite `tests/test_integration.c` client section (lines ~711–745)
|
||||
Replace the `nsigner_client_t` stack usage with:
|
||||
```c
|
||||
nsigner_transport_t *t = nsigner_transport_open_unix(SOCKET_NAME_A, 5000);
|
||||
nsigner_client_t *c = nsigner_client_new(t);
|
||||
cJSON *result = NULL;
|
||||
if (nsigner_client_call(c, "get_public_key", params, &result) == NOSTR_SUCCESS) { ... }
|
||||
nsigner_client_free(c);
|
||||
```
|
||||
Update the `strstr(resp, "\"id\":\"1\"")` assertions to cJSON-based checks on `result`. Remove the headerless decls for the old client (lines 3–451 embed many n_signer internal headers — only the client decls need removal; the server-side decls stay because the test still spawns the server).
|
||||
|
||||
### Step 4 — Update `Makefile`
|
||||
- Remove `$(CLIENT_DIR)/nsigner_client.c` and `$(SRC_DIR)/auth_envelope.c` from the 3 targets (`test_integration`, `example_get_public_key_client`, `example_sign_event_client`).
|
||||
- Remove `-I$(CLIENT_DIR)` from those targets' compile lines (the new headers come from `-Iresources/nostr_core_lib/nostr_core` already in `CFLAGS`).
|
||||
- The new client symbols resolve from `resources/nostr_core_lib/libnostr_core_x64.a` (already in `LDFLAGS`).
|
||||
- `auth_envelope.c` stays in the main `nsigner` binary build (server side still uses it for verification) — just not in the client targets.
|
||||
|
||||
### Step 5 — Delete `client/nsigner_client.{c,h}` and update `client/README.md`
|
||||
Either remove the files or leave `client/README.md` as a pointer to `resources/nostr_core_lib/nostr_core/nsigner_client.h`. The `client/` dir can be removed entirely if nothing else references it.
|
||||
|
||||
### Step 6 — Verify build + run
|
||||
- `make clean && make dev` — confirm the main binary still builds.
|
||||
- `make examples` — confirm the 2 example binaries build against the new API.
|
||||
- `make test-integration` — confirm the integration test builds and passes (needs a running n_signer).
|
||||
- Run the examples against a live n_signer (`@nsigner-test`) to confirm end-to-end.
|
||||
|
||||
### Step 7 — Wire-contract parity check (optional but recommended)
|
||||
Compare `nsigner_client_compute_body_hash_hex` in the new client against n_signer's `src/auth_envelope.c` body-hash logic to confirm they agree on JCS canonicalization and `SHA-256("null")` for null params. This is risk item #1 from the integration plan. If they diverge, TCP-mode auth will fail.
|
||||
|
||||
## 6. Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| [`examples/get_public_key_client.c`](../examples/get_public_key_client.c) | Rewrite to use new client/signer API |
|
||||
| [`examples/sign_event_client.c`](../examples/sign_event_client.c) | Rewrite to use new client/signer API |
|
||||
| [`tests/test_integration.c`](../tests/test_integration.c) | Replace old client usage (lines ~711–745) with new client; update assertions; remove old client headerless decls |
|
||||
| [`Makefile`](../Makefile) | Drop `client/nsigner_client.c` + `src/auth_envelope.c` from 3 targets; drop `-I$(CLIENT_DIR)` |
|
||||
| [`client/nsigner_client.c`](../client/nsigner_client.c) | Delete |
|
||||
| [`client/nsigner_client.h`](../client/nsigner_client.h) | Delete |
|
||||
| [`client/README.md`](../client/README.md) | Rewrite as pointer to `nostr_core_lib` client, or delete |
|
||||
|
||||
## 7. Risks / open items
|
||||
|
||||
1. **CLI output format change**: old examples printed full JSON-RPC response strings. If any script/CI parses that exact format, switching to the new API may change the output shape (e.g., no `"id"` field if using `nostr_signer_get_public_key` directly). Mitigation: use `nsigner_client_call` and re-serialize, or document the change.
|
||||
2. **test_integration.c headerless decls**: the test embeds ~450 lines of n_signer internal declarations (lines 3–451) to compile as a single TU. Only the client-related decls need removal; the server/dispatcher/policy decls must stay. Careful editing required.
|
||||
3. **Auth envelope divergence**: if n_signer's `src/auth_envelope.c` and the new `nsigner_client.c` compute body hashes differently, TCP auth breaks. Needs a parity test (Step 7).
|
||||
4. **`NOSTR_ENABLE_NSIGNER_CLIENT` already set**: the static lib is built with it ON, so the new client symbols are present. No build-flag changes needed in n_signer. But if someone rebuilds the lib without the flag, the client targets will fail to link. Worth a Makefile guard.
|
||||
5. **`nostr_signer_nsigner_unix` auto-discovery**: if `socket_name` is NULL/empty, the new factory auto-discovers a single `@nsigner*` socket. The old client required an explicit name. Examples that default to `"nsigner"` should pass it explicitly to preserve behavior.
|
||||
|
||||
## 8. Mermaid — migration flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph BEFORE[Before]
|
||||
OLD[client/nsigner_client.c]
|
||||
AE[src/auth_envelope.c]
|
||||
EX1[examples/get_public_key_client.c]
|
||||
EX2[examples/sign_event_client.c]
|
||||
TI[tests/test_integration.c]
|
||||
OLD --> EX1
|
||||
OLD --> EX2
|
||||
OLD --> TI
|
||||
AE --> OLD
|
||||
end
|
||||
subgraph LIB[nostr_core_lib static lib]
|
||||
NT[nsigner_transport.c]
|
||||
NC[nsigner_client.c]
|
||||
NS[nostr_signer.c]
|
||||
end
|
||||
subgraph AFTER[After]
|
||||
EX1N[examples/get_public_key_client.c]
|
||||
EX2N[examples/sign_event_client.c]
|
||||
TIN[tests/test_integration.c]
|
||||
EX1N --> NS
|
||||
EX2N --> NS
|
||||
TIN --> NC
|
||||
NS --> NC
|
||||
NC --> NT
|
||||
end
|
||||
BEFORE -.replaced by.-> AFTER
|
||||
LIB -.linked via.-> AFTER
|
||||
```
|
||||
214
plans/qrexec_persistent_bridge.md
Normal file
214
plans/qrexec_persistent_bridge.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Plan: qrexec transport to a persistent, in-RAM-mnemonic signer
|
||||
|
||||
Status: design / ready for review.
|
||||
|
||||
Related:
|
||||
- [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) — current qrexec design (per-invocation process)
|
||||
- [`documents/SECURITY.md`](../documents/SECURITY.md) — no-persistence, mlock-only mnemonic model
|
||||
- [`documents/FIPS_DEPLOYMENT.md`](../documents/FIPS_DEPLOYMENT.md) — the TCP/FIPS transport we already validated
|
||||
- [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc) — service entrypoint
|
||||
- [`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy) — dom0 policy
|
||||
- [`src/server.c`](../src/server.c:1279) — caller identity extraction
|
||||
- [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) — JS caller example
|
||||
|
||||
---
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
We want caller qubes to reach the signer in the `nostr_signer` qube **without FIPS**, using Qubes' native qrexec IPC. Advantages over the FIPS/TCP path:
|
||||
|
||||
- **No network.** Traffic never leaves the physical machine; no mesh, no IPv6 routing, no chance of an off-host connection.
|
||||
- **Stronger identity.** The caller is identified by `QREXEC_REMOTE_DOMAIN` — a hypervisor-vouched source-qube name, not an app-layer key. This is the `qubes` identity kind already described in [`documents/SECURITY.md`](../documents/SECURITY.md) §3.
|
||||
- **dom0 as the first enforcement boundary.** The qrexec policy decides which qubes may even reach the service, before n_signer's own approval layer runs.
|
||||
|
||||
---
|
||||
|
||||
## 2. The core tension (why the shipped design is wrong for us)
|
||||
|
||||
The current shipped design ([`documents/QUBES_OS.md`](../documents/QUBES_OS.md) §3.2, [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc)) runs, per request:
|
||||
|
||||
```
|
||||
exec nsigner --listen qrexec
|
||||
```
|
||||
|
||||
qrexec spawns a **fresh process for every request**. But n_signer's security model ([`documents/SECURITY.md`](../documents/SECURITY.md) §1) requires the mnemonic to be:
|
||||
|
||||
- entered once, at a terminal, by a human,
|
||||
- held only in `mlock`'d RAM,
|
||||
- never written to disk, argv, or env.
|
||||
|
||||
A fresh per-request process has **no mnemonic**. The only ways to give it one per-invocation all violate the model:
|
||||
|
||||
- ❌ mnemonic file on disk — defeats "nothing on disk."
|
||||
- ❌ `--mnemonic <phrase>` argv — rejected by design ([`plans/mnemonic_startup_input.md`](mnemonic_startup_input.md) §2).
|
||||
- ❌ `NSIGNER_MNEMONIC` env — rejected by design.
|
||||
- ❌ TUI prompt per request — impossible; qrexec has no interactive terminal, and it would prompt on every single call.
|
||||
|
||||
Therefore the per-invocation qrexec design is fundamentally incompatible with the persistent-mnemonic model. **We reject it.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Chosen design: a stateless qrexec → unix-socket bridge
|
||||
|
||||
Keep exactly one **persistent** signer process, started by the human at the terminal (mnemonic in RAM, as today). The qrexec service becomes a **thin, stateless relay** that forwards one framed request to the running signer's abstract unix socket and relays one framed response back.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Caller qube app] -->|qrexec-client-vm qubes.NsignerRpc| B[dom0 policy check]
|
||||
B -->|allowed| C[qubes.NsignerRpc bridge in nostr_signer]
|
||||
C -->|connect abstract unix socket| D[persistent nsigner --listen unix]
|
||||
D -->|mnemonic in mlock RAM| E[sign / get_public_key]
|
||||
E --> D
|
||||
D --> C
|
||||
C --> A
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- The **bridge holds no secrets.** It is a dumb pipe: read framed bytes from qrexec stdin, write them to the unix socket, copy the socket's framed response back to qrexec stdout, exit.
|
||||
- The **signer stays persistent** with the mnemonic in RAM, started once by the human exactly as it is today for the FIPS/TCP path.
|
||||
- **No mnemonic on disk, in argv, or in env.** The model in [`documents/SECURITY.md`](../documents/SECURITY.md) is preserved verbatim.
|
||||
|
||||
---
|
||||
|
||||
## 4. The identity-propagation problem (must-fix)
|
||||
|
||||
This is the crux and the main reason this needs a plan rather than a one-line script.
|
||||
|
||||
n_signer extracts the caller identity in [`server_get_caller()`](../src/server.c:1269):
|
||||
|
||||
- When `fd == STDIN_FILENO` (the qrexec/stdio process), it reads `QREXEC_REMOTE_DOMAIN` and tags the caller `qubes:<source-vm>` ([`src/server.c:1279`](../src/server.c:1279)).
|
||||
- Otherwise (a real socket fd) it uses `SO_PEERCRED` (unix) or `getpeername` (tcp).
|
||||
|
||||
If the bridge simply connects to the signer's unix socket, the signer sees the **bridge process** via `SO_PEERCRED` — i.e. `uid:<bridge-uid>`, **not** `qubes:<source-vm>`. The hypervisor-vouched source-qube identity is lost, and every caller collapses into one indistinguishable local uid. That destroys the entire point of using qrexec for identity.
|
||||
|
||||
Three candidate solutions:
|
||||
|
||||
### Option A — signer runs in qrexec mode, bridge injects the env var (rejected)
|
||||
|
||||
Have the bridge itself be `nsigner --listen qrexec`, but somehow attach to the persistent mnemonic. There is no mechanism for one nsigner process to borrow another's mlock'd mnemonic. Rejected — it is just the per-invocation design again.
|
||||
|
||||
### Option B — bridge forwards source-qube as an application-layer field (chosen, needs small code change)
|
||||
|
||||
The bridge reads `QREXEC_REMOTE_DOMAIN` (which qrexec sets in the bridge's environment) and passes it to the signer as a trusted out-of-band field on the local socket. Concretely:
|
||||
|
||||
1. Add a new listen sub-mode or startup flag to the persistent signer that marks its unix socket as a **trusted bridge socket** — e.g. `--listen unix --bridge-source-trusted`. Only bind this on an abstract socket the operator controls.
|
||||
2. Extend the wire handling so that on a bridge-trusted socket, the signer accepts a per-connection preamble line carrying the source-qube name (e.g. a first framed control message `{"qrexec_source":"<vm>"}`), and composes the caller id as `qubes:<vm>` exactly as the native qrexec path does today ([`src/server.c:1286`](../src/server.c:1286)).
|
||||
3. The trust argument: the bridge socket is an **abstract unix socket inside the signer qube**, reachable only by processes already inside that qube. The qrexec framework is the thing that authenticated the source qube and set `QREXEC_REMOTE_DOMAIN`; the bridge merely relays that framework-vouched value. This keeps identity quality equivalent to the native qrexec path.
|
||||
|
||||
This is the smallest change that preserves `qubes:<source-vm>` identity. It requires:
|
||||
|
||||
- a new startup flag on the signer,
|
||||
- a small preamble-parsing branch in [`src/server.c`](../src/server.c),
|
||||
- the bridge script to send the preamble before relaying the request.
|
||||
|
||||
### Option C — signer binds the qrexec service directly, no separate bridge (rejected for now)
|
||||
|
||||
Have qrexec hand the connection's stdin/stdout **directly** to the persistent signer via a passed FD, so the signer's existing `fd == STDIN_FILENO` path fires and reads `QREXEC_REMOTE_DOMAIN` natively. This is the cleanest in theory but qrexec's execution model spawns a new process per call; wiring an existing long-lived process to receive per-call qrexec FDs would require a socket-activation-style shim (e.g. `systemd`-socket or a small accept-loop that re-execs). Deferred as a future refinement — Option B gets us working sooner with a clear trust story.
|
||||
|
||||
**Decision: implement Option B.** Document the trust boundary explicitly.
|
||||
|
||||
---
|
||||
|
||||
## 5. Components and changes
|
||||
|
||||
### 5.1 Persistent signer (nostr_signer qube) — started by human
|
||||
|
||||
Unchanged startup ergonomics from the FIPS path, minus TCP, plus the bridge flag:
|
||||
|
||||
```bash
|
||||
~/.local/bin/nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
```
|
||||
|
||||
- Mnemonic entered interactively at the terminal (mlock RAM), exactly as today.
|
||||
- Binds abstract unix socket `@nsigner` inside the qube only.
|
||||
- `--allow-all` is **not** used; native deny-by-default + prompt behavior is preserved. First request from `qubes:<vm>` for an index prompts the human at the signer terminal, per [`documents/SECURITY.md`](../documents/SECURITY.md) §4. This is the intended trust anchor.
|
||||
|
||||
Requires the code change in §4 Option B.
|
||||
|
||||
### 5.2 qrexec bridge service (nostr_signer qube)
|
||||
|
||||
New/updated [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc) — a stateless relay, no secrets, no mnemonic:
|
||||
|
||||
Responsibilities:
|
||||
1. Read `QREXEC_REMOTE_DOMAIN` from env.
|
||||
2. Connect to abstract unix socket `@nsigner`.
|
||||
3. Send the trusted-source preamble (`{"qrexec_source":"<vm>"}` framed).
|
||||
4. Copy one framed request from stdin → socket.
|
||||
5. Copy one framed response from socket → stdout.
|
||||
6. Exit.
|
||||
|
||||
Implementation language: a small C helper (preferred, ships in the static binary as a `nsigner bridge` subcommand) or a vetted `python3` script. A `nsigner bridge` subcommand is cleanest — it reuses the existing framing code and avoids a python dependency in the signer qube.
|
||||
|
||||
> **Proposed:** add `nsigner bridge --to @nsigner` subcommand that does exactly the relay above, so the qrexec service is `exec nsigner bridge --to @nsigner`.
|
||||
|
||||
### 5.3 dom0 policy — unchanged shape
|
||||
|
||||
[`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy):
|
||||
|
||||
```
|
||||
qubes.NsignerRpc * @anyvm @tag:nsigner-signer ask default_target=nostr_signer
|
||||
qubes.NsignerRpc * @anyvm @anyvm deny
|
||||
```
|
||||
|
||||
- `ask` gives a dom0-level confirmation on first use (defense in depth on top of the signer's own prompt).
|
||||
- Tag the signer qube: `qvm-tags nostr_signer add nsigner-signer`.
|
||||
- Consider `allow` instead of `ask` for specific trusted caller qubes once the flow is validated, e.g.:
|
||||
`qubes.NsignerRpc * ai @tag:nsigner-signer allow target=nostr_signer`
|
||||
|
||||
### 5.4 Caller-side client (any caller qube)
|
||||
|
||||
[`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js) already does the right thing: it spawns `qrexec-client-vm <target> qubes.NsignerRpc`, sends one framed request, reads one framed response. **No auth envelope is needed** — identity comes from `QREXEC_REMOTE_DOMAIN`. The example needs no change once the bridge is in place.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security review against n_signer intent
|
||||
|
||||
| Requirement (from SECURITY.md / QUBES_OS.md) | Preserved? | How |
|
||||
|---|---|---|
|
||||
| Mnemonic only in mlock'd RAM, entered at terminal | ✅ | Persistent signer started by human; bridge holds no key material |
|
||||
| Nothing on disk / argv / env for the mnemonic | ✅ | Bridge never touches the mnemonic; no mnemonic file |
|
||||
| One process, one terminal, one human | ✅ | Exactly one persistent signer with its TUI; bridge is a dumb relay, not a signer |
|
||||
| Deny-by-default, human approval for new callers | ✅ | `--allow-all` dropped; native prompt fires at the signer terminal for each new `qubes:<vm>`+index |
|
||||
| Hypervisor-vouched caller identity | ✅ | `QREXEC_REMOTE_DOMAIN` relayed via trusted bridge socket, tagged `qubes:<vm>` |
|
||||
| dom0 as first enforcement boundary | ✅ | qrexec policy `ask`/`deny` runs before the signer sees anything |
|
||||
| Crash = total wipe | ✅ | Unchanged; only the persistent signer holds state |
|
||||
| No off-host connectivity | ✅ | qrexec is intra-host IPC; no network at all |
|
||||
|
||||
Explicit trust boundary added by this design (must be documented in SECURITY.md if implemented):
|
||||
|
||||
> The bridge socket `@nsigner` inside the signer qube is trusted to relay the `QREXEC_REMOTE_DOMAIN` value faithfully. Any process already inside the signer qube could connect to it and assert an arbitrary source-qube name. This is acceptable because (a) the signer qube is a dedicated, minimal, trusted qube, and (b) a process already inside the signer qube is inside the trust boundary anyway. The identity quality equals the native qrexec path.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation checklist
|
||||
|
||||
Code (in `n_signer`):
|
||||
- [ ] Add `nsigner bridge --to <abstract-socket>` subcommand (stateless framed relay + source preamble).
|
||||
- [ ] Add `--bridge-source-trusted` flag to the unix listener; parse the `{"qrexec_source":"<vm>"}` preamble and compose `qubes:<vm>` caller id.
|
||||
- [ ] Preserve deny-by-default + prompt for bridge-originated callers (no `--allow-all`).
|
||||
- [ ] Unit/integration test: bridge relay round-trip with a faked `QREXEC_REMOTE_DOMAIN`.
|
||||
|
||||
Packaging:
|
||||
- [ ] Update [`packaging/qubes/rpc/qubes.NsignerRpc`](../packaging/qubes/rpc/qubes.NsignerRpc) to `exec nsigner bridge --to @nsigner` (no mnemonic file).
|
||||
- [ ] Keep [`packaging/qubes/policy.d/40-nsigner.policy`](../packaging/qubes/policy.d/40-nsigner.policy) target = `nostr_signer`.
|
||||
|
||||
Operator runbook:
|
||||
- [ ] nostr_signer: install service, start persistent signer with `--listen unix --socket-name nsigner --bridge-source-trusted`, enter mnemonic.
|
||||
- [ ] dom0: install policy, `qvm-tags nostr_signer add nsigner-signer`.
|
||||
- [ ] caller qube: run [`examples/n_signer_qube_example_qrexec.js`](../examples/n_signer_qube_example_qrexec.js).
|
||||
|
||||
Docs:
|
||||
- [ ] Update [`documents/QUBES_OS.md`](../documents/QUBES_OS.md) §3.2 to describe the persistent-signer + bridge model, superseding the per-invocation design.
|
||||
- [ ] Add the §6 trust-boundary note to [`documents/SECURITY.md`](../documents/SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
## 8. Interim (no-code) fallback for testing today
|
||||
|
||||
Until the `bridge` subcommand and `--bridge-source-trusted` flag land, we can validate the transport path (but **not** proper identity) with a temporary python bridge that forwards to a persistent `nsigner --listen unix --socket-name nsigner --allow-all`. This proves qrexec plumbing end-to-end. It must not be used in production because:
|
||||
|
||||
- `--allow-all` disables the human approval anchor, and
|
||||
- the caller collapses to the bridge's local uid (no `qubes:<vm>` identity).
|
||||
|
||||
This interim path is for smoke-testing the qrexec/dom0 policy wiring only.
|
||||
Reference in New Issue
Block a user