Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cd64032ee | ||
|
|
ea11bb5a87 | ||
|
|
1a48f40b99 |
Generated
+1
-1
@@ -1490,7 +1490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nsigner"
|
||||
version = "0.0.1"
|
||||
version = "0.0.4"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nsigner"
|
||||
version = "0.0.2"
|
||||
version = "0.0.5"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Attended Nostr signing daemon — Rust port of n_signer"
|
||||
|
||||
@@ -1,3 +1,798 @@
|
||||
# signer
|
||||
# nsigner
|
||||
|
||||
A Rust port of the [n_signer](https://github.com/lt/n_signer) project.
|
||||
`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.
|
||||
|
||||
It runs in the foreground, attached to your terminal. The terminal is the trust anchor and control surface. Nothing touches disk at runtime. If the process crashes or exits, all in-memory state is gone.
|
||||
|
||||
This is a **program, not a daemon**:
|
||||
|
||||
- no hidden background process
|
||||
- no detached service lifecycle
|
||||
- no runtime config or state files
|
||||
- no persistence to recover after compromise
|
||||
|
||||
## 1. What it is
|
||||
|
||||
`nsigner` is one binary that combines:
|
||||
|
||||
- mnemonic handling (BIP-39)
|
||||
- role selection and derivation (BIP-32 / SLIP-0010)
|
||||
- purpose/curve enforcement
|
||||
- request dispatch
|
||||
- interactive terminal UI (ratatui)
|
||||
- transport adapter(s) (Unix socket, qrexec, FIPS/TCP, HTTP, stdio)
|
||||
- OTP one-time pad encryption (optional, with USB pad)
|
||||
- post-quantum algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
|
||||
|
||||
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
|
||||
|
||||
## 2. Security model
|
||||
|
||||
### 2.1 Zero filesystem footprint
|
||||
|
||||
At runtime, `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.
|
||||
|
||||
### 2.2 Crash = total wipe
|
||||
|
||||
All sensitive and operational state exists only in-process RAM (mlock'd where applicable): mnemonic-derived key material, role table, activity display buffer. If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design. Sensitive buffers are zeroized with [`zeroize`](src/secure_mem.rs) on drop.
|
||||
|
||||
### 2.3 Single binary, no external runtime dependencies
|
||||
|
||||
The release build (`opt-level = "z"`, `lto = true`, `panic = "abort"`, `strip = true`) produces one optimized binary. No shared libraries are required at runtime beyond the system libc.
|
||||
|
||||
### 2.4 Always-attended operation
|
||||
|
||||
`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.
|
||||
|
||||
### 2.5 Secret memory backing: `mlock`
|
||||
|
||||
Sensitive buffers (mnemonic, master seed, per-role private keys) live in `mlock`'d RAM and are zeroized on free. This gives swap protection and crash-wipe semantics on every supported platform, including Qubes OS Xen guests.
|
||||
|
||||
## 3. How it works
|
||||
|
||||
### 3.1 Startup phase (TUI input mode)
|
||||
|
||||
When started interactively, `nsigner` immediately enters a startup popup:
|
||||
|
||||
1. **Seed entry popup** — enter an existing BIP-39 mnemonic, or type `g` to generate a fresh 12-word mnemonic (displayed numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then press Enter to continue).
|
||||
2. On successful load, derive keys for any pre-registered roles (the default `main` role is auto-registered), start the server with the default transport (Unix), and transition to the main screen.
|
||||
3. Invalid mnemonics show an error line and retry (max 10 attempts).
|
||||
|
||||
No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash.
|
||||
|
||||
For parent-process launchers, startup can also be non-interactive:
|
||||
|
||||
- `--mnemonic-stdin`: read one mnemonic line from stdin at startup, then continue normally.
|
||||
- `--mnemonic-fd N`: read one mnemonic line from inherited file descriptor `N` at startup.
|
||||
|
||||
These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. Non-interactive modes run headless (no TUI).
|
||||
|
||||
### 3.2 Running phase (main screen)
|
||||
|
||||
After unlock, the terminal becomes a live status and control console. The title `Signer v0.0.1` is centered on its own line. Below it, collapsed-border panes show:
|
||||
|
||||
- **Information** — signer name, transport addresses (Unix / Qube / FIPS / HTTP), OTP pad status.
|
||||
- **Transport** — four toggle lines (`[x] U̲nix Socket`, `[ ] Qube b̲ridge`, `[ ] F̲IPS`, `[ ] H̲TTP`). The `[x]` / `[ ]` indicator shows on/off state. The underlined key letter toggles a transport on/off. The last active transport cannot be disabled. Toggling restarts the server immediately.
|
||||
- **Roles** — table of registered roles (Role, Purpose, Curve). `A̲dd` opens the add-role popup; `D̲elete` removes the selected role immediately.
|
||||
- **Activity** — scrollable, newest first. Each entry is a timestamped log line in the format `<caller_id> <curve> <key_path>`. `Cl̲ear` wipes the log.
|
||||
- **Bottom bar** — `He̲lp Q̲uit`.
|
||||
|
||||
Command hints show only the word with the key letter underlined (e.g. "Quit" with Q underlined, "Qube bridge" with B underlined). The underlined letter is the actual key binding, which may not be the first letter.
|
||||
|
||||
**Focus and activation:** `Tab` / `Shift-Tab` cycles focus through all nine commands on the main screen — the four transports, Add, Delete, Clear, Help, Quit. The focused command is reverse-highlighted. `Enter` activates the focused command. Pressing a command's underlined key letter fires it directly regardless of focus. `Up`/`Down` (or `j`/`k`) scroll the activity log.
|
||||
|
||||
### 3.3 Help screen
|
||||
|
||||
Press `L` from the main screen to open a full-screen, scrollable Help overlay describing what the app does, what transports are, what roles are, and listing the key commands. A Navigation section at the end explains how to leave the screen:
|
||||
|
||||
- `Up`/`Down` or `j`/`k` — scroll
|
||||
- `Page Up` / `Page Down` — scroll by a page
|
||||
- `ESC` or `B` — back to the main screen
|
||||
- `Q` — quit the program
|
||||
|
||||
The bottom bar shows `Ba̲ck Q̲uit` so the exit path is always visible.
|
||||
|
||||
### 3.4 Add-role popup
|
||||
|
||||
Press `A` from the main screen to open the add-role popup. The flow is:
|
||||
|
||||
1. **Preset menu** — choose from 10 presets (Standard Nostr, hardened range, agent range, SSH, Age, ML-DSA-65, SLH-DSA-128s, ML-KEM-768, OTP, Custom).
|
||||
2. **Name entry** — InputField pre-filled with a default role name.
|
||||
3. **Curve select** — custom roles only.
|
||||
4. **Path entry** — InputField pre-filled with the preset's derivation path template.
|
||||
5. **OTP dir / name** — OTP roles only.
|
||||
6. **Confirm** — register the role, derive its key immediately, and return to the main screen automatically.
|
||||
|
||||
`ESC` at any stage cancels and returns to the main screen.
|
||||
|
||||
### 3.5 Shutdown
|
||||
|
||||
- `Q` (or `ESC` on the main screen) quits the process: all session state is destroyed.
|
||||
- Terminal close or process termination has the same effect: total state wipe.
|
||||
|
||||
## 4. API
|
||||
|
||||
`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.
|
||||
|
||||
### 4.1 Request format
|
||||
|
||||
```json
|
||||
{ "id": "<string>", "method": "<verb>", "params": [ <arg0>, <arg1>, ..., { <options> } ] }
|
||||
```
|
||||
|
||||
- `id` — caller-supplied string echoed verbatim in the response. Used to match requests to responses.
|
||||
- `method` — the verb name (see [§4.3](#43-verbs)).
|
||||
- `params` — a JSON array. Positional arguments come first; the **last array element** is conventionally an options object. The options object is optional for most verbs.
|
||||
|
||||
### 4.2 Response format
|
||||
|
||||
Success:
|
||||
```json
|
||||
{ "id": "<string>", "result": <value> }
|
||||
```
|
||||
|
||||
`result` is a JSON string. For structured verbs the string is itself a serialized JSON object — clients should `JSON.parse` it.
|
||||
|
||||
Error:
|
||||
```json
|
||||
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
|
||||
```
|
||||
|
||||
Error codes:
|
||||
|
||||
| Code | Message | Meaning |
|
||||
|-------|-------------------------------|--------------------------------------------------------------------|
|
||||
| -32700| `parse_error` | Request was not valid JSON. |
|
||||
| -32600| `invalid_request` | Missing `id`, `method`, or `params`, or `params` is not an array. |
|
||||
| -32601| `method_not_found` | Unknown verb, or verb not valid for the selected algorithm. |
|
||||
| -32602| `invalid_params` | Malformed arguments (bad hex, wrong length, missing field, etc.). |
|
||||
| 1001 | `ambiguous_role_selector` | More than one role selector was supplied. |
|
||||
| 1002 | `unknown_role` | No role matched the selector. |
|
||||
| 1003 | `no_default_role` | No selector given and no `main` role exists. |
|
||||
| 1004 | `purpose_mismatch` | Role's purpose is not valid for this verb. |
|
||||
| 1005 | `curve_mismatch` | Role's curve is not valid for this verb. |
|
||||
| 1006 | `mnemonic_not_loaded` | No mnemonic is loaded in the signer. |
|
||||
| 1007 | `no_termination_condition` | `nostr_mine_event` called without `difficulty` or `timeout_sec`. |
|
||||
| 1008 | `mining_failed` | Internal error during proof-of-work mining. |
|
||||
| 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. |
|
||||
| 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. |
|
||||
| 2003 | `path_not_allowed` | `role_path` does not match any registered role or allowed path. |
|
||||
| 2005 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
|
||||
| 2008 | `role_required` | `role` is required when using `role_path`. |
|
||||
| 2009 | `path_required` | `role_path` is required for roles with variable path templates. |
|
||||
|
||||
### 4.3 Verbs
|
||||
|
||||
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `role` + `role_path` and implement Nostr-protocol-specific serialization on top of the raw crypto.
|
||||
|
||||
| Verb | Algorithms | Positional params | Options |
|
||||
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
|
||||
| `get_info` | n/a (metadata) | — | — |
|
||||
| `get_public_key` | all key-deriving algorithms | — | `algorithm`, `index` |
|
||||
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>` | `algorithm`, `index`, `scheme`* |
|
||||
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>`, `<signature_hex>` | `algorithm`, `index`, `scheme`* |
|
||||
| `encapsulate` | ml-kem-768 | `<peer_pubkey_hex>` | `algorithm` |
|
||||
| `decapsulate` | ml-kem-768 | `<ciphertext_hex>` | `algorithm`, `index` |
|
||||
| `derive_shared_secret` | x25519 | `<peer_pubkey_hex>` | `algorithm`, `index` |
|
||||
| `derive` | secp256k1 | `<data>` | `algorithm`, `index` (required) |
|
||||
| `encrypt` | otp | `<plaintext_base64>` | `algorithm`, `encoding` |
|
||||
| `decrypt` | otp | `<ciphertext>` | `algorithm`, `encoding` |
|
||||
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `role`, `role_path`, `format` |
|
||||
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path` |
|
||||
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path`, `difficulty`, `timeout_sec`, `threads` |
|
||||
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
|
||||
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
|
||||
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
|
||||
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
|
||||
|
||||
\* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`.
|
||||
|
||||
#### Enforcement matrix
|
||||
|
||||
| Verb | Valid algorithms |
|
||||
|----------------------------|-----------------------------------------------|
|
||||
| `sign` / `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s |
|
||||
| `encapsulate` / `decapsulate` | ml-kem-768 |
|
||||
| `derive_shared_secret` | x25519 |
|
||||
| `derive` | secp256k1 |
|
||||
| `encrypt` / `decrypt` | otp |
|
||||
| `get_public_key` | all key-deriving algorithms |
|
||||
| `nostr_*` | secp256k1 (Nostr protocol) |
|
||||
|
||||
Any unlisted `(verb, algorithm)` pair is rejected with `algorithm_not_supported_for_verb` (1010).
|
||||
|
||||
### 4.4 Algorithms
|
||||
|
||||
All keys derive deterministically from the loaded BIP-39 mnemonic. The caller selects an algorithm by name and a derivation `index` (an integer `<n>` substituted into the algorithm's derivation path). OTP is the exception — it does not derive a key, it consumes a bound one-time pad (see [§4.4.3](#443-otp)).
|
||||
|
||||
#### 4.4.1 Algorithm table
|
||||
|
||||
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|
||||
|-----------------|-----------------|---------------|---------------------------------------|-------------------------------|
|
||||
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
|
||||
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
||||
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
|
||||
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0/0'` → DRBG | 4032 / 1952 |
|
||||
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 |
|
||||
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 |
|
||||
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
|
||||
|
||||
#### 4.4.2 Key derivation
|
||||
|
||||
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
|
||||
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
|
||||
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces the RNG during keygen. Same mnemonic, same index, same key pair every time. The PQ implementations are the pure-Rust crates [`ml-dsa`](https://crates.io/crates/ml-dsa), [`ml-kem`](https://crates.io/crates/ml-kem), and [`slh-dsa`](https://crates.io/crates/slh-dsa). The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
|
||||
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
|
||||
|
||||
#### 4.4.3 OTP
|
||||
|
||||
The `otp` algorithm is a stream-style one-time pad, not a key-derivation scheme. It is selected like any other algorithm via `{"algorithm":"otp"}` and works with the `encrypt` / `decrypt` verbs. One pad per session; the pad offset advances monotonically across requests and is reported in every response.
|
||||
|
||||
### 4.5 Examples
|
||||
|
||||
#### `get_info`
|
||||
|
||||
Returns signer metadata: `name`, `implementation`, `version`, `api`, and the supported `verbs` / `algorithms` arrays. Safe to call before the mnemonic is loaded — clients use it to feature-detect.
|
||||
|
||||
```json
|
||||
{ "id": "0", "method": "get_info", "params": [] }
|
||||
```
|
||||
|
||||
#### `get_public_key`
|
||||
|
||||
```json
|
||||
{ "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "1", "result": "{\"algorithm\":\"ml-dsa-65\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}" }
|
||||
```
|
||||
|
||||
`key_id` is the first 16 hex characters of the public key — a short display identifier.
|
||||
|
||||
#### `sign`
|
||||
|
||||
```json
|
||||
{ "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "2", "result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\",\"key_id\":\"<16 hex>\"}" }
|
||||
```
|
||||
|
||||
The first positional argument is the message as hex. For `secp256k1` the `scheme` option selects `"schnorr"` (default, BIP-340) or `"ecdsa"`:
|
||||
|
||||
```json
|
||||
{ "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] }
|
||||
```
|
||||
|
||||
#### `verify`
|
||||
|
||||
```json
|
||||
{ "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "4", "result": "{\"valid\":true,\"algorithm\":\"ed25519\"}" }
|
||||
```
|
||||
|
||||
The signer derives its own public key from `(algorithm, index)` and verifies against it. To verify an arbitrary third-party key, use a client-side library.
|
||||
|
||||
#### `encapsulate` (ML-KEM-768)
|
||||
|
||||
```json
|
||||
{ "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "5", "result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
|
||||
```
|
||||
|
||||
`peer_pubkey_hex` is the recipient's ML-KEM-768 public key (1184 bytes → 2368 hex chars). Send the returned `ciphertext` to the recipient; both sides end up with the same `shared_secret`.
|
||||
|
||||
#### `decapsulate` (ML-KEM-768)
|
||||
|
||||
```json
|
||||
{ "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
|
||||
```
|
||||
|
||||
#### `derive_shared_secret` (X25519)
|
||||
|
||||
```json
|
||||
{ "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "7", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"x25519\"}" }
|
||||
```
|
||||
|
||||
`peer_pubkey_hex` is the peer's 32-byte X25519 public key (64 hex chars). Feed the returned `shared_secret` into your own symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305).
|
||||
|
||||
#### `derive` (secp256k1 HMAC-SHA256)
|
||||
|
||||
```json
|
||||
{ "id": "10", "method": "derive", "params": [ "<data>", { "algorithm": "secp256k1", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "10", "result": "{\"algorithm\":\"secp256k1\",\"key_id\":\"<16hex>\",\"digest\":\"<64hex>\"}" }
|
||||
```
|
||||
|
||||
Computes `HMAC-SHA256(privkey, data)` where `privkey` is the secp256k1 private key derived on demand at `(algorithm: "secp256k1", index: N)`. `data` is an arbitrary caller-supplied UTF-8 string. Returns the 32-byte digest as 64 lowercase hex chars.
|
||||
|
||||
`index` is **required** (no default) — forces conscious selection of which derived key to use as the HMAC key. Omitting it returns `invalid_params`.
|
||||
|
||||
This is a generic key-derived MAC primitive. Callers domain-separate by prefixing their own label into `data` (e.g. `"myapp/identifier-v1:<path>"`). The private key never leaves the signer; only the digest is returned. Use cases include deterministic, per-user, opaque identifiers for NIP-33 parameterized-replaceable events (e.g. bookmark folder `d` tags) where the same logical name must produce the same `d` tag across devices.
|
||||
|
||||
#### `encrypt` / `decrypt` (OTP)
|
||||
|
||||
```json
|
||||
{ "id": "8", "method": "encrypt", "params": [ "<plaintext_base64>", { "algorithm": "otp", "encoding": "ascii" } ] }
|
||||
{ "id": "9", "method": "decrypt", "params": [ "<ciphertext>", { "algorithm": "otp", "encoding": "ascii" } ] }
|
||||
```
|
||||
|
||||
`encoding` is `"ascii"` (ASCII-armored, default) or `"binary"` (base64-encoded raw `.otp` blob). If omitted on `decrypt`, auto-detection by magic bytes is used.
|
||||
|
||||
`encrypt` response:
|
||||
```json
|
||||
{
|
||||
"id": "8",
|
||||
"result": "{\"ciphertext\":\"<ascii-armor-or-base64-blob>\",\"encoding\":\"ascii\",\"pad_chksum\":\"<64 hex>\",\"pad_offset_before\":288,\"pad_offset_after\":416}"
|
||||
}
|
||||
```
|
||||
|
||||
`decrypt` response:
|
||||
```json
|
||||
{ "id": "9", "result": "{\"plaintext\":\"<base64>\",\"pad_chksum\":\"<64 hex>\"}" }
|
||||
```
|
||||
|
||||
If no pad is bound at startup, the error is `-32601` `method_not_found` (otp_pad_not_bound).
|
||||
|
||||
#### `nostr_get_public_key`
|
||||
|
||||
```json
|
||||
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "role": "main" } ] }
|
||||
```
|
||||
|
||||
Response (default): a plain 64-hex-char secp256k1 public key string.
|
||||
Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`.
|
||||
|
||||
#### `nostr_sign_event`
|
||||
|
||||
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
|
||||
|
||||
```json
|
||||
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`<event_json>` is the unsigned event object:
|
||||
```json
|
||||
{ "pubkey": "...", "created_at": 1234567890, "kind": 1, "tags": [], "content": "hello" }
|
||||
```
|
||||
|
||||
Response: the signed event JSON string, with `id` and `sig` populated.
|
||||
|
||||
#### `nostr_mine_event`
|
||||
|
||||
Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step. Mining runs in a detached thread so the signer stays responsive.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "12",
|
||||
"method": "nostr_mine_event",
|
||||
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "role": "main" } ]
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Required | Default | Meaning |
|
||||
|---------------|-------------------------------|---------|---------------------------------------------------------------|
|
||||
| `difficulty` | one of `difficulty`/`timeout` | 0 | Target leading zero bits. Stops early if reached. |
|
||||
| `timeout_sec` | one of `difficulty`/`timeout` | 600 | Time budget in seconds. Always returns the best event found. |
|
||||
| `threads` | no | 1 | Mining threads (clamped to 1..32). |
|
||||
|
||||
At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when **either** condition is met. Timeout is never an error — the best event found is always returned.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "12",
|
||||
"result": "{\"event\":\"<signed event JSON with nonce tag>\",\"achieved_difficulty\":18,\"target_difficulty\":20,\"target_reached\":false,\"elapsed_sec\":30,\"attempts\":4523456}"
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
- `1007` `no_termination_condition` — neither `difficulty` nor `timeout_sec` given.
|
||||
- `1008` `mining_failed` — internal mining error.
|
||||
|
||||
#### `nostr_nip04_encrypt` / `nostr_nip04_decrypt`
|
||||
|
||||
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
|
||||
|
||||
```json
|
||||
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
|
||||
|
||||
#### `nostr_nip44_encrypt` / `nostr_nip44_decrypt`
|
||||
|
||||
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
|
||||
|
||||
```json
|
||||
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
|
||||
|
||||
### 4.6 Role-based selectors (Nostr verbs)
|
||||
|
||||
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Supported selectors:
|
||||
|
||||
| Selector | Meaning |
|
||||
|----------------|--------------------------------------------------|
|
||||
| `role` | Name of a pre-registered role entry (required) |
|
||||
| `role_path` | Full BIP-44 derivation path (required) |
|
||||
|
||||
**Selector resolution**: both `role` and `role_path` are required together — they form a single combined selector. The server verifies that the supplied `role_path` matches the role's registered template (expanding any wildcard). There is no resolution order and no default role: omitting either field is rejected (`2008 role_required` / `2009 path_required`). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
|
||||
|
||||
#### Named path-roles
|
||||
|
||||
In the add-role popup, you define **named path-roles** that bind a role name (which acts as an access token for clients) to a derivation path template. The derivation path template is hidden from clients — they only know the role name and send the full concrete `role_path` with each request.
|
||||
|
||||
The popup presents a **preset menu** of 10 options covering the common role types. You can still define custom roles manually via the "Custom path" option.
|
||||
|
||||
```
|
||||
Preset menu:
|
||||
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
|
||||
2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
|
||||
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
|
||||
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
|
||||
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
|
||||
6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
|
||||
7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
|
||||
8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
|
||||
9. OTP role (one-time pad encryption)
|
||||
10. Custom path
|
||||
```
|
||||
|
||||
Purpose is auto-detected from the path prefix (e.g. `m/44'/1237'` → nostr, `m/44'/102001'` → ssh). The path template is pre-filled from the chosen preset and can be edited inline.
|
||||
|
||||
**Path template syntax:**
|
||||
- **Wildcard**: `m/44'/1237'/*'/0'/0'` — any non-negative integer, hardened. No range limit.
|
||||
- **Range**: `m/44'/1237'/0-3/1/0` — index 0..3, hardened if segment ends with `'` (e.g. `0-3'`)
|
||||
- **Set**: `m/44'/1237'/1+34+54/1/0` — specific indices 1, 34, 54
|
||||
- **Fixed path**: `m/44'/1237'/0'/0/0` — no variable segment, single fixed key
|
||||
- The first segment that is a plain number, range (`N-M`), set (`A+B+C`), or wildcard (`*`) becomes the variable. Segments with `'` (like `44'`, `1237'`) are treated as literal hardened constants.
|
||||
|
||||
The role name itself acts as a password: any caller that knows the role name and supplies a matching `role_path` is served without attendant interaction.
|
||||
|
||||
Clients request keys by supplying both `role` and the full concrete `role_path`:
|
||||
|
||||
```json
|
||||
{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/0'/1/0"}]}
|
||||
```
|
||||
|
||||
→ derives `m/44'/1237'/0'/1/0`, verified against the `myrole` template.
|
||||
|
||||
```json
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/5'/1/0"}]}
|
||||
```
|
||||
|
||||
→ `2003 path_not_allowed` (5 is outside the registered template, if the template was a fixed path or limited range).
|
||||
|
||||
```json
|
||||
{"id":"3","method":"nostr_get_public_key","params":[{"role":"unknown","role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
|
||||
→ `1002 unknown_role` (name not registered).
|
||||
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{"role":"myrole"}]}
|
||||
```
|
||||
|
||||
→ `2009 path_required` (`role_path` is required).
|
||||
|
||||
```json
|
||||
{"id":"5","method":"nostr_get_public_key","params":[{"role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
|
||||
→ `2008 role_required` (`role` is required when using `role_path`).
|
||||
|
||||
### 4.7 Non-interactive role registration
|
||||
|
||||
Roles can be registered from the command line with `--register-role` (repeatable), avoiding the popup entirely. The spec format is `<name>:<curve>:<path-template>`. If `<curve>` is empty, the curve is auto-detected from the path prefix.
|
||||
|
||||
```bash
|
||||
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'
|
||||
```
|
||||
|
||||
If no `--register-role` is given in non-interactive mode, a default `main` role (`m/44'/1237'/0'/0/0`, secp256k1, nostr) is auto-registered.
|
||||
|
||||
## 5. Transports
|
||||
|
||||
The API is transport-independent. The same JSON request works over every transport; only the framing differs.
|
||||
|
||||
| Transport | `--listen` flag | Framing | Caller identity |
|
||||
|-----------|--------------------------------|------------------------------------------|----------------------------|
|
||||
| Unix socket (abstract) | `unix` (default on desktop) | Length-prefixed framed JSON | `SO_PEERCRED` → `uid:<n>` |
|
||||
| stdio | `stdio` | One framed request/response over stdin/stdout | inherited uid |
|
||||
| qrexec | `qrexec` | Same as stdio; caller from `QREXEC_REMOTE_DOMAIN` | `qubes:<vm>` |
|
||||
| FIPS/TCP | `tcp:[host]:port` | Length-prefixed framed JSON | (transport-defined) |
|
||||
| HTTP | `http:host:port` | Standard HTTP POST, JSON body, no custom framing. CORS enabled. | (transport-defined) |
|
||||
|
||||
### 5.1 HTTP examples
|
||||
|
||||
Start the signer:
|
||||
```bash
|
||||
nsigner --listen http:127.0.0.1:11111 --mnemonic-stdin
|
||||
```
|
||||
|
||||
Get a public key:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
|
||||
```
|
||||
|
||||
Sign a Nostr event:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
```
|
||||
|
||||
OTP encrypt:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"algorithm":"otp","encoding":"ascii"}]}'
|
||||
```
|
||||
|
||||
### 5.2 Unix socket examples (framed mode)
|
||||
|
||||
```bash
|
||||
# get_public_key
|
||||
nsigner --socket-name nsigner01 client \
|
||||
'{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
|
||||
|
||||
# Sign a Nostr event
|
||||
nsigner --socket-name nsigner01 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 \
|
||||
'{"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.
|
||||
|
||||
Properties: no pathname in filesystem; endpoint lifetime bound to process/kernel namespace; no stale socket files; caller identity via peer credentials (`SO_PEERCRED`); per-launch random name avoids collisions and leaks no seed-derived identifier.
|
||||
|
||||
Naming rules:
|
||||
- Default: random pick at startup, displayed in the Information section.
|
||||
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name.
|
||||
|
||||
Discovery:
|
||||
- `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.
|
||||
- `--bridge-source-trusted` (unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed `{"qrexec_source":"<vm>"}` preamble before the request, and the caller identity is composed as `qubes:<vm>`.
|
||||
|
||||
### 5.4 Caller verification
|
||||
|
||||
Every transport must provide concrete caller identity before policy evaluation.
|
||||
|
||||
- Linux AF_UNIX: map peer credentials to caller identity.
|
||||
- Relay session: bind remote peer/session identity before allowing signer verbs.
|
||||
|
||||
Identity verification and the role-name-as-password gate are separate layers. Passing identity checks does not bypass the role-name requirement.
|
||||
|
||||
## 6. Platform targets
|
||||
|
||||
### 6.1 Linux desktop (primary)
|
||||
|
||||
Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
|
||||
|
||||
### 6.2 Qubes OS qube
|
||||
|
||||
Qubes deployment runs `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).
|
||||
|
||||
Three transport paths are supported:
|
||||
|
||||
**FIPS/TCP** — the signer listens on `tcp:[::]:11111` and FIPS carries traffic between qubes as an IPv6 mesh substrate.
|
||||
|
||||
**HTTP** — the signer listens on `http:127.0.0.1:11111` for curl-friendly access within the same qube. No auth envelopes required (relies on localhost binding + the role-name-as-password gate).
|
||||
|
||||
**Qubes qrexec bridge** (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless `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>`.
|
||||
|
||||
#### Qrexec bridge setup
|
||||
|
||||
**In the signer qube** (`nostr_signer`):
|
||||
```bash
|
||||
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
```
|
||||
|
||||
**From a caller qube** (via the qrexec service):
|
||||
```bash
|
||||
nsigner bridge --to nsigner
|
||||
```
|
||||
|
||||
## 7. Usage
|
||||
|
||||
### 7.1 Run the program
|
||||
|
||||
```bash
|
||||
nsigner
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Qrexec bridge mode (stateless relay to a persistent signer's unix socket):
|
||||
```bash
|
||||
nsigner bridge --to nsigner
|
||||
```
|
||||
|
||||
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
|
||||
```bash
|
||||
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
```
|
||||
|
||||
### 7.2 Send a request (client mode)
|
||||
|
||||
The `nsigner client` subcommand sends a hand-built JSON-RPC object over the socket:
|
||||
|
||||
```bash
|
||||
nsigner --socket-name nsigner01 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 -
|
||||
```
|
||||
|
||||
If only one signer is running you can omit the `--socket-name` override and the client will use the default discovery rule.
|
||||
|
||||
### 7.3 List running signers
|
||||
|
||||
```bash
|
||||
nsigner list
|
||||
```
|
||||
|
||||
Prints the names of any currently running `nsigner` instances, e.g.:
|
||||
```text
|
||||
nsigner_hairy_dog
|
||||
nsigner_brave_canyon
|
||||
```
|
||||
|
||||
### 7.4 Example session
|
||||
|
||||
Terminal A:
|
||||
```text
|
||||
$ nsigner
|
||||
nsigner v0.0.2
|
||||
[seed entry popup → enter mnemonic]
|
||||
[main screen shows: signer name nsigner_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"}]}'
|
||||
{"id":"2","result":"<signed_event_json>"}
|
||||
```
|
||||
|
||||
## 8. Build
|
||||
|
||||
### 8.1 Dependencies
|
||||
|
||||
The build expects the local [`nostr_core_lib_rust`](../nostr_core_lib_rust) checkout (sibling directory) for the `nostr-core` and `nostr-nips` path dependencies, and the vendored [`ratatui`](ratatui) submodule for the TUI.
|
||||
|
||||
```bash
|
||||
git submodule update --init ratatui
|
||||
```
|
||||
|
||||
### 8.2 Local dev build
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
./target/debug/nsigner --version
|
||||
```
|
||||
|
||||
### 8.3 Release build
|
||||
|
||||
The release profile is tuned for a small, optimized, stripped binary:
|
||||
|
||||
```toml
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
./target/release/nsigner --version
|
||||
```
|
||||
|
||||
### 8.4 Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
## 9. Project layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| [`src/main.rs`](src/main.rs:1) | CLI parsing, subcommands (`client`, `bridge`, `list`), server startup |
|
||||
| [`src/lib.rs`](src/lib.rs:1) | Crate root, module declarations, `VERSION` |
|
||||
| [`src/tui.rs`](src/tui.rs:1) | ratatui TUI: seed-entry popup, main screen, add-role popup, help overlay |
|
||||
| [`src/server.rs`](src/server.rs:1) | Multi-transport server with poll loop, caller identity, request framing |
|
||||
| [`src/dispatcher.rs`](src/dispatcher.rs:1) | Verb dispatch and JSON-RPC response construction |
|
||||
| [`src/role_table.rs`](src/role_table.rs:1) | Role registry, path-template parsing, purpose/curve enforcement |
|
||||
| [`src/selector.rs`](src/selector.rs:1) | Role selector resolution (`role` + `role_path`) |
|
||||
| [`src/enforcement.rs`](src/enforcement.rs:1) | Verb/algorithm/purpose/curve enforcement matrix |
|
||||
| [`src/key_store.rs`](src/key_store.rs:1) | BIP-32 / SLIP-0010 key derivation and storage |
|
||||
| [`src/mnemonic.rs`](src/mnemonic.rs:1) | BIP-39 mnemonic loading and seed derivation |
|
||||
| [`src/pq_crypto.rs`](src/pq_crypto.rs:1) | Post-quantum keygen (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) |
|
||||
| [`src/pq_drbg.rs`](src/pq_drbg.rs:1) | SHAKE-256 DRBG for PQ keygen |
|
||||
| [`src/alg_cache.rs`](src/alg_cache.rs:1) | Per-algorithm derived-key cache |
|
||||
| [`src/otp_pad.rs`](src/otp_pad.rs:1) | One-time pad binding, offset tracking, encrypt/decrypt |
|
||||
| [`src/miner.rs`](src/miner.rs:1) | NIP-13 proof-of-work mining for `nostr_mine_event` |
|
||||
| [`src/auth_envelope.rs`](src/auth_envelope.rs:1) | Auth envelope verification and nonce cache |
|
||||
| [`src/transport.rs`](src/transport.rs:1) | Length-prefixed framing, abstract Unix socket connect |
|
||||
| [`src/http.rs`](src/http.rs:1) | HTTP listener transport |
|
||||
| [`src/socket_name.rs`](src/socket_name.rs:1) | Random socket-name generation and discovery |
|
||||
| [`src/secure_mem.rs`](src/secure_mem.rs:1) | `mlock` / `zeroize` helpers |
|
||||
| [`src/error.rs`](src/error.rs:1) | `NsignerError` enum |
|
||||
| [`ratatui/`](ratatui:1) | Vendored ratatui submodule (TUI framework) |
|
||||
| [`plans/`](plans:1) | Design and migration plans |
|
||||
|
||||
## 10. Differences from the C `n_signer`
|
||||
|
||||
This Rust port preserves the security model and API of the original C `n_signer` while differing in implementation details:
|
||||
|
||||
- **Language**: Rust instead of C. Memory safety is enforced by the type system; sensitive buffers use [`zeroize`](https://crates.io/crates/zeroize) instead of hand-rolled `secure_memzero`.
|
||||
- **PQ crypto**: pure-Rust crates (`ml-dsa`, `ml-kem`, `slh-dsa`) instead of vendored PQClean C.
|
||||
- **TUI**: [`ratatui`](https://github.com/ratatui/ratatui) instead of the vendored `tui_continuous` C component. The TUI flow is redesigned (see [`plans/tui_flow_redesign.md`](plans/tui_flow_redesign.md:1)): a single main screen with a startup seed-entry popup, an add-role popup, and a scrollable Help overlay, replacing the original linear setup wizard.
|
||||
- **Approval prompts**: the C version's interactive approval prompts (`y/n/e/a`) are not yet ported; the Rust port uses the role-name-as-password gate as the primary access control. Interactive approval is a future addition.
|
||||
- **No firmware targets**: the C project also targets ESP32 / MCU hardware via TinyUSB. This Rust port targets Linux desktop and Qubes OS only.
|
||||
- **No `--preapprove` flag**: pre-approval entries are not yet implemented in the Rust port.
|
||||
- **No `--allow-all` flag**: the Rust port does not carry the C project's `--allow-all` development shortcut; access control is the role-name-as-password gate plus caller identity.
|
||||
|
||||
+81
-63
@@ -125,26 +125,26 @@ the C-format log entries (newest first).
|
||||
│ Information │ Activity │
|
||||
│ │ │
|
||||
│ signer name: nsigner01 │ 2026-08-18 15:05:42 unix:1000 │
|
||||
│ Unix address: │ sign_event(main) ALLOWED:no-auth │
|
||||
│ Unix address: │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ nsigner01 │ 2026-08-18 15:05:30 unix:1000 │
|
||||
│ Qube address: │ get_info() ALLOWED:no-auth │
|
||||
│ Qube address: │ - - │
|
||||
│ (inactive) │ 2026-08-18 15:04:55 unix:1000 │
|
||||
│ FIPS address: │ nip44_encrypt(main) ALLOWED:no-auth │
|
||||
│ FIPS address: │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ (inactive) │ │
|
||||
│ HTTP address: │ │
|
||||
│ (inactive) │ │
|
||||
│ OTP pad: chksum=a1b2c3 offset=128/4096 │ │
|
||||
│ │ nip44_encrypt(main) ALLOWED:no-auth │
|
||||
├──────────────────────────────────────────┤ 2026-08-18 15:03:12 unix:1000 │
|
||||
│ Transport │ sign_event(nostr_range,0) │
|
||||
│ │ ALLOWED:no-auth │
|
||||
│ ▸ [x] U̲nix Socket │ 2026-08-18 15:02:00 unix:1000 │
|
||||
│ [ ] Qube b̲ridge │ get_info() ALLOWED:no-auth │
|
||||
│ [ ] F̲IPS │ 2026-08-18 15:01:30 unix:1000 │
|
||||
│ [ ] H̲TTP │ sign_event(main) ALLOWED:no-auth │
|
||||
│ │ 2026-08-18 15:00:22 unix:1000 │
|
||||
├──────────────────────────────────────────┤ sign_event(main) ALLOWED:no-auth │
|
||||
│ Roles │ 2026-08-18 15:00:10 nsigner started │
|
||||
│ │ 2026-08-18 15:03:12 unix:1000 │
|
||||
├──────────────────────────────────────────┤ secp256k1 m/44'/1237'/*'/0/0 │
|
||||
│ Transport │ 2026-08-18 15:02:00 unix:1000 │
|
||||
│ │ - - │
|
||||
│ ▸ [x] U̲nix Socket │ 2026-08-18 15:01:30 unix:1000 │
|
||||
│ [ ] Qube b̲ridge │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ [ ] F̲IPS │ 2026-08-18 15:00:22 unix:1000 │
|
||||
│ [ ] H̲TTP │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ │ 2026-08-18 15:00:10 nsigner started │
|
||||
├──────────────────────────────────────────┤ │
|
||||
│ Roles │ │
|
||||
│ │ │
|
||||
│ Role Purpose Curve │ │
|
||||
│ ───────────── ──────── ──────────── │ │
|
||||
@@ -165,7 +165,8 @@ Each section has its own commands on the bottom line, left-aligned:
|
||||
command letter is underlined in each label (`U̲nix Socket`,
|
||||
`Q̲ube bridge`, `F̲IPS`, `H̲TTP`). Tab or Up/Down moves between
|
||||
lines, Enter or the underlined key toggles that transport on/off
|
||||
(radio-button: one active at a time, server restarts immediately).
|
||||
(independent checkbox: toggling one only flips itself; the last
|
||||
active transport cannot be disabled; server restarts immediately).
|
||||
Active transport is also shown in bold/reversed.
|
||||
- **Roles**: `A̲dd D̲elete` — add a new role, delete the selected role
|
||||
- **Activity**: `Cl̲ear` — clear the activity log (with a blank row
|
||||
@@ -195,8 +196,9 @@ indicator plus the transport name with the key letter underlined
|
||||
(`U̲nix Socket`, `Qube b̲ridge`, `F̲IPS`, `H̲TTP`). No separate
|
||||
command line — each line is its own toggle. Tab or Up/Down moves
|
||||
between lines, Enter or the underlined key letter toggles that
|
||||
transport on/off (radio-button: one active at a time, server
|
||||
restarts immediately). Active transport also shown in bold/reversed.
|
||||
transport on/off (independent checkbox: toggling one only flips
|
||||
itself; the last active transport cannot be disabled; server restarts
|
||||
immediately). Active transport also shown in bold/reversed.
|
||||
|
||||
Key assignments (all unique across the main screen — this is the
|
||||
canonical set shown in the mockup):
|
||||
@@ -224,32 +226,28 @@ bottom: `Cl̲ear`.
|
||||
|
||||
### Activity log format
|
||||
|
||||
The current Rust implementation logs only "request handled" — it does
|
||||
not show what was actually handled. The C implementation logs detailed
|
||||
request information. Copy the C format:
|
||||
Each activity entry shows four fields: `time uid curve path`. The
|
||||
timestamp is added by `ActivityLog::add()`; the message itself is
|
||||
`uid curve path`.
|
||||
|
||||
```
|
||||
<caller_id> <method>(<role_name>[,<concrete_path>]) <verdict>:<source_label>
|
||||
<caller_id> <curve> <key_path>
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `unix:1000 sign_event(main) ALLOWED:no-auth`
|
||||
- `unix:1000 sign_event(nostr_range,0) ALLOWED:no-auth`
|
||||
- `unix:1000 get_info() ALLOWED:no-auth`
|
||||
- `tcp:[::1]:12345 nip44_encrypt(main) ALLOWED:no-auth`
|
||||
- `unix:1000 secp256k1 m/44'/1237'/0'/0/0`
|
||||
- `unix:1000 ed25519 m/44'/102001'/0'/0'/0'`
|
||||
- `unix:1000 - -` (get_info / algorithm verbs — no role)
|
||||
|
||||
**Implementation:** `ServerContext::process_request` must return an
|
||||
activity description string alongside the JSON response. Change the
|
||||
return type to `(String, String)` — the response and the activity log
|
||||
message. The activity message is constructed from:
|
||||
**Implementation:** `ServerContext::process_request` returns
|
||||
`(String, String)` — the response and the activity log message. The
|
||||
activity message is constructed from:
|
||||
- `caller.caller_id` — e.g. `unix:1000` or `tcp:[::1]:12345`
|
||||
- `method` — the JSON-RPC method (e.g. `sign_event`, `get_info`)
|
||||
- `role_name` — from the resolved selector (if a role was matched)
|
||||
- `concrete_path` — if the role has a path template with `%d`, the
|
||||
concrete index (e.g. `0`)
|
||||
- `verdict` — `ALLOWED` (since we removed policy, all valid requests
|
||||
are allowed; denied requests get `DENIED` with the error reason)
|
||||
- `source_label` — `no-auth` (since we removed policy/authorization)
|
||||
- `curve` — the role's curve string (e.g. `secp256k1`, `ed25519`)
|
||||
- `key_path` — the role's derivation path via `RoleEntry::display_path()`
|
||||
(e.g. `m/44'/1237'/0'/0/0` or `m/44'/1237'/*'/0/0 [0-99]`)
|
||||
- For requests without a role (get_info, algorithm verbs, OTP), the
|
||||
curve and path are `-`.
|
||||
|
||||
The `service_server()` method in `App` passes this message to
|
||||
`activity_log.add()` instead of the generic "request handled".
|
||||
@@ -263,13 +261,17 @@ selected role.
|
||||
- **Add** (`A`): opens the `AddRole` popup showing the role preset
|
||||
menu (same 1–10 presets as current wizard). Select a preset (or
|
||||
custom), then enter role name and path template via `InputField`
|
||||
with pre-filled defaults. On confirm, register the role and derive
|
||||
its key immediately, then return to the main screen.
|
||||
with pre-filled defaults. On confirm, register the role, derive its
|
||||
key immediately, and return to the main screen automatically — no
|
||||
extra Enter needed.
|
||||
- **Delete** (`D`): deletes the currently selected role immediately —
|
||||
no confirmation overlay. The role is removed from the table and its
|
||||
derived key is wiped. The selection moves to the next role.
|
||||
- **Select**: Up/Down arrows or Tab move selection through the role
|
||||
list. The `▸` cursor shows the selected role.
|
||||
- **Columns**: Role, Purpose, Curve, Key path (derivation path via
|
||||
`RoleEntry::display_path()`, e.g. `m/44'/1237'/0'/0/0` or
|
||||
`m/44'/1237'/*'/0/0 [0-99]`).
|
||||
|
||||
### Help screen
|
||||
|
||||
@@ -383,9 +385,12 @@ pub enum RoleAddStage {
|
||||
## Server changes
|
||||
|
||||
Currently `ServerContext` supports only one listen mode at a time. The
|
||||
transport screen uses radio-button behavior: only one transport can be
|
||||
active at a time. Toggling one on turns the others off. This matches
|
||||
the current C behavior and avoids server architecture changes.
|
||||
transport toggles are independent checkboxes — toggling one only flips
|
||||
itself, and multiple transports can be checked at once. The server
|
||||
listens on the first active transport by priority (Unix > Qrexec > TCP
|
||||
> HTTP). The last active transport cannot be disabled. This avoids
|
||||
server architecture changes while allowing the user to select which
|
||||
transport is active.
|
||||
|
||||
Additionally, `process_request` must return an activity log message
|
||||
alongside the JSON response (see "Activity log format" above). Change
|
||||
@@ -480,7 +485,7 @@ and deleted directly in the Roles section.
|
||||
and Roles are sections on Main, Lock is removed entirely, Commands
|
||||
replaced by Help.)
|
||||
|
||||
2. **Update `App` struct** — remove wizard fields, add `cmd_cursor`,
|
||||
2. **Update `App` struct** — remove wizard fields, add `help_scroll`,
|
||||
`role_cursor`, `transport_cursor`, `seed_generate_mode`,
|
||||
`RoleAddStage` enum and fields. Update `App::new` to start on
|
||||
`Screen::SeedEntry`.
|
||||
@@ -496,8 +501,10 @@ and deleted directly in the Roles section.
|
||||
collapsed borders. Left column has three sections: Information (top),
|
||||
Transport (middle), Roles (bottom). Right column has Activity
|
||||
(scrollable, newest first). Information section shows "signer name"
|
||||
(renamed from "client name") + transport addresses. Remove the
|
||||
Connections sub-panel.
|
||||
(renamed from "client name") + transport addresses (each on an
|
||||
indented row beneath the label, without `@` for Unix). Remove the
|
||||
Connections sub-panel. Roles and Activity sections have a blank row
|
||||
above their command lines at the bottom.
|
||||
|
||||
5. **Implement Transport section on main screen** — renders 4
|
||||
toggle-button lines, each showing `[x]` or `[ ]` indicator plus
|
||||
@@ -506,8 +513,9 @@ and deleted directly in the Roles section.
|
||||
selected line (`transport_cursor`). Active transport is also shown
|
||||
in bold/reversed. Tab/Up/Down moves between lines, Enter or the
|
||||
underlined key letter (`U`/`B`/`F`/`H`) toggles that transport
|
||||
(radio-button: one active at a time, toggling restarts the server).
|
||||
No separate command line for this section. All key commands on the
|
||||
(independent checkbox: only flips itself; last active cannot be
|
||||
disabled; toggling restarts the server). No separate command line
|
||||
for this section. All key commands on the
|
||||
main screen must be unique: U, B, F, H (transport), A, D (roles),
|
||||
C (clear activity), L (help), Q (quit).
|
||||
|
||||
@@ -517,7 +525,16 @@ and deleted directly in the Roles section.
|
||||
currently selected role immediately (no confirmation), `↑`/`↓`/Tab
|
||||
moves the cursor. On add/delete, re-derive keys.
|
||||
|
||||
7. **Implement Help screen** — `draw_help()` renders a scrollable
|
||||
7. **Implement AddRole popup** — `draw_add_role()` renders a centered
|
||||
popup over the Main screen showing the role preset menu (same 1–10
|
||||
presets as current wizard). `handle_add_role_key()` processes the
|
||||
multi-stage flow: `PresetMenu` → `NameEntry` (InputField with
|
||||
pre-filled default) → `CurveSelect` (custom only) → `PathEntry`
|
||||
(InputField with pre-filled default) → `OtpDir`/`OtpName` (OTP
|
||||
only) → `Confirm`. On confirm, register the role, derive its key,
|
||||
and return to `Screen::Main`. ESC cancels and returns to Main.
|
||||
|
||||
8. **Implement Help screen** — `draw_help()` renders a scrollable
|
||||
`Paragraph` describing what the app does, what transports are, what
|
||||
roles are, and listing the key commands at the end. Track a
|
||||
`help_scroll` offset. `handle_help_key()`: Up/Down (and Page
|
||||
@@ -525,14 +542,14 @@ and deleted directly in the Roles section.
|
||||
shown when content overflows. This is a reference screen — no
|
||||
actions executed from here.
|
||||
|
||||
8. **Update key command bars** — each screen's bottom bar shows the
|
||||
relevant key bindings for that screen. Use underlined-first-letter
|
||||
word hints (e.g. `Q̲uit`, `R̲oles`) instead of "Q quit" style.
|
||||
Replace the old `key_span` helper with a new `cmd_hint` helper that
|
||||
produces a `Span` with the key command letter underlined (which may
|
||||
not be the first letter of the word).
|
||||
9. **Update key command bars** — each section's bottom line shows the
|
||||
relevant key bindings for that section. Use underlined-key-letter
|
||||
word hints (e.g. `Q̲uit`, `He̲lp`, `A̲dd`, `D̲elete`, `Cl̲ear`)
|
||||
instead of "Q quit" style. Replace the old `key_span` helper with a
|
||||
new `cmd_hint` helper that produces a `Span` with the key command
|
||||
letter underlined (which may not be the first letter of the word).
|
||||
|
||||
9. **Update activity log format** — change `ServerContext::process_request`
|
||||
10. **Update activity log format** — change `ServerContext::process_request`
|
||||
to return `(String, String)` (response + activity message). Construct
|
||||
the activity message from `caller_id`, `method`, `role_name`,
|
||||
`concrete_path`, `verdict`, and `source_label` matching the C format:
|
||||
@@ -541,23 +558,24 @@ and deleted directly in the Roles section.
|
||||
`service_server()` in `App` to log this message instead of
|
||||
"request handled".
|
||||
|
||||
10. **Update `run()` loop** — service server only on `Main` screen
|
||||
(all overlay screens pause server processing). Update the
|
||||
`handle_key` dispatch for the new screen enum.
|
||||
11. **Update `run()` loop** — service server only on `Main` screen
|
||||
(overlay screens `AddRole` and `Help` pause server processing).
|
||||
Update the `handle_key` dispatch for the new screen enum.
|
||||
|
||||
11. **Update `main.rs`** — adjust `App::new` call if needed. The
|
||||
12. **Update `main.rs`** — adjust `App::new` call if needed. The
|
||||
`listen_override` path: if `--listen` is given, skip seed entry
|
||||
popup and go straight to main with the specified transport. But
|
||||
still need a mnemonic — so `--listen` with interactive mode should
|
||||
still show the seed entry popup, then go to main with the transport
|
||||
pre-selected.
|
||||
|
||||
12. **Test** — `cargo test` (unit tests unaffected). Manual test:
|
||||
13. **Test** — `cargo test` (unit tests unaffected). Manual test:
|
||||
start signer, verify seed entry popup, verify main screen with
|
||||
collapsed borders and centered title, verify Roles add/remove,
|
||||
verify Transport toggle (4 lines, tab navigation), verify Help
|
||||
screen shows all key commands, verify activity log shows detailed
|
||||
request info, connect with `nsigner_client`.
|
||||
collapsed borders and centered title, verify Roles add/delete,
|
||||
verify AddRole popup preset menu flow, verify Transport toggle
|
||||
(4 lines, tab navigation), verify Help screen is scrollable and
|
||||
shows app description + key commands, verify activity log shows
|
||||
detailed request info, connect with `nsigner_client`.
|
||||
|
||||
## What stays the same
|
||||
|
||||
|
||||
+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.2";
|
||||
pub const VERSION: &str = "v0.0.5";
|
||||
|
||||
+14
-71
@@ -134,16 +134,16 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
}
|
||||
|
||||
let interactive = !cli.mnemonic_stdin && cli.mnemonic_fd.is_none();
|
||||
let listen_override = cli.listen.as_deref().map(parse_listen_mode);
|
||||
let listen_mode = cli.listen.as_deref().map(parse_listen_mode);
|
||||
|
||||
// Non-interactive (--mnemonic-stdin / --mnemonic-fd) always runs headless,
|
||||
// even for Unix mode — the TUI needs a real TTY.
|
||||
if !interactive {
|
||||
return run_headless(cli, listen_override.unwrap_or(ListenMode::Unix));
|
||||
return run_headless(cli, listen_mode.unwrap_or(ListenMode::Unix));
|
||||
}
|
||||
|
||||
// Headless modes never show a TUI.
|
||||
if let Some(mode) = listen_override {
|
||||
if let Some(mode) = listen_mode {
|
||||
if mode != ListenMode::Unix {
|
||||
return run_headless(cli, mode);
|
||||
}
|
||||
@@ -157,8 +157,12 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
});
|
||||
let auth_mode = parse_auth_mode(&cli.auth);
|
||||
|
||||
if interactive && listen_override.is_none() {
|
||||
if interactive {
|
||||
// ── Fully interactive: App handles everything ────────────
|
||||
// The seed entry popup is always shown; if --listen was given
|
||||
// (Unix only — non-Unix modes are headless above), the transport
|
||||
// is pre-selected on the main screen. Pass the raw --listen string
|
||||
// so the App can adopt an explicit tcp:/http: bind address.
|
||||
let mut app = nsigner::tui::App::new(
|
||||
RoleTable::new(),
|
||||
MnemonicState::new(),
|
||||
@@ -167,77 +171,16 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
socket_name,
|
||||
0,
|
||||
auth_mode,
|
||||
None,
|
||||
cli.listen.clone(),
|
||||
);
|
||||
let mut terminal = ratatui::init();
|
||||
let result = app.run(&mut terminal);
|
||||
ratatui::restore();
|
||||
return result.map_err(|e| NsignerError::IoFailed(e.to_string()));
|
||||
}
|
||||
|
||||
// ── Non-interactive setup: mnemonic + roles ──────────────────
|
||||
let mut mnemonic = MnemonicState::new();
|
||||
|
||||
if cli.mnemonic_stdin {
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
let phrase = input.trim().to_string();
|
||||
mnemonic.load(&phrase)?;
|
||||
} else if let Some(fd) = cli.mnemonic_fd {
|
||||
use std::io::Read;
|
||||
use std::os::unix::io::FromRawFd;
|
||||
let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||
let mut input = String::new();
|
||||
file.read_to_string(&mut input)
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
let phrase = input.trim().to_string();
|
||||
mnemonic.load(&phrase)?;
|
||||
}
|
||||
|
||||
// ── Role table ────────────────────────────────────────────────
|
||||
let mut role_table = RoleTable::new();
|
||||
|
||||
if !cli.register_role.is_empty() {
|
||||
for spec in &cli.register_role {
|
||||
register_role_from_spec(&mut role_table, spec)?;
|
||||
}
|
||||
result.map_err(|e| NsignerError::IoFailed(e.to_string()))
|
||||
} else {
|
||||
role_table
|
||||
.register_role_path(
|
||||
"main",
|
||||
"m/44'/1237'/0'/0/0",
|
||||
RolePurpose::Nostr,
|
||||
RoleCurve::Secp256k1,
|
||||
-1, -1, -1, &[],
|
||||
)
|
||||
.map_err(|e| NsignerError::Internal(e.to_string()))?;
|
||||
// Unreachable: non-interactive modes return headless above.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Key store & algorithm cache ───────────────────────────────
|
||||
let mut key_store = KeyStore::new();
|
||||
let alg_key_cache = AlgorithmKeyCache::new();
|
||||
|
||||
// ── Derive keys ──────────────────────────────────────────────
|
||||
let derived_count = key_store.derive_all(&mut role_table, &mnemonic)?;
|
||||
|
||||
// ── Unix mode: TUI main screen (skip setup screens) ───────────
|
||||
let mode = listen_override.unwrap_or(ListenMode::Unix);
|
||||
let mut app = nsigner::tui::App::new(
|
||||
role_table,
|
||||
mnemonic,
|
||||
key_store,
|
||||
alg_key_cache,
|
||||
socket_name,
|
||||
derived_count,
|
||||
auth_mode,
|
||||
Some(mode),
|
||||
);
|
||||
let mut terminal = ratatui::init();
|
||||
let result = app.run(&mut terminal);
|
||||
ratatui::restore();
|
||||
result.map_err(|e| NsignerError::IoFailed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Run a headless server (stdio, qrexec, tcp, http) — no TUI.
|
||||
@@ -310,8 +253,8 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
|
||||
alg_key_cache: &mut alg_key_cache,
|
||||
};
|
||||
match server.handle_one(&mut dispatcher) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
Ok(Some(_activity)) => {}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
+52
-25
@@ -137,11 +137,12 @@ impl ServerContext {
|
||||
}
|
||||
|
||||
/// Handle one pending connection (non-blocking).
|
||||
/// Returns Ok(true) if handled, Ok(false) if nothing pending.
|
||||
/// Returns Ok(Some(activity_msg)) if a request was handled,
|
||||
/// Ok(None) if nothing pending.
|
||||
pub fn handle_one(
|
||||
&mut self,
|
||||
dispatcher: &mut DispatcherContext,
|
||||
) -> Result<bool, NsignerError> {
|
||||
) -> Result<Option<String>, NsignerError> {
|
||||
if let Some(ref listener) = self.listener {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
@@ -157,29 +158,29 @@ impl ServerContext {
|
||||
|
||||
// Read framed request. A connection with no data yet
|
||||
// (WouldBlock) or an empty/closed probe is not a handled
|
||||
// request — return Ok(false) so we don't log it as handled.
|
||||
// request — return Ok(None) so we don't log it as handled.
|
||||
let request = match crate::transport::recv_framed(&mut reader) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
// Identify caller via SO_PEERCRED
|
||||
let caller = identify_unix_caller(&reader);
|
||||
|
||||
// Process request (role-name-as-password model: no authorization)
|
||||
let response = self.process_request(dispatcher, &request, &caller);
|
||||
let (response, activity) = self.process_request(dispatcher, &request, &caller);
|
||||
|
||||
// Send framed response
|
||||
if let Err(_) = crate::transport::send_framed(&mut writer, &response) {
|
||||
// Client disconnected — ignore
|
||||
}
|
||||
return Ok(true);
|
||||
return Ok(Some(activity));
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false); // Nothing pending
|
||||
return Ok(None); // Nothing pending
|
||||
}
|
||||
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
|
||||
}
|
||||
@@ -204,38 +205,38 @@ impl ServerContext {
|
||||
match crate::http::recv_request(&mut reader) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
} else {
|
||||
match crate::transport::recv_framed(&mut reader) {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
};
|
||||
|
||||
// Process request (role-name-as-password model: no authorization)
|
||||
let response = self.process_request(dispatcher, &request, &caller);
|
||||
let (response, activity) = self.process_request(dispatcher, &request, &caller);
|
||||
|
||||
if self.listen_mode == ListenMode::Http {
|
||||
let _ = crate::http::send_response(&mut writer, &response);
|
||||
} else {
|
||||
let _ = crate::transport::send_framed(&mut writer, &response);
|
||||
}
|
||||
return Ok(true);
|
||||
return Ok(Some(activity));
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Process a request.
|
||||
@@ -251,7 +252,7 @@ impl ServerContext {
|
||||
dispatcher: &mut DispatcherContext,
|
||||
request: &str,
|
||||
caller: &CallerIdentity,
|
||||
) -> String {
|
||||
) -> (String, String) {
|
||||
// ── Auth envelope verification ─────────────────────────────
|
||||
let mut caller = caller.clone();
|
||||
if self.auth_mode != AuthMode::Off {
|
||||
@@ -268,7 +269,9 @@ impl ServerContext {
|
||||
}
|
||||
Err((code, msg)) => {
|
||||
if self.auth_mode == AuthMode::Required {
|
||||
return make_auth_error(&request, code, msg);
|
||||
let response = make_auth_error(&request, code, msg);
|
||||
let activity = format!("{} DENIED:{}", caller.caller_id, msg);
|
||||
return (response, activity);
|
||||
}
|
||||
// Optional: continue without auth
|
||||
}
|
||||
@@ -280,36 +283,60 @@ impl ServerContext {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// Malformed request — let the dispatcher produce the error
|
||||
return crate::dispatcher::handle_request(dispatcher, request);
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} DENIED:malformed", caller.caller_id);
|
||||
return (response, activity);
|
||||
}
|
||||
};
|
||||
|
||||
// get_info is metadata — no key material
|
||||
if method == crate::enforcement::VERB_GET_INFO {
|
||||
return crate::dispatcher::handle_request(dispatcher, request);
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} - -", caller.caller_id);
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
// Algorithm-based verbs (bypass role table) — no authorization
|
||||
if crate::enforcement::is_algorithm_verb(&method) {
|
||||
return self.process_algorithm_verb(dispatcher, request, &selector_req);
|
||||
let response = self.process_algorithm_verb(dispatcher, request, &selector_req);
|
||||
let activity = format!("{} - -", caller.caller_id);
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
// OTP verbs
|
||||
if method == crate::enforcement::VERB_ENCRYPT || method == crate::enforcement::VERB_DECRYPT {
|
||||
return crate::dispatcher::handle_request(dispatcher, request);
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} - -", caller.caller_id);
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
// ── Resolve role selector (the "password" check) ───────────
|
||||
// If the role name doesn't exist, the request is rejected here.
|
||||
let _role_index = match selector_resolve(&selector_req, dispatcher.role_table) {
|
||||
let role_index = match selector_resolve(&selector_req, dispatcher.role_table) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
return make_selector_error(&request, e);
|
||||
let response = make_selector_error(&request, e);
|
||||
let activity = format!(
|
||||
"{} {}() DENIED:{}",
|
||||
caller.caller_id,
|
||||
method,
|
||||
e.as_str()
|
||||
);
|
||||
return (response, activity);
|
||||
}
|
||||
};
|
||||
|
||||
// Role entry from the resolved selector — used for the activity
|
||||
// message (curve + key path).
|
||||
let role_entry = &dispatcher.role_table.entries[role_index];
|
||||
let curve = role_entry.curve_str.clone();
|
||||
let path = role_entry.display_path();
|
||||
|
||||
// ── Dispatch ───────────────────────────────────────────────
|
||||
crate::dispatcher::handle_request(dispatcher, request)
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
// Activity format: uid curve path (timestamp is added by the log).
|
||||
let activity = format!("{} {} {}", caller.caller_id, curve, path);
|
||||
(response, activity)
|
||||
}
|
||||
|
||||
/// Process an algorithm-based verb.
|
||||
|
||||
+1040
-720
File diff suppressed because it is too large
Load Diff
@@ -71,8 +71,8 @@ fn spawn_server_loop(
|
||||
alg_key_cache: &mut alg_cache,
|
||||
};
|
||||
match server.handle_one(&mut dispatcher) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
Ok(Some(_activity)) => {}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(_) => break,
|
||||
|
||||
Reference in New Issue
Block a user