Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782716c53b | ||
|
|
b2f5d06570 | ||
|
|
03a977b774 | ||
|
|
9c762edbd2 | ||
|
|
b5b58ecb87 | ||
|
|
a428d5e77b | ||
|
|
c685806aeb | ||
|
|
a3e1d70fe8 | ||
|
|
567985434d | ||
|
|
a472e9785b | ||
|
|
374b71cdf4 | ||
|
|
b3cc4704c3 | ||
|
|
5d3cc4e96d | ||
|
|
2f7f29ed99 | ||
|
|
7bc367e695 | ||
|
|
52ac2357f3 | ||
|
|
2cd64032ee | ||
|
|
ea11bb5a87 | ||
|
|
1a48f40b99 |
Generated
+33
-33
@@ -1446,7 +1446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-core"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -1468,7 +1468,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-nips"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"block-modes",
|
||||
@@ -1488,37 +1488,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nsigner"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"clap",
|
||||
"crossterm 0.27.0",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"hmac 0.12.1",
|
||||
"libc",
|
||||
"ml-dsa",
|
||||
"ml-kem",
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
"rand",
|
||||
"rand_core 0.6.4",
|
||||
"ratatui",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha3 0.10.9",
|
||||
"slh-dsa",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2236,6 +2205,37 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signer"
|
||||
version = "0.0.21"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"clap",
|
||||
"crossterm 0.27.0",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"hmac 0.12.1",
|
||||
"libc",
|
||||
"ml-dsa",
|
||||
"ml-kem",
|
||||
"nostr-core",
|
||||
"nostr-nips",
|
||||
"rand",
|
||||
"rand_core 0.6.4",
|
||||
"ratatui",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha3 0.10.9",
|
||||
"slh-dsa",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
||||
+8
-4
@@ -1,16 +1,20 @@
|
||||
[package]
|
||||
name = "nsigner"
|
||||
version = "0.0.2"
|
||||
name = "signer"
|
||||
version = "0.0.21"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Attended Nostr signing daemon — Rust port of n_signer"
|
||||
|
||||
[[bin]]
|
||||
name = "nsigner"
|
||||
name = "signer"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "signer-client"
|
||||
path = "src/client/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "nsigner"
|
||||
name = "signer"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,3 +1,798 @@
|
||||
# signer
|
||||
|
||||
A Rust port of the [n_signer](https://github.com/lt/n_signer) project.
|
||||
`signer` is a single statically-linked program that holds signing key material in locked memory and signs on request.
|
||||
|
||||
It runs in the foreground, attached to your terminal. The terminal is the trust anchor and control surface. Nothing touches disk at runtime. If the process crashes or exits, all in-memory state is gone.
|
||||
|
||||
This is a **program, not a daemon**:
|
||||
|
||||
- no hidden background process
|
||||
- no detached service lifecycle
|
||||
- no runtime config or state files
|
||||
- no persistence to recover after compromise
|
||||
|
||||
## 1. What it is
|
||||
|
||||
`signer` is one binary that combines:
|
||||
|
||||
- mnemonic handling (BIP-39)
|
||||
- role selection and derivation (BIP-32 / SLIP-0010)
|
||||
- purpose/curve enforcement
|
||||
- request dispatch
|
||||
- interactive terminal UI (ratatui)
|
||||
- transport adapter(s) (Unix socket, qrexec, FIPS/TCP, HTTP, stdio)
|
||||
- OTP one-time pad encryption (optional, with USB pad)
|
||||
- post-quantum algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
|
||||
|
||||
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
|
||||
|
||||
## 2. Security model
|
||||
|
||||
### 2.1 Zero filesystem footprint
|
||||
|
||||
At runtime, `signer` writes nothing to disk: no config files, no logs, no PID files, no lock files, no socket pathname artifacts. On Linux desktop, local IPC uses abstract namespace Unix sockets (`@name` semantics) that exist only in kernel memory and disappear with process/kernel namespace lifetime.
|
||||
|
||||
### 2.2 Crash = total wipe
|
||||
|
||||
All sensitive and operational state exists only in-process RAM (mlock'd where applicable): mnemonic-derived key material, role table, activity display buffer. If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design. Sensitive buffers are zeroized with [`zeroize`](src/secure_mem.rs) on drop.
|
||||
|
||||
### 2.3 Single binary, no external runtime dependencies
|
||||
|
||||
The release build (`opt-level = "z"`, `lto = true`, `panic = "abort"`, `strip = true`) produces one optimized binary. No shared libraries are required at runtime beyond the system libc.
|
||||
|
||||
### 2.4 Always-attended operation
|
||||
|
||||
`signer` is intentionally human-attended. It stays attached to a terminal and the role-name-as-password model means a caller must already know the role name (the "password") to reach a key. Human presence is part of the security model.
|
||||
|
||||
### 2.5 Secret memory backing: `mlock`
|
||||
|
||||
Sensitive buffers (mnemonic, master seed, per-role private keys) live in `mlock`'d RAM and are zeroized on free. This gives swap protection and crash-wipe semantics on every supported platform, including Qubes OS Xen guests.
|
||||
|
||||
## 3. How it works
|
||||
|
||||
### 3.1 Startup phase (TUI input mode)
|
||||
|
||||
When started interactively, `signer` immediately enters a startup popup:
|
||||
|
||||
1. **Seed entry popup** — enter an existing BIP-39 mnemonic, or type `g` to generate a fresh 12-word mnemonic (displayed numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then press Enter to continue).
|
||||
2. On successful load, derive keys for any pre-registered roles (the default `main` role is auto-registered), start the server with the default transport (Unix), and transition to the main screen.
|
||||
3. Invalid mnemonics show an error line and retry (max 10 attempts).
|
||||
|
||||
No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash.
|
||||
|
||||
For parent-process launchers, startup can also be non-interactive:
|
||||
|
||||
- `--mnemonic-stdin`: read one mnemonic line from stdin at startup, then continue normally.
|
||||
- `--mnemonic-fd N`: read one mnemonic line from inherited file descriptor `N` at startup.
|
||||
|
||||
These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. Non-interactive modes run headless (no TUI).
|
||||
|
||||
### 3.2 Running phase (main screen)
|
||||
|
||||
After unlock, the terminal becomes a live status and control console. The title `Signer v0.0.1` is centered on its own line. Below it, collapsed-border panes show:
|
||||
|
||||
- **Information** — signer name, transport addresses (Unix / Qube / FIPS / HTTP), OTP pad status.
|
||||
- **Transport** — four toggle lines (`[x] U̲nix Socket`, `[ ] Qube b̲ridge`, `[ ] F̲IPS`, `[ ] H̲TTP`). The `[x]` / `[ ]` indicator shows on/off state. The underlined key letter toggles a transport on/off. The last active transport cannot be disabled. Toggling restarts the server immediately.
|
||||
- **Roles** — table of registered roles (Role, Purpose, Curve). `A̲dd` opens the add-role popup; `D̲elete` removes the selected role immediately.
|
||||
- **Activity** — scrollable, newest first. Each entry is a timestamped log line in the format `<caller_id> <curve> <key_path>`. `Cl̲ear` wipes the log.
|
||||
- **Bottom bar** — `He̲lp Q̲uit`.
|
||||
|
||||
Command hints show only the word with the key letter underlined (e.g. "Quit" with Q underlined, "Qube bridge" with B underlined). The underlined letter is the actual key binding, which may not be the first letter.
|
||||
|
||||
**Focus and activation:** `Tab` / `Shift-Tab` cycles focus through all nine commands on the main screen — the four transports, Add, Delete, Clear, Help, Quit. The focused command is reverse-highlighted. `Enter` activates the focused command. Pressing a command's underlined key letter fires it directly regardless of focus. `Up`/`Down` (or `j`/`k`) scroll the activity log.
|
||||
|
||||
### 3.3 Help screen
|
||||
|
||||
Press `L` from the main screen to open a full-screen, scrollable Help overlay describing what the app does, what transports are, what roles are, and listing the key commands. A Navigation section at the end explains how to leave the screen:
|
||||
|
||||
- `Up`/`Down` or `j`/`k` — scroll
|
||||
- `Page Up` / `Page Down` — scroll by a page
|
||||
- `ESC` or `B` — back to the main screen
|
||||
- `Q` — quit the program
|
||||
|
||||
The bottom bar shows `Ba̲ck Q̲uit` so the exit path is always visible.
|
||||
|
||||
### 3.4 Add-role popup
|
||||
|
||||
Press `A` from the main screen to open the add-role popup. The flow is:
|
||||
|
||||
1. **Preset menu** — choose from 10 presets (Standard Nostr, hardened range, agent range, SSH, Age, ML-DSA-65, SLH-DSA-128s, ML-KEM-768, OTP, Custom).
|
||||
2. **Name entry** — InputField pre-filled with a default role name.
|
||||
3. **Curve select** — custom roles only.
|
||||
4. **Path entry** — InputField pre-filled with the preset's derivation path template.
|
||||
5. **OTP dir / name** — OTP roles only.
|
||||
6. **Confirm** — register the role, derive its key immediately, and return to the main screen automatically.
|
||||
|
||||
`ESC` at any stage cancels and returns to the main screen.
|
||||
|
||||
### 3.5 Shutdown
|
||||
|
||||
- `Q` (or `ESC` on the main screen) quits the process: all session state is destroyed.
|
||||
- Terminal close or process termination has the same effect: total state wipe.
|
||||
|
||||
## 4. API
|
||||
|
||||
`signer` exposes a JSON-RPC 2.0-style request/response protocol. Every request is a single JSON object; every response is a single JSON object. This section is the complete, authoritative description of the API.
|
||||
|
||||
### 4.1 Request format
|
||||
|
||||
```json
|
||||
{ "id": "<string>", "method": "<verb>", "params": [ <arg0>, <arg1>, ..., { <options> } ] }
|
||||
```
|
||||
|
||||
- `id` — caller-supplied string echoed verbatim in the response. Used to match requests to responses.
|
||||
- `method` — the verb name (see [§4.3](#43-verbs)).
|
||||
- `params` — a JSON array. Positional arguments come first; the **last array element** is conventionally an options object. The options object is optional for most verbs.
|
||||
|
||||
### 4.2 Response format
|
||||
|
||||
Success:
|
||||
```json
|
||||
{ "id": "<string>", "result": <value> }
|
||||
```
|
||||
|
||||
`result` is a JSON string. For structured verbs the string is itself a serialized JSON object — clients should `JSON.parse` it.
|
||||
|
||||
Error:
|
||||
```json
|
||||
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
|
||||
```
|
||||
|
||||
Error codes:
|
||||
|
||||
| Code | Message | Meaning |
|
||||
|-------|-------------------------------|--------------------------------------------------------------------|
|
||||
| -32700| `parse_error` | Request was not valid JSON. |
|
||||
| -32600| `invalid_request` | Missing `id`, `method`, or `params`, or `params` is not an array. |
|
||||
| -32601| `method_not_found` | Unknown verb, or verb not valid for the selected algorithm. |
|
||||
| -32602| `invalid_params` | Malformed arguments (bad hex, wrong length, missing field, etc.). |
|
||||
| 1001 | `ambiguous_role_selector` | More than one role selector was supplied. |
|
||||
| 1002 | `unknown_role` | No role matched the selector. |
|
||||
| 1003 | `no_default_role` | No selector given and no `main` role exists. |
|
||||
| 1004 | `purpose_mismatch` | Role's purpose is not valid for this verb. |
|
||||
| 1005 | `curve_mismatch` | Role's curve is not valid for this verb. |
|
||||
| 1006 | `mnemonic_not_loaded` | No mnemonic is loaded in the signer. |
|
||||
| 1007 | `no_termination_condition` | `nostr_mine_event` called without `difficulty` or `timeout_sec`. |
|
||||
| 1008 | `mining_failed` | Internal error during proof-of-work mining. |
|
||||
| 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. |
|
||||
| 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. |
|
||||
| 2003 | `path_not_allowed` | `role_path` does not match any registered role or allowed path. |
|
||||
| 2005 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
|
||||
| 2008 | `role_required` | `role` is required when using `role_path`. |
|
||||
| 2009 | `path_required` | `role_path` is required for roles with variable path templates. |
|
||||
|
||||
### 4.3 Verbs
|
||||
|
||||
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `role` + `role_path` and implement Nostr-protocol-specific serialization on top of the raw crypto.
|
||||
|
||||
| Verb | Algorithms | Positional params | Options |
|
||||
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
|
||||
| `get_info` | n/a (metadata) | — | — |
|
||||
| `get_public_key` | all key-deriving algorithms | — | `algorithm`, `index` |
|
||||
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>` | `algorithm`, `index`, `scheme`* |
|
||||
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>`, `<signature_hex>` | `algorithm`, `index`, `scheme`* |
|
||||
| `encapsulate` | ml-kem-768 | `<peer_pubkey_hex>` | `algorithm` |
|
||||
| `decapsulate` | ml-kem-768 | `<ciphertext_hex>` | `algorithm`, `index` |
|
||||
| `derive_shared_secret` | x25519 | `<peer_pubkey_hex>` | `algorithm`, `index` |
|
||||
| `derive` | secp256k1 | `<data>` | `algorithm`, `index` (required) |
|
||||
| `encrypt` | otp | `<plaintext_base64>` | `algorithm`, `encoding` |
|
||||
| `decrypt` | otp | `<ciphertext>` | `algorithm`, `encoding` |
|
||||
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `role`, `role_path`, `format` |
|
||||
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path` |
|
||||
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path`, `difficulty`, `timeout_sec`, `threads` |
|
||||
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
|
||||
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
|
||||
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
|
||||
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
|
||||
|
||||
\* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`.
|
||||
|
||||
#### Enforcement matrix
|
||||
|
||||
| Verb | Valid algorithms |
|
||||
|----------------------------|-----------------------------------------------|
|
||||
| `sign` / `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s |
|
||||
| `encapsulate` / `decapsulate` | ml-kem-768 |
|
||||
| `derive_shared_secret` | x25519 |
|
||||
| `derive` | secp256k1 |
|
||||
| `encrypt` / `decrypt` | otp |
|
||||
| `get_public_key` | all key-deriving algorithms |
|
||||
| `nostr_*` | secp256k1 (Nostr protocol) |
|
||||
|
||||
Any unlisted `(verb, algorithm)` pair is rejected with `algorithm_not_supported_for_verb` (1010).
|
||||
|
||||
### 4.4 Algorithms
|
||||
|
||||
All keys derive deterministically from the loaded BIP-39 mnemonic. The caller selects an algorithm by name and a derivation `index` (an integer `<n>` substituted into the algorithm's derivation path). OTP is the exception — it does not derive a key, it consumes a bound one-time pad (see [§4.4.3](#443-otp)).
|
||||
|
||||
#### 4.4.1 Algorithm table
|
||||
|
||||
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|
||||
|-----------------|-----------------|---------------|----------------------------------------|-------------------------------|
|
||||
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
|
||||
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
||||
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
||||
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0'/0'` (BIP-32) | 32 / 1952 |
|
||||
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0'/0'` (BIP-32) | 64 / 32 |
|
||||
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0'/0'` (BIP-32) | 64 / 1184 |
|
||||
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
|
||||
|
||||
#### 4.4.2 Key derivation
|
||||
|
||||
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
|
||||
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
|
||||
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use the **v2 FIPS seeded derivation** (see [`plans/pq_seeded_derivation_plan.md`](plans/pq_seeded_derivation_plan.md)): BIP-32 child bytes at the exact seed length required by each algorithm feed the seeded keygen APIs directly — no DRBG expansion. ML-DSA-65 takes one 32-byte child; SLH-DSA-128s takes two children concatenated (first 48 of 64 bytes, split as sk.seed ∥ sk.prf ∥ pk.seed); ML-KEM-768 takes two children concatenated (all 64 bytes, split as d ∥ z). Same mnemonic, same index, same key pair every time — and the same keys as the nostr_quantum_preparation web app (verified against its pinned test vectors by `tests/pq_conformance.rs`). PQ private keys are stored in seed form. The PQ implementations are the pure-Rust crates [`ml-dsa`](https://crates.io/crates/ml-dsa), [`ml-kem`](https://crates.io/crates/ml-kem), and [`slh-dsa`](https://crates.io/crates/slh-dsa) (SLH-DSA uses the SHA2-128s parameter set). The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
|
||||
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
|
||||
|
||||
#### 4.4.3 OTP
|
||||
|
||||
The `otp` algorithm is a stream-style one-time pad, not a key-derivation scheme. It is selected like any other algorithm via `{"algorithm":"otp"}` and works with the `encrypt` / `decrypt` verbs. One pad per session; the pad offset advances monotonically across requests and is reported in every response.
|
||||
|
||||
### 4.5 Examples
|
||||
|
||||
#### `get_info`
|
||||
|
||||
Returns signer metadata: `name`, `implementation`, `version`, `api`, and the supported `verbs` / `algorithms` arrays. Safe to call before the mnemonic is loaded — clients use it to feature-detect.
|
||||
|
||||
```json
|
||||
{ "id": "0", "method": "get_info", "params": [] }
|
||||
```
|
||||
|
||||
#### `get_public_key`
|
||||
|
||||
```json
|
||||
{ "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "1", "result": "{\"algorithm\":\"ml-dsa-65\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}" }
|
||||
```
|
||||
|
||||
`key_id` is the first 16 hex characters of the public key — a short display identifier.
|
||||
|
||||
#### `sign`
|
||||
|
||||
```json
|
||||
{ "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "2", "result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\",\"key_id\":\"<16 hex>\"}" }
|
||||
```
|
||||
|
||||
The first positional argument is the message as hex. For `secp256k1` the `scheme` option selects `"schnorr"` (default, BIP-340) or `"ecdsa"`:
|
||||
|
||||
```json
|
||||
{ "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] }
|
||||
```
|
||||
|
||||
#### `verify`
|
||||
|
||||
```json
|
||||
{ "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "4", "result": "{\"valid\":true,\"algorithm\":\"ed25519\"}" }
|
||||
```
|
||||
|
||||
The signer derives its own public key from `(algorithm, index)` and verifies against it. To verify an arbitrary third-party key, use a client-side library.
|
||||
|
||||
#### `encapsulate` (ML-KEM-768)
|
||||
|
||||
```json
|
||||
{ "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "5", "result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
|
||||
```
|
||||
|
||||
`peer_pubkey_hex` is the recipient's ML-KEM-768 public key (1184 bytes → 2368 hex chars). Send the returned `ciphertext` to the recipient; both sides end up with the same `shared_secret`.
|
||||
|
||||
#### `decapsulate` (ML-KEM-768)
|
||||
|
||||
```json
|
||||
{ "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
|
||||
```
|
||||
|
||||
#### `derive_shared_secret` (X25519)
|
||||
|
||||
```json
|
||||
{ "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "7", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"x25519\"}" }
|
||||
```
|
||||
|
||||
`peer_pubkey_hex` is the peer's 32-byte X25519 public key (64 hex chars). Feed the returned `shared_secret` into your own symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305).
|
||||
|
||||
#### `derive` (secp256k1 HMAC-SHA256)
|
||||
|
||||
```json
|
||||
{ "id": "10", "method": "derive", "params": [ "<data>", { "algorithm": "secp256k1", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "10", "result": "{\"algorithm\":\"secp256k1\",\"key_id\":\"<16hex>\",\"digest\":\"<64hex>\"}" }
|
||||
```
|
||||
|
||||
Computes `HMAC-SHA256(privkey, data)` where `privkey` is the secp256k1 private key derived on demand at `(algorithm: "secp256k1", index: N)`. `data` is an arbitrary caller-supplied UTF-8 string. Returns the 32-byte digest as 64 lowercase hex chars.
|
||||
|
||||
`index` is **required** (no default) — forces conscious selection of which derived key to use as the HMAC key. Omitting it returns `invalid_params`.
|
||||
|
||||
This is a generic key-derived MAC primitive. Callers domain-separate by prefixing their own label into `data` (e.g. `"myapp/identifier-v1:<path>"`). The private key never leaves the signer; only the digest is returned. Use cases include deterministic, per-user, opaque identifiers for NIP-33 parameterized-replaceable events (e.g. bookmark folder `d` tags) where the same logical name must produce the same `d` tag across devices.
|
||||
|
||||
#### `encrypt` / `decrypt` (OTP)
|
||||
|
||||
```json
|
||||
{ "id": "8", "method": "encrypt", "params": [ "<plaintext_base64>", { "algorithm": "otp", "encoding": "ascii" } ] }
|
||||
{ "id": "9", "method": "decrypt", "params": [ "<ciphertext>", { "algorithm": "otp", "encoding": "ascii" } ] }
|
||||
```
|
||||
|
||||
`encoding` is `"ascii"` (ASCII-armored, default) or `"binary"` (base64-encoded raw `.otp` blob). If omitted on `decrypt`, auto-detection by magic bytes is used.
|
||||
|
||||
`encrypt` response:
|
||||
```json
|
||||
{
|
||||
"id": "8",
|
||||
"result": "{\"ciphertext\":\"<ascii-armor-or-base64-blob>\",\"encoding\":\"ascii\",\"pad_chksum\":\"<64 hex>\",\"pad_offset_before\":288,\"pad_offset_after\":416}"
|
||||
}
|
||||
```
|
||||
|
||||
`decrypt` response:
|
||||
```json
|
||||
{ "id": "9", "result": "{\"plaintext\":\"<base64>\",\"pad_chksum\":\"<64 hex>\"}" }
|
||||
```
|
||||
|
||||
If no pad is bound at startup, the error is `-32601` `method_not_found` (otp_pad_not_bound).
|
||||
|
||||
#### `nostr_get_public_key`
|
||||
|
||||
```json
|
||||
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "role": "main" } ] }
|
||||
```
|
||||
|
||||
Response (default): a plain 64-hex-char secp256k1 public key string.
|
||||
Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`.
|
||||
|
||||
#### `nostr_sign_event`
|
||||
|
||||
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
|
||||
|
||||
```json
|
||||
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`<event_json>` is the unsigned event object:
|
||||
```json
|
||||
{ "pubkey": "...", "created_at": 1234567890, "kind": 1, "tags": [], "content": "hello" }
|
||||
```
|
||||
|
||||
Response: the signed event JSON string, with `id` and `sig` populated.
|
||||
|
||||
#### `nostr_mine_event`
|
||||
|
||||
Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step. Mining runs in a detached thread so the signer stays responsive.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "12",
|
||||
"method": "nostr_mine_event",
|
||||
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "role": "main" } ]
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Required | Default | Meaning |
|
||||
|---------------|-------------------------------|---------|---------------------------------------------------------------|
|
||||
| `difficulty` | one of `difficulty`/`timeout` | 0 | Target leading zero bits. Stops early if reached. |
|
||||
| `timeout_sec` | one of `difficulty`/`timeout` | 600 | Time budget in seconds. Always returns the best event found. |
|
||||
| `threads` | no | 1 | Mining threads (clamped to 1..32). |
|
||||
|
||||
At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when **either** condition is met. Timeout is never an error — the best event found is always returned.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "12",
|
||||
"result": "{\"event\":\"<signed event JSON with nonce tag>\",\"achieved_difficulty\":18,\"target_difficulty\":20,\"target_reached\":false,\"elapsed_sec\":30,\"attempts\":4523456}"
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
- `1007` `no_termination_condition` — neither `difficulty` nor `timeout_sec` given.
|
||||
- `1008` `mining_failed` — internal mining error.
|
||||
|
||||
#### `nostr_nip04_encrypt` / `nostr_nip04_decrypt`
|
||||
|
||||
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
|
||||
|
||||
```json
|
||||
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
|
||||
|
||||
#### `nostr_nip44_encrypt` / `nostr_nip44_decrypt`
|
||||
|
||||
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
|
||||
|
||||
```json
|
||||
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
|
||||
|
||||
### 4.6 Role-based selectors (Nostr verbs)
|
||||
|
||||
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Supported selectors:
|
||||
|
||||
| Selector | Meaning |
|
||||
|----------------|--------------------------------------------------|
|
||||
| `role` | Name of a pre-registered role entry (required) |
|
||||
| `role_path` | Full BIP-44 derivation path (required) |
|
||||
|
||||
**Selector resolution**: both `role` and `role_path` are required together — they form a single combined selector. The server verifies that the supplied `role_path` matches the role's registered template (expanding any wildcard). There is no resolution order and no default role: omitting either field is rejected (`2008 role_required` / `2009 path_required`). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
|
||||
|
||||
#### Named path-roles
|
||||
|
||||
In the add-role popup, you define **named path-roles** that bind a role name (which acts as an access token for clients) to a derivation path template. The derivation path template is hidden from clients — they only know the role name and send the full concrete `role_path` with each request.
|
||||
|
||||
The popup presents a **preset menu** of 10 options covering the common role types. You can still define custom roles manually via the "Custom path" option.
|
||||
|
||||
```
|
||||
Preset menu:
|
||||
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
|
||||
2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
|
||||
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
|
||||
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
|
||||
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
|
||||
6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
|
||||
7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
|
||||
8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
|
||||
9. OTP role (one-time pad encryption)
|
||||
10. Custom path
|
||||
```
|
||||
|
||||
Purpose is auto-detected from the path prefix (e.g. `m/44'/1237'` → nostr, `m/44'/102001'` → ssh). The path template is pre-filled from the chosen preset and can be edited inline.
|
||||
|
||||
**Path template syntax:**
|
||||
- **Wildcard**: `m/44'/1237'/*'/0'/0'` — any non-negative integer, hardened. No range limit.
|
||||
- **Range**: `m/44'/1237'/0-3/1/0` — index 0..3, hardened if segment ends with `'` (e.g. `0-3'`)
|
||||
- **Set**: `m/44'/1237'/1+34+54/1/0` — specific indices 1, 34, 54
|
||||
- **Fixed path**: `m/44'/1237'/0'/0/0` — no variable segment, single fixed key
|
||||
- The first segment that is a plain number, range (`N-M`), set (`A+B+C`), or wildcard (`*`) becomes the variable. Segments with `'` (like `44'`, `1237'`) are treated as literal hardened constants.
|
||||
|
||||
The role name itself acts as a password: any caller that knows the role name and supplies a matching `role_path` is served without attendant interaction.
|
||||
|
||||
Clients request keys by supplying both `role` and the full concrete `role_path`:
|
||||
|
||||
```json
|
||||
{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/0'/1/0"}]}
|
||||
```
|
||||
|
||||
→ derives `m/44'/1237'/0'/1/0`, verified against the `myrole` template.
|
||||
|
||||
```json
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/5'/1/0"}]}
|
||||
```
|
||||
|
||||
→ `2003 path_not_allowed` (5 is outside the registered template, if the template was a fixed path or limited range).
|
||||
|
||||
```json
|
||||
{"id":"3","method":"nostr_get_public_key","params":[{"role":"unknown","role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
|
||||
→ `1002 unknown_role` (name not registered).
|
||||
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{"role":"myrole"}]}
|
||||
```
|
||||
|
||||
→ `2009 path_required` (`role_path` is required).
|
||||
|
||||
```json
|
||||
{"id":"5","method":"nostr_get_public_key","params":[{"role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
|
||||
→ `2008 role_required` (`role` is required when using `role_path`).
|
||||
|
||||
### 4.7 Non-interactive role registration
|
||||
|
||||
Roles can be registered from the command line with `--register-role` (repeatable), avoiding the popup entirely. The spec format is `<name>:<curve>:<path-template>`. If `<curve>` is empty, the curve is auto-detected from the path prefix.
|
||||
|
||||
```bash
|
||||
signer --register-role main:secp256k1:m/44'/1237'/0'/0/0
|
||||
signer --register-role nostr_range::m/44'/1237'/*'/0/0
|
||||
signer --register-role ssh::m/44'/102001'/0'/0'/0'
|
||||
```
|
||||
|
||||
If no `--register-role` is given in non-interactive mode, a default `main` role (`m/44'/1237'/0'/0/0`, secp256k1, nostr) is auto-registered.
|
||||
|
||||
## 5. Transports
|
||||
|
||||
The API is transport-independent. The same JSON request works over every transport; only the framing differs.
|
||||
|
||||
| Transport | `--listen` flag | Framing | Caller identity |
|
||||
|-----------|--------------------------------|------------------------------------------|----------------------------|
|
||||
| Unix socket (abstract) | `unix` (default on desktop) | Length-prefixed framed JSON | `SO_PEERCRED` → `uid:<n>` |
|
||||
| stdio | `stdio` | One framed request/response over stdin/stdout | inherited uid |
|
||||
| qrexec | `qrexec` | Same as stdio; caller from `QREXEC_REMOTE_DOMAIN` | `qubes:<vm>` |
|
||||
| FIPS/TCP | `tcp:[host]:port` | Length-prefixed framed JSON | (transport-defined) |
|
||||
| HTTP | `http:host:port` | Standard HTTP POST, JSON body, no custom framing. CORS enabled. | (transport-defined) |
|
||||
|
||||
### 5.1 HTTP examples
|
||||
|
||||
Start the signer:
|
||||
```bash
|
||||
signer --listen http:127.0.0.1:11111 --mnemonic-stdin
|
||||
```
|
||||
|
||||
Get a public key:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
|
||||
```
|
||||
|
||||
Sign a Nostr event:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
```
|
||||
|
||||
OTP encrypt:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"algorithm":"otp","encoding":"ascii"}]}'
|
||||
```
|
||||
|
||||
### 5.2 Unix socket examples (framed mode)
|
||||
|
||||
```bash
|
||||
# get_public_key
|
||||
signer --socket-name signer01 client \
|
||||
'{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
|
||||
|
||||
# Sign a Nostr event
|
||||
signer --socket-name signer01 client \
|
||||
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
|
||||
# ed25519 sign
|
||||
signer --socket-name signer01 client \
|
||||
'{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}'
|
||||
```
|
||||
|
||||
### 5.3 Linux desktop: abstract namespace Unix socket
|
||||
|
||||
Primary local transport is AF_UNIX abstract namespace. Each running `signer` process binds to a unique abstract name of the form `@signer_<word1>_<word2>`, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. `@signer_hairy_dog`). This lets multiple signers coexist on one host.
|
||||
|
||||
Properties: no pathname in filesystem; endpoint lifetime bound to process/kernel namespace; no stale socket files; caller identity via peer credentials (`SO_PEERCRED`); per-launch random name avoids collisions and leaks no seed-derived identifier.
|
||||
|
||||
Naming rules:
|
||||
- Default: random pick at startup, displayed in the Information section.
|
||||
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name.
|
||||
|
||||
Discovery:
|
||||
- `signer list` enumerates currently bound `signer_*` abstract sockets by reading `/proc/net/unix`.
|
||||
- `signer --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
|
||||
- `signer --listen qrexec` is the same stdio framing, but caller identity comes from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
|
||||
- `signer --listen tcp:[::]:11111` enables FIPS/TCP listening (framed JSON, not HTTP).
|
||||
- `signer --listen http:127.0.0.1:11111` enables HTTP listening for curl-friendly access. CORS headers included for browser access. Defaults to localhost; pass `http:0.0.0.0:PORT` to expose externally.
|
||||
- `signer bridge --to <socket-name>` is a stateless relay for Qubes qrexec: reads one framed request from stdin, forwards it to a persistent signer's abstract unix socket, and relays the response to stdout. Used as the `qubes.signerRpc` service entrypoint.
|
||||
- `--bridge-source-trusted` (unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed `{"qrexec_source":"<vm>"}` preamble before the request, and the caller identity is composed as `qubes:<vm>`.
|
||||
|
||||
### 5.4 Caller verification
|
||||
|
||||
Every transport must provide concrete caller identity before policy evaluation.
|
||||
|
||||
- Linux AF_UNIX: map peer credentials to caller identity.
|
||||
- Relay session: bind remote peer/session identity before allowing signer verbs.
|
||||
|
||||
Identity verification and the role-name-as-password gate are separate layers. Passing identity checks does not bypass the role-name requirement.
|
||||
|
||||
## 6. Platform targets
|
||||
|
||||
### 6.1 Linux desktop (primary)
|
||||
|
||||
Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
|
||||
|
||||
### 6.2 Qubes OS qube
|
||||
|
||||
Qubes deployment runs `signer` in a dedicated signer qube (e.g. `nostr_signer`) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation).
|
||||
|
||||
Three transport paths are supported:
|
||||
|
||||
**FIPS/TCP** — the signer listens on `tcp:[::]:11111` and FIPS carries traffic between qubes as an IPv6 mesh substrate.
|
||||
|
||||
**HTTP** — the signer listens on `http:127.0.0.1:11111` for curl-friendly access within the same qube. No auth envelopes required (relies on localhost binding + the role-name-as-password gate).
|
||||
|
||||
**Qubes qrexec bridge** (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless `signer bridge` relay (the `qubes.signerRpc` qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is `qubes:<source-vm>`.
|
||||
|
||||
#### Qrexec bridge setup
|
||||
|
||||
**In the signer qube** (`nostr_signer`):
|
||||
```bash
|
||||
signer --listen unix --socket-name signer --bridge-source-trusted
|
||||
```
|
||||
|
||||
**From a caller qube** (via the qrexec service):
|
||||
```bash
|
||||
signer bridge --to signer
|
||||
```
|
||||
|
||||
## 7. Usage
|
||||
|
||||
### 7.1 Run the program
|
||||
|
||||
```bash
|
||||
signer
|
||||
```
|
||||
|
||||
Program starts in attached foreground mode and shows the seed-entry popup. After mnemonic acceptance, the main screen shows the randomly assigned signer name and its abstract socket address.
|
||||
|
||||
To force a specific socket name (e.g. for scripted clients):
|
||||
```bash
|
||||
signer --name my_test_signer
|
||||
```
|
||||
|
||||
Other transport modes:
|
||||
```bash
|
||||
signer --listen qrexec # Qubes qrexec (single framed request over stdin/stdout)
|
||||
signer --listen stdio # Generic stdio (single framed request over stdin/stdout)
|
||||
signer --listen tcp:[::]:11111 # FIPS/TCP (framed JSON, no TUI)
|
||||
signer --listen http:127.0.0.1:11111 # HTTP (curl-friendly, no TUI)
|
||||
```
|
||||
|
||||
With OTP pad bound:
|
||||
```bash
|
||||
signer --listen http:127.0.0.1:11111 --otp-pad-dir /media/user/Music/pads --otp-pad 333e9902db839d9d --mnemonic-stdin
|
||||
```
|
||||
|
||||
Qrexec bridge mode (stateless relay to a persistent signer's unix socket):
|
||||
```bash
|
||||
signer bridge --to signer
|
||||
```
|
||||
|
||||
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
|
||||
```bash
|
||||
signer --listen unix --socket-name signer --bridge-source-trusted
|
||||
```
|
||||
|
||||
### 7.2 Send a request (client mode)
|
||||
|
||||
The `signer client` subcommand sends a hand-built JSON-RPC object over the socket:
|
||||
|
||||
```bash
|
||||
signer --socket-name signer01 client \
|
||||
'{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
|
||||
```
|
||||
|
||||
Read the request from stdin with `-`:
|
||||
```bash
|
||||
echo '{"id":"1","method":"get_info","params":[]}' | signer client -
|
||||
```
|
||||
|
||||
If only one signer is running you can omit the `--socket-name` override and the client will use the default discovery rule.
|
||||
|
||||
### 7.3 List running signers
|
||||
|
||||
```bash
|
||||
signer list
|
||||
```
|
||||
|
||||
Prints the names of any currently running `signer` instances, e.g.:
|
||||
```text
|
||||
signer_hairy_dog
|
||||
signer_brave_canyon
|
||||
```
|
||||
|
||||
### 7.4 Example session
|
||||
|
||||
Terminal A:
|
||||
```text
|
||||
$ signer
|
||||
signer v0.0.2
|
||||
[seed entry popup → enter mnemonic]
|
||||
[main screen shows: signer name signer_hairy_dog, Unix address active]
|
||||
```
|
||||
|
||||
Terminal B:
|
||||
```text
|
||||
$ signer --socket-name signer_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
|
||||
{"id":"2","result":"<signed_event_json>"}
|
||||
```
|
||||
|
||||
## 8. Build
|
||||
|
||||
### 8.1 Dependencies
|
||||
|
||||
The build expects the local [`nostr_core_lib_rust`](../nostr_core_lib_rust) checkout (sibling directory) for the `nostr-core` and `nostr-nips` path dependencies, and the vendored [`ratatui`](ratatui) submodule for the TUI.
|
||||
|
||||
```bash
|
||||
git submodule update --init ratatui
|
||||
```
|
||||
|
||||
### 8.2 Local dev build
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
./target/debug/signer --version
|
||||
```
|
||||
|
||||
### 8.3 Release build
|
||||
|
||||
The release profile is tuned for a small, optimized, stripped binary:
|
||||
|
||||
```toml
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
./target/release/signer --version
|
||||
```
|
||||
|
||||
### 8.4 Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
## 9. Project layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| [`src/main.rs`](src/main.rs:1) | CLI parsing, subcommands (`client`, `bridge`, `list`), server startup |
|
||||
| [`src/lib.rs`](src/lib.rs:1) | Crate root, module declarations, `VERSION` |
|
||||
| [`src/tui.rs`](src/tui.rs:1) | ratatui TUI: seed-entry popup, main screen, add-role popup, help overlay |
|
||||
| [`src/server.rs`](src/server.rs:1) | Multi-transport server with poll loop, caller identity, request framing |
|
||||
| [`src/dispatcher.rs`](src/dispatcher.rs:1) | Verb dispatch and JSON-RPC response construction |
|
||||
| [`src/role_table.rs`](src/role_table.rs:1) | Role registry, path-template parsing, purpose/curve enforcement |
|
||||
| [`src/selector.rs`](src/selector.rs:1) | Role selector resolution (`role` + `role_path`) |
|
||||
| [`src/enforcement.rs`](src/enforcement.rs:1) | Verb/algorithm/purpose/curve enforcement matrix |
|
||||
| [`src/key_store.rs`](src/key_store.rs:1) | BIP-32 / SLIP-0010 key derivation and storage |
|
||||
| [`src/mnemonic.rs`](src/mnemonic.rs:1) | BIP-39 mnemonic loading and seed derivation |
|
||||
| [`src/pq_crypto.rs`](src/pq_crypto.rs:1) | Post-quantum keygen (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) |
|
||||
| [`src/pq_drbg.rs`](src/pq_drbg.rs:1) | SHAKE-256 DRBG (retained port; not used for PQ keygen — see the v2 seeded derivation) |
|
||||
| [`src/alg_cache.rs`](src/alg_cache.rs:1) | Per-algorithm derived-key cache |
|
||||
| [`src/otp_pad.rs`](src/otp_pad.rs:1) | One-time pad binding, offset tracking, encrypt/decrypt |
|
||||
| [`src/miner.rs`](src/miner.rs:1) | NIP-13 proof-of-work mining for `nostr_mine_event` |
|
||||
| [`src/auth_envelope.rs`](src/auth_envelope.rs:1) | Auth envelope verification and nonce cache |
|
||||
| [`src/transport.rs`](src/transport.rs:1) | Length-prefixed framing, abstract Unix socket connect |
|
||||
| [`src/http.rs`](src/http.rs:1) | HTTP listener transport |
|
||||
| [`src/socket_name.rs`](src/socket_name.rs:1) | Random socket-name generation and discovery |
|
||||
| [`src/secure_mem.rs`](src/secure_mem.rs:1) | `mlock` / `zeroize` helpers |
|
||||
| [`src/error.rs`](src/error.rs:1) | `signerError` enum |
|
||||
| [`ratatui/`](ratatui:1) | Vendored ratatui submodule (TUI framework) |
|
||||
| [`plans/`](plans:1) | Design and migration plans |
|
||||
|
||||
## 10. Differences from the C `n_signer`
|
||||
|
||||
This Rust port preserves the security model and API of the original C `n_signer` while differing in implementation details:
|
||||
|
||||
- **Language**: Rust instead of C. Memory safety is enforced by the type system; sensitive buffers use [`zeroize`](https://crates.io/crates/zeroize) instead of hand-rolled `secure_memzero`.
|
||||
- **PQ crypto**: pure-Rust crates (`ml-dsa`, `ml-kem`, `slh-dsa`) instead of vendored PQClean C.
|
||||
- **TUI**: [`ratatui`](https://github.com/ratatui/ratatui) instead of the vendored `tui_continuous` C component. The TUI flow is redesigned (see [`plans/tui_flow_redesign.md`](plans/tui_flow_redesign.md:1)): a single main screen with a startup seed-entry popup, an add-role popup, and a scrollable Help overlay, replacing the original linear setup wizard.
|
||||
- **Approval prompts**: the C version's interactive approval prompts (`y/n/e/a`) are not yet ported; the Rust port uses the role-name-as-password gate as the primary access control. Interactive approval is a future addition.
|
||||
- **No firmware targets**: the C project also targets ESP32 / MCU hardware via TinyUSB. This Rust port targets Linux desktop and Qubes OS only.
|
||||
- **No `--preapprove` flag**: pre-approval entries are not yet implemented in the Rust port.
|
||||
- **No `--allow-all` flag**: the Rust port does not carry the C project's `--allow-all` development shortcut; access control is the role-name-as-password gate plus caller identity.
|
||||
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# signer (Rust) — Local Deploy Script
|
||||
#
|
||||
# Builds release binaries and installs them to /usr/local/bin/.
|
||||
#
|
||||
# USAGE:
|
||||
# ./deploy_local.sh # build release + install
|
||||
# ./deploy_local.sh --debug # build debug + install
|
||||
# ./deploy_local.sh -h, --help
|
||||
#
|
||||
# Installs:
|
||||
# /usr/local/bin/signer (the signing daemon)
|
||||
# /usr/local/bin/signer-client (the CLI client)
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
|
||||
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
|
||||
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
|
||||
PROFILE="release"
|
||||
CARGO_FLAG="--release"
|
||||
TARGET_DIR="target/release"
|
||||
|
||||
show_usage() {
|
||||
echo "signer (Rust) Local Deploy Script"
|
||||
echo ""
|
||||
echo "USAGE:"
|
||||
echo " $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "OPTIONS:"
|
||||
echo " --debug Build debug profile instead of release"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Installs to /usr/local/bin/:"
|
||||
echo " signer (the signing daemon)"
|
||||
echo " signer-client (the CLI client)"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--debug)
|
||||
PROFILE="debug"
|
||||
CARGO_FLAG=""
|
||||
TARGET_DIR="target/debug"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown option: $1"
|
||||
show_usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check we're in the project root (Cargo.toml present)
|
||||
if [[ ! -f "Cargo.toml" ]]; then
|
||||
print_error "Cargo.toml not found. Run this script from the project root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check the binaries we expect are declared
|
||||
if ! grep -q 'name = "signer"' Cargo.toml || ! grep -q 'name = "signer-client"' Cargo.toml; then
|
||||
print_error "Expected binaries 'signer' and 'signer-client' not found in Cargo.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build
|
||||
print_status "Building ${PROFILE} binaries (cargo build ${CARGO_FLAG})..."
|
||||
cargo build ${CARGO_FLAG} 2>&1 | tail -5 || {
|
||||
print_error "Build failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
SIGNER_BIN="${TARGET_DIR}/signer"
|
||||
CLIENT_BIN="${TARGET_DIR}/signer-client"
|
||||
|
||||
for bin in "$SIGNER_BIN" "$CLIENT_BIN"; do
|
||||
if [[ ! -f "$bin" ]]; then
|
||||
print_error "Built binary not found: $bin"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
print_success "Binaries built: $SIGNER_BIN, $CLIENT_BIN"
|
||||
|
||||
# Install to /usr/local/bin
|
||||
DEST_DIR="/usr/local/bin"
|
||||
|
||||
if [[ ! -d "$DEST_DIR" ]]; then
|
||||
print_status "Creating $DEST_DIR..."
|
||||
sudo mkdir -p "$DEST_DIR"
|
||||
fi
|
||||
|
||||
install_binary() {
|
||||
local src="$1"
|
||||
local name
|
||||
name=$(basename "$src")
|
||||
print_status "Installing $name to $DEST_DIR/..."
|
||||
sudo install -m 0755 "$src" "$DEST_DIR/$name"
|
||||
print_success "Installed: $DEST_DIR/$name"
|
||||
}
|
||||
|
||||
install_binary "$SIGNER_BIN"
|
||||
install_binary "$CLIENT_BIN"
|
||||
|
||||
# Verify
|
||||
print_status "Verification:"
|
||||
"$DEST_DIR/signer" --version || true
|
||||
"$DEST_DIR/signer-client" --version || true
|
||||
|
||||
print_success "Local deploy completed (${PROFILE} profile)"
|
||||
+24
-6
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# nsigner (Rust) — Increment and Push Script
|
||||
# signer (Rust) — Increment and Push Script
|
||||
#
|
||||
# Increments the version (patch/minor/major), updates Cargo.toml and
|
||||
# src/lib.rs, commits, tags, and pushes. Optionally creates a release
|
||||
@@ -34,7 +34,7 @@ RELEASE_MODE=false
|
||||
VERSION_INCREMENT_TYPE="patch"
|
||||
|
||||
show_usage() {
|
||||
echo "nsigner (Rust) Increment and Push Script"
|
||||
echo "signer (Rust) Increment and Push Script"
|
||||
echo ""
|
||||
echo "USAGE:"
|
||||
echo " $0 [OPTIONS] \"commit message\""
|
||||
@@ -169,18 +169,23 @@ verify_binary_version() {
|
||||
}
|
||||
|
||||
build_release_binary() {
|
||||
print_status "Building release binary (cargo build --release)..."
|
||||
print_status "Building release binaries (cargo build --release)..."
|
||||
cargo build --release 2>&1 | tail -5 || return 1
|
||||
|
||||
local bin_path="target/release/nsigner"
|
||||
local bin_path="target/release/signer"
|
||||
verify_binary_version "$bin_path" "$NEW_VERSION" || return 1
|
||||
|
||||
local client_path="target/release/signer-client"
|
||||
if [[ -f "$client_path" ]]; then
|
||||
print_success "Release binary built: $client_path"
|
||||
fi
|
||||
|
||||
print_success "Release binary built: $bin_path"
|
||||
return 0
|
||||
}
|
||||
|
||||
create_source_tarball() {
|
||||
local tarball_name="nsigner-${NEW_VERSION#v}.tar.gz"
|
||||
local tarball_name="signer-${NEW_VERSION#v}.tar.gz"
|
||||
|
||||
if tar -czf "$tarball_name" \
|
||||
--exclude='target/*' \
|
||||
@@ -279,7 +284,8 @@ main() {
|
||||
|
||||
git_commit_and_push
|
||||
|
||||
local binary_path="target/release/nsigner"
|
||||
local binary_path="target/release/signer"
|
||||
local client_path="target/release/signer-client"
|
||||
local tarball_path=""
|
||||
tarball_path=$(create_source_tarball || true)
|
||||
|
||||
@@ -288,6 +294,18 @@ main() {
|
||||
|
||||
if [[ -n "$release_id" ]]; then
|
||||
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
|
||||
# Also upload the signer-client binary if it exists
|
||||
if [[ -f "$client_path" ]]; then
|
||||
local token
|
||||
token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
|
||||
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/signer"
|
||||
local assets_url="$api_url/releases/$release_id/assets"
|
||||
print_status "Uploading signer-client..."
|
||||
curl -s -X POST "$assets_url" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$client_path;filename=signer-client" \
|
||||
-F "name=signer-client" > /dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
print_success "Release flow completed: $NEW_VERSION"
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# User-only installer for Qubes AppVM persistence model.
|
||||
# Nothing is written to /usr, /etc, or other root-owned paths.
|
||||
#
|
||||
# Installs into $HOME:
|
||||
# - signer -> ~/.local/bin/signer
|
||||
# - signer-client -> ~/.local/bin/signer-client
|
||||
# - startup helper -> ~/start_signer.sh
|
||||
#
|
||||
# Usage:
|
||||
# bash install_signer.sh
|
||||
# bash install_signer.sh --help
|
||||
#
|
||||
# Optional env vars:
|
||||
# SIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
|
||||
# SIGNER_GITEA_TOKEN=<token> # if signer release assets are private
|
||||
# SIGNER_BINARY_URL=<direct url to signer binary>
|
||||
# SIGNER_CLIENT_BINARY_URL=<direct url to signer-client binary>
|
||||
|
||||
SIGNER_VERSION="${SIGNER_VERSION:-}"
|
||||
|
||||
PREFIX_BIN="${HOME}/.local/bin"
|
||||
|
||||
log() { printf "\033[1;34m[INFO]\033[0m %s\n" "$*"; }
|
||||
warn() { printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
|
||||
err() { printf "\033[1;31m[ERR ]\033[0m %s\n" "$*"; }
|
||||
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
Usage: bash install_signer.sh [options]
|
||||
|
||||
User-only install (Qubes AppVM friendly):
|
||||
- signer ${SIGNER_VERSION:-(latest)}
|
||||
- signer-client ${SIGNER_VERSION:-(latest)}
|
||||
- signer startup helper script
|
||||
|
||||
Options:
|
||||
-h, --help Show this help and exit
|
||||
|
||||
Optional env vars:
|
||||
SIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
|
||||
SIGNER_GITEA_TOKEN=<token> # required if signer release assets are private
|
||||
SIGNER_BINARY_URL=<direct url to signer binary>
|
||||
SIGNER_CLIENT_BINARY_URL=<direct url to signer-client binary>
|
||||
|
||||
Install paths:
|
||||
~/.local/bin/signer
|
||||
~/.local/bin/signer-client
|
||||
~/start_signer.sh
|
||||
EOF
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
err "Missing command: $1"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
install_runtime_deps() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
log "Installing runtime dependencies via apt"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ca-certificates curl jq
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
log "Installing runtime dependencies via dnf"
|
||||
sudo dnf install -y ca-certificates curl jq
|
||||
else
|
||||
err "Unsupported distro: need apt-get or dnf to install runtime dependencies"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_dirs() {
|
||||
mkdir -p "${PREFIX_BIN}"
|
||||
}
|
||||
|
||||
resolve_signer_version() {
|
||||
local headers=()
|
||||
local latest_tag=""
|
||||
|
||||
if [[ -n "${SIGNER_VERSION}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
|
||||
headers=(-H "Authorization: token ${SIGNER_GITEA_TOKEN}")
|
||||
fi
|
||||
|
||||
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/signer/releases" \
|
||||
| jq -r '.[0].tag_name // empty' || true)"
|
||||
|
||||
if [[ -z "${latest_tag}" ]]; then
|
||||
err "Could not resolve latest signer release tag from API."
|
||||
err "Set SIGNER_VERSION explicitly (e.g. SIGNER_VERSION=v0.0.14)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIGNER_VERSION="${latest_tag}"
|
||||
}
|
||||
|
||||
# Resolve a release asset URL by asset name suffix.
|
||||
# $1 = asset name to match exactly (e.g. "signer" or "signer-client")
|
||||
download_signer_asset_url() {
|
||||
local asset_name="$1"
|
||||
local headers=()
|
||||
local api_tag_url="https://git.laantungir.net/api/v1/repos/laantungir/signer/releases/tags/${SIGNER_VERSION}"
|
||||
|
||||
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
|
||||
headers=(-H "Authorization: token ${SIGNER_GITEA_TOKEN}")
|
||||
fi
|
||||
|
||||
curl -fsSL "${headers[@]}" "${api_tag_url}" \
|
||||
| jq -r '.assets[]?.browser_download_url // empty' \
|
||||
| grep -E "/${asset_name}$" \
|
||||
| head -n1 || true
|
||||
}
|
||||
|
||||
verify_installed_version() {
|
||||
local expected="$1"
|
||||
local bin_path="$2"
|
||||
local got_line=""
|
||||
local got_ver=""
|
||||
|
||||
if [[ ! -x "${bin_path}" ]]; then
|
||||
err "Installed binary missing: ${bin_path}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
got_line="$("${bin_path}" --version 2>/dev/null || true)"
|
||||
got_ver="$(printf '%s\n' "${got_line}" | awk '{print $2}')"
|
||||
|
||||
if [[ -z "${got_ver}" ]]; then
|
||||
err "Could not determine installed signer version from: ${got_line}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${got_ver}" != "${expected}" ]]; then
|
||||
err "Downloaded binary version mismatch: expected ${expected}, got ${got_ver}"
|
||||
err "Release asset appears stale or mislabeled."
|
||||
err "Use SIGNER_BINARY_URL to pin a known-good binary, or wait for a rebuilt release artifact."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Download a single binary asset.
|
||||
# $1 = asset name (for URL resolution fallback)
|
||||
# $2 = override URL env var name (e.g. SIGNER_BINARY_URL)
|
||||
# $3 = output path
|
||||
download_binary() {
|
||||
local asset_name="$1"
|
||||
local override_env="$2"
|
||||
local out_path="$3"
|
||||
local asset_url=""
|
||||
|
||||
# Resolve override from env var
|
||||
eval "asset_url=\"\${${override_env}:-}\""
|
||||
|
||||
if [[ -z "${asset_url}" ]]; then
|
||||
asset_url="$(download_signer_asset_url "${asset_name}")"
|
||||
fi
|
||||
|
||||
if [[ -z "${asset_url}" ]]; then
|
||||
err "Could not find downloadable ${asset_name} release binary for ${SIGNER_VERSION}."
|
||||
err "Provide ${override_env} or SIGNER_GITEA_TOKEN so the release asset can be resolved."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Using ${asset_name} binary URL: ${asset_url}"
|
||||
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
|
||||
curl -fL -H "Authorization: token ${SIGNER_GITEA_TOKEN}" -o "${out_path}" "${asset_url}"
|
||||
else
|
||||
curl -fL -o "${out_path}" "${asset_url}"
|
||||
fi
|
||||
chmod 0755 "${out_path}"
|
||||
}
|
||||
|
||||
install_signer() {
|
||||
local release_page=""
|
||||
|
||||
resolve_signer_version
|
||||
release_page="https://git.laantungir.net/laantungir/signer/releases/tag/${SIGNER_VERSION}"
|
||||
|
||||
log "Installing signer ${SIGNER_VERSION}"
|
||||
log "Release page: ${release_page}"
|
||||
|
||||
# Install the signer daemon binary
|
||||
download_binary "signer" "SIGNER_BINARY_URL" "${PREFIX_BIN}/signer"
|
||||
verify_installed_version "${SIGNER_VERSION}" "${PREFIX_BIN}/signer"
|
||||
log "Installed ${PREFIX_BIN}/signer from release binary"
|
||||
|
||||
# Install the signer-client CLI binary (best-effort: older releases may not have it)
|
||||
if [[ -z "${SIGNER_CLIENT_BINARY_URL:-}" ]] && ! download_signer_asset_url "signer-client" >/dev/null 2>&1; then
|
||||
warn "No signer-client asset found for ${SIGNER_VERSION}; skipping client install."
|
||||
else
|
||||
download_binary "signer-client" "SIGNER_CLIENT_BINARY_URL" "${PREFIX_BIN}/signer-client"
|
||||
log "Installed ${PREFIX_BIN}/signer-client from release binary"
|
||||
fi
|
||||
}
|
||||
|
||||
write_signer_start_script() {
|
||||
local script_path="${HOME}/start_signer.sh"
|
||||
|
||||
cat >"${script_path}" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
LISTEN_TARGET="${SIGNER_LISTEN_TARGET:-tcp:[::]:8080}"
|
||||
|
||||
echo "=== signer startup ==="
|
||||
echo "listen target: ${LISTEN_TARGET}"
|
||||
|
||||
# Optional: print current FIPS identity info if fipsctl is available.
|
||||
if command -v fipsctl >/dev/null 2>&1; then
|
||||
if fipsctl show status >/dev/null 2>&1; then
|
||||
STATUS_JSON="$(fipsctl show status)"
|
||||
elif sudo -n fipsctl show status >/dev/null 2>&1; then
|
||||
STATUS_JSON="$(sudo -n fipsctl show status)"
|
||||
else
|
||||
STATUS_JSON=""
|
||||
fi
|
||||
|
||||
if [[ -n "${STATUS_JSON}" ]]; then
|
||||
FIPS_IPV6="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"ipv6_addr": "\([^"]*\)".*/\1/p')"
|
||||
FIPS_NPUB="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"npub": "\([^"]*\)".*/\1/p')"
|
||||
LISTEN_PORT="$(printf '%s\n' "${LISTEN_TARGET}" | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p')"
|
||||
[[ -n "${FIPS_IPV6}" ]] && echo "fips ipv6: ${FIPS_IPV6}"
|
||||
[[ -n "${FIPS_NPUB}" ]] && echo "fips npub: ${FIPS_NPUB}"
|
||||
if [[ -n "${FIPS_NPUB}" && -n "${LISTEN_PORT}" ]]; then
|
||||
echo "fips address: http://${FIPS_NPUB}.fips:${LISTEN_PORT}"
|
||||
fi
|
||||
else
|
||||
echo "fips status: unavailable (run as user in fips group or with sudo)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Starting signer..."
|
||||
echo "On first remote request, approve in prompt with [y] or [a]."
|
||||
exec "$HOME/.local/bin/signer" --listen "${LISTEN_TARGET}"
|
||||
EOF
|
||||
|
||||
chmod 0755 "${script_path}"
|
||||
log "Wrote ${script_path}"
|
||||
}
|
||||
|
||||
post_checks() {
|
||||
export PATH="${PREFIX_BIN}:${PATH}"
|
||||
|
||||
log "Running post-install checks"
|
||||
require_cmd signer
|
||||
signer --version || true
|
||||
|
||||
if [[ -x "${PREFIX_BIN}/signer-client" ]]; then
|
||||
signer-client --version || true
|
||||
fi
|
||||
|
||||
log "User binaries installed in: ${PREFIX_BIN}"
|
||||
log "If needed, add to shell PATH: export PATH=\"${PREFIX_BIN}:\$PATH\""
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
err "Unknown option: $1"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install_runtime_deps
|
||||
prepare_dirs
|
||||
install_signer
|
||||
write_signer_start_script
|
||||
post_checks
|
||||
|
||||
log "Completed user-only install of signer"
|
||||
log "Start signer with: ~/start_signer.sh"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,7 +1,7 @@
|
||||
# Menu Gap Analysis: C `main.c` vs Rust `signer`
|
||||
|
||||
**Source of truth:** the C code in [`src/main.c`](../n_signer/src/main.c:1), NOT
|
||||
[`documents/nsigner_menus.md`](../n_signer/documents/nsigner_menus.md:1) (which is
|
||||
[`documents/signer_menus.md`](../n_signer/documents/signer_menus.md:1) (which is
|
||||
stale — e.g. it claims the wizard prompts `Require interactive approval? [Y/n]`, but
|
||||
the actual C code hardcodes `requires_approval = 0` and never prompts).
|
||||
|
||||
@@ -31,7 +31,7 @@ Legend: ✅ matches, ⚠️ partial, ❌ missing/divergent.
|
||||
- Success: `Seed phrase is valid and accepted.`
|
||||
|
||||
**Rust** ([`load_mnemonic_tui`](../signer/src/main.rs:549)):
|
||||
- Frame: `nsigner v<ver> > Unlock`, title `"Enter mnemonic phrase"`
|
||||
- Frame: `signer v<ver> > Unlock`, title `"Enter mnemonic phrase"`
|
||||
- Prompt: `Enter your BIP-39 mnemonic phrase, or 'g' to generate a new one.`
|
||||
- `g`/`G` → generate, numbered, warning ✅
|
||||
- Otherwise → load as mnemonic (paste works implicitly) ⚠️
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Problem
|
||||
|
||||
The Rust `nsigner` library modules are complete and tested (92 tests pass), but the server loop in [`server.rs`](src/server.rs:117) accepts connections, reads requests, dispatches them, and sends responses **without any policy enforcement**. The `policy: &mut PolicyTable` parameter is accepted but never used. This means the daemon would sign anything for anyone without prompting — it is not an "attended signer."
|
||||
The Rust `signer` library modules are complete and tested (92 tests pass), but the server loop in [`server.rs`](src/server.rs:117) accepts connections, reads requests, dispatches them, and sends responses **without any policy enforcement**. The `policy: &mut PolicyTable` parameter is accepted but never used. This means the daemon would sign anything for anyone without prompting — it is not an "attended signer."
|
||||
|
||||
## What the C version does (server.c)
|
||||
|
||||
@@ -148,10 +148,10 @@ Add methods to insert session grants:
|
||||
```rust
|
||||
impl PolicyTable {
|
||||
/// Insert a session grant for caller+role+verb.
|
||||
pub fn insert_session_grant(&mut self, caller: &str, verb: &str, role: &str) -> Result<(), NsignerError>;
|
||||
pub fn insert_session_grant(&mut self, caller: &str, verb: &str, role: &str) -> Result<(), SignerError>;
|
||||
|
||||
/// Insert a session grant for caller+role (all verbs).
|
||||
pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), NsignerError>;
|
||||
pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), SignerError>;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ graph TB
|
||||
|
||||
### 4. Error Handling
|
||||
- **C**: Integer error codes + string messages
|
||||
- **Rust**: `thiserror`-based `NsignerError` enum. The JSON-RPC error codes are preserved exactly for wire compatibility.
|
||||
- **Rust**: `thiserror`-based `SignerError` enum. The JSON-RPC error codes are preserved exactly for wire compatibility.
|
||||
|
||||
### 5. Transport
|
||||
- **C**: Raw syscalls (`socket`, `bind`, `accept`, `SO_PEERCRED`)
|
||||
@@ -323,7 +323,7 @@ signer/
|
||||
│ ├── otp_pad.rs # One-time pad encryption
|
||||
│ ├── socket_name.rs # Abstract socket naming
|
||||
│ ├── tui.rs # Terminal UI
|
||||
│ └── error.rs # NsignerError enum
|
||||
│ └── error.rs # SignerError enum
|
||||
├── tests/
|
||||
│ ├── integration_test.rs
|
||||
│ ├── algorithm_test.rs
|
||||
@@ -337,7 +337,7 @@ signer/
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "nsigner"
|
||||
name = "signer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
@@ -372,7 +372,7 @@ tempfile = "3"
|
||||
3. **Derivation paths**: BIP-32/SLIP-0010 paths must produce identical keys from the same mnemonic
|
||||
4. **Socket protocol**: Length-prefixed framing (4-byte BE) must be compatible
|
||||
5. **HTTP**: Same minimal HTTP/1.1 POST-only parser behavior
|
||||
6. **Abstract socket names**: `@nsigner_<word1>_<word2>` format preserved
|
||||
6. **Abstract socket names**: `@signer_<word1>_<word2>` format preserved
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# PQ Seeded Derivation Migration Plan (signer)
|
||||
|
||||
## Status
|
||||
|
||||
**Implemented** (signer). Companion to the v2 hardened-derivation design in
|
||||
`nostr_quantum_preparation/plans/v2-hardened-derivation.md`. That project is the
|
||||
only one with users; it keeps a v1→v2 migration path. signer (and n_signer)
|
||||
have no users, so we are free to align to the FIPS seeded interface directly.
|
||||
|
||||
Implementation notes (divergences from the original proposal, all consistent
|
||||
with its intent):
|
||||
|
||||
- `derive_pq_seed(mnemonic, coin, indices)` was realized as
|
||||
`derive_pq_seed_from_path(mnemonic, path, seed_len)` — the sibling child is
|
||||
derived by incrementing the last path level, so callers pass a single path.
|
||||
- PQ private keys are stored in seed form (ML-DSA-65 32 B, ML-KEM-768 64 B,
|
||||
SLH-DSA-128s 64 B sk serialization); `CryptoAlg::sizes()` reflects this.
|
||||
- SLH-DSA-128s uses the SHA2 parameter set (`slh_dsa::Sha2_128s`), matching
|
||||
the web app's `slh_dsa_sha2_128s`.
|
||||
- The v2 conformance test (`tests/pq_conformance.rs`) reproduces
|
||||
`seed-to-pubkeys.v2.json` exactly for all three algorithms.
|
||||
- The dispatcher's `encapsulate`/`decapsulate` verbs and the sign-verb
|
||||
private-key truncation bug were fixed as part of this work.
|
||||
- n_signer (C) still needs the same migration — filed separately there.
|
||||
|
||||
## Background
|
||||
|
||||
signer is the Rust port of n_signer and inherited two PQ derivation choices:
|
||||
|
||||
1. **SHAKE-256 DRBG pipeline** ([`src/pq_drbg.rs`](../src/pq_drbg.rs)): the
|
||||
BIP-44-derived 32-byte seed feeds a SHAKE-256 DRBG that stands in for
|
||||
PQClean's `randombytes()` callback during keygen. This was an API artifact
|
||||
of the C PQClean integration, not a cryptographic choice.
|
||||
2. **SLIP-0010 derivation for PQ paths**
|
||||
([`src/pq_crypto.rs:90`](../src/pq_crypto.rs)): `derive_seed_from_mnemonic()`
|
||||
branches on the path prefix — BIP-32 for `m/44'/1237'`, SLIP-0010 for
|
||||
everything else (ed25519, x25519, PQ).
|
||||
|
||||
The nostr_quantum_preparation web app (the project with actual users) has
|
||||
standardized v2 on:
|
||||
|
||||
- **Per-algorithm coin types** in the unregistered SLIP-44 `102XXX'` range
|
||||
(102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768 — matching
|
||||
signer's existing allocations — plus new 102006' ML-DSA-44 and 102007'
|
||||
Falcon-512).
|
||||
- **FIPS seeded keygen**: BIP32 child bytes (exact length: 32 B ML-DSA,
|
||||
48 B SLH-DSA-128s, 64 B ML-KEM) → `keygen(seed)`. No DRBG.
|
||||
- **BIP32 (not SLIP-0010)** for the PQ paths, via `@scure/bip32` `HDKey`.
|
||||
|
||||
## Problem
|
||||
|
||||
Two divergences prevent cross-project key parity (same mnemonic + same path →
|
||||
same PQ keys):
|
||||
|
||||
| Divergence | signer today | nostr_quantum_preparation v2 |
|
||||
|---|---|---|
|
||||
| Seed expansion | SHAKE-256 DRBG → RNG-fed keygen | exact-length seed → seeded keygen |
|
||||
| Derivation function for PQ paths | SLIP-0010 | BIP32 |
|
||||
|
||||
SLIP-0010 and BIP32 produce different master keys (different HMAC keys) and
|
||||
different child derivation, so even identical paths yield unrelated seeds.
|
||||
The DRBG pipeline additionally means signer can never reproduce FIPS-seeded
|
||||
keys regardless of derivation function.
|
||||
|
||||
Neither divergence is a security weakness — both are deterministic expansions
|
||||
of secret material — but parity matters operationally: a user should be able
|
||||
to derive the same PQ identity in the web app and on signer hardware from one
|
||||
mnemonic.
|
||||
|
||||
## Solution
|
||||
|
||||
Migrate signer's PQ keygen to the FIPS seeded interface and PQ path derivation
|
||||
to BIP32, keeping ed25519/x25519 on SLIP-0010 (correct for those curves).
|
||||
|
||||
### Target pipeline
|
||||
|
||||
```
|
||||
mnemonic → BIP39 seed → BIP32 master → m/44'/<coin>'/0'/0'/<n>' → child bytes
|
||||
→ concatenate/truncate to algorithm seed length → seeded keygen
|
||||
```
|
||||
|
||||
| Algorithm | Coin type | Path | Seed len | RustCrypto API |
|
||||
|---|---|---|---|---|
|
||||
| ML-DSA-65 | `102003'` | `m/44'/102003'/0'/0'/0'` | 32 B | `ml_dsa::SigningKey::from_seed(&[u8; 32])` |
|
||||
| SLH-DSA-128s | `102004'` | `m/44'/102004'/0'/0'/0'` + `/1'` | 48 B | `slh_dsa` seeded keygen (verify exact API at rc version in use) |
|
||||
| ML-KEM-768 | `102005'` | `m/44'/102005'/0'/0'/0'` + `/1'` | 64 B | `ml_kem::DecapsulationKey::from_seed(&[u8; 64])` |
|
||||
| ML-DSA-44 | `102006'` | `m/44'/102006'/0'/0'/0'` | 32 B | future — add with `ml-dsa` crate |
|
||||
| Falcon-512 | `102007'` | `m/44'/102007'/0'/0'/0'` + `/1'` | 48 B | future — no stable RustCrypto crate; rejection sampling makes cross-library determinism impossible anyway |
|
||||
|
||||
48/64-byte seeds come from two hardened children concatenated (first 48 of 64
|
||||
used where 48 B is required) — matching the web app's construction exactly.
|
||||
|
||||
### Falcon caveat
|
||||
|
||||
Falcon keygen is rejection-sampling-based with no universally implemented seed
|
||||
interface. Even with identical seeds, implementations disagree. The web app
|
||||
pins noble's behavior in test vectors and flags Falcon as per-library in its
|
||||
NIP proposal; signer should do the same when Falcon support lands, and should
|
||||
not promise parity for it.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `src/pq_crypto.rs`
|
||||
|
||||
- `derive_seed_from_mnemonic()`: route PQ coin types (102003'–102007') through
|
||||
BIP-32 (same branch as `m/44'/1237'`), keeping SLIP-0010 only for
|
||||
ed25519/x25519 (102001'/102002').
|
||||
- Add `derive_pq_seed(mnemonic, coin_type, indices) -> Vec<u8>` implementing
|
||||
the concatenate/truncate-to-length construction.
|
||||
- Replace DRBG-fed keygen call sites with the seeded APIs above.
|
||||
|
||||
### 2. `src/pq_drbg.rs`
|
||||
|
||||
- Keep the module (it is a faithful port and may serve future PQClean-style
|
||||
integrations) but remove it from the PQ keygen path. Mark as not-used-for-
|
||||
derivation in the module doc.
|
||||
|
||||
### 3. `src/alg_cache.rs`, `src/role_table.rs`, `src/tui.rs`, `src/main.rs`
|
||||
|
||||
- No path changes needed for 102003'–102005' (already correct).
|
||||
- Add `MlDsa44` (`102006'`) to `CryptoAlg`, path formatting, purpose mapping
|
||||
(`PqSig`), and TUI presets when the `ml-dsa` crate's ML-DSA-44 variant is
|
||||
wired in. Falcon (`102007'`) waits on a viable crate.
|
||||
|
||||
### 4. Tests
|
||||
|
||||
- Cross-implementation conformance: reproduce the web app's
|
||||
`seed-to-pubkeys.v2.json` vector (same fixed mnemonic) for ML-DSA-65,
|
||||
SLH-DSA-128s, and ML-KEM-768. This is the acceptance test for parity.
|
||||
- Regression: DRBG removal does not change ed25519/x25519 derivation.
|
||||
- Unit: 48/64-byte seed construction matches the two-children concatenation.
|
||||
|
||||
### 5. Docs
|
||||
|
||||
- `README.md` / `documents/` equivalent: document the seeded pipeline, the
|
||||
BIP32-for-PQ decision, the coin-type registry (102003'–102005' existing,
|
||||
102006'–102007' reserved), and the Falcon caveat.
|
||||
- Note for n_signer (C): same migration applies; file it there separately.
|
||||
|
||||
## What we are explicitly NOT doing
|
||||
|
||||
- Not changing secp256k1 NIP-06 derivation (`m/44'/1237'/n'/0/0`, BIP-32).
|
||||
- Not changing ed25519/x25519 SLIP-0010 derivation (correct for those curves).
|
||||
- Not preserving DRBG-derived PQ keys (no users; clean break is the point).
|
||||
- Not implementing Falcon now (no stable crate; determinism caveat).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. `cargo test` passes with the seeded pipeline.
|
||||
2. The web app's v2 vector reproduces exactly for ML-DSA-65, SLH-DSA-128s,
|
||||
ML-KEM-768 (same mnemonic → same pubkeys in Rust and JS).
|
||||
3. ed25519/x25519 pubkeys unchanged from pre-migration for the same mnemonic.
|
||||
@@ -33,8 +33,8 @@ full width at the bottom.
|
||||
├──────────────────────────────────────┬───────────────────────────────┤
|
||||
│ Information │ Activity (latest first) ▲ │
|
||||
│ session=unlocked (12 words) │ 2026-08-18 08:00:15 req… │
|
||||
│ signer=nsigner01 derived=2 │ 2026-08-18 08:00:01 start │
|
||||
│ socket=@nsigner01 transport=unix │ │
|
||||
│ signer=signer01 derived=2 │ 2026-08-18 08:00:01 start │
|
||||
│ socket=@signer01 transport=unix │ │
|
||||
│ OTP pad: chksum=abc… offset=128 │ │
|
||||
├──────────────────────────────────────┤ │
|
||||
│ Roles │ │
|
||||
@@ -273,7 +273,7 @@ Each setup screen has its own `draw` function and event handler. The
|
||||
`tui_continuous.rs` entirely.
|
||||
9. **Test** — `cargo test` (unit tests don't touch the TUI). Manual test:
|
||||
start signer, verify setup screens work with InputField, verify 4-section
|
||||
main screen renders, press `d`/`l`/`r`/`q`, connect with `nsigner_client`.
|
||||
main screen renders, press `d`/`l`/`r`/`q`, connect with `signer_client`.
|
||||
|
||||
## What stays the same
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# Plan: `signer-client` — Rust CLI for the signer daemon
|
||||
|
||||
## Goal
|
||||
|
||||
A standalone Rust command-line client `signer-client` that connects to a running
|
||||
`signer` process over its framed transports (Unix abstract socket, TCP, serial,
|
||||
qrexec) and exposes the full JSON-RPC verb surface over stdin/stdout so that
|
||||
signed events can be piped directly into `nak publish`.
|
||||
|
||||
This is a **Rust port** of the C [`n_signer_client.c`](../n_signer/client/n_signer_client.c:1)
|
||||
(~855 lines). It reuses the existing `signer` library crate for transport
|
||||
framing, socket discovery, and verb/error constants — the new code is the
|
||||
typed-verb client layer + CLI parsing + non-Unix transports.
|
||||
|
||||
## Deliverable & placement
|
||||
|
||||
- New binary target `signer-client` declared in [`Cargo.toml`](../Cargo.toml:1):
|
||||
```toml
|
||||
[[bin]]
|
||||
name = "signer-client"
|
||||
path = "src/client/main.rs"
|
||||
```
|
||||
- New module tree under `src/client/`:
|
||||
- [`src/client/main.rs`](src/client/main.rs:1) — entry point, CLI parse, dispatch
|
||||
- [`src/client/cli.rs`](src/client/cli.rs:1) — clap `Cli` / `Verb` structs + usage text
|
||||
- [`src/client/transport.rs`](src/client/transport.rs:1) — `ClientTransport` enum (Unix/Tcp/Serial/Qrexec) + open/connect helpers
|
||||
- [`src/client/rpc.rs`](src/client/rpc.rs:1) — low-level `SignerClient` (send framed JSON-RPC, recv, parse result/error)
|
||||
- [`src/client/signer.rs`](src/client/signer.rs:1) — high-level `SignerSigner` typed-verb wrappers (mirrors C `nostr_signer_t`)
|
||||
- [`src/client/auth.rs`](src/client/auth.rs:1) — client-side auth envelope builder for TCP/qrexec
|
||||
- New doc: [`src/client/README.md`](src/client/README.md:1) — usage, verbs, pipe-to-nak recipes (port of `n_signer_client_README.md`).
|
||||
- The existing `client` subcommand in [`src/main.rs`](src/main.rs:276) stays as a thin raw-passthrough convenience; it is **not** removed.
|
||||
|
||||
## What already exists (reuse, don't re-port)
|
||||
|
||||
| Concern | Existing Rust module | Reuse |
|
||||
|---|---|---|
|
||||
| 4-byte BE framing | [`transport.rs`](src/transport.rs:11) `send_framed` / `recv_framed` | yes, generic over `Read`/`Write` |
|
||||
| Abstract Unix connect | [`transport.rs`](src/transport.rs:36) `connect_abstract_unix` | yes |
|
||||
| Socket list / discover | [`socket_name.rs`](src/socket_name.rs:45) `list_sockets` / `discover_single_socket` | yes |
|
||||
| Verb constants | [`enforcement.rs`](src/enforcement.rs:22) `VERB_*` | yes (import, don't redeclare) |
|
||||
| RPC error codes | [`error.rs`](src/error.rs:57) `RpcError` constants | yes (for interpreting server errors) |
|
||||
| Auth envelope verify (server) | [`auth_envelope.rs`](src/auth_envelope.rs:1) | reference only — client needs the **build** side |
|
||||
|
||||
## What is new (the port)
|
||||
|
||||
1. **CLI parsing** — clap `derive` structs mirroring the C `argv` loop in
|
||||
[`n_signer_client.c`](../n_signer/client/n_signer_client.c:326): global
|
||||
options, selector options, algorithm options, mine-event options, and a
|
||||
`Verb` enum.
|
||||
2. **`ClientTransport`** — enum wrapping the four connection types behind a
|
||||
unified `send`/`recv` interface (the C `signer_transport_t` vtable).
|
||||
- Unix: `connect_abstract_unix` (already in crate).
|
||||
- TCP: `std::net::TcpStream` + framed I/O (server already speaks framed
|
||||
JSON over TCP per [`server.rs`](src/server.rs:108)).
|
||||
- Serial: `std::fs::OpenOptions` on `/dev/ttyACM*` + framed I/O over the
|
||||
file handle (matches C `signer_transport_open_serial`).
|
||||
- Qrexec: spawn `qrexec-client-vm <qube> <service>` via `std::process`,
|
||||
pipe framed JSON over its stdin/stdout (matches C
|
||||
`signer_transport_open_qrexec`).
|
||||
3. **`SignerClient`** — low-level RPC caller: builds `{"id","method","params"}`
|
||||
JSON, sends framed, receives framed, splits `result` vs `error`, holds
|
||||
`last_error`. Mirrors C `signer_client_t` / `signer_client_call`.
|
||||
4. **`SignerSigner`** — high-level typed-verb layer. Holds a `SignerClient`
|
||||
plus the resolved selector (`role` + `role_path`) and auth state. One
|
||||
method per verb, each building the correct `params` array + options object
|
||||
and parsing the typed result. Mirrors C `nostr_signer_t` /
|
||||
`nostr_signer_signer_from_client`.
|
||||
5. **Client-side auth envelope builder** — for TCP/qrexec: construct a
|
||||
NIP-42 kind-22242 auth event from the `--auth-privkey`, sign it, and
|
||||
prepend it to the request frame. The server-side verifier in
|
||||
[`auth_envelope.rs`](src/auth_envelope.rs:1) defines the wire shape; the
|
||||
builder produces the matching shape.
|
||||
6. **stdin/stdout contract** — per-verb payload sourcing (argv-or-stdin) and
|
||||
single-line newline-terminated output, exactly as in the C client.
|
||||
|
||||
## CLI shape
|
||||
|
||||
```
|
||||
signer-client [global options] <verb> [verb args...]
|
||||
```
|
||||
|
||||
### Global options
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
|
||||
| `--timeout <ms>` | `5000` | Transport timeout |
|
||||
| `--tcp <host:port>` | none | TCP transport (requires `--auth-privkey`) |
|
||||
| `--serial <device>` | none | USB CDC-ACM serial transport |
|
||||
| `--qrexec <qube:service>` | none | Qubes qrexec transport |
|
||||
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
|
||||
| `--auth-label <text>` | none | Auth envelope label |
|
||||
|
||||
### Selector options (nostr verbs)
|
||||
|
||||
| Flag | Meaning | JSON emitted |
|
||||
|---|---|---|
|
||||
| `--role <name>` | Named path-role | `{"role":"<name>"}` |
|
||||
| `--path <path>` | Full BIP-44 derivation path | `{"role_path":"<path>"}` |
|
||||
|
||||
### Algorithm options (algorithm verbs)
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--algorithm`, `-a <alg>` | none | secp256k1/ed25519/x25519/ml-dsa-65/slh-dsa-128s/ml-kem-768/otp |
|
||||
| `--index <N>` | `0` | Algorithm derivation index |
|
||||
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 sign/verify only |
|
||||
| `--encoding <ascii\|binary>` | `ascii` | OTP encrypt/decrypt only |
|
||||
| `--format <plain\|structured>` | `plain` | get-public-key output shape |
|
||||
|
||||
### Mine-event options
|
||||
|
||||
| Flag | Meaning |
|
||||
|---|---|
|
||||
| `--difficulty <N>` | Target leading zero bits |
|
||||
| `--threads <N>` | Mining threads (default 1) |
|
||||
| `--timeout-sec <N>` | Mining timeout in seconds |
|
||||
|
||||
## Verb surface (full)
|
||||
|
||||
Mirrors [`n_signer_client.c`](../n_signer/client/n_signer_client.c:522) dispatch
|
||||
and the verb table in [`enforcement.rs`](src/enforcement.rs:22).
|
||||
|
||||
### Utility
|
||||
|
||||
| Verb | stdout |
|
||||
|---|---|
|
||||
| `list` | Running signer abstract sockets (one per line) |
|
||||
|
||||
### Metadata
|
||||
|
||||
| Verb | RPC method | stdout |
|
||||
|---|---|---|
|
||||
| `get-info` | `get_info` | raw result JSON |
|
||||
|
||||
### Nostr verbs (role-based; require `--role` + `--path`)
|
||||
|
||||
| Verb | RPC method | stdin/argv | stdout |
|
||||
|---|---|---|---|
|
||||
| `get-public-key` | `nostr_get_public_key` | none | pubkey hex (or structured JSON with `--format structured`) |
|
||||
| `sign-event` | `nostr_sign_event` | event JSON argv or stdin | signed event JSON |
|
||||
| `mine-event` | `nostr_mine_event` | event JSON argv or stdin | signed mined event JSON |
|
||||
| `nip04-encrypt <peer>` | `nostr_nip04_encrypt` | plaintext argv or stdin | ciphertext |
|
||||
| `nip04-decrypt <peer>` | `nostr_nip04_decrypt` | ciphertext argv or stdin | plaintext |
|
||||
| `nip44-encrypt <peer>` | `nostr_nip44_encrypt` | plaintext argv or stdin | ciphertext |
|
||||
| `nip44-decrypt <peer>` | `nostr_nip44_decrypt` | ciphertext argv or stdin | plaintext |
|
||||
|
||||
### Algorithm-based verbs (use `--algorithm` + `--index`)
|
||||
|
||||
| Verb | RPC method | argv | stdout |
|
||||
|---|---|---|---|
|
||||
| `get-public-key` | `get_public_key` | none | structured JSON |
|
||||
| `sign <msg-hex>` | `sign` | hex bytes | structured JSON |
|
||||
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid`/`invalid` (exit 0/1) |
|
||||
| `derive <data>` | `derive` | UTF-8 argv or stdin | structured JSON |
|
||||
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | structured JSON |
|
||||
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | structured JSON |
|
||||
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
|
||||
| `encrypt <plaintext>` | `encrypt` | plaintext argv or stdin | ciphertext |
|
||||
| `decrypt <ciphertext>` | `decrypt` | ciphertext argv or stdin | plaintext |
|
||||
|
||||
### Generic escape hatch
|
||||
|
||||
| Verb | RPC method | input | stdout |
|
||||
|---|---|---|---|
|
||||
| `call <method>` | `<method>` | JSON params array stdin or argv | raw result JSON |
|
||||
|
||||
## stdin/stdout contract (pipe-friendly)
|
||||
|
||||
- All payload output → stdout, single line, newline-terminated.
|
||||
- All diagnostics → stderr.
|
||||
- Exit codes: `0` success, `1` invalid (verify only), `2` error.
|
||||
- `sign-event`, `nip04-*`, `nip44-*`, `derive`, `encrypt`, `decrypt` read
|
||||
payload from argv if present, else one stdin line.
|
||||
- `sign`, `verify`, `encapsulate`, `decapsulate`, `derive-shared-secret`
|
||||
take hex from argv only.
|
||||
- `call` reads JSON params array from stdin (one line) or argv.
|
||||
|
||||
## Selector handling
|
||||
|
||||
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object
|
||||
(trailing element of `params`):
|
||||
|
||||
- `--role <name> --path <path>` → `{"role":"<name>","role_path":"<path>"}`
|
||||
(both required for nostr verbs; client-side error if either missing).
|
||||
- Algorithm verbs: `--algorithm` + `--index` populate the options object
|
||||
instead; `--scheme` adds `"scheme"` for secp256k1 sign/verify; `--encoding`
|
||||
adds `"encoding"` for OTP encrypt/decrypt.
|
||||
- `--index` is only valid with `--algorithm` (client rejects otherwise).
|
||||
|
||||
## Transport
|
||||
|
||||
- **Unix** (default): `connect_abstract_unix(name)`. Auto-discover via
|
||||
`discover_single_socket()` when no `--socket-name` and no explicit
|
||||
transport given; error if zero or >1 found.
|
||||
- **TCP**: `TcpStream::connect((host, port))`; requires `--auth-privkey`
|
||||
(32-byte hex). Auth envelope built client-side and prepended.
|
||||
- **Serial**: open `/dev/ttyACM*` via `OpenOptions::read_write` + framed
|
||||
I/O over the file.
|
||||
- **Qrexec**: spawn `qrexec-client-vm <qube> <service>`, pipe framed JSON
|
||||
over child stdin/stdout.
|
||||
- All four share the same `send_framed`/`recv_framed` after construction.
|
||||
|
||||
## Auth envelope (client side)
|
||||
|
||||
For TCP (and qrexec when `--auth-privkey` is given):
|
||||
|
||||
1. Derive secp256k1 keypair from the 32-byte `--auth-privkey`.
|
||||
2. Build a NIP-42 kind-22242 event with:
|
||||
- `created_at` = now
|
||||
- `tags` = `[["challenge","<request-hash>"]]` (or label tag from
|
||||
`--auth-label`)
|
||||
- content = the JSON-RPC request body (or its sha256, per server contract
|
||||
in [`auth_envelope.rs`](src/auth_envelope.rs:1)).
|
||||
3. Sign the event (Schnorr), serialize, and send as a framed preamble before
|
||||
the actual request frame.
|
||||
|
||||
The exact envelope shape is read from the server-side verifier
|
||||
[`auth_envelope.rs`](src/auth_envelope.rs:81) to guarantee wire
|
||||
compatibility.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CLI[cli.rs<br/>clap parse] --> Main[main.rs]
|
||||
Main -->|open| Tr[transport.rs<br/>ClientTransport enum]
|
||||
Tr -->|unix| Unix[connect_abstract_unix]
|
||||
Tr -->|tcp| Tcp[TcpStream + auth.rs]
|
||||
Tr -->|serial| Serial[OpenOptions /dev/ttyACM]
|
||||
Tr -->|qrexec| Qrexec[spawn qrexec-client-vm]
|
||||
Tr --> Rpc[rpc.rs<br/>SignerClient]
|
||||
Rpc --> Signer[signer.rs<br/>SignerSigner typed verbs]
|
||||
Signer -->|build params| Disp[signer daemon<br/>dispatcher.rs]
|
||||
Disp -->|result/error| Signer
|
||||
Signer -->|one line| Stdout[stdout]
|
||||
```
|
||||
|
||||
## Implementation order (todos for Code mode)
|
||||
|
||||
1. Add `[[bin]]` target to `Cargo.toml`; create `src/client/` skeleton with
|
||||
`mod` declarations in `src/client/main.rs`.
|
||||
2. Implement `cli.rs`: clap `Cli` + `Verb` enum + all option structs +
|
||||
`print_usage`.
|
||||
3. Implement `transport.rs`: `ClientTransport` enum with `open_unix`,
|
||||
`open_tcp`, `open_serial`, `open_qrexec`; unified `send`/`recv` via the
|
||||
existing `send_framed`/`recv_framed`. Include `parse_host_port` and
|
||||
`parse_qube_service` helpers.
|
||||
4. Implement `rpc.rs`: `SignerClient` struct holding the transport,
|
||||
`call(method, params) -> Result<Value, String>`, `last_error`, and
|
||||
framed send/recv using `serde_json`.
|
||||
5. Implement `signer.rs`: `SignerSigner` with selector state + one method
|
||||
per verb (get_info, get_public_key, sign_event, mine_event, nip04/44
|
||||
encrypt/decrypt, sign, verify, derive, encapsulate, decapsulate,
|
||||
derive_shared_secret, otp encrypt/decrypt). Each builds the `params`
|
||||
array + options object and parses the typed result.
|
||||
6. Implement `auth.rs`: client-side auth envelope builder (secp256k1
|
||||
keypair from hex, kind-22242 event, sign, serialize) matching the server
|
||||
verifier shape.
|
||||
7. Implement `main.rs`: wire CLI → transport → client → signer → verb
|
||||
dispatch → stdout. Include stdin-line reader, hex helpers, exit codes
|
||||
(0/1/2), and the `list` / `call` verbs.
|
||||
8. Write `src/client/README.md` (port of `n_signer_client_README.md`).
|
||||
9. Smoke test: build, run `signer-client list`, `get-info`,
|
||||
`--role main --path ... get-public-key`, `sign-event` pipe-to-stdout,
|
||||
`--algorithm ed25519 sign`, `verify` valid/invalid exit codes.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** in `rpc.rs` / `signer.rs`: build-params correctness using
|
||||
`serde_json::json!` assertions (no socket needed).
|
||||
- **Integration test** `tests/client_smoke.rs`: spawn `signer
|
||||
--mnemonic-stdin --listen unix --socket-name signer_test` with a fixed
|
||||
test mnemonic in a thread, then run the client verbs against it and
|
||||
assert stdout shape + exit codes. Tear down the server.
|
||||
- Manual pipe-to-`nak` check for `sign-event`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No TUI, no approval UI — the human attendant lives in the running
|
||||
`signer` process; the client is a thin wire caller.
|
||||
- No key storage, no mnemonic handling.
|
||||
- No HTTP-listener client (HTTP is a server-side listener mode; the client
|
||||
uses the framed transports).
|
||||
- No NIP-46 bunker mode.
|
||||
- No changes to the existing `client` subcommand in `src/main.rs` (kept as
|
||||
a raw-passthrough convenience).
|
||||
+84
-66
@@ -34,7 +34,7 @@ underlined (e.g. "Quit" with Q underlined, not "Q quit").
|
||||
|
||||
The user asked whether "client name" should be renamed since the
|
||||
signer is more a server than a client. **Decision: rename to "signer
|
||||
name".** The field shows the socket name (e.g. `nsigner01`), which is
|
||||
name".** The field shows the socket name (e.g. `signer01`), which is
|
||||
the name clients use to connect. Calling it "signer name" is clearer
|
||||
than "client name" and consistent with the program name.
|
||||
|
||||
@@ -124,27 +124,27 @@ the C-format log entries (newest first).
|
||||
├──────────────────────────────────────────┬──────────────────────────────────────────┤
|
||||
│ Information │ Activity │
|
||||
│ │ │
|
||||
│ signer name: nsigner01 │ 2026-08-18 15:05:42 unix:1000 │
|
||||
│ Unix address: │ sign_event(main) ALLOWED:no-auth │
|
||||
│ nsigner01 │ 2026-08-18 15:05:30 unix:1000 │
|
||||
│ Qube address: │ get_info() ALLOWED:no-auth │
|
||||
│ signer name: signer01 │ 2026-08-18 15:05:42 unix:1000 │
|
||||
│ Unix address: │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ signer01 │ 2026-08-18 15:05:30 unix:1000 │
|
||||
│ Qube address: │ - - │
|
||||
│ (inactive) │ 2026-08-18 15:04:55 unix:1000 │
|
||||
│ FIPS address: │ nip44_encrypt(main) ALLOWED:no-auth │
|
||||
│ FIPS address: │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ (inactive) │ │
|
||||
│ HTTP address: │ │
|
||||
│ (inactive) │ │
|
||||
│ OTP pad: chksum=a1b2c3 offset=128/4096 │ │
|
||||
│ │ nip44_encrypt(main) ALLOWED:no-auth │
|
||||
├──────────────────────────────────────────┤ 2026-08-18 15:03:12 unix:1000 │
|
||||
│ Transport │ sign_event(nostr_range,0) │
|
||||
│ │ ALLOWED:no-auth │
|
||||
│ ▸ [x] U̲nix Socket │ 2026-08-18 15:02:00 unix:1000 │
|
||||
│ [ ] Qube b̲ridge │ get_info() ALLOWED:no-auth │
|
||||
│ [ ] F̲IPS │ 2026-08-18 15:01:30 unix:1000 │
|
||||
│ [ ] H̲TTP │ sign_event(main) ALLOWED:no-auth │
|
||||
│ │ 2026-08-18 15:00:22 unix:1000 │
|
||||
├──────────────────────────────────────────┤ sign_event(main) ALLOWED:no-auth │
|
||||
│ Roles │ 2026-08-18 15:00:10 nsigner started │
|
||||
│ │ 2026-08-18 15:03:12 unix:1000 │
|
||||
├──────────────────────────────────────────┤ secp256k1 m/44'/1237'/*'/0/0 │
|
||||
│ Transport │ 2026-08-18 15:02:00 unix:1000 │
|
||||
│ │ - - │
|
||||
│ ▸ [x] U̲nix Socket │ 2026-08-18 15:01:30 unix:1000 │
|
||||
│ [ ] Qube b̲ridge │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ [ ] F̲IPS │ 2026-08-18 15:00:22 unix:1000 │
|
||||
│ [ ] H̲TTP │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ │ 2026-08-18 15:00:10 signer started │
|
||||
├──────────────────────────────────────────┤ │
|
||||
│ Roles │ │
|
||||
│ │ │
|
||||
│ Role Purpose Curve │ │
|
||||
│ ───────────── ──────── ──────────── │ │
|
||||
@@ -165,7 +165,8 @@ Each section has its own commands on the bottom line, left-aligned:
|
||||
command letter is underlined in each label (`U̲nix Socket`,
|
||||
`Q̲ube bridge`, `F̲IPS`, `H̲TTP`). Tab or Up/Down moves between
|
||||
lines, Enter or the underlined key toggles that transport on/off
|
||||
(radio-button: one active at a time, server restarts immediately).
|
||||
(independent checkbox: toggling one only flips itself; the last
|
||||
active transport cannot be disabled; server restarts immediately).
|
||||
Active transport is also shown in bold/reversed.
|
||||
- **Roles**: `A̲dd D̲elete` — add a new role, delete the selected role
|
||||
- **Activity**: `Cl̲ear` — clear the activity log (with a blank row
|
||||
@@ -195,8 +196,9 @@ indicator plus the transport name with the key letter underlined
|
||||
(`U̲nix Socket`, `Qube b̲ridge`, `F̲IPS`, `H̲TTP`). No separate
|
||||
command line — each line is its own toggle. Tab or Up/Down moves
|
||||
between lines, Enter or the underlined key letter toggles that
|
||||
transport on/off (radio-button: one active at a time, server
|
||||
restarts immediately). Active transport also shown in bold/reversed.
|
||||
transport on/off (independent checkbox: toggling one only flips
|
||||
itself; the last active transport cannot be disabled; server restarts
|
||||
immediately). Active transport also shown in bold/reversed.
|
||||
|
||||
Key assignments (all unique across the main screen — this is the
|
||||
canonical set shown in the mockup):
|
||||
@@ -224,32 +226,28 @@ bottom: `Cl̲ear`.
|
||||
|
||||
### Activity log format
|
||||
|
||||
The current Rust implementation logs only "request handled" — it does
|
||||
not show what was actually handled. The C implementation logs detailed
|
||||
request information. Copy the C format:
|
||||
Each activity entry shows four fields: `time uid curve path`. The
|
||||
timestamp is added by `ActivityLog::add()`; the message itself is
|
||||
`uid curve path`.
|
||||
|
||||
```
|
||||
<caller_id> <method>(<role_name>[,<concrete_path>]) <verdict>:<source_label>
|
||||
<caller_id> <curve> <key_path>
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `unix:1000 sign_event(main) ALLOWED:no-auth`
|
||||
- `unix:1000 sign_event(nostr_range,0) ALLOWED:no-auth`
|
||||
- `unix:1000 get_info() ALLOWED:no-auth`
|
||||
- `tcp:[::1]:12345 nip44_encrypt(main) ALLOWED:no-auth`
|
||||
- `unix:1000 secp256k1 m/44'/1237'/0'/0/0`
|
||||
- `unix:1000 ed25519 m/44'/102001'/0'/0'/0'`
|
||||
- `unix:1000 - -` (get_info / algorithm verbs — no role)
|
||||
|
||||
**Implementation:** `ServerContext::process_request` must return an
|
||||
activity description string alongside the JSON response. Change the
|
||||
return type to `(String, String)` — the response and the activity log
|
||||
message. The activity message is constructed from:
|
||||
**Implementation:** `ServerContext::process_request` returns
|
||||
`(String, String)` — the response and the activity log message. The
|
||||
activity message is constructed from:
|
||||
- `caller.caller_id` — e.g. `unix:1000` or `tcp:[::1]:12345`
|
||||
- `method` — the JSON-RPC method (e.g. `sign_event`, `get_info`)
|
||||
- `role_name` — from the resolved selector (if a role was matched)
|
||||
- `concrete_path` — if the role has a path template with `%d`, the
|
||||
concrete index (e.g. `0`)
|
||||
- `verdict` — `ALLOWED` (since we removed policy, all valid requests
|
||||
are allowed; denied requests get `DENIED` with the error reason)
|
||||
- `source_label` — `no-auth` (since we removed policy/authorization)
|
||||
- `curve` — the role's curve string (e.g. `secp256k1`, `ed25519`)
|
||||
- `key_path` — the role's derivation path via `RoleEntry::display_path()`
|
||||
(e.g. `m/44'/1237'/0'/0/0` or `m/44'/1237'/*'/0/0 [0-99]`)
|
||||
- For requests without a role (get_info, algorithm verbs, OTP), the
|
||||
curve and path are `-`.
|
||||
|
||||
The `service_server()` method in `App` passes this message to
|
||||
`activity_log.add()` instead of the generic "request handled".
|
||||
@@ -263,13 +261,17 @@ selected role.
|
||||
- **Add** (`A`): opens the `AddRole` popup showing the role preset
|
||||
menu (same 1–10 presets as current wizard). Select a preset (or
|
||||
custom), then enter role name and path template via `InputField`
|
||||
with pre-filled defaults. On confirm, register the role and derive
|
||||
its key immediately, then return to the main screen.
|
||||
with pre-filled defaults. On confirm, register the role, derive its
|
||||
key immediately, and return to the main screen automatically — no
|
||||
extra Enter needed.
|
||||
- **Delete** (`D`): deletes the currently selected role immediately —
|
||||
no confirmation overlay. The role is removed from the table and its
|
||||
derived key is wiped. The selection moves to the next role.
|
||||
- **Select**: Up/Down arrows or Tab move selection through the role
|
||||
list. The `▸` cursor shows the selected role.
|
||||
- **Columns**: Role, Purpose, Curve, Key path (derivation path via
|
||||
`RoleEntry::display_path()`, e.g. `m/44'/1237'/0'/0/0` or
|
||||
`m/44'/1237'/*'/0/0 [0-99]`).
|
||||
|
||||
### Help screen
|
||||
|
||||
@@ -383,9 +385,12 @@ pub enum RoleAddStage {
|
||||
## Server changes
|
||||
|
||||
Currently `ServerContext` supports only one listen mode at a time. The
|
||||
transport screen uses radio-button behavior: only one transport can be
|
||||
active at a time. Toggling one on turns the others off. This matches
|
||||
the current C behavior and avoids server architecture changes.
|
||||
transport toggles are independent checkboxes — toggling one only flips
|
||||
itself, and multiple transports can be checked at once. The server
|
||||
listens on the first active transport by priority (Unix > Qrexec > TCP
|
||||
> HTTP). The last active transport cannot be disabled. This avoids
|
||||
server architecture changes while allowing the user to select which
|
||||
transport is active.
|
||||
|
||||
Additionally, `process_request` must return an activity log message
|
||||
alongside the JSON response (see "Activity log format" above). Change
|
||||
@@ -480,7 +485,7 @@ and deleted directly in the Roles section.
|
||||
and Roles are sections on Main, Lock is removed entirely, Commands
|
||||
replaced by Help.)
|
||||
|
||||
2. **Update `App` struct** — remove wizard fields, add `cmd_cursor`,
|
||||
2. **Update `App` struct** — remove wizard fields, add `help_scroll`,
|
||||
`role_cursor`, `transport_cursor`, `seed_generate_mode`,
|
||||
`RoleAddStage` enum and fields. Update `App::new` to start on
|
||||
`Screen::SeedEntry`.
|
||||
@@ -496,8 +501,10 @@ and deleted directly in the Roles section.
|
||||
collapsed borders. Left column has three sections: Information (top),
|
||||
Transport (middle), Roles (bottom). Right column has Activity
|
||||
(scrollable, newest first). Information section shows "signer name"
|
||||
(renamed from "client name") + transport addresses. Remove the
|
||||
Connections sub-panel.
|
||||
(renamed from "client name") + transport addresses (each on an
|
||||
indented row beneath the label, without `@` for Unix). Remove the
|
||||
Connections sub-panel. Roles and Activity sections have a blank row
|
||||
above their command lines at the bottom.
|
||||
|
||||
5. **Implement Transport section on main screen** — renders 4
|
||||
toggle-button lines, each showing `[x]` or `[ ]` indicator plus
|
||||
@@ -506,8 +513,9 @@ and deleted directly in the Roles section.
|
||||
selected line (`transport_cursor`). Active transport is also shown
|
||||
in bold/reversed. Tab/Up/Down moves between lines, Enter or the
|
||||
underlined key letter (`U`/`B`/`F`/`H`) toggles that transport
|
||||
(radio-button: one active at a time, toggling restarts the server).
|
||||
No separate command line for this section. All key commands on the
|
||||
(independent checkbox: only flips itself; last active cannot be
|
||||
disabled; toggling restarts the server). No separate command line
|
||||
for this section. All key commands on the
|
||||
main screen must be unique: U, B, F, H (transport), A, D (roles),
|
||||
C (clear activity), L (help), Q (quit).
|
||||
|
||||
@@ -517,7 +525,16 @@ and deleted directly in the Roles section.
|
||||
currently selected role immediately (no confirmation), `↑`/`↓`/Tab
|
||||
moves the cursor. On add/delete, re-derive keys.
|
||||
|
||||
7. **Implement Help screen** — `draw_help()` renders a scrollable
|
||||
7. **Implement AddRole popup** — `draw_add_role()` renders a centered
|
||||
popup over the Main screen showing the role preset menu (same 1–10
|
||||
presets as current wizard). `handle_add_role_key()` processes the
|
||||
multi-stage flow: `PresetMenu` → `NameEntry` (InputField with
|
||||
pre-filled default) → `CurveSelect` (custom only) → `PathEntry`
|
||||
(InputField with pre-filled default) → `OtpDir`/`OtpName` (OTP
|
||||
only) → `Confirm`. On confirm, register the role, derive its key,
|
||||
and return to `Screen::Main`. ESC cancels and returns to Main.
|
||||
|
||||
8. **Implement Help screen** — `draw_help()` renders a scrollable
|
||||
`Paragraph` describing what the app does, what transports are, what
|
||||
roles are, and listing the key commands at the end. Track a
|
||||
`help_scroll` offset. `handle_help_key()`: Up/Down (and Page
|
||||
@@ -525,14 +542,14 @@ and deleted directly in the Roles section.
|
||||
shown when content overflows. This is a reference screen — no
|
||||
actions executed from here.
|
||||
|
||||
8. **Update key command bars** — each screen's bottom bar shows the
|
||||
relevant key bindings for that screen. Use underlined-first-letter
|
||||
word hints (e.g. `Q̲uit`, `R̲oles`) instead of "Q quit" style.
|
||||
Replace the old `key_span` helper with a new `cmd_hint` helper that
|
||||
produces a `Span` with the key command letter underlined (which may
|
||||
not be the first letter of the word).
|
||||
9. **Update key command bars** — each section's bottom line shows the
|
||||
relevant key bindings for that section. Use underlined-key-letter
|
||||
word hints (e.g. `Q̲uit`, `He̲lp`, `A̲dd`, `D̲elete`, `Cl̲ear`)
|
||||
instead of "Q quit" style. Replace the old `key_span` helper with a
|
||||
new `cmd_hint` helper that produces a `Span` with the key command
|
||||
letter underlined (which may not be the first letter of the word).
|
||||
|
||||
9. **Update activity log format** — change `ServerContext::process_request`
|
||||
10. **Update activity log format** — change `ServerContext::process_request`
|
||||
to return `(String, String)` (response + activity message). Construct
|
||||
the activity message from `caller_id`, `method`, `role_name`,
|
||||
`concrete_path`, `verdict`, and `source_label` matching the C format:
|
||||
@@ -541,23 +558,24 @@ and deleted directly in the Roles section.
|
||||
`service_server()` in `App` to log this message instead of
|
||||
"request handled".
|
||||
|
||||
10. **Update `run()` loop** — service server only on `Main` screen
|
||||
(all overlay screens pause server processing). Update the
|
||||
`handle_key` dispatch for the new screen enum.
|
||||
11. **Update `run()` loop** — service server only on `Main` screen
|
||||
(overlay screens `AddRole` and `Help` pause server processing).
|
||||
Update the `handle_key` dispatch for the new screen enum.
|
||||
|
||||
11. **Update `main.rs`** — adjust `App::new` call if needed. The
|
||||
12. **Update `main.rs`** — adjust `App::new` call if needed. The
|
||||
`listen_override` path: if `--listen` is given, skip seed entry
|
||||
popup and go straight to main with the specified transport. But
|
||||
still need a mnemonic — so `--listen` with interactive mode should
|
||||
still show the seed entry popup, then go to main with the transport
|
||||
pre-selected.
|
||||
|
||||
12. **Test** — `cargo test` (unit tests unaffected). Manual test:
|
||||
13. **Test** — `cargo test` (unit tests unaffected). Manual test:
|
||||
start signer, verify seed entry popup, verify main screen with
|
||||
collapsed borders and centered title, verify Roles add/remove,
|
||||
verify Transport toggle (4 lines, tab navigation), verify Help
|
||||
screen shows all key commands, verify activity log shows detailed
|
||||
request info, connect with `nsigner_client`.
|
||||
collapsed borders and centered title, verify Roles add/delete,
|
||||
verify AddRole popup preset menu flow, verify Transport toggle
|
||||
(4 lines, tab navigation), verify Help screen is scrollable and
|
||||
shows app description + key commands, verify activity log shows
|
||||
detailed request info, connect with `signer_client`.
|
||||
|
||||
## What stays the same
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# qdiag.sh — read-only Qubes dom0 diagnostics for SignerRpc qrexec issues.
|
||||
# Run as root in dom0: sudo bash qdiag.sh
|
||||
# Output: qdiag.txt in the current directory. No system changes are made.
|
||||
|
||||
OUT="qdiag.txt"
|
||||
: >"$OUT"
|
||||
|
||||
sec() { printf '\n===== %s =====\n' "$1" >>"$OUT"; }
|
||||
run() { printf '\n$ %s\n' "$*" >>"$OUT"; "$@" >>"$OUT" 2>&1 || true; }
|
||||
runsh() { printf '\n$ %s\n' "$1" >>"$OUT"; bash -c "$1" >>"$OUT" 2>&1 || true; }
|
||||
|
||||
sec "1. Identity / versions"
|
||||
run hostname
|
||||
run id
|
||||
run uname -a
|
||||
run cat /etc/qubes-release
|
||||
run qubesctl --version
|
||||
run rpm -q qubes-core-admin-linux qubes-core-qrexec 2>/dev/null
|
||||
|
||||
sec "2. Qubes version detail"
|
||||
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE 2>/dev/null
|
||||
run qvm-ls --running 2>/dev/null
|
||||
|
||||
sec "3. Policy directories inventory"
|
||||
run ls -la /etc/qubes/policy.d/
|
||||
run ls -la /etc/qubes-rpc/policy/ 2>/dev/null
|
||||
run ls -la /usr/share/qubes/policy.d/ 2>/dev/null
|
||||
run ls -la /etc/qubes-rpc/ 2>/dev/null
|
||||
|
||||
sec "4. Policy file ownership/permissions"
|
||||
runsh 'find /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d -maxdepth 1 -type f -printf "%M %u:%g %s %p\n" 2>/dev/null | sort'
|
||||
|
||||
sec "5. Symlinks in policy dirs"
|
||||
runsh 'find /etc/qubes/policy.d /etc/qubes-rpc /etc/qubes-rpc/policy -maxdepth 2 -type l -printf "%p -> %l\n" 2>/dev/null'
|
||||
|
||||
sec "6. Search for SignerRpc / nsigner / signer policy files"
|
||||
runsh 'find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -iname "*signer*" -print 2>/dev/null | grep -vi "^/home" | head -50'
|
||||
runsh 'grep -RIl "SignerRpc\|NsignerRpc\|nsigner" /etc/qubes /usr/share/qubes 2>/dev/null | head -30'
|
||||
|
||||
sec "7. Policy contents (signer-related)"
|
||||
runsh 'for f in $(grep -RIl "SignerRpc\|NsignerRpc\|nsigner\|signer" /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d 2>/dev/null); do echo "--- $f ---"; cat "$f"; echo; done'
|
||||
|
||||
sec "8. Clipboard policy contents"
|
||||
runsh 'for f in $(grep -RIl "ClipboardPaste" /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d 2>/dev/null); do echo "--- $f ---"; cat "$f"; echo; done'
|
||||
|
||||
sec "9. Full policy.d listing with contents (all files)"
|
||||
runsh 'for f in /etc/qubes/policy.d/*; do echo "--- $f ---"; cat "$f" 2>/dev/null; echo; done'
|
||||
|
||||
sec "10. Effective policy query (if tools exist)"
|
||||
runsh 'command -v qrexec-policy-graph && qrexec-policy-graph --include-ask 2>&1 | grep -iE "signer|clipboard" | head -30'
|
||||
runsh 'command -v qvm-tags && qvm-tags dom0 2>/dev/null | head -20'
|
||||
|
||||
sec "11. qrexec policy daemon status"
|
||||
run systemctl status qrexec-policy-daemon --no-pager -l 2>&1
|
||||
run systemctl is-active qrexec-policy-daemon 2>&1
|
||||
|
||||
sec "12. Target qube info (nostr_signer)"
|
||||
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE,NETVM nostr_signer 2>/dev/null
|
||||
run qvm-features nostr_signer 2>/dev/null
|
||||
run qvm-prefs nostr_signer 2>/dev/null
|
||||
|
||||
sec "13. Caller qube info (ai)"
|
||||
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE,NETVM ai 2>/dev/null
|
||||
run qvm-features ai 2>/dev/null
|
||||
|
||||
sec "14. Recent qrexec/policy journal errors"
|
||||
runsh 'journalctl -b --no-pager 2>/dev/null | grep -iE "qrexec|policy|SignerRpc|NsignerRpc|clipboard" | tail -80'
|
||||
|
||||
sec "15. qrexec service definitions in target qube (via qvm-run, read-only)"
|
||||
runsh 'qvm-run -p nostr_signer "ls -la /etc/qubes-rpc/ 2>/dev/null; echo ---; ls -la /rw/config/qubes-rpc/ 2>/dev/null; echo ---; cat /rw/config/rc.local 2>/dev/null" 2>&1 | head -60'
|
||||
|
||||
sec "16. Test qrexec call to nostr_signer (read-only get_info)"
|
||||
runsh 'echo "{\"id\":\"diag\",\"method\":\"get_info\",\"params\":[]}" | timeout 10 qrexec-client-vm nostr_signer qubes.SignerRpc 2>&1; echo "exit=$?"'
|
||||
runsh 'echo "{\"id\":\"diag\",\"method\":\"get_info\",\"params\":[]}" | timeout 10 qrexec-client-vm nostr_signer qubes.NsignerRpc 2>&1; echo "exit=$?"'
|
||||
|
||||
sec "17. Qubes global config files"
|
||||
runsh 'ls -la /etc/qubes/ | head -30'
|
||||
runsh 'cat /etc/qubes/policy.d/50-config-input.policy 2>/dev/null'
|
||||
runsh 'cat /etc/qubes/policy.d/50-config-updates.policy 2>/dev/null'
|
||||
|
||||
sec "DONE"
|
||||
printf 'Diagnostics complete. Output saved to %s\n' "$OUT"
|
||||
exit 0
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# qfix.sh — repair dom0 qrexec policy + signer handler. Run as root in dom0:
|
||||
# sudo bash qfix.sh
|
||||
# Fixes (with backups saved to ~/qfix-backup-<ts>/):
|
||||
# 1. Removes invalid old-format file /etc/qubes-rpc/policy/qubes.SignerRpc
|
||||
# (its "* * * allow" content breaks ALL qrexec policy loading)
|
||||
# 2. Rewrites /etc/qubes/policy.d/45-signer.policy with direct targets
|
||||
# 3. Sets the signer-signer tag on nostr_signer (optional, for tag rules)
|
||||
# 4. Creates /etc/qubes-rpc/qubes.SignerRpc handler inside nostr_signer
|
||||
# (modeled on the existing qubes.NsignerRpc handler)
|
||||
# 5. Verifies policy loads cleanly
|
||||
|
||||
set -u
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
BK=~/qfix-backup-$TS
|
||||
mkdir -p "$BK"
|
||||
|
||||
ok() { printf '\033[1;32m[OK]\033[0m %s\n' "$*"; }
|
||||
info(){ printf '\033[1;34m[..]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[ERR]\033[0m %s\n' "$*"; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || { err "Run as root: sudo bash qfix.sh"; exit 1; }
|
||||
|
||||
# ── 1. Remove the invalid old-format policy file ─────────────────────
|
||||
if [[ -f /etc/qubes-rpc/policy/qubes.SignerRpc ]]; then
|
||||
cp /etc/qubes-rpc/policy/qubes.SignerRpc "$BK/" 2>/dev/null
|
||||
rm -f /etc/qubes-rpc/policy/qubes.SignerRpc
|
||||
ok "removed invalid /etc/qubes-rpc/policy/qubes.SignerRpc (backed up)"
|
||||
else
|
||||
ok "no invalid old-format file present"
|
||||
fi
|
||||
|
||||
# ── 2. Rewrite 45-signer.policy with direct target rules ─────────────
|
||||
SIGNER_POLICY=/etc/qubes/policy.d/45-signer.policy
|
||||
if [[ -f "$SIGNER_POLICY" ]]; then
|
||||
cp "$SIGNER_POLICY" "$BK/45-signer.policy"
|
||||
fi
|
||||
cat > "$SIGNER_POLICY" <<'EOF'
|
||||
# Qubes OS qrexec policy for signer (qubes.SignerRpc)
|
||||
# Direct rules: caller -> nostr_signer
|
||||
qubes.SignerRpc * ai nostr_signer allow
|
||||
qubes.SignerRpc * nostr nostr_signer allow
|
||||
qubes.SignerRpc * @anyvm @anyvm ask default_target=nostr_signer
|
||||
EOF
|
||||
chown root:root "$SIGNER_POLICY"
|
||||
chmod 0644 "$SIGNER_POLICY"
|
||||
ok "rewrote $SIGNER_POLICY"
|
||||
|
||||
# ── 3. Tag nostr_signer (enables @tag:signer-signer rules if ever used) ──
|
||||
qvm-tags nostr_signer add signer-signer 2>/dev/null \
|
||||
&& ok "tagged nostr_signer with signer-signer" \
|
||||
|| info "tag set skipped (non-fatal)"
|
||||
|
||||
# ── 4. Create the qrexec handler inside nostr_signer ─────────────────
|
||||
info "inspecting existing handlers in nostr_signer..."
|
||||
qvm-run -p nostr_signer 'cat /etc/qubes-rpc/qubes.NsignerRpc 2>/dev/null' || true
|
||||
|
||||
# Detect signer binary location in the target qube
|
||||
BIN=$(qvm-run -p nostr_signer \
|
||||
'for b in $HOME/.local/bin/signer /usr/local/bin/signer; do [ -x "$b" ] && echo "$b" && break; done' 2>/dev/null | tr -d '\r')
|
||||
[[ -n "$BIN" ]] || { err "signer binary not found in nostr_signer (run install_signer.sh there first)"; BIN="/home/user/.local/bin/signer"; }
|
||||
info "signer binary in nostr_signer: $BIN"
|
||||
|
||||
qvm-run -u root -p nostr_signer "printf '#!/bin/sh\nexec $BIN bridge\n' > /etc/qubes-rpc/qubes.SignerRpc && chmod 0755 /etc/qubes-rpc/qubes.SignerRpc" \
|
||||
&& ok "created /etc/qubes-rpc/qubes.SignerRpc in nostr_signer" \
|
||||
|| err "handler creation failed (create manually)"
|
||||
|
||||
# Show what we created
|
||||
qvm-run -p nostr_signer 'ls -la /etc/qubes-rpc/qubes.SignerRpc; cat /etc/qubes-rpc/qubes.SignerRpc' || true
|
||||
|
||||
# ── 5. Verify policy loads cleanly ───────────────────────────────────
|
||||
info "verifying policy syntax..."
|
||||
if qrexec-policy-graph --include-ask >/dev/null 2>"$BK/policy-graph.err"; then
|
||||
ok "policy loads cleanly (no syntax errors)"
|
||||
else
|
||||
err "policy still has errors:"
|
||||
cat "$BK/policy-graph.err"
|
||||
fi
|
||||
|
||||
echo
|
||||
ok "repair complete. Backups in $BK"
|
||||
echo "Now test from the ai qube:"
|
||||
echo " signer-client --qrexec nostr_signer:qubes.SignerRpc --role main --path \"m/44'/1237'/0'/0/0\" get-public-key"
|
||||
echo "Clipboard (dom0 -> ai) should also work again: Ctrl+Shift+C in dom0, Ctrl+Shift+V in ai"
|
||||
+93
-27
@@ -6,7 +6,7 @@
|
||||
use crate::mnemonic::MnemonicState;
|
||||
use crate::pq_crypto::CryptoAlg;
|
||||
use crate::secure_mem::SecureBuf;
|
||||
use crate::NsignerError;
|
||||
use crate::SignerError;
|
||||
|
||||
pub const ALG_KEY_CACHE_MAX: usize = 32;
|
||||
|
||||
@@ -47,9 +47,9 @@ impl AlgorithmKeyCache {
|
||||
mnemonic: &MnemonicState,
|
||||
alg: CryptoAlg,
|
||||
index: i32,
|
||||
) -> Result<(), NsignerError> {
|
||||
) -> Result<(), SignerError> {
|
||||
if !mnemonic.is_loaded() {
|
||||
return Err(NsignerError::MnemonicNotLoaded);
|
||||
return Err(SignerError::MnemonicNotLoaded);
|
||||
}
|
||||
|
||||
// Already cached?
|
||||
@@ -62,19 +62,9 @@ impl AlgorithmKeyCache {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
|
||||
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
|
||||
|
||||
// Build the standard derivation path for this algorithm
|
||||
let path = match alg {
|
||||
CryptoAlg::Secp256k1 => format!("m/44'/1237'/{}'/0/0", index),
|
||||
CryptoAlg::Ed25519 => format!("m/44'/102001'/{}'/0'/0'", index),
|
||||
CryptoAlg::X25519 => format!("m/44'/102002'/{}'/0'/0'", index),
|
||||
CryptoAlg::MlDsa65 => format!("m/44'/102003'/{}'/0'/0'", index),
|
||||
CryptoAlg::SlhDsa128s => format!("m/44'/102004'/{}'/0'/0'", index),
|
||||
CryptoAlg::MlKem768 => format!("m/44'/102005'/{}'/0'/0'", index),
|
||||
CryptoAlg::Unknown => return Err(NsignerError::InvalidInput),
|
||||
};
|
||||
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
|
||||
|
||||
let path = standard_path(alg, index)?;
|
||||
let entry = derive_alg_key(phrase, &path, alg, index)?;
|
||||
self.entries.push(entry);
|
||||
Ok(())
|
||||
@@ -92,18 +82,48 @@ impl Default for AlgorithmKeyCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard derivation path for an algorithm at a given index.
|
||||
///
|
||||
/// secp256k1 uses the NIP-06 path; ed25519/x25519/PQ use their
|
||||
/// per-algorithm SLIP-44 coin types (102001'–102005').
|
||||
pub fn standard_path(alg: CryptoAlg, index: i32) -> Result<String, SignerError> {
|
||||
match alg {
|
||||
CryptoAlg::Secp256k1 => Ok(format!("m/44'/1237'/{}'/0/0", index)),
|
||||
CryptoAlg::Ed25519 => Ok(format!("m/44'/102001'/{}'/0'/0'", index)),
|
||||
CryptoAlg::X25519 => Ok(format!("m/44'/102002'/{}'/0'/0'", index)),
|
||||
CryptoAlg::MlDsa65 => Ok(format!("m/44'/102003'/{}'/0'/0'", index)),
|
||||
CryptoAlg::SlhDsa128s => Ok(format!("m/44'/102004'/{}'/0'/0'", index)),
|
||||
CryptoAlg::MlKem768 => Ok(format!("m/44'/102005'/{}'/0'/0'", index)),
|
||||
CryptoAlg::Unknown => Err(SignerError::InvalidInput),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a single algorithm key entry.
|
||||
fn derive_alg_key(
|
||||
mnemonic_phrase: &str,
|
||||
path: &str,
|
||||
alg: CryptoAlg,
|
||||
index: i32,
|
||||
) -> Result<AlgKeyEntry, NsignerError> {
|
||||
) -> Result<AlgKeyEntry, SignerError> {
|
||||
let sizes = alg
|
||||
.sizes()
|
||||
.ok_or(NsignerError::KeyDerivationFailed)?;
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
|
||||
let seed = crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
|
||||
// PQ algorithms use the v2 seeded derivation: exact-length seed
|
||||
// (32/48/64 B) from BIP-32 children, fed to the seeded keygen APIs.
|
||||
// Classical algorithms use the plain 32-byte derived seed.
|
||||
let seed = match alg {
|
||||
CryptoAlg::MlDsa65 => {
|
||||
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 32)?
|
||||
}
|
||||
CryptoAlg::SlhDsa128s => {
|
||||
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 48)?
|
||||
}
|
||||
CryptoAlg::MlKem768 => {
|
||||
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 64)?
|
||||
}
|
||||
_ => crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?.to_vec(),
|
||||
};
|
||||
|
||||
match alg {
|
||||
CryptoAlg::Secp256k1 => {
|
||||
@@ -111,19 +131,19 @@ fn derive_alg_key(
|
||||
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic_phrase, "");
|
||||
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
|
||||
let path_indices = nips::nip006::parse_bip44_path(path)
|
||||
.map_err(|_| NsignerError::KeyDerivationFailed)?;
|
||||
.map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (derived_key, _) = nips::nip006::bip32_derive_path(
|
||||
&master_key,
|
||||
&master_chain_code,
|
||||
&path_indices,
|
||||
)
|
||||
.map_err(|_| NsignerError::KeyDerivationFailed)?;
|
||||
.map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
|
||||
let mut priv_arr = [0u8; 32];
|
||||
priv_arr.copy_from_slice(&derived_key);
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
|
||||
let pk = nostr_core::crypto::keys::public_key_from_secret_key(&sk)
|
||||
.map_err(|_| NsignerError::CryptoFailed)?;
|
||||
.map_err(|_| SignerError::CryptoFailed)?;
|
||||
|
||||
let pubkey_hex = hex::encode(pk.as_bytes());
|
||||
let key_id = if pubkey_hex.len() >= 16 {
|
||||
@@ -149,7 +169,9 @@ fn derive_alg_key(
|
||||
})
|
||||
}
|
||||
CryptoAlg::Ed25519 => {
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed);
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed_arr);
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
let key_id = if pubkey_hex.len() >= 16 {
|
||||
pubkey_hex[..16].to_string()
|
||||
@@ -174,7 +196,9 @@ fn derive_alg_key(
|
||||
})
|
||||
}
|
||||
CryptoAlg::X25519 => {
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed);
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed_arr);
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
let key_id = if pubkey_hex.len() >= 16 {
|
||||
pubkey_hex[..16].to_string()
|
||||
@@ -198,10 +222,52 @@ fn derive_alg_key(
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
|
||||
// PQ algorithms — TODO: Phase 13
|
||||
Err(NsignerError::NotYetImplemented)
|
||||
CryptoAlg::MlDsa65 => {
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr)?;
|
||||
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
|
||||
}
|
||||
CryptoAlg::Unknown => Err(NsignerError::InvalidInput),
|
||||
CryptoAlg::SlhDsa128s => {
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::slh_dsa_128s_keygen_from_seed(&seed)?;
|
||||
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
|
||||
}
|
||||
CryptoAlg::MlKem768 => {
|
||||
let (priv_bytes, pub_bytes) = crate::pq_crypto::ml_kem_768_keygen_from_seed(&seed)?;
|
||||
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
|
||||
}
|
||||
CryptoAlg::Unknown => Err(SignerError::InvalidInput),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an `AlgKeyEntry` from PQ keygen output (seed-form private key).
|
||||
fn finish_pq_entry(
|
||||
alg: CryptoAlg,
|
||||
index: i32,
|
||||
sizes: crate::pq_crypto::CryptoAlgSizes,
|
||||
priv_bytes: Vec<u8>,
|
||||
pub_bytes: Vec<u8>,
|
||||
) -> Result<AlgKeyEntry, SignerError> {
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
let key_id = if pubkey_hex.len() >= 16 {
|
||||
pubkey_hex[..16].to_string()
|
||||
} else {
|
||||
pubkey_hex.clone()
|
||||
};
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
|
||||
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
|
||||
pub_buf.copy_from(&pub_bytes);
|
||||
|
||||
Ok(AlgKeyEntry {
|
||||
alg,
|
||||
index,
|
||||
private_key: priv_buf,
|
||||
public_key: pub_buf,
|
||||
pubkey_hex,
|
||||
key_id,
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,17 +132,17 @@ pub fn verify_request(
|
||||
return Err((AUTH_ERR_KIND_INVALID, "auth_kind_invalid"));
|
||||
}
|
||||
|
||||
// Extract tags: nsigner_rpc, nsigner_method, nsigner_body_hash
|
||||
// Extract tags: signer_rpc, signer_method, signer_body_hash
|
||||
let mut tag_rpc: Option<String> = None;
|
||||
let mut tag_method: Option<String> = None;
|
||||
let mut tag_body_hash: Option<String> = None;
|
||||
|
||||
for tag in &auth_event.tags {
|
||||
if tag.kind() == "nsigner_rpc" {
|
||||
if tag.kind() == "signer_rpc" {
|
||||
tag_rpc = tag.get(1).map(|s| s.to_string());
|
||||
} else if tag.kind() == "nsigner_method" {
|
||||
} else if tag.kind() == "signer_method" {
|
||||
tag_method = tag.get(1).map(|s| s.to_string());
|
||||
} else if tag.kind() == "nsigner_body_hash" {
|
||||
} else if tag.kind() == "signer_body_hash" {
|
||||
tag_body_hash = tag.get(1).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# `signer-client` — Rust CLI for signer
|
||||
|
||||
A standalone command-line client that connects to a running [`signer`](../..)
|
||||
process and calls its JSON-RPC verbs over stdin/stdout. Designed for
|
||||
pipe-to-`nak` workflows. Rust port of the C
|
||||
[`n_signer_client.c`](../../n_signer/client/n_signer_client.c).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cargo build --bin signer-client
|
||||
```
|
||||
|
||||
Produces `target/debug/signer-client` (or `target/release/signer-client`).
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
signer-client [global options] <verb> [verb args...]
|
||||
```
|
||||
|
||||
### Global options
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
|
||||
| `--timeout <ms>` | `5000` | Transport timeout |
|
||||
| `--tcp <host:port>` | none | TCP transport (requires `--auth-privkey`) |
|
||||
| `--serial <device>` | none | USB CDC-ACM serial transport |
|
||||
| `--qrexec <qube:service>` | none | Qubes qrexec transport |
|
||||
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
|
||||
| `--auth-label <text>` | none | Auth envelope label |
|
||||
|
||||
### Selector options (nostr verbs)
|
||||
|
||||
| Flag | Meaning | JSON emitted |
|
||||
|------|---------|-------------|
|
||||
| `--role <name>` | Named path-role registered in the signer | `{"role":"<name>"}` |
|
||||
| `--path <path>` | Full BIP-44 derivation path | `{"role_path":"<path>"}` |
|
||||
|
||||
### Algorithm options (algorithm verbs)
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `--algorithm`, `-a <alg>` | none | `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
|
||||
| `--index <N>` | `0` | Algorithm derivation index |
|
||||
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 `sign`/`verify` only |
|
||||
| `--encoding <ascii\|binary>` | `ascii` | OTP `encrypt`/`decrypt` only |
|
||||
| `--format <plain\|structured>` | `plain` | `get-public-key` output shape |
|
||||
|
||||
### Mine-event options
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `--difficulty <N>` | Target leading zero bits |
|
||||
| `--threads <N>` | Mining threads (default 1) |
|
||||
| `--timeout-sec <N>` | Mining timeout in seconds |
|
||||
|
||||
## Verb reference
|
||||
|
||||
### Utility
|
||||
|
||||
| Verb | stdout |
|
||||
|------|--------|
|
||||
| `list` | Lists running signer abstract sockets (one per line) |
|
||||
|
||||
### Metadata
|
||||
|
||||
| Verb | RPC method | stdout |
|
||||
|------|------------|--------|
|
||||
| `get-info` | `get_info` | raw result JSON (name, version, verbs, algorithms) |
|
||||
|
||||
### Nostr verbs (role-based; require `--role` + `--path`)
|
||||
|
||||
| Verb | RPC method | stdin/argv | stdout |
|
||||
|------|------------|------------|--------|
|
||||
| `get-public-key` | `nostr_get_public_key` | none | pubkey hex (or structured JSON with `--format structured`) |
|
||||
| `sign-event` | `nostr_sign_event` | event JSON from argv or stdin | signed event JSON |
|
||||
| `mine-event` | `nostr_mine_event` | event JSON from argv or stdin | signed mined event JSON |
|
||||
| `nip04-encrypt <peer>` | `nostr_nip04_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip04-decrypt <peer>` | `nostr_nip04_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
| `nip44-encrypt <peer>` | `nostr_nip44_encrypt` | plaintext from argv or stdin | ciphertext |
|
||||
| `nip44-decrypt <peer>` | `nostr_nip44_decrypt` | ciphertext from argv or stdin | plaintext |
|
||||
|
||||
### Algorithm-based verbs (use `--algorithm` + `--index`)
|
||||
|
||||
| Verb | RPC method | argv | stdout |
|
||||
|------|------------|------|--------|
|
||||
| `get-public-key` | `get_public_key` | none | structured JSON `{"algorithm":...,"public_key":...,"key_id":...}` |
|
||||
| `sign <msg-hex>` | `sign` | hex bytes | structured JSON `{"signature":...,"algorithm":...,"key_id":...}` |
|
||||
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
|
||||
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | structured JSON |
|
||||
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | structured JSON |
|
||||
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | structured JSON |
|
||||
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
|
||||
| `encrypt <plaintext>` | `encrypt` | plaintext (argv or stdin) | ciphertext |
|
||||
| `decrypt <ciphertext>` | `decrypt` | ciphertext (argv or stdin) | plaintext |
|
||||
|
||||
### Generic escape hatch
|
||||
|
||||
| Verb | RPC method | input | stdout |
|
||||
|------|------------|-------|--------|
|
||||
| `call <method>` | `<method>` | JSON params array from stdin or argv | raw result JSON |
|
||||
|
||||
## Selector explanation
|
||||
|
||||
The `nostr_*` verbs select a key via the options object using both
|
||||
`--role` and `--path`:
|
||||
|
||||
- **`--role <name> --path <path>`** — Both required for all `nostr_*` verbs.
|
||||
Sends `{"role":"<name>","role_path":"<path>"}` to the server.
|
||||
- **`--role` without `--path`** — Client-side error: `--path is required`.
|
||||
- **`--path` without `--role`** — Client-side error: `--role is required`.
|
||||
|
||||
For algorithm verbs, `--algorithm` and `--index` populate the options object
|
||||
instead.
|
||||
|
||||
## Pipe-to-nak recipes
|
||||
|
||||
```bash
|
||||
# Get public key
|
||||
signer-client --role main --path "m/44'/1237'/0'/0/0" get-public-key
|
||||
|
||||
# Sign an event and publish via nak
|
||||
echo '{"kind":1,"content":"hello nostr","tags":[],"created_at":1700000000}' \
|
||||
| signer-client --role main --path "m/44'/1237'/0'/0/0" sign-event \
|
||||
| nak publish
|
||||
|
||||
# Algorithm-based signing
|
||||
signer-client --algorithm ed25519 --index 0 sign 68656c6c6f
|
||||
|
||||
# Verify a signature
|
||||
signer-client --algorithm secp256k1 verify <msg-hex> <sig-hex> && echo "valid"
|
||||
|
||||
# Get signer info
|
||||
signer-client get-info
|
||||
```
|
||||
|
||||
## Transport options
|
||||
|
||||
| Transport | Flag | Notes |
|
||||
|-----------|------|-------|
|
||||
| UNIX abstract socket | `--socket-name <name>` or auto-discover | Default. Auto-discovers if exactly one `signer*` socket exists. |
|
||||
| TCP | `--tcp <host:port>` | Requires `--auth-privkey` for auth envelope. |
|
||||
| Serial (USB CDC-ACM) | `--serial <device>` | e.g. `--serial /dev/ttyACM0` |
|
||||
| Qubes qrexec | `--qrexec <qube:service>` | e.g. `--qrexec sys-signer:qubes.SignerRpc` |
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Invalid (verify verb only — signature is invalid) |
|
||||
| 2 | Error (transport, RPC, or usage error) |
|
||||
|
||||
## stdin/stdout contract
|
||||
|
||||
- All payload output goes to stdout as a single line, newline-terminated.
|
||||
- All diagnostics (errors, warnings) go to stderr.
|
||||
- `sign-event`, `nip04-*`, `nip44-*`, `derive`, `encrypt`, `decrypt` read
|
||||
their payload from argv if present, otherwise from stdin (one line).
|
||||
- `sign`, `verify`, `encapsulate`, `decapsulate`, `derive-shared-secret`
|
||||
take hex from argv only (binary payloads).
|
||||
- `call` reads a JSON params array from stdin (one line) or argv.
|
||||
|
||||
## Module layout
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| [`main.rs`](main.rs:1) | Entry point, CLI parse, verb dispatch, stdin/stdout |
|
||||
| [`cli.rs`](cli.rs:1) | clap `Cli` / `Verb` structs + usage text |
|
||||
| [`transport.rs`](transport.rs:1) | `ClientTransport` enum (Unix/Tcp/Serial/Qrexec) |
|
||||
| [`rpc.rs`](rpc.rs:1) | Low-level `SignerClient` (framed JSON-RPC send/recv) |
|
||||
| [`signer.rs`](signer.rs:1) | High-level `SignerSigner` typed-verb wrappers |
|
||||
| [`auth.rs`](auth.rs:1) | Client-side auth envelope builder (NIP-42 kind 22242) |
|
||||
|
||||
## See also
|
||||
|
||||
- [`plans/signer_client_plan.md`](../../plans/signer_client_plan.md:1) — implementation plan
|
||||
- [`README.md`](../../README.md:1) — signer main documentation
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Client-side auth envelope builder for TCP/qrexec transports.
|
||||
//!
|
||||
//! Builds a NIP-42 kind-22242 auth event matching the wire shape verified by
|
||||
//! [`signer::auth_envelope::verify_request`]. The event is attached as a
|
||||
//! top-level `"auth"` field on the JSON-RPC request.
|
||||
//!
|
||||
//! Tag contract (must match the server verifier):
|
||||
//! - `["signer_rpc", <request id>]`
|
||||
//! - `["signer_method", <method>]`
|
||||
//! - `["signer_body_hash", <hex sha256 of compact params JSON>]`
|
||||
//! - `content` = auth label
|
||||
|
||||
use nostr_core::types::{Event, Kind, SecretKey, Tag};
|
||||
use serde_json::Value;
|
||||
|
||||
/// NIP-42 auth event kind (matches server `AUTH_EVENT_KIND`).
|
||||
const AUTH_EVENT_KIND: u64 = 22242;
|
||||
|
||||
/// Build a signed auth event for a JSON-RPC request.
|
||||
///
|
||||
/// `request_id` is the JSON-RPC `id`, `method` is the verb, `params` is the
|
||||
/// params array (will be serialized compactly for the body hash), and
|
||||
/// `label` becomes the event `content`.
|
||||
pub fn build_auth_event(
|
||||
privkey_hex: &str,
|
||||
request_id: &str,
|
||||
method: &str,
|
||||
params: &Value,
|
||||
label: &str,
|
||||
) -> Result<Event, String> {
|
||||
// Decode 32-byte privkey
|
||||
let priv_bytes = hex::decode(privkey_hex)
|
||||
.map_err(|_| "--auth-privkey must be valid hex".to_string())?;
|
||||
if priv_bytes.len() != 32 {
|
||||
return Err("--auth-privkey must be 32 bytes (64 hex chars)".into());
|
||||
}
|
||||
let mut priv_arr = [0u8; 32];
|
||||
priv_arr.copy_from_slice(&priv_bytes);
|
||||
let sk = SecretKey::from_bytes(priv_arr);
|
||||
|
||||
// Body hash = SHA-256 of compact params JSON (matches server)
|
||||
let params_compact = serde_json::to_string(params)
|
||||
.map_err(|e| format!("params serialize failed: {}", e))?;
|
||||
let body_hash = nostr_core::crypto::sha256::sha256(params_compact.as_bytes());
|
||||
let body_hash_hex = hex::encode(&body_hash);
|
||||
|
||||
// Tags (two-element: kind + value)
|
||||
let tags = vec![
|
||||
Tag::with_value("signer_rpc", request_id),
|
||||
Tag::with_value("signer_method", method),
|
||||
Tag::with_value("signer_body_hash", &body_hash_hex),
|
||||
];
|
||||
|
||||
let created_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let kind = Kind::from_u64(AUTH_EVENT_KIND);
|
||||
|
||||
nips::nip001::create_and_sign_event(kind, label, tags, &sk, created_at)
|
||||
.map_err(|e| format!("auth event sign failed: {}", e))
|
||||
}
|
||||
|
||||
/// Attach a signed auth event to a JSON-RPC request value (in place).
|
||||
///
|
||||
/// The request must be a JSON object with `id`, `method`, `params` fields.
|
||||
pub fn attach_auth(
|
||||
request: &mut Value,
|
||||
privkey_hex: &str,
|
||||
label: &str,
|
||||
) -> Result<(), String> {
|
||||
let id = request
|
||||
.get("id")
|
||||
.ok_or("request missing id")?
|
||||
.to_string();
|
||||
let id_str = if let Some(s) = request.get("id").and_then(|v| v.as_str()) {
|
||||
s.to_string()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
let method = request
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("request missing method")?
|
||||
.to_string();
|
||||
let params = request
|
||||
.get("params")
|
||||
.ok_or("request missing params")?;
|
||||
|
||||
let event = build_auth_event(privkey_hex, &id_str, &method, params, label)?;
|
||||
let event_val = serde_json::to_value(&event)
|
||||
.map_err(|e| format!("auth event serialize failed: {}", e))?;
|
||||
if let Some(obj) = request.as_object_mut() {
|
||||
obj.insert("auth".to_string(), event_val);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("request is not an object".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! CLI parsing — clap structs mirroring the C `n_signer_client.c` argv loop.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// Command-line arguments for signer-client.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "signer-client",
|
||||
version = ::signer::VERSION,
|
||||
about = "Standalone CLI for the signer JSON-RPC API",
|
||||
after_help = "EXAMPLES:
|
||||
# List running signer sockets
|
||||
signer-client list
|
||||
|
||||
# Get signer metadata (auto-discovers the single running socket)
|
||||
signer-client get-info
|
||||
|
||||
# Get a Nostr public key by role + path
|
||||
signer-client --role main --path \"m/44'/1237'/0'/0/0\" get-public-key
|
||||
|
||||
# Get a public key by algorithm + index
|
||||
signer-client --algorithm ed25519 --index 0 get-public-key
|
||||
|
||||
# Sign a raw message (hex) with ed25519
|
||||
signer-client --algorithm ed25519 --index 0 sign 68656c6c6f
|
||||
|
||||
# Verify a signature (exit 0 = valid, 1 = invalid)
|
||||
signer-client --algorithm ed25519 --index 0 verify <msg-hex> <sig-hex>
|
||||
|
||||
# Sign a Nostr event from stdin and pipe to nak for publishing
|
||||
echo '{\"kind\":1,\"content\":\"hello nostr\",\"tags\":[],\"created_at\":1700000000}' \\
|
||||
| signer-client --role main --path \"m/44'/1237'/0'/0/0\" sign-event \\
|
||||
| nak publish
|
||||
|
||||
# NIP-44 encrypt a message to a peer
|
||||
signer-client --role main --path \"m/44'/1237'/0'/0/0\" nip44-encrypt <peer-pubkey> 'secret'
|
||||
|
||||
# NIP-44 decrypt
|
||||
signer-client --role main --path \"m/44'/1237'/0'/0/0\" nip44-decrypt <peer-pubkey> <ciphertext>
|
||||
|
||||
# Derive an HMAC digest (secp256k1)
|
||||
|
||||
signer-client --algorithm secp256k1 --index 0 derive testdata
|
||||
|
||||
# Raw JSON-RPC passthrough
|
||||
echo '[]' | signer-client call get_info
|
||||
|
||||
# Explicit socket name
|
||||
signer-client -n signer01 get-info
|
||||
|
||||
# TCP transport (requires auth envelope privkey)
|
||||
signer-client --tcp host:port --auth-privkey <64-hex> get-info
|
||||
|
||||
# Serial transport (USB CDC-ACM)
|
||||
signer-client --serial /dev/ttyACM0 get-info
|
||||
|
||||
# Qubes qrexec transport
|
||||
signer-client --qrexec sys-signer:qubes.SignerRpc get-info
|
||||
|
||||
Run 'signer-client <verb> --help' for verb-specific details."
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Abstract socket name (without @ prefix). Default: auto-discover.
|
||||
#[arg(long, short = 'n', value_name = "name", global = true)]
|
||||
pub socket_name: Option<String>,
|
||||
|
||||
/// Transport timeout in milliseconds (default 5000).
|
||||
#[arg(long, value_name = "ms", global = true, default_value = "5000")]
|
||||
pub timeout: u64,
|
||||
|
||||
/// TCP transport: host:port (requires --auth-privkey).
|
||||
#[arg(long, value_name = "host:port", global = true)]
|
||||
pub tcp: Option<String>,
|
||||
|
||||
/// USB CDC-ACM serial transport: device path.
|
||||
#[arg(long, value_name = "device", global = true)]
|
||||
pub serial: Option<String>,
|
||||
|
||||
/// Qubes qrexec transport: qube:service.
|
||||
#[arg(long, value_name = "qube:service", global = true)]
|
||||
pub qrexec: Option<String>,
|
||||
|
||||
/// Auth envelope privkey (32 bytes hex) for TCP/qrexec.
|
||||
#[arg(long, value_name = "hex", global = true)]
|
||||
pub auth_privkey: Option<String>,
|
||||
|
||||
/// Auth envelope label.
|
||||
#[arg(long, value_name = "text", global = true)]
|
||||
pub auth_label: Option<String>,
|
||||
|
||||
// ── Selector options (nostr verbs) ───────────────────────────────
|
||||
/// Named path-role.
|
||||
#[arg(long, value_name = "name", global = true)]
|
||||
pub role: Option<String>,
|
||||
|
||||
/// Full BIP-44 derivation path.
|
||||
#[arg(long, value_name = "path", global = true)]
|
||||
pub path: Option<String>,
|
||||
|
||||
// ── Algorithm options ────────────────────────────────────────────
|
||||
/// Algorithm: secp256k1/ed25519/x25519/ml-dsa-65/slh-dsa-128s/ml-kem-768/otp.
|
||||
#[arg(long, short = 'a', value_name = "alg", global = true)]
|
||||
pub algorithm: Option<String>,
|
||||
|
||||
/// Algorithm derivation index (default 0).
|
||||
#[arg(long, value_name = "N", global = true)]
|
||||
pub index: Option<i64>,
|
||||
|
||||
/// secp256k1 sign/verify scheme: schnorr|ecdsa (default schnorr).
|
||||
#[arg(long, value_name = "s", global = true)]
|
||||
pub scheme: Option<String>,
|
||||
|
||||
/// OTP encoding: ascii|binary (default ascii).
|
||||
#[arg(long, value_name = "enc", global = true)]
|
||||
pub encoding: Option<String>,
|
||||
|
||||
/// get-public-key output format: plain|structured (default plain).
|
||||
#[arg(long, value_name = "fmt", global = true)]
|
||||
pub format: Option<String>,
|
||||
|
||||
// ── Mine-event options ───────────────────────────────────────────
|
||||
/// Target leading zero bits for mine-event.
|
||||
#[arg(long, value_name = "N", global = true)]
|
||||
pub difficulty: Option<u32>,
|
||||
|
||||
/// Mining threads (default 1).
|
||||
#[arg(long, value_name = "N", global = true)]
|
||||
pub threads: Option<u32>,
|
||||
|
||||
/// Mining timeout in seconds.
|
||||
#[arg(long, value_name = "N", global = true)]
|
||||
pub timeout_sec: Option<u64>,
|
||||
|
||||
/// Verb subcommand.
|
||||
#[command(subcommand)]
|
||||
pub verb: Verb,
|
||||
}
|
||||
|
||||
/// Verb subcommands.
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Verb {
|
||||
/// List running signer abstract sockets.
|
||||
List,
|
||||
|
||||
/// Get signer metadata.
|
||||
GetInfo,
|
||||
|
||||
/// Get a public key (nostr role-based or algorithm-based).
|
||||
GetPublicKey,
|
||||
|
||||
/// Sign a Nostr event (event JSON from argv or stdin).
|
||||
SignEvent,
|
||||
|
||||
/// Mine a Nostr event with proof-of-work.
|
||||
MineEvent,
|
||||
|
||||
/// NIP-04 encrypt: <peer-pubkey> [plaintext]
|
||||
Nip04Encrypt {
|
||||
/// Peer public key (hex).
|
||||
peer: String,
|
||||
/// Plaintext (read from stdin if omitted).
|
||||
plaintext: Option<String>,
|
||||
},
|
||||
|
||||
/// NIP-04 decrypt: <peer-pubkey> [ciphertext]
|
||||
Nip04Decrypt {
|
||||
/// Peer public key (hex).
|
||||
peer: String,
|
||||
/// Ciphertext (read from stdin if omitted).
|
||||
ciphertext: Option<String>,
|
||||
},
|
||||
|
||||
/// NIP-44 encrypt: <peer-pubkey> [plaintext]
|
||||
Nip44Encrypt {
|
||||
/// Peer public key (hex).
|
||||
peer: String,
|
||||
/// Plaintext (read from stdin if omitted).
|
||||
plaintext: Option<String>,
|
||||
},
|
||||
|
||||
/// NIP-44 decrypt: <peer-pubkey> [ciphertext]
|
||||
Nip44Decrypt {
|
||||
/// Peer public key (hex).
|
||||
peer: String,
|
||||
/// Ciphertext (read from stdin if omitted).
|
||||
ciphertext: Option<String>,
|
||||
},
|
||||
|
||||
/// Sign a raw message: <msg-hex>
|
||||
Sign {
|
||||
/// Message bytes (hex).
|
||||
msg_hex: String,
|
||||
},
|
||||
|
||||
/// Verify a signature: <msg-hex> <sig-hex>
|
||||
Verify {
|
||||
/// Message bytes (hex).
|
||||
msg_hex: String,
|
||||
/// Signature bytes (hex).
|
||||
sig_hex: String,
|
||||
},
|
||||
|
||||
/// Derive an HMAC or algorithm digest: [data]
|
||||
Derive {
|
||||
/// Data (UTF-8; read from stdin if omitted).
|
||||
data: Option<String>,
|
||||
},
|
||||
|
||||
/// ML-KEM encapsulate: <peer-pubkey-hex>
|
||||
Encapsulate {
|
||||
/// Peer public key (hex).
|
||||
peer_pubkey_hex: String,
|
||||
},
|
||||
|
||||
/// ML-KEM decapsulate: <ciphertext-hex>
|
||||
Decapsulate {
|
||||
/// Ciphertext (hex).
|
||||
ciphertext_hex: String,
|
||||
},
|
||||
|
||||
/// X25519 derive-shared-secret: <peer-pubkey-hex>
|
||||
DeriveSharedSecret {
|
||||
/// Peer public key (hex).
|
||||
peer_pubkey_hex: String,
|
||||
},
|
||||
|
||||
/// OTP encrypt: [plaintext]
|
||||
Encrypt {
|
||||
/// Plaintext (read from stdin if omitted).
|
||||
plaintext: Option<String>,
|
||||
},
|
||||
|
||||
/// OTP decrypt: [ciphertext]
|
||||
Decrypt {
|
||||
/// Ciphertext (read from stdin if omitted).
|
||||
ciphertext: Option<String>,
|
||||
},
|
||||
|
||||
/// Raw passthrough: <method> [params-json...]
|
||||
Call {
|
||||
/// JSON-RPC method name.
|
||||
method: String,
|
||||
/// JSON params (joined; read from stdin if omitted).
|
||||
params: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Print usage to stderr (used for --help fallback / errors).
|
||||
#[allow(dead_code)]
|
||||
pub fn print_usage(prog: &str) {
|
||||
eprintln!(
|
||||
"Usage: {prog} [global options] <verb> [verb args...]\n\
|
||||
\n\
|
||||
Global options:\n\
|
||||
\x20 -n, --socket-name <name> Abstract socket name (default: auto-discover)\n\
|
||||
\x20 --timeout <ms> Transport timeout (default 5000)\n\
|
||||
\x20 --tcp <host:port> TCP transport (requires --auth-privkey)\n\
|
||||
\x20 --serial <device> USB CDC-ACM serial transport\n\
|
||||
\x20 --qrexec <qube:svc> Qubes qrexec transport\n\
|
||||
\x20 --auth-privkey <hex> Auth envelope privkey (32 bytes hex)\n\
|
||||
\x20 --auth-label <text> Auth envelope label\n\
|
||||
\n\
|
||||
Selector options (nostr verbs):\n\
|
||||
\x20 --role <name> Named path-role\n\
|
||||
\x20 --path <path> Full BIP-44 derivation path\n\
|
||||
\n\
|
||||
Algorithm options:\n\
|
||||
\x20 -a, --algorithm <alg> secp256k1/ed25519/x25519/ml-dsa-65/slh-dsa-128s/ml-kem-768/otp\n\
|
||||
\x20 --scheme <schnorr|ecdsa> secp256k1 sign/verify scheme (default schnorr)\n\
|
||||
\x20 --encoding <ascii|binary> OTP encoding (default ascii)\n\
|
||||
\x20 --format <plain|structured> get-public-key output (default plain)\n\
|
||||
\x20 --index <N> Algorithm derivation index\n\
|
||||
\n\
|
||||
Mine-event options:\n\
|
||||
\x20 --difficulty <N> Target leading zero bits\n\
|
||||
\x20 --threads <N> Mining threads (default 1)\n\
|
||||
\x20 --timeout-sec <N> Mining timeout in seconds\n\
|
||||
\n\
|
||||
Verbs:\n\
|
||||
\x20 list List running signer sockets\n\
|
||||
\x20 get-info\n\
|
||||
\x20 get-public-key\n\
|
||||
\x20 sign-event\n\
|
||||
\x20 mine-event\n\
|
||||
\x20 nip04-encrypt <peer-pubkey>\n\
|
||||
\x20 nip04-decrypt <peer-pubkey>\n\
|
||||
\x20 nip44-encrypt <peer-pubkey>\n\
|
||||
\x20 nip44-decrypt <peer-pubkey>\n\
|
||||
\x20 sign <msg-hex>\n\
|
||||
\x20 verify <msg-hex> <sig-hex>\n\
|
||||
\x20 derive <data>\n\
|
||||
\x20 encapsulate <peer-pubkey-hex>\n\
|
||||
\x20 decapsulate <ciphertext-hex>\n\
|
||||
\x20 derive-shared-secret <peer-pubkey-hex>\n\
|
||||
\x20 encrypt <plaintext>\n\
|
||||
\x20 decrypt <ciphertext>\n\
|
||||
\x20 call <method>\n\
|
||||
\n\
|
||||
Run '{prog} --help' for full clap-generated usage."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
//! signer-client — standalone CLI for the signer JSON-RPC API.
|
||||
//!
|
||||
//! Port of the C [`n_signer_client.c`](../../n_signer/client/n_signer_client.c).
|
||||
//! Connects to a running `signer` process over its framed transports
|
||||
//! (Unix / TCP / serial / qrexec) and exposes the full verb surface over
|
||||
//! stdin/stdout so that signed events can be piped directly into `nak publish`.
|
||||
|
||||
mod auth;
|
||||
mod cli;
|
||||
mod rpc;
|
||||
mod signer;
|
||||
mod transport;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Verb};
|
||||
use crate::signer::{result_to_line, AlgOptions, SignerSigner};
|
||||
|
||||
fn main() {
|
||||
let cli = match Cli::try_parse() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
// clap prints its own usage on --help / errors; exit accordingly.
|
||||
e.exit();
|
||||
}
|
||||
};
|
||||
|
||||
std::process::exit(run(cli));
|
||||
}
|
||||
|
||||
/// Entry point. Returns the exit code.
|
||||
fn run(cli: Cli) -> i32 {
|
||||
// ── list verb (no connection needed) ──────────────────────────────
|
||||
if matches!(cli.verb, Verb::List) {
|
||||
let sockets = ::signer::socket_name::list_sockets();
|
||||
if sockets.is_empty() {
|
||||
println!("no signer sockets found");
|
||||
} else {
|
||||
for name in &sockets {
|
||||
println!("{}", name);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── validate --index usage (algorithm-only) ───────────────────────
|
||||
let is_algorithm_verb = cli.algorithm.is_some();
|
||||
if cli.index.is_some() && !is_algorithm_verb {
|
||||
eprintln!("error: --index is only valid with --algorithm (for algorithm verbs)");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// ── validate --role / --path for nostr verbs ──────────────────────
|
||||
let is_nostr_verb = matches!(
|
||||
cli.verb,
|
||||
Verb::GetPublicKey
|
||||
| Verb::SignEvent
|
||||
| Verb::MineEvent
|
||||
| Verb::Nip04Encrypt { .. }
|
||||
| Verb::Nip04Decrypt { .. }
|
||||
| Verb::Nip44Encrypt { .. }
|
||||
| Verb::Nip44Decrypt { .. }
|
||||
);
|
||||
if is_nostr_verb && !is_algorithm_verb {
|
||||
if cli.role.is_none() {
|
||||
eprintln!("error: --role is required for nostr verbs");
|
||||
return 2;
|
||||
}
|
||||
if cli.path.is_none() {
|
||||
eprintln!("error: --path is required for nostr verbs");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ── open transport ────────────────────────────────────────────────
|
||||
let (transport, _resolved_name) = match transport::open_from_cli(
|
||||
cli.socket_name.as_deref(),
|
||||
cli.timeout,
|
||||
cli.tcp.as_deref(),
|
||||
cli.serial.as_deref(),
|
||||
cli.qrexec.as_deref(),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
// ── TCP requires auth-privkey ─────────────────────────────────────
|
||||
if cli.tcp.is_some() && cli.auth_privkey.is_none() {
|
||||
eprintln!("error: --tcp requires --auth-privkey");
|
||||
return 2;
|
||||
}
|
||||
|
||||
let mut signer = SignerSigner::new(rpc::SignerClient::new(transport));
|
||||
|
||||
// ── set selector for nostr verbs ──────────────────────────────────
|
||||
if is_nostr_verb && !is_algorithm_verb {
|
||||
signer.set_selector(cli.role.clone(), cli.path.clone());
|
||||
}
|
||||
|
||||
// ── set auth envelope if configured ───────────────────────────────
|
||||
if let Some(privkey) = &cli.auth_privkey {
|
||||
// Validate hex length up front.
|
||||
if hex::decode(privkey).map(|b| b.len()).unwrap_or(0) != 32 {
|
||||
eprintln!("error: --auth-privkey must be 32 bytes (64 hex chars)");
|
||||
return 2;
|
||||
}
|
||||
signer.set_auth(privkey.clone(), cli.auth_label.clone().unwrap_or_default());
|
||||
}
|
||||
|
||||
// ── build algorithm options if --algorithm is set ─────────────────
|
||||
let alg_opts = if is_algorithm_verb {
|
||||
Some(AlgOptions {
|
||||
algorithm: cli.algorithm.clone().unwrap_or_else(|| "secp256k1".into()),
|
||||
index: cli.index.unwrap_or(0),
|
||||
scheme: cli.scheme.clone(),
|
||||
encoding: cli.encoding.clone(),
|
||||
format: cli.format.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ── dispatch verb ─────────────────────────────────────────────────
|
||||
match cli.verb {
|
||||
Verb::List => unreachable!("handled above"),
|
||||
|
||||
Verb::GetInfo => {
|
||||
match signer.get_info() {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::GetPublicKey => {
|
||||
if is_algorithm_verb {
|
||||
let opts = alg_opts.as_ref().unwrap();
|
||||
match signer.alg_get_public_key(opts) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let want_structured = cli.format.as_deref() == Some("structured");
|
||||
match signer.nostr_get_public_key(want_structured) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::SignEvent => {
|
||||
let event_json = match read_event_json(None) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match signer.nostr_sign_event(&event_json) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::MineEvent => {
|
||||
let event_json = match read_event_json(None) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match signer.nostr_mine_event(
|
||||
&event_json,
|
||||
cli.difficulty.unwrap_or(0),
|
||||
cli.threads.unwrap_or(1),
|
||||
cli.timeout_sec.unwrap_or(0),
|
||||
) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Nip04Encrypt { peer, plaintext } => {
|
||||
let pt = match read_payload(plaintext) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match signer.nip04_encrypt(&peer, &pt) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Nip04Decrypt { peer, ciphertext } => {
|
||||
let ct = match read_payload(ciphertext) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match signer.nip04_decrypt(&peer, &ct) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Nip44Encrypt { peer, plaintext } => {
|
||||
let pt = match read_payload(plaintext) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match signer.nip44_encrypt(&peer, &pt) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Nip44Decrypt { peer, ciphertext } => {
|
||||
let ct = match read_payload(ciphertext) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match signer.nip44_decrypt(&peer, &ct) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Sign { msg_hex } => {
|
||||
if hex::decode(&msg_hex).is_err() {
|
||||
eprintln!("error: invalid hex message");
|
||||
return 2;
|
||||
}
|
||||
let fallback = AlgOptions {
|
||||
algorithm: "secp256k1".into(),
|
||||
index: 0,
|
||||
scheme: cli.scheme.clone(),
|
||||
encoding: None,
|
||||
format: None,
|
||||
};
|
||||
let opts = alg_opts.as_ref().unwrap_or(&fallback);
|
||||
match signer.alg_sign(opts, &msg_hex) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Verify { msg_hex, sig_hex } => {
|
||||
if hex::decode(&msg_hex).is_err() {
|
||||
eprintln!("error: invalid hex message");
|
||||
return 2;
|
||||
}
|
||||
if hex::decode(&sig_hex).is_err() {
|
||||
eprintln!("error: invalid hex signature");
|
||||
return 2;
|
||||
}
|
||||
let fallback = AlgOptions {
|
||||
algorithm: "secp256k1".into(),
|
||||
index: 0,
|
||||
scheme: cli.scheme.clone(),
|
||||
encoding: None,
|
||||
format: None,
|
||||
};
|
||||
let opts = alg_opts.as_ref().unwrap_or(&fallback);
|
||||
match signer.alg_verify(opts, &msg_hex, &sig_hex) {
|
||||
Ok(valid) => {
|
||||
println!("{}", if valid { "valid" } else { "invalid" });
|
||||
if valid {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Derive { data } => {
|
||||
let data = match read_payload(data) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
if is_algorithm_verb {
|
||||
let opts = alg_opts.as_ref().unwrap();
|
||||
match signer.alg_derive(opts, &data) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nostr derive (HMAC) — use the nostr selector with the derive verb.
|
||||
let opts = signer.selector.to_options();
|
||||
match signer.call_raw_method("derive", serde_json::json!([data, opts])) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Encapsulate { peer_pubkey_hex } => {
|
||||
let opts = alg_opts.as_ref().unwrap();
|
||||
match signer.alg_encapsulate(opts, &peer_pubkey_hex) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Decapsulate { ciphertext_hex } => {
|
||||
let opts = alg_opts.as_ref().unwrap();
|
||||
match signer.alg_decapsulate(opts, &ciphertext_hex) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::DeriveSharedSecret { peer_pubkey_hex } => {
|
||||
let opts = alg_opts.as_ref().unwrap();
|
||||
match signer.alg_derive_shared_secret(opts, &peer_pubkey_hex) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Encrypt { plaintext } => {
|
||||
let pt = match read_payload(plaintext) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let fallback = AlgOptions {
|
||||
algorithm: "otp".into(),
|
||||
index: 0,
|
||||
scheme: None,
|
||||
encoding: cli.encoding.clone(),
|
||||
format: None,
|
||||
};
|
||||
let opts = alg_opts.as_ref().unwrap_or(&fallback);
|
||||
match signer.otp_encrypt(opts, &pt) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Decrypt { ciphertext } => {
|
||||
let ct = match read_payload(ciphertext) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let fallback = AlgOptions {
|
||||
algorithm: "otp".into(),
|
||||
index: 0,
|
||||
scheme: None,
|
||||
encoding: cli.encoding.clone(),
|
||||
format: None,
|
||||
};
|
||||
let opts = alg_opts.as_ref().unwrap_or(&fallback);
|
||||
match signer.otp_decrypt(opts, &ct) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Verb::Call { method, params } => {
|
||||
let params_value = match build_call_params(params) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match signer.call_raw_method(&method, params_value) {
|
||||
Ok(result) => {
|
||||
println!("{}", result_to_line(&result));
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one line from stdin (newline stripped).
|
||||
fn read_stdin_line() -> Result<String, String> {
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(|e| format!("stdin read failed: {}", e))?;
|
||||
let trimmed = input.trim_end_matches('\n').to_string();
|
||||
if trimmed.is_empty() {
|
||||
Err("no input on stdin".into())
|
||||
} else {
|
||||
Ok(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a payload: use `argv` if provided, else one stdin line.
|
||||
fn read_payload(argv: Option<String>) -> Result<String, String> {
|
||||
if let Some(s) = argv {
|
||||
Ok(s)
|
||||
} else {
|
||||
read_stdin_line()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read event JSON: from argv (passed in) or one stdin line.
|
||||
fn read_event_json(argv: Option<String>) -> Result<String, String> {
|
||||
read_payload(argv)
|
||||
}
|
||||
|
||||
/// Build a JSON params value for the `call` verb.
|
||||
///
|
||||
/// If argv params are given, join them with spaces and parse as JSON.
|
||||
/// Otherwise read one line from stdin and parse.
|
||||
fn build_call_params(params: Vec<String>) -> Result<serde_json::Value, String> {
|
||||
if params.is_empty() {
|
||||
let line = read_stdin_line()?;
|
||||
serde_json::from_str(&line)
|
||||
.map_err(|e| format!("failed to parse params JSON from stdin: {}", e))
|
||||
} else {
|
||||
let joined = params.join(" ");
|
||||
serde_json::from_str(&joined)
|
||||
.map_err(|e| format!("failed to parse params JSON from argv: {}", e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Low-level JSON-RPC 2.0 client — framed send/recv over a transport.
|
||||
//!
|
||||
//! Port of the C `signer_client_t` / `signer_client_call`. Builds a
|
||||
//! `{"id","method","params"}` request, sends it framed, receives the framed
|
||||
//! response, and splits `result` vs `error`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::transport::ClientTransport;
|
||||
|
||||
/// Low-level signer RPC client. Owns the transport.
|
||||
pub struct SignerClient {
|
||||
transport: ClientTransport,
|
||||
last_error: String,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl SignerClient {
|
||||
/// Wrap an open transport.
|
||||
pub fn new(transport: ClientTransport) -> Self {
|
||||
SignerClient {
|
||||
transport,
|
||||
last_error: String::new(),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Last error message from a failed call.
|
||||
#[allow(dead_code)]
|
||||
pub fn last_error(&self) -> &str {
|
||||
&self.last_error
|
||||
}
|
||||
|
||||
/// Allocate the next request id.
|
||||
fn next_request_id(&mut self) -> String {
|
||||
let id = self.next_id.to_string();
|
||||
self.next_id += 1;
|
||||
id
|
||||
}
|
||||
|
||||
/// Send a raw JSON-RPC request value and return the parsed response value.
|
||||
///
|
||||
/// The caller is responsible for attaching an `auth` field if needed.
|
||||
pub fn call_raw(&mut self, request: &Value) -> Result<Value, String> {
|
||||
let request_str = serde_json::to_string(request)
|
||||
.map_err(|e| format!("request serialize failed: {}", e))?;
|
||||
self.transport
|
||||
.send(&request_str)
|
||||
.map_err(|e| format!("send failed: {}", e))?;
|
||||
let response_str = self
|
||||
.transport
|
||||
.recv()
|
||||
.map_err(|e| format!("recv failed: {}", e))?;
|
||||
let response: Value = serde_json::from_str(&response_str)
|
||||
.map_err(|e| format!("response parse failed: {}", e))?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Call a method with the given params array (no auth).
|
||||
///
|
||||
/// On success, returns the `result` value. On error, sets `last_error`
|
||||
/// and returns `Err(message)`.
|
||||
pub fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let id = self.next_request_id();
|
||||
let request = json!({
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let response = self.call_raw(&request)?;
|
||||
self.extract_result(&response)
|
||||
}
|
||||
|
||||
/// Call a method with an auth envelope attached.
|
||||
pub fn call_with_auth(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: Value,
|
||||
auth_privkey: &str,
|
||||
auth_label: &str,
|
||||
) -> Result<Value, String> {
|
||||
let id = self.next_request_id();
|
||||
let mut request = json!({
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
super::auth::attach_auth(&mut request, auth_privkey, auth_label)?;
|
||||
let response = self.call_raw(&request)?;
|
||||
self.extract_result(&response)
|
||||
}
|
||||
|
||||
/// Extract `result` from a JSON-RPC response, or capture the error.
|
||||
fn extract_result(&mut self, response: &Value) -> Result<Value, String> {
|
||||
if let Some(err) = response.get("error") {
|
||||
let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown_error");
|
||||
self.last_error = format!("{} (code {})", msg, code);
|
||||
Err(self.last_error.clone())
|
||||
} else if let Some(result) = response.get("result") {
|
||||
Ok(result.clone())
|
||||
} else {
|
||||
self.last_error = "response has neither result nor error".into();
|
||||
Err(self.last_error.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_result_success() {
|
||||
let mut client = SignerClient {
|
||||
transport: ClientTransport::Unix(
|
||||
std::os::unix::net::UnixStream::pair().unwrap().0,
|
||||
),
|
||||
last_error: String::new(),
|
||||
next_id: 1,
|
||||
};
|
||||
let resp = json!({"id":"1","result":"deadbeef"});
|
||||
let r = client.extract_result(&resp).unwrap();
|
||||
assert_eq!(r, json!("deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_result_error() {
|
||||
let mut client = SignerClient {
|
||||
transport: ClientTransport::Unix(
|
||||
std::os::unix::net::UnixStream::pair().unwrap().0,
|
||||
),
|
||||
last_error: String::new(),
|
||||
next_id: 1,
|
||||
};
|
||||
let resp = json!({"id":"1","error":{"code":-32601,"message":"method_not_found"}});
|
||||
let r = client.extract_result(&resp);
|
||||
assert!(r.is_err());
|
||||
assert!(client.last_error().contains("method_not_found"));
|
||||
assert!(client.last_error().contains("-32601"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! High-level typed-verb layer — one method per JSON-RPC verb.
|
||||
//!
|
||||
//! Port of the C `nostr_signer_t` wrappers. Each method builds the correct
|
||||
//! `params` array + options object, calls the low-level [`SignerClient`],
|
||||
//! and parses the typed result.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::rpc::SignerClient;
|
||||
|
||||
/// Selector state for nostr verbs.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Selector {
|
||||
pub role: Option<String>,
|
||||
pub role_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
/// Build the options object for a nostr verb.
|
||||
pub fn to_options(&self) -> Value {
|
||||
let mut opts = serde_json::Map::new();
|
||||
if let Some(r) = &self.role {
|
||||
opts.insert("role".into(), json!(r));
|
||||
}
|
||||
if let Some(p) = &self.role_path {
|
||||
opts.insert("role_path".into(), json!(p));
|
||||
}
|
||||
Value::Object(opts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Algorithm options for algorithm-based verbs.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AlgOptions {
|
||||
pub algorithm: String,
|
||||
pub index: i64,
|
||||
pub scheme: Option<String>,
|
||||
pub encoding: Option<String>,
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
impl AlgOptions {
|
||||
/// Build the options object for an algorithm verb.
|
||||
pub fn to_options(&self) -> Value {
|
||||
let mut opts = serde_json::Map::new();
|
||||
opts.insert("algorithm".into(), json!(self.algorithm));
|
||||
opts.insert("index".into(), json!(self.index));
|
||||
if let Some(s) = &self.scheme {
|
||||
opts.insert("scheme".into(), json!(s));
|
||||
}
|
||||
if let Some(e) = &self.encoding {
|
||||
opts.insert("encoding".into(), json!(e));
|
||||
}
|
||||
if let Some(f) = &self.format {
|
||||
opts.insert("format".into(), json!(f));
|
||||
}
|
||||
Value::Object(opts)
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level signer signer. Wraps a low-level client and holds selector +
|
||||
/// auth state used across typed verbs.
|
||||
pub struct SignerSigner {
|
||||
pub client: SignerClient,
|
||||
pub selector: Selector,
|
||||
pub auth_privkey: Option<String>,
|
||||
pub auth_label: String,
|
||||
}
|
||||
|
||||
impl SignerSigner {
|
||||
pub fn new(client: SignerClient) -> Self {
|
||||
SignerSigner {
|
||||
client,
|
||||
selector: Selector::default(),
|
||||
auth_privkey: None,
|
||||
auth_label: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the role/path selector for nostr verbs.
|
||||
pub fn set_selector(&mut self, role: Option<String>, role_path: Option<String>) {
|
||||
self.selector = Selector { role, role_path };
|
||||
}
|
||||
|
||||
/// Set auth envelope credentials (for TCP/qrexec).
|
||||
pub fn set_auth(&mut self, privkey_hex: String, label: String) {
|
||||
self.auth_privkey = Some(privkey_hex);
|
||||
self.auth_label = label;
|
||||
}
|
||||
|
||||
/// Dispatch a call, attaching auth if configured.
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
if let Some(privkey) = &self.auth_privkey.clone() {
|
||||
self.client
|
||||
.call_with_auth(method, params, privkey, &self.auth_label)
|
||||
} else {
|
||||
self.client.call(method, params)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Metadata ─────────────────────────────────────────────────────
|
||||
|
||||
/// `get_info` → raw info object.
|
||||
pub fn get_info(&mut self) -> Result<Value, String> {
|
||||
self.call("get_info", json!([]))
|
||||
}
|
||||
|
||||
// ── Nostr verbs (role-based) ──────────────────────────────────────
|
||||
|
||||
/// `nostr_get_public_key` → pubkey hex string (plain) or structured object.
|
||||
pub fn nostr_get_public_key(&mut self, want_structured: bool) -> Result<Value, String> {
|
||||
let mut opts = self.selector.to_options();
|
||||
if want_structured {
|
||||
if let Some(obj) = opts.as_object_mut() {
|
||||
obj.insert("format".into(), json!("structured"));
|
||||
}
|
||||
}
|
||||
self.call("nostr_get_public_key", json!([opts]))
|
||||
}
|
||||
|
||||
/// `nostr_sign_event` → signed event JSON string.
|
||||
pub fn nostr_sign_event(&mut self, event_json: &str) -> Result<Value, String> {
|
||||
let opts = self.selector.to_options();
|
||||
self.call("nostr_sign_event", json!([event_json, opts]))
|
||||
}
|
||||
|
||||
/// `nostr_mine_event` → signed mined event JSON string.
|
||||
pub fn nostr_mine_event(
|
||||
&mut self,
|
||||
event_json: &str,
|
||||
difficulty: u32,
|
||||
threads: u32,
|
||||
timeout_sec: u64,
|
||||
) -> Result<Value, String> {
|
||||
let mut opts = self.selector.to_options();
|
||||
if let Some(obj) = opts.as_object_mut() {
|
||||
if difficulty > 0 {
|
||||
obj.insert("difficulty".into(), json!(difficulty));
|
||||
}
|
||||
if threads > 0 {
|
||||
obj.insert("threads".into(), json!(threads));
|
||||
}
|
||||
if timeout_sec > 0 {
|
||||
obj.insert("timeout_sec".into(), json!(timeout_sec));
|
||||
}
|
||||
}
|
||||
self.call("nostr_mine_event", json!([event_json, opts]))
|
||||
}
|
||||
|
||||
/// `nostr_nip04_encrypt` → ciphertext string.
|
||||
pub fn nip04_encrypt(&mut self, peer: &str, plaintext: &str) -> Result<Value, String> {
|
||||
let opts = self.selector.to_options();
|
||||
self.call("nostr_nip04_encrypt", json!([peer, plaintext, opts]))
|
||||
}
|
||||
|
||||
/// `nostr_nip04_decrypt` → plaintext string.
|
||||
pub fn nip04_decrypt(&mut self, peer: &str, ciphertext: &str) -> Result<Value, String> {
|
||||
let opts = self.selector.to_options();
|
||||
self.call("nostr_nip04_decrypt", json!([peer, ciphertext, opts]))
|
||||
}
|
||||
|
||||
/// `nostr_nip44_encrypt` → base64 ciphertext string.
|
||||
pub fn nip44_encrypt(&mut self, peer: &str, plaintext: &str) -> Result<Value, String> {
|
||||
let opts = self.selector.to_options();
|
||||
self.call("nostr_nip44_encrypt", json!([peer, plaintext, opts]))
|
||||
}
|
||||
|
||||
/// `nostr_nip44_decrypt` → base64 plaintext string.
|
||||
pub fn nip44_decrypt(&mut self, peer: &str, ciphertext_b64: &str) -> Result<Value, String> {
|
||||
let opts = self.selector.to_options();
|
||||
self.call("nostr_nip44_decrypt", json!([peer, ciphertext_b64, opts]))
|
||||
}
|
||||
|
||||
// ── Algorithm-based verbs ─────────────────────────────────────────
|
||||
|
||||
/// `get_public_key` (algorithm) → structured object.
|
||||
pub fn alg_get_public_key(&mut self, alg: &AlgOptions) -> Result<Value, String> {
|
||||
let opts = alg.to_options();
|
||||
self.call("get_public_key", json!([opts]))
|
||||
}
|
||||
|
||||
/// `sign` (algorithm) → structured object with `signature`.
|
||||
pub fn alg_sign(&mut self, alg: &AlgOptions, msg_hex: &str) -> Result<Value, String> {
|
||||
let opts = alg.to_options();
|
||||
self.call("sign", json!([msg_hex, opts]))
|
||||
}
|
||||
|
||||
/// `verify` (algorithm) → bool (from the `valid` field).
|
||||
pub fn alg_verify(
|
||||
&mut self,
|
||||
alg: &AlgOptions,
|
||||
msg_hex: &str,
|
||||
sig_hex: &str,
|
||||
) -> Result<bool, String> {
|
||||
let opts = alg.to_options();
|
||||
let result = self.call("verify", json!([msg_hex, sig_hex, opts]))?;
|
||||
Ok(result
|
||||
.get("valid")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// `derive` (algorithm) → structured object with `digest`.
|
||||
pub fn alg_derive(&mut self, alg: &AlgOptions, data: &str) -> Result<Value, String> {
|
||||
let opts = alg.to_options();
|
||||
self.call("derive", json!([data, opts]))
|
||||
}
|
||||
|
||||
/// `encapsulate` (ML-KEM) → structured object.
|
||||
pub fn alg_encapsulate(&mut self, alg: &AlgOptions, peer_pubkey_hex: &str) -> Result<Value, String> {
|
||||
let opts = alg.to_options();
|
||||
self.call("encapsulate", json!([peer_pubkey_hex, opts]))
|
||||
}
|
||||
|
||||
/// `decapsulate` (ML-KEM) → structured object.
|
||||
pub fn alg_decapsulate(&mut self, alg: &AlgOptions, ciphertext_hex: &str) -> Result<Value, String> {
|
||||
let opts = alg.to_options();
|
||||
self.call("decapsulate", json!([ciphertext_hex, opts]))
|
||||
}
|
||||
|
||||
/// `derive_shared_secret` (X25519) → shared secret hex string.
|
||||
pub fn alg_derive_shared_secret(
|
||||
&mut self,
|
||||
alg: &AlgOptions,
|
||||
peer_pubkey_hex: &str,
|
||||
) -> Result<Value, String> {
|
||||
let opts = alg.to_options();
|
||||
self.call("derive_shared_secret", json!([peer_pubkey_hex, opts]))
|
||||
}
|
||||
|
||||
/// `encrypt` (OTP) → ciphertext string.
|
||||
pub fn otp_encrypt(&mut self, alg: &AlgOptions, plaintext: &str) -> Result<Value, String> {
|
||||
let opts = alg.to_options();
|
||||
self.call("encrypt", json!([plaintext, opts]))
|
||||
}
|
||||
|
||||
/// `decrypt` (OTP) → plaintext string.
|
||||
pub fn otp_decrypt(&mut self, alg: &AlgOptions, ciphertext: &str) -> Result<Value, String> {
|
||||
let opts = alg.to_options();
|
||||
self.call("decrypt", json!([ciphertext, opts]))
|
||||
}
|
||||
|
||||
// ── Generic escape hatch ──────────────────────────────────────────
|
||||
|
||||
/// Raw passthrough: call an arbitrary method with a params array.
|
||||
pub fn call_raw_method(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.call(method, params)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result extraction helpers ─────────────────────────────────────────
|
||||
|
||||
/// Coerce a result `Value` into a single output line.
|
||||
///
|
||||
/// - Strings → the raw string content.
|
||||
/// - Other JSON → compact JSON serialization.
|
||||
pub fn result_to_line(result: &Value) -> String {
|
||||
if let Some(s) = result.as_str() {
|
||||
s.to_string()
|
||||
} else {
|
||||
serde_json::to_string(result).unwrap_or_else(|_| "null".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_selector_options_both() {
|
||||
let s = Selector {
|
||||
role: Some("main".into()),
|
||||
role_path: Some("m/44'/1237'/0'/0/0".into()),
|
||||
};
|
||||
let opts = s.to_options();
|
||||
assert_eq!(opts["role"], json!("main"));
|
||||
assert_eq!(opts["role_path"], json!("m/44'/1237'/0'/0/0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alg_options_defaults() {
|
||||
let a = AlgOptions {
|
||||
algorithm: "ed25519".into(),
|
||||
index: 0,
|
||||
scheme: None,
|
||||
encoding: None,
|
||||
format: None,
|
||||
};
|
||||
let opts = a.to_options();
|
||||
assert_eq!(opts["algorithm"], json!("ed25519"));
|
||||
assert_eq!(opts["index"], json!(0));
|
||||
assert!(opts.get("scheme").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alg_options_scheme() {
|
||||
let a = AlgOptions {
|
||||
algorithm: "secp256k1".into(),
|
||||
index: 1,
|
||||
scheme: Some("ecdsa".into()),
|
||||
encoding: None,
|
||||
format: None,
|
||||
};
|
||||
let opts = a.to_options();
|
||||
assert_eq!(opts["scheme"], json!("ecdsa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_result_to_line_string() {
|
||||
assert_eq!(result_to_line(&json!("deadbeef")), "deadbeef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_result_to_line_object() {
|
||||
let v = json!({"signature": "abc"});
|
||||
assert_eq!(result_to_line(&v), r#"{"signature":"abc"}"#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Client transport — unified framed I/O over Unix / TCP / Serial / Qrexec.
|
||||
//!
|
||||
//! Port of the C `signer_transport_t` vtable. All four transports share the
|
||||
//! same `send_framed` / `recv_framed` path after construction (defined in
|
||||
//! [`::signer::transport`]).
|
||||
|
||||
use std::io;
|
||||
use std::process::{Child, ChildStdin, ChildStdout};
|
||||
|
||||
/// A connected client transport. Owns the underlying handle (and, for
|
||||
/// qrexec, the child process).
|
||||
pub enum ClientTransport {
|
||||
/// Unix abstract socket.
|
||||
Unix(std::os::unix::net::UnixStream),
|
||||
/// TCP stream.
|
||||
Tcp(std::net::TcpStream),
|
||||
/// Serial device file (read/write).
|
||||
Serial(std::fs::File),
|
||||
/// Qubes qrexec child: stdin + stdout + child handle.
|
||||
Qrexec {
|
||||
stdin: ChildStdin,
|
||||
stdout: ChildStdout,
|
||||
child: Child,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClientTransport {
|
||||
/// Open a Unix abstract-socket transport by name (without `@`).
|
||||
pub fn open_unix(name: &str, _timeout_ms: u64) -> io::Result<Self> {
|
||||
let stream = ::signer::transport::connect_abstract_unix(name)?;
|
||||
Ok(ClientTransport::Unix(stream))
|
||||
}
|
||||
|
||||
/// Open a TCP transport to `host:port`.
|
||||
pub fn open_tcp(host: &str, port: u16, timeout_ms: u64) -> io::Result<Self> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let stream = std::net::TcpStream::connect(&addr)?;
|
||||
let dur = std::time::Duration::from_millis(timeout_ms);
|
||||
stream.set_read_timeout(Some(dur))?;
|
||||
stream.set_write_timeout(Some(dur))?;
|
||||
Ok(ClientTransport::Tcp(stream))
|
||||
}
|
||||
|
||||
/// Open a USB CDC-ACM serial transport at `device` (e.g. `/dev/ttyACM0`).
|
||||
pub fn open_serial(device: &str, _timeout_ms: u64) -> io::Result<Self> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK)
|
||||
.open(device)?;
|
||||
Ok(ClientTransport::Serial(file))
|
||||
}
|
||||
|
||||
/// Open a Qubes qrexec transport to `qube:service`.
|
||||
///
|
||||
/// Spawns `qrexec-client-vm <qube> <service>` and pipes framed JSON
|
||||
/// over its stdin/stdout.
|
||||
pub fn open_qrexec(qube: &str, service: &str, _timeout_ms: u64) -> io::Result<Self> {
|
||||
let mut child = std::process::Command::new("qrexec-client-vm")
|
||||
.arg(qube)
|
||||
.arg(service)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()?;
|
||||
let stdin = child.stdin.take().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::Other, "qrexec: no stdin")
|
||||
})?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::Other, "qrexec: no stdout")
|
||||
})?;
|
||||
Ok(ClientTransport::Qrexec { stdin, stdout, child })
|
||||
}
|
||||
|
||||
/// Send a framed JSON message.
|
||||
pub fn send(&mut self, payload: &str) -> io::Result<()> {
|
||||
match self {
|
||||
ClientTransport::Unix(s) => ::signer::transport::send_framed(s, payload),
|
||||
ClientTransport::Tcp(s) => ::signer::transport::send_framed(s, payload),
|
||||
ClientTransport::Serial(f) => ::signer::transport::send_framed(f, payload),
|
||||
ClientTransport::Qrexec { stdin, .. } => {
|
||||
::signer::transport::send_framed(stdin, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a framed JSON message.
|
||||
pub fn recv(&mut self) -> io::Result<String> {
|
||||
match self {
|
||||
ClientTransport::Unix(s) => ::signer::transport::recv_framed(s),
|
||||
ClientTransport::Tcp(s) => ::signer::transport::recv_framed(s),
|
||||
ClientTransport::Serial(f) => ::signer::transport::recv_framed(f),
|
||||
ClientTransport::Qrexec { stdout, .. } => {
|
||||
::signer::transport::recv_framed(stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ClientTransport {
|
||||
fn drop(&mut self) {
|
||||
if let ClientTransport::Qrexec { child, .. } = self {
|
||||
// Best-effort: wait for the qrexec child to exit.
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `host:port` into `(host, port)`.
|
||||
pub fn parse_host_port(s: &str) -> Result<(String, u16), String> {
|
||||
let colon = s.rfind(':').ok_or("missing ':' in host:port")?;
|
||||
if colon == 0 {
|
||||
return Err("empty host".into());
|
||||
}
|
||||
let host = s[..colon].to_string();
|
||||
let port: u16 = s[colon + 1..]
|
||||
.parse()
|
||||
.map_err(|_| "invalid port")?;
|
||||
Ok((host, port))
|
||||
}
|
||||
|
||||
/// Parse `qube:service` into `(qube, service)`.
|
||||
pub fn parse_qube_service(s: &str) -> Result<(String, String), String> {
|
||||
let colon = s.find(':').ok_or("missing ':' in qube:service")?;
|
||||
if colon == 0 {
|
||||
return Err("empty qube".into());
|
||||
}
|
||||
let qube = s[..colon].to_string();
|
||||
let service = s[colon + 1..].to_string();
|
||||
if service.is_empty() {
|
||||
return Err("empty service".into());
|
||||
}
|
||||
Ok((qube, service))
|
||||
}
|
||||
|
||||
/// Open a transport based on the CLI flags. Returns the transport and the
|
||||
/// resolved socket name (for diagnostics).
|
||||
pub fn open_from_cli(
|
||||
socket_name: Option<&str>,
|
||||
timeout_ms: u64,
|
||||
tcp: Option<&str>,
|
||||
serial: Option<&str>,
|
||||
qrexec: Option<&str>,
|
||||
) -> Result<(ClientTransport, String), String> {
|
||||
let transport_count =
|
||||
tcp.is_some() as usize + serial.is_some() as usize + qrexec.is_some() as usize
|
||||
+ socket_name.is_some() as usize;
|
||||
if transport_count > 1 {
|
||||
return Err(
|
||||
"--tcp, --serial, --qrexec, and --socket-name are mutually exclusive".into(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(tcp_arg) = tcp {
|
||||
let (host, port) = parse_host_port(tcp_arg)?;
|
||||
let t = ClientTransport::open_tcp(&host, port, timeout_ms)
|
||||
.map_err(|e| format!("cannot open TCP transport to {}: {}", tcp_arg, e))?;
|
||||
return Ok((t, tcp_arg.to_string()));
|
||||
}
|
||||
|
||||
if let Some(dev) = serial {
|
||||
let t = ClientTransport::open_serial(dev, timeout_ms)
|
||||
.map_err(|e| format!("cannot open serial transport on {}: {}", dev, e))?;
|
||||
return Ok((t, dev.to_string()));
|
||||
}
|
||||
|
||||
if let Some(qr) = qrexec {
|
||||
let (qube, service) = parse_qube_service(qr)?;
|
||||
let t = ClientTransport::open_qrexec(&qube, &service, timeout_ms)
|
||||
.map_err(|e| format!("cannot open qrexec transport to {}: {}", qr, e))?;
|
||||
return Ok((t, qr.to_string()));
|
||||
}
|
||||
|
||||
if let Some(name) = socket_name {
|
||||
let t = ClientTransport::open_unix(name, timeout_ms)
|
||||
.map_err(|e| format!("cannot open unix transport {}: {}", name, e))?;
|
||||
return Ok((t, name.to_string()));
|
||||
}
|
||||
|
||||
// Auto-discover: enumerate abstract UNIX sockets.
|
||||
let sockets = ::signer::socket_name::list_sockets();
|
||||
if sockets.is_empty() {
|
||||
return Err("no signer sockets found. Is signer running?".into());
|
||||
}
|
||||
if sockets.len() > 1 {
|
||||
let mut msg = String::from(
|
||||
"multiple signer sockets found. Use --socket-name to select one:\n",
|
||||
);
|
||||
for n in &sockets {
|
||||
msg.push_str(&format!(" {}\n", n));
|
||||
}
|
||||
return Err(msg);
|
||||
}
|
||||
let name = sockets[0].clone();
|
||||
let t = ClientTransport::open_unix(&name, timeout_ms)
|
||||
.map_err(|e| format!("cannot open unix transport {}: {}", name, e))?;
|
||||
Ok((t, name))
|
||||
}
|
||||
+246
-22
@@ -11,7 +11,7 @@ use crate::mnemonic::MnemonicState;
|
||||
use crate::pq_crypto::CryptoAlg;
|
||||
use crate::role_table::RoleTable;
|
||||
use crate::selector::{selector_resolve, SelectorRequest};
|
||||
use crate::NsignerError;
|
||||
use crate::SignerError;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -179,7 +179,7 @@ fn handle_algorithm_verb(
|
||||
}
|
||||
|
||||
let priv_slice = key_entry.private_key.as_slice();
|
||||
let sig = sign_with_alg(alg, &priv_slice[..32].try_into().unwrap(), &msg_bytes);
|
||||
let sig = sign_with_alg(alg, priv_slice, &msg_bytes);
|
||||
match sig {
|
||||
Ok(s) => {
|
||||
let sig_hex = hex::encode(&s);
|
||||
@@ -286,13 +286,79 @@ fn handle_algorithm_verb(
|
||||
}
|
||||
|
||||
enforcement::VERB_ENCAPSULATE => {
|
||||
// ML-KEM-768 only — TODO: Phase 13
|
||||
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
|
||||
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
|
||||
// params[0] = peer public key hex (1184 bytes → 2368 hex chars).
|
||||
let pub_hex = match params.first().and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return make_error_response(id, RpcError::INVALID_PARAMS),
|
||||
};
|
||||
let pub_bytes = match hex::decode(pub_hex) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "invalid_pubkey_hex" },
|
||||
),
|
||||
};
|
||||
let sizes = alg.sizes().unwrap();
|
||||
if pub_bytes.len() != sizes.pub_key_len {
|
||||
return make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "invalid_pubkey_length" },
|
||||
);
|
||||
}
|
||||
|
||||
match crate::pq_crypto::ml_kem_768_encaps(&pub_bytes) {
|
||||
Ok((ct, ss)) => {
|
||||
let result = json!({
|
||||
"ciphertext": hex::encode(&ct),
|
||||
"shared_secret": hex::encode(&ss),
|
||||
"algorithm": "ml-kem-768",
|
||||
});
|
||||
make_success_response(id, &result.to_string())
|
||||
}
|
||||
Err(_) => make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "encaps_failed" },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
enforcement::VERB_DECAPSULATE => {
|
||||
// ML-KEM-768 only — TODO: Phase 13
|
||||
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
|
||||
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
|
||||
// params[0] = ciphertext hex (1088 bytes → 2176 hex chars).
|
||||
let ct_hex = match params.first().and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return make_error_response(id, RpcError::INVALID_PARAMS),
|
||||
};
|
||||
let ct_bytes = match hex::decode(ct_hex) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "invalid_ciphertext_hex" },
|
||||
),
|
||||
};
|
||||
let sizes = alg.sizes().unwrap();
|
||||
if ct_bytes.len() != sizes.ciphertext_len {
|
||||
return make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "invalid_ciphertext_length" },
|
||||
);
|
||||
}
|
||||
|
||||
let priv_slice = key_entry.private_key.as_slice();
|
||||
match crate::pq_crypto::ml_kem_768_decaps(priv_slice, &ct_bytes) {
|
||||
Ok(ss) => {
|
||||
let result = json!({
|
||||
"shared_secret": hex::encode(&ss),
|
||||
"algorithm": "ml-kem-768",
|
||||
});
|
||||
make_success_response(id, &result.to_string())
|
||||
}
|
||||
Err(_) => make_error_response(
|
||||
id,
|
||||
RpcError { code: -32602, message: "decaps_failed" },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
|
||||
@@ -357,12 +423,26 @@ fn handle_nostr_verb(
|
||||
return make_error_response(id, RpcError::MNEMONIC_NOT_LOADED);
|
||||
}
|
||||
|
||||
// Ensure key is derived
|
||||
if !role.derived {
|
||||
if let Err(_) = ctx
|
||||
.key_store
|
||||
.derive_one(ctx.role_table, ctx.mnemonic, role_index)
|
||||
{
|
||||
// Ensure key is derived. For variable-path roles, use the concrete
|
||||
// path supplied by the client; for fixed-path roles, use the stored
|
||||
// template. Variable-path roles are re-derived whenever the requested
|
||||
// path differs from the currently-derived one (the cache is per-path,
|
||||
// not per-role).
|
||||
let has_variable = role.has_variable_path();
|
||||
let needs_derive = if has_variable && sel.has_role_path {
|
||||
role.derived_path.as_deref() != Some(sel.role_path.as_str())
|
||||
} else {
|
||||
!role.derived
|
||||
};
|
||||
if needs_derive {
|
||||
let result = if has_variable && sel.has_role_path {
|
||||
ctx.key_store
|
||||
.derive_one_with_path(ctx.role_table, ctx.mnemonic, role_index, &sel.role_path)
|
||||
} else {
|
||||
ctx.key_store
|
||||
.derive_one(ctx.role_table, ctx.mnemonic, role_index)
|
||||
};
|
||||
if let Err(_) = result {
|
||||
return make_error_response(
|
||||
id,
|
||||
RpcError {
|
||||
@@ -407,7 +487,12 @@ fn handle_nostr_verb(
|
||||
None => return make_error_response(id, RpcError::INVALID_PARAMS),
|
||||
};
|
||||
match ctx.key_store.sign_event(role_index, event_json) {
|
||||
Ok(signed) => make_success_response(id, &format!("\"{}\"", signed)),
|
||||
// The signed event is itself JSON; serialize it as a proper
|
||||
// JSON string value so embedded quotes are escaped.
|
||||
Ok(signed) => {
|
||||
let wrapped = serde_json::to_string(&signed).unwrap_or_else(|_| "\"\"".into());
|
||||
make_success_response(id, &wrapped)
|
||||
}
|
||||
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
|
||||
}
|
||||
}
|
||||
@@ -544,23 +629,31 @@ fn is_nostr_verb(verb: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8; 32], msg: &[u8]) -> Result<Vec<u8>, NsignerError> {
|
||||
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, SignerError> {
|
||||
match alg {
|
||||
CryptoAlg::Secp256k1 => {
|
||||
if priv_key.len() != 32 {
|
||||
return Err(SignerError::CryptoFailed);
|
||||
}
|
||||
let arr: [u8; 32] = priv_key.try_into().unwrap();
|
||||
// Check for scheme option (schnorr default, ecdsa alternative)
|
||||
// For now, default to schnorr
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(*priv_key);
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(arr);
|
||||
let digest = nostr_core::crypto::sha256::sha256(msg);
|
||||
let sig = nostr_core::crypto::keys::schnorr_sign(&sk, &digest)?;
|
||||
Ok(sig.as_bytes().to_vec())
|
||||
}
|
||||
CryptoAlg::Ed25519 => {
|
||||
let sig = crate::pq_crypto::ed25519_sign(priv_key, msg);
|
||||
if priv_key.len() != 32 {
|
||||
return Err(SignerError::CryptoFailed);
|
||||
}
|
||||
let arr: [u8; 32] = priv_key.try_into().unwrap();
|
||||
let sig = crate::pq_crypto::ed25519_sign(&arr, msg);
|
||||
Ok(sig.to_vec())
|
||||
}
|
||||
CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_sign(priv_key, msg),
|
||||
CryptoAlg::SlhDsa128s => crate::pq_crypto::slh_dsa_128s_sign(priv_key, msg),
|
||||
_ => Err(NsignerError::CryptoFailed),
|
||||
_ => Err(SignerError::CryptoFailed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,11 +812,6 @@ mod tests {
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
let msg_hex = hex::encode(b"hello world");
|
||||
let sign_req = format!(
|
||||
r#"{{"id":"5","method":"sign","params":["{}"],{{"algorithm":"ed25519","index":0}}}}"#,
|
||||
msg_hex
|
||||
);
|
||||
// Fix JSON format
|
||||
let sign_req = format!(
|
||||
r#"{{"id":"5","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
|
||||
msg_hex
|
||||
@@ -754,4 +842,140 @@ mod tests {
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("\"error\""));
|
||||
}
|
||||
|
||||
// ── PQ algorithm verbs (v2 seeded derivation) ────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ml_dsa_65_sign_verify() {
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
// get_public_key must succeed (was key_derivation_failed before).
|
||||
let req = r#"{"id":"p1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
|
||||
assert_eq!(pub_hex.len(), 1952 * 2);
|
||||
|
||||
// sign
|
||||
let msg_hex = hex::encode(b"hello world");
|
||||
let sign_req = format!(
|
||||
r#"{{"id":"p2","method":"sign","params":["{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
|
||||
msg_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &sign_req);
|
||||
assert!(resp.contains("\"result\""), "sign: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
|
||||
assert_eq!(sig_hex.len(), 3309 * 2);
|
||||
|
||||
// verify (valid)
|
||||
let verify_req = format!(
|
||||
r#"{{"id":"p3","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
|
||||
msg_hex, sig_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &verify_req);
|
||||
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
|
||||
|
||||
// verify (wrong message)
|
||||
let verify_req = format!(
|
||||
r#"{{"id":"p4","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
|
||||
hex::encode(b"wrong message"),
|
||||
sig_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &verify_req);
|
||||
assert!(resp.contains("\"valid\":false"), "verify wrong: {}", resp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slh_dsa_128s_sign_verify() {
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
let req = r#"{"id":"s1","method":"get_public_key","params":[{"algorithm":"slh-dsa-128s","index":0}]}"#;
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
|
||||
assert_eq!(pub_hex.len(), 32 * 2);
|
||||
|
||||
let msg_hex = hex::encode(b"hello world");
|
||||
let sign_req = format!(
|
||||
r#"{{"id":"s2","method":"sign","params":["{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
|
||||
msg_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &sign_req);
|
||||
assert!(resp.contains("\"result\""), "sign: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
|
||||
assert_eq!(sig_hex.len(), 7856 * 2);
|
||||
|
||||
let verify_req = format!(
|
||||
r#"{{"id":"s3","method":"verify","params":["{}","{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
|
||||
msg_hex, sig_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &verify_req);
|
||||
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_kem_768_encaps_decaps() {
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
// Derive our ML-KEM keypair to get a public key to encapsulate to.
|
||||
let req = r#"{"id":"k1","method":"get_public_key","params":[{"algorithm":"ml-kem-768","index":0}]}"#;
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
|
||||
assert_eq!(pub_hex.len(), 1184 * 2);
|
||||
|
||||
// encapsulate
|
||||
let enc_req = format!(
|
||||
r#"{{"id":"k2","method":"encapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
|
||||
pub_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &enc_req);
|
||||
assert!(resp.contains("\"result\""), "encapsulate: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let ct_hex = resp_json["result"]["ciphertext"].as_str().unwrap();
|
||||
let ss_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
|
||||
assert_eq!(ct_hex.len(), 1088 * 2);
|
||||
assert_eq!(ss_hex.len(), 32 * 2);
|
||||
|
||||
// decapsulate
|
||||
let dec_req = format!(
|
||||
r#"{{"id":"k3","method":"decapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
|
||||
ct_hex
|
||||
);
|
||||
let resp = handle_request(&mut ctx, &dec_req);
|
||||
assert!(resp.contains("\"result\""), "decapsulate: {}", resp);
|
||||
let resp_json: Value = serde_json::from_str(&resp).unwrap();
|
||||
let ss2_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
|
||||
assert_eq!(ss_hex, ss2_hex, "shared secrets must match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_kem_768_encaps_bad_pubkey_length() {
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
let req = r#"{"id":"k4","method":"encapsulate","params":["00ff",{"algorithm":"ml-kem-768","index":0}]}"#;
|
||||
let resp = handle_request(&mut ctx, req);
|
||||
assert!(resp.contains("invalid_pubkey_length"), "resp: {}", resp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pq_key_derivation_deterministic() {
|
||||
// Same mnemonic + index → same pubkey across dispatcher calls.
|
||||
let (mut table, mnemonic, mut store, mut cache) = setup();
|
||||
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
|
||||
|
||||
let req = r#"{"id":"d1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
|
||||
let resp1 = handle_request(&mut ctx, req);
|
||||
let resp2 = handle_request(&mut ctx, req);
|
||||
assert_eq!(resp1, resp2);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -1,13 +1,13 @@
|
||||
//! Error types for nsigner.
|
||||
//! Error types for signer.
|
||||
//!
|
||||
//! JSON-RPC error codes are preserved exactly for wire compatibility
|
||||
//! with the C n_signer.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// nsigner-specific errors (internal operations).
|
||||
/// signer-specific errors (internal operations).
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum NsignerError {
|
||||
pub enum SignerError {
|
||||
#[error("invalid input")]
|
||||
InvalidInput,
|
||||
#[error("memory allocation failed (mlock)")]
|
||||
@@ -32,13 +32,13 @@ pub enum NsignerError {
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl From<nostr_core::error::NostrError> for NsignerError {
|
||||
impl From<nostr_core::error::NostrError> for SignerError {
|
||||
fn from(e: nostr_core::error::NostrError) -> Self {
|
||||
// Map NostrError to NsignerError
|
||||
// Map NostrError to SignerError
|
||||
match e {
|
||||
nostr_core::error::NostrError::InvalidInput => NsignerError::InvalidInput,
|
||||
nostr_core::error::NostrError::CryptoFailed => NsignerError::CryptoFailed,
|
||||
_ => NsignerError::CryptoFailed,
|
||||
nostr_core::error::NostrError::InvalidInput => SignerError::InvalidInput,
|
||||
nostr_core::error::NostrError::CryptoFailed => SignerError::CryptoFailed,
|
||||
_ => SignerError::CryptoFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ impl RpcError {
|
||||
pub const INVALID_PARAMS: Self = RpcError { code: -32602, message: "invalid_params" };
|
||||
pub const INTERNAL_ERROR: Self = RpcError { code: -32603, message: "internal_error" };
|
||||
|
||||
// ── nsigner-specific errors ──────────────────────────────────────
|
||||
// ── signer-specific errors ──────────────────────────────────────
|
||||
pub const AMBIGUOUS_ROLE_SELECTOR: Self = RpcError { code: 1001, message: "ambiguous_role_selector" };
|
||||
pub const UNKNOWN_ROLE: Self = RpcError { code: 1002, message: "unknown_role" };
|
||||
pub const NO_DEFAULT_ROLE: Self = RpcError { code: 1003, message: "no_default_role" };
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//! Minimal HTTP/1.1 parser for nsigner's HTTP listener mode.
|
||||
//! Minimal HTTP/1.1 parser for signer's HTTP listener mode.
|
||||
//!
|
||||
//! Port of `http_listener.c`. Only supports POST with a JSON body.
|
||||
//! No chunked encoding, no keep-alive, one request per connection.
|
||||
|
||||
+148
-43
@@ -7,7 +7,7 @@ use crate::mnemonic::MnemonicState;
|
||||
use crate::pq_crypto::{self, CryptoAlg};
|
||||
use crate::role_table::{self, RoleCurve, RoleEntry, RolePurpose, RoleTable};
|
||||
use crate::secure_mem::SecureBuf;
|
||||
use crate::NsignerError;
|
||||
use crate::SignerError;
|
||||
|
||||
/// Per-role derived key material (stored in secure memory).
|
||||
pub struct DerivedKey {
|
||||
@@ -35,12 +35,12 @@ impl KeyStore {
|
||||
&mut self,
|
||||
table: &mut RoleTable,
|
||||
mnemonic: &MnemonicState,
|
||||
) -> Result<usize, NsignerError> {
|
||||
) -> Result<usize, SignerError> {
|
||||
if !mnemonic.is_loaded() {
|
||||
return Err(NsignerError::MnemonicNotLoaded);
|
||||
return Err(SignerError::MnemonicNotLoaded);
|
||||
}
|
||||
|
||||
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
|
||||
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
|
||||
self.keys.clear();
|
||||
self.keys.resize_with(table.entries.len(), || None);
|
||||
|
||||
@@ -86,16 +86,16 @@ impl KeyStore {
|
||||
table: &mut RoleTable,
|
||||
mnemonic: &MnemonicState,
|
||||
role_index: usize,
|
||||
) -> Result<(), NsignerError> {
|
||||
) -> Result<(), SignerError> {
|
||||
if !mnemonic.is_loaded() {
|
||||
return Err(NsignerError::MnemonicNotLoaded);
|
||||
return Err(SignerError::MnemonicNotLoaded);
|
||||
}
|
||||
|
||||
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
|
||||
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
|
||||
let role = table
|
||||
.entries
|
||||
.get_mut(role_index)
|
||||
.ok_or(NsignerError::InvalidInput)?;
|
||||
.ok_or(SignerError::InvalidInput)?;
|
||||
|
||||
role.derived = false;
|
||||
role.pubkey_hex.clear();
|
||||
@@ -111,6 +111,42 @@ impl KeyStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive key for a role using a concrete path (substituting the
|
||||
/// variable index into the template). Used for variable-path roles
|
||||
/// where the client supplies the full concrete `role_path`.
|
||||
pub fn derive_one_with_path(
|
||||
&mut self,
|
||||
table: &mut RoleTable,
|
||||
mnemonic: &MnemonicState,
|
||||
role_index: usize,
|
||||
concrete_path: &str,
|
||||
) -> Result<(), SignerError> {
|
||||
if !mnemonic.is_loaded() {
|
||||
return Err(SignerError::MnemonicNotLoaded);
|
||||
}
|
||||
|
||||
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
|
||||
let role = table
|
||||
.entries
|
||||
.get_mut(role_index)
|
||||
.ok_or(SignerError::InvalidInput)?;
|
||||
|
||||
role.derived = false;
|
||||
role.pubkey_hex.clear();
|
||||
role.derived_path = None;
|
||||
|
||||
let dk = derive_for_role(concrete_path, role, phrase)?;
|
||||
role.pubkey_hex = dk.pubkey_hex.clone();
|
||||
role.derived = true;
|
||||
role.derived_path = Some(concrete_path.to_string());
|
||||
|
||||
if self.keys.len() <= role_index {
|
||||
self.keys.resize_with(role_index + 1, || None);
|
||||
}
|
||||
self.keys[role_index] = Some(dk);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the derived private key for a role (by table index).
|
||||
pub fn get_private_key(&self, role_index: usize) -> Option<&[u8]> {
|
||||
self.keys.get(role_index)?.as_ref().map(|dk| dk.private_key.as_slice())
|
||||
@@ -127,14 +163,14 @@ impl KeyStore {
|
||||
&self,
|
||||
role_index: usize,
|
||||
event_json: &str,
|
||||
) -> Result<String, NsignerError> {
|
||||
) -> Result<String, SignerError> {
|
||||
let priv_bytes = self
|
||||
.get_private_key(role_index)
|
||||
.ok_or(NsignerError::KeyDerivationFailed)?;
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
|
||||
// Parse the unsigned event from JSON
|
||||
let event: nostr_core::types::Event =
|
||||
serde_json::from_str(event_json).map_err(|_| NsignerError::InvalidInput)?;
|
||||
serde_json::from_str(event_json).map_err(|_| SignerError::InvalidInput)?;
|
||||
|
||||
// Use NIP-01 to create and sign the event
|
||||
let mut priv_arr = [0u8; 32];
|
||||
@@ -148,9 +184,9 @@ impl KeyStore {
|
||||
&sk,
|
||||
event.created_at,
|
||||
)
|
||||
.map_err(|_| NsignerError::CryptoFailed)?;
|
||||
.map_err(|_| SignerError::CryptoFailed)?;
|
||||
|
||||
serde_json::to_string(&signed).map_err(|_| NsignerError::InvalidInput)
|
||||
serde_json::to_string(&signed).map_err(|_| SignerError::InvalidInput)
|
||||
}
|
||||
|
||||
/// NIP-44 encrypt.
|
||||
@@ -159,20 +195,20 @@ impl KeyStore {
|
||||
role_index: usize,
|
||||
recipient_pubkey_hex: &str,
|
||||
plaintext: &str,
|
||||
) -> Result<Vec<u8>, NsignerError> {
|
||||
) -> Result<Vec<u8>, SignerError> {
|
||||
let priv_bytes = self
|
||||
.get_private_key(role_index)
|
||||
.ok_or(NsignerError::KeyDerivationFailed)?;
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex
|
||||
.parse()
|
||||
.map_err(|_| NsignerError::InvalidInput)?;
|
||||
.map_err(|_| SignerError::InvalidInput)?;
|
||||
|
||||
let mut priv_arr = [0u8; 32];
|
||||
priv_arr.copy_from_slice(&priv_bytes[..32]);
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
|
||||
|
||||
nostr_core::crypto::nip44::nip44_encrypt(&sk, &recipient_pk, plaintext.as_bytes())
|
||||
.map_err(|_| NsignerError::CryptoFailed)
|
||||
.map_err(|_| SignerError::CryptoFailed)
|
||||
}
|
||||
|
||||
/// NIP-44 decrypt.
|
||||
@@ -181,20 +217,20 @@ impl KeyStore {
|
||||
role_index: usize,
|
||||
sender_pubkey_hex: &str,
|
||||
ciphertext: &[u8],
|
||||
) -> Result<Vec<u8>, NsignerError> {
|
||||
) -> Result<Vec<u8>, SignerError> {
|
||||
let priv_bytes = self
|
||||
.get_private_key(role_index)
|
||||
.ok_or(NsignerError::KeyDerivationFailed)?;
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex
|
||||
.parse()
|
||||
.map_err(|_| NsignerError::InvalidInput)?;
|
||||
.map_err(|_| SignerError::InvalidInput)?;
|
||||
|
||||
let mut priv_arr = [0u8; 32];
|
||||
priv_arr.copy_from_slice(&priv_bytes[..32]);
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
|
||||
|
||||
nostr_core::crypto::nip44::nip44_decrypt(&sk, &sender_pk, ciphertext)
|
||||
.map_err(|_| NsignerError::CryptoFailed)
|
||||
.map_err(|_| SignerError::CryptoFailed)
|
||||
}
|
||||
|
||||
/// NIP-04 encrypt.
|
||||
@@ -203,20 +239,20 @@ impl KeyStore {
|
||||
role_index: usize,
|
||||
recipient_pubkey_hex: &str,
|
||||
plaintext: &str,
|
||||
) -> Result<String, NsignerError> {
|
||||
) -> Result<String, SignerError> {
|
||||
let priv_bytes = self
|
||||
.get_private_key(role_index)
|
||||
.ok_or(NsignerError::KeyDerivationFailed)?;
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex
|
||||
.parse()
|
||||
.map_err(|_| NsignerError::InvalidInput)?;
|
||||
.map_err(|_| SignerError::InvalidInput)?;
|
||||
|
||||
let mut priv_arr = [0u8; 32];
|
||||
priv_arr.copy_from_slice(&priv_bytes[..32]);
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
|
||||
|
||||
nips::nip004::nip04_encrypt(&sk, &recipient_pk, plaintext)
|
||||
.map_err(|_| NsignerError::CryptoFailed)
|
||||
.map_err(|_| SignerError::CryptoFailed)
|
||||
}
|
||||
|
||||
/// NIP-04 decrypt.
|
||||
@@ -225,20 +261,20 @@ impl KeyStore {
|
||||
role_index: usize,
|
||||
sender_pubkey_hex: &str,
|
||||
ciphertext: &str,
|
||||
) -> Result<String, NsignerError> {
|
||||
) -> Result<String, SignerError> {
|
||||
let priv_bytes = self
|
||||
.get_private_key(role_index)
|
||||
.ok_or(NsignerError::KeyDerivationFailed)?;
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex
|
||||
.parse()
|
||||
.map_err(|_| NsignerError::InvalidInput)?;
|
||||
.map_err(|_| SignerError::InvalidInput)?;
|
||||
|
||||
let mut priv_arr = [0u8; 32];
|
||||
priv_arr.copy_from_slice(&priv_bytes[..32]);
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
|
||||
|
||||
nips::nip004::nip04_decrypt(&sk, &sender_pk, ciphertext)
|
||||
.map_err(|_| NsignerError::CryptoFailed)
|
||||
.map_err(|_| SignerError::CryptoFailed)
|
||||
}
|
||||
|
||||
/// Zeroize all derived keys.
|
||||
@@ -254,21 +290,28 @@ fn derive_for_role(
|
||||
path: &str,
|
||||
role: &RoleEntry,
|
||||
mnemonic_phrase: &str,
|
||||
) -> Result<DerivedKey, NsignerError> {
|
||||
) -> Result<DerivedKey, SignerError> {
|
||||
let alg = role_table::crypto_alg_from_role(role.curve, role.purpose);
|
||||
// crypto_alg_from_role returns Unknown for OTP, but we skip OTP earlier
|
||||
let alg = if alg == CryptoAlg::Unknown {
|
||||
return Err(NsignerError::KeyDerivationFailed);
|
||||
return Err(SignerError::KeyDerivationFailed);
|
||||
} else {
|
||||
alg
|
||||
};
|
||||
|
||||
let sizes = alg
|
||||
.sizes()
|
||||
.ok_or(NsignerError::KeyDerivationFailed)?;
|
||||
.ok_or(SignerError::KeyDerivationFailed)?;
|
||||
|
||||
// Derive the 32-byte seed from the mnemonic using the path
|
||||
let seed = pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
|
||||
// PQ algorithms use the v2 seeded derivation: exact-length seed
|
||||
// (32/48/64 B) from BIP-32 children, fed to the seeded keygen APIs.
|
||||
// Classical algorithms use the plain 32-byte derived seed.
|
||||
let seed = match alg {
|
||||
CryptoAlg::MlDsa65 => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 32)?,
|
||||
CryptoAlg::SlhDsa128s => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 48)?,
|
||||
CryptoAlg::MlKem768 => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 64)?,
|
||||
_ => pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?.to_vec(),
|
||||
};
|
||||
|
||||
match alg {
|
||||
CryptoAlg::Secp256k1 => {
|
||||
@@ -276,19 +319,19 @@ fn derive_for_role(
|
||||
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic_phrase, "");
|
||||
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
|
||||
let path_indices = nips::nip006::parse_bip44_path(path)
|
||||
.map_err(|_| NsignerError::KeyDerivationFailed)?;
|
||||
.map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (derived_key, _) = nips::nip006::bip32_derive_path(
|
||||
&master_key,
|
||||
&master_chain_code,
|
||||
&path_indices,
|
||||
)
|
||||
.map_err(|_| NsignerError::KeyDerivationFailed)?;
|
||||
.map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
|
||||
let mut priv_arr = [0u8; 32];
|
||||
priv_arr.copy_from_slice(&derived_key);
|
||||
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
|
||||
let pk = nostr_core::crypto::keys::public_key_from_secret_key(&sk)
|
||||
.map_err(|_| NsignerError::CryptoFailed)?;
|
||||
.map_err(|_| SignerError::CryptoFailed)?;
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_arr);
|
||||
@@ -309,7 +352,9 @@ fn derive_for_role(
|
||||
})
|
||||
}
|
||||
CryptoAlg::Ed25519 => {
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed);
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed_arr);
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
@@ -329,7 +374,9 @@ fn derive_for_role(
|
||||
})
|
||||
}
|
||||
CryptoAlg::X25519 => {
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed);
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed_arr);
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
@@ -348,11 +395,69 @@ fn derive_for_role(
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
|
||||
// PQ algorithms — TODO: Phase 13
|
||||
Err(NsignerError::NotYetImplemented)
|
||||
CryptoAlg::MlDsa65 => {
|
||||
let seed_arr: [u8; 32] =
|
||||
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr)?;
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
|
||||
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
|
||||
pub_buf.copy_from(&pub_bytes);
|
||||
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
|
||||
Ok(DerivedKey {
|
||||
private_key: priv_buf,
|
||||
public_key: pub_buf,
|
||||
pubkey_hex,
|
||||
npub: String::new(),
|
||||
alg,
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::Unknown => Err(NsignerError::KeyDerivationFailed),
|
||||
CryptoAlg::SlhDsa128s => {
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::slh_dsa_128s_keygen_from_seed(&seed)?;
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
|
||||
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
|
||||
pub_buf.copy_from(&pub_bytes);
|
||||
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
|
||||
Ok(DerivedKey {
|
||||
private_key: priv_buf,
|
||||
public_key: pub_buf,
|
||||
pubkey_hex,
|
||||
npub: String::new(),
|
||||
alg,
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::MlKem768 => {
|
||||
let (priv_bytes, pub_bytes) = pq_crypto::ml_kem_768_keygen_from_seed(&seed)?;
|
||||
|
||||
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
|
||||
priv_buf.copy_from(&priv_bytes);
|
||||
|
||||
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
|
||||
pub_buf.copy_from(&pub_bytes);
|
||||
|
||||
let pubkey_hex = hex::encode(&pub_bytes);
|
||||
|
||||
Ok(DerivedKey {
|
||||
private_key: priv_buf,
|
||||
public_key: pub_buf,
|
||||
pubkey_hex,
|
||||
npub: String::new(),
|
||||
alg,
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
CryptoAlg::Unknown => Err(SignerError::KeyDerivationFailed),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
//! # nsigner — Attended Nostr signing daemon
|
||||
//! # signer — Attended Nostr signing daemon
|
||||
//!
|
||||
//! Rust port of the C-based `n_signer`. Holds signing key material in
|
||||
//! locked memory and signs on request via a JSON-RPC 2.0 API over
|
||||
@@ -28,7 +28,7 @@ pub mod socket_name;
|
||||
pub mod tui;
|
||||
pub mod error;
|
||||
|
||||
pub use error::NsignerError;
|
||||
pub use error::SignerError;
|
||||
|
||||
/// Version string (matches C NSIGNER_VERSION).
|
||||
pub const VERSION: &str = "v0.0.2";
|
||||
pub const VERSION: &str = "v0.0.21";
|
||||
|
||||
+58
-111
@@ -1,22 +1,22 @@
|
||||
//! nsigner — attended Nostr signing daemon.
|
||||
//! signer — attended Nostr signing daemon.
|
||||
//!
|
||||
//! Port of `main.c`. Single binary that holds signing key material in
|
||||
//! locked memory and signs on request via JSON-RPC 2.0.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use nsigner::{
|
||||
use signer::{
|
||||
alg_cache::AlgorithmKeyCache,
|
||||
dispatcher::DispatcherContext,
|
||||
key_store::KeyStore,
|
||||
mnemonic::MnemonicState,
|
||||
role_table::{RoleCurve, RolePurpose, RoleTable},
|
||||
server::{AuthMode, ListenMode, ServerContext},
|
||||
NsignerError,
|
||||
SignerError,
|
||||
};
|
||||
|
||||
/// Command-line arguments.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "nsigner", version = nsigner::VERSION, about = "Attended Nostr signing daemon")]
|
||||
#[command(name = "signer", version = signer::VERSION, about = "Attended Nostr signing daemon")]
|
||||
struct Cli {
|
||||
/// Socket name (abstract namespace, without @ prefix)
|
||||
#[arg(long, short = 'n', alias = "name")]
|
||||
@@ -85,7 +85,7 @@ enum Commands {
|
||||
to: Option<String>,
|
||||
},
|
||||
|
||||
/// List running nsigner abstract sockets
|
||||
/// List running signer abstract sockets
|
||||
List,
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ fn main() {
|
||||
match server_main(&cli) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
eprintln!("nsigner: {}", e);
|
||||
eprintln!("signer: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -126,24 +126,24 @@ fn main() {
|
||||
/// `--register-role` / `--listen`): mnemonic and roles are set up here,
|
||||
/// then the App runs with `listen_override` so it goes straight to the
|
||||
/// main screen. Headless modes (stdio/qrexec/tcp/http) never show a TUI.
|
||||
fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
println!("nsigner {}", nsigner::VERSION);
|
||||
fn server_main(cli: &Cli) -> Result<(), SignerError> {
|
||||
println!("signer {}", signer::VERSION);
|
||||
|
||||
if cli.allow_unlocked_memory {
|
||||
nsigner::secure_mem::allow_unlocked();
|
||||
signer::secure_mem::allow_unlocked();
|
||||
}
|
||||
|
||||
let interactive = !cli.mnemonic_stdin && cli.mnemonic_fd.is_none();
|
||||
let listen_override = cli.listen.as_deref().map(parse_listen_mode);
|
||||
let listen_mode = cli.listen.as_deref().map(parse_listen_mode);
|
||||
|
||||
// Non-interactive (--mnemonic-stdin / --mnemonic-fd) always runs headless,
|
||||
// even for Unix mode — the TUI needs a real TTY.
|
||||
if !interactive {
|
||||
return run_headless(cli, listen_override.unwrap_or(ListenMode::Unix));
|
||||
return run_headless(cli, listen_mode.unwrap_or(ListenMode::Unix));
|
||||
}
|
||||
|
||||
// Headless modes never show a TUI.
|
||||
if let Some(mode) = listen_override {
|
||||
if let Some(mode) = listen_mode {
|
||||
if mode != ListenMode::Unix {
|
||||
return run_headless(cli, mode);
|
||||
}
|
||||
@@ -153,13 +153,17 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
.socket_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
nsigner::socket_name::socket_name_random().unwrap_or_default()
|
||||
signer::socket_name::socket_name_random().unwrap_or_default()
|
||||
});
|
||||
let auth_mode = parse_auth_mode(&cli.auth);
|
||||
|
||||
if interactive && listen_override.is_none() {
|
||||
if interactive {
|
||||
// ── Fully interactive: App handles everything ────────────
|
||||
let mut app = nsigner::tui::App::new(
|
||||
// The seed entry popup is always shown; if --listen was given
|
||||
// (Unix only — non-Unix modes are headless above), the transport
|
||||
// is pre-selected on the main screen. Pass the raw --listen string
|
||||
// so the App can adopt an explicit tcp:/http: bind address.
|
||||
let mut app = signer::tui::App::new(
|
||||
RoleTable::new(),
|
||||
MnemonicState::new(),
|
||||
KeyStore::new(),
|
||||
@@ -167,88 +171,27 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
socket_name,
|
||||
0,
|
||||
auth_mode,
|
||||
None,
|
||||
cli.listen.clone(),
|
||||
);
|
||||
let mut terminal = ratatui::init();
|
||||
let result = app.run(&mut terminal);
|
||||
ratatui::restore();
|
||||
return result.map_err(|e| NsignerError::IoFailed(e.to_string()));
|
||||
}
|
||||
|
||||
// ── Non-interactive setup: mnemonic + roles ──────────────────
|
||||
let mut mnemonic = MnemonicState::new();
|
||||
|
||||
if cli.mnemonic_stdin {
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
let phrase = input.trim().to_string();
|
||||
mnemonic.load(&phrase)?;
|
||||
} else if let Some(fd) = cli.mnemonic_fd {
|
||||
use std::io::Read;
|
||||
use std::os::unix::io::FromRawFd;
|
||||
let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||
let mut input = String::new();
|
||||
file.read_to_string(&mut input)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
let phrase = input.trim().to_string();
|
||||
mnemonic.load(&phrase)?;
|
||||
}
|
||||
|
||||
// ── Role table ────────────────────────────────────────────────
|
||||
let mut role_table = RoleTable::new();
|
||||
|
||||
if !cli.register_role.is_empty() {
|
||||
for spec in &cli.register_role {
|
||||
register_role_from_spec(&mut role_table, spec)?;
|
||||
}
|
||||
result.map_err(|e| SignerError::IoFailed(e.to_string()))
|
||||
} else {
|
||||
role_table
|
||||
.register_role_path(
|
||||
"main",
|
||||
"m/44'/1237'/0'/0/0",
|
||||
RolePurpose::Nostr,
|
||||
RoleCurve::Secp256k1,
|
||||
-1, -1, -1, &[],
|
||||
)
|
||||
.map_err(|e| NsignerError::Internal(e.to_string()))?;
|
||||
// Unreachable: non-interactive modes return headless above.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Key store & algorithm cache ───────────────────────────────
|
||||
let mut key_store = KeyStore::new();
|
||||
let alg_key_cache = AlgorithmKeyCache::new();
|
||||
|
||||
// ── Derive keys ──────────────────────────────────────────────
|
||||
let derived_count = key_store.derive_all(&mut role_table, &mnemonic)?;
|
||||
|
||||
// ── Unix mode: TUI main screen (skip setup screens) ───────────
|
||||
let mode = listen_override.unwrap_or(ListenMode::Unix);
|
||||
let mut app = nsigner::tui::App::new(
|
||||
role_table,
|
||||
mnemonic,
|
||||
key_store,
|
||||
alg_key_cache,
|
||||
socket_name,
|
||||
derived_count,
|
||||
auth_mode,
|
||||
Some(mode),
|
||||
);
|
||||
let mut terminal = ratatui::init();
|
||||
let result = app.run(&mut terminal);
|
||||
ratatui::restore();
|
||||
result.map_err(|e| NsignerError::IoFailed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Run a headless server (stdio, qrexec, tcp, http) — no TUI.
|
||||
fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError> {
|
||||
fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), SignerError> {
|
||||
let mut mnemonic = MnemonicState::new();
|
||||
|
||||
if cli.mnemonic_stdin {
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
let phrase = input.trim().to_string();
|
||||
mnemonic.load(&phrase)?;
|
||||
} else if let Some(fd) = cli.mnemonic_fd {
|
||||
@@ -257,7 +200,7 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
|
||||
let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||
let mut input = String::new();
|
||||
file.read_to_string(&mut input)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
let phrase = input.trim().to_string();
|
||||
mnemonic.load(&phrase)?;
|
||||
}
|
||||
@@ -276,7 +219,7 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
|
||||
RoleCurve::Secp256k1,
|
||||
-1, -1, -1, &[],
|
||||
)
|
||||
.map_err(|e| NsignerError::Internal(e.to_string()))?;
|
||||
.map_err(|e| SignerError::Internal(e.to_string()))?;
|
||||
}
|
||||
|
||||
let mut key_store = KeyStore::new();
|
||||
@@ -298,7 +241,9 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
|
||||
key_store: &mut key_store,
|
||||
alg_key_cache: &mut alg_key_cache,
|
||||
};
|
||||
let _ = server.handle_one(&mut dispatcher);
|
||||
if let Ok(Some(activity)) = server.handle_one(&mut dispatcher) {
|
||||
println!("{}", activity);
|
||||
}
|
||||
server.stop();
|
||||
} else {
|
||||
// Tcp / Http poll loop
|
||||
@@ -310,8 +255,10 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
|
||||
alg_key_cache: &mut alg_key_cache,
|
||||
};
|
||||
match server.handle_one(&mut dispatcher) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
Ok(Some(activity)) => {
|
||||
println!("{}", activity);
|
||||
}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -333,16 +280,16 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
|
||||
fn client_main(request: &str, cli: &Cli) -> i32 {
|
||||
use std::io::Read;
|
||||
|
||||
let socket_name = cli.socket_name.as_deref().unwrap_or("nsigner01");
|
||||
let socket_name = cli.socket_name.as_deref().unwrap_or("signer01");
|
||||
|
||||
// Discover single socket if not explicit
|
||||
let socket_name = if cli.socket_name.is_some() {
|
||||
socket_name.to_string()
|
||||
} else {
|
||||
nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| socket_name.to_string())
|
||||
signer::socket_name::discover_single_socket().unwrap_or_else(|_| socket_name.to_string())
|
||||
};
|
||||
|
||||
let mut stream = match nsigner::transport::connect_abstract_unix(&socket_name) {
|
||||
let mut stream = match signer::transport::connect_abstract_unix(&socket_name) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect to {}: {}", socket_name, e);
|
||||
@@ -363,13 +310,13 @@ fn client_main(request: &str, cli: &Cli) -> i32 {
|
||||
};
|
||||
|
||||
// Send framed request
|
||||
if nsigner::transport::send_framed(&mut stream, &request).is_err() {
|
||||
if signer::transport::send_framed(&mut stream, &request).is_err() {
|
||||
eprintln!("Failed to send request");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Receive framed response
|
||||
match nsigner::transport::recv_framed(&mut stream) {
|
||||
match signer::transport::recv_framed(&mut stream) {
|
||||
Ok(response) => {
|
||||
println!("{}", response);
|
||||
0
|
||||
@@ -385,18 +332,18 @@ fn client_main(request: &str, cli: &Cli) -> i32 {
|
||||
fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
|
||||
|
||||
|
||||
let target = to.unwrap_or("nsigner01");
|
||||
let target = to.unwrap_or("signer01");
|
||||
let target = if cli.socket_name.is_some() {
|
||||
target.to_string()
|
||||
} else {
|
||||
nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| target.to_string())
|
||||
signer::socket_name::discover_single_socket().unwrap_or_else(|_| target.to_string())
|
||||
};
|
||||
|
||||
// Read source qube from qrexec environment
|
||||
let source_qube = std::env::var("QREXEC_REMOTE_DOMAIN").unwrap_or_default();
|
||||
|
||||
// Connect to persistent signer via abstract socket
|
||||
let mut stream = match nsigner::transport::connect_abstract_unix(&target) {
|
||||
let mut stream = match signer::transport::connect_abstract_unix(&target) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("bridge: cannot connect to {}: {}", target, e);
|
||||
@@ -406,14 +353,14 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
|
||||
|
||||
// Send source-qube preamble
|
||||
let preamble = format!(r#"{{"qrexec_source":"{}"}}"#, source_qube);
|
||||
if nsigner::transport::send_framed(&mut stream, &preamble).is_err() {
|
||||
if signer::transport::send_framed(&mut stream, &preamble).is_err() {
|
||||
eprintln!("bridge: failed to send preamble");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Read one framed request from stdin and forward
|
||||
let mut stdin = std::io::stdin();
|
||||
let request = match nsigner::transport::recv_framed(&mut stdin) {
|
||||
let request = match signer::transport::recv_framed(&mut stdin) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("bridge: failed to read request from stdin: {}", e);
|
||||
@@ -421,17 +368,17 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
|
||||
}
|
||||
};
|
||||
|
||||
if nsigner::transport::send_framed(&mut stream, &request).is_err() {
|
||||
if signer::transport::send_framed(&mut stream, &request).is_err() {
|
||||
eprintln!("bridge: failed to forward request");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Relay response to stdout
|
||||
match nsigner::transport::recv_framed(&mut stream) {
|
||||
match signer::transport::recv_framed(&mut stream) {
|
||||
Ok(response) => {
|
||||
|
||||
let mut stdout = std::io::stdout();
|
||||
if nsigner::transport::send_framed(&mut stdout, &response).is_err() {
|
||||
if signer::transport::send_framed(&mut stdout, &response).is_err() {
|
||||
eprintln!("bridge: failed to relay response");
|
||||
return 1;
|
||||
}
|
||||
@@ -444,9 +391,9 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// List subcommand: list running nsigner sockets.
|
||||
/// List subcommand: list running signer sockets.
|
||||
fn list_main() -> i32 {
|
||||
let sockets = nsigner::socket_name::list_sockets();
|
||||
let sockets = signer::socket_name::list_sockets();
|
||||
if sockets.is_empty() {
|
||||
println!("(none)");
|
||||
} else {
|
||||
@@ -461,10 +408,10 @@ fn list_main() -> i32 {
|
||||
fn register_role_from_spec(
|
||||
role_table: &mut RoleTable,
|
||||
spec: &str,
|
||||
) -> Result<(), NsignerError> {
|
||||
) -> Result<(), SignerError> {
|
||||
let parts: Vec<&str> = spec.splitn(3, ':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
let name = parts[0];
|
||||
@@ -472,13 +419,13 @@ fn register_role_from_spec(
|
||||
let path_token = parts[2];
|
||||
|
||||
if name.is_empty() || path_token.is_empty() {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
// Resolve curve
|
||||
let curve = if curve_str.is_empty() {
|
||||
// Auto-detect from path
|
||||
match nsigner::role_table::purpose_from_path(path_token) {
|
||||
match signer::role_table::purpose_from_path(path_token) {
|
||||
RolePurpose::Ssh => RoleCurve::Ed25519,
|
||||
RolePurpose::Age => RoleCurve::X25519,
|
||||
RolePurpose::PqSig => {
|
||||
@@ -496,15 +443,15 @@ fn register_role_from_spec(
|
||||
};
|
||||
|
||||
if curve == RoleCurve::Unknown {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
let purpose = nsigner::role_table::purpose_from_path(path_token);
|
||||
let purpose = signer::role_table::purpose_from_path(path_token);
|
||||
|
||||
// Parse path template
|
||||
let (template, range_lo, range_hi, allowed_indices) =
|
||||
nsigner::role_table::parse_path_template(path_token)
|
||||
.map_err(|_| NsignerError::InvalidInput)?;
|
||||
signer::role_table::parse_path_template(path_token)
|
||||
.map_err(|_| SignerError::InvalidInput)?;
|
||||
|
||||
role_table
|
||||
.register_role_path(
|
||||
@@ -517,7 +464,7 @@ fn register_role_from_spec(
|
||||
-1, // no default index
|
||||
&allowed_indices,
|
||||
)
|
||||
.map_err(|e| NsignerError::Internal(e.to_string()))?;
|
||||
.map_err(|e| SignerError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+6
-6
@@ -44,14 +44,14 @@ pub fn miner_run(
|
||||
target_difficulty: i32,
|
||||
thread_count: i32,
|
||||
timeout_sec: u64,
|
||||
) -> Result<MineResult, crate::NsignerError> {
|
||||
) -> Result<MineResult, crate::SignerError> {
|
||||
let threads = thread_count.clamp(1, 32) as usize;
|
||||
let timeout = if timeout_sec == 0 { 600 } else { timeout_sec };
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout);
|
||||
|
||||
// Parse the unsigned event
|
||||
let mut event: Event =
|
||||
serde_json::from_str(event_json).map_err(|_| crate::NsignerError::InvalidInput)?;
|
||||
serde_json::from_str(event_json).map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
|
||||
// Ensure there's a nonce tag (will be updated by workers)
|
||||
let has_nonce = event.tags.iter().any(|t| t.kind() == "nonce");
|
||||
@@ -96,9 +96,9 @@ pub fn miner_run(
|
||||
}
|
||||
|
||||
let shared = Arc::try_unwrap(shared)
|
||||
.map_err(|_| crate::NsignerError::Internal("mining thread still holds shared state".into()))?
|
||||
.map_err(|_| crate::SignerError::Internal("mining thread still holds shared state".into()))?
|
||||
.into_inner()
|
||||
.map_err(|_| crate::NsignerError::Internal("mining shared state poisoned".into()))?;
|
||||
.map_err(|_| crate::SignerError::Internal("mining shared state poisoned".into()))?;
|
||||
|
||||
let elapsed = start.elapsed().as_secs();
|
||||
|
||||
@@ -117,8 +117,8 @@ pub fn miner_run(
|
||||
&sk,
|
||||
event.created_at,
|
||||
)
|
||||
.map_err(|_| crate::NsignerError::CryptoFailed)?;
|
||||
serde_json::to_string(&signed).map_err(|_| crate::NsignerError::InvalidInput)?
|
||||
.map_err(|_| crate::SignerError::CryptoFailed)?;
|
||||
serde_json::to_string(&signed).map_err(|_| crate::SignerError::InvalidInput)?
|
||||
} else {
|
||||
// No event mined — return the original unsigned event
|
||||
event_json.to_string()
|
||||
|
||||
+7
-7
@@ -4,7 +4,7 @@
|
||||
//! generation, and seed conversion.
|
||||
|
||||
use crate::secure_mem::SecureBuf;
|
||||
use crate::NsignerError;
|
||||
use crate::SignerError;
|
||||
|
||||
/// Maximum mnemonic length: 24 words * ~10 chars + spaces + null.
|
||||
pub const MNEMONIC_MAX_LEN: usize = 256;
|
||||
@@ -32,18 +32,18 @@ impl MnemonicState {
|
||||
///
|
||||
/// Validates word count (12/15/18/21/24) and BIP-39 checksum.
|
||||
/// Returns `InvalidInput` on invalid mnemonic, `MemoryFailed` on alloc error.
|
||||
pub fn load(&mut self, phrase: &str) -> Result<(), NsignerError> {
|
||||
pub fn load(&mut self, phrase: &str) -> Result<(), SignerError> {
|
||||
let words: Vec<&str> = phrase.split_whitespace().collect();
|
||||
let count = words.len();
|
||||
|
||||
// Validate word count
|
||||
if ![12, 15, 18, 21, 24].contains(&count) {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
// Validate via nostr_core_lib_rust
|
||||
if !nips::nip006::mnemonic_validate(phrase) {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
// Store in secure memory
|
||||
@@ -62,14 +62,14 @@ impl MnemonicState {
|
||||
/// Generate a new BIP-39 mnemonic phrase.
|
||||
///
|
||||
/// `word_count` must be 12, 15, 18, 21, or 24.
|
||||
pub fn generate(&mut self, word_count: u8) -> Result<String, NsignerError> {
|
||||
pub fn generate(&mut self, word_count: u8) -> Result<String, SignerError> {
|
||||
let entropy_bytes = match word_count {
|
||||
12 => 16,
|
||||
15 => 20,
|
||||
18 => 24,
|
||||
21 => 28,
|
||||
24 => 32,
|
||||
_ => return Err(NsignerError::InvalidInput),
|
||||
_ => return Err(SignerError::InvalidInput),
|
||||
};
|
||||
|
||||
let mut entropy = vec![0u8; entropy_bytes];
|
||||
@@ -77,7 +77,7 @@ impl MnemonicState {
|
||||
rand::thread_rng().fill_bytes(&mut entropy);
|
||||
|
||||
let phrase = nips::nip006::mnemonic_from_bytes(&entropy)
|
||||
.map_err(|_| NsignerError::CryptoFailed)?;
|
||||
.map_err(|_| SignerError::CryptoFailed)?;
|
||||
|
||||
// Zeroize entropy
|
||||
use zeroize::Zeroize;
|
||||
|
||||
+23
-23
@@ -4,7 +4,7 @@
|
||||
//! startup. Pad offset advances monotonically across requests.
|
||||
|
||||
use crate::secure_mem::SecureBuf;
|
||||
use crate::NsignerError;
|
||||
use crate::SignerError;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
@@ -63,14 +63,14 @@ impl OtpPadState {
|
||||
/// `dir` — directory containing .pad and .state files
|
||||
/// `spec` — pad checksum (64 hex) or unique prefix
|
||||
/// `allow_blkback` — allow pads on qvm-block devices (not for production)
|
||||
pub fn bind(&mut self, dir: &str, spec: &str, _allow_blkback: bool) -> Result<(), NsignerError> {
|
||||
pub fn bind(&mut self, dir: &str, spec: &str, _allow_blkback: bool) -> Result<(), SignerError> {
|
||||
// Find the pad file matching the spec
|
||||
let pad_filename = if spec.len() == 64 {
|
||||
format!("{}/{}.pad", dir, spec)
|
||||
} else {
|
||||
// Prefix match — find a .pad file starting with spec
|
||||
let entries = std::fs::read_dir(dir)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
|
||||
let mut found = None;
|
||||
for entry in entries {
|
||||
@@ -85,15 +85,15 @@ impl OtpPadState {
|
||||
}
|
||||
found
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.ok_or(NsignerError::InvalidInput)?
|
||||
.ok_or(SignerError::InvalidInput)?
|
||||
};
|
||||
|
||||
let file = File::open(&pad_filename)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
let size = metadata.len();
|
||||
|
||||
// Extract checksum from filename
|
||||
@@ -113,7 +113,7 @@ impl OtpPadState {
|
||||
|
||||
// Allocate scratch buffer for XOR
|
||||
let scratch = SecureBuf::alloc(4 * 1024 * 1024) // 4 MB max chunk
|
||||
.map_err(|_| NsignerError::MemoryFailed)?;
|
||||
.map_err(|_| SignerError::MemoryFailed)?;
|
||||
|
||||
self.bound = true;
|
||||
self.pads_dir = dir.to_string();
|
||||
@@ -144,9 +144,9 @@ impl OtpPadState {
|
||||
&mut self,
|
||||
plaintext: &[u8],
|
||||
_encoding: Option<&str>,
|
||||
) -> Result<(Vec<u8>, u64, u64), NsignerError> {
|
||||
) -> Result<(Vec<u8>, u64, u64), SignerError> {
|
||||
if !self.bound {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
let off_before = self.offset;
|
||||
@@ -164,32 +164,32 @@ impl OtpPadState {
|
||||
&mut self,
|
||||
ciphertext: &[u8],
|
||||
_encoding: Option<&str>,
|
||||
) -> Result<Vec<u8>, NsignerError> {
|
||||
) -> Result<Vec<u8>, SignerError> {
|
||||
if !self.bound {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
self.xor_with_pad(ciphertext)
|
||||
}
|
||||
|
||||
/// XOR data with pad bytes at the current offset, advancing the offset.
|
||||
fn xor_with_pad(&mut self, data: &[u8]) -> Result<Vec<u8>, NsignerError> {
|
||||
let file = self.pad_file.as_mut().ok_or(NsignerError::InvalidInput)?;
|
||||
let scratch = self.scratch.as_mut().ok_or(NsignerError::InvalidInput)?;
|
||||
fn xor_with_pad(&mut self, data: &[u8]) -> Result<Vec<u8>, SignerError> {
|
||||
let file = self.pad_file.as_mut().ok_or(SignerError::InvalidInput)?;
|
||||
let scratch = self.scratch.as_mut().ok_or(SignerError::InvalidInput)?;
|
||||
|
||||
let data_len = data.len();
|
||||
if data_len > scratch.size() {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
// Seek to current offset
|
||||
file.seek(SeekFrom::Start(self.offset))
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
|
||||
// Read pad bytes
|
||||
let pad_slice = &mut scratch.as_mut_slice()[..data_len];
|
||||
file.read_exact(pad_slice)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
|
||||
// XOR
|
||||
let result: Vec<u8> = data
|
||||
@@ -231,11 +231,11 @@ static GLOBAL_OTP_PAD: Mutex<Option<OtpPadState>> = Mutex::new(None);
|
||||
|
||||
/// Bind the global OTP pad. Called from the role wizard (OTP preset) or from
|
||||
/// `--otp-pad-dir` CLI handling. Replaces any previously bound pad.
|
||||
pub fn bind_global(dir: &str, spec: &str, allow_blkback: bool) -> Result<(), NsignerError> {
|
||||
pub fn bind_global(dir: &str, spec: &str, allow_blkback: bool) -> Result<(), SignerError> {
|
||||
let mut pad = OtpPadState::new();
|
||||
pad.bind(dir, spec, allow_blkback)?;
|
||||
let mut guard = GLOBAL_OTP_PAD.lock().map_err(|e| {
|
||||
NsignerError::Internal(format!("global otp pad lock poisoned: {}", e))
|
||||
SignerError::Internal(format!("global otp pad lock poisoned: {}", e))
|
||||
})?;
|
||||
*guard = Some(pad);
|
||||
Ok(())
|
||||
@@ -287,7 +287,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_bind_and_encrypt() {
|
||||
let dir = std::env::temp_dir().join("nsigner_otp_test_1");
|
||||
let dir = std::env::temp_dir().join("signer_otp_test_1");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let chksum = make_test_pad(&dir, 1024);
|
||||
|
||||
@@ -313,7 +313,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let dir = std::env::temp_dir().join("nsigner_otp_test_2");
|
||||
let dir = std::env::temp_dir().join("signer_otp_test_2");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let chksum = make_test_pad(&dir, 1024);
|
||||
|
||||
@@ -346,7 +346,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_unbind() {
|
||||
let dir = std::env::temp_dir().join("nsigner_otp_test_3");
|
||||
let dir = std::env::temp_dir().join("signer_otp_test_3");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let chksum = make_test_pad(&dir, 1024);
|
||||
|
||||
@@ -363,7 +363,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_offset_advances() {
|
||||
let dir = std::env::temp_dir().join("nsigner_otp_test_4");
|
||||
let dir = std::env::temp_dir().join("signer_otp_test_4");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let chksum = make_test_pad(&dir, 1024);
|
||||
|
||||
|
||||
+390
-67
@@ -1,8 +1,13 @@
|
||||
//! Post-quantum crypto algorithm registry.
|
||||
//! Post-quantum crypto algorithm registry and operations.
|
||||
//!
|
||||
//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum and size
|
||||
//! constants for all six algorithms. Actual crypto operations
|
||||
//! (ed25519, x25519, PQ) are implemented in Phase 13.
|
||||
//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum, size constants,
|
||||
//! and crypto operations for all six algorithms.
|
||||
//!
|
||||
//! PQ keygen uses the v2 FIPS seeded derivation scheme (see
|
||||
//! `plans/pq_seeded_derivation_plan.md`): BIP-32 child bytes at the exact
|
||||
//! seed length required by each algorithm feed the seeded keygen APIs
|
||||
//! directly — no DRBG expansion. This matches the nostr_quantum_preparation
|
||||
//! web app byte-for-byte (same mnemonic + path → same pubkeys).
|
||||
|
||||
// ── Algorithm Identifiers ────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,6 +60,13 @@ pub struct CryptoAlgSizes {
|
||||
}
|
||||
|
||||
impl CryptoAlg {
|
||||
/// Sizes for each algorithm.
|
||||
///
|
||||
/// PQ private keys are stored in **seed form** (the preferred
|
||||
/// serialization of the RustCrypto crates): ML-DSA-65 as the 32-byte ξ
|
||||
/// seed, ML-KEM-768 as the 64-byte d ∥ z seed, SLH-DSA-128s as the
|
||||
/// 64-byte sk serialization (sk.seed ∥ sk.prf ∥ pk). Public key,
|
||||
/// signature, and ciphertext lengths are the standard FIPS sizes.
|
||||
pub fn sizes(&self) -> Option<CryptoAlgSizes> {
|
||||
match self {
|
||||
Self::Secp256k1 => Some(CryptoAlgSizes {
|
||||
@@ -67,53 +79,126 @@ impl CryptoAlg {
|
||||
priv_key_len: 32, pub_key_len: 32, sig_len: 0, ciphertext_len: 0, shared_secret_len: 32,
|
||||
}),
|
||||
Self::MlDsa65 => Some(CryptoAlgSizes {
|
||||
priv_key_len: 4032, pub_key_len: 1952, sig_len: 3309, ciphertext_len: 0, shared_secret_len: 0,
|
||||
priv_key_len: 32, pub_key_len: 1952, sig_len: 3309, ciphertext_len: 0, shared_secret_len: 0,
|
||||
}),
|
||||
Self::SlhDsa128s => Some(CryptoAlgSizes {
|
||||
priv_key_len: 64, pub_key_len: 32, sig_len: 7856, ciphertext_len: 0, shared_secret_len: 0,
|
||||
}),
|
||||
Self::MlKem768 => Some(CryptoAlgSizes {
|
||||
priv_key_len: 2400, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32,
|
||||
priv_key_len: 64, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32,
|
||||
}),
|
||||
Self::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crypto Operations (stubs — Phase 13) ─────────────────────────────────────
|
||||
// ── Crypto Operations ────────────────────────────────────────────────────────
|
||||
|
||||
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path (SLIP-0010).
|
||||
/// BIP-32 path prefixes routed through BIP-32 derivation.
|
||||
///
|
||||
/// For secp256k1: uses BIP-32 derivation.
|
||||
/// - `m/44'/1237'` — Nostr secp256k1 (NIP-06)
|
||||
/// - `m/44'/102003'` … `m/44'/102007'` — PQ coin types (v2 seeded scheme;
|
||||
/// 102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768,
|
||||
/// 102006' ML-DSA-44 and 102007' Falcon-512 reserved)
|
||||
///
|
||||
/// Everything else — ed25519 (`102001'`) and x25519 (`102002'`) — uses
|
||||
/// SLIP-0010, which is the correct derivation for those curves.
|
||||
const BIP32_PATH_PREFIXES: &[&str] = &[
|
||||
"m/44'/1237'",
|
||||
"m/44'/102003'",
|
||||
"m/44'/102004'",
|
||||
"m/44'/102005'",
|
||||
"m/44'/102006'",
|
||||
"m/44'/102007'",
|
||||
];
|
||||
|
||||
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path.
|
||||
///
|
||||
/// For secp256k1 and PQ coin types: uses BIP-32 derivation.
|
||||
/// For ed25519/x25519: uses SLIP-0010 (all-hardened).
|
||||
/// For PQ: uses SLIP-0010 to get a 32-byte seed, then feeds DRBG for keygen.
|
||||
///
|
||||
/// Note: PQ keygen should use [`derive_pq_seed_from_path`] instead — it
|
||||
/// produces the exact-length seed (32/48/64 bytes) required by the FIPS
|
||||
/// seeded keygen APIs.
|
||||
pub fn derive_seed_from_mnemonic(
|
||||
mnemonic: &str,
|
||||
path: &str,
|
||||
) -> Result<[u8; 32], crate::NsignerError> {
|
||||
) -> Result<[u8; 32], crate::SignerError> {
|
||||
let seed = nips::nip006::mnemonic_to_seed(mnemonic, "");
|
||||
|
||||
// Parse the path
|
||||
let path_indices = nips::nip006::parse_bip44_path(path)
|
||||
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
|
||||
// Determine if this is a secp256k1 path (BIP-32) or ed25519/x25519 path (SLIP-0010)
|
||||
// by checking the purpose prefix.
|
||||
if path.starts_with("m/44'/1237'") {
|
||||
// BIP-32 derivation for secp256k1
|
||||
// BIP-32 for secp256k1 (NIP-06) and PQ coin types (v2 seeded scheme);
|
||||
// SLIP-0010 for ed25519/x25519 (correct for those curves).
|
||||
if BIP32_PATH_PREFIXES.iter().any(|p| path.starts_with(p)) {
|
||||
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&seed);
|
||||
let (derived_key, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &path_indices)
|
||||
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
Ok(derived_key)
|
||||
} else {
|
||||
// SLIP-0010 derivation for ed25519/x25519/PQ
|
||||
let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&seed);
|
||||
let (derived_key, _) = nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices)
|
||||
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
Ok(derived_key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a PQ keygen seed of `seed_len` bytes (32/48/64) from a BIP-32 path.
|
||||
///
|
||||
/// v2 seeded construction (matches nostr_quantum_preparation exactly):
|
||||
/// - 32-byte seeds: the child private key at `path`.
|
||||
/// - 48/64-byte seeds: the children at `path` and at the sibling path (last
|
||||
/// level incremented by 1, hardened bit preserved) concatenated to 64
|
||||
/// bytes, then truncated to the FIRST `seed_len` bytes.
|
||||
///
|
||||
/// The truncation rule is normative: taking the last 48 bytes or
|
||||
/// concatenating in the opposite order produces different keys and breaks
|
||||
/// seed-phrase recoverability.
|
||||
pub fn derive_pq_seed_from_path(
|
||||
mnemonic: &str,
|
||||
path: &str,
|
||||
seed_len: usize,
|
||||
) -> Result<Vec<u8>, crate::SignerError> {
|
||||
if !matches!(seed_len, 32 | 48 | 64) {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic, "");
|
||||
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
|
||||
|
||||
let indices = nips::nip006::parse_bip44_path(path)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
if indices.is_empty() {
|
||||
return Err(crate::SignerError::KeyDerivationFailed);
|
||||
}
|
||||
|
||||
let (child0, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &indices)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
|
||||
if seed_len == 32 {
|
||||
return Ok(child0.to_vec());
|
||||
}
|
||||
|
||||
// Sibling path: last level + 1 (a plain u32 increment preserves the
|
||||
// hardened bit: 0x80000000 + 1 = 0x80000001, i.e. hardened 1').
|
||||
let mut sibling_indices = indices.clone();
|
||||
let last = sibling_indices
|
||||
.last_mut()
|
||||
.ok_or(crate::SignerError::KeyDerivationFailed)?;
|
||||
*last = last.wrapping_add(1);
|
||||
|
||||
let (child1, _) =
|
||||
nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &sibling_indices)
|
||||
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
|
||||
|
||||
let mut combined = [0u8; 64];
|
||||
combined[..32].copy_from_slice(&child0);
|
||||
combined[32..].copy_from_slice(&child1);
|
||||
Ok(combined[..seed_len].to_vec())
|
||||
}
|
||||
|
||||
/// ed25519: derive keypair from a 32-byte seed.
|
||||
pub fn ed25519_keygen_from_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
@@ -162,12 +247,12 @@ pub fn x25519_ecdh(our_priv: &[u8; 32], peer_pub: &[u8; 32]) -> [u8; 32] {
|
||||
/// secp256k1 ECDSA sign arbitrary bytes.
|
||||
/// Hashes the message with SHA-256 before signing.
|
||||
/// Returns 64-byte compact signature (r || s).
|
||||
pub fn secp256k1_ecdsa_sign(priv_key: &[u8; 32], msg: &[u8]) -> Result<[u8; 64], crate::NsignerError> {
|
||||
pub fn secp256k1_ecdsa_sign(priv_key: &[u8; 32], msg: &[u8]) -> Result<[u8; 64], crate::SignerError> {
|
||||
use secp256k1::{Message, Secp256k1, SecretKey};
|
||||
let secp = Secp256k1::new();
|
||||
let sk = SecretKey::from_slice(priv_key).map_err(|_| crate::NsignerError::CryptoFailed)?;
|
||||
let sk = SecretKey::from_slice(priv_key).map_err(|_| crate::SignerError::CryptoFailed)?;
|
||||
let hash = sha256(msg);
|
||||
let msg = Message::from_digest_slice(&hash).map_err(|_| crate::NsignerError::CryptoFailed)?;
|
||||
let msg = Message::from_digest_slice(&hash).map_err(|_| crate::SignerError::CryptoFailed)?;
|
||||
let sig = secp.sign_ecdsa(&msg, &sk);
|
||||
Ok(sig.serialize_compact())
|
||||
}
|
||||
@@ -200,69 +285,146 @@ pub fn secp256k1_ecdsa_verify(pub_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) ->
|
||||
secp.verify_ecdsa(&msg, &signature, &pk).is_ok()
|
||||
}
|
||||
|
||||
// ── PQ Crypto Stubs ──────────────────────────────────────────────────────────
|
||||
// ── PQ Crypto (FIPS seeded keygen — v2 scheme) ───────────────────────────────
|
||||
//
|
||||
// The pure Rust crates (ml-dsa, ml-kem, slh-dsa) are included as dependencies
|
||||
// for future implementation. Their APIs use `TryCryptoRng`, `KeyExport`, and
|
||||
// other traits that require careful integration with the SHAKE-256 DRBG.
|
||||
//
|
||||
// TODO: Wire up the crate APIs for deterministic keygen from seed, sign, verify,
|
||||
// encapsulate, and decapsulate operations.
|
||||
// Keygen consumes the exact-length seed derived by `derive_pq_seed_from_path`
|
||||
// (32 B ML-DSA, 48 B SLH-DSA, 64 B ML-KEM) via the RustCrypto seeded APIs.
|
||||
// Private keys are stored in seed form (see `CryptoAlg::sizes`).
|
||||
|
||||
/// ML-DSA-65: generate keypair from a 32-byte seed (deterministic).
|
||||
/// TODO: Wire up ml-dsa crate API.
|
||||
pub fn ml_dsa_65_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
|
||||
Err(crate::NsignerError::NotYetImplemented)
|
||||
///
|
||||
/// Returns (private_key = 32-byte ξ seed, public_key = 1952 bytes).
|
||||
pub fn ml_dsa_65_keygen_from_seed(seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
use ml_dsa::{MlDsa65, SigningKey, signature::Keypair};
|
||||
|
||||
let sk = SigningKey::<MlDsa65>::from_seed(seed.into());
|
||||
let vk = sk.verifying_key();
|
||||
Ok((sk.to_seed().to_vec(), vk.encode().to_vec()))
|
||||
}
|
||||
|
||||
/// ML-DSA-65: sign a message.
|
||||
/// TODO: Wire up ml-dsa crate API.
|
||||
pub fn ml_dsa_65_sign(_priv: &[u8], _msg: &[u8]) -> Result<Vec<u8>, crate::NsignerError> {
|
||||
Err(crate::NsignerError::NotYetImplemented)
|
||||
/// ML-DSA-65: sign a message. priv is the 32-byte ξ seed.
|
||||
/// Returns the 3309-byte signature (deterministic FIPS 204 variant).
|
||||
pub fn ml_dsa_65_sign(priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
|
||||
use ml_dsa::{MlDsa65, Seed, SigningKey, signature::Signer};
|
||||
|
||||
if priv_key.len() != 32 {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
let seed: Seed = priv_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let sk = SigningKey::<MlDsa65>::from_seed(&seed);
|
||||
let sig = sk.sign(msg);
|
||||
Ok(sig.encode().to_vec())
|
||||
}
|
||||
|
||||
/// ML-DSA-65: verify a signature.
|
||||
/// TODO: Wire up ml-dsa crate API.
|
||||
pub fn ml_dsa_65_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
|
||||
false
|
||||
/// ML-DSA-65: verify a signature. pub is the 1952-byte public key.
|
||||
pub fn ml_dsa_65_verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
|
||||
use ml_dsa::{MlDsa65, Signature, VerifyingKey, signature::Verifier};
|
||||
|
||||
if pub_key.len() != 1952 {
|
||||
return false;
|
||||
}
|
||||
let enc: ml_dsa::EncodedVerifyingKey<MlDsa65> =
|
||||
match pub_key.try_into() {
|
||||
Ok(e) => e,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let vk = VerifyingKey::<MlDsa65>::decode(&enc);
|
||||
let signature = match Signature::<MlDsa65>::try_from(sig) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
vk.verify(msg, &signature).is_ok()
|
||||
}
|
||||
|
||||
/// SLH-DSA-128s: generate keypair from a 32-byte seed (deterministic).
|
||||
/// TODO: Wire up slh-dsa crate API.
|
||||
pub fn slh_dsa_128s_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
|
||||
Err(crate::NsignerError::NotYetImplemented)
|
||||
/// SLH-DSA-128s (SHA2 small): generate keypair from a 48-byte seed.
|
||||
///
|
||||
/// The seed splits as sk.seed(16) ∥ sk.prf(16) ∥ pk.seed(16) — matching
|
||||
/// noble's `slh_dsa_sha2_128s.keygen(seed)` exactly.
|
||||
/// Returns (private_key = 64-byte sk serialization, public_key = 32 bytes).
|
||||
pub fn slh_dsa_128s_keygen_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
use slh_dsa::{Sha2_128s, SigningKey, signature::Keypair};
|
||||
|
||||
if seed.len() != 48 {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
let sk = SigningKey::<Sha2_128s>::slh_keygen_internal(&seed[..16], &seed[16..32], &seed[32..48]);
|
||||
let vk = sk.verifying_key();
|
||||
Ok((sk.to_bytes().to_vec(), vk.to_bytes().to_vec()))
|
||||
}
|
||||
|
||||
/// SLH-DSA-128s: sign a message.
|
||||
/// TODO: Wire up slh-dsa crate API.
|
||||
pub fn slh_dsa_128s_sign(_priv: &[u8], _msg: &[u8]) -> Result<Vec<u8>, crate::NsignerError> {
|
||||
Err(crate::NsignerError::NotYetImplemented)
|
||||
/// SLH-DSA-128s: sign a message. priv is the 64-byte sk serialization.
|
||||
/// Returns the 7856-byte signature (deterministic: opt_rand = pk.seed).
|
||||
pub fn slh_dsa_128s_sign(priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
|
||||
use slh_dsa::{Sha2_128s, SigningKey, signature::Signer};
|
||||
|
||||
let sk = SigningKey::<Sha2_128s>::try_from(priv_key)
|
||||
.map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let sig = sk.sign(msg);
|
||||
Ok(sig.to_vec())
|
||||
}
|
||||
|
||||
/// SLH-DSA-128s: verify a signature.
|
||||
/// TODO: Wire up slh-dsa crate API.
|
||||
pub fn slh_dsa_128s_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
|
||||
false
|
||||
/// SLH-DSA-128s: verify a signature. pub is the 32-byte public key.
|
||||
pub fn slh_dsa_128s_verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
|
||||
use slh_dsa::{Sha2_128s, Signature, VerifyingKey, signature::Verifier};
|
||||
|
||||
let vk = match VerifyingKey::<Sha2_128s>::try_from(pub_key) {
|
||||
Ok(k) => k,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let signature = match Signature::<Sha2_128s>::try_from(sig) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
vk.verify(msg, &signature).is_ok()
|
||||
}
|
||||
|
||||
/// ML-KEM-768: generate keypair from a 32-byte seed (deterministic).
|
||||
/// TODO: Wire up ml-kem crate API.
|
||||
pub fn ml_kem_768_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
|
||||
Err(crate::NsignerError::NotYetImplemented)
|
||||
/// ML-KEM-768: generate keypair from a 64-byte seed (deterministic).
|
||||
///
|
||||
/// The seed splits as d(32) ∥ z(32) — matching noble's `ml_kem768.keygen(seed)`.
|
||||
/// Returns (private_key = 64-byte seed, public_key = 1184 bytes).
|
||||
pub fn ml_kem_768_keygen_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
|
||||
use ml_kem::ml_kem_768::DecapsulationKey;
|
||||
use ml_kem::{KeyExport, Seed};
|
||||
|
||||
if seed.len() != 64 {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
let seed: Seed = seed.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let dk = DecapsulationKey::from_seed(seed);
|
||||
let ek = dk.encapsulation_key();
|
||||
Ok((dk.to_seed().ok_or(crate::SignerError::CryptoFailed)?.to_vec(), ek.to_bytes().to_vec()))
|
||||
}
|
||||
|
||||
/// ML-KEM-768: encapsulate. pub is 1184-byte public key.
|
||||
/// Returns (ciphertext[1088], shared_secret[32]).
|
||||
/// TODO: Wire up ml-kem crate API.
|
||||
pub fn ml_kem_768_encaps(_pub: &[u8]) -> Result<(Vec<u8>, [u8; 32]), crate::NsignerError> {
|
||||
Err(crate::NsignerError::NotYetImplemented)
|
||||
/// ML-KEM-768: encapsulate. pub is the 1184-byte public key.
|
||||
/// Returns (ciphertext[1088], shared_secret[32]). Uses OS randomness —
|
||||
/// each encapsulation produces a different ciphertext, by design.
|
||||
pub fn ml_kem_768_encaps(pub_key: &[u8]) -> Result<(Vec<u8>, [u8; 32]), crate::SignerError> {
|
||||
use ml_kem::ml_kem_768::EncapsulationKey;
|
||||
use ml_kem::kem::Encapsulate;
|
||||
|
||||
let ek = EncapsulationKey::new(
|
||||
pub_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?,
|
||||
)
|
||||
.map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let (ct, ss) = ek.encapsulate();
|
||||
Ok((ct.to_vec(), ss.into()))
|
||||
}
|
||||
|
||||
/// ML-KEM-768: decapsulate. priv is 2400-byte secret key, ct is 1088-byte ciphertext.
|
||||
/// Returns shared_secret[32].
|
||||
/// TODO: Wire up ml-kem crate API.
|
||||
pub fn ml_kem_768_decaps(_priv: &[u8], _ct: &[u8]) -> Result<[u8; 32], crate::NsignerError> {
|
||||
Err(crate::NsignerError::NotYetImplemented)
|
||||
/// ML-KEM-768: decapsulate. priv is the 64-byte seed, ct is the 1088-byte
|
||||
/// ciphertext. Returns shared_secret[32].
|
||||
pub fn ml_kem_768_decaps(priv_key: &[u8], ct: &[u8]) -> Result<[u8; 32], crate::SignerError> {
|
||||
use ml_kem::ml_kem_768::DecapsulationKey;
|
||||
use ml_kem::kem::Decapsulate;
|
||||
use ml_kem::Seed;
|
||||
|
||||
if priv_key.len() != 64 {
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
let seed: Seed = priv_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let dk = DecapsulationKey::from_seed(seed);
|
||||
let ct_arr = ct.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
|
||||
let ss = dk.decapsulate(&ct_arr);
|
||||
Ok(ss.into())
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -276,6 +438,9 @@ fn sha256(data: &[u8]) -> [u8; 32] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The fixed test mnemonic used by nostr_quantum_preparation's vectors.
|
||||
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
|
||||
#[test]
|
||||
fn test_alg_from_str() {
|
||||
assert_eq!(CryptoAlg::from_str("secp256k1"), CryptoAlg::Secp256k1);
|
||||
@@ -290,10 +455,22 @@ mod tests {
|
||||
assert_eq!(s.priv_key_len, 32);
|
||||
assert_eq!(s.pub_key_len, 32);
|
||||
|
||||
// PQ private keys are stored in seed form.
|
||||
let s = CryptoAlg::MlDsa65.sizes().unwrap();
|
||||
assert_eq!(s.priv_key_len, 32);
|
||||
assert_eq!(s.pub_key_len, 1952);
|
||||
assert_eq!(s.sig_len, 3309);
|
||||
|
||||
let s = CryptoAlg::SlhDsa128s.sizes().unwrap();
|
||||
assert_eq!(s.priv_key_len, 64);
|
||||
assert_eq!(s.pub_key_len, 32);
|
||||
assert_eq!(s.sig_len, 7856);
|
||||
|
||||
let s = CryptoAlg::MlKem768.sizes().unwrap();
|
||||
assert_eq!(s.priv_key_len, 2400);
|
||||
assert_eq!(s.priv_key_len, 64);
|
||||
assert_eq!(s.pub_key_len, 1184);
|
||||
assert_eq!(s.ciphertext_len, 1088);
|
||||
assert_eq!(s.shared_secret_len, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -316,4 +493,150 @@ mod tests {
|
||||
let shared_b = x25519_ecdh(&priv_b, &pub_a);
|
||||
assert_eq!(shared_a, shared_b);
|
||||
}
|
||||
|
||||
// ── v2 seeded derivation ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_pq_seed_lengths() {
|
||||
// 32-byte seed: single child.
|
||||
let s32 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
|
||||
assert_eq!(s32.len(), 32);
|
||||
|
||||
// 48-byte seed: two children concatenated, first 48 of 64.
|
||||
let s48 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102004'/0'/0'/0'", 48).unwrap();
|
||||
assert_eq!(s48.len(), 48);
|
||||
|
||||
// 64-byte seed: two children concatenated, all 64.
|
||||
let s64 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102005'/0'/0'/0'", 64).unwrap();
|
||||
assert_eq!(s64.len(), 64);
|
||||
|
||||
// The 48-byte seed is a prefix of the 64-byte seed only when the
|
||||
// paths share the same coin type — here they differ, so just check
|
||||
// prefix consistency within the same coin type.
|
||||
let s48b = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102005'/0'/0'/0'", 48).unwrap();
|
||||
assert_eq!(&s64[..48], s48b.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pq_seed_determinism() {
|
||||
let a = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
|
||||
let b = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
|
||||
assert_eq!(a, b);
|
||||
|
||||
// Different index → different seed.
|
||||
let c = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/1'", 32).unwrap();
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pq_seed_invalid_length() {
|
||||
assert!(derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 33).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pq_paths_use_bip32() {
|
||||
// PQ coin types must route through BIP-32 (v2 scheme), not SLIP-0010.
|
||||
// The 32-byte PQ seed equals the BIP-32 child at the same path.
|
||||
let pq_seed =
|
||||
derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
|
||||
let bip32_seed =
|
||||
derive_seed_from_mnemonic(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'").unwrap();
|
||||
assert_eq!(pq_seed, bip32_seed.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ed25519_path_still_slip10() {
|
||||
// ed25519 (102001') must remain SLIP-0010 — regression guard.
|
||||
let seed = derive_seed_from_mnemonic(TEST_MNEMONIC, "m/44'/102001'/0'/0'/0'").unwrap();
|
||||
|
||||
let bip39_seed = nips::nip006::mnemonic_to_seed(TEST_MNEMONIC, "");
|
||||
let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&bip39_seed);
|
||||
let path_indices = nips::nip006::parse_bip44_path("m/44'/102001'/0'/0'/0'").unwrap();
|
||||
let (expected, _) =
|
||||
nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices)
|
||||
.unwrap();
|
||||
assert_eq!(seed, expected);
|
||||
}
|
||||
|
||||
// ── PQ keygen / sign / verify / KEM roundtrips ────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ml_dsa_65_roundtrip() {
|
||||
let seed = [0x42u8; 32];
|
||||
let (priv_key, pub_key) = ml_dsa_65_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key.len(), 32);
|
||||
assert_eq!(pub_key.len(), 1952);
|
||||
|
||||
// Determinism: same seed → same keypair.
|
||||
let (priv2, pub2) = ml_dsa_65_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key, priv2);
|
||||
assert_eq!(pub_key, pub2);
|
||||
|
||||
let msg = b"hello world";
|
||||
let sig = ml_dsa_65_sign(&priv_key, msg).unwrap();
|
||||
assert_eq!(sig.len(), 3309);
|
||||
assert!(ml_dsa_65_verify(&pub_key, msg, &sig));
|
||||
assert!(!ml_dsa_65_verify(&pub_key, b"wrong message", &sig));
|
||||
|
||||
// Different seed → different key → verify fails.
|
||||
let (_, pub_other) = ml_dsa_65_keygen_from_seed(&[0x99u8; 32]).unwrap();
|
||||
assert!(!ml_dsa_65_verify(&pub_other, msg, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slh_dsa_128s_roundtrip() {
|
||||
let seed = [0x42u8; 48];
|
||||
let (priv_key, pub_key) = slh_dsa_128s_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key.len(), 64);
|
||||
assert_eq!(pub_key.len(), 32);
|
||||
|
||||
// Determinism.
|
||||
let (priv2, pub2) = slh_dsa_128s_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key, priv2);
|
||||
assert_eq!(pub_key, pub2);
|
||||
|
||||
let msg = b"hello world";
|
||||
let sig = slh_dsa_128s_sign(&priv_key, msg).unwrap();
|
||||
assert_eq!(sig.len(), 7856);
|
||||
assert!(slh_dsa_128s_verify(&pub_key, msg, &sig));
|
||||
assert!(!slh_dsa_128s_verify(&pub_key, b"wrong message", &sig));
|
||||
|
||||
// Deterministic signing: same key + msg → same signature
|
||||
// (opt_rand defaults to pk.seed).
|
||||
let sig2 = slh_dsa_128s_sign(&priv_key, msg).unwrap();
|
||||
assert_eq!(sig, sig2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_kem_768_roundtrip() {
|
||||
let seed = [0x42u8; 64];
|
||||
let (priv_key, pub_key) = ml_kem_768_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key.len(), 64);
|
||||
assert_eq!(pub_key.len(), 1184);
|
||||
|
||||
// Determinism.
|
||||
let (priv2, pub2) = ml_kem_768_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(priv_key, priv2);
|
||||
assert_eq!(pub_key, pub2);
|
||||
|
||||
// Encaps/decaps roundtrip.
|
||||
let (ct, ss_send) = ml_kem_768_encaps(&pub_key).unwrap();
|
||||
assert_eq!(ct.len(), 1088);
|
||||
assert_eq!(ss_send.len(), 32);
|
||||
let ss_recv = ml_kem_768_decaps(&priv_key, &ct).unwrap();
|
||||
assert_eq!(ss_send, ss_recv);
|
||||
|
||||
// Encapsulation is randomized: two calls → different ciphertexts.
|
||||
let (ct2, ss2) = ml_kem_768_encaps(&pub_key).unwrap();
|
||||
assert_ne!(ct, ct2);
|
||||
assert_ne!(ss_send.to_vec(), ss2.to_vec());
|
||||
assert_eq!(ml_kem_768_decaps(&priv_key, &ct2).unwrap(), ss2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ml_kem_768_invalid_inputs() {
|
||||
assert!(ml_kem_768_keygen_from_seed(&[0u8; 32]).is_err());
|
||||
assert!(ml_kem_768_decaps(&[0u8; 64], &[0u8; 1087]).is_err());
|
||||
assert!(ml_kem_768_encaps(&[0u8; 1183]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -1,7 +1,14 @@
|
||||
//! Deterministic PRNG for post-quantum key generation.
|
||||
//! Deterministic PRNG (NOT used for PQ key derivation).
|
||||
//!
|
||||
//! Port of `pq_drbg.c`. Implements a SHAKE-256-based deterministic PRNG
|
||||
//! that replaces PQClean's `randombytes()` callback. Same seed → same output.
|
||||
//! that replaced PQClean's `randombytes()` callback in the C n_signer.
|
||||
//! Same seed → same output.
|
||||
//!
|
||||
//! **Not used for derivation**: PQ keygen now uses the v2 FIPS seeded
|
||||
//! interface (see `plans/pq_seeded_derivation_plan.md`) — BIP-32 child
|
||||
//! bytes at the exact seed length feed the seeded keygen APIs directly.
|
||||
//! This module is retained as a faithful port for any future
|
||||
//! PQClean-style integration that needs an RNG-fed keygen.
|
||||
|
||||
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
|
||||
|
||||
|
||||
+18
-15
@@ -4,7 +4,7 @@
|
||||
//! (acting as an access token) to a BIP-44 derivation path template.
|
||||
//! The template may contain a `%d` placeholder for a variable index.
|
||||
|
||||
use crate::NsignerError;
|
||||
use crate::SignerError;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// ── Limits ───────────────────────────────────────────────────────────────────
|
||||
@@ -137,6 +137,8 @@ pub struct RoleEntry {
|
||||
pub pubkey_hex: String,
|
||||
/// 1 if pubkey_hex has been populated.
|
||||
pub derived: bool,
|
||||
/// The concrete path the key was last derived for (variable-path roles).
|
||||
pub derived_path: Option<String>,
|
||||
/// Inclusive lower bound for %d; -1 = fixed path (no variable).
|
||||
pub path_range_lo: i32,
|
||||
/// Inclusive upper bound; == path_range_lo for single.
|
||||
@@ -162,6 +164,7 @@ impl Default for RoleEntry {
|
||||
role_path: String::new(),
|
||||
pubkey_hex: String::new(),
|
||||
derived: false,
|
||||
derived_path: None,
|
||||
path_range_lo: -1,
|
||||
path_range_hi: -1,
|
||||
path_default_index: -1,
|
||||
@@ -264,12 +267,12 @@ impl RoleTable {
|
||||
}
|
||||
|
||||
/// Add a role entry. Returns error if table full or name duplicate.
|
||||
pub fn add(&mut self, entry: RoleEntry) -> Result<(), NsignerError> {
|
||||
pub fn add(&mut self, entry: RoleEntry) -> Result<(), SignerError> {
|
||||
if self.entries.len() >= ROLE_TABLE_MAX_ENTRIES {
|
||||
return Err(NsignerError::Internal("role table full".into()));
|
||||
return Err(SignerError::Internal("role table full".into()));
|
||||
}
|
||||
if self.find_by_name(&entry.name).is_some() {
|
||||
return Err(NsignerError::Internal("duplicate role name".into()));
|
||||
return Err(SignerError::Internal("duplicate role name".into()));
|
||||
}
|
||||
self.entries.push(entry);
|
||||
Ok(())
|
||||
@@ -303,7 +306,7 @@ impl RoleTable {
|
||||
}
|
||||
|
||||
/// Register a nostr-index role if missing.
|
||||
pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), NsignerError> {
|
||||
pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), SignerError> {
|
||||
if self.find_by_nostr_index(nostr_index).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -336,7 +339,7 @@ impl RoleTable {
|
||||
range_hi: i32,
|
||||
default_index: i32,
|
||||
allowed_indices: &[i32],
|
||||
) -> Result<(), NsignerError> {
|
||||
) -> Result<(), SignerError> {
|
||||
let mut entry = RoleEntry::default();
|
||||
entry.name = name.to_string();
|
||||
entry.purpose = purpose;
|
||||
@@ -431,10 +434,10 @@ pub fn role_path_extract_index(concrete: &str, template: &str) -> i32 {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn parse_path_template(
|
||||
token: &str,
|
||||
) -> Result<(String, i32, i32, Vec<i32>), NsignerError> {
|
||||
) -> Result<(String, i32, i32, Vec<i32>), SignerError> {
|
||||
let segs: Vec<&str> = token.split('/').collect();
|
||||
if segs.is_empty() {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
let mut template_out = String::new();
|
||||
@@ -490,18 +493,18 @@ pub fn parse_path_template(
|
||||
let mut set = HashSet::new();
|
||||
for tok in seg_clean.split('+') {
|
||||
if let Some(dash) = tok.find('-') {
|
||||
let lo: i32 = tok[..dash].parse().map_err(|_| NsignerError::InvalidInput)?;
|
||||
let hi: i32 = tok[dash + 1..].parse().map_err(|_| NsignerError::InvalidInput)?;
|
||||
let lo: i32 = tok[..dash].parse().map_err(|_| SignerError::InvalidInput)?;
|
||||
let hi: i32 = tok[dash + 1..].parse().map_err(|_| SignerError::InvalidInput)?;
|
||||
if lo < 0 || hi < 0 || lo > hi {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
for v in lo..=hi {
|
||||
set.insert(v);
|
||||
}
|
||||
} else {
|
||||
let val: i32 = tok.parse().map_err(|_| NsignerError::InvalidInput)?;
|
||||
let val: i32 = tok.parse().map_err(|_| SignerError::InvalidInput)?;
|
||||
if val < 0 {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
set.insert(val);
|
||||
}
|
||||
@@ -527,10 +530,10 @@ pub fn parse_path_template(
|
||||
let dash_pos = seg_clean.find('-').unwrap();
|
||||
let lo: i32 = seg_clean[..dash_pos]
|
||||
.parse()
|
||||
.map_err(|_| NsignerError::InvalidInput)?;
|
||||
.map_err(|_| SignerError::InvalidInput)?;
|
||||
let hi: i32 = seg_clean[dash_pos + 1..]
|
||||
.parse()
|
||||
.map_err(|_| NsignerError::InvalidInput)?;
|
||||
.map_err(|_| SignerError::InvalidInput)?;
|
||||
if lo < 0 || hi < 0 || lo > hi {
|
||||
// Not a valid numeric range — treat as literal
|
||||
template_out.push_str(seg);
|
||||
|
||||
+7
-7
@@ -43,17 +43,17 @@ impl SecureBuf {
|
||||
///
|
||||
/// Returns `MemoryFailed` if allocation or mlock fails (unless
|
||||
/// `allow_unlocked()` was called).
|
||||
pub fn alloc(size: usize) -> Result<Self, crate::NsignerError> {
|
||||
pub fn alloc(size: usize) -> Result<Self, crate::SignerError> {
|
||||
if size == 0 {
|
||||
return Err(crate::NsignerError::InvalidInput);
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
let layout = Layout::from_size_align(size, 1)
|
||||
.map_err(|_| crate::NsignerError::MemoryFailed)?;
|
||||
.map_err(|_| crate::SignerError::MemoryFailed)?;
|
||||
|
||||
let ptr = unsafe { alloc(layout) };
|
||||
if ptr.is_null() {
|
||||
return Err(crate::NsignerError::MemoryFailed);
|
||||
return Err(crate::SignerError::MemoryFailed);
|
||||
}
|
||||
|
||||
// Zero-initialize
|
||||
@@ -64,7 +64,7 @@ impl SecureBuf {
|
||||
if !locked && !is_unlocked_allowed() {
|
||||
// mlock failed and unlocked mode not permitted — fail hard
|
||||
unsafe { dealloc(ptr, layout) };
|
||||
return Err(crate::NsignerError::MemoryFailed);
|
||||
return Err(crate::SignerError::MemoryFailed);
|
||||
}
|
||||
|
||||
Ok(SecureBuf { ptr, size, locked })
|
||||
@@ -111,9 +111,9 @@ impl SecureBuf {
|
||||
/// If `new_size` is 0, returns `InvalidInput`. If allocation of the
|
||||
/// new buffer fails, the original buffer is left intact and an error
|
||||
/// is returned.
|
||||
pub fn resize(&mut self, new_size: usize) -> Result<(), crate::NsignerError> {
|
||||
pub fn resize(&mut self, new_size: usize) -> Result<(), crate::SignerError> {
|
||||
if new_size == 0 {
|
||||
return Err(crate::NsignerError::InvalidInput);
|
||||
return Err(crate::SignerError::InvalidInput);
|
||||
}
|
||||
if new_size == self.size {
|
||||
return Ok(());
|
||||
|
||||
+262
-41
@@ -10,7 +10,7 @@
|
||||
use crate::auth_envelope::AuthNonceCache;
|
||||
use crate::dispatcher::DispatcherContext;
|
||||
use crate::selector::{selector_resolve, SelectorRequest};
|
||||
use crate::NsignerError;
|
||||
use crate::SignerError;
|
||||
use std::net::TcpListener;
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
@@ -62,6 +62,38 @@ impl CallerIdentity {
|
||||
auth_label: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a rich identity string for the activity log — as much as can be
|
||||
/// identified about the caller.
|
||||
///
|
||||
/// - Unix socket: `uid:<n> gid:<n> pid:<n>`
|
||||
/// - Qrexec: `qubes:<vm>`
|
||||
/// - TCP/HTTP: `tcp:<addr>`
|
||||
/// - Auth envelope verified: `pubkey:<hex> label:<label>` appended.
|
||||
pub fn identity_str(&self) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
match self.kind {
|
||||
ListenMode::Unix => {
|
||||
parts.push(format!("uid:{}", self.uid));
|
||||
if self.gid != 0 {
|
||||
parts.push(format!("gid:{}", self.gid));
|
||||
}
|
||||
if self.pid != 0 {
|
||||
parts.push(format!("pid:{}", self.pid));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
parts.push(self.caller_id.clone());
|
||||
}
|
||||
}
|
||||
if self.auth_present {
|
||||
parts.push(format!("pubkey:{}", self.auth_pubkey_hex));
|
||||
if !self.auth_label.is_empty() {
|
||||
parts.push(format!("label:{}", self.auth_label));
|
||||
}
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Server context.
|
||||
@@ -93,14 +125,14 @@ impl ServerContext {
|
||||
}
|
||||
|
||||
/// Start listening. Returns error on bind failure.
|
||||
pub fn start(&mut self) -> Result<(), NsignerError> {
|
||||
pub fn start(&mut self) -> Result<(), SignerError> {
|
||||
match self.listen_mode {
|
||||
ListenMode::Unix => {
|
||||
// Abstract namespace: bind via libc (sun_path[0] = '\0')
|
||||
let listener = bind_abstract_unix(&self.socket_name)?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
self.listener = Some(listener);
|
||||
self.running = true;
|
||||
Ok(())
|
||||
@@ -113,10 +145,10 @@ impl ServerContext {
|
||||
.or_else(|| self.socket_name.strip_prefix("http:"))
|
||||
.unwrap_or(&self.socket_name);
|
||||
let listener = TcpListener::bind(addr)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
self.tcp_listener = Some(listener);
|
||||
self.running = true;
|
||||
Ok(())
|
||||
@@ -137,11 +169,12 @@ impl ServerContext {
|
||||
}
|
||||
|
||||
/// Handle one pending connection (non-blocking).
|
||||
/// Returns Ok(true) if handled, Ok(false) if nothing pending.
|
||||
/// Returns Ok(Some(activity_msg)) if a request was handled,
|
||||
/// Ok(None) if nothing pending.
|
||||
pub fn handle_one(
|
||||
&mut self,
|
||||
dispatcher: &mut DispatcherContext,
|
||||
) -> Result<bool, NsignerError> {
|
||||
) -> Result<Option<String>, SignerError> {
|
||||
if let Some(ref listener) = self.listener {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
@@ -152,36 +185,60 @@ impl ServerContext {
|
||||
|
||||
let mut reader = stream
|
||||
.try_clone()
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
let mut writer = stream;
|
||||
|
||||
// Read framed request. A connection with no data yet
|
||||
// (WouldBlock) or an empty/closed probe is not a handled
|
||||
// request — return Ok(false) so we don't log it as handled.
|
||||
let request = match crate::transport::recv_framed(&mut reader) {
|
||||
// request — return Ok(None) so we don't log it as handled.
|
||||
let mut request = match crate::transport::recv_framed(&mut reader) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
// Identify caller via SO_PEERCRED
|
||||
let caller = identify_unix_caller(&reader);
|
||||
let mut caller = identify_unix_caller(&reader);
|
||||
|
||||
// Bridge preamble: `signer bridge` (qrexec relay) sends a
|
||||
// {"qrexec_source":"<qube>"} frame before the actual
|
||||
// JSON-RPC request. Consume it, record the source qube,
|
||||
// and read the real request that follows.
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&request) {
|
||||
if v.get("qrexec_source").is_some() && v.get("method").is_none() {
|
||||
caller.source_qube = v
|
||||
.get("qrexec_source")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !caller.source_qube.is_empty() {
|
||||
caller.caller_id = format!("qubes:{}", caller.source_qube);
|
||||
}
|
||||
request = match crate::transport::recv_framed(&mut reader) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Process request (role-name-as-password model: no authorization)
|
||||
let response = self.process_request(dispatcher, &request, &caller);
|
||||
let (response, activity) = self.process_request(dispatcher, &request, &caller);
|
||||
|
||||
// Send framed response
|
||||
if let Err(_) = crate::transport::send_framed(&mut writer, &response) {
|
||||
// Client disconnected — ignore
|
||||
}
|
||||
return Ok(true);
|
||||
return Ok(Some(activity));
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false); // Nothing pending
|
||||
return Ok(None); // Nothing pending
|
||||
}
|
||||
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
|
||||
Err(e) => return Err(SignerError::IoFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,45 +254,45 @@ impl ServerContext {
|
||||
|
||||
let mut reader = stream
|
||||
.try_clone()
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
|
||||
let mut writer = stream;
|
||||
|
||||
let request = if self.listen_mode == ListenMode::Http {
|
||||
match crate::http::recv_request(&mut reader) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
} else {
|
||||
match crate::transport::recv_framed(&mut reader) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
};
|
||||
|
||||
// Process request (role-name-as-password model: no authorization)
|
||||
let response = self.process_request(dispatcher, &request, &caller);
|
||||
let (response, activity) = self.process_request(dispatcher, &request, &caller);
|
||||
|
||||
if self.listen_mode == ListenMode::Http {
|
||||
let _ = crate::http::send_response(&mut writer, &response);
|
||||
} else {
|
||||
let _ = crate::transport::send_framed(&mut writer, &response);
|
||||
}
|
||||
return Ok(true);
|
||||
return Ok(Some(activity));
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
|
||||
Err(e) => return Err(SignerError::IoFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Process a request.
|
||||
@@ -251,7 +308,7 @@ impl ServerContext {
|
||||
dispatcher: &mut DispatcherContext,
|
||||
request: &str,
|
||||
caller: &CallerIdentity,
|
||||
) -> String {
|
||||
) -> (String, String) {
|
||||
// ── Auth envelope verification ─────────────────────────────
|
||||
let mut caller = caller.clone();
|
||||
if self.auth_mode != AuthMode::Off {
|
||||
@@ -268,7 +325,9 @@ impl ServerContext {
|
||||
}
|
||||
Err((code, msg)) => {
|
||||
if self.auth_mode == AuthMode::Required {
|
||||
return make_auth_error(&request, code, msg);
|
||||
let response = make_auth_error(&request, code, msg);
|
||||
let activity = format!("{} DENIED:{}", caller.identity_str(), msg);
|
||||
return (response, activity);
|
||||
}
|
||||
// Optional: continue without auth
|
||||
}
|
||||
@@ -280,36 +339,81 @@ impl ServerContext {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// Malformed request — let the dispatcher produce the error
|
||||
return crate::dispatcher::handle_request(dispatcher, request);
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} DENIED:malformed", caller.identity_str());
|
||||
return (response, activity);
|
||||
}
|
||||
};
|
||||
|
||||
// get_info is metadata — no key material
|
||||
if method == crate::enforcement::VERB_GET_INFO {
|
||||
return crate::dispatcher::handle_request(dispatcher, request);
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} {}()", caller.identity_str(), method);
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
// Algorithm-based verbs (bypass role table) — no authorization
|
||||
if crate::enforcement::is_algorithm_verb(&method) {
|
||||
return self.process_algorithm_verb(dispatcher, request, &selector_req);
|
||||
let response = self.process_algorithm_verb(dispatcher, request, &selector_req);
|
||||
// Activity: caller method(algorithm,index) — the algorithm's
|
||||
// standard derivation path identifies the key material.
|
||||
let (alg_name, path) = extract_algorithm_and_path(request);
|
||||
let activity = match (alg_name, path) {
|
||||
(Some(a), Some(p)) => format!("{} {}({},{} {})", caller.identity_str(), method, a, selector_req.index, p),
|
||||
(Some(a), None) => format!("{} {}({})", caller.identity_str(), method, a),
|
||||
_ => format!("{} {}()", caller.identity_str(), method),
|
||||
};
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
// OTP verbs
|
||||
if method == crate::enforcement::VERB_ENCRYPT || method == crate::enforcement::VERB_DECRYPT {
|
||||
return crate::dispatcher::handle_request(dispatcher, request);
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} {}()", caller.identity_str(), method);
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
// ── Resolve role selector (the "password" check) ───────────
|
||||
// If the role name doesn't exist, the request is rejected here.
|
||||
let _role_index = match selector_resolve(&selector_req, dispatcher.role_table) {
|
||||
let role_index = match selector_resolve(&selector_req, dispatcher.role_table) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
return make_selector_error(&request, e);
|
||||
let response = make_selector_error(&request, e);
|
||||
let activity = format!(
|
||||
"{} {}() DENIED:{}",
|
||||
caller.identity_str(),
|
||||
method,
|
||||
e.as_str()
|
||||
);
|
||||
return (response, activity);
|
||||
}
|
||||
};
|
||||
|
||||
// Role entry from the resolved selector — used for the activity
|
||||
// message (role name, curve, and the concrete key path requested).
|
||||
let role_entry = &dispatcher.role_table.entries[role_index];
|
||||
let role_name = role_entry.name.clone();
|
||||
let curve = role_entry.curve_str.clone();
|
||||
// The actual key path the caller requested/accessed — the concrete
|
||||
// role_path from the request when supplied, otherwise the role's
|
||||
// fixed/derived path. (Not the allowed range.)
|
||||
let actual_path = if selector_req.has_role_path {
|
||||
selector_req.role_path.clone()
|
||||
} else {
|
||||
role_entry.display_path()
|
||||
};
|
||||
|
||||
// ── Dispatch ───────────────────────────────────────────────
|
||||
crate::dispatcher::handle_request(dispatcher, request)
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
// Activity format: uid role curve path (timestamp is added by the log).
|
||||
let activity = format!(
|
||||
"{} {} {} {}",
|
||||
caller.identity_str(),
|
||||
role_name,
|
||||
curve,
|
||||
actual_path
|
||||
);
|
||||
(response, activity)
|
||||
}
|
||||
|
||||
/// Process an algorithm-based verb.
|
||||
@@ -331,16 +435,16 @@ impl ServerContext {
|
||||
///
|
||||
/// Rust's safe `UnixListener::bind` rejects paths containing null bytes,
|
||||
/// so abstract sockets (sun_path[0] = '\0') must be bound via libc.
|
||||
fn bind_abstract_unix(name: &str) -> Result<UnixListener, NsignerError> {
|
||||
fn bind_abstract_unix(name: &str) -> Result<UnixListener, SignerError> {
|
||||
use std::os::unix::io::FromRawFd;
|
||||
|
||||
if name.len() >= 107 {
|
||||
return Err(NsignerError::InvalidInput);
|
||||
return Err(SignerError::InvalidInput);
|
||||
}
|
||||
|
||||
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
|
||||
if fd < 0 {
|
||||
return Err(NsignerError::IoFailed("socket() failed".into()));
|
||||
return Err(SignerError::IoFailed("socket() failed".into()));
|
||||
}
|
||||
|
||||
// Build sockaddr_un with abstract namespace (sun_path[0] = '\0')
|
||||
@@ -364,14 +468,14 @@ fn bind_abstract_unix(name: &str) -> Result<UnixListener, NsignerError> {
|
||||
if rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
unsafe { libc::close(fd) };
|
||||
return Err(NsignerError::IoFailed(format!("bind: {}", err)));
|
||||
return Err(SignerError::IoFailed(format!("bind: {}", err)));
|
||||
}
|
||||
|
||||
let rc = unsafe { libc::listen(fd, 16) };
|
||||
if rc != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
unsafe { libc::close(fd) };
|
||||
return Err(NsignerError::IoFailed(format!("listen: {}", err)));
|
||||
return Err(SignerError::IoFailed(format!("listen: {}", err)));
|
||||
}
|
||||
|
||||
// Wrap the raw fd in a UnixListener
|
||||
@@ -458,6 +562,40 @@ fn extract_method_and_selector(request: &str) -> Option<(String, SelectorRequest
|
||||
Some((method, sel))
|
||||
}
|
||||
|
||||
/// Extract the algorithm name and its standard derivation path from an
|
||||
/// algorithm-verb request's options (for the activity log).
|
||||
///
|
||||
/// Returns `(algorithm_name, Some(path))` when the request carries a valid
|
||||
/// algorithm; `(None, None)` otherwise.
|
||||
fn extract_algorithm_and_path(request: &str) -> (Option<String>, Option<String>) {
|
||||
let root: serde_json::Value = match serde_json::from_str(request) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (None, None),
|
||||
};
|
||||
let alg_str = root
|
||||
.get("params")
|
||||
.and_then(|p| p.as_array())
|
||||
.and_then(|p| p.last())
|
||||
.and_then(|o| o.get("algorithm"))
|
||||
.and_then(|v| v.as_str());
|
||||
let Some(alg_str) = alg_str else {
|
||||
return (None, None);
|
||||
};
|
||||
let alg = crate::pq_crypto::CryptoAlg::from_str(alg_str);
|
||||
if alg == crate::pq_crypto::CryptoAlg::Unknown {
|
||||
return (Some(alg_str.to_string()), None);
|
||||
}
|
||||
let index = root
|
||||
.get("params")
|
||||
.and_then(|p| p.as_array())
|
||||
.and_then(|p| p.last())
|
||||
.and_then(|o| o.get("index"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0) as i32;
|
||||
let path = crate::alg_cache::standard_path(alg, index).ok();
|
||||
(Some(alg.as_str().to_string()), path)
|
||||
}
|
||||
|
||||
/// Build an auth error response.
|
||||
fn make_auth_error(request: &str, code: i32, message: &str) -> String {
|
||||
let id = extract_id(request);
|
||||
@@ -500,3 +638,86 @@ fn extract_id(request: &str) -> String {
|
||||
})
|
||||
.unwrap_or_else(|| "null".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build the activity line for an algorithm-verb request, exactly as
|
||||
/// `process_request` does.
|
||||
fn activity_for(request: &str) -> String {
|
||||
let (method, selector_req) =
|
||||
extract_method_and_selector(request).expect("valid request");
|
||||
assert!(crate::enforcement::is_algorithm_verb(&method));
|
||||
let (alg_name, path) = extract_algorithm_and_path(request);
|
||||
let caller_id = "uid:1000";
|
||||
match (alg_name, path) {
|
||||
(Some(a), Some(p)) => {
|
||||
format!("{} {}({},{} {})", caller_id, method, a, selector_req.index, p)
|
||||
}
|
||||
(Some(a), None) => format!("{} {}({})", caller_id, method, a),
|
||||
_ => format!("{} {}()", caller_id, method),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_all_algorithms() {
|
||||
// Every algorithm must produce a non-blank activity line with its
|
||||
// curve name and standard derivation path.
|
||||
let cases = [
|
||||
("secp256k1", "m/44'/1237'/0'/0/0"),
|
||||
("ed25519", "m/44'/102001'/0'/0'/0'"),
|
||||
("x25519", "m/44'/102002'/0'/0'/0'"),
|
||||
("ml-dsa-65", "m/44'/102003'/0'/0'/0'"),
|
||||
("slh-dsa-128s", "m/44'/102004'/0'/0'/0'"),
|
||||
("ml-kem-768", "m/44'/102005'/0'/0'/0'"),
|
||||
];
|
||||
for (alg, expected_path) in cases {
|
||||
let req = format!(
|
||||
r#"{{"id":"1","method":"get_public_key","params":[{{"algorithm":"{}","index":0}}]}}"#,
|
||||
alg
|
||||
);
|
||||
let activity = activity_for(&req);
|
||||
assert!(
|
||||
activity.contains(alg),
|
||||
"activity '{}' must contain algorithm '{}'",
|
||||
activity,
|
||||
alg
|
||||
);
|
||||
assert!(
|
||||
activity.contains(expected_path),
|
||||
"activity '{}' must contain path '{}'",
|
||||
activity,
|
||||
expected_path
|
||||
);
|
||||
assert!(
|
||||
!activity.contains("- -"),
|
||||
"activity '{}' must not contain the blank placeholder",
|
||||
activity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_index_substituted() {
|
||||
let req = r#"{"id":"1","method":"sign","params":["00ff",{"algorithm":"ml-dsa-65","index":7}]}"#;
|
||||
let activity = activity_for(req);
|
||||
assert!(activity.contains("m/44'/102003'/7'/0'/0'"), "activity: {}", activity);
|
||||
assert!(activity.contains("sign(ml-dsa-65,7"), "activity: {}", activity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_unknown_algorithm() {
|
||||
let req = r#"{"id":"1","method":"sign","params":["00ff",{"algorithm":"bogus","index":0}]}"#;
|
||||
let activity = activity_for(req);
|
||||
// Unknown algorithm: name echoed, no path (dispatcher will reject).
|
||||
assert!(activity.contains("sign(bogus)"), "activity: {}", activity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_missing_algorithm() {
|
||||
let req = r#"{"id":"1","method":"sign","params":["00ff",{}]}"#;
|
||||
let activity = activity_for(req);
|
||||
assert!(activity.contains("sign()"), "activity: {}", activity);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-22
@@ -1,23 +1,23 @@
|
||||
//! Socket naming — sequential abstract socket name generation.
|
||||
//!
|
||||
//! Generates names in the format `nsigner01`, `nsigner02`, … incrementing
|
||||
//! Generates names in the format `signer01`, `signer02`, … incrementing
|
||||
//! until an unused name is found (by checking /proc/net/unix).
|
||||
//!
|
||||
//! The `nsigner` prefix is required for compatibility with the C
|
||||
//! `nsigner_client` / `nsigner_transport_list_unix`, which scans
|
||||
//! /proc/net/unix for the literal prefix `@nsigner`.
|
||||
//! The `signer` prefix is required for compatibility with the C
|
||||
//! `signer_client` / `signer_transport_list_unix`, which scans
|
||||
//! /proc/net/unix for the literal prefix `@signer`.
|
||||
|
||||
/// Prefix for generated socket names.
|
||||
pub const SOCKET_NAME_PREFIX: &str = "nsigner";
|
||||
pub const SOCKET_NAME_PREFIX: &str = "signer";
|
||||
|
||||
/// Generate a socket name: `nsigner01`, `nsigner02`, …
|
||||
/// Generate a socket name: `signer01`, `signer02`, …
|
||||
///
|
||||
/// Scans /proc/net/unix for already-running nsigner sockets and picks the
|
||||
/// Scans /proc/net/unix for already-running signer sockets and picks the
|
||||
/// lowest unused number (starting at 1, zero-padded to 2 digits).
|
||||
pub fn socket_name_random() -> Result<String, crate::NsignerError> {
|
||||
pub fn socket_name_random() -> Result<String, crate::SignerError> {
|
||||
let in_use = list_sockets();
|
||||
|
||||
// Try nsigner01, nsigner02, … up to nsigner99
|
||||
// Try signer01, signer02, … up to signer99
|
||||
for n in 1..=99u32 {
|
||||
let candidate = format!("{}{:02}", SOCKET_NAME_PREFIX, n);
|
||||
if !in_use.contains(&candidate) {
|
||||
@@ -25,7 +25,7 @@ pub fn socket_name_random() -> Result<String, crate::NsignerError> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: nsigner100, nsigner101, … (no zero-padding beyond 99)
|
||||
// Fallback: signer100, signer101, … (no zero-padding beyond 99)
|
||||
for n in 100..=9999u32 {
|
||||
let candidate = format!("{}{}", SOCKET_NAME_PREFIX, n);
|
||||
if !in_use.contains(&candidate) {
|
||||
@@ -33,22 +33,22 @@ pub fn socket_name_random() -> Result<String, crate::NsignerError> {
|
||||
}
|
||||
}
|
||||
|
||||
Err(crate::NsignerError::Internal(
|
||||
"no available socket name (nsigner01..nsigner9999 all in use)".into(),
|
||||
Err(crate::SignerError::Internal(
|
||||
"no available socket name (signer01..signer9999 all in use)".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// List running nsigner abstract sockets by reading /proc/net/unix.
|
||||
/// List running signer abstract sockets by reading /proc/net/unix.
|
||||
///
|
||||
/// Matches the C `nsigner_transport_list_unix` scan: looks for the literal
|
||||
/// prefix `@nsigner` in the path column.
|
||||
/// Matches the C `signer_transport_list_unix` scan: looks for the literal
|
||||
/// prefix `@signer` in the path column.
|
||||
pub fn list_sockets() -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/net/unix") {
|
||||
for line in content.lines() {
|
||||
// Look for @nsigner prefix in the path column (matches C client scan)
|
||||
if let Some(pos) = line.find("@nsigner") {
|
||||
// Look for @signer prefix in the path column (matches C client scan)
|
||||
if let Some(pos) = line.find("@signer") {
|
||||
let rest = &line[pos + 1..]; // skip @
|
||||
// Extract the name (up to whitespace or end of line)
|
||||
let name: String = rest
|
||||
@@ -65,14 +65,14 @@ pub fn list_sockets() -> Vec<String> {
|
||||
found
|
||||
}
|
||||
|
||||
/// Discover a single running nsigner socket.
|
||||
/// Discover a single running signer socket.
|
||||
/// Returns Ok(name) if exactly one is found, Err if zero or multiple.
|
||||
pub fn discover_single_socket() -> Result<String, crate::NsignerError> {
|
||||
pub fn discover_single_socket() -> Result<String, crate::SignerError> {
|
||||
let sockets = list_sockets();
|
||||
if sockets.len() == 1 {
|
||||
Ok(sockets[0].clone())
|
||||
} else {
|
||||
Err(crate::NsignerError::NotFound)
|
||||
Err(crate::SignerError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_socket_name_random() {
|
||||
let name = socket_name_random().unwrap();
|
||||
assert!(name.starts_with("nsigner"));
|
||||
// Should be nsigner01..nsigner99 (8 chars) or nsigner100+ (9+ chars)
|
||||
assert!(name.starts_with("signer"));
|
||||
// Should be signer01..signer99 (8 chars) or signer100+ (9+ chars)
|
||||
assert!(name.len() >= 8);
|
||||
}
|
||||
}
|
||||
|
||||
+1197
-727
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -4,7 +4,7 @@
|
||||
//! caller sends a request with a role name → selector resolution → dispatch.
|
||||
//! No policy table, no approval prompt. Knowing a valid role name is sufficient.
|
||||
|
||||
use nsigner::{
|
||||
use signer::{
|
||||
alg_cache::AlgorithmKeyCache,
|
||||
dispatcher::DispatcherContext,
|
||||
key_store::KeyStore,
|
||||
@@ -71,8 +71,8 @@ fn spawn_server_loop(
|
||||
alg_key_cache: &mut alg_cache,
|
||||
};
|
||||
match server.handle_one(&mut dispatcher) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
Ok(Some(_activity)) => {}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -85,15 +85,15 @@ fn spawn_server_loop(
|
||||
|
||||
/// Send a framed request to a Unix socket and return the response.
|
||||
fn send_request(socket_name: &str, request: &str) -> String {
|
||||
let mut stream = nsigner::transport::connect_abstract_unix(socket_name).unwrap();
|
||||
nsigner::transport::send_framed(&mut stream, request).unwrap();
|
||||
nsigner::transport::recv_framed(&mut stream).unwrap()
|
||||
let mut stream = signer::transport::connect_abstract_unix(socket_name).unwrap();
|
||||
signer::transport::send_framed(&mut stream, request).unwrap();
|
||||
signer::transport::recv_framed(&mut stream).unwrap()
|
||||
}
|
||||
|
||||
/// Wait for the server socket to be ready.
|
||||
fn wait_for_server(socket_name: &str, attempts: u32) {
|
||||
for _ in 0..attempts {
|
||||
if nsigner::transport::connect_abstract_unix(socket_name).is_ok() {
|
||||
if signer::transport::connect_abstract_unix(socket_name).is_ok() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
@@ -103,7 +103,7 @@ fn wait_for_server(socket_name: &str, attempts: u32) {
|
||||
|
||||
#[test]
|
||||
fn test_get_info_works() {
|
||||
let socket_name = format!("nsigner_test_info_{}", std::process::id());
|
||||
let socket_name = format!("signer_test_info_{}", std::process::id());
|
||||
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
||||
|
||||
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
||||
@@ -113,14 +113,14 @@ fn test_get_info_works() {
|
||||
let resp = send_request(&socket_name, r#"{"id":"1","method":"get_info","params":[]}"#);
|
||||
assert!(resp.contains("\"result\""), "get_info failed: {}", resp);
|
||||
|
||||
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
||||
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
handle.join().ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_role_as_password_allows_with_valid_role() {
|
||||
let socket_name = format!("nsigner_test_allow_{}", std::process::id());
|
||||
let socket_name = format!("signer_test_allow_{}", std::process::id());
|
||||
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
||||
|
||||
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
||||
@@ -136,14 +136,14 @@ fn test_role_as_password_allows_with_valid_role() {
|
||||
assert!(resp.contains("e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f"),
|
||||
"expected pubkey in result, got: {}", resp);
|
||||
|
||||
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
||||
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
handle.join().ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_role_returns_selector_error() {
|
||||
let socket_name = format!("nsigner_test_unknown_{}", std::process::id());
|
||||
let socket_name = format!("signer_test_unknown_{}", std::process::id());
|
||||
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
||||
|
||||
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
||||
@@ -156,14 +156,14 @@ fn test_unknown_role_returns_selector_error() {
|
||||
);
|
||||
assert!(resp.contains("unknown_role"), "expected unknown_role, got: {}", resp);
|
||||
|
||||
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
||||
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
handle.join().ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ed25519_sign_allowed_no_authorization() {
|
||||
let socket_name = format!("nsigner_test_alg_{}", std::process::id());
|
||||
let socket_name = format!("signer_test_alg_{}", std::process::id());
|
||||
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
||||
|
||||
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
||||
@@ -179,14 +179,14 @@ fn test_ed25519_sign_allowed_no_authorization() {
|
||||
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
|
||||
assert!(resp.contains("signature"), "expected signature in result: {}", resp);
|
||||
|
||||
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
||||
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
handle.join().ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repeated_requests_all_allowed() {
|
||||
let socket_name = format!("nsigner_test_repeat_{}", std::process::id());
|
||||
let socket_name = format!("signer_test_repeat_{}", std::process::id());
|
||||
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
||||
|
||||
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
||||
@@ -202,7 +202,7 @@ fn test_repeated_requests_all_allowed() {
|
||||
assert!(resp.contains("\"result\""), "request {} failed: {}", i, resp);
|
||||
}
|
||||
|
||||
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
||||
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
handle.join().ok();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Cross-implementation PQ keygen conformance.
|
||||
//!
|
||||
//! Validates the v2 seeded derivation against the pinned vectors from
|
||||
//! nostr_quantum_preparation (`test/vectors/seed-to-pubkeys.v2.json`):
|
||||
//! the same fixed mnemonic must produce the same PQ public keys in the
|
||||
//! web app (noble) and here (RustCrypto crates).
|
||||
//!
|
||||
//! The test is skipped (passes with a note) when the vector file does not
|
||||
//! exist yet — coordinate generation with the web app project
|
||||
//! (`test/vectors/generate-vectors.mjs`).
|
||||
|
||||
use signer::pq_crypto;
|
||||
|
||||
const VECTOR_PATH: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../nostr_quantum_preparation/test/vectors/seed-to-pubkeys.v2.json"
|
||||
);
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Vector {
|
||||
mnemonic: String,
|
||||
#[serde(rename = "derivedPublicKeys")]
|
||||
derived_public_keys: DerivedPublicKeys,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DerivedPublicKeys {
|
||||
#[serde(rename = "ml-dsa-65")]
|
||||
ml_dsa_65: KeyEntry,
|
||||
#[serde(rename = "slh-dsa-128s")]
|
||||
slh_dsa_128s: KeyEntry,
|
||||
#[serde(rename = "ml-kem-768")]
|
||||
ml_kem_768: KeyEntry,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct KeyEntry {
|
||||
#[serde(rename = "derivationPath")]
|
||||
derivation_path: String,
|
||||
#[serde(rename = "publicKeyHex")]
|
||||
public_key_hex: String,
|
||||
}
|
||||
|
||||
/// Extract the base path (strip the trailing leaf index and any sibling
|
||||
/// notation like "+ m/44'/102004'/0'/0'/1'").
|
||||
fn base_path(full: &str) -> String {
|
||||
full.split(" + ").next().unwrap_or(full).to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_pubkeys_match_web_app_v2_vectors() {
|
||||
let data = match std::fs::read_to_string(VECTOR_PATH) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
eprintln!("SKIP: {VECTOR_PATH} not found — generate it in nostr_quantum_preparation");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let vector: Vector = serde_json::from_str(&data)
|
||||
.expect("valid v2 vector JSON");
|
||||
|
||||
// ML-DSA-65: 32-byte seed from one child.
|
||||
let seed = pq_crypto::derive_pq_seed_from_path(
|
||||
&vector.mnemonic,
|
||||
&base_path(&vector.derived_public_keys.ml_dsa_65.derivation_path),
|
||||
32,
|
||||
)
|
||||
.unwrap();
|
||||
let seed_arr: [u8; 32] = seed.as_slice().try_into().unwrap();
|
||||
let (_, pub_key) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(&pub_key),
|
||||
vector.derived_public_keys.ml_dsa_65.public_key_hex,
|
||||
"ml-dsa-65 pubkey must match the web app vector"
|
||||
);
|
||||
|
||||
// SLH-DSA-128s: 48-byte seed from two children (first 48 of 64).
|
||||
let seed = pq_crypto::derive_pq_seed_from_path(
|
||||
&vector.mnemonic,
|
||||
&base_path(&vector.derived_public_keys.slh_dsa_128s.derivation_path),
|
||||
48,
|
||||
)
|
||||
.unwrap();
|
||||
let (_, pub_key) = pq_crypto::slh_dsa_128s_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(&pub_key),
|
||||
vector.derived_public_keys.slh_dsa_128s.public_key_hex,
|
||||
"slh-dsa-128s pubkey must match the web app vector"
|
||||
);
|
||||
|
||||
// ML-KEM-768: 64-byte seed from two children.
|
||||
let seed = pq_crypto::derive_pq_seed_from_path(
|
||||
&vector.mnemonic,
|
||||
&base_path(&vector.derived_public_keys.ml_kem_768.derivation_path),
|
||||
64,
|
||||
)
|
||||
.unwrap();
|
||||
let (_, pub_key) = pq_crypto::ml_kem_768_keygen_from_seed(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(&pub_key),
|
||||
vector.derived_public_keys.ml_kem_768.public_key_hex,
|
||||
"ml-kem-768 pubkey must match the web app vector"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user