3 Commits
9 changed files with 1989 additions and 886 deletions
Generated
+1 -1
View File
@@ -1490,7 +1490,7 @@ dependencies = [
[[package]]
name = "nsigner"
version = "0.0.1"
version = "0.0.4"
dependencies = [
"base64",
"chacha20poly1305",
+1 -1
View File
@@ -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"
+797 -2
View File
@@ -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
View File
@@ -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 110 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 110
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
View File
@@ -31,4 +31,4 @@ pub mod error;
pub use error::NsignerError;
/// Version string (matches C NSIGNER_VERSION).
pub const VERSION: &str = "v0.0.2";
pub const VERSION: &str = "v0.0.5";
+14 -71
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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,