Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7e178c0f | ||
|
|
892abe0ccf | ||
|
|
782716c53b | ||
|
|
b2f5d06570 | ||
|
|
03a977b774 | ||
|
|
9c762edbd2 | ||
|
|
b5b58ecb87 | ||
|
|
a428d5e77b | ||
|
|
c685806aeb | ||
|
|
a3e1d70fe8 | ||
|
|
567985434d | ||
|
|
a472e9785b | ||
|
|
374b71cdf4 | ||
|
|
b3cc4704c3 | ||
|
|
5d3cc4e96d | ||
|
|
2f7f29ed99 | ||
|
|
7bc367e695 | ||
|
|
52ac2357f3 | ||
|
|
2cd64032ee |
@@ -0,0 +1,5 @@
|
||||
signer/target
|
||||
signer/dist
|
||||
signer/.git
|
||||
nostr_core_lib_rust/target
|
||||
nostr_core_lib_rust/.git
|
||||
@@ -1,3 +1,4 @@
|
||||
/target/
|
||||
/dist/
|
||||
*.log
|
||||
*.tar.gz
|
||||
|
||||
Generated
+33
-33
@@ -1446,7 +1446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-core"
|
||||
version = "0.0.2"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -1468,7 +1468,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-nips"
|
||||
version = "0.0.2"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"block-modes",
|
||||
@@ -1488,37 +1488,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nsigner"
|
||||
version = "0.0.3"
|
||||
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.22"
|
||||
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.4"
|
||||
name = "signer"
|
||||
version = "0.0.23"
|
||||
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]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM rust:1.88.0-alpine3.20
|
||||
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
binutils \
|
||||
file \
|
||||
musl-dev \
|
||||
openssl-dev \
|
||||
openssl-libs-static \
|
||||
perl \
|
||||
pkgconf \
|
||||
linux-headers \
|
||||
&& rustup target add x86_64-unknown-linux-musl
|
||||
|
||||
ENV CARGO_NET_OFFLINE=false \
|
||||
RUSTFLAGS=-Ctarget-cpu=x86-64
|
||||
|
||||
WORKDIR /workspace/signer
|
||||
|
||||
COPY nostr_core_lib_rust /workspace/nostr_core_lib_rust
|
||||
COPY signer /workspace/signer
|
||||
|
||||
RUN cargo build --release --target x86_64-unknown-linux-musl
|
||||
RUN file target/x86_64-unknown-linux-musl/release/signer \
|
||||
&& file target/x86_64-unknown-linux-musl/release/signer-client \
|
||||
&& ! readelf -l target/x86_64-unknown-linux-musl/release/signer | grep -q 'INTERP'
|
||||
|
||||
CMD ["sh", "-c", "mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/signer /out/signer && cp target/x86_64-unknown-linux-musl/release/signer-client /out/signer_client"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# nsigner
|
||||
# signer
|
||||
|
||||
`nsigner` is a Rust port of the [n_signer](https://github.com/lt/n_signer) project — a single statically-linked program that holds signing key material in locked memory and signs on request.
|
||||
`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.
|
||||
|
||||
@@ -13,7 +13,7 @@ This is a **program, not a daemon**:
|
||||
|
||||
## 1. What it is
|
||||
|
||||
`nsigner` is one binary that combines:
|
||||
`signer` is one binary that combines:
|
||||
|
||||
- mnemonic handling (BIP-39)
|
||||
- role selection and derivation (BIP-32 / SLIP-0010)
|
||||
@@ -30,7 +30,7 @@ You run it when you need signing. You stop it when you are done. Closing the ter
|
||||
|
||||
### 2.1 Zero filesystem footprint
|
||||
|
||||
At runtime, `nsigner` 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.
|
||||
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
|
||||
|
||||
@@ -42,7 +42,7 @@ The release build (`opt-level = "z"`, `lto = true`, `panic = "abort"`, `strip =
|
||||
|
||||
### 2.4 Always-attended operation
|
||||
|
||||
`nsigner` 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.
|
||||
`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`
|
||||
|
||||
@@ -52,7 +52,7 @@ Sensitive buffers (mnemonic, master seed, per-role private keys) live in `mlock`
|
||||
|
||||
### 3.1 Startup phase (TUI input mode)
|
||||
|
||||
When started interactively, `nsigner` immediately enters a startup popup:
|
||||
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.
|
||||
@@ -112,7 +112,7 @@ Press `A` from the main screen to open the add-role popup. The flow is:
|
||||
|
||||
## 4. API
|
||||
|
||||
`nsigner` 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.
|
||||
`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
|
||||
|
||||
@@ -207,21 +207,21 @@ All keys derive deterministically from the loaded BIP-39 mnemonic. The caller se
|
||||
|
||||
#### 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'` → DRBG | 4032 / 1952 |
|
||||
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 |
|
||||
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 |
|
||||
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
|
||||
| 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 a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces the RNG during keygen. Same mnemonic, same index, same key pair every time. 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). 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.
|
||||
- **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
|
||||
@@ -516,9 +516,9 @@ Clients request keys by supplying both `role` and the full concrete `role_path`:
|
||||
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
|
||||
nsigner --register-role main:secp256k1:m/44'/1237'/0'/0/0
|
||||
nsigner --register-role nostr_range::m/44'/1237'/*'/0/0
|
||||
nsigner --register-role ssh::m/44'/102001'/0'/0'/0'
|
||||
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.
|
||||
@@ -539,7 +539,7 @@ The API is transport-independent. The same JSON request works over every transpo
|
||||
|
||||
Start the signer:
|
||||
```bash
|
||||
nsigner --listen http:127.0.0.1:11111 --mnemonic-stdin
|
||||
signer --listen http:127.0.0.1:11111 --mnemonic-stdin
|
||||
```
|
||||
|
||||
Get a public key:
|
||||
@@ -564,21 +564,21 @@ curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
|
||||
```bash
|
||||
# get_public_key
|
||||
nsigner --socket-name nsigner01 client \
|
||||
signer --socket-name signer01 client \
|
||||
'{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
|
||||
|
||||
# Sign a Nostr event
|
||||
nsigner --socket-name nsigner01 client \
|
||||
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
|
||||
nsigner --socket-name nsigner01 client \
|
||||
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 `nsigner` process binds to a unique abstract name of the form `@nsigner_<word1>_<word2>`, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. `@nsigner_hairy_dog`). This lets multiple signers coexist on one host.
|
||||
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.
|
||||
|
||||
@@ -587,12 +587,12 @@ Naming rules:
|
||||
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name.
|
||||
|
||||
Discovery:
|
||||
- `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`.
|
||||
- `nsigner --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
|
||||
- `nsigner --listen qrexec` is the same stdio framing, but caller identity comes from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
|
||||
- `nsigner --listen tcp:[::]:11111` enables FIPS/TCP listening (framed JSON, not HTTP).
|
||||
- `nsigner --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.
|
||||
- `nsigner 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.NsignerRpc` service entrypoint.
|
||||
- `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
|
||||
@@ -612,7 +612,7 @@ Primary deployment is a local, foreground terminal program with abstract namespa
|
||||
|
||||
### 6.2 Qubes OS qube
|
||||
|
||||
Qubes deployment runs `nsigner` 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).
|
||||
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:
|
||||
|
||||
@@ -620,18 +620,18 @@ Three transport paths are supported:
|
||||
|
||||
**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 `nsigner bridge` relay (the `qubes.NsignerRpc` qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is `qubes:<source-vm>`.
|
||||
**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
|
||||
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
signer --listen unix --socket-name signer --bridge-source-trusted
|
||||
```
|
||||
|
||||
**From a caller qube** (via the qrexec service):
|
||||
```bash
|
||||
nsigner bridge --to nsigner
|
||||
signer bridge --to signer
|
||||
```
|
||||
|
||||
## 7. Usage
|
||||
@@ -639,51 +639,51 @@ nsigner bridge --to nsigner
|
||||
### 7.1 Run the program
|
||||
|
||||
```bash
|
||||
nsigner
|
||||
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
|
||||
nsigner --name my_test_signer
|
||||
signer --name my_test_signer
|
||||
```
|
||||
|
||||
Other transport modes:
|
||||
```bash
|
||||
nsigner --listen qrexec # Qubes qrexec (single framed request over stdin/stdout)
|
||||
nsigner --listen stdio # Generic stdio (single framed request over stdin/stdout)
|
||||
nsigner --listen tcp:[::]:11111 # FIPS/TCP (framed JSON, no TUI)
|
||||
nsigner --listen http:127.0.0.1:11111 # HTTP (curl-friendly, no TUI)
|
||||
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
|
||||
nsigner --listen http:127.0.0.1:11111 --otp-pad-dir /media/user/Music/pads --otp-pad 333e9902db839d9d --mnemonic-stdin
|
||||
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
|
||||
nsigner bridge --to nsigner
|
||||
signer bridge --to signer
|
||||
```
|
||||
|
||||
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
|
||||
```bash
|
||||
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
signer --listen unix --socket-name signer --bridge-source-trusted
|
||||
```
|
||||
|
||||
### 7.2 Send a request (client mode)
|
||||
|
||||
The `nsigner client` subcommand sends a hand-built JSON-RPC object over the socket:
|
||||
The `signer client` subcommand sends a hand-built JSON-RPC object over the socket:
|
||||
|
||||
```bash
|
||||
nsigner --socket-name nsigner01 client \
|
||||
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":[]}' | nsigner client -
|
||||
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.
|
||||
@@ -691,28 +691,28 @@ If only one signer is running you can omit the `--socket-name` override and the
|
||||
### 7.3 List running signers
|
||||
|
||||
```bash
|
||||
nsigner list
|
||||
signer list
|
||||
```
|
||||
|
||||
Prints the names of any currently running `nsigner` instances, e.g.:
|
||||
Prints the names of any currently running `signer` instances, e.g.:
|
||||
```text
|
||||
nsigner_hairy_dog
|
||||
nsigner_brave_canyon
|
||||
signer_hairy_dog
|
||||
signer_brave_canyon
|
||||
```
|
||||
|
||||
### 7.4 Example session
|
||||
|
||||
Terminal A:
|
||||
```text
|
||||
$ nsigner
|
||||
nsigner v0.0.2
|
||||
$ signer
|
||||
signer v0.0.2
|
||||
[seed entry popup → enter mnemonic]
|
||||
[main screen shows: signer name nsigner_hairy_dog, Unix address active]
|
||||
[main screen shows: signer name signer_hairy_dog, Unix address active]
|
||||
```
|
||||
|
||||
Terminal B:
|
||||
```text
|
||||
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
|
||||
$ 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>"}
|
||||
```
|
||||
|
||||
@@ -728,12 +728,43 @@ git submodule update --init ratatui
|
||||
|
||||
### 8.2 Local dev build
|
||||
|
||||
Native builds are intended for local development and use the host Rust toolchain and libc:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
./target/debug/nsigner --version
|
||||
./target/debug/signer --version
|
||||
```
|
||||
|
||||
### 8.3 Release build
|
||||
### 8.3 Portable static musl release build
|
||||
|
||||
Portable release binaries are built in Docker for x86_64 Linux using the `x86_64-unknown-linux-musl` target. This avoids a runtime dependency on the target system's glibc version. The build expects the sibling `nostr_core_lib_rust` checkout described above.
|
||||
|
||||
```bash
|
||||
./build_musl.sh
|
||||
```
|
||||
|
||||
The artifacts are written directly to `dist/`:
|
||||
|
||||
- `signer`
|
||||
- `signer_client`
|
||||
|
||||
Verify the output:
|
||||
|
||||
```bash
|
||||
file dist/signer
|
||||
ldd dist/signer
|
||||
./dist/signer --version
|
||||
```
|
||||
|
||||
`ldd` should report that the executable is not dynamically linked. Static musl removes the glibc runtime dependency, but the binaries still require a compatible Linux kernel, x86_64 CPU, terminal environment, Qubes/qrexec environment where applicable, and sufficient `RLIMIT_MEMLOCK` for locked secret memory.
|
||||
|
||||
To deploy the portable binaries locally:
|
||||
|
||||
```bash
|
||||
./deploy_local.sh --musl
|
||||
```
|
||||
|
||||
### 8.4 Native release build
|
||||
|
||||
The release profile is tuned for a small, optimized, stripped binary:
|
||||
|
||||
@@ -748,15 +779,17 @@ strip = true
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
./target/release/nsigner --version
|
||||
./target/release/signer --version
|
||||
```
|
||||
|
||||
### 8.4 Tests
|
||||
### 8.5 Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
The portable build runs the release compilation and static-linkage checks inside Docker. Runtime smoke tests should be performed on the intended Qubes/Linux deployment environment, including Unix sockets, TCP/HTTP, qrexec, TUI startup, signing, and `mlock` behavior.
|
||||
|
||||
## 9. Project layout
|
||||
|
||||
| Path | Purpose |
|
||||
@@ -769,10 +802,12 @@ cargo test
|
||||
| [`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 |
|
||||
| [`Dockerfile.musl`](Dockerfile.musl:1) | Reproducible Docker environment for static musl releases |
|
||||
| [`build_musl.sh`](build_musl.sh:1) | Builds and validates portable x86_64 musl binaries |
|
||||
| [`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 for PQ keygen |
|
||||
| [`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` |
|
||||
@@ -781,7 +816,7 @@ cargo test
|
||||
| [`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) | `NsignerError` enum |
|
||||
| [`src/error.rs`](src/error.rs:1) | `signerError` enum |
|
||||
| [`ratatui/`](ratatui:1) | Vendored ratatui submodule (TUI framework) |
|
||||
| [`plans/`](plans:1) | Design and migration plans |
|
||||
|
||||
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build portable static x86_64 Linux binaries in a pinned musl container.
|
||||
# The Docker build context is the parent directory because Cargo.toml uses
|
||||
# ../nostr_core_lib_rust as a path dependency. Set SIGNER_NOSTR_CORE_DIR
|
||||
# when the sibling checkout is stored elsewhere.
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_NAME="$(basename "${PROJECT_DIR}")"
|
||||
PARENT_DIR="$(dirname "${PROJECT_DIR}")"
|
||||
IMAGE_NAME="${SIGNER_MUSL_IMAGE:-signer-musl-build:rust-1.88.0-alpine3.20}"
|
||||
OUTPUT_DIR="${SIGNER_MUSL_OUTPUT_DIR:-${PROJECT_DIR}/dist}"
|
||||
DEPENDENCY_DIR="${SIGNER_NOSTR_CORE_DIR:-${PARENT_DIR}/nostr_core_lib_rust}"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "error: Docker is required for the musl build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${PROJECT_DIR}/Cargo.toml" ]]; then
|
||||
echo "error: Cargo.toml not found in ${PROJECT_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${DEPENDENCY_DIR}/core/Cargo.toml" || ! -f "${DEPENDENCY_DIR}/nips/Cargo.toml" ]]; then
|
||||
echo "error: invalid nostr_core_lib_rust checkout: ${DEPENDENCY_DIR}" >&2
|
||||
echo "expected core/Cargo.toml and nips/Cargo.toml" >&2
|
||||
echo "hint: clone the complete sibling checkout or set SIGNER_NOSTR_CORE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
rm -f "${OUTPUT_DIR}/signer" \
|
||||
"${OUTPUT_DIR}/signer_client"
|
||||
|
||||
CONTAINER_NAME="signer-musl-build-$$-${RANDOM}"
|
||||
cleanup() {
|
||||
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
printf '[INFO] Building musl image %s\n' "${IMAGE_NAME}"
|
||||
if [[ "${DEPENDENCY_DIR}" != "${PARENT_DIR}/nostr_core_lib_rust" ]]; then
|
||||
echo "error: SIGNER_NOSTR_CORE_DIR must point to the sibling ../nostr_core_lib_rust directory when using Docker" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker build \
|
||||
--file "${PROJECT_DIR}/Dockerfile.musl" \
|
||||
--tag "${IMAGE_NAME}" \
|
||||
"${PARENT_DIR}"
|
||||
|
||||
printf '[INFO] Extracting portable binaries\n'
|
||||
docker create --name "${CONTAINER_NAME}" "${IMAGE_NAME}" >/dev/null
|
||||
docker cp "${CONTAINER_NAME}:/workspace/signer/target/x86_64-unknown-linux-musl/release/signer" \
|
||||
"${OUTPUT_DIR}/signer"
|
||||
docker cp "${CONTAINER_NAME}:/workspace/signer/target/x86_64-unknown-linux-musl/release/signer-client" \
|
||||
"${OUTPUT_DIR}/signer_client"
|
||||
chmod 0755 "${OUTPUT_DIR}/signer" \
|
||||
"${OUTPUT_DIR}/signer_client"
|
||||
|
||||
for binary in \
|
||||
"${OUTPUT_DIR}/signer" \
|
||||
"${OUTPUT_DIR}/signer_client"; do
|
||||
file "${binary}"
|
||||
if ! file "${binary}" | grep -Eq 'ELF 64-bit LSB (pie )?executable, x86-64'; then
|
||||
echo "error: ${binary} is not an x86_64 ELF executable" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ldd "${binary}" 2>&1 | grep -Eqi 'not a dynamic executable|statically linked'; then
|
||||
:
|
||||
else
|
||||
echo "error: ${binary} appears to be dynamically linked" >&2
|
||||
ldd "${binary}" 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
"${binary}" --version
|
||||
done
|
||||
|
||||
printf '[SUCCESS] Portable binaries written to %s\n' "${OUTPUT_DIR}"
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# signer (Rust) — Local Deploy Script
|
||||
#
|
||||
# Builds binaries and installs them to /usr/local/bin/.
|
||||
#
|
||||
# USAGE:
|
||||
# ./deploy_local.sh # native release build + install
|
||||
# ./deploy_local.sh --musl # portable static musl release + install
|
||||
# ./deploy_local.sh --debug # native debug build + 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"
|
||||
BUILD_KIND="native"
|
||||
|
||||
show_usage() {
|
||||
echo "signer (Rust) Local Deploy Script"
|
||||
echo ""
|
||||
echo "USAGE:"
|
||||
echo " $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "OPTIONS:"
|
||||
echo " --musl Build static x86_64 musl binaries in Docker"
|
||||
echo " --debug Build native 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
|
||||
--musl)
|
||||
BUILD_KIND="musl"
|
||||
PROFILE="release"
|
||||
CARGO_FLAG=""
|
||||
TARGET_DIR="dist"
|
||||
shift
|
||||
;;
|
||||
--debug)
|
||||
if [[ "$BUILD_KIND" == "musl" ]]; then
|
||||
print_error "--musl currently supports release builds only"
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
if [[ "$BUILD_KIND" == "musl" ]]; then
|
||||
print_status "Building static musl release binaries in Docker..."
|
||||
./build_musl.sh
|
||||
SIGNER_BIN="${TARGET_DIR}/signer-linux-x86_64-musl"
|
||||
CLIENT_BIN="${TARGET_DIR}/signer-client-linux-x86_64-musl"
|
||||
else
|
||||
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"
|
||||
fi
|
||||
|
||||
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="$2"
|
||||
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" "signer"
|
||||
install_binary "$CLIENT_BIN" "signer-client"
|
||||
|
||||
# Verify
|
||||
print_status "Verification:"
|
||||
"$DEST_DIR/signer" --version || true
|
||||
"$DEST_DIR/signer-client" --version || true
|
||||
|
||||
print_success "Local deploy completed (${PROFILE} ${BUILD_KIND} profile)"
|
||||
+27
-8
@@ -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,24 @@ verify_binary_version() {
|
||||
}
|
||||
|
||||
build_release_binary() {
|
||||
print_status "Building release binary (cargo build --release)..."
|
||||
cargo build --release 2>&1 | tail -5 || return 1
|
||||
print_status "Building static musl release binaries..."
|
||||
./build_musl.sh || return 1
|
||||
|
||||
local bin_path="target/release/nsigner"
|
||||
local bin_path="dist/signer"
|
||||
verify_binary_version "$bin_path" "$NEW_VERSION" || return 1
|
||||
|
||||
print_success "Release binary built: $bin_path"
|
||||
local client_path="dist/signer_client"
|
||||
if [[ -f "$client_path" ]]; then
|
||||
verify_binary_version "$client_path" "$NEW_VERSION" || return 1
|
||||
print_success "Portable client binary built: $client_path"
|
||||
fi
|
||||
|
||||
print_success "Portable signer 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 +285,8 @@ main() {
|
||||
|
||||
git_commit_and_push
|
||||
|
||||
local binary_path="target/release/nsigner"
|
||||
local binary_path="dist/signer"
|
||||
local client_path="dist/signer_client"
|
||||
local tarball_path=""
|
||||
tarball_path=$(create_source_tarball || true)
|
||||
|
||||
@@ -288,6 +295,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,299 @@
|
||||
#!/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_BINARY_ASSET=<asset name> # optional release asset override
|
||||
# SIGNER_CLIENT_BINARY_ASSET=<asset name> # optional release asset override
|
||||
|
||||
SIGNER_VERSION="${SIGNER_VERSION:-}"
|
||||
SIGNER_BINARY_ASSET="${SIGNER_BINARY_ASSET:-signer}"
|
||||
SIGNER_CLIENT_BINARY_ASSET="${SIGNER_CLIENT_BINARY_ASSET:-signer_client}"
|
||||
|
||||
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>
|
||||
SIGNER_BINARY_ASSET=<asset name> # defaults to signer-linux-x86_64-musl
|
||||
SIGNER_CLIENT_BINARY_ASSET=<asset name> # defaults to signer-client-linux-x86_64-musl
|
||||
|
||||
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-linux-x86_64-musl")
|
||||
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
|
||||
|
||||
# Older releases used unqualified asset names. Keep that fallback while
|
||||
# preferring explicit portable musl assets for new releases.
|
||||
if [[ -z "${asset_url}" && "${asset_name}" == *-linux-x86_64-musl ]]; then
|
||||
asset_url="$(download_signer_asset_url "${asset_name%-linux-x86_64-musl}")"
|
||||
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}"
|
||||
|
||||
# Prefer the statically linked musl artifact; fall back to legacy names.
|
||||
download_binary "${SIGNER_BINARY_ASSET}" "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_BINARY_ASSET}" >/dev/null 2>&1 && ! 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_BINARY_ASSET}" "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) ⚠️
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Static musl portability plan
|
||||
|
||||
## Objective
|
||||
|
||||
Produce portable x86_64 Linux release binaries that do not depend on the target machine's glibc version. The primary release artifacts will be statically linked against musl and will remain compatible with the project's Linux, Qubes, Unix-socket, qrexec, TCP/HTTP, terminal, and secure-memory requirements.
|
||||
|
||||
## Current findings
|
||||
|
||||
- The development host is x86_64 Ubuntu 22.04 with glibc 2.35.
|
||||
- Rust 1.80.1 and Cargo 1.80.1 are installed, but `rustup` is unavailable.
|
||||
- Docker is available, so the build can be isolated from the host toolchain and libc.
|
||||
- The project directly uses conventional Linux APIs through `libc`: `mlock`, `munlock`, `AF_UNIX`, `SO_PEERCRED`, `getuid`, `close`, `listen`, and `localtime_r`.
|
||||
- Cryptography is implemented through Rust crates; no mandatory OpenSSL dependency was found in this repository.
|
||||
- The sibling `nostr_core_lib_rust` checkout is a path dependency declared by `Cargo.toml` and must be present in the container build context.
|
||||
- The vendored `ratatui` submodule and its crossterm backend must compile for the musl target.
|
||||
- Existing release scripts build directly with the host Cargo installation and therefore do not guarantee libc portability.
|
||||
|
||||
## Compatibility policy
|
||||
|
||||
- Primary portable target: `x86_64-unknown-linux-musl`.
|
||||
- Release artifacts are intended for x86_64 Linux systems regardless of the installed glibc version, subject to Linux kernel, CPU, terminal, qrexec, and resource-limit requirements.
|
||||
- Do not use `-C target-cpu=native`; build for a conservative x86_64 baseline.
|
||||
- Keep the normal host-native build for development and debugging.
|
||||
- Treat `RLIMIT_MEMLOCK` separately from libc portability; static linking does not remove the need for appropriate memory-lock limits.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### 1. Add a reproducible musl build image
|
||||
|
||||
Create a pinned Docker build definition, preferably using a stable musl-based Rust image or a pinned Alpine image with an explicitly installed Rust toolchain.
|
||||
|
||||
The image must provide:
|
||||
|
||||
- Rust and Cargo versions compatible with the project's `Cargo.toml` and lockfile.
|
||||
- The `x86_64-unknown-linux-musl` target.
|
||||
- A C compiler/linker suitable for musl.
|
||||
- Required build utilities and certificate configuration.
|
||||
- No dependency on the host's Rust installation or glibc-linked build output.
|
||||
|
||||
Ensure the container can access both the project and the sibling `nostr_core_lib_rust` path dependency. The build must initialize or use the existing `ratatui` submodule consistently with normal project setup.
|
||||
|
||||
### 2. Add a portable build entry point
|
||||
|
||||
Create `build_musl.sh` with explicit behavior:
|
||||
|
||||
1. Validate that Docker is available.
|
||||
2. Validate that the project root and required sibling path dependency exist.
|
||||
3. Build both `signer` and `signer-client` for `x86_64-unknown-linux-musl` in release mode.
|
||||
4. Use a dedicated output directory such as `dist/musl-x86_64`.
|
||||
5. Copy the binaries using explicit names:
|
||||
- `signer-linux-x86_64-musl`
|
||||
- `signer-client-linux-x86_64-musl`
|
||||
6. Avoid copying host-native binaries into the portable output.
|
||||
7. Run binary validation and version checks before reporting success.
|
||||
8. Return a nonzero status for missing dependencies, failed builds, invalid ELF output, dynamic linkage, or version mismatch.
|
||||
|
||||
Keep the script usable from the repository root and make its Docker invocation safe for ordinary user development.
|
||||
|
||||
### 3. Validate static linkage and runtime behavior
|
||||
|
||||
Add validation to the build flow using tools available in the container:
|
||||
|
||||
- `file` must identify x86_64 ELF executables.
|
||||
- `readelf` must show the expected musl/static characteristics and no glibc loader requirement.
|
||||
- `ldd` must not identify unresolved dynamic runtime dependencies.
|
||||
- `signer --version` and `signer-client --version` must report the Cargo package version.
|
||||
- Run `cargo test --target x86_64-unknown-linux-musl` where the test environment supports it.
|
||||
|
||||
Perform smoke checks for:
|
||||
|
||||
- stdio framing;
|
||||
- Unix abstract socket bind/connect and peer identity;
|
||||
- TCP and HTTP startup;
|
||||
- qrexec subprocess integration where the host provides qrexec;
|
||||
- mnemonic input and key derivation;
|
||||
- representative signing and verification operations;
|
||||
- TUI initialization in a terminal;
|
||||
- `mlock` success or the documented unlocked-memory fallback behavior.
|
||||
|
||||
If a dependency cannot support musl, record the failure and determine whether it is optional, can be feature-gated, or requires retaining a glibc artifact.
|
||||
|
||||
### 4. Integrate release automation
|
||||
|
||||
Update `increment_and_push.sh` so release mode invokes the portable musl builder rather than the host-native `cargo build --release` path.
|
||||
|
||||
Preserve:
|
||||
|
||||
- version incrementing;
|
||||
- source version updates;
|
||||
- binary version verification;
|
||||
- tagging and pushing;
|
||||
- Gitea release creation;
|
||||
- source tarball generation.
|
||||
|
||||
Extend asset upload handling to upload both musl binaries with their platform-specific names. Do not silently publish a host-linked binary under an ambiguous name.
|
||||
|
||||
Decide whether the existing unqualified assets remain as compatibility aliases. The safer default is to publish explicit musl names and retain legacy fallback handling only for older releases.
|
||||
|
||||
### 5. Update local deployment
|
||||
|
||||
Extend `deploy_local.sh` with an explicit portable option, such as `--musl`.
|
||||
|
||||
Suggested behavior:
|
||||
|
||||
- default development/debug builds remain native;
|
||||
- `--musl` invokes the Docker build and installs the resulting portable binaries;
|
||||
- release deployment should clearly report whether the installed binary is native or musl;
|
||||
- installation continues to use `/usr/local/bin/` only when the user has the required privileges.
|
||||
|
||||
Add checks so the script does not mistake a musl artifact for a native build or install an absent/stale binary.
|
||||
|
||||
### 6. Update the installer
|
||||
|
||||
Update `install_signer.sh` to prefer the explicit musl release assets:
|
||||
|
||||
- `signer-linux-x86_64-musl`
|
||||
- `signer-client-linux-x86_64-musl`
|
||||
|
||||
Retain support for existing older releases whose assets are named simply `signer` or `signer-client`. Add an override for users who need a different asset URL or a native glibc build.
|
||||
|
||||
Continue verifying the installed program version, and add target/artifact validation where practical so a mislabeled asset is rejected.
|
||||
|
||||
### 7. Update documentation
|
||||
|
||||
Update the build and platform sections of `README.md` to explain:
|
||||
|
||||
- native builds are for local development;
|
||||
- portable release builds use static musl;
|
||||
- the portable target is x86_64 Linux;
|
||||
- the artifact names and output directory;
|
||||
- how to verify static linkage with `file`, `readelf`, and `ldd`;
|
||||
- static musl removes the glibc runtime dependency but not Linux kernel, CPU, terminal, qrexec, or `RLIMIT_MEMLOCK` requirements;
|
||||
- Qubes users should use the musl release artifact unless a native glibc build is specifically required.
|
||||
|
||||
Update the project layout section with the new build definition and script.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A clean Docker invocation builds both release binaries without using the host Rust compiler or host glibc.
|
||||
- The resulting binaries are x86_64 and statically linked against musl.
|
||||
- Neither binary requires the glibc dynamic loader or a minimum GLIBC symbol version.
|
||||
- Both binaries pass version checks and the relevant test suite.
|
||||
- Representative Unix, stdio, TCP/HTTP, crypto, TUI, and secure-memory paths are validated.
|
||||
- Release automation uploads unambiguous musl artifacts.
|
||||
- The installer can select the musl artifacts and remains compatible with older release naming.
|
||||
- Documentation explains how to build, verify, install, and deploy the portable binaries.
|
||||
|
||||
## Deferred options
|
||||
|
||||
- Publishing a glibc-2.17-compatible artifact can be added later if a target integration requires glibc behavior.
|
||||
- Additional architectures can be added later, but must have separate build images, artifact names, and validation.
|
||||
- Musl should not be made the only local development target until the smoke tests demonstrate that all supported transports and terminal behavior are equivalent for the project's deployment environments.
|
||||
@@ -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).
|
||||
@@ -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,9 +124,9 @@ the C-format log entries (newest first).
|
||||
├──────────────────────────────────────────┬──────────────────────────────────────────┤
|
||||
│ Information │ Activity │
|
||||
│ │ │
|
||||
│ signer name: nsigner01 │ 2026-08-18 15:05:42 unix:1000 │
|
||||
│ signer name: signer01 │ 2026-08-18 15:05:42 unix:1000 │
|
||||
│ Unix address: │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ nsigner01 │ 2026-08-18 15:05:30 unix:1000 │
|
||||
│ signer01 │ 2026-08-18 15:05:30 unix:1000 │
|
||||
│ Qube address: │ - - │
|
||||
│ (inactive) │ 2026-08-18 15:04:55 unix:1000 │
|
||||
│ FIPS address: │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
@@ -142,7 +142,7 @@ the C-format log entries (newest first).
|
||||
│ [ ] 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 nsigner started │
|
||||
│ │ 2026-08-18 15:00:10 signer started │
|
||||
├──────────────────────────────────────────┤ │
|
||||
│ Roles │ │
|
||||
│ │ │
|
||||
@@ -575,7 +575,7 @@ and deleted directly in the Roles section.
|
||||
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 `nsigner_client`.
|
||||
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.4";
|
||||
pub const VERSION: &str = "v0.0.23";
|
||||
|
||||
+46
-42
@@ -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,11 +126,11 @@ 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();
|
||||
@@ -153,7 +153,7 @@ 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);
|
||||
|
||||
@@ -163,7 +163,7 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
// (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 = nsigner::tui::App::new(
|
||||
let mut app = signer::tui::App::new(
|
||||
RoleTable::new(),
|
||||
MnemonicState::new(),
|
||||
KeyStore::new(),
|
||||
@@ -176,7 +176,7 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
let mut terminal = ratatui::init();
|
||||
let result = app.run(&mut terminal);
|
||||
ratatui::restore();
|
||||
result.map_err(|e| NsignerError::IoFailed(e.to_string()))
|
||||
result.map_err(|e| SignerError::IoFailed(e.to_string()))
|
||||
} else {
|
||||
// Unreachable: non-interactive modes return headless above.
|
||||
Ok(())
|
||||
@@ -184,14 +184,14 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -200,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)?;
|
||||
}
|
||||
@@ -219,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();
|
||||
@@ -241,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
|
||||
@@ -253,7 +255,9 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
|
||||
alg_key_cache: &mut alg_key_cache,
|
||||
};
|
||||
match server.handle_one(&mut dispatcher) {
|
||||
Ok(Some(_activity)) => {}
|
||||
Ok(Some(activity)) => {
|
||||
println!("{}", activity);
|
||||
}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
@@ -276,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);
|
||||
@@ -306,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
|
||||
@@ -328,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);
|
||||
@@ -349,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);
|
||||
@@ -364,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;
|
||||
}
|
||||
@@ -387,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 {
|
||||
@@ -404,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];
|
||||
@@ -415,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 => {
|
||||
@@ -439,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(
|
||||
@@ -460,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(());
|
||||
|
||||
+221
-27
@@ -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(())
|
||||
@@ -142,7 +174,7 @@ impl ServerContext {
|
||||
pub fn handle_one(
|
||||
&mut self,
|
||||
dispatcher: &mut DispatcherContext,
|
||||
) -> Result<Option<String>, NsignerError> {
|
||||
) -> Result<Option<String>, SignerError> {
|
||||
if let Some(ref listener) = self.listener {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
@@ -153,13 +185,13 @@ 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(None) so we don't log it as handled.
|
||||
let request = match crate::transport::recv_framed(&mut reader) {
|
||||
let mut request = match crate::transport::recv_framed(&mut reader) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(None);
|
||||
@@ -168,7 +200,31 @@ impl ServerContext {
|
||||
};
|
||||
|
||||
// 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, activity) = self.process_request(dispatcher, &request, &caller);
|
||||
@@ -182,7 +238,7 @@ impl ServerContext {
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(None); // Nothing pending
|
||||
}
|
||||
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
|
||||
Err(e) => return Err(SignerError::IoFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +254,7 @@ 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 {
|
||||
@@ -232,7 +288,7 @@ impl ServerContext {
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
|
||||
Err(e) => return Err(SignerError::IoFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +326,7 @@ impl ServerContext {
|
||||
Err((code, msg)) => {
|
||||
if self.auth_mode == AuthMode::Required {
|
||||
let response = make_auth_error(&request, code, msg);
|
||||
let activity = format!("{} DENIED:{}", caller.caller_id, msg);
|
||||
let activity = format!("{} DENIED:{}", caller.identity_str(), msg);
|
||||
return (response, activity);
|
||||
}
|
||||
// Optional: continue without auth
|
||||
@@ -284,7 +340,7 @@ impl ServerContext {
|
||||
None => {
|
||||
// Malformed request — let the dispatcher produce the error
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} DENIED:malformed", caller.caller_id);
|
||||
let activity = format!("{} DENIED:malformed", caller.identity_str());
|
||||
return (response, activity);
|
||||
}
|
||||
};
|
||||
@@ -292,21 +348,28 @@ impl ServerContext {
|
||||
// get_info is metadata — no key material
|
||||
if method == crate::enforcement::VERB_GET_INFO {
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} - -", caller.caller_id);
|
||||
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) {
|
||||
let response = self.process_algorithm_verb(dispatcher, request, &selector_req);
|
||||
let activity = format!("{} - -", caller.caller_id);
|
||||
// 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 {
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} - -", caller.caller_id);
|
||||
let activity = format!("{} {}()", caller.identity_str(), method);
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
@@ -318,7 +381,7 @@ impl ServerContext {
|
||||
let response = make_selector_error(&request, e);
|
||||
let activity = format!(
|
||||
"{} {}() DENIED:{}",
|
||||
caller.caller_id,
|
||||
caller.identity_str(),
|
||||
method,
|
||||
e.as_str()
|
||||
);
|
||||
@@ -327,15 +390,29 @@ impl ServerContext {
|
||||
};
|
||||
|
||||
// Role entry from the resolved selector — used for the activity
|
||||
// message (curve + key path).
|
||||
// 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();
|
||||
let path = role_entry.display_path();
|
||||
// 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 ───────────────────────────────────────────────
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
// Activity format: uid curve path (timestamp is added by the log).
|
||||
let activity = format!("{} {} {}", caller.caller_id, curve, path);
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -358,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')
|
||||
@@ -391,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
|
||||
@@ -485,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);
|
||||
@@ -527,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);
|
||||
}
|
||||
}
|
||||
|
||||
+238
-81
@@ -10,7 +10,7 @@ use crate::key_store::KeyStore;
|
||||
use crate::mnemonic::MnemonicState;
|
||||
use crate::role_table::{RoleCurve, RolePurpose, RoleTable};
|
||||
use crate::server::{AuthMode, ListenMode, ServerContext};
|
||||
use crate::NsignerError;
|
||||
use crate::SignerError;
|
||||
|
||||
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
@@ -36,11 +36,16 @@ pub const TRANSPORT_HTTP: u8 = 0x08;
|
||||
|
||||
/// Qrexec service name used for the bridge transport.
|
||||
/// Matches NSIGNER_QREXEC_SERVICE_NAME in n_signer/src/main.c.
|
||||
pub const QREXEC_SERVICE_NAME: &str = "qubes.NsignerRpc";
|
||||
pub const QREXEC_SERVICE_NAME: &str = "qubes.SignerRpc";
|
||||
|
||||
/// Check if a word is in the BIP-39 English wordlist.
|
||||
fn is_valid_bip39_word(word: &str) -> bool {
|
||||
nips::nip006::bip39_wordlist().binary_search(&word).is_ok()
|
||||
}
|
||||
|
||||
/// Number of focusable commands on the main screen:
|
||||
/// 4 transports + Add + Delete + Clear + Help + Quit.
|
||||
pub const FOCUS_COUNT: usize = 9;
|
||||
/// 4 transports + Add + Edit + Delete + Clear + Help + Quit.
|
||||
pub const FOCUS_COUNT: usize = 10;
|
||||
|
||||
/// Look up the local FIPS node identity by running `fipsctl show status`.
|
||||
/// Returns the node's `npub` (a Nostr public-key identifier) when both
|
||||
@@ -387,6 +392,10 @@ pub struct App {
|
||||
pub role_add_default_purpose: RolePurpose,
|
||||
/// Saved OTP pad directory between the OtpDir and OtpName stages.
|
||||
pub role_add_otp_dir: String,
|
||||
/// If true, the add-role popup is editing an existing role instead of
|
||||
/// creating a new one. The original name is stored for lookup.
|
||||
pub role_edit_mode: bool,
|
||||
pub role_edit_original_name: String,
|
||||
|
||||
// Transport
|
||||
pub transport_toggles: [bool; 4],
|
||||
@@ -399,8 +408,8 @@ pub struct App {
|
||||
|
||||
// Unified command focus.
|
||||
/// Index of the currently focused command on the main screen.
|
||||
/// 0-3 = transports (Unix, Qube, FIPS, HTTP), 4 = Add, 5 = Delete,
|
||||
/// 6 = Clear, 7 = Help, 8 = Quit. Tab cycles forward.
|
||||
/// 0-3 = transports (Unix, Qube, FIPS, HTTP), 4 = Add, 5 = Edit,
|
||||
/// 6 = Delete, 7 = Clear, 8 = Help, 9 = Quit. Tab cycles forward.
|
||||
pub focus: usize,
|
||||
|
||||
// Main screen
|
||||
@@ -448,6 +457,8 @@ impl App {
|
||||
role_add_default_curve: "secp256k1".to_string(),
|
||||
role_add_default_purpose: RolePurpose::Nostr,
|
||||
role_add_otp_dir: String::new(),
|
||||
role_edit_mode: false,
|
||||
role_edit_original_name: String::new(),
|
||||
transport_toggles: [true, false, false, false],
|
||||
transport_mask: TRANSPORT_UNIX,
|
||||
transport_cursor: 0,
|
||||
@@ -524,7 +535,7 @@ impl App {
|
||||
}
|
||||
|
||||
/// Start the server for the currently selected transport.
|
||||
fn start_server(&mut self) -> Result<(), NsignerError> {
|
||||
fn start_server(&mut self) -> Result<(), SignerError> {
|
||||
// Pick the first active transport by priority (Unix > Qrexec > TCP > HTTP).
|
||||
let (listen_mode, server_name) = if self.transport_toggles[0] {
|
||||
(ListenMode::Unix, self.socket_name.clone())
|
||||
@@ -581,27 +592,25 @@ impl App {
|
||||
// ── Seed entry ──────────────────────────────────────────────────────
|
||||
|
||||
fn handle_seed_key(&mut self, key: KeyEvent) {
|
||||
// 'g' generates a new mnemonic directly.
|
||||
if key.code == KeyCode::Char('g') || key.code == KeyCode::Char('G') {
|
||||
match self.mnemonic.generate(12) {
|
||||
Ok(phrase) => {
|
||||
self.generated_phrase = phrase;
|
||||
self.seed_generate_mode = true;
|
||||
self.screen = Screen::SeedDisplay;
|
||||
}
|
||||
Err(_) => {
|
||||
self.seed_attempts += 1;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if edit_key(&mut self.unlock_input, key) {
|
||||
return;
|
||||
}
|
||||
if key.code == KeyCode::Enter {
|
||||
let input = self.unlock_input.buffer.trim().to_string();
|
||||
self.unlock_input = InputField::new("");
|
||||
if !input.is_empty() {
|
||||
if input.is_empty() {
|
||||
// Enter on an empty buffer generates a fresh mnemonic.
|
||||
match self.mnemonic.generate(12) {
|
||||
Ok(phrase) => {
|
||||
self.generated_phrase = phrase;
|
||||
self.seed_generate_mode = true;
|
||||
self.screen = Screen::SeedDisplay;
|
||||
}
|
||||
Err(_) => {
|
||||
self.seed_attempts += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match self.mnemonic.load(&input) {
|
||||
Ok(()) => self.enter_main(),
|
||||
Err(_) => {
|
||||
@@ -643,7 +652,7 @@ impl App {
|
||||
self.transport_mask_apply();
|
||||
match self.start_server() {
|
||||
Ok(()) => {
|
||||
self.activity_log.add("nsigner started");
|
||||
self.activity_log.add("signer started");
|
||||
}
|
||||
Err(e) => {
|
||||
self.activity_log.add(&format!("server start failed: {}", e));
|
||||
@@ -663,10 +672,15 @@ impl App {
|
||||
self.should_quit = true;
|
||||
}
|
||||
KeyCode::Char('a') | KeyCode::Char('A') => {
|
||||
self.role_edit_mode = false;
|
||||
self.role_edit_original_name.clear();
|
||||
self.role_add_stage = RoleAddStage::PresetMenu;
|
||||
self.role_add_choice = 1;
|
||||
self.screen = Screen::AddRole;
|
||||
}
|
||||
KeyCode::Char('e') | KeyCode::Char('E') => {
|
||||
self.edit_selected_role();
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||
self.delete_selected_role();
|
||||
}
|
||||
@@ -678,7 +692,8 @@ impl App {
|
||||
KeyCode::Char('b') | KeyCode::Char('B') => self.toggle_transport(1),
|
||||
KeyCode::Char('f') | KeyCode::Char('F') => self.toggle_transport(2),
|
||||
KeyCode::Char('h') | KeyCode::Char('H') => self.toggle_transport(3),
|
||||
// Tab / Shift-Tab cycle focus through all commands on the page.
|
||||
// Tab / Shift-Tab and Left/Right arrows cycle focus through
|
||||
// all commands on the page.
|
||||
KeyCode::Tab => {
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
self.focus = (self.focus + FOCUS_COUNT - 1) % FOCUS_COUNT;
|
||||
@@ -689,16 +704,36 @@ impl App {
|
||||
KeyCode::BackTab => {
|
||||
self.focus = (self.focus + FOCUS_COUNT - 1) % FOCUS_COUNT;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.focus = (self.focus + 1) % FOCUS_COUNT;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.focus = (self.focus + FOCUS_COUNT - 1) % FOCUS_COUNT;
|
||||
}
|
||||
// Enter activates whichever command is currently focused.
|
||||
KeyCode::Enter => self.activate_focused(),
|
||||
// Up/Down scroll the activity log.
|
||||
// Up/Down and j/k move role selection when a role command
|
||||
// (Edit / Delete) is focused, otherwise scroll the log.
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.activity_scroll > 0 {
|
||||
if self.focus == 5 || self.focus == 6 {
|
||||
// Move role cursor up
|
||||
if self.role_cursor > 0 {
|
||||
self.role_cursor -= 1;
|
||||
}
|
||||
} else if self.activity_scroll > 0 {
|
||||
self.activity_scroll -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.activity_scroll += 1;
|
||||
if self.focus == 5 || self.focus == 6 {
|
||||
// Move role cursor down
|
||||
let last = self.role_table.entries.len().saturating_sub(1);
|
||||
if self.role_cursor < last {
|
||||
self.role_cursor += 1;
|
||||
}
|
||||
} else {
|
||||
self.activity_scroll += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -712,17 +747,20 @@ impl App {
|
||||
2 => self.toggle_transport(2),
|
||||
3 => self.toggle_transport(3),
|
||||
4 => {
|
||||
self.role_edit_mode = false;
|
||||
self.role_edit_original_name.clear();
|
||||
self.role_add_stage = RoleAddStage::PresetMenu;
|
||||
self.role_add_choice = 1;
|
||||
self.screen = Screen::AddRole;
|
||||
}
|
||||
5 => self.delete_selected_role(),
|
||||
6 => {
|
||||
5 => self.edit_selected_role(),
|
||||
6 => self.delete_selected_role(),
|
||||
7 => {
|
||||
self.activity_log.clear();
|
||||
self.activity_scroll = 0;
|
||||
}
|
||||
7 => self.screen = Screen::Help,
|
||||
8 => self.should_quit = true,
|
||||
8 => self.screen = Screen::Help,
|
||||
9 => self.should_quit = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -743,7 +781,7 @@ impl App {
|
||||
}
|
||||
match self.start_server() {
|
||||
Ok(()) => {
|
||||
self.activity_log.add("nsigner started");
|
||||
self.activity_log.add("signer started");
|
||||
}
|
||||
Err(e) => {
|
||||
self.activity_log.add(&format!("server start failed: {}", e));
|
||||
@@ -771,10 +809,34 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the add-role popup pre-filled with the selected role's data
|
||||
/// for editing. Sets `role_edit_mode` so that on confirm the old
|
||||
/// entry is replaced rather than a new one being added.
|
||||
fn edit_selected_role(&mut self) {
|
||||
let count = self.role_table.entries.len();
|
||||
if count == 0 || self.role_cursor >= count {
|
||||
return;
|
||||
}
|
||||
let entry = &self.role_table.entries[self.role_cursor];
|
||||
self.role_edit_mode = true;
|
||||
self.role_edit_original_name = entry.name.clone();
|
||||
self.role_add_default_name = entry.name.clone();
|
||||
self.role_add_default_path = entry.role_path.clone();
|
||||
self.role_add_default_curve = entry.curve_str.clone();
|
||||
self.role_add_default_purpose = entry.purpose;
|
||||
self.role_add_choice = 10; // custom — skip preset, go to NameEntry
|
||||
self.role_add_input = InputField::new(&entry.name);
|
||||
self.role_add_stage = RoleAddStage::NameEntry;
|
||||
self.role_add_name = entry.name.clone();
|
||||
self.screen = Screen::AddRole;
|
||||
}
|
||||
|
||||
// ── Add-role popup ──────────────────────────────────────────────────
|
||||
|
||||
fn handle_add_role_key(&mut self, key: KeyEvent) {
|
||||
if key.code == KeyCode::Esc {
|
||||
self.role_edit_mode = false;
|
||||
self.role_edit_original_name.clear();
|
||||
self.screen = Screen::Main;
|
||||
return;
|
||||
}
|
||||
@@ -978,25 +1040,41 @@ impl App {
|
||||
self.role_add_input = InputField::new("");
|
||||
return;
|
||||
}
|
||||
if self.role_table.find_by_name(&name).is_some() {
|
||||
// Duplicate — back to preset
|
||||
// In edit mode, the name may match the original — that's fine.
|
||||
// Only reject duplicates that belong to a *different* role.
|
||||
if !self.role_edit_mode {
|
||||
if self.role_table.find_by_name(&name).is_some() {
|
||||
// Duplicate — back to preset
|
||||
self.role_add_stage = RoleAddStage::PresetMenu;
|
||||
return;
|
||||
}
|
||||
} else if name != self.role_edit_original_name
|
||||
&& self.role_table.find_by_name(&name).is_some()
|
||||
{
|
||||
// Renamed to collide with another existing role.
|
||||
self.role_add_stage = RoleAddStage::PresetMenu;
|
||||
return;
|
||||
}
|
||||
self.role_add_name = name.clone();
|
||||
match self.role_add_choice {
|
||||
// Custom: pick the curve next, then edit the path.
|
||||
10 => {
|
||||
self.role_add_stage = RoleAddStage::CurveSelect;
|
||||
}
|
||||
// OTP: collect the pad directory and pad name next.
|
||||
9 => {
|
||||
self.role_add_input = InputField::new("");
|
||||
self.role_add_stage = RoleAddStage::OtpDir;
|
||||
}
|
||||
// Presets with a fixed path: register immediately.
|
||||
_ => {
|
||||
self.add_role_register_path(&name);
|
||||
if self.role_edit_mode {
|
||||
// Editing: go straight to path entry (curve is already known).
|
||||
self.role_add_input = InputField::new(&self.role_add_default_path);
|
||||
self.role_add_stage = RoleAddStage::PathEntry;
|
||||
} else {
|
||||
match self.role_add_choice {
|
||||
// Custom: pick the curve next, then edit the path.
|
||||
10 => {
|
||||
self.role_add_stage = RoleAddStage::CurveSelect;
|
||||
}
|
||||
// OTP: collect the pad directory and pad name next.
|
||||
9 => {
|
||||
self.role_add_input = InputField::new("");
|
||||
self.role_add_stage = RoleAddStage::OtpDir;
|
||||
}
|
||||
// Presets with a fixed path: register immediately.
|
||||
_ => {
|
||||
self.add_role_register_path(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1025,32 +1103,57 @@ impl App {
|
||||
let purpose = self.role_add_default_purpose;
|
||||
match crate::role_table::parse_path_template(path) {
|
||||
Ok((template, range_lo, range_hi, allowed_indices)) => {
|
||||
match self.role_table.register_role_path(
|
||||
&name,
|
||||
&template,
|
||||
purpose,
|
||||
curve,
|
||||
range_lo,
|
||||
range_hi,
|
||||
-1,
|
||||
&allowed_indices,
|
||||
) {
|
||||
Ok(()) => {
|
||||
if self.role_edit_mode {
|
||||
// Replace the old entry in-place.
|
||||
let old_name = self.role_edit_original_name.clone();
|
||||
if let Some(pos) = self.role_table.entries.iter().position(|e| e.name == old_name)
|
||||
{
|
||||
self.role_table.entries[pos].name = name.clone();
|
||||
self.role_table.entries[pos].purpose = purpose;
|
||||
self.role_table.entries[pos].curve = curve;
|
||||
self.role_table.entries[pos].role_path = template.clone();
|
||||
self.role_table.entries[pos].path_range_lo = range_lo;
|
||||
self.role_table.entries[pos].path_range_hi = range_hi;
|
||||
self.role_table.entries[pos].path_allowed_indices = allowed_indices.clone();
|
||||
self.role_table.entries[pos].path_default_index = -1;
|
||||
// Re-derive this role's key.
|
||||
self.key_store.wipe();
|
||||
let _ = self.key_store.derive_all(&mut self.role_table, &self.mnemonic);
|
||||
self.activity_log
|
||||
.add(&format!("role '{}' registered", name));
|
||||
// Derive the new role's key immediately.
|
||||
if let Some(idx) =
|
||||
self.role_table.entries.iter().position(|e| e.name == name)
|
||||
{
|
||||
let _ = self
|
||||
.key_store
|
||||
.derive_one(&mut self.role_table, &self.mnemonic, idx);
|
||||
}
|
||||
self.role_cursor = self.role_table.entries.len().saturating_sub(1);
|
||||
// Return to Main immediately — no extra Enter needed.
|
||||
self.screen = Screen::Main;
|
||||
.add(&format!("role '{}' updated", name));
|
||||
self.role_cursor = pos;
|
||||
}
|
||||
self.role_edit_mode = false;
|
||||
self.role_edit_original_name.clear();
|
||||
self.screen = Screen::Main;
|
||||
} else {
|
||||
match self.role_table.register_role_path(
|
||||
&name,
|
||||
&template,
|
||||
purpose,
|
||||
curve,
|
||||
range_lo,
|
||||
range_hi,
|
||||
-1,
|
||||
&allowed_indices,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.activity_log
|
||||
.add(&format!("role '{}' registered", name));
|
||||
// Derive the new role's key immediately.
|
||||
if let Some(idx) =
|
||||
self.role_table.entries.iter().position(|e| e.name == name)
|
||||
{
|
||||
let _ = self
|
||||
.key_store
|
||||
.derive_one(&mut self.role_table, &self.mnemonic, idx);
|
||||
}
|
||||
self.role_cursor = self.role_table.entries.len().saturating_sub(1);
|
||||
// Return to Main immediately — no extra Enter needed.
|
||||
self.screen = Screen::Main;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -1188,13 +1291,57 @@ impl App {
|
||||
let area = f.area();
|
||||
let mut lines = vec![
|
||||
Line::from(""),
|
||||
Line::from("Enter seed phrase or G to generate new:"),
|
||||
Line::from("Enter seed phrase (Enter to load, or Enter on empty to generate):"),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
format!("> {}", self.unlock_input.buffer),
|
||||
Style::default(),
|
||||
)),
|
||||
];
|
||||
|
||||
// Per-word validation: split the input by whitespace and check
|
||||
// each completed word against the BIP-39 wordlist. The word
|
||||
// currently being typed (last fragment without a trailing space)
|
||||
// is not checked — only space-separated completed words count.
|
||||
let buffer = &self.unlock_input.buffer;
|
||||
let has_trailing_space = buffer.ends_with(' ');
|
||||
let words: Vec<&str> = buffer.split_whitespace().collect();
|
||||
let completed_count = if has_trailing_space {
|
||||
words.len()
|
||||
} else {
|
||||
words.len().saturating_sub(1)
|
||||
};
|
||||
|
||||
if completed_count > 0 {
|
||||
let red = Style::default().fg(ratatui::style::Color::Red);
|
||||
let green = Style::default().fg(ratatui::style::Color::Green);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
for (i, w) in words.iter().take(completed_count).enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
if is_valid_bip39_word(w) {
|
||||
spans.push(Span::styled(format!("✓{}", w), green));
|
||||
} else {
|
||||
spans.push(Span::styled(format!("✗{}", w), red));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
// Word count indicator.
|
||||
let total_words = words.len();
|
||||
if total_words > 0 {
|
||||
let valid_counts = [12, 15, 18, 21, 24];
|
||||
let count_note = if valid_counts.contains(&total_words) {
|
||||
format!("Words: {} (valid count)", total_words)
|
||||
} else {
|
||||
format!("Words: {} (need 12/15/18/21/24)", total_words)
|
||||
};
|
||||
lines.push(Line::from(count_note));
|
||||
}
|
||||
|
||||
if self.seed_attempts > 0 {
|
||||
lines.push(Line::from(format!(
|
||||
"Invalid mnemonic. Attempts: {}/10",
|
||||
@@ -1288,9 +1435,9 @@ impl App {
|
||||
self.draw_roles_section(f, roles);
|
||||
self.draw_activity_section(f, right);
|
||||
|
||||
// Bottom bar: Help (focus 7) / Quit (focus 8).
|
||||
let help_focused = self.focus == 7;
|
||||
let quit_focused = self.focus == 8;
|
||||
// Bottom bar: Help (focus 8) / Quit (focus 9).
|
||||
let help_focused = self.focus == 8;
|
||||
let quit_focused = self.focus == 9;
|
||||
let mut help_spans = App::cmd_hint('l', "Help");
|
||||
if help_focused {
|
||||
for s in &mut help_spans {
|
||||
@@ -1337,7 +1484,7 @@ impl App {
|
||||
// client command that callers use to reach the signer,
|
||||
// matching the connection info in n_signer's main.c.
|
||||
lines.push(Line::from(format!(" qrexec service: {}", QREXEC_SERVICE_NAME)));
|
||||
lines.push(Line::from(format!(" nsigner_client --qrexec <qube>:{}", QREXEC_SERVICE_NAME)));
|
||||
lines.push(Line::from(format!(" signer_client --qrexec <qube>:{}", QREXEC_SERVICE_NAME)));
|
||||
} else {
|
||||
lines.push(Line::from(" (inactive)"));
|
||||
}
|
||||
@@ -1431,10 +1578,10 @@ impl App {
|
||||
)),
|
||||
];
|
||||
for (i, e) in self.role_table.entries.iter().enumerate() {
|
||||
// Only highlight a role row when a role command (Add/Delete)
|
||||
// Only highlight a role row when a role command (Edit/Delete)
|
||||
// is focused, so the table doesn't show a stale highlight by
|
||||
// default.
|
||||
let selected = (self.focus == 4 || self.focus == 5) && self.role_cursor == i;
|
||||
let selected = (self.focus == 5 || self.focus == 6) && self.role_cursor == i;
|
||||
let row = Line::from(format!(
|
||||
" {:<14} {:<8} {:<12} {}",
|
||||
e.name, e.purpose_str, e.curve_str, e.display_path()
|
||||
@@ -1448,9 +1595,10 @@ impl App {
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), rows_area);
|
||||
|
||||
// Add (focus 4) / Delete (focus 5) — reverse-highlight when focused.
|
||||
// Add (focus 4) / Edit (focus 5) / Delete (focus 6) — reverse-highlight when focused.
|
||||
let add_focused = self.focus == 4;
|
||||
let del_focused = self.focus == 5;
|
||||
let edit_focused = self.focus == 5;
|
||||
let del_focused = self.focus == 6;
|
||||
let mut spans = Vec::new();
|
||||
let mut add_spans = App::cmd_hint('a', "Add");
|
||||
if add_focused {
|
||||
@@ -1460,6 +1608,14 @@ impl App {
|
||||
}
|
||||
spans.extend(add_spans);
|
||||
spans.push(Span::raw(" "));
|
||||
let mut edit_spans = App::cmd_hint('e', "Edit");
|
||||
if edit_focused {
|
||||
for s in &mut edit_spans {
|
||||
*s = s.clone().style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
}
|
||||
}
|
||||
spans.extend(edit_spans);
|
||||
spans.push(Span::raw(" "));
|
||||
let mut del_spans = App::cmd_hint('d', "Delete");
|
||||
if del_focused {
|
||||
for s in &mut del_spans {
|
||||
@@ -1505,7 +1661,7 @@ impl App {
|
||||
.end_symbol(Some("▼"));
|
||||
f.render_stateful_widget(sb, log_area, &mut scrollbar_state);
|
||||
|
||||
let clear_focused = self.focus == 6;
|
||||
let clear_focused = self.focus == 7;
|
||||
let mut spans = App::cmd_hint('c', "Clear");
|
||||
if clear_focused {
|
||||
for s in &mut spans {
|
||||
@@ -1664,6 +1820,7 @@ impl App {
|
||||
Line::from("F Toggle FIPS transport"),
|
||||
Line::from("H Toggle HTTP transport"),
|
||||
Line::from("A Add a role"),
|
||||
Line::from("E Edit the selected role"),
|
||||
Line::from("D Delete the selected role"),
|
||||
Line::from("C Clear the activity log"),
|
||||
Line::from("L Open the Help screen"),
|
||||
|
||||
+15
-15
@@ -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,
|
||||
@@ -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