v0.0.11 - Added signer-client: Rust port of n_signer_client with full verb surface, 4 transports, and client-side auth envelope

This commit is contained in:
Laan Tungir
2026-08-20 11:17:01 -04:00
parent b3cc4704c3
commit 374b71cdf4
12 changed files with 2086 additions and 51 deletions
Generated
+1 -1
View File
@@ -1490,7 +1490,7 @@ dependencies = [
[[package]]
name = "nsigner"
version = "0.0.9"
version = "0.0.10"
dependencies = [
"base64",
"chacha20poly1305",
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nsigner"
version = "0.0.10"
version = "0.0.11"
edition = "2021"
license = "MIT"
description = "Attended Nostr signing daemon — Rust port of n_signer"
@@ -9,6 +9,10 @@ description = "Attended Nostr signing daemon — Rust port of n_signer"
name = "nsigner"
path = "src/main.rs"
[[bin]]
name = "signer-client"
path = "src/client/main.rs"
[lib]
name = "nsigner"
path = "src/lib.rs"
+48 -48
View File
@@ -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
@@ -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>"}
```
@@ -730,7 +730,7 @@ git submodule update --init ratatui
```bash
cargo build
./target/debug/nsigner --version
./target/debug/signer --version
```
### 8.3 Release build
@@ -748,7 +748,7 @@ strip = true
```bash
cargo build --release
./target/release/nsigner --version
./target/release/signer --version
```
### 8.4 Tests
@@ -781,7 +781,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 |
+287
View File
@@ -0,0 +1,287 @@
# Plan: `signer-client` — Rust CLI for the nsigner daemon
## Goal
A standalone Rust command-line client `signer-client` that connects to a running
`nsigner` 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 `nsigner` 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 `NsignerClient` (send framed JSON-RPC, recv, parse result/error)
- [`src/client/signer.rs`](src/client/signer.rs:1) — high-level `NsignerSigner` 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 `nsigner_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 `nsigner_transport_open_serial`).
- Qrexec: spawn `qrexec-client-vm <qube> <service>` via `std::process`,
pipe framed JSON over its stdin/stdout (matches C
`nsigner_transport_open_qrexec`).
3. **`NsignerClient`** — low-level RPC caller: builds `{"id","method","params"}`
JSON, sends framed, receives framed, splits `result` vs `error`, holds
`last_error`. Mirrors C `nsigner_client_t` / `nsigner_client_call`.
4. **`NsignerSigner`** — high-level typed-verb layer. Holds a `NsignerClient`
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_nsigner_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 nsigner 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/>NsignerClient]
Rpc --> Signer[signer.rs<br/>NsignerSigner typed verbs]
Signer -->|build params| Disp[nsigner 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`: `NsignerClient` struct holding the transport,
`call(method, params) -> Result<Value, String>`, `last_error`, and
framed send/recv using `serde_json`.
5. Implement `signer.rs`: `NsignerSigner` 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 `nsigner
--mnemonic-stdin --listen unix --socket-name nsigner_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
`nsigner` 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).
+180
View File
@@ -0,0 +1,180 @@
# `signer-client` — Rust CLI for nsigner
A standalone command-line client that connects to a running [`nsigner`](../..)
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 nsigner 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 `nsigner*` 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.NsignerRpc` |
## 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 `NsignerClient` (framed JSON-RPC send/recv) |
| [`signer.rs`](signer.rs:1) | High-level `NsignerSigner` 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) — nsigner main documentation
+100
View File
@@ -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
//! [`nsigner::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):
//! - `["nsigner_rpc", <request id>]`
//! - `["nsigner_method", <method>]`
//! - `["nsigner_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("nsigner_rpc", request_id),
Tag::with_value("nsigner_method", method),
Tag::with_value("nsigner_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())
}
}
+251
View File
@@ -0,0 +1,251 @@
//! 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 = nsigner::VERSION,
about = "Standalone CLI for the nsigner JSON-RPC API"
)]
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 nsigner 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 nsigner 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."
);
}
+551
View File
@@ -0,0 +1,551 @@
//! signer-client — standalone CLI for the nsigner JSON-RPC API.
//!
//! Port of the C [`n_signer_client.c`](../../n_signer/client/n_signer_client.c).
//! Connects to a running `nsigner` 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 signer::{result_to_line, AlgOptions, NsignerSigner};
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 = nsigner::socket_name::list_sockets();
if sockets.is_empty() {
println!("no nsigner 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 = NsignerSigner::new(rpc::NsignerClient::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))
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Low-level JSON-RPC 2.0 client — framed send/recv over a transport.
//!
//! Port of the C `nsigner_client_t` / `nsigner_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 nsigner RPC client. Owns the transport.
pub struct NsignerClient {
transport: ClientTransport,
last_error: String,
next_id: u64,
}
impl NsignerClient {
/// Wrap an open transport.
pub fn new(transport: ClientTransport) -> Self {
NsignerClient {
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 = NsignerClient {
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 = NsignerClient {
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"));
}
}
+318
View File
@@ -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 [`NsignerClient`],
//! and parses the typed result.
use serde_json::{json, Value};
use super::rpc::NsignerClient;
/// 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 nsigner signer. Wraps a low-level client and holds selector +
/// auth state used across typed verbs.
pub struct NsignerSigner {
pub client: NsignerClient,
pub selector: Selector,
pub auth_privkey: Option<String>,
pub auth_label: String,
}
impl NsignerSigner {
pub fn new(client: NsignerClient) -> Self {
NsignerSigner {
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"}"#);
}
}
+199
View File
@@ -0,0 +1,199 @@
//! Client transport — unified framed I/O over Unix / TCP / Serial / Qrexec.
//!
//! Port of the C `nsigner_transport_t` vtable. All four transports share the
//! same `send_framed` / `recv_framed` path after construction (defined in
//! [`nsigner::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 = nsigner::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) => nsigner::transport::send_framed(s, payload),
ClientTransport::Tcp(s) => nsigner::transport::send_framed(s, payload),
ClientTransport::Serial(f) => nsigner::transport::send_framed(f, payload),
ClientTransport::Qrexec { stdin, .. } => {
nsigner::transport::send_framed(stdin, payload)
}
}
}
/// Receive a framed JSON message.
pub fn recv(&mut self) -> io::Result<String> {
match self {
ClientTransport::Unix(s) => nsigner::transport::recv_framed(s),
ClientTransport::Tcp(s) => nsigner::transport::recv_framed(s),
ClientTransport::Serial(f) => nsigner::transport::recv_framed(f),
ClientTransport::Qrexec { stdout, .. } => {
nsigner::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 = nsigner::socket_name::list_sockets();
if sockets.is_empty() {
return Err("no nsigner sockets found. Is nsigner running?".into());
}
if sockets.len() > 1 {
let mut msg = String::from(
"multiple nsigner 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))
}
+1 -1
View File
@@ -31,4 +31,4 @@ pub mod error;
pub use error::NsignerError;
/// Version string (matches C NSIGNER_VERSION).
pub const VERSION: &str = "v0.0.10";
pub const VERSION: &str = "v0.0.11";