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:
Generated
+1
-1
@@ -1490,7 +1490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nsigner"
|
||||
version = "0.0.9"
|
||||
version = "0.0.10"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
|
||||
+5
-1
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"}"#);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user