From 1a48f40b994f503deb0f8b4e5b1985d923daa324 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Wed, 19 Aug 2026 09:51:33 -0400 Subject: [PATCH] v0.0.3 - TUI: unified Tab-focus model, content-sized popups, correct FIPS address, mandatory role-name entry, Help screen exit fix, adapted README --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 799 ++++++++++++++++- plans/tui_flow_redesign.md | 144 +-- src/lib.rs | 2 +- src/main.rs | 85 +- src/server.rs | 77 +- src/tui.rs | 1747 +++++++++++++++++++++--------------- tests/integration.rs | 4 +- 9 files changed, 1976 insertions(+), 886 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f45128e..8863c44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1490,7 +1490,7 @@ dependencies = [ [[package]] name = "nsigner" -version = "0.0.1" +version = "0.0.2" dependencies = [ "base64", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index d0db013..d39c536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nsigner" -version = "0.0.2" +version = "0.0.3" edition = "2021" license = "MIT" description = "Attended Nostr signing daemon — Rust port of n_signer" diff --git a/README.md b/README.md index 73acf2a..b1373ae 100644 --- a/README.md +++ b/README.md @@ -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 ` `. `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": "", "method": "", "params": [ , , ..., { } ] } +``` + +- `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": "", "result": } +``` + +`result` is a JSON string. For structured verbs the string is itself a serialized JSON object — clients should `JSON.parse` it. + +Error: +```json +{ "id": "", "error": { "code": , "message": "" } } +``` + +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 | `` | `algorithm`, `index`, `scheme`* | +| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | ``, `` | `algorithm`, `index`, `scheme`* | +| `encapsulate` | ml-kem-768 | `` | `algorithm` | +| `decapsulate` | ml-kem-768 | `` | `algorithm`, `index` | +| `derive_shared_secret` | x25519 | `` | `algorithm`, `index` | +| `derive` | secp256k1 | `` | `algorithm`, `index` (required) | +| `encrypt` | otp | `` | `algorithm`, `encoding` | +| `decrypt` | otp | `` | `algorithm`, `encoding` | +| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `role`, `role_path`, `format` | +| `nostr_sign_event` | secp256k1 (NIP-06) | `` | `role`, `role_path` | +| `nostr_mine_event` | secp256k1 (NIP-06) | `` | `role`, `role_path`, `difficulty`, `timeout_sec`, `threads` | +| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | ``, `` | `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. diff --git a/plans/tui_flow_redesign.md b/plans/tui_flow_redesign.md index 6e77a8b..4bcae32 100644 --- a/plans/tui_flow_redesign.md +++ b/plans/tui_flow_redesign.md @@ -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 diff --git a/src/lib.rs b/src/lib.rs index 94635f2..55fac55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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.3"; diff --git a/src/main.rs b/src/main.rs index 7677994..ce79b2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) => { diff --git a/src/server.rs b/src/server.rs index 208788a..548f103 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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. diff --git a/src/tui.rs b/src/tui.rs index 816cbe5..5eff4f9 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,21 +1,23 @@ //! Terminal UI — ratatui-based interactive signer TUI. //! -//! Provides the setup screens (unlock, role wizard, transport selection), -//! the main status display (Information / Roles / Activity / Commands), -//! the connections screen, and the lock screen. +//! Provides a single main status screen (Information / Transport / Roles / +//! Activity) with a startup seed-entry popup, an add-role popup, and a +//! full-screen Help overlay. Borders are collapsed for a cleaner look and +//! command hints show only the word with the key letter underlined. use crate::dispatcher::DispatcherContext; use crate::key_store::KeyStore; use crate::mnemonic::MnemonicState; -use crate::role_table::{RoleCurve, RolePurpose, RoleSelectorType, RoleTable}; +use crate::role_table::{RoleCurve, RolePurpose, RoleTable}; use crate::server::{AuthMode, ListenMode, ServerContext}; use crate::NsignerError; -use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::layout::{Constraint, Layout, Rect, Spacing}; use ratatui::style::{Modifier, Style}; +use ratatui::symbols::merge::MergeStrategy; use ratatui::text::{Line, Span}; use ratatui::widgets::{ - Block, Borders, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, + Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, }; use ratatui::Frame; @@ -32,6 +34,39 @@ pub const TRANSPORT_QREXEC_BRIDGE: u8 = 0x02; pub const TRANSPORT_TCP: u8 = 0x04; pub const TRANSPORT_HTTP: u8 = 0x08; +/// Number of focusable commands on the main screen: +/// 4 transports + Add + Delete + Clear + Help + Quit. +pub const FOCUS_COUNT: usize = 9; + +/// Look up the local FIPS node identity by running `fipsctl show status`. +/// Returns the node's `npub` (a Nostr public-key identifier) when both +/// `npub` and `ipv6_addr` are present in the JSON output, mirroring the +/// C `lookup_local_fips_identity` in n_signer/src/main.c. Returns `None` +/// if `fipsctl` is unavailable or the output is not usable. +fn lookup_local_fips_identity() -> Option<String> { + let output = std::process::Command::new("fipsctl") + .args(["show", "status"]) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + let npub = json.get("npub")?.as_str()?; + let ipv6 = json.get("ipv6_addr")?.as_str()?; + if npub.is_empty() || ipv6.is_empty() { + return None; + } + Some(npub.to_string()) +} + +/// Extract the port suffix from a `host:port` bind address. +fn extract_listen_port(addr: &str) -> String { + match addr.rsplit_once(':') { + Some((_, port)) if !port.is_empty() => port.to_string(), + _ => String::new(), + } +} + +#[allow(dead_code)] fn transport_str(mask: u8) -> String { let mut parts = Vec::new(); if mask & TRANSPORT_UNIX != 0 { @@ -144,29 +179,25 @@ fn format_timestamp(epoch: u64) -> String { /// The current screen the TUI is showing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { - /// Unlock — mnemonic source (g = generate, else paste/enter) - Unlock, - /// Generated mnemonic display (press Enter to continue) - GenerateMnemonic, - /// Role wizard — preset menu and role entry - RoleWizard, - /// Transport selection — checkbox toggle - TransportSelection, - /// Main status display + /// Startup popup — seed phrase entry + SeedEntry, + /// Startup popup — showing generated mnemonic + SeedDisplay, + /// Main status screen (Information + Transport + Roles + Activity + bottom bar) Main, - /// Connection instructions - Connections, - /// Lock — re-enter mnemonic - Lock, + /// Add-role popup — role preset selection (over the Main screen) + AddRole, + /// Help — full-screen overlay describing the app and key commands + Help, } -/// Stage within the role wizard. +/// Stage within the add-role popup flow. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WizardStage { +pub(crate) enum RoleAddStage { /// Preset menu selection (1-10) - SelectPreset, + PresetMenu, /// Role name entry (pre-filled) - RoleName, + NameEntry, /// Curve selection (custom only) CurveSelect, /// Path template entry (custom only, pre-filled) @@ -175,8 +206,6 @@ pub(crate) enum WizardStage { OtpDir, /// OTP pad name entry OtpName, - /// Confirmation + "Define another role?" prompt - ConfirmRegister, } // ──────────────────────────────────────────────────────────────────────────── @@ -327,42 +356,53 @@ pub struct App { pub socket_name: String, pub derived_count: usize, pub auth_mode: AuthMode, - /// If set (via --listen), skip the transport selection screen. - pub listen_override: Option<ListenMode>, + /// If set (via --listen), the raw listen spec (e.g. "tcp:127.0.0.1:11111") + /// used to pre-select the transport and bind address on the main screen. + pub listen_override: Option<String>, - /// Server is created lazily after transport selection. + /// Server is created lazily after the seed is loaded. pub server: Option<ServerContext>, pub screen: Screen, pub should_quit: bool, - // Unlock / lock + // Seed entry pub unlock_input: InputField, - pub unlock_attempts: i32, - pub unlock_mode: bool, // true if mnemonic entry phase (after E choice) + pub seed_attempts: i32, + pub seed_generate_mode: bool, // true while showing the generated mnemonic + pub generated_phrase: String, - // Role wizard - pub(crate) wizard_stage: WizardStage, - pub wizard_choice: i32, - pub wizard_default_name: String, - pub wizard_default_path: String, - pub wizard_default_curve: String, - pub wizard_default_purpose: RolePurpose, - pub wizard_role_name: InputField, - pub wizard_path: InputField, - pub wizard_otp_dir: InputField, - pub wizard_otp_name: InputField, - pub wizard_roles_created: usize, + // Add-role popup + pub(crate) role_add_stage: RoleAddStage, + pub role_add_input: InputField, + pub role_add_choice: i32, + pub role_add_name: String, + pub role_add_default_name: String, + pub role_add_default_path: String, + pub role_add_default_curve: String, + pub role_add_default_purpose: RolePurpose, + /// Saved OTP pad directory between the OtpDir and OtpName stages. + pub role_add_otp_dir: String, - // Transport selection + // Transport pub transport_toggles: [bool; 4], pub transport_mask: u8, + pub transport_cursor: usize, + /// Bind address (host:port) used when the FIPS/TCP transport is active. + pub tcp_bind_addr: String, + /// Bind address (host:port) used when the HTTP transport is active. + pub http_bind_addr: String, - // Generated mnemonic - pub generated_phrase: String, + // Unified command focus. + /// Index of the currently focused command on the main screen. + /// 0-3 = transports (Unix, Qube, FIPS, HTTP), 4 = Add, 5 = Delete, + /// 6 = Clear, 7 = Help, 8 = Quit. Tab cycles forward. + pub focus: usize, // Main screen pub activity_scroll: usize, + pub role_cursor: usize, + pub help_scroll: usize, } impl App { @@ -376,7 +416,7 @@ impl App { socket_name: String, derived_count: usize, auth_mode: AuthMode, - listen_override: Option<ListenMode>, + listen_override: Option<String>, ) -> Self { Self { role_table, @@ -389,47 +429,53 @@ impl App { auth_mode, listen_override, server: None, - screen: Screen::Unlock, + screen: Screen::SeedEntry, should_quit: false, unlock_input: InputField::new(""), - unlock_attempts: 0, - unlock_mode: false, - wizard_stage: WizardStage::SelectPreset, - wizard_choice: 1, - wizard_default_name: "main".to_string(), - wizard_default_path: "m/44'/1237'/0'/0/0".to_string(), - wizard_default_curve: "secp256k1".to_string(), - wizard_default_purpose: RolePurpose::Nostr, - wizard_role_name: InputField::new("main"), - wizard_path: InputField::new("m/44'/1237'/0'/0/0"), - wizard_otp_dir: InputField::new(""), - wizard_otp_name: InputField::new(""), - wizard_roles_created: 0, + seed_attempts: 0, + seed_generate_mode: false, + generated_phrase: String::new(), + role_add_stage: RoleAddStage::PresetMenu, + role_add_input: InputField::new(""), + role_add_choice: 1, + role_add_name: String::new(), + role_add_default_name: "main".to_string(), + role_add_default_path: "m/44'/1237'/0'/0/0".to_string(), + role_add_default_curve: "secp256k1".to_string(), + role_add_default_purpose: RolePurpose::Nostr, + role_add_otp_dir: String::new(), transport_toggles: [true, false, false, false], transport_mask: TRANSPORT_UNIX, - generated_phrase: String::new(), + transport_cursor: 0, + tcp_bind_addr: "127.0.0.1:11111".to_string(), + http_bind_addr: "127.0.0.1:11111".to_string(), + focus: 0, activity_scroll: 0, + role_cursor: 0, + help_scroll: 0, } } /// Run the TUI event loop until quit. pub fn run(&mut self, terminal: &mut ratatui::DefaultTerminal) -> std::io::Result<()> { - // If --listen was given, skip the setup screens and go straight to main. - if let Some(mode) = self.listen_override { - self.transport_toggles = match mode { - ListenMode::Unix => [true, false, false, false], - ListenMode::Qrexec => [false, true, false, false], - ListenMode::Tcp => [false, false, true, false], - ListenMode::Http => [false, false, false, true], - _ => [true, false, false, false], - }; - self.transport_mask_apply(); - if let Err(e) = self.start_server() { - eprintln!("server start failed: {}", e); - return Ok(()); + // If --listen was given, pre-select the transport and adopt any + // explicit bind address. The seed entry popup is still shown; the + // transport is applied when the seed loads. + if let Some(spec) = &self.listen_override { + if let Some(addr) = spec.strip_prefix("tcp:") { + self.tcp_bind_addr = addr.to_string(); + self.transport_toggles = [false, false, true, false]; + } else if let Some(addr) = spec.strip_prefix("http:") { + self.http_bind_addr = addr.to_string(); + self.transport_toggles = [false, false, false, true]; + } else { + match spec.as_str() { + "unix" => self.transport_toggles = [true, false, false, false], + "qrexec" => self.transport_toggles = [false, true, false, false], + _ => self.transport_toggles = [true, false, false, false], + } } - self.activity_log.add("nsigner started"); - self.screen = Screen::Main; + self.transport_mask_apply(); } loop { @@ -442,10 +488,7 @@ impl App { break; } } - } else if matches!( - self.screen, - Screen::Main | Screen::Connections | Screen::Lock - ) { + } else if self.screen == Screen::Main { // No key — service any pending server connection. self.service_server(); } @@ -466,81 +509,100 @@ impl App { alg_key_cache: &mut self.alg_key_cache, }; match server.handle_one(&mut dispatcher) { - Ok(true) => { - self.activity_log.add("request handled"); + Ok(Some(activity)) => { + self.activity_log.add(&activity); } - Ok(false) => {} + Ok(None) => {} Err(e) => { self.activity_log.add(&format!("server error: {}", e)); } } } - /// Start the server after transport selection. + /// Start the server for the currently selected transport. fn start_server(&mut self) -> Result<(), NsignerError> { - let listen_mode = if self.transport_toggles[0] { - ListenMode::Unix + // Pick the first active transport by priority (Unix > Qrexec > TCP > HTTP). + let (listen_mode, server_name) = if self.transport_toggles[0] { + (ListenMode::Unix, self.socket_name.clone()) } else if self.transport_toggles[1] { - ListenMode::Qrexec + (ListenMode::Qrexec, self.socket_name.clone()) } else if self.transport_toggles[2] { - ListenMode::Tcp + // ServerContext::start expects the "tcp:host:port" form. + ( + ListenMode::Tcp, + format!("tcp:{}", self.tcp_bind_addr), + ) } else if self.transport_toggles[3] { - ListenMode::Http + // ServerContext::start expects the "http:host:port" form. + ( + ListenMode::Http, + format!("http:{}", self.http_bind_addr), + ) } else { - ListenMode::Unix + // No transport active — default to Unix so the server keeps running. + (ListenMode::Unix, self.socket_name.clone()) }; - let mut server = ServerContext::new(&self.socket_name, listen_mode, self.auth_mode); + let mut server = ServerContext::new(&server_name, listen_mode, self.auth_mode); server.start()?; self.server = Some(server); Ok(()) } + fn transport_mask_apply(&mut self) { + let mask = self + .transport_toggles + .iter() + .enumerate() + .fold(0u8, |acc, (i, &on)| { + if on { + acc | (1u8 << i) + } else { + acc + } + }); + self.transport_mask = mask; + } + /// Handle a single key event. fn handle_key(&mut self, key: KeyEvent) { match self.screen { - Screen::Unlock => self.handle_unlock_key(key), - Screen::GenerateMnemonic => self.handle_generate_key(key), - Screen::RoleWizard => self.handle_wizard_key(key), - Screen::TransportSelection => self.handle_transport_key(key), - Screen::Main | Screen::Connections => self.handle_main_key(key), - Screen::Lock => self.handle_lock_key(key), + Screen::SeedEntry => self.handle_seed_key(key), + Screen::SeedDisplay => self.handle_seed_display_key(key), + Screen::Main => self.handle_main_key(key), + Screen::AddRole => self.handle_add_role_key(key), + Screen::Help => self.handle_help_key(key), } } - // ── Unlock ────────────────────────────────────────────────────────── + // ── Seed entry ────────────────────────────────────────────────────── - fn handle_unlock_key(&mut self, key: KeyEvent) { + fn handle_seed_key(&mut self, key: KeyEvent) { + // 'g' generates a new mnemonic directly. + if key.code == KeyCode::Char('g') || key.code == KeyCode::Char('G') { + match self.mnemonic.generate(12) { + Ok(phrase) => { + self.generated_phrase = phrase; + self.seed_generate_mode = true; + self.screen = Screen::SeedDisplay; + } + Err(_) => { + self.seed_attempts += 1; + } + } + return; + } if edit_key(&mut self.unlock_input, key) { return; } if key.code == KeyCode::Enter { let input = self.unlock_input.buffer.trim().to_string(); self.unlock_input = InputField::new(""); - if input == "g" || input == "G" { - match self.mnemonic.generate(12) { - Ok(phrase) => { - self.generated_phrase = phrase; - self.screen = Screen::GenerateMnemonic; - } - Err(_) => { - self.unlock_attempts += 1; - } - } - } else if matches!(input.as_str(), "q" | "Q" | "x" | "X") { - self.should_quit = true; - } else if !input.is_empty() { + if !input.is_empty() { match self.mnemonic.load(&input) { - Ok(()) => { - self.derived_count = - match self.key_store.derive_all(&mut self.role_table, &self.mnemonic) { - Ok(n) => n, - Err(_) => 0, - }; - self.screen = Screen::RoleWizard; - } + Ok(()) => self.enter_main(), Err(_) => { - self.unlock_attempts += 1; - if self.unlock_attempts >= 10 { + self.seed_attempts += 1; + if self.seed_attempts >= 10 { self.should_quit = true; } } @@ -549,116 +611,257 @@ impl App { } } - fn handle_generate_key(&mut self, key: KeyEvent) { + fn handle_seed_display_key(&mut self, key: KeyEvent) { if key.code == KeyCode::Enter { - self.derived_count = - match self.key_store.derive_all(&mut self.role_table, &self.mnemonic) { - Ok(n) => n, - Err(_) => 0, - }; - self.screen = Screen::RoleWizard; + self.enter_main(); } } - // ── Role wizard ───────────────────────────────────────────────────── + /// Load the mnemonic, derive keys, start the server, and go to Main. + fn enter_main(&mut self) { + // Ensure at least the default "main" role exists. + if self.role_table.entries.is_empty() { + let _ = self.role_table.register_role_path( + "main", + "m/44'/1237'/0'/0/0", + RolePurpose::Nostr, + RoleCurve::Secp256k1, + -1, + -1, + -1, + &[], + ); + } + self.derived_count = match self.key_store.derive_all(&mut self.role_table, &self.mnemonic) { + Ok(n) => n, + Err(_) => 0, + }; + self.transport_mask_apply(); + match self.start_server() { + Ok(()) => { + self.activity_log.add("nsigner started"); + } + Err(e) => { + self.activity_log.add(&format!("server start failed: {}", e)); + } + } + self.screen = Screen::Main; + } - fn handle_wizard_key(&mut self, key: KeyEvent) { - match self.wizard_stage { - WizardStage::SelectPreset => self.wizard_key_select(key), - WizardStage::RoleName => { - if edit_key(&mut self.wizard_role_name, key) { - return; - } - if key.code == KeyCode::Enter { - self.wizard_after_name(); + // ── Main screen ───────────────────────────────────────────────────── + + fn handle_main_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('l') | KeyCode::Char('L') => { + self.screen = Screen::Help; + } + KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { + self.should_quit = true; + } + KeyCode::Char('a') | KeyCode::Char('A') => { + self.role_add_stage = RoleAddStage::PresetMenu; + self.role_add_choice = 1; + self.screen = Screen::AddRole; + } + KeyCode::Char('d') | KeyCode::Char('D') => { + self.delete_selected_role(); + } + KeyCode::Char('c') | KeyCode::Char('C') => { + self.activity_log.clear(); + self.activity_scroll = 0; + } + KeyCode::Char('u') | KeyCode::Char('U') => self.toggle_transport(0), + KeyCode::Char('b') | KeyCode::Char('B') => self.toggle_transport(1), + KeyCode::Char('f') | KeyCode::Char('F') => self.toggle_transport(2), + KeyCode::Char('h') | KeyCode::Char('H') => self.toggle_transport(3), + // Tab / Shift-Tab cycle focus through all commands on the page. + KeyCode::Tab => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.focus = (self.focus + FOCUS_COUNT - 1) % FOCUS_COUNT; + } else { + self.focus = (self.focus + 1) % FOCUS_COUNT; } } - WizardStage::CurveSelect => self.wizard_key_curve(key), - WizardStage::PathEntry => { - if edit_key(&mut self.wizard_path, key) { - return; - } - if key.code == KeyCode::Enter { - self.wizard_after_path(); + KeyCode::BackTab => { + self.focus = (self.focus + FOCUS_COUNT - 1) % FOCUS_COUNT; + } + // Enter activates whichever command is currently focused. + KeyCode::Enter => self.activate_focused(), + // Up/Down scroll the activity log. + KeyCode::Up | KeyCode::Char('k') => { + if self.activity_scroll > 0 { + self.activity_scroll -= 1; } } - WizardStage::OtpDir => { - if edit_key(&mut self.wizard_otp_dir, key) { + KeyCode::Down | KeyCode::Char('j') => { + self.activity_scroll += 1; + } + _ => {} + } + } + + /// Fire the command currently indicated by `self.focus`. + fn activate_focused(&mut self) { + match self.focus { + 0 => self.toggle_transport(0), + 1 => self.toggle_transport(1), + 2 => self.toggle_transport(2), + 3 => self.toggle_transport(3), + 4 => { + self.role_add_stage = RoleAddStage::PresetMenu; + self.role_add_choice = 1; + self.screen = Screen::AddRole; + } + 5 => self.delete_selected_role(), + 6 => { + self.activity_log.clear(); + self.activity_scroll = 0; + } + 7 => self.screen = Screen::Help, + 8 => self.should_quit = true, + _ => {} + } + } + + /// Toggle a transport (independent checkbox — only flips itself) and + /// restart the server with the new transport mask. The last active + /// transport cannot be toggled off. + fn toggle_transport(&mut self, idx: usize) { + // Refuse to disable the last active transport. + if self.transport_toggles[idx] && self.transport_toggles.iter().filter(|&&on| on).count() == 1 { + self.activity_log.add("at least one transport must remain active"); + return; + } + self.transport_toggles[idx] = !self.transport_toggles[idx]; + self.transport_mask_apply(); + if let Some(mut server) = self.server.take() { + server.stop(); + } + match self.start_server() { + Ok(()) => { + self.activity_log.add("nsigner started"); + } + Err(e) => { + self.activity_log.add(&format!("server start failed: {}", e)); + } + } + } + + /// Delete the currently selected role immediately (no confirmation). + fn delete_selected_role(&mut self) { + let count = self.role_table.entries.len(); + if count == 0 { + return; + } + if self.role_cursor >= count { + self.role_cursor = count - 1; + } + let name = self.role_table.entries[self.role_cursor].name.clone(); + self.role_table.entries.remove(self.role_cursor); + // Re-derive keys (wipes the removed role's key). + self.key_store.wipe(); + let _ = self.key_store.derive_all(&mut self.role_table, &self.mnemonic); + self.activity_log.add(&format!("role '{}' deleted", name)); + if self.role_cursor >= self.role_table.entries.len() && !self.role_table.entries.is_empty() { + self.role_cursor = self.role_table.entries.len() - 1; + } + } + + // ── Add-role popup ────────────────────────────────────────────────── + + fn handle_add_role_key(&mut self, key: KeyEvent) { + if key.code == KeyCode::Esc { + self.screen = Screen::Main; + return; + } + match self.role_add_stage { + RoleAddStage::PresetMenu => self.add_role_key_preset(key), + RoleAddStage::NameEntry => { + if edit_key(&mut self.role_add_input, key) { return; } if key.code == KeyCode::Enter { - let dir = self.wizard_otp_dir.buffer.trim().to_string(); + self.add_role_after_name(); + } + } + RoleAddStage::CurveSelect => self.add_role_key_curve(key), + RoleAddStage::PathEntry => { + if edit_key(&mut self.role_add_input, key) { + return; + } + if key.code == KeyCode::Enter { + self.add_role_after_path(); + } + } + RoleAddStage::OtpDir => { + if edit_key(&mut self.role_add_input, key) { + return; + } + if key.code == KeyCode::Enter { + let dir = self.role_add_input.buffer.trim().to_string(); if dir.is_empty() { - self.wizard_stage = WizardStage::SelectPreset; - } else { - self.wizard_stage = WizardStage::OtpName; + // Stay on this stage until a directory is given. + return; } + // Save the directory; the OtpName stage reuses the input + // field for the pad name. + self.role_add_otp_dir = dir; + self.role_add_input = InputField::new(""); + self.role_add_stage = RoleAddStage::OtpName; } } - WizardStage::OtpName => { - if edit_key(&mut self.wizard_otp_name, key) { + RoleAddStage::OtpName => { + if edit_key(&mut self.role_add_input, key) { return; } if key.code == KeyCode::Enter { - self.wizard_register_otp(); - } - } - WizardStage::ConfirmRegister => { - if key.code == KeyCode::Char('y') || key.code == KeyCode::Char('Y') { - // Define another role - self.wizard_reset_for_new(); - self.wizard_stage = WizardStage::SelectPreset; - } else if key.code == KeyCode::Enter - || key.code == KeyCode::Char('n') - || key.code == KeyCode::Char('N') - { - if self.wizard_roles_created == 0 { - // Mandatory at least one role — stay in wizard - self.wizard_reset_for_new(); - self.wizard_stage = WizardStage::SelectPreset; - } else { - self.screen = Screen::TransportSelection; - } + self.add_role_register_otp(); } } } } - fn wizard_key_select(&mut self, key: KeyEvent) { + fn add_role_key_preset(&mut self, key: KeyEvent) { if let KeyCode::Char(c) = key.code { - if let Some(d) = c.to_digit(10) { - if d >= 1 && d <= 10 { - self.wizard_choice = d as i32; - self.wizard_apply_preset(); - // OTP goes straight to pad dir; custom goes to curve; others go to name - if self.wizard_choice == 9 { - self.wizard_stage = WizardStage::OtpDir; - } else if self.wizard_choice == 10 { - self.wizard_stage = WizardStage::CurveSelect; - } else { - self.wizard_role_name = InputField::new(&self.wizard_default_name); - self.wizard_stage = WizardStage::RoleName; - } - } - } + // Use letters A-J for presets 1-10 (avoids the 1/10 ambiguity). + let choice = match c.to_ascii_uppercase() { + 'A' => 1, + 'B' => 2, + 'C' => 3, + 'D' => 4, + 'E' => 5, + 'F' => 6, + 'G' => 7, + 'H' => 8, + 'I' => 9, + 'J' => 10, + _ => return, + }; + self.role_add_choice = choice; + self.add_role_apply_preset(); + // Every preset — including custom (10) and OTP (9) — asks for + // the role name first. The name is mandatory and cannot be + // skipped, because it is the access token clients use. + self.role_add_input = InputField::new(&self.role_add_default_name); + self.role_add_stage = RoleAddStage::NameEntry; } } - fn wizard_key_curve(&mut self, key: KeyEvent) { + fn add_role_key_curve(&mut self, key: KeyEvent) { if let KeyCode::Char(c) = key.code { if let Some(d) = c.to_digit(10) { if d >= 1 && d <= 6 { - self.wizard_custom_curve(d as i32); - self.wizard_path = InputField::new(&self.wizard_default_path); - self.wizard_stage = WizardStage::PathEntry; + self.add_role_custom_curve(d as i32); + self.role_add_input = InputField::new(&self.role_add_default_path); + self.role_add_stage = RoleAddStage::PathEntry; } } } } - fn wizard_apply_preset(&mut self) { + fn add_role_apply_preset(&mut self) { use crate::role_table::RolePurpose; - let (name, path, curve, purpose) = match self.wizard_choice { + let (name, path, curve, purpose) = match self.role_add_choice { 2 => ( "nostr_range", "m/44'/1237'/*'/0/0", @@ -715,93 +918,107 @@ impl App { RolePurpose::Nostr, ), }; - self.wizard_default_name = name.to_string(); - self.wizard_default_path = path.to_string(); - self.wizard_default_curve = curve.to_string(); - self.wizard_default_purpose = purpose; + self.role_add_default_name = name.to_string(); + self.role_add_default_path = path.to_string(); + self.role_add_default_curve = curve.to_string(); + self.role_add_default_purpose = purpose; } - fn wizard_custom_curve(&mut self, cchoice: i32) { + fn add_role_custom_curve(&mut self, cchoice: i32) { use crate::role_table::RolePurpose; match cchoice { 2 => { - self.wizard_default_curve = "ed25519".into(); - self.wizard_default_path = "m/44'/102001'/0'/0'/0'".into(); - self.wizard_default_purpose = RolePurpose::Ssh; + self.role_add_default_curve = "ed25519".into(); + self.role_add_default_path = "m/44'/102001'/0'/0'/0'".into(); + self.role_add_default_purpose = RolePurpose::Ssh; } 3 => { - self.wizard_default_curve = "x25519".into(); - self.wizard_default_path = "m/44'/102002'/0'/0'/0'".into(); - self.wizard_default_purpose = RolePurpose::Age; + self.role_add_default_curve = "x25519".into(); + self.role_add_default_path = "m/44'/102002'/0'/0'/0'".into(); + self.role_add_default_purpose = RolePurpose::Age; } 4 => { - self.wizard_default_curve = "ml-dsa-65".into(); - self.wizard_default_path = "m/44'/102003'/0'/0'/0'".into(); - self.wizard_default_purpose = RolePurpose::PqSig; + self.role_add_default_curve = "ml-dsa-65".into(); + self.role_add_default_path = "m/44'/102003'/0'/0'/0'".into(); + self.role_add_default_purpose = RolePurpose::PqSig; } 5 => { - self.wizard_default_curve = "slh-dsa-128s".into(); - self.wizard_default_path = "m/44'/102004'/0'/0'/0'".into(); - self.wizard_default_purpose = RolePurpose::PqSig; + self.role_add_default_curve = "slh-dsa-128s".into(); + self.role_add_default_path = "m/44'/102004'/0'/0'/0'".into(); + self.role_add_default_purpose = RolePurpose::PqSig; } 6 => { - self.wizard_default_curve = "ml-kem-768".into(); - self.wizard_default_path = "m/44'/102005'/0'/0'/0'".into(); - self.wizard_default_purpose = RolePurpose::PqKem; + self.role_add_default_curve = "ml-kem-768".into(); + self.role_add_default_path = "m/44'/102005'/0'/0'/0'".into(); + self.role_add_default_purpose = RolePurpose::PqKem; } _ => { - self.wizard_default_curve = "secp256k1".into(); - self.wizard_default_path = "m/44'/1237'/0'/0/0".into(); - self.wizard_default_purpose = RolePurpose::Nostr; + self.role_add_default_curve = "secp256k1".into(); + self.role_add_default_path = "m/44'/1237'/0'/0/0".into(); + self.role_add_default_purpose = RolePurpose::Nostr; } } } - fn wizard_after_name(&mut self) { + fn add_role_after_name(&mut self) { let name = { - let s = self.wizard_role_name.buffer.trim().to_string(); + let s = self.role_add_input.buffer.trim().to_string(); if s.is_empty() { - self.wizard_default_name.clone() + self.role_add_default_name.clone() } else { s } }; - if self.role_table.find_by_name(&name).is_some() { - // Duplicate — back to preset - self.wizard_stage = WizardStage::SelectPreset; + if name.is_empty() { + // No usable name — stay on name entry so the user must supply one. + self.role_add_input = InputField::new(""); return; } - // Store the chosen name for registration - self.wizard_role_name = InputField::new(&name); - if self.wizard_choice == 10 { - self.wizard_stage = WizardStage::PathEntry; - } else { - self.wizard_register_path(&name); + if self.role_table.find_by_name(&name).is_some() { + // Duplicate — back to preset + self.role_add_stage = RoleAddStage::PresetMenu; + return; + } + self.role_add_name = name.clone(); + match self.role_add_choice { + // Custom: pick the curve next, then edit the path. + 10 => { + self.role_add_stage = RoleAddStage::CurveSelect; + } + // OTP: collect the pad directory and pad name next. + 9 => { + self.role_add_input = InputField::new(""); + self.role_add_stage = RoleAddStage::OtpDir; + } + // Presets with a fixed path: register immediately. + _ => { + self.add_role_register_path(&name); + } } } - fn wizard_after_path(&mut self) { + fn add_role_after_path(&mut self) { let path = { - let s = self.wizard_path.buffer.trim().to_string(); + let s = self.role_add_input.buffer.trim().to_string(); if s.is_empty() { - self.wizard_default_path.clone() + self.role_add_default_path.clone() } else { s } }; - let name = self.wizard_role_name.buffer.trim().to_string(); - self.wizard_register_path_with(name, &path); + let name = self.role_add_name.clone(); + self.add_role_register_path_with(name, &path); } /// Register a path-based role (presets and custom). - fn wizard_register_path(&mut self, name: &str) { - let path = self.wizard_default_path.clone(); - self.wizard_register_path_with(name.to_string(), &path); + fn add_role_register_path(&mut self, name: &str) { + let path = self.role_add_default_path.clone(); + self.add_role_register_path_with(name.to_string(), &path); } - fn wizard_register_path_with(&mut self, name: String, path: &str) { - let curve = crate::role_table::RoleCurve::from_str(&self.wizard_default_curve); - let purpose = self.wizard_default_purpose; + fn add_role_register_path_with(&mut self, name: String, path: &str) { + let curve = crate::role_table::RoleCurve::from_str(&self.role_add_default_curve); + let purpose = self.role_add_default_purpose; match crate::role_table::parse_path_template(path) { Ok((template, range_lo, range_hi, allowed_indices)) => { match self.role_table.register_role_path( @@ -815,28 +1032,39 @@ impl App { &allowed_indices, ) { Ok(()) => { - self.wizard_roles_created += 1; self.activity_log .add(&format!("role '{}' registered", name)); + // Derive the new role's key immediately. + if let Some(idx) = + self.role_table.entries.iter().position(|e| e.name == name) + { + let _ = self + .key_store + .derive_one(&mut self.role_table, &self.mnemonic, idx); + } + self.role_cursor = self.role_table.entries.len().saturating_sub(1); + // Return to Main immediately — no extra Enter needed. + self.screen = Screen::Main; } Err(_) => {} } - self.wizard_stage = WizardStage::ConfirmRegister; } Err(_) => { // Invalid path — back to path entry - self.wizard_path = InputField::new(path); - self.wizard_stage = WizardStage::PathEntry; + self.role_add_input = InputField::new(path); + self.role_add_stage = RoleAddStage::PathEntry; } } } - fn wizard_register_otp(&mut self) { - let name = self.wizard_role_name.buffer.trim().to_string(); - let dir = self.wizard_otp_dir.buffer.trim().to_string(); - let spec = self.wizard_otp_name.buffer.trim().to_string(); + fn add_role_register_otp(&mut self) { + let name = self.role_add_name.clone(); + // The pad directory was collected in the OtpDir stage and saved in + // role_add_otp_dir; the input field now holds the pad name/spec. + let dir = self.role_add_otp_dir.clone(); + let spec = self.role_add_input.buffer.trim().to_string(); if dir.is_empty() || spec.is_empty() { - self.wizard_stage = WizardStage::SelectPreset; + // Missing pad name — stay on OtpName so the user can supply it. return; } match crate::otp_pad::bind_global(&dir, &spec, false) { @@ -847,7 +1075,7 @@ impl App { entry.curve_str = "otp".to_string(); entry.purpose = RolePurpose::Nostr; entry.curve = RoleCurve::Unknown; - entry.selector_type = RoleSelectorType::RolePath; + entry.selector_type = crate::role_table::RoleSelectorType::RolePath; entry.role_path = String::new(); entry.nostr_index = -1; entry.path_range_lo = -1; @@ -855,147 +1083,47 @@ impl App { entry.path_default_index = -1; entry.requires_approval = false; if self.role_table.add(entry).is_ok() { - self.wizard_roles_created += 1; self.activity_log .add(&format!("otp role '{}' registered", name)); + self.role_cursor = self.role_table.entries.len().saturating_sub(1); + // Return to Main immediately — no extra Enter needed. + self.screen = Screen::Main; } } Err(_) => {} } - self.wizard_stage = WizardStage::ConfirmRegister; } - fn wizard_reset_for_new(&mut self) { - self.wizard_choice = 1; - self.wizard_default_name = "main".to_string(); - self.wizard_default_path = "m/44'/1237'/0'/0/0".to_string(); - self.wizard_default_curve = "secp256k1".to_string(); - self.wizard_default_purpose = RolePurpose::Nostr; - self.wizard_role_name = InputField::new("main"); - self.wizard_path = InputField::new("m/44'/1237'/0'/0/0"); - self.wizard_otp_dir = InputField::new(""); - self.wizard_otp_name = InputField::new(""); - } + // ── Help screen ───────────────────────────────────────────────────── - // ── Transport selection ───────────────────────────────────────────── - - fn handle_transport_key(&mut self, key: KeyEvent) { + fn handle_help_key(&mut self, key: KeyEvent) { match key.code { - KeyCode::Char('a') | KeyCode::Char('A') => { - self.transport_toggles = [true, true, true, true]; + // ESC / B always returns to the main screen. + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { + self.screen = Screen::Main; } - KeyCode::Char('1') => self.transport_toggles[0] = !self.transport_toggles[0], - KeyCode::Char('2') => self.transport_toggles[1] = !self.transport_toggles[1], - KeyCode::Char('3') => self.transport_toggles[2] = !self.transport_toggles[2], - KeyCode::Char('4') => self.transport_toggles[3] = !self.transport_toggles[3], - KeyCode::Enter => { - let any = self.transport_toggles.iter().any(|&b| b); - if any { - self.transport_mask_apply(); - match self.start_server() { - Ok(()) => { - self.activity_log.add("nsigner started"); - self.screen = Screen::Main; - } - Err(e) => { - self.activity_log - .add(&format!("server start failed: {}", e)); - // Stay on transport screen - } - } - } - } - _ => {} - } - } - - fn transport_mask_apply(&mut self) { - let mask = self.transport_toggles.iter().enumerate().fold(0u8, |acc, (i, &on)| { - if on { - acc | (1u8 << i) - } else { - acc - } - }); - self.transport_mask = mask; - } - - // ── Main screen ───────────────────────────────────────────────────── - - fn handle_main_key(&mut self, key: KeyEvent) { - // If the connections sub-screen is showing, any key returns to main. - if self.screen == Screen::Connections { - self.screen = Screen::Main; - return; - } - match key.code { - KeyCode::Char('l') | KeyCode::Char('L') => { - // Lock: wipe keys, unload mnemonic - self.key_store.wipe(); - self.alg_key_cache.wipe(); - self.mnemonic.unload(); - self.unlock_input = InputField::new(""); - self.unlock_attempts = 0; - self.activity_log.add("session locked"); - self.screen = Screen::Lock; - } - KeyCode::Char('r') | KeyCode::Char('R') => { - // Refresh is implicit (redraw each loop). - } - KeyCode::Char('d') | KeyCode::Char('D') => { - self.screen = Screen::Connections; - } - KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Char('x') | KeyCode::Char('X') => { + // Q quits the program directly from the help screen, matching + // the "Q Quit" entry listed in the key commands below. + KeyCode::Char('q') | KeyCode::Char('Q') => { self.should_quit = true; } - KeyCode::Esc => self.should_quit = true, KeyCode::Up | KeyCode::Char('k') => { - if self.activity_scroll > 0 { - self.activity_scroll -= 1; + if self.help_scroll > 0 { + self.help_scroll -= 1; } } KeyCode::Down | KeyCode::Char('j') => { - self.activity_scroll += 1; + self.help_scroll += 1; + } + KeyCode::PageUp => { + self.help_scroll = self.help_scroll.saturating_sub(10); + } + KeyCode::PageDown => { + self.help_scroll += 10; } _ => {} } } - - // ── Lock ───────────────────────────────────────────────────────────── - - fn handle_lock_key(&mut self, key: KeyEvent) { - if edit_key(&mut self.unlock_input, key) { - return; - } - if key.code == KeyCode::Enter { - let input = self.unlock_input.buffer.trim().to_string(); - if input == "q" || input == "Q" || input == "x" || input == "X" { - self.should_quit = true; - return; - } - match self.mnemonic.load(&input) { - Ok(()) => { - match self.key_store.derive_all(&mut self.role_table, &self.mnemonic) { - Ok(n) => { - self.derived_count = n; - self.activity_log.add("session re-unlocked"); - self.screen = Screen::Main; - } - Err(_) => { - self.screen = Screen::Main; - } - } - } - Err(_) => { - self.unlock_attempts += 1; - self.unlock_input = InputField::new(""); - if self.unlock_attempts >= 10 { - self.should_quit = true; - } - } - } - } - } } // ──────────────────────────────────────────────────────────────────────────── @@ -1006,392 +1134,571 @@ impl App { /// Draw the current screen. fn draw(&mut self, f: &mut Frame) { match self.screen { - Screen::Unlock => self.draw_unlock(f), - Screen::GenerateMnemonic => self.draw_generate(f), - Screen::RoleWizard => self.draw_wizard(f), - Screen::TransportSelection => self.draw_transport(f), - // Connections and Lock are contained within the main menu screen. - Screen::Main | Screen::Connections | Screen::Lock => self.draw_main(f), + Screen::SeedEntry => self.draw_seed_entry(f), + Screen::SeedDisplay => self.draw_seed_display(f), + Screen::Main => self.draw_main(f), + Screen::AddRole => self.draw_add_role(f), + Screen::Help => self.draw_help(f), } } - /// A key binding span: key letter bold/underlined, rest normal. - fn key_span(key: &str, rest: &str) -> Span<'static> { - Span::styled( - format!("{}{}", key, rest), - Style::default(), - ) - } - - /// Draw the main status display. + /// A command hint: the word with the key command letter underlined. /// - /// The main menu frame (title bar + commands) is always drawn. The body - /// shows the two-column layout, or the connections / lock content in the - /// right column when those sub-screens are active. - fn draw_main(&mut self, f: &mut Frame) { - let area = f.area(); - - let outer = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(0), - Constraint::Length(3), - ]) - .split(area); - - // Title bar (breadcrumb reflects the active sub-screen) - let sub = match self.screen { - Screen::Connections => " > Connection Instructions", - Screen::Lock => " > Lock", - _ => "", - }; - let title = format!(" signer {} > Main Menu{} ", crate::VERSION, sub); - f.render_widget(Block::default().borders(Borders::ALL).title(title), outer[0]); - - // Body: left column / right column - let body = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(outer[1]); - - let left = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(8), Constraint::Min(0)]) - .split(body[0]); - - // ── Section 0: Information ────────────────────────────── - let session = if self.mnemonic.is_loaded() { "unlocked" } else { "locked" }; - let otp_status = crate::otp_pad::global_status(); - let mut info_lines = vec![ - Line::from(format!("session={} ({} words)", session, self.mnemonic.word_count())), - Line::from(format!("signer={} derived={}", self.socket_name, self.derived_count)), - Line::from(format!("socket=@{}", self.socket_name)), - Line::from(format!( - "transport={}", - transport_str(self.transport_mask) - )), - ]; - if !otp_status.is_empty() { - info_lines.push(Line::from(otp_status)); - } - let info = Paragraph::new(info_lines) - .block(Block::default().borders(Borders::ALL).title(" Information ")); - f.render_widget(info, left[0]); - - // ── Section 1: Roles ──────────────────────────────────── - let header = Row::new(vec!["Role", "Purpose", "Curve", "Derivation path"]) - .style(Style::default().add_modifier(Modifier::BOLD)); - let rows: Vec<Row> = self - .role_table - .entries - .iter() - .map(|e| { - Row::new(vec![ - e.name.clone(), - e.purpose_str.clone(), - e.curve_str.clone(), - e.display_path(), - ]) - }) - .collect(); - let widths = [ - Constraint::Percentage(20), - Constraint::Percentage(15), - Constraint::Percentage(20), - Constraint::Percentage(45), - ]; - let table = Table::new(rows, widths) - .header(header) - .block(Block::default().borders(Borders::ALL).title(" Roles ")); - f.render_widget(table, left[1]); - - // ── Right column ──────────────────────────────────────── - // If a sub-screen (connections / lock) is active, render its content - // in the right column instead of the activity list. - if self.screen == Screen::Connections { - self.draw_connections_in(body[1], f); - } else if self.screen == Screen::Lock { - self.draw_lock_in(body[1], f); - } else { - // ── Section 2: Activity (scrollable) ───────────────── - // Use a Paragraph (not a List) so entries fill the area from the - // top and only scroll when content overflows. A List with a - // selected item centers the selection, leaving the lower half - // empty until scrolling starts. - let activity_block = Block::default() - .borders(Borders::ALL) - .title(" Activity (latest first) "); - let entries: Vec<Line> = self - .activity_log - .entries() - .iter() - .map(|s| Line::from(s.to_string())) - .collect(); - let items = Paragraph::new(entries).block(activity_block); - - // Inner area (inside the block borders) height in rows. - let inner_height = body[1].height.saturating_sub(2) as usize; - let entry_count = self.activity_log.entries().len(); - let max_scroll = entry_count.saturating_sub(inner_height); - if self.activity_scroll > max_scroll { - self.activity_scroll = max_scroll; + /// The underlined letter is the actual key binding, which may not be the + /// first letter of the word (e.g. "Help" with L underlined, "Qube bridge" + /// with B underlined). + fn cmd_hint(key: char, word: &str) -> Vec<Span<'static>> { + let underline = Style::default().add_modifier(Modifier::UNDERLINED); + let lower = word.to_lowercase(); + let key_lower = key.to_ascii_lowercase(); + if let Some(pos) = lower.find(key_lower) { + let before = &word[..pos]; + let ch = word[pos..].chars().next().unwrap(); + let after = &word[pos + ch.len_utf8()..]; + let mut spans = Vec::new(); + if !before.is_empty() { + spans.push(Span::raw(before.to_string())); } - // Only shift the content when the user has scrolled; at scroll=0 - // the Paragraph fills its area from the top. - let scroll_y = if self.activity_scroll > 0 { - self.activity_scroll as u16 - } else { - 0 - }; - f.render_widget(items.scroll((scroll_y, 0)), body[1]); - - let mut scrollbar_state = - ScrollbarState::new(entry_count).position(self.activity_scroll); - let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("▲")) - .end_symbol(Some("▼")); - f.render_stateful_widget(sb, body[1], &mut scrollbar_state); + spans.push(Span::styled(ch.to_string(), underline)); + if !after.is_empty() { + spans.push(Span::raw(after.to_string())); + } + spans + } else { + vec![Span::raw(word.to_string())] } - - // ── Section 3: Commands ───────────────────────────────── - let cmd_line = Line::from(vec![ - App::key_span("l", " lock/reunlock "), - App::key_span("r", " refresh "), - App::key_span("d", " display connections "), - App::key_span("q", "/x quit"), - ]); - f.render_widget( - Paragraph::new(cmd_line).block(Block::default().borders(Borders::ALL).title(" Commands ")), - outer[2], - ); } - /// Draw connection instructions in a given area (used inside the main menu). - fn draw_connections_in(&self, area: ratatui::layout::Rect, f: &mut Frame) { - let block = Block::default() - .borders(Borders::ALL) - .title(" Connections "); - let mut text = vec![Line::from("")]; - - if self.transport_mask & TRANSPORT_UNIX != 0 { - text.push(Line::from("Unix socket:")); - text.push(Line::from(format!(" @{}", self.socket_name))); - text.push(Line::from(" Example:")); - text.push(Line::from(format!( - " nsigner --socket-name {} client '{{\"id\":\"1\",\"method\":\"get_info\",\"params\":[]}}'", - self.socket_name - ))); - text.push(Line::from("")); - } - if self.transport_mask & TRANSPORT_QREXEC_BRIDGE != 0 { - text.push(Line::from("Qrexec:")); - text.push(Line::from(" qrexec (one request per invocation)")); - text.push(Line::from("")); - } - if self.transport_mask & TRANSPORT_TCP != 0 { - text.push(Line::from("FIPS:")); - text.push(Line::from(" tcp:[::]:11111")); - text.push(Line::from(" Example:")); - text.push(Line::from(" nsigner --listen tcp:[::]:11111 client '<json>'")); - text.push(Line::from("")); - } - if self.transport_mask & TRANSPORT_HTTP != 0 { - text.push(Line::from("HTTP:")); - text.push(Line::from(" http://127.0.0.1:8080")); - text.push(Line::from(" Example:")); - text.push(Line::from(" curl -X POST http://127.0.0.1:8080/ \\")); - text.push(Line::from(" -H 'Content-Type: application/json' \\")); - text.push(Line::from(" -d '{\"id\":\"1\",\"method\":\"get_info\",\"params\":[]}'")); - text.push(Line::from("")); - } - - let otp_status = crate::otp_pad::global_status(); - if !otp_status.is_empty() { - text.push(Line::from(otp_status)); - text.push(Line::from("")); - } - - text.push(Line::from("Press any key to return")); - f.render_widget(Paragraph::new(text).block(block), area); + /// Center a rect of the given width/height within `r`. + fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { + let x = r.x + r.width.saturating_sub(width) / 2; + let y = r.y + r.height.saturating_sub(height) / 2; + Rect::new(x, y, width.min(r.width), height.min(r.height)) } - /// Draw the lock prompt in a given area (used inside the main menu). - fn draw_lock_in(&mut self, area: ratatui::layout::Rect, f: &mut Frame) { - let block = Block::default().borders(Borders::ALL).title(" Lock "); - let mut text = vec![ - Line::from("Session locked. Re-enter mnemonic to unlock."), + // ── Seed entry popup ──────────────────────────────────────────────── + + fn draw_seed_entry(&mut self, f: &mut Frame) { + use ratatui::text::Text; + + let area = f.area(); + let mut lines = vec![ + Line::from(""), + Line::from("Enter seed phrase or G to generate new:"), Line::from(""), Line::from(Span::styled( format!("> {}", self.unlock_input.buffer), Style::default(), )), ]; - if self.unlock_attempts > 0 { - text.push(Line::from(format!( + if self.seed_attempts > 0 { + lines.push(Line::from(format!( "Invalid mnemonic. Attempts: {}/10", - self.unlock_attempts + self.seed_attempts ))); } - let cursor_row = text.len().saturating_sub(1) as u16; - f.render_widget(Paragraph::new(text).block(block), area); - let cursor_col = 2 + self.unlock_input.cursor as u16; + + // Size the popup to fit the widest line, clamped to the terminal. + let body = Text::from(lines.clone()); + let content_w = body.width() as u16; + let content_h = body.height() as u16; + let popup_w = (content_w + 4).min(area.width); // +2 border, +2 padding + let popup_h = (content_h + 2).min(area.height); // +2 border + let popup = App::centered_rect(popup_w, popup_h, area); + f.render_widget(ratatui::widgets::Clear, popup); + let block = Block::bordered().title(format!(" Signer {} ", crate::VERSION)); + let inner = block.inner(popup); + f.render_widget(block, popup); + f.render_widget(Paragraph::new(lines), inner); + + // Cursor position is relative to `inner` (inside the border), not `popup`. + let cursor_row = inner.y + 3; + let cursor_col = inner.x + 2 + self.unlock_input.cursor as u16; f.set_cursor_position(( - cursor_col.min(area.width.saturating_sub(1)), - cursor_row.min(area.height.saturating_sub(1)), + cursor_col.min(popup.right().saturating_sub(1)), + cursor_row.min(popup.bottom().saturating_sub(1)), )); } - /// Draw the unlock screen. - fn draw_unlock(&mut self, f: &mut Frame) { + fn draw_seed_display(&mut self, f: &mut Frame) { + use ratatui::text::Text; + let area = f.area(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" signer {} > Unlock ", crate::VERSION)); - let mut text = vec![ - Line::from("Mnemonic source: [E]nter existing or [G]enerate new"), - Line::from("Default is E; you can also paste full mnemonic here."), + let mut lines = vec![ Line::from(""), - ]; - if self.unlock_attempts > 0 { - text.push(Line::from(format!( - "Invalid mnemonic (must be 12/15/18/21/24 words). Attempts: {}/10", - self.unlock_attempts - ))); - text.push(Line::from("")); - } - text.push(Line::from("")); - text.push(Line::from(Span::styled( - format!("> {}", self.unlock_input.buffer), - Style::default(), - ))); - let cursor_row = (text.len().saturating_sub(1)) as u16; - f.render_widget(Paragraph::new(text).block(block), area); - let cursor_col = 2 + self.unlock_input.cursor as u16; - f.set_cursor_position(( - cursor_col.min(area.width.saturating_sub(1)), - cursor_row.min(area.height.saturating_sub(1)), - )); - } - - /// Draw the generated-mnemonic screen. - fn draw_generate(&mut self, f: &mut Frame) { - let area = f.area(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" signer {} > Generated Mnemonic ", crate::VERSION)); - let mut text = vec![ Line::from("Generated mnemonic (WRITE THIS DOWN - it will not be shown again):"), Line::from(""), ]; for (i, word) in self.generated_phrase.split_whitespace().enumerate() { - text.push(Line::from(format!("{:2}. {}", i + 1, word))); + lines.push(Line::from(format!("{:2}. {}", i + 1, word))); } - text.push(Line::from("")); - text.push(Line::from("Press Enter after writing down your mnemonic to continue.")); - f.render_widget(Paragraph::new(text).block(block), area); + lines.push(Line::from("")); + lines.push(Line::from("Press Enter after writing down your mnemonic to continue.")); + + // Size the popup to fit the content, clamped to the terminal size. + let body = Text::from(lines.clone()); + let content_w = body.width() as u16; + let content_h = body.height() as u16; + let popup_w = (content_w + 4).min(area.width); // +2 border, +2 padding + let popup_h = (content_h + 2).min(area.height); // +2 border + let popup = App::centered_rect(popup_w, popup_h, area); + f.render_widget(ratatui::widgets::Clear, popup); + let block = Block::bordered().title(format!(" Signer {} ", crate::VERSION)); + let inner = block.inner(popup); + f.render_widget(block, popup); + f.render_widget(Paragraph::new(lines), inner); } - /// Draw the role wizard screen. - fn draw_wizard(&mut self, f: &mut Frame) { + // ── Main screen ───────────────────────────────────────────────────── + + fn draw_main(&mut self, f: &mut Frame) { let area = f.area(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" signer {} > Role Configuration ", crate::VERSION)); - let mut text: Vec<Line> = vec![ - Line::from(" "), - Line::from(" 1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0"), - Line::from(" 2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0"), - Line::from(" 3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'"), - Line::from(" 4. SSH role: ed25519, m/44'/102001'/0'/0'/0'"), - Line::from(" 5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'"), - Line::from(" 6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'"), - Line::from(" 7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'"), - Line::from(" 8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'"), - Line::from(" 9. OTP role (one-time pad encryption)"), - Line::from(" 10. Custom path"), + let [title, body, bottom] = area.layout(&Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(3), + ])); + + // Centered title on its own line — no border box. + f.render_widget( + Line::from(format!("Signer {}", crate::VERSION)).centered(), + title, + ); + + // Body: left column (Information / Transport / Roles) + right (Activity). + let [left, right] = body.layout(&Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .spacing(Spacing::Overlap(1))); + + let [info, transport, roles] = left.layout(&Layout::vertical([ + Constraint::Length(13), + Constraint::Length(7), + Constraint::Min(0), + ]) + .spacing(Spacing::Overlap(1))); + + self.draw_info_section(f, info); + self.draw_transport_section(f, transport); + self.draw_roles_section(f, roles); + self.draw_activity_section(f, right); + + // Bottom bar: Help (focus 7) / Quit (focus 8). + let help_focused = self.focus == 7; + let quit_focused = self.focus == 8; + let mut help_spans = App::cmd_hint('l', "Help"); + if help_focused { + for s in &mut help_spans { + *s = s.clone().style(Style::default().add_modifier(Modifier::REVERSED)); + } + } + let mut quit_spans = App::cmd_hint('q', "Quit"); + if quit_focused { + for s in &mut quit_spans { + *s = s.clone().style(Style::default().add_modifier(Modifier::REVERSED)); + } + } + let mut spans = Vec::new(); + spans.extend(help_spans); + spans.push(Span::raw(" ")); + spans.extend(quit_spans); + f.render_widget( + Paragraph::new(Line::from(spans)) + .block(Block::bordered().merge_borders(MergeStrategy::Exact)), + bottom, + ); + } + + fn draw_info_section(&self, f: &mut Frame, area: Rect) { + let block = Block::bordered() + .merge_borders(MergeStrategy::Exact) + .title(" Information "); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut lines = vec![Line::from("")]; + lines.push(Line::from(format!("signer name: {}", self.socket_name))); + + lines.push(Line::from("Unix address:")); + if self.transport_toggles[0] { + lines.push(Line::from(format!(" {}", self.socket_name))); + } else { + lines.push(Line::from(" (inactive)")); + } + + lines.push(Line::from("Qube address:")); + if self.transport_toggles[1] { + // Qrexec is a one-request-per-invocation transport: there is + // no persistent bind address, so show how callers reach it. + lines.push(Line::from(" qrexec (one request per invocation)")); + } else { + lines.push(Line::from(" (inactive)")); + } + + lines.push(Line::from("FIPS address:")); + if self.transport_toggles[2] { + // FIPS/TCP listener. If a local FIPS identity is available + // (via `fipsctl show status`), show the npub-based URL that + // FIPS clients use; otherwise fall back to the raw host:port. + let port = extract_listen_port(&self.tcp_bind_addr); + let addr = match lookup_local_fips_identity() { + Some(npub) if !port.is_empty() => format!("http://{}.fips:{}", npub, port), + _ => self.tcp_bind_addr.clone(), + }; + lines.push(Line::from(format!(" {}", addr))); + } else { + lines.push(Line::from(" (inactive)")); + } + + lines.push(Line::from("HTTP address:")); + if self.transport_toggles[3] { + // HTTP listener — show the full http:// URL clients use. + lines.push(Line::from(format!(" http://{}", self.http_bind_addr))); + } else { + lines.push(Line::from(" (inactive)")); + } + + let otp = crate::otp_pad::global_status(); + if !otp.is_empty() { + let otp_line = otp.replacen("OTP pad bound:", "OTP pad:", 1); + lines.push(Line::from(otp_line)); + } + + f.render_widget(Paragraph::new(lines), inner); + } + + fn draw_transport_section(&self, f: &mut Frame, area: Rect) { + let block = Block::bordered() + .merge_borders(MergeStrategy::Exact) + .title(" Transport "); + let inner = block.inner(area); + f.render_widget(block, area); + + let labels = [ + ('U', "Unix Socket"), + ('B', "Qube bridge"), + ('F', "FIPS"), + ('H', "HTTP"), + ]; + let mut lines = vec![Line::from("")]; + for (i, (key, word)) in labels.iter().enumerate() { + let active = self.transport_toggles[i]; + let focused = self.focus == i; + let check = if active { "[x]" } else { "[ ]" }; + let mut spans = vec![Span::raw(format!(" {} ", check))]; + spans.extend(App::cmd_hint(*key, word)); + let mut line = Line::from(spans); + if focused { + // Reverse-highlight the focused command line. + line = line.style(Style::default().add_modifier(Modifier::REVERSED)); + } + lines.push(line); + } + lines.push(Line::from("")); + f.render_widget(Paragraph::new(lines), inner); + } + + fn draw_roles_section(&self, f: &mut Frame, area: Rect) { + let block = Block::bordered() + .merge_borders(MergeStrategy::Exact) + .title(" Roles "); + let inner = block.inner(area); + f.render_widget(block, area); + + // Split inner area: role rows on top, command line at the very bottom + // so Add/Delete always stay visible on small terminals. + let [rows_area, cmd_area] = inner.layout(&Layout::vertical([ + Constraint::Min(0), + Constraint::Length(1), + ])); + + let mut lines = vec![ + Line::from(""), + Line::from(format!( + " {:<14} {:<8} {:<12} {}", + "Role", "Purpose", "Curve", "Key path" + )), + Line::from(format!( + " {:<14} {:<8} {:<12} {}", + "─────────────", "────────", "────────────", "──────────────────────────" + )), + ]; + for (i, e) in self.role_table.entries.iter().enumerate() { + // Only highlight a role row when a role command (Add/Delete) + // is focused, so the table doesn't show a stale highlight by + // default. + let selected = (self.focus == 4 || self.focus == 5) && self.role_cursor == i; + let row = Line::from(format!( + " {:<14} {:<8} {:<12} {}", + e.name, e.purpose_str, e.curve_str, e.display_path() + )); + let row = if selected { + row.style(Style::default().add_modifier(Modifier::REVERSED)) + } else { + row + }; + lines.push(row); + } + f.render_widget(Paragraph::new(lines), rows_area); + + // Add (focus 4) / Delete (focus 5) — reverse-highlight when focused. + let add_focused = self.focus == 4; + let del_focused = self.focus == 5; + let mut spans = Vec::new(); + let mut add_spans = App::cmd_hint('a', "Add"); + if add_focused { + for s in &mut add_spans { + *s = s.clone().style(Style::default().add_modifier(Modifier::REVERSED)); + } + } + spans.extend(add_spans); + spans.push(Span::raw(" ")); + let mut del_spans = App::cmd_hint('d', "Delete"); + if del_focused { + for s in &mut del_spans { + *s = s.clone().style(Style::default().add_modifier(Modifier::REVERSED)); + } + } + spans.extend(del_spans); + f.render_widget(Line::from(spans), cmd_area); + } + + fn draw_activity_section(&mut self, f: &mut Frame, area: Rect) { + let block = Block::bordered() + .merge_borders(MergeStrategy::Exact) + .title(" Activity "); + let inner = block.inner(area); + f.render_widget(block, area); + + let [log_area, _blank, cmd] = inner.layout(&Layout::vertical([ + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ])); + + let entries: Vec<Line> = self + .activity_log + .entries() + .iter() + .map(|s| Line::from(s.to_string())) + .collect(); + let entry_count = entries.len(); + let inner_height = log_area.height as usize; + let max_scroll = entry_count.saturating_sub(inner_height); + if self.activity_scroll > max_scroll { + self.activity_scroll = max_scroll; + } + let scroll_y = self.activity_scroll as u16; + f.render_widget(Paragraph::new(entries).scroll((scroll_y, 0)), log_area); + + let mut scrollbar_state = + ScrollbarState::new(entry_count).position(self.activity_scroll); + let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + f.render_stateful_widget(sb, log_area, &mut scrollbar_state); + + let clear_focused = self.focus == 6; + let mut spans = App::cmd_hint('c', "Clear"); + if clear_focused { + for s in &mut spans { + *s = s.clone().style(Style::default().add_modifier(Modifier::REVERSED)); + } + } + f.render_widget(Line::from(spans), cmd); + } + + // ── Add-role popup ────────────────────────────────────────────────── + + fn draw_add_role(&mut self, f: &mut Frame) { + // Draw the main screen underneath, then the popup on top. + self.draw_main(f); + + let area = f.area(); + // Width 78 to fit the longest preset line; height 18 for the full menu. + let popup = App::centered_rect(78, 18, area); + // Clear the popup area so the main screen content behind it does + // not show through the popup's blank cells. + f.render_widget(ratatui::widgets::Clear, popup); + let block = Block::bordered().title(" Add Role "); + let inner = block.inner(popup); + f.render_widget(block, popup); + + let mut lines: Vec<Line> = vec![Line::from("")]; + match self.role_add_stage { + RoleAddStage::PresetMenu => { + lines.push(Line::from("Select a role preset (A-J):")); + lines.push(Line::from("")); + lines.push(Line::from(" A. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0")); + lines.push(Line::from(" B. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0")); + lines.push(Line::from(" C. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'")); + lines.push(Line::from(" D. SSH role: ed25519, m/44'/102001'/0'/0'/0'")); + lines.push(Line::from(" E. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'")); + lines.push(Line::from(" F. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'")); + lines.push(Line::from(" G. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'")); + lines.push(Line::from(" H. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'")); + lines.push(Line::from(" I. OTP role (one-time pad encryption)")); + lines.push(Line::from(" J. Custom path")); + lines.push(Line::from("")); + lines.push(Line::from(" ESC to cancel")); + } + RoleAddStage::NameEntry => { + lines.push(Line::from(format!( + "Role name [{}]: {}", + self.role_add_default_name, self.role_add_input.buffer + ))); + } + RoleAddStage::CurveSelect => { + lines.push(Line::from("Curve:")); + lines.push(Line::from(" 1) secp256k1 (Nostr, Bitcoin)")); + lines.push(Line::from(" 2) ed25519 (SSH)")); + lines.push(Line::from(" 3) x25519 (key agreement, Age)")); + lines.push(Line::from(" 4) ml-dsa-65 (post-quantum signatures)")); + lines.push(Line::from(" 5) slh-dsa-128s (post-quantum signatures)")); + lines.push(Line::from(" 6) ml-kem-768 (post-quantum KEM)")); + lines.push(Line::from(" Select [1]: ")); + } + RoleAddStage::PathEntry => { + lines.push(Line::from(format!( + "Path template [{}]: {}", + self.role_add_default_path, self.role_add_input.buffer + ))); + } + RoleAddStage::OtpDir => { + lines.push(Line::from(format!( + "OTP pad directory (e.g. /media/usb0): {}", + self.role_add_input.buffer + ))); + } + RoleAddStage::OtpName => { + lines.push(Line::from(format!( + "OTP pad name (e.g. mypad): {}", + self.role_add_input.buffer + ))); + } + } + f.render_widget(Paragraph::new(lines), inner); + + // Cursor for input stages. Position is relative to `inner` (inside + // the border), not `popup`. + if matches!( + self.role_add_stage, + RoleAddStage::NameEntry + | RoleAddStage::PathEntry + | RoleAddStage::OtpDir + | RoleAddStage::OtpName + ) { + let prefix = self.add_role_prompt_prefix(); + let cursor_row = inner.y + 1; + let cursor_col = inner.x + prefix.chars().count() as u16 + self.role_add_input.cursor as u16; + f.set_cursor_position(( + cursor_col.min(popup.right().saturating_sub(1)), + cursor_row.min(popup.bottom().saturating_sub(1)), + )); + } + } + + /// The prompt prefix for the current add-role input stage. + fn add_role_prompt_prefix(&self) -> String { + match self.role_add_stage { + RoleAddStage::NameEntry => { + format!("Role name [{}]: ", self.role_add_default_name) + } + RoleAddStage::PathEntry => { + format!("Path template [{}]: ", self.role_add_default_path) + } + RoleAddStage::OtpDir => "OTP pad directory (e.g. /media/usb0): ".to_string(), + RoleAddStage::OtpName => "OTP pad name (e.g. mypad): ".to_string(), + _ => String::new(), + } + } + + // ── Help screen ───────────────────────────────────────────────────── + + fn draw_help(&mut self, f: &mut Frame) { + let area = f.area(); + let [title, body, bottom] = area.layout(&Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ])); + + f.render_widget( + Line::from(format!("Signer {}", crate::VERSION)).centered(), + title, + ); + + let block = Block::bordered().merge_borders(MergeStrategy::Exact); + let inner = block.inner(body); + f.render_widget(block, body); + + let content = vec![ + Line::from(""), + Line::from("Signer is an attended Nostr signing daemon. It holds your keys and signs"), + Line::from("requests from clients over one or more transports."), + Line::from(""), + Line::from("Transports"), + Line::from("──────────"), + Line::from("A transport is a way for clients to reach the signer."), + Line::from("- Unix Socket: local same-machine access via an abstract socket."), + Line::from("- Qube bridge: access from other Qubes via qrexec."), + Line::from("- FIPS: TCP listener for framed JSON over a network."), + Line::from("- HTTP: HTTP listener for curl-friendly requests."), + Line::from(""), + Line::from("Roles"), + Line::from("─────"), + Line::from("A role binds a name to a derivation path and curve. Clients address"), + Line::from("requests by role name, which also serves as the password."), + Line::from(""), + Line::from("Key commands (main screen)"), + Line::from("──────────────────────────"), + Line::from("U Toggle Unix Socket transport"), + Line::from("B Toggle Qube bridge transport"), + Line::from("F Toggle FIPS transport"), + Line::from("H Toggle HTTP transport"), + Line::from("A Add a role"), + Line::from("D Delete the selected role"), + Line::from("C Clear the activity log"), + Line::from("L Open the Help screen"), + Line::from("Q Quit"), + Line::from(""), + Line::from("Navigation (main screen)"), + Line::from("────────────────────────"), + Line::from("Tab / Shift-Tab Move focus between all commands"), + Line::from("Enter Activate the highlighted command"), + Line::from("Up/Down or j/k Scroll the activity log"), + Line::from(""), + Line::from("Navigation (this screen)"), + Line::from("─────────────────────────"), + Line::from("Up/Down or j/k Scroll this screen"), + Line::from("Page Up/Page Down Scroll by a page"), + Line::from("ESC or B Back to the main screen"), + Line::from("Q Quit the program"), Line::from(""), ]; - match self.wizard_stage { - WizardStage::SelectPreset => { - text.push(Line::from(Span::styled(" Select [1]: ", Style::default()))); - } - WizardStage::RoleName => { - text.push(Line::from(format!( - " Role name [{}]: {}", - self.wizard_default_name, self.wizard_role_name.buffer - ))); - } - WizardStage::CurveSelect => { - text.push(Line::from(" Curve:")); - text.push(Line::from(" 1) secp256k1 (Nostr, Bitcoin)")); - text.push(Line::from(" 2) ed25519 (SSH)")); - text.push(Line::from(" 3) x25519 (key agreement, Age)")); - text.push(Line::from(" 4) ml-dsa-65 (post-quantum signatures)")); - text.push(Line::from(" 5) slh-dsa-128s (post-quantum signatures)")); - text.push(Line::from(" 6) ml-kem-768 (post-quantum KEM)")); - text.push(Line::from(" Select [1]: ")); - } - WizardStage::PathEntry => { - text.push(Line::from(format!( - " Path template [{}]: {}", - self.wizard_default_path, self.wizard_path.buffer - ))); - } - WizardStage::OtpDir => { - text.push(Line::from(format!( - " OTP pad directory (e.g. /media/usb0): {}", - self.wizard_otp_dir.buffer - ))); - } - WizardStage::OtpName => { - text.push(Line::from(format!( - " OTP pad name (e.g. mypad): {}", - self.wizard_otp_name.buffer - ))); - } - WizardStage::ConfirmRegister => { - text.push(Line::from(format!( - " Role registered ({} role(s) configured).", - self.wizard_roles_created - ))); - text.push(Line::from(" Define another role? [y/N]: ")); - } + let inner_height = inner.height as usize; + let content_len = content.len(); + let max_scroll = content_len.saturating_sub(inner_height); + if self.help_scroll > max_scroll { + self.help_scroll = max_scroll; } + let scroll_y = self.help_scroll as u16; + f.render_widget(Paragraph::new(content).scroll((scroll_y, 0)), inner); - f.render_widget(Paragraph::new(text).block(block), area); - } + let mut scrollbar_state = ScrollbarState::new(content_len).position(self.help_scroll); + let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight); + f.render_stateful_widget(sb, inner, &mut scrollbar_state); - /// Draw the transport selection screen. - fn draw_transport(&mut self, f: &mut Frame) { - let area = f.area(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" signer {} > Transport Selection ", crate::VERSION)); - let t = self.transport_toggles; - let text = vec![ - Line::from("Select one or more (type a number to toggle, 'a' for all, Enter to confirm):"), - Line::from(""), - Line::from(format!( - " [{}] 1. Local Unix socket (same machine/qube)", - if t[0] { "x" } else { " " } - )), - Line::from(format!( - " [{}] 2. Qubes qrexec bridge (other qubes via qrexec, no network)", - if t[1] { "x" } else { " " } - )), - Line::from(format!( - " [{}] 3. FIPS/TCP listener (framed JSON, FIPS mesh or local network)", - if t[2] { "x" } else { " " } - )), - Line::from(format!( - " [{}] 4. HTTP listener (curl-friendly, localhost by default)", - if t[3] { "x" } else { " " } - )), - Line::from(""), - Line::from(" [a] select all Enter = confirm"), - ]; - f.render_widget(Paragraph::new(text).block(block), area); + let mut spans = Vec::new(); + spans.extend(App::cmd_hint('b', "Back")); + spans.push(Span::raw(" ")); + spans.extend(App::cmd_hint('q', "Quit")); + f.render_widget( + Paragraph::new(Line::from(spans)) + .block(Block::bordered().merge_borders(MergeStrategy::Exact)), + bottom, + ); } } diff --git a/tests/integration.rs b/tests/integration.rs index a717121..47375dc 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -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,