v0.0.1 - Port tui_continuous library to Rust, integrate with signer TUI, add versioning scheme

This commit is contained in:
Laan Tungir
2026-08-17 14:44:36 -04:00
commit cf1c353ce7
32 changed files with 12653 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target/
*.log
*.tar.gz
Generated
+2706
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
[package]
name = "nsigner"
version = "0.0.1"
edition = "2021"
license = "MIT"
description = "Attended Nostr signing daemon — Rust port of n_signer"
[[bin]]
name = "nsigner"
path = "src/main.rs"
[lib]
name = "nsigner"
path = "src/lib.rs"
[dependencies]
# nostr_core_lib_rust — local path dependencies
nostr-core = { path = "../nostr_core_lib_rust/core" }
nips = { package = "nostr-nips", path = "../nostr_core_lib_rust/nips" }
# Crypto
secp256k1 = { version = "0.29", features = ["rand-std", "hashes", "global-context"] }
sha2 = "0.10"
hmac = "0.12"
hex = "0.4"
zeroize = { version = "1", features = ["zeroize_derive"] }
ed25519-dalek = { version = "2", features = ["rand_core"] }
x25519-dalek = { version = "2", features = ["static_secrets"] }
rand = "0.8"
rand_core = "0.6"
sha3 = "0.10"
chacha20poly1305 = "0.10"
# Post-quantum crypto (pure Rust implementations)
ml-dsa = { version = "0.1", features = ["rand_core"] }
ml-kem = { version = "0.3", features = ["getrandom"] }
slh-dsa = "0.2.0-rc.5"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# System
libc = "0.2"
# CLI
clap = { version = "4", features = ["derive"] }
# TUI
crossterm = "0.27"
# Encoding
base64 = "0.22"
# Error handling
thiserror = "2"
[dev-dependencies]
tempfile = "3"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
+309
View File
@@ -0,0 +1,309 @@
#!/bin/bash
set -e
# nsigner (Rust) — Increment and Push Script
#
# Increments the version (patch/minor/major), updates Cargo.toml and
# src/lib.rs, commits, tags, and pushes. Optionally creates a release
# build and uploads assets to Gitea.
#
# USAGE:
# ./increment_and_push.sh [OPTIONS] "commit message"
#
# OPTIONS:
# -p, --patch Increment patch version (default)
# -m, --minor Increment minor version
# -M, --major Increment major version
# -r, --release Create release build and upload assets
# -h, --help Show this help message
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
COMMIT_MESSAGE=""
RELEASE_MODE=false
VERSION_INCREMENT_TYPE="patch"
show_usage() {
echo "nsigner (Rust) Increment and Push Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS] \"commit message\""
echo ""
echo "OPTIONS:"
echo " -p, --patch Increment patch version (default)"
echo " -m, --minor Increment minor version"
echo " -M, --major Increment major version"
echo " -r, --release Create release build and upload assets"
echo " -h, --help Show this help message"
}
while [[ $# -gt 0 ]]; do
case $1 in
-r|--release)
RELEASE_MODE=true
shift
;;
-p|--patch)
VERSION_INCREMENT_TYPE="patch"
shift
;;
-m|--minor)
VERSION_INCREMENT_TYPE="minor"
shift
;;
-M|--major)
VERSION_INCREMENT_TYPE="major"
shift
;;
-h|--help)
show_usage
exit 0
;;
*)
if [[ -z "$COMMIT_MESSAGE" ]]; then
COMMIT_MESSAGE="$1"
fi
shift
;;
esac
done
if [[ -z "$COMMIT_MESSAGE" ]]; then
print_error "Commit message is required"
show_usage
exit 1
fi
check_git_repo() {
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not in a git repository"
exit 1
fi
}
# Update version in Cargo.toml and src/lib.rs
update_version_in_source() {
local new_version="$1" # e.g. "v0.0.2"
local new_version_no_v="${new_version#v}" # e.g. "0.0.2"
# Update Cargo.toml
sed -i "s/^version = \".*\"/version = \"$new_version_no_v\"/" Cargo.toml
print_success "Updated Cargo.toml to $new_version_no_v"
# Update src/lib.rs (VERSION constant)
sed -i "s/pub const VERSION: \&str = \"v[0-9]*\.[0-9]*\.[0-9]*\"/pub const VERSION: \&str = \"$new_version\"/" src/lib.rs
print_success "Updated src/lib.rs to $new_version"
}
increment_version() {
local increment_type="$1"
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "")
if [[ -z "$LATEST_TAG" ]]; then
LATEST_TAG="v0.0.0"
print_warning "No version tags found, starting from $LATEST_TAG"
fi
VERSION=${LATEST_TAG#v}
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
else
print_error "Invalid version format in tag: $LATEST_TAG"
exit 1
fi
if [[ "$increment_type" == "major" ]]; then
NEW_VERSION="v$((MAJOR + 1)).0.0"
elif [[ "$increment_type" == "minor" ]]; then
NEW_VERSION="v${MAJOR}.$((MINOR + 1)).0"
else
NEW_VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
update_version_in_source "$NEW_VERSION"
export NEW_VERSION
}
git_commit_and_push() {
git add .
if ! git diff --staged --quiet; then
git commit -m "$NEW_VERSION - $COMMIT_MESSAGE"
else
print_warning "No changes to commit"
fi
git push
git push origin "$NEW_VERSION" 2>/dev/null || git push --force origin "$NEW_VERSION" 2>/dev/null || true
}
verify_binary_version() {
local bin_path="$1"
local expected="$2"
if [[ ! -x "$bin_path" ]]; then
print_error "Binary not found or not executable: $bin_path"
return 1
fi
local got
got="$($bin_path --version 2>/dev/null | awk '{print $2}')"
if [[ "$got" != "$expected" ]]; then
print_error "Binary version mismatch: expected $expected, got ${got:-<unknown>}"
return 1
fi
return 0
}
build_release_binary() {
print_status "Building release binary (cargo build --release)..."
cargo build --release 2>&1 | tail -5 || return 1
local bin_path="target/release/nsigner"
verify_binary_version "$bin_path" "$NEW_VERSION" || return 1
print_success "Release binary built: $bin_path"
return 0
}
create_source_tarball() {
local tarball_name="nsigner-${NEW_VERSION#v}.tar.gz"
if tar -czf "$tarball_name" \
--exclude='target/*' \
--exclude='.git*' \
--exclude='*.log' \
--exclude='*.tar.gz' \
. > /dev/null 2>&1; then
echo "$tarball_name"
else
return 1
fi
}
create_gitea_release() {
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping release creation."
return 0
fi
local token
token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/signer"
local response
response=$(curl -s -X POST "$api_url/releases" \
-H "Authorization: token $token" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$NEW_VERSION\", \"target_commitish\": \"master\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\", \"draft\": false, \"prerelease\": false}")
if echo "$response" | grep -q '"id"'; then
local release_id
release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
# Work around Gitea bug: releases created via API get created_unix=0
local now_unix
now_unix=$(date +%s)
print_status "Fixing release timestamp in Gitea database (workaround for Gitea bug)..."
ssh -o ConnectTimeout=10 ubuntu@laantungir.net \
"sudo sqlite3 /data/gitea/gitea.db \"UPDATE release SET created_unix=$now_unix WHERE id=$release_id;\"" \
2>/dev/null || print_warning "Could not fix release timestamp via SSH (release may not appear in web UI)"
echo "$release_id"
else
print_error "Failed to create Gitea release: $response"
return 1
fi
}
upload_release_assets() {
local release_id="$1"
local binary_path="$2"
local tarball_path="$3"
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping asset uploads."
return 0
fi
local token
token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/signer"
local assets_url="$api_url/releases/$release_id/assets"
upload_asset() {
local path="$1"
if [[ -f "$path" ]]; then
print_status "Uploading $(basename "$path")..."
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$path;filename=$(basename "$path")" \
-F "name=$(basename "$path")" > /dev/null
fi
}
upload_asset "$binary_path"
upload_asset "$tarball_path"
}
main() {
check_git_repo
if [[ "$RELEASE_MODE" == true ]]; then
increment_version "$VERSION_INCREMENT_TYPE"
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true
git tag "$NEW_VERSION" > /dev/null 2>&1
fi
if ! build_release_binary; then
print_error "Release build failed; aborting before push/upload"
exit 1
fi
git_commit_and_push
local binary_path="target/release/nsigner"
local tarball_path=""
tarball_path=$(create_source_tarball || true)
local release_id=""
release_id=$(create_gitea_release || true)
if [[ -n "$release_id" ]]; then
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
fi
print_success "Release flow completed: $NEW_VERSION"
else
increment_version "$VERSION_INCREMENT_TYPE"
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true
git tag "$NEW_VERSION" > /dev/null 2>&1
fi
git_commit_and_push
print_success "Increment and push completed: $NEW_VERSION"
fi
}
main
+220
View File
@@ -0,0 +1,220 @@
# Plan: Wire Policy Enforcement into the Server Loop
## Problem
The Rust `nsigner` library modules are complete and tested (92 tests pass), but the server loop in [`server.rs`](src/server.rs:117) accepts connections, reads requests, dispatches them, and sends responses **without any policy enforcement**. The `policy: &mut PolicyTable` parameter is accepted but never used. This means the daemon would sign anything for anyone without prompting — it is not an "attended signer."
## What the C version does (server.c)
The C `server_handle_one()` implements a full security pipeline before dispatching:
1. **Caller identification** (`server_get_caller()`):
- Unix socket: `SO_PEERCRED` → `uid:<uid>`
- TCP: `getpeername()` → `tcp:<ip>:<port>`
- stdio/qrexec: `QREXEC_REMOTE_DOMAIN` env → `qubes:<domain>` or `uid:<uid>`
2. **Auth envelope verification** (if `--auth optional|required`):
- Extracts `auth` field from JSON-RPC request
- Verifies NIP-42-style event signature, timestamp skew, replay protection
- Composes caller_id as `pubkey:<hex>` for authenticated callers
3. **Selector resolution** (`extract_method_and_selector()` + `selector_resolve()`):
- Parses `method`, `role`, `role_path`, `index`, `nostr_index` from request
- Resolves against role table, handles fixed-path vs template roles
- Detects `pending_derivation` (new identity that will be derived if approved)
4. **Policy check** (`policy_check_with_role()`):
- Role-as-password: if `role.requires_approval == 0`, allow immediately
- Otherwise check policy table entries (caller, verb, role, purpose, algorithm, index range)
- Returns `Allow`, `Deny`, `Prompt`, or `NoMatch`
5. **Approval prompt** (`prompt_for_policy_decision()`):
- If `Prompt`, shows TUI prompt with caller, method, role, purpose
- Returns `Allow`, `Deny`, `AllowSessionVerb`, or `AllowSessionAll`
- Session grants are inserted into the policy table for subsequent requests
6. **Pending derivation** (if approved and `pending_derivation`):
- Derives the key for the requested role/index before dispatching
7. **Dispatch** — only if all checks pass
## Current Rust state
| Component | Status | Notes |
|-----------|--------|-------|
| [`policy.rs`](src/policy.rs) | ✅ Complete | `PolicyTable`, `check()`, `check_with_role()`, `check_algorithm()`, `parse_preapprove_spec()` |
| [`auth_envelope.rs`](src/auth_envelope.rs) | ✅ Complete | `AuthNonceCache`, `verify_request()` |
| [`selector.rs`](src/selector.rs) | ✅ Complete | `SelectorRequest`, `selector_resolve()` |
| [`tui.rs`](src/tui.rs) | ✅ Complete | `approval_prompt()` with y/n/e/a |
| [`server.rs`](src/server.rs) | ❌ **Missing** | `handle_one()` accepts `policy` but never uses it |
| [`main.rs`](src/main.rs) | ⚠️ Partial | Creates `PolicyTable`, adds preapprove entries, but no session grant support |
## Implementation plan
### Step 1: Add caller identification to `server.rs`
Add a `CallerIdentity` struct and `identify_caller()` function:
```rust
pub struct CallerIdentity {
pub uid: u32,
pub gid: u32,
pub pid: u32,
pub kind: ListenMode,
pub caller_id: String, // "uid:1000", "tcp:127.0.0.1:8080", "qubes:work"
pub source_qube: String, // qrexec only
pub auth_present: bool,
pub auth_pubkey_hex: String,
pub auth_label: String,
}
```
- Unix: `getsockopt(SO_PEERCRED)` via `libc::getsockopt` on the `UnixStream` fd
- TCP: `getpeername()` via `TcpStream::peer_addr()`
- stdio/qrexec: `std::env::var("QREXEC_REMOTE_DOMAIN")`
### Step 2: Add auth envelope verification to `server.rs`
In `handle_one()`, after reading the request but before dispatch:
```rust
if self.auth_mode != AuthMode::Off {
let mut cache = AuthNonceCache::new(); // or store in ServerContext
match auth_envelope::verify_request(&request, &mut cache, self.auth_skew_seconds) {
Ok((pubkey, label)) => {
caller.auth_present = true;
caller.auth_pubkey_hex = pubkey;
caller.auth_label = label;
caller.caller_id = format!("pubkey:{}", pubkey);
}
Err((code, msg)) => {
if self.auth_mode == AuthMode::Required {
return Ok(send_auth_error(code, msg));
}
// Optional: continue without auth
}
}
}
```
### Step 3: Add selector extraction and policy check to `server.rs`
Before calling `dispatcher::handle_request()`:
```rust
// Extract method and selector from request JSON
let (method, selector_req) = extract_method_and_selector(&request)?;
// Resolve selector against role table
let role_index = selector_resolve(&selector_req, dispatcher.role_table)?;
let role = &dispatcher.role_table.entries[role_index];
// Check policy
let (result, source) = policy.check_with_role(
&caller.caller_id,
method,
&role.name,
role.purpose_str(),
Some(role),
);
match result {
PolicyResult::Allow => { /* proceed to dispatch */ }
PolicyResult::Deny => { /* send policy_denied error */ }
PolicyResult::Prompt => {
let decision = tui::approval_prompt(&caller.caller_id, method, &role.name, role.purpose_str());
match decision {
PolicyResult::Allow => { /* proceed */ }
PolicyResult::AllowSessionVerb => {
// Insert session grant into policy table
policy.insert_session_grant(&caller.caller_id, method, &role.name);
/* proceed */
}
PolicyResult::AllowSessionAll => {
policy.insert_session_grant_all(&caller.caller_id, &role.name);
/* proceed */
}
_ => { /* deny */ }
}
}
_ => { /* deny */ }
}
```
### Step 4: Add session grant support to `policy.rs`
Add methods to insert session grants:
```rust
impl PolicyTable {
/// Insert a session grant for caller+role+verb.
pub fn insert_session_grant(&mut self, caller: &str, verb: &str, role: &str) -> Result<(), NsignerError>;
/// Insert a session grant for caller+role (all verbs).
pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), NsignerError>;
}
```
These create `PolicyEntry` with `source: PolicySource::SessionGrant` and `prompt: PromptMode::Never`, inserted before the catch-all deny rule.
### Step 5: Add `extract_method_and_selector()` helper
Port the C function that parses a JSON-RPC request to extract:
- `method` (string)
- `role` (from last params object)
- `role_path` (from last params object)
- `index` (from last params object)
- `nostr_index` (from last params object)
Returns `(method, SelectorRequest)`.
### Step 6: Add `pending_derivation` support
When the selector resolves to a role that hasn't been derived yet (or a template role with a new index), set `pending_derivation = true`. After policy approval, derive the key before dispatching:
```rust
if pending_derivation {
key_store.derive_one(role_table, mnemonic, role_index)?;
}
```
### Step 7: Add `--allow-all` flag support
In `main.rs`, when `cli.allow_all` is true, set a global flag that makes `approval_prompt()` return `Allow` immediately (matching C's `g_prompt_always_allow`).
### Step 8: Add `policy` to `DispatcherContext` or pass separately
The dispatcher currently doesn't know about policy. Options:
- **Option A**: Pass `&mut PolicyTable` to `handle_request()` — changes dispatcher API
- **Option B**: Do policy check in `server.rs` before calling dispatcher — cleaner separation
**Recommendation**: Option B. The server is the security boundary; the dispatcher is just a router.
### Step 9: Integration tests
Add tests in `tests/` that:
1. Start a server on a Unix socket with a test mnemonic
2. Connect a client and send a `get_info` request (should work without policy)
3. Send a `nostr_get_public_key` request without preapproval (should prompt/deny)
4. Send with preapproval (should allow)
5. Test session grants (approve once, second request should not prompt)
## File changes
| File | Changes |
|------|---------|
| [`src/server.rs`](src/server.rs) | Add `CallerIdentity`, `identify_caller()`, auth envelope check, selector extraction, policy check, approval prompt call, pending derivation |
| [`src/policy.rs`](src/policy.rs) | Add `insert_session_grant()`, `insert_session_grant_all()` |
| [`src/main.rs`](src/main.rs) | Add `--allow-all` flag handling, pass `AuthNonceCache` to server |
| [`src/tui.rs`](src/tui.rs) | Add `prompt_always_allow` flag support |
| [`src/dispatcher.rs`](src/dispatcher.rs) | No changes needed (policy stays in server) |
| [`tests/integration.rs`](tests/integration.rs) | New file: end-to-end server+client tests |
## Verification
After implementation:
1. `cargo test` — all existing tests still pass
2. `cargo test --test integration` — new integration tests pass
3. Manual test: start server, connect client, verify prompt appears for unapproved requests
4. Manual test: verify preapproved requests skip prompt
5. Manual test: verify session grants work (approve once, no re-prompt)
+385
View File
@@ -0,0 +1,385 @@
# n_signer → Rust Port Implementation Plan
## Overview
Port the C-based `n_signer` (located at `~/lt/n_signer`) to Rust, targeting **x86_64 Linux** only. Microcontroller implementations (ESP32, KB2040, etc.) are excluded. The Rust port will use `~/lt/nostr_core_lib_rust` as the crypto/Nostr protocol library.
## Architecture
```mermaid
graph TB
subgraph "n_signer_rust"
Main[main.rs<br/>CLI + startup + TUI loop]
Main --> SecureMem
Main --> Mnemonic
Main --> RoleWizard
Main --> Server
Main --> Policy
SecureMem[secure_mem.rs<br/>mlock + zeroize]
Mnemonic[mnemonic.rs<br/>BIP-39 via nips::nip006]
RoleTable[role_table.rs<br/>role entries + path templates]
Selector[selector.rs<br/>role resolution]
Enforcement[enforcement.rs<br/>verb/algorithm validation]
Policy[policy.rs<br/>caller access control]
Dispatcher[dispatcher.rs<br/>JSON-RPC 2.0 routing]
Dispatcher --> KeyStore
Dispatcher --> AlgCache
Dispatcher --> OtpPad
Dispatcher --> Miner
KeyStore[key_store.rs<br/>derived keys via nostr_core]
AlgCache[alg_cache.rs<br/>on-demand key derivation]
OtpPad[otp_pad.rs<br/>one-time pad encrypt/decrypt]
Miner[miner.rs<br/>NIP-13 PoW via nips::nip013]
Server[server.rs<br/>multi-transport poll loop]
Server --> Transport
Server --> AuthEnvelope
Server --> Policy
Server --> Dispatcher
Transport[transport.rs<br/>framed JSON + HTTP]
AuthEnvelope[auth_envelope.rs<br/>request authentication]
PQCrypto[pq_crypto.rs<br/>ed25519, x25519, PQ algorithms]
Tui[tui.rs<br/>terminal UI]
end
subgraph "nostr_core_lib_rust"
Core[core::<br/>keys, sha256, hmac, nip44]
Nips[nips::<br/>nip001, nip004, nip006, nip013, nip019, nip044]
SignerLib[signer::<br/>NostrSigner trait]
end
KeyStore --> Core
KeyStore --> Nips
Dispatcher --> Nips
Miner --> Nips
PQCrypto --> Core
```
## Dependency Mapping: C → Rust
| C Module | Rust Module | nostr_core_lib_rust Usage | External Crates |
|----------|-------------|--------------------------|------------------|
| `secure_mem.c` | `secure_mem.rs` | — | `zeroize`, `libc` (mlock/munlock) |
| `mnemonic.c` | `mnemonic.rs` | `nips::nip006` (BIP-39 wordlist, mnemonic_to_seed) | — |
| `role_table.c` | `role_table.rs` | — | — |
| `selector.c` | `selector.rs` | — | — |
| `enforcement.c` | `enforcement.rs` | — | — |
| `policy.c` | `policy.rs` | — | — |
| `key_store.c` | `key_store.rs` | `core::crypto::keys`, `nips::nip001`, `nips::nip004`, `nips::nip044` | `secp256k1` (via nostr_core) |
| `pq_crypto.c` | `pq_crypto.rs` | — | `ed25519-dalek`, `x25519-dalek`, `pqcrypto` (or vendored PQClean) |
| `pq_drbg.c` | `pq_drbg.rs` | — | `sha3` (SHAKE-256) |
| `dispatcher.c` | `dispatcher.rs` | `nips::nip001`, `nips::nip004`, `nips::nip044`, `nips::nip013` | `serde_json` |
| `server.c` | `server.rs` | — | `libc` (sockets, SO_PEERCRED) |
| `transport_frame.c` | `transport.rs` | — | — |
| `http_listener.c` | `http.rs` | — | — |
| `auth_envelope.c` | `auth_envelope.rs` | `core::crypto::keys` (verify) | — |
| `miner.c` | `miner.rs` | `nips::nip013` | `std::thread` |
| `otp_pad.c` | `otp_pad.rs` | — | — |
| `socket_name.c` | `socket_name.rs` | `nips::nip006` (BIP-39 wordlist for random names) | — |
| `main.c` (TUI) | `tui.rs` | — | `crossterm` or `ratatui` |
| `main.c` (CLI) | `main.rs` | — | `clap` |
## Key Design Decisions
### 1. Memory Safety
- **C**: Manual `mlock`/`munlock` + `explicit_bzero` via `secure_buf_t`
- **Rust**: `zeroize` crate with `ZeroizeOnDrop` derive. Wrap sensitive buffers in a `SecureBuf` type that calls `mlock` on alloc and `munlock`+`zeroize` on drop. Use `libc::mlock`/`libc::munlock` for the syscall.
### 2. Concurrency Model
- **C**: Single-threaded poll loop with detached pthread for `nostr_mine_event`
- **Rust**: Same model — single-threaded `poll(2)` loop via `mio` or raw `libc::poll`. Mining runs in `std::thread::spawn`. No async runtime needed (the C version is sync).
### 3. JSON Handling
- **C**: cJSON (manual parse/build)
- **Rust**: `serde_json` with typed structs for request/response
### 4. Error Handling
- **C**: Integer error codes + string messages
- **Rust**: `thiserror`-based `NsignerError` enum. The JSON-RPC error codes are preserved exactly for wire compatibility.
### 5. Transport
- **C**: Raw syscalls (`socket`, `bind`, `accept`, `SO_PEERCRED`)
- **Rust**: `std::os::unix::net::UnixListener` + `libc` for `SO_PEERCRED`. TCP via `std::net::TcpListener`. HTTP via a minimal hand-rolled parser (same as C — no framework).
### 6. TUI
- **C**: `tui_continuous` vendored library
- **Rust**: `crossterm` for terminal control + custom rendering (or `ratatui` if it fits the continuous-redraw model). Start with `crossterm` + manual rendering to match the C behavior closely.
### 7. PQ Crypto
- **C**: PQClean vendored sources + custom DRBG
- **Rust**: Use `ed25519-dalek` and `x25519-dalek` for classic curves. For PQ algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768), either:
- **Option A**: FFI to the existing PQClean C code (fastest path to working)
- **Option B**: Use `pqcrypto` crate or vendor the PQClean Rust ports
- **Recommendation**: Start with Option A (FFI) for Phase 13, migrate to pure Rust later
### 8. nostr_core_lib_rust Integration
The Rust library provides:
- `core::crypto::keys` — secp256k1 key generation, Schnorr sign/verify, ECDH
- `core::crypto::hmac` — HMAC-SHA256/512, HKDF
- `core::crypto::nip44` — NIP-44 encryption (ChaCha20-Poly1305)
- `core::types` — Event, PublicKey, SecretKey, Signature, etc.
- `nips::nip001` — `create_and_sign_event`, `validate_event`
- `nips::nip004` — NIP-04 encrypt/decrypt
- `nips::nip006` — BIP-39 mnemonic generation, validation, `mnemonic_to_seed`, `keypair_from_seed`
- `nips::nip013` — NIP-13 proof-of-work mining
- `nips::nip019` — bech32 encoding (npub, nsec)
- `signer::NostrSigner` trait — can be used for the signer abstraction
**Gap**: `nips::nip006::keypair_from_seed` currently uses the master key directly rather than doing full BIP-32 derivation through `m/44'/1237'/0'/0/0`. The port needs to implement proper BIP-32 HD derivation (either add it to `nostr_core_lib_rust` or implement locally in n_signer).
## Phased Implementation
### Phase 1: Project Scaffolding
- [ ] Create `Cargo.toml` workspace with path dependency on `~/lt/nostr_core_lib_rust`
- [ ] Create `src/` module structure matching the C source layout
- [ ] Add dependencies: `serde`, `serde_json`, `libc`, `zeroize`, `clap`, `crossterm`, `hex`, `base64`, `thiserror`, `secp256k1` (transitive via nostr_core)
- [ ] Create `build.rs` if PQClean FFI is needed
- [ ] Verify `cargo build` compiles with empty module stubs
### Phase 2: Secure Memory (`secure_mem.rs`)
- [ ] `SecureBuf` struct: holds `Vec<u8>`, `locked: bool`
- [ ] `SecureBuf::alloc(size)` — malloc + `libc::mlock` + zero init
- [ ] `SecureBuf::free()` — `zeroize` + `libc::munlock` + drop
- [ ] `secure_memzero()` — use `zeroize::zeroize()`
- [ ] `allow_unlocked()` flag for dev mode
- [ ] Implement `Drop` trait for automatic cleanup
- [ ] Unit tests: alloc/free, zeroization verification
### Phase 3: Mnemonic (`mnemonic.rs`)
- [ ] `MnemonicState` struct: holds `SecureBuf`, `loaded: bool`, `word_count: u8`
- [ ] `mnemonic_load(phrase)` — validate via `nips::nip006::mnemonic_validate`, store in `SecureBuf`
- [ ] `mnemonic_generate(word_count)` — use `nips::nip006::mnemonic_from_bytes` with `rand::thread_rng` entropy
- [ ] `mnemonic_unload()` — wipe `SecureBuf`
- [ ] Unit tests: valid/invalid mnemonics, word count validation
### Phase 4: Role Table, Selector, Enforcement
- [ ] `RolePurpose` enum: Nostr, Bitcoin, Ssh, Age, Fips, PqSig, PqKem
- [ ] `RoleCurve` enum: Secp256k1, Ed25519, X25519, MlDsa65, SlhDsa128s, MlKem768
- [ ] `RoleEntry` struct: name, purpose, curve, selector_type, path, range, allowed_indices, requires_approval
- [ ] `RoleTable` struct: Vec of entries, add/find/register methods
- [ ] Path template parser: wildcard (`*`), range (`N-M`), set (`A+B+C`), fixed path
- [ ] `SelectorRequest` struct: has_role, has_role_path, has_index
- [ ] `selector_resolve()` — match role+path against table
- [ ] `enforce_verb_role()` — check purpose==Nostr && curve==Secp256k1 for nostr verbs
- [ ] `enforce_verb_algorithm()` — check verb+algorithm validity
- [ ] Unit tests: path template parsing, selector resolution, enforcement matrix
### Phase 5: Policy (`policy.rs`)
- [ ] `PromptMode` enum: Never, FirstPerBoot, EveryRequest, Deny
- [ ] `PolicyEntry` struct: caller, verbs, roles, purposes, algorithms, index range, prompt mode, source
- [ ] `PolicyTable` struct: Vec of entries
- [ ] `policy_check()` — role-based check
- [ ] `policy_check_algorithm()` — algorithm-based check
- [ ] `policy_check_with_role()` — role-as-password shortcut (requires_approval==0 → allow)
- [ ] `parse_preapprove_spec()` — parse `caller=uid:1000,role=main,verb=sign`
- [ ] Session grants: insert before catch-all
- [ ] Unit tests: policy matching, preapprove parsing, session grants
### Phase 6: Key Store & Crypto (`key_store.rs` + `alg_cache.rs`)
- [ ] `DerivedKey` struct: private_key (SecureBuf), public_key (SecureBuf), pubkey_hex, npub, alg
- [ ] `KeyStore` struct: Vec of derived keys per role
- [ ] BIP-32 HD derivation: implement proper path derivation (`m/44'/1237'/<n>'/0/0`)
- Either add BIP-32 to `nostr_core_lib_rust` or implement locally using `secp256k1` crate
- [ ] SLIP-0010 derivation for ed25519/x25519 (HMAC-SHA512, all-hardened paths)
- [ ] `crypto_derive_all()` — derive keys for all roles
- [ ] `crypto_derive_one()` — derive for a single role (on-demand)
- [ ] `crypto_sign_event()` — via `nips::nip001::create_and_sign_event`
- [ ] `crypto_nip04_encrypt/decrypt()` — via `nips::nip004`
- [ ] `crypto_nip44_encrypt/decrypt()` — via `core::crypto::nip44`
- [ ] `AlgorithmKeyCache` — FIFO cache of on-demand derived keys (alg, index) → keypair
- [ ] Unit tests: derivation determinism, sign/verify roundtrip
### Phase 7: Dispatcher (`dispatcher.rs`)
- [ ] `DispatcherContext` struct: references to role_table, mnemonic, key_store, alg_cache
- [ ] `dispatcher_handle_request(json)` → response JSON string
- [ ] Parse JSON-RPC: id, method, params, options
- [ ] Route algorithm verbs: `get_public_key`, `sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`, `derive`
- [ ] Route nostr verbs: `nostr_get_public_key`, `nostr_sign_event`, `nostr_mine_event`, `nostr_nip04_*`, `nostr_nip44_*`
- [ ] Route OTP verbs: `encrypt`, `decrypt`
- [ ] Route metadata: `get_info`
- [ ] Error response builder with exact error codes from C API
- [ ] Unit tests: each verb with valid/invalid params
### Phase 8: Transport (`transport.rs` + `http.rs`)
- [ ] `transport_send_framed(fd, payload)` — 4-byte BE length prefix + data
- [ ] `transport_recv_framed(fd)` — read length, then payload
- [ ] `http_recv_request(fd)` — minimal HTTP/1.1 POST parser
- [ ] `http_send_response(fd, json)` — HTTP 200 + CORS headers
- [ ] `http_send_error(fd, code, message)`
- [ ] `http_send_cors_preflight(fd)` — OPTIONS response
- [ ] Unit tests: framing roundtrip, HTTP parse
### Phase 9: Server (`server.rs`)
- [ ] `ListenMode` enum: Unix, Stdio, Qrexec, Tcp, Http
- [ ] `CallerIdentity` struct: uid, gid, pid, kind, caller_id, source_qube, auth fields
- [ ] `ServerContext` struct: socket_name, listen_fd, listen_mode, dispatcher, policy, auth_mode, whitelists
- [ ] `server_start()` — bind socket (abstract Unix / TCP / HTTP)
- [ ] `server_handle_one()` — accept, read request, auth verify, policy check, dispatch, send response
- [ ] `server_get_caller()` — `SO_PEERCRED` for Unix, `getpeername` for TCP
- [ ] Bridge-source-trusted preamble handling
- [ ] Approval callback mechanism
- [ ] Non-blocking poll loop integration
- [ ] Integration tests: Unix socket roundtrip, HTTP roundtrip
### Phase 10: Miner (`miner.rs`)
- [ ] `MineResult` struct: best_event, achieved_difficulty, target_difficulty, target_reached, elapsed_sec, total_attempts
- [ ] `miner_run(event, private_key, target_difficulty, thread_count, timeout_sec)` → MineResult
- [ ] Multi-threaded: each thread tries nonces with unique stride
- [ ] Stop on target reached or timeout
- [ ] Use `nips::nip013` for PoW computation
- [ ] Unit tests: difficulty achievement, timeout behavior
### Phase 11: Auth Envelope (`auth_envelope.rs`)
- [ ] `AuthNonceCache` — per-pubkey monotonic timestamp + event ID tracking
- [ ] `auth_envelope_verify_request()` — verify auth field in JSON-RPC request
- [ ] Replay protection: check created_at > max_seen, check event_id uniqueness
- [ ] Unit tests: valid/invalid auth, replay detection
### Phase 12: OTP Pad (`otp_pad.rs`)
- [ ] `OtpPadState` struct: bound, pads_dir, chksum, pad_path, pad_file, pad_size, offset, scratch buffer
- [ ] `otp_pad_bind(dir, spec, allow_blkback)` — open pad file, read checksum, verify
- [ ] `otp_pad_encrypt(plaintext, encoding)` — XOR with pad bytes, advance offset
- [ ] `otp_pad_decrypt(ciphertext, encoding)` — reverse XOR
- [ ] ASCII armor encoding + binary encoding
- [ ] Pad offset persistence (`.state` file)
- [ ] Unit tests: encrypt/decrypt roundtrip, offset advancement
### Phase 13: PQ Crypto (`pq_crypto.rs` + `pq_drbg.rs`)
- [ ] `CryptoAlg` enum: Secp256k1, Ed25519, X25519, MlDsa65, SlhDsa128s, MlKem768
- [ ] `CryptoAlgSizes` struct: priv/pub/sig/ciphertext/shared_secret lengths
- [ ] ed25519: `ed25519-dalek` crate
- [ ] x25519: `x25519-dalek` crate
- [ ] SHAKE-256 DRBG: `sha3` crate
- [ ] ML-DSA-65, SLH-DSA-128s, ML-KEM-768: FFI to PQClean (initial), pure Rust later
- [ ] `crypto_alg_from_role()`, `crypto_alg_from_str()`, `crypto_alg_to_str()`
- [ ] Keygen, sign, verify, encaps, decaps for each algorithm
- [ ] Unit tests: keygen determinism, sign/verify roundtrip
### Phase 14: TUI (`tui.rs`)
- [ ] Terminal setup via `crossterm`
- [ ] `render_status()` — roles table, activity log, status line, menu
- [ ] `render_connections()` — on-demand connection display (press `d`)
- [ ] Approval prompt screen
- [ ] Role wizard (preset menu, path editor)
- [ ] Transport selection menu
- [ ] Mnemonic entry screen (echo disabled)
- [ ] Mnemonic generation display
- [ ] Index whitelist prompt
- [ ] OTP pad selection prompt
- [ ] Hotkeys: `l` (lock), `r` (refresh), `d` (connections), `q` (quit)
- [ ] Terminal resize handling
### Phase 15: Main (`main.rs`)
- [ ] CLI parsing with `clap`: `--socket-name`, `--listen`, `--preapprove`, `--register-role`, `--auth`, `--mnemonic-stdin`, `--mnemonic-fd`, `--allow-all`, `--bridge-source-trusted`, `--otp-pad-dir`, `--otp-pad`
- [ ] Subcommands: `client`, `bridge`, `list`
- [ ] Startup flow: mnemonic → roles → transport → socket name → server start → TUI loop
- [ ] Session lock/re-unlock
- [ ] Signal handling: SIGINT, SIGTERM, SIGPIPE (ignore)
- [ ] Shutdown: wipe all secrets, close sockets
- [ ] Non-interactive mode: `--mnemonic-stdin` / `--mnemonic-fd` / `--register-role`
### Phase 16: Integration Tests & Build
- [ ] End-to-end test: start signer, send `get_info`, `get_public_key`, `nostr_sign_event`
- [ ] Algorithm verb tests: `sign`/`verify` for each algorithm
- [ ] NIP-04/NIP-44 encrypt/decrypt roundtrip
- [ ] NIP-13 mining test
- [ ] Policy enforcement tests: allow/deny/prompt
- [ ] Multi-transport test: Unix + HTTP simultaneously
- [ ] OTP encrypt/decrypt test
- [ ] `cargo build --release` verification
- [ ] Static build target (musl) investigation
## File Structure
```
signer/
├── Cargo.toml
├── build.rs # PQClean FFI build script (Phase 13)
├── src/
│ ├── main.rs # CLI + startup + TUI loop
│ ├── lib.rs # Public API re-exports
│ ├── secure_mem.rs # SecureBuf, mlock, zeroize
│ ├── mnemonic.rs # BIP-39 mnemonic state
│ ├── role_table.rs # Role entries, path templates
│ ├── selector.rs # Role selector resolution
│ ├── enforcement.rs # Verb/role/algorithm enforcement
│ ├── policy.rs # Caller access control
│ ├── key_store.rs # Derived key storage
│ ├── alg_cache.rs # On-demand algorithm key cache
│ ├── pq_crypto.rs # ed25519, x25519, PQ algorithms
│ ├── pq_drbg.rs # SHAKE-256 DRBG for PQ keygen
│ ├── dispatcher.rs # JSON-RPC 2.0 request routing
│ ├── server.rs # Multi-transport server
│ ├── transport.rs # Length-prefixed framing
│ ├── http.rs # Minimal HTTP/1.1 parser
│ ├── auth_envelope.rs # Request authentication
│ ├── miner.rs # NIP-13 PoW mining
│ ├── otp_pad.rs # One-time pad encryption
│ ├── socket_name.rs # Abstract socket naming
│ ├── tui.rs # Terminal UI
│ └── error.rs # NsignerError enum
├── tests/
│ ├── integration_test.rs
│ ├── algorithm_test.rs
│ ├── policy_test.rs
│ └── transport_test.rs
└── resources/
└── pqclean/ # Vendored PQClean (if FFI path)
```
## Cargo.toml (Draft)
```toml
[package]
name = "nsigner"
version = "0.1.0"
edition = "2021"
[dependencies]
nostr-core = { path = "../nostr_core_lib_rust/nostr-core" }
nips = { path = "../nostr_core_lib_rust/nips" }
serde = { workspace = true }
serde_json = { workspace = true }
zeroize = { workspace = true }
libc = "0.2"
clap = { version = "4", features = ["derive"] }
crossterm = "0.27"
hex = { workspace = true }
base64 = { workspace = true }
thiserror = { workspace = true }
secp256k1 = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
rand = { workspace = true }
ed25519-dalek = "2"
x25519-dalek = "2"
sha3 = "0.10"
[dev-dependencies]
tempfile = "3"
```
## Compatibility Requirements
1. **Wire protocol**: JSON-RPC 2.0 request/response format must be byte-identical to C version
2. **Error codes**: All error codes (-32700, -32600, 1001-2009) must match exactly
3. **Derivation paths**: BIP-32/SLIP-0010 paths must produce identical keys from the same mnemonic
4. **Socket protocol**: Length-prefixed framing (4-byte BE) must be compatible
5. **HTTP**: Same minimal HTTP/1.1 POST-only parser behavior
6. **Abstract socket names**: `@nsigner_<word1>_<word2>` format preserved
## Open Questions
1. **BIP-32 derivation**: `nostr_core_lib_rust` `nips::nip006::keypair_from_seed` does not do full BIP-32 HD derivation through the path. Should we:
- (a) Add proper BIP-32 to `nostr_core_lib_rust`, or
- (b) Implement BIP-32 locally in n_signer using the `secp256k1` crate directly?
2. **PQ crypto**: Should we FFI to PQClean C code initially, or find/use Rust PQ crates?
3. **TUI scope**: Full TUI parity with C version (role wizard, transport menu, approval prompts, connection display), or start with a simpler CLI?
+155
View File
@@ -0,0 +1,155 @@
# Port `tui_continuous` C library to Rust
## Overview
Port the vendored C library `resources/tui_continuous/tui_continuous.c` (536 lines, ~16 public functions) to Rust as a standalone, well-documented module. Keep it separate from the main `tui.rs` so it can be spun out as its own crate later.
## File structure
```
src/
tui_continuous.rs <-- The ported library (new file)
tui.rs <-- Existing app-level TUI code (will call tui_continuous)
```
The existing `tui.rs` will be refactored to call `tui_continuous` primitives instead of using raw `println!`/`tprint!`/`crossterm`.
## C API surface (16 functions)
### Types to port
| C type | Rust equivalent |
|--------|----------------|
| `TuiSize { width, height }` | `pub struct TuiSize { pub width: u16, pub height: u16 }` |
| `TuiMenuItem { label, shortcut }` | `pub struct TuiMenuItem { pub label: &'static str, pub shortcut: char }` |
| `TuiFrame { app_name, app_version, breadcrumb }` | `pub struct TuiFrame { pub app_name: &'static str, pub app_version: &'static str, pub breadcrumb: &'static str }` |
| `TuiMenu { items, count }` | `pub struct TuiMenu<'a> { pub items: &'a [TuiMenuItem] }` |
| `TuiStatus { text }` | `pub struct TuiStatus<'a> { pub text: Option<&'a str> }` |
| `TuiColumn { name, width, right_align }` | `pub struct TuiColumn { pub name: &'static str, pub width: u16, pub right_align: bool }` |
| `TuiTable { columns, get_cell, ... }` | `pub struct TuiTable<'a, F> { pub columns: &'a [TuiColumn], pub row_count: usize, pub get_cell: F }` where `F: Fn(usize, usize, &mut [u8])` |
### Functions to port (in order)
| # | C function | Rust signature | Notes |
|---|-----------|---------------|-------|
| 1 | `tui_terminal_size()` | `pub fn terminal_size() -> TuiSize` | Use `crossterm::terminal::size()` |
| 2 | `tui_install_resize_handler()` | `pub fn install_resize_handler()` | Register SIGWINCH → set `g_resize_pending` flag |
| 3 | `tui_resize_pending()` | `pub fn resize_pending() -> bool` | Check and clear `g_resize_pending` |
| 4 | `tui_init()` | `pub fn init()` | Calls `crossterm::terminal::enable_raw_mode()` |
| 5 | `tui_cleanup()` | `pub fn cleanup()` | Calls `crossterm::terminal::disable_raw_mode()` |
| 6 | `tui_get_key()` | `pub fn get_key() -> Result<TuiKey, Error>` | Returns `TuiKey::Char(c)`, `TuiKey::Resize`, `TuiKey::Eof` |
| 7 | `tui_print()` | `pub fn print(fmt: std::fmt::Arguments)` | Parse `^_`→underline, `^*`→bold, `^:`→reset, `^^`→literal `^` |
| 8 | `tui_clear_continuous()` | `pub fn clear_continuous(term_height: u16)` | ANSI escape sequence to clear scrollback region |
| 9 | `tui_render_top_frame()` | `pub fn render_top_frame(frame: &TuiFrame)` | Draw `====` header with centered title + breadcrumb |
| 10 | `tui_menu_left_col()` | `pub fn menu_left_col(frame: &TuiFrame) -> u16` | Compute left column for centered menu |
| 11 | `tui_render_menu()` | `pub fn render_menu(menu: &TuiMenu, left_col: u16)` | Render each menu item via `tui_print()` |
| 12 | `tui_render_status()` | `pub fn render_status_line(status: &TuiStatus)` | Print status text if non-empty |
| 13 | `tui_anchor_prompt()` | `pub fn anchor_prompt(filler_lines: u16, left_col: u16)` | Fill blank lines and position cursor |
| 14 | `tui_render_content_screen()` | `pub fn render_content_screen(frame: &TuiFrame, title: Option<&str>)` | `clear_continuous` + `render_top_frame` + optional title |
| 15 | `tui_render_screen()` | `pub fn render_screen(frame: &TuiFrame, menu: Option<&TuiMenu>, status: Option<&TuiStatus>)` | Full screen layout with filler lines |
| 16 | `tui_render_table()` | `pub fn render_table(table: &TuiTable)` | Column-aligned table with compact mode fallback |
| 17 | `tui_read_line()` | `pub fn read_line(buf: &mut [u8]) -> Result<usize, Error>` | Read line with `fgets`-like semantics (cooked mode) |
| 18 | `tui_is_escape_input()` | `pub fn is_escape_input(input: &str) -> bool` | Check for `q`/`x`/`exit`/`quit`/`esc` |
| 19 | `tui_menu_match_key()` | `pub fn menu_match_key(menu: &TuiMenu, input: &str) -> Option<usize>` | Match single-char input to menu item shortcut |
| 20 | `tui_compute_unique_prefixes()` | `pub fn compute_unique_prefixes(ids: &[&str]) -> Vec<usize>` | Compute minimal unique prefix lengths |
| 21 | `tui_confirm()` | `pub fn confirm(prompt: &str) -> bool` | `[y/n]` with single-key in raw mode, line fallback |
| 22 | `tui_prompt_default()` | `pub fn prompt_default(prompt: &str, default: &str, out: &mut String) -> io::Result<()>` | Prompt with default value |
| 23 | `tui_press_enter()` | `pub fn press_enter(message: Option<&str>)` | Wait for any key with `tui_get_key()` |
| 24 | `tui_has_stdin_pipe()` | `pub fn has_stdin_pipe() -> bool` | Check `isatty(STDIN_FILENO)` via libc |
## Implementation details
### Hotkey markup (`tui_print`)
The `^_X^` → `\033[4mX\033[0m` (underline) and `^*X^` → `\033[1mX\033[0m` (bold) markup is central to how the C TUI renders labels. The Rust `tui_print` must:
1. Write `\r\n` at end (raw mode needs explicit CR)
2. Parse `^_` / `^*` / `^:` / `^^` sequences
3. Use `\x1b[4m` / `\x1b[1m` / `\x1b[0m` ANSI escape codes
### SIGWINCH handling
The `g_resize_pending` static flag is set by a signal handler. In Rust, use `std::sync::atomic::AtomicBool`:
```rust
use std::sync::atomic::{AtomicBool, Ordering};
static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_sigwinch(_: i32) {
RESIZE_PENDING.store(true, Ordering::SeqCst);
}
```
### `tui_get_key()` in Rust
```rust
pub enum TuiKey {
Char(char),
Eof,
Resize,
}
pub fn get_key() -> io::Result<TuiKey> {
use crossterm::event::{read, Event, KeyCode, KeyEvent};
// Check for SIGWINCH first
if RESIZE_PENDING.swap(false, Ordering::SeqCst) {
return Ok(TuiKey::Resize);
}
// Block on crossterm::event::read()
match read()? {
Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) => Ok(TuiKey::Char(c)),
Event::Resize(..) => Ok(TuiKey::Resize),
_ => Ok(TuiKey::Eof),
}
}
```
### `tui_render_table()` with callbacks
The C version uses a callback `get_cell(row, col, out, out_size, user_data)`. In Rust, use a closure:
```rust
pub struct TuiTable<'a, F: Fn(usize, usize) -> String> {
pub columns: &'a [TuiColumn],
pub row_count: usize,
pub get_cell: F,
}
```
## Refactoring main.rs
After the port is done, update `main.rs` to:
1. Remove `tui::init()`/`cleanup()` calls — use `tui_continuous::init()`/`cleanup()`
2. Use `tui_continuous::render_top_frame()` and `tui_continuous::render_table()` for the status screen
3. Use `tui_continuous::get_key()` instead of `tui::poll_key()`
4. Add `tui_continuous::install_resize_handler()` at startup
5. Add `tui_continuous::resize_pending()` check in the main loop
6. Add 'r' (refresh) and 'l' (lock/reunlock) key handlers
7. Add activity log
## Dependencies
No new dependencies needed. Uses:
- `crossterm` (already in Cargo.toml) for terminal size, raw mode
- `libc` (already in Cargo.toml) for SIGWINCH, isatty
- `std::sync::atomic` for resize flag
## Phase plan
### Phase 1: Core types and utilities
- `TuiSize`, `TuiMenuItem`, `TuiFrame`, `TuiMenu`, `TuiStatus`, `TuiColumn`, `TuiTable` types
- `terminal_size()`, `install_resize_handler()`, `resize_pending()`
- `init()`, `cleanup()`, `get_key()`
### Phase 2: Print and rendering
- `print()` with hotkey markup
- `clear_continuous()`, `render_top_frame()`, `menu_left_col()`
- `render_menu()`, `render_status_line()`, `anchor_prompt()`
- `render_content_screen()`, `render_screen()`
### Phase 3: Table rendering
- `render_table()` with compact mode fallback
- `compute_unique_prefixes()`
### Phase 4: Input helpers
- `read_line()`, `is_escape_input()`, `menu_match_key()`
- `confirm()`, `prompt_default()`, `press_enter()`, `has_stdin_pipe()`
### Phase 5: Integration
- Update `main.rs` to use `tui_continuous`
- Add activity log, lock/reunlock, refresh, SIGWINCH handling
- Remove or reduce `tui.rs` to just the high-level app screens
+150
View File
@@ -0,0 +1,150 @@
# TUI Analysis: C `tui_continuous` vs Rust `tui.rs`
## C TUI architecture
The C version uses a vendored library `tui_continuous` (v0.0.9) from `resources/tui_continuous/`. It provides:
### Rendering primitives
- `tui_clear_continuous(height)` — clear scrollback
- `tui_render_top_frame(&frame, width)` — draws a full-width box (╔═╗) with app name, version, breadcrumb
- `tui_render_table(&table)` — renders a table with header, dashed separator, aligned columns
- `tui_render_menu(&menu, left_col)` — renders menu items with shortcut keys
- `tui_render_content_screen(&frame, title)` — renders a content screen (approval, unlock, wizard)
- `tui_anchor_prompt(filler_lines, left_col)` — positions the input cursor
- `tui_print(fmt, ...)` — printf-like with hotkey markup (`^_X^` = underline, `^*X^` = bold, `^:` = reset)
### Input primitives
- `tui_init()` / `tui_cleanup()` — raw mode management
- `tui_get_key()` — single key press (returns TUI_KEY_EOF, TUI_KEY_RESIZE, or 0-255)
- `tui_read_line(buf, len)` — read a line with editing
- `tui_resize_pending()` — check for SIGWINCH
- `tui_terminal_size()` — get terminal dimensions
- `tui_install_resize_handler()` — install SIGWINCH handler
### Key types
- `TuiFrame` — `{app_name, app_version, breadcrumb}`
- `TuiMenu` — `{items[], count}`
- `TuiMenuItem` — `{label, shortcut}`
- `TuiTable` — `{columns, column_count, row_count, get_cell, ...}`
- `TuiColumn` — `{name, width, right_align}`
- `TuiSize` — `{width, height}`
## Current Rust state
The Rust `tui.rs` and `main.rs` use simple `println!` and `tprint!` (custom macro) with `crossterm` for key input. It does NOT use the `tui_continuous` conventions.
## Gap analysis
### 1. Frame rendering — missing
C calls `tui_render_top_frame(&frame, size.width)` which draws:
```
╔══════════════════════════════════════════════════════════════╗
║ n_signer v0.1.0 > Main Menu ║
╚══════════════════════════════════════════════════════════════╝
```
Rust does this manually with `tprint!` — the box art is there but not to spec.
**Fix**: Port `tui_render_top_frame()` to Rust. The C source is in `resources/tui_continuous/tui_continuous.c`.
### 2. Table rendering — missing
C uses `tui_render_table()` with `TuiColumn` config. The status screen shows:
```
Role Purpose Curve Derivation path
---- ------- ----- ---------------
main nostr secp256k1 m/44'/1237'/0'/0/0
```
Rust uses fixed-format `println!` without proper column alignment logic.
**Fix**: Port `tui_render_table()` to Rust.
### 3. Menu rendering — missing
C uses `tui_render_menu()` which renders:
```
^_l^: lock/reunlock ^_r^: refresh ^_d^: display connections ^_q^:/x quit
```
Rust uses a plain `tprint!("[d] connection details [q] quit")` without menu struct or hotkey marking.
**Fix**: Port `tui_render_menu()` to Rust.
### 4. Content screen rendering — missing
C uses `tui_render_content_screen()` for approval/unlock/wizard screens. This draws a full-screen box with breadcrumb title.
**Fix**: Port `tui_render_content_screen()` to Rust.
### 5. Hotkey markup (`^_X^`, `^*X^`, `^:`) — missing
C parses `^_` → underline, `^*` → bold, `^:` → reset. All menu labels use this. Rust doesn't support markup.
**Fix**: Port `tui_print()` with markup parsing to Rust.
### 6. `tui_get_key()` with resize detection — missing
C's `tui_get_key()` returns `TUI_KEY_RESIZE` on SIGWINCH. Rust's `poll_key()` doesn't handle this — it just returns `TuiKey::Other`.
**Fix**: Add resize detection to `poll_key()`.
### 7. Activity log — missing
C maintains a `g_activity_log` with timestamps (e.g., "2024-01-15 10:30:45 signed event"). The status screen shows "Activity (latest first):". Rust doesn't have this.
**Fix**: Add an `ActivityLog` struct and integrate it into the render loop.
### 8. Lock/reunlock ('l' key) — missing
C supports pressing 'l' to lock the session (wipe keys, unload mnemonic) and re-prompt for the mnemonic. Rust doesn't have this.
**Fix**: Add the 'l' key handler in the main loop.
### 9. Refresh ('r' key) — missing
C re-renders the status screen on 'r'. Rust doesn't handle 'r'.
**Fix**: Add the 'r' key handler.
### 10. `tui_read_line()` — missing
C has `tui_read_line()` for line input in raw mode. Rust has `read_line_raw()` which is a basic implementation. C's version handles backspace, Ctrl-U (kill line), Ctrl-W (kill word), etc.
**Fix**: Enhance `read_line_raw()` to match C's `tui_read_line()`.
### 11. Connection info struct — missing
C uses `connection_info_entry_t` with `title`, `connection_string`, `example`, `extra` fields. Rust's `render_connections()` is hardcoded.
**Fix**: Port the connection info struct and builder functions.
### 12. `tui_confirm()` — missing
C has `tui_confirm()` for [y/n] prompts. Rust doesn't have this.
**Fix**: Port `tui_confirm()` to Rust.
### 13. `tui_press_enter()` — missing
C has `tui_press_enter()` for "Press Enter to continue..." prompts. Rust uses inline `read_line()`.
**Fix**: Port `tui_press_enter()` to Rust.
### 14. `tui_has_stdin_pipe()` — missing
C detects if stdin is a pipe vs TTY. Rust doesn't have this check.
**Fix**: Port `tui_has_stdin_pipe()` to Rust.
## Summary of changes needed
| # | Feature | C function | Rust status | Effort |
|---|---------|-----------|-------------|--------|
| 1 | Frame rendering | `tui_render_top_frame()` | Manual box art | Medium |
| 2 | Table rendering | `tui_render_table()` | Fixed `println!` | Medium |
| 3 | Menu rendering | `tui_render_menu()` | Hardcoded string | Small |
| 4 | Content screen | `tui_render_content_screen()` | Missing | Medium |
| 5 | Hotkey markup | `tui_print()` | Missing | Medium |
| 6 | Key input with resize | `tui_get_key()` | Basic `poll_key()` | Small |
| 7 | Activity log | `activity_log_t` | Missing | Medium |
| 8 | Lock/reunlock | 'l' key handler | Missing | Medium |
| 9 | Refresh | 'r' key handler | Missing | Small |
| 10 | Line input | `tui_read_line()` | Basic `read_line_raw()` | Small |
| 11 | Connection info struct | `connection_info_entry_t` | Hardcoded | Small |
| 12 | Confirm prompt | `tui_confirm()` | Missing | Small |
| 13 | Press Enter | `tui_press_enter()` | Inline code | Small |
| 14 | Pipe detection | `tui_has_stdin_pipe()` | Missing | Small |
| 15 | SIGWINCH handling | `tui_install_resize_handler()` | Missing | Small |
| 16 | Approval callback | `tui_approval_cb()` | Inline in server.rs | Medium |
## Implementation strategy
The cleanest approach is to port the `tui_continuous` C library to Rust as a self-contained module, then update `tui.rs` and `main.rs` to use it. This avoids the formatting mismatch because the C version's `tui_print()` with `\r\n` is the correct approach for raw mode.
+207
View File
@@ -0,0 +1,207 @@
//! Algorithm key cache — on-demand key derivation by (algorithm, index).
//!
//! Port of the algorithm_key_cache from `key_store.c`. Holds up to
//! ALG_KEY_CACHE_MAX derived keys in secure memory. FIFO eviction.
use crate::mnemonic::MnemonicState;
use crate::pq_crypto::CryptoAlg;
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
pub const ALG_KEY_CACHE_MAX: usize = 32;
/// A cached algorithm key entry.
pub struct AlgKeyEntry {
pub alg: CryptoAlg,
pub index: i32,
pub private_key: SecureBuf,
pub public_key: SecureBuf,
pub pubkey_hex: String,
pub key_id: String, // first 16 hex chars of pubkey
pub valid: bool,
}
/// Algorithm key cache — FIFO eviction when full.
pub struct AlgorithmKeyCache {
entries: Vec<AlgKeyEntry>,
}
impl AlgorithmKeyCache {
pub fn new() -> Self {
AlgorithmKeyCache {
entries: Vec::with_capacity(ALG_KEY_CACHE_MAX),
}
}
/// Look up a cached entry by (alg, index).
pub fn get(&self, alg: CryptoAlg, index: i32) -> Option<&AlgKeyEntry> {
self.entries
.iter()
.find(|e| e.alg == alg && e.index == index && e.valid)
}
/// Derive a key on-demand by (alg, index) and store it in the cache.
/// Uses the standard derivation path for the algorithm.
pub fn derive(
&mut self,
mnemonic: &MnemonicState,
alg: CryptoAlg,
index: i32,
) -> Result<(), NsignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
}
// Already cached?
if self.get(alg, index).is_some() {
return Ok(());
}
// Evict oldest if full (FIFO)
if self.entries.len() >= ALG_KEY_CACHE_MAX {
self.entries.remove(0);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
// Build the standard derivation path for this algorithm
let path = match alg {
CryptoAlg::Secp256k1 => format!("m/44'/1237'/{}'/0/0", index),
CryptoAlg::Ed25519 => format!("m/44'/102001'/{}'/0'/0'", index),
CryptoAlg::X25519 => format!("m/44'/102002'/{}'/0'/0'", index),
CryptoAlg::MlDsa65 => format!("m/44'/102003'/{}'/0'/0'", index),
CryptoAlg::SlhDsa128s => format!("m/44'/102004'/{}'/0'/0'", index),
CryptoAlg::MlKem768 => format!("m/44'/102005'/{}'/0'/0'", index),
CryptoAlg::Unknown => return Err(NsignerError::InvalidInput),
};
let entry = derive_alg_key(phrase, &path, alg, index)?;
self.entries.push(entry);
Ok(())
}
/// Zeroize and free all entries.
pub fn wipe(&mut self) {
self.entries.clear();
}
}
impl Default for AlgorithmKeyCache {
fn default() -> Self {
Self::new()
}
}
/// Derive a single algorithm key entry.
fn derive_alg_key(
mnemonic_phrase: &str,
path: &str,
alg: CryptoAlg,
index: i32,
) -> Result<AlgKeyEntry, NsignerError> {
let sizes = alg
.sizes()
.ok_or(NsignerError::KeyDerivationFailed)?;
let seed = crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
match alg {
CryptoAlg::Secp256k1 => {
// Full BIP-32 derivation
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic_phrase, "");
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
let path_indices = nips::nip006::parse_bip44_path(path)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
let (derived_key, _) = nips::nip006::bip32_derive_path(
&master_key,
&master_chain_code,
&path_indices,
)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&derived_key);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
let pk = nostr_core::crypto::keys::public_key_from_secret_key(&sk)
.map_err(|_| NsignerError::CryptoFailed)?;
let pubkey_hex = hex::encode(pk.as_bytes());
let key_id = if pubkey_hex.len() >= 16 {
pubkey_hex[..16].to_string()
} else {
pubkey_hex.clone()
};
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_arr);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from_slice(pk.as_bytes());
Ok(AlgKeyEntry {
alg,
index,
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
key_id,
valid: true,
})
}
CryptoAlg::Ed25519 => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed);
let pubkey_hex = hex::encode(&pub_bytes);
let key_id = if pubkey_hex.len() >= 16 {
pubkey_hex[..16].to_string()
} else {
pubkey_hex.clone()
};
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(&pub_bytes);
Ok(AlgKeyEntry {
alg,
index,
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
key_id,
valid: true,
})
}
CryptoAlg::X25519 => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed);
let pubkey_hex = hex::encode(&pub_bytes);
let key_id = if pubkey_hex.len() >= 16 {
pubkey_hex[..16].to_string()
} else {
pubkey_hex.clone()
};
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(&pub_bytes);
Ok(AlgKeyEntry {
alg,
index,
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
key_id,
valid: true,
})
}
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
// PQ algorithms — TODO: Phase 13
Err(NsignerError::NotYetImplemented)
}
CryptoAlg::Unknown => Err(NsignerError::InvalidInput),
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Auth envelope — request authentication for TCP/qrexec transports.
//!
//! Port of `auth_envelope.c`. Verifies a NIP-42-style auth envelope
//! in JSON-RPC requests. Checks signature, created_at freshness,
//! and replay protection.
use nostr_core::types::Event;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
/// NIP-42 auth event kind.
pub const AUTH_EVENT_KIND: u64 = 22242;
/// Default timestamp skew tolerance (seconds).
pub const AUTH_DEFAULT_SKEW_SECONDS: i32 = 300;
/// Auth nonce cache for replay protection.
///
/// Tracks per-pubkey monotonic timestamps + event IDs for same-second requests.
pub struct AuthNonceCache {
// pubkey_hex → (max_created_at, event_ids)
entries: HashMap<String, (i64, Vec<[u8; 32]>)>,
}
impl AuthNonceCache {
pub fn new() -> Self {
AuthNonceCache {
entries: HashMap::new(),
}
}
/// Check and update replay protection.
///
/// Returns true if the (pubkey, created_at, event_id) tuple is acceptable.
/// Returns false if it's a replay.
pub fn check_and_update(
&mut self,
pubkey_hex: &str,
created_at: i64,
event_id: &[u8; 32],
) -> bool {
let entry = self
.entries
.entry(pubkey_hex.to_string())
.or_insert((0, Vec::new()));
if created_at > entry.0 {
// Newer timestamp: update max, clear event ID set, accept
entry.0 = created_at;
entry.1.clear();
entry.1.push(*event_id);
true
} else if created_at == entry.0 {
// Same timestamp: check event ID set for duplicates
if entry.1.contains(event_id) {
false // duplicate event ID
} else {
entry.1.push(*event_id);
true
}
} else {
// Older timestamp: reject as replay
false
}
}
}
impl Default for AuthNonceCache {
fn default() -> Self {
Self::new()
}
}
/// Auth error codes.
pub const AUTH_ERR_ENVELOPE_MALFORMED: i32 = 3001;
pub const AUTH_ERR_SIGNATURE_INVALID: i32 = 3002;
pub const AUTH_ERR_KIND_INVALID: i32 = 3003;
pub const AUTH_ERR_ENVELOPE_MISMATCH: i32 = 3004;
pub const AUTH_ERR_BODY_MISMATCH: i32 = 3005;
pub const AUTH_ERR_ENVELOPE_STALE: i32 = 3006;
pub const AUTH_ERR_REPLAY_DETECTED: i32 = 3007;
pub const AUTH_ERR_ENVELOPE_REQUIRED: i32 = 3008;
/// Verify an auth envelope in a JSON-RPC request.
///
/// On success, returns (pubkey_hex, label).
/// On failure, returns (error_code, error_message).
pub fn verify_request(
request_json: &str,
cache: &mut AuthNonceCache,
skew_seconds: i32,
) -> Result<(String, String), (i32, &'static str)> {
let root: serde_json::Value = serde_json::from_str(request_json)
.map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
// Extract method and id from the request
let method = root
.get("method")
.and_then(|v| v.as_str())
.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
let request_id = root
.get("id")
.map(|v| {
if let Some(s) = v.as_str() {
s.to_string()
} else {
v.to_string()
}
})
.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
// Extract auth envelope
let auth = root
.get("auth")
.ok_or((AUTH_ERR_ENVELOPE_REQUIRED, "auth_envelope_required"))?;
// Parse auth as a Nostr event
let auth_event: Event = serde_json::from_value(auth.clone())
.map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
// Validate event structure
nips::nip001::validate_event_structure(&auth_event)
.map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
// Verify event signature
nips::nip001::verify_event_signature(&auth_event)
.map_err(|_| (AUTH_ERR_SIGNATURE_INVALID, "auth_signature_invalid"))?;
// Check kind is AUTH_EVENT_KIND (22242)
if auth_event.kind.as_u64() != AUTH_EVENT_KIND {
return Err((AUTH_ERR_KIND_INVALID, "auth_kind_invalid"));
}
// Extract tags: nsigner_rpc, nsigner_method, nsigner_body_hash
let mut tag_rpc: Option<String> = None;
let mut tag_method: Option<String> = None;
let mut tag_body_hash: Option<String> = None;
for tag in &auth_event.tags {
if tag.kind() == "nsigner_rpc" {
tag_rpc = tag.get(1).map(|s| s.to_string());
} else if tag.kind() == "nsigner_method" {
tag_method = tag.get(1).map(|s| s.to_string());
} else if tag.kind() == "nsigner_body_hash" {
tag_body_hash = tag.get(1).map(|s| s.to_string());
}
}
let tag_rpc = tag_rpc.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
let tag_method =
tag_method.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
let tag_body_hash =
tag_body_hash.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?;
// Verify tags match request
if tag_rpc != request_id || tag_method != method {
return Err((AUTH_ERR_ENVELOPE_MISMATCH, "auth_envelope_mismatch"));
}
// Compute body hash (SHA-256 of the params array)
let params = root.get("params").unwrap_or(&serde_json::Value::Null);
let params_compact = serde_json::to_string(params).unwrap_or_default();
let body_hash = nostr_core::crypto::sha256::sha256(params_compact.as_bytes());
let body_hash_hex = hex::encode(&body_hash);
if !tag_body_hash.eq_ignore_ascii_case(&body_hash_hex) {
return Err((AUTH_ERR_BODY_MISMATCH, "auth_body_mismatch"));
}
// Check timestamp skew
let skew = if skew_seconds <= 0 {
AUTH_DEFAULT_SKEW_SECONDS
} else {
skew_seconds
};
let now = current_timestamp();
let created = auth_event.created_at as i64;
if (now - created).abs() > skew as i64 {
return Err((AUTH_ERR_ENVELOPE_STALE, "auth_envelope_stale"));
}
// Extract pubkey
let pubkey_hex = auth_event.pubkey.to_string();
// Extract event ID for replay protection
let event_id = match &auth_event.id {
Some(id) => id.as_bytes(),
None => return Err((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed")),
};
// Replay protection
if !cache.check_and_update(&pubkey_hex, created, event_id) {
return Err((AUTH_ERR_REPLAY_DETECTED, "auth_replay_detected"));
}
// Extract label from content
let label = auth_event.content.clone();
Ok((pubkey_hex, label))
}
/// Get current Unix timestamp in seconds.
fn current_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nonce_cache_new_timestamp() {
let mut cache = AuthNonceCache::new();
let event_id = [1u8; 32];
assert!(cache.check_and_update("pubkey1", 1000, &event_id));
}
#[test]
fn test_nonce_cache_same_timestamp_different_id() {
let mut cache = AuthNonceCache::new();
let id1 = [1u8; 32];
let id2 = [2u8; 32];
assert!(cache.check_and_update("pubkey1", 1000, &id1));
assert!(cache.check_and_update("pubkey1", 1000, &id2));
}
#[test]
fn test_nonce_cache_replay_rejected() {
let mut cache = AuthNonceCache::new();
let event_id = [1u8; 32];
assert!(cache.check_and_update("pubkey1", 1000, &event_id));
// Same timestamp + same event ID = replay
assert!(!cache.check_and_update("pubkey1", 1000, &event_id));
}
#[test]
fn test_nonce_cache_older_timestamp_rejected() {
let mut cache = AuthNonceCache::new();
let event_id = [1u8; 32];
assert!(cache.check_and_update("pubkey1", 1000, &event_id));
// Older timestamp = replay
assert!(!cache.check_and_update("pubkey1", 999, &event_id));
}
#[test]
fn test_nonce_cache_newer_timestamp_clears() {
let mut cache = AuthNonceCache::new();
let id1 = [1u8; 32];
let id2 = [2u8; 32];
assert!(cache.check_and_update("pubkey1", 1000, &id1));
// Newer timestamp clears the set, so id1 is acceptable again
assert!(cache.check_and_update("pubkey1", 1001, &id1));
assert!(cache.check_and_update("pubkey1", 1001, &id2));
}
#[test]
fn test_nonce_cache_different_pubkeys_independent() {
let mut cache = AuthNonceCache::new();
let event_id = [1u8; 32];
assert!(cache.check_and_update("pubkey1", 1000, &event_id));
// Different pubkey with same timestamp + event ID is fine
assert!(cache.check_and_update("pubkey2", 1000, &event_id));
}
#[test]
fn test_verify_request_no_auth() {
let mut cache = AuthNonceCache::new();
let request = r#"{"id":"1","method":"get_info","params":[]}"#;
let result = verify_request(request, &mut cache, 300);
assert_eq!(result, Err((AUTH_ERR_ENVELOPE_REQUIRED, "auth_envelope_required")));
}
#[test]
fn test_verify_request_malformed_json() {
let mut cache = AuthNonceCache::new();
let result = verify_request("not valid json", &mut cache, 300);
assert_eq!(result, Err((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed")));
}
}
+757
View File
@@ -0,0 +1,757 @@
//! Dispatcher — JSON-RPC 2.0 request routing.
//!
//! Port of `dispatcher.c`. Routes verbs to the appropriate handler
//! (algorithm-based, nostr, OTP, or metadata).
use crate::alg_cache::AlgorithmKeyCache;
use crate::enforcement;
use crate::error::RpcError;
use crate::key_store::KeyStore;
use crate::mnemonic::MnemonicState;
use crate::pq_crypto::CryptoAlg;
use crate::role_table::RoleTable;
use crate::selector::{selector_resolve, SelectorRequest};
use crate::NsignerError;
use serde_json::{json, Value};
use base64::Engine;
/// Dispatcher context — holds references to shared state.
pub struct DispatcherContext<'a> {
pub role_table: &'a mut RoleTable,
pub mnemonic: &'a MnemonicState,
pub key_store: &'a mut KeyStore,
pub alg_key_cache: &'a mut AlgorithmKeyCache,
}
/// Process a JSON-RPC request string and produce a JSON-RPC response string.
///
/// Response format on success: `{"id":"...","result":"..."}`
/// Response format on error: `{"id":"...","error":{"code":N,"message":"..."}}`
pub fn handle_request(ctx: &mut DispatcherContext, json_request: &str) -> String {
let root: Value = match serde_json::from_str(json_request) {
Ok(v) => v,
Err(_) => return make_error_response("null", RpcError::PARSE_ERROR),
};
let id = root
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("null")
.to_string();
let method = match root.get("method").and_then(|v| v.as_str()) {
Some(m) => m,
None => return make_error_response(&id, RpcError::INVALID_REQUEST),
};
let params = match root.get("params") {
Some(p) if p.is_array() => p.as_array().unwrap(),
_ => return make_error_response(&id, RpcError::INVALID_REQUEST),
};
// Extract options (last param if it's an object)
let options = params
.last()
.and_then(|v| if v.is_object() { Some(v) } else { None });
// ── Route ──────────────────────────────────────────────────────────
// Algorithm-based verbs (bypass role table)
if enforcement::is_algorithm_verb(method) {
return handle_algorithm_verb(ctx, &id, method, params, options);
}
// get_info (metadata; no key material)
if method == enforcement::VERB_GET_INFO {
return handle_get_info(&id);
}
// OTP verbs (encrypt/decrypt with algorithm:"otp")
if method == enforcement::VERB_ENCRYPT || method == enforcement::VERB_DECRYPT {
if let Some(opts) = options {
if opts.get("algorithm").and_then(|v| v.as_str()) == Some("otp") {
return handle_otp_verb(&id, method, params);
}
}
// Non-OTP encrypt/decrypt with other algorithms
return make_error_response(&id, RpcError::ALGORITHM_NOT_SUPPORTED);
}
// Nostr verbs (role-based)
if is_nostr_verb(method) {
return handle_nostr_verb(ctx, &id, method, params, options);
}
make_error_response(&id, RpcError::METHOD_NOT_FOUND)
}
// ── Algorithm Verb Handler ───────────────────────────────────────────────────
fn handle_algorithm_verb(
ctx: &mut DispatcherContext,
id: &str,
method: &str,
params: &[Value],
options: Option<&Value>,
) -> String {
let alg = match options.and_then(|o| o.get("algorithm")).and_then(|v| v.as_str()) {
Some(s) => CryptoAlg::from_str(s),
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
if alg == CryptoAlg::Unknown {
return make_error_response(
id,
RpcError {
code: -32602,
message: "missing_or_invalid_algorithm",
},
);
}
let index = options
.and_then(|o| o.get("index"))
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
// Enforcement: check verb+algorithm validity
if let Err(_) = enforcement::enforce_verb_algorithm(method, alg) {
return make_error_response(id, RpcError::ALGORITHM_NOT_SUPPORTED);
}
// Mnemonic must be loaded
if !ctx.mnemonic.is_loaded() {
return make_error_response(id, RpcError::MNEMONIC_NOT_LOADED);
}
// Derive the key on demand
if let Err(_) = ctx.alg_key_cache.derive(ctx.mnemonic, alg, index) {
return make_error_response(
id,
RpcError {
code: -32602,
message: "key_derivation_failed",
},
);
}
let key_entry = match ctx.alg_key_cache.get(alg, index) {
Some(e) => e,
None => {
return make_error_response(
id,
RpcError {
code: -32602,
message: "key_derivation_failed",
},
)
}
};
let alg_name = alg.as_str();
let key_id = &key_entry.key_id;
// ── Dispatch by verb ──────────────────────────────────────────────
match method {
enforcement::VERB_GET_PUBLIC_KEY => {
let result = json!({
"algorithm": alg_name,
"public_key": key_entry.pubkey_hex,
"key_id": key_id,
});
make_success_response(id, &result.to_string())
}
enforcement::VERB_SIGN => {
let msg_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let msg_bytes = match hex::decode(msg_hex) {
Ok(b) => b,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
if msg_bytes.is_empty() {
return make_error_response(id, RpcError::INVALID_PARAMS);
}
let priv_slice = key_entry.private_key.as_slice();
let sig = sign_with_alg(alg, &priv_slice[..32].try_into().unwrap(), &msg_bytes);
match sig {
Ok(s) => {
let sig_hex = hex::encode(&s);
let result = json!({
"algorithm": alg_name,
"key_id": key_id,
"signature": sig_hex,
});
make_success_response(id, &result.to_string())
}
Err(_) => make_error_response(
id,
RpcError {
code: -32602,
message: "signing_failed",
},
),
}
}
enforcement::VERB_VERIFY => {
let msg_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let sig_hex = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let msg_bytes = match hex::decode(msg_hex) {
Ok(b) => b,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let sig_bytes = match hex::decode(sig_hex) {
Ok(b) => b,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let pub_slice = key_entry.public_key.as_slice();
let valid = verify_with_alg(alg, pub_slice, &msg_bytes, &sig_bytes);
let result = json!({
"valid": valid,
"algorithm": alg_name,
});
make_success_response(id, &result.to_string())
}
enforcement::VERB_DERIVE_SHARED => {
if alg != CryptoAlg::X25519 {
return make_error_response(id, RpcError::ALGORITHM_NOT_SUPPORTED);
}
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let peer_bytes = match hex::decode(peer_hex) {
Ok(b) if b.len() == 32 => b,
_ => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let priv_slice = key_entry.private_key.as_slice();
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_slice[..32]);
let peer_arr: [u8; 32] = peer_bytes[..32].try_into().unwrap();
let shared = crate::pq_crypto::x25519_ecdh(&priv_arr, &peer_arr);
let shared_hex = hex::encode(&shared);
let result = json!({
"shared_secret": shared_hex,
"algorithm": "x25519",
});
make_success_response(id, &result.to_string())
}
enforcement::VERB_DERIVE => {
// HMAC-SHA256(privkey, data) — index is required
let data_str = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
// index is required for derive (no default)
let has_index = options
.and_then(|o| o.get("index"))
.and_then(|v| v.as_i64())
.is_some();
if !has_index {
return make_error_response(
id,
RpcError {
code: -32602,
message: "missing_index",
},
);
}
let priv_slice = key_entry.private_key.as_slice();
let mac = nostr_core::crypto::hmac::hmac_sha256(priv_slice, data_str.as_bytes());
let mac_hex = hex::encode(&mac);
let result = json!({
"algorithm": alg_name,
"key_id": key_id,
"digest": mac_hex,
});
make_success_response(id, &result.to_string())
}
enforcement::VERB_ENCAPSULATE => {
// ML-KEM-768 only — TODO: Phase 13
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
}
enforcement::VERB_DECAPSULATE => {
// ML-KEM-768 only — TODO: Phase 13
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
}
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
}
}
// ── Nostr Verb Handler ───────────────────────────────────────────────────────
fn handle_nostr_verb(
ctx: &mut DispatcherContext,
id: &str,
method: &str,
params: &[Value],
options: Option<&Value>,
) -> String {
// Parse selector from options
let mut sel = SelectorRequest::new();
if let Some(opts) = options {
if let Some(role) = opts.get("role").and_then(|v| v.as_str()) {
sel.has_role = true;
sel.role_name = role.to_string();
}
if let Some(path) = opts.get("role_path").and_then(|v| v.as_str()) {
sel.has_role_path = true;
sel.role_path = path.to_string();
}
}
// Resolve selector
let role_index = match selector_resolve(&sel, ctx.role_table) {
Ok(i) => i,
Err(e) => {
return make_error_response(
id,
match e {
crate::selector::SelectorError::Ambiguous => RpcError::AMBIGUOUS_ROLE_SELECTOR,
crate::selector::SelectorError::NotFound => RpcError::UNKNOWN_ROLE,
crate::selector::SelectorError::NoDefault => RpcError::NO_DEFAULT_ROLE,
crate::selector::SelectorError::PathMismatch => RpcError::PATH_NOT_ALLOWED,
crate::selector::SelectorError::RoleRequired => RpcError::ROLE_REQUIRED,
crate::selector::SelectorError::PathRequired => RpcError::PATH_REQUIRED,
_ => RpcError::INVALID_PARAMS,
},
)
}
};
// Enforce verb+role
let role = &ctx.role_table.entries[role_index];
if let Err(_) = enforcement::enforce_verb_role(method, role) {
return make_error_response(
id,
RpcError {
code: 1004,
message: "purpose_mismatch",
},
);
}
// Mnemonic must be loaded
if !ctx.mnemonic.is_loaded() {
return make_error_response(id, RpcError::MNEMONIC_NOT_LOADED);
}
// Ensure key is derived
if !role.derived {
if let Err(_) = ctx
.key_store
.derive_one(ctx.role_table, ctx.mnemonic, role_index)
{
return make_error_response(
id,
RpcError {
code: -32602,
message: "key_derivation_failed",
},
);
}
}
// Dispatch by verb
match method {
enforcement::VERB_NOSTR_GET_PUBLIC_KEY => {
let pub_hex = ctx.key_store.get_pubkey_hex(role_index).unwrap_or("");
// Check for structured format option
let want_structured = options
.and_then(|o| o.get("format"))
.and_then(|v| v.as_str())
== Some("structured");
if want_structured {
let key_id = if pub_hex.len() >= 16 {
&pub_hex[..16]
} else {
&pub_hex
};
let result = json!({
"algorithm": "secp256k1",
"public_key": pub_hex,
"key_id": key_id,
});
make_success_response(id, &result.to_string())
} else {
// Plain hex string
make_success_response(id, &format!("\"{}\"", pub_hex))
}
}
enforcement::VERB_NOSTR_SIGN_EVENT => {
let event_json = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.sign_event(role_index, event_json) {
Ok(signed) => make_success_response(id, &format!("\"{}\"", signed)),
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
enforcement::VERB_NOSTR_MINE_EVENT => {
// TODO: Phase 10 — NIP-13 mining
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
}
enforcement::VERB_NOSTR_NIP44_ENCRYPT => {
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let plaintext = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.nip44_encrypt(role_index, peer_hex, plaintext) {
Ok(ct) => {
let ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct);
make_success_response(id, &format!("\"{}\"", ct_b64))
}
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
enforcement::VERB_NOSTR_NIP44_DECRYPT => {
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ciphertext_b64 = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ct = match base64::engine::general_purpose::STANDARD.decode(ciphertext_b64) {
Ok(b) => b,
Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.nip44_decrypt(role_index, peer_hex, &ct) {
Ok(pt) => {
let pt_b64 = base64::engine::general_purpose::STANDARD.encode(&pt);
make_success_response(id, &format!("\"{}\"", pt_b64))
}
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
enforcement::VERB_NOSTR_NIP04_ENCRYPT => {
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let plaintext = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.nip04_encrypt(role_index, peer_hex, plaintext) {
Ok(ct) => make_success_response(id, &format!("\"{}\"", ct)),
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
enforcement::VERB_NOSTR_NIP04_DECRYPT => {
let peer_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ciphertext = match params.get(1).and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.nip04_decrypt(role_index, peer_hex, ciphertext) {
Ok(pt) => make_success_response(id, &format!("\"{}\"", pt)),
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
}
}
// ── OTP Verb Handler ─────────────────────────────────────────────────────────
fn handle_otp_verb(id: &str, method: &str, _params: &[Value]) -> String {
// TODO: Phase 12 — OTP pad encrypt/decrypt
let _ = method;
make_error_response(
id,
RpcError {
code: -32601,
message: "otp_pad_not_bound",
},
)
}
// ── get_info ────────────────────────────────────────────────────────────────
fn handle_get_info(id: &str) -> String {
let info = json!({
"name": "n_signer",
"implementation": "host",
"version": crate::VERSION,
"api": "json-rpc-2.0",
"verbs": [
"get_info", "get_public_key", "sign", "verify",
"encapsulate", "decapsulate", "derive_shared_secret", "derive",
"encrypt", "decrypt",
"nostr_get_public_key", "nostr_sign_event", "nostr_mine_event",
"nostr_nip04_encrypt", "nostr_nip04_decrypt",
"nostr_nip44_encrypt", "nostr_nip44_decrypt",
],
"algorithms": [
"secp256k1", "ed25519", "x25519",
"ml-dsa-65", "slh-dsa-128s", "ml-kem-768", "otp",
],
});
make_success_response(id, &info.to_string())
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn is_nostr_verb(verb: &str) -> bool {
matches!(
verb,
enforcement::VERB_NOSTR_GET_PUBLIC_KEY
| enforcement::VERB_NOSTR_SIGN_EVENT
| enforcement::VERB_NOSTR_MINE_EVENT
| enforcement::VERB_NOSTR_NIP44_ENCRYPT
| enforcement::VERB_NOSTR_NIP44_DECRYPT
| enforcement::VERB_NOSTR_NIP04_ENCRYPT
| enforcement::VERB_NOSTR_NIP04_DECRYPT
)
}
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8; 32], msg: &[u8]) -> Result<Vec<u8>, NsignerError> {
match alg {
CryptoAlg::Secp256k1 => {
// Check for scheme option (schnorr default, ecdsa alternative)
// For now, default to schnorr
let sk = nostr_core::types::SecretKey::from_bytes(*priv_key);
let digest = nostr_core::crypto::sha256::sha256(msg);
let sig = nostr_core::crypto::keys::schnorr_sign(&sk, &digest)?;
Ok(sig.as_bytes().to_vec())
}
CryptoAlg::Ed25519 => {
let sig = crate::pq_crypto::ed25519_sign(priv_key, msg);
Ok(sig.to_vec())
}
CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_sign(priv_key, msg),
CryptoAlg::SlhDsa128s => crate::pq_crypto::slh_dsa_128s_sign(priv_key, msg),
_ => Err(NsignerError::CryptoFailed),
}
}
fn verify_with_alg(alg: CryptoAlg, pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
match alg {
CryptoAlg::Secp256k1 => {
if pub_key.len() != 32 || sig.len() != 64 {
return false;
}
// Use schnorr verify (default)
let pub_arr: [u8; 32] = pub_key[..32].try_into().unwrap();
let sig_arr: [u8; 64] = sig[..64].try_into().unwrap();
let digest = nostr_core::crypto::sha256::sha256(msg);
let pk = nostr_core::types::PublicKey::from_bytes(pub_arr);
let signature = nostr_core::types::Signature::from_bytes(sig_arr);
nostr_core::crypto::keys::schnorr_verify(&pk, &digest, &signature).unwrap_or(false)
}
CryptoAlg::Ed25519 => {
if pub_key.len() != 32 || sig.len() != 64 {
return false;
}
let pub_arr: [u8; 32] = pub_key[..32].try_into().unwrap();
let sig_arr: [u8; 64] = sig[..64].try_into().unwrap();
crate::pq_crypto::ed25519_verify(&pub_arr, msg, &sig_arr)
}
CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_verify(pub_key, msg, sig),
CryptoAlg::SlhDsa128s => crate::pq_crypto::slh_dsa_128s_verify(pub_key, msg, sig),
_ => false,
}
}
fn make_error_response(id: &str, err: RpcError) -> String {
format!(
r#"{{"id":"{}","error":{}}}"#,
id,
err.to_json()
)
}
fn make_success_response(id: &str, result: &str) -> String {
// result is already a JSON value string — wrap it as a JSON string
format!(r#"{{"id":"{}","result":{}}}"#, id, result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alg_cache::AlgorithmKeyCache;
use crate::key_store::KeyStore;
use crate::mnemonic::MnemonicState;
use crate::role_table::*;
fn make_ctx<'a>(
role_table: &'a mut RoleTable,
mnemonic: &'a MnemonicState,
key_store: &'a mut KeyStore,
alg_cache: &'a mut AlgorithmKeyCache,
) -> DispatcherContext<'a> {
DispatcherContext {
role_table,
mnemonic,
key_store,
alg_key_cache: alg_cache,
}
}
fn setup() -> (
RoleTable,
MnemonicState,
KeyStore,
AlgorithmKeyCache,
) {
let mut mnemonic = MnemonicState::new();
mnemonic
.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
.unwrap();
let mut table = RoleTable::new();
table
.register_role_path(
"main",
"m/44'/1237'/0'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
-1, -1, -1, &[],
)
.unwrap();
let mut store = KeyStore::new();
store.derive_all(&mut table, &mnemonic).unwrap();
(table, mnemonic, store, AlgorithmKeyCache::new())
}
#[test]
fn test_get_info() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"1","method":"get_info","params":[]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""));
assert!(resp.contains("n_signer"));
assert!(resp.contains("json-rpc-2.0"));
}
#[test]
fn test_nostr_get_public_key() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"2","method":"nostr_get_public_key","params":[],"params":[{},{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#;
// Use proper format
let req = r#"{"id":"2","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""));
assert!(!resp.contains("\"error\""));
}
#[test]
fn test_unknown_method() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"3","method":"nonexistent_method","params":[]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"error\""));
assert!(resp.contains("-32601"));
}
#[test]
fn test_parse_error() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"not valid json"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"error\""));
assert!(resp.contains("-32700"));
}
#[test]
fn test_ed25519_get_public_key() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"4","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""));
assert!(resp.contains("ed25519"));
}
#[test]
fn test_ed25519_sign_verify() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"5","method":"sign","params":["{}"],{{"algorithm":"ed25519","index":0}}}}"#,
msg_hex
);
// Fix JSON format
let sign_req = format!(
r#"{{"id":"5","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
msg_hex
);
let resp = handle_request(&mut ctx, &sign_req);
assert!(resp.contains("\"result\""), "sign response: {}", resp);
assert!(resp.contains("signature"));
// Extract signature from response
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
// Verify
let verify_req = format!(
r#"{{"id":"6","method":"verify","params":["{}","{}",{{"algorithm":"ed25519","index":0}}]}}"#,
msg_hex, sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":true"));
}
#[test]
fn test_missing_algorithm() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"7","method":"get_public_key","params":[{}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"error\""));
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Enforcement — verb/role/algorithm validation.
//!
//! Port of `enforcement.c`. Checks whether a verb is allowed
//! to execute against a role (purpose/curve) or algorithm.
use crate::role_table::{RoleCurve, RoleEntry, RolePurpose};
// ── Error Codes ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnforceError {
Ok = 0,
PurposeMismatch = -1,
CurveMismatch = -2,
UnknownVerb = -3,
Algorithm = -4,
}
// ── Verb Constants ───────────────────────────────────────────────────────────
// Algorithm-based verbs (algorithm is a parameter, not implicit).
pub const VERB_SIGN: &str = "sign";
pub const VERB_VERIFY: &str = "verify";
pub const VERB_ENCAPSULATE: &str = "encapsulate";
pub const VERB_DECAPSULATE: &str = "decapsulate";
pub const VERB_DERIVE_SHARED: &str = "derive_shared_secret";
pub const VERB_DERIVE: &str = "derive";
pub const VERB_GET_PUBLIC_KEY: &str = "get_public_key";
// Nostr protocol verbs (secp256k1 NIP-06, role-based selector).
pub const VERB_NOSTR_GET_PUBLIC_KEY: &str = "nostr_get_public_key";
pub const VERB_NOSTR_SIGN_EVENT: &str = "nostr_sign_event";
pub const VERB_NOSTR_MINE_EVENT: &str = "nostr_mine_event";
pub const VERB_NOSTR_NIP44_ENCRYPT: &str = "nostr_nip44_encrypt";
pub const VERB_NOSTR_NIP44_DECRYPT: &str = "nostr_nip44_decrypt";
pub const VERB_NOSTR_NIP04_ENCRYPT: &str = "nostr_nip04_encrypt";
pub const VERB_NOSTR_NIP04_DECRYPT: &str = "nostr_nip04_decrypt";
// OTP verbs (one-time pad; selected via algorithm:"otp").
pub const VERB_ENCRYPT: &str = "encrypt";
pub const VERB_DECRYPT: &str = "decrypt";
// Metadata verb (no key material, no approval, no role required).
pub const VERB_GET_INFO: &str = "get_info";
// ── Role-based Enforcement ───────────────────────────────────────────────────
/// Check whether `verb` is allowed to execute against `role`.
///
/// Rules:
/// - All nostr verbs require purpose == Nostr and curve == Secp256k1
/// - Unknown verbs return UnknownVerb (fail-closed)
pub fn enforce_verb_role(verb: &str, role: &RoleEntry) -> Result<(), EnforceError> {
let is_nostr_verb = matches!(
verb,
VERB_NOSTR_GET_PUBLIC_KEY
| VERB_NOSTR_SIGN_EVENT
| VERB_NOSTR_MINE_EVENT
| VERB_NOSTR_NIP44_ENCRYPT
| VERB_NOSTR_NIP44_DECRYPT
| VERB_NOSTR_NIP04_ENCRYPT
| VERB_NOSTR_NIP04_DECRYPT
);
if is_nostr_verb {
if role.purpose != RolePurpose::Nostr {
return Err(EnforceError::PurposeMismatch);
}
if role.curve != RoleCurve::Secp256k1 {
return Err(EnforceError::CurveMismatch);
}
return Ok(());
}
Err(EnforceError::UnknownVerb)
}
// ── Algorithm-based Enforcement ───────────────────────────────────────────────
/// Check whether a verb is valid for an algorithm (algorithm-based enforcement).
/// Does NOT check purpose — purpose is irrelevant for the new verbs.
pub fn enforce_verb_algorithm(verb: &str, alg: crate::pq_crypto::CryptoAlg) -> Result<(), EnforceError> {
use crate::pq_crypto::CryptoAlg::*;
match verb {
VERB_SIGN | VERB_VERIFY => match alg {
Secp256k1 | Ed25519 | MlDsa65 | SlhDsa128s => Ok(()),
_ => Err(EnforceError::Algorithm),
},
VERB_ENCAPSULATE | VERB_DECAPSULATE => match alg {
MlKem768 => Ok(()),
_ => Err(EnforceError::Algorithm),
},
VERB_DERIVE_SHARED => match alg {
X25519 => Ok(()),
_ => Err(EnforceError::Algorithm),
},
VERB_DERIVE => match alg {
Secp256k1 => Ok(()),
_ => Err(EnforceError::Algorithm),
},
VERB_GET_PUBLIC_KEY => match alg {
Secp256k1 | Ed25519 | X25519 | MlDsa65 | SlhDsa128s | MlKem768 => Ok(()),
_ => Err(EnforceError::Algorithm),
},
_ => Err(EnforceError::UnknownVerb),
}
}
/// Check if a verb is an algorithm-based verb (bypasses role table).
pub fn is_algorithm_verb(verb: &str) -> bool {
matches!(
verb,
VERB_SIGN
| VERB_VERIFY
| VERB_ENCAPSULATE
| VERB_DECAPSULATE
| VERB_DERIVE_SHARED
| VERB_GET_PUBLIC_KEY
| VERB_DERIVE
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::role_table::*;
fn make_nostr_role() -> RoleEntry {
let mut r = RoleEntry::default();
r.name = "main".into();
r.purpose = RolePurpose::Nostr;
r.curve = RoleCurve::Secp256k1;
r
}
fn make_ssh_role() -> RoleEntry {
let mut r = RoleEntry::default();
r.name = "ssh".into();
r.purpose = RolePurpose::Ssh;
r.curve = RoleCurve::Ed25519;
r
}
#[test]
fn test_nostr_verb_on_nostr_role() {
let role = make_nostr_role();
assert!(enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &role).is_ok());
assert!(enforce_verb_role(VERB_NOSTR_GET_PUBLIC_KEY, &role).is_ok());
}
#[test]
fn test_nostr_verb_on_ssh_role_fails() {
let role = make_ssh_role();
assert_eq!(
enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &role),
Err(EnforceError::PurposeMismatch)
);
}
#[test]
fn test_unknown_verb_fails() {
let role = make_nostr_role();
assert_eq!(
enforce_verb_role("unknown_verb", &role),
Err(EnforceError::UnknownVerb)
);
}
#[test]
fn test_algorithm_sign() {
use crate::pq_crypto::CryptoAlg::*;
assert!(enforce_verb_algorithm(VERB_SIGN, Secp256k1).is_ok());
assert!(enforce_verb_algorithm(VERB_SIGN, Ed25519).is_ok());
assert!(enforce_verb_algorithm(VERB_SIGN, MlDsa65).is_ok());
assert_eq!(
enforce_verb_algorithm(VERB_SIGN, X25519),
Err(EnforceError::Algorithm)
);
}
#[test]
fn test_algorithm_encapsulate() {
use crate::pq_crypto::CryptoAlg::*;
assert!(enforce_verb_algorithm(VERB_ENCAPSULATE, MlKem768).is_ok());
assert_eq!(
enforce_verb_algorithm(VERB_ENCAPSULATE, Secp256k1),
Err(EnforceError::Algorithm)
);
}
}
+93
View File
@@ -0,0 +1,93 @@
//! Error types for nsigner.
//!
//! JSON-RPC error codes are preserved exactly for wire compatibility
//! with the C n_signer.
use thiserror::Error;
/// nsigner-specific errors (internal operations).
#[derive(Error, Debug, Clone)]
pub enum NsignerError {
#[error("invalid input")]
InvalidInput,
#[error("memory allocation failed (mlock)")]
MemoryFailed,
#[error("I/O operation failed: {0}")]
IoFailed(String),
#[error("crypto operation failed")]
CryptoFailed,
#[error("key derivation failed")]
KeyDerivationFailed,
#[error("mnemonic not loaded")]
MnemonicNotLoaded,
#[error("role not found")]
RoleNotFound,
#[error("not found")]
NotFound,
#[error("policy denied")]
PolicyDenied,
#[error("not yet implemented")]
NotYetImplemented,
#[error("internal error: {0}")]
Internal(String),
}
impl From<nostr_core::error::NostrError> for NsignerError {
fn from(e: nostr_core::error::NostrError) -> Self {
// Map NostrError to NsignerError
match e {
nostr_core::error::NostrError::InvalidInput => NsignerError::InvalidInput,
nostr_core::error::NostrError::CryptoFailed => NsignerError::CryptoFailed,
_ => NsignerError::CryptoFailed,
}
}
}
/// JSON-RPC 2.0 error code + message (wire format).
///
/// These codes match the C n_signer exactly for client compatibility.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RpcError {
pub code: i32,
pub message: &'static str,
}
impl RpcError {
// ── JSON-RPC standard errors ──────────────────────────────────────
pub const PARSE_ERROR: Self = RpcError { code: -32700, message: "parse_error" };
pub const INVALID_REQUEST: Self = RpcError { code: -32600, message: "invalid_request" };
pub const METHOD_NOT_FOUND: Self = RpcError { code: -32601, message: "method_not_found" };
pub const INVALID_PARAMS: Self = RpcError { code: -32602, message: "invalid_params" };
pub const INTERNAL_ERROR: Self = RpcError { code: -32603, message: "internal_error" };
// ── nsigner-specific errors ──────────────────────────────────────
pub const AMBIGUOUS_ROLE_SELECTOR: Self = RpcError { code: 1001, message: "ambiguous_role_selector" };
pub const UNKNOWN_ROLE: Self = RpcError { code: 1002, message: "unknown_role" };
pub const NO_DEFAULT_ROLE: Self = RpcError { code: 1003, message: "no_default_role" };
pub const PURPOSE_MISMATCH: Self = RpcError { code: 1004, message: "purpose_mismatch" };
pub const CURVE_MISMATCH: Self = RpcError { code: 1005, message: "curve_mismatch" };
pub const MNEMONIC_NOT_LOADED: Self = RpcError { code: 1006, message: "mnemonic_not_loaded" };
pub const NO_TERMINATION_CONDITION: Self = RpcError { code: 1007, message: "no_termination_condition" };
pub const MINING_FAILED: Self = RpcError { code: 1008, message: "mining_failed" };
pub const NOT_YET_IMPLEMENTED: Self = RpcError { code: 1009, message: "not_yet_implemented" };
pub const ALGORITHM_NOT_SUPPORTED: Self = RpcError { code: 1010, message: "algorithm_not_supported_for_verb" };
pub const PATH_NOT_ALLOWED: Self = RpcError { code: 2003, message: "path_not_allowed" };
pub const INDEX_OUT_OF_RANGE: Self = RpcError { code: 2005, message: "index_out_of_range" };
pub const ROLE_REQUIRED: Self = RpcError { code: 2008, message: "role_required" };
pub const PATH_REQUIRED: Self = RpcError { code: 2009, message: "path_required" };
/// Serialize to a JSON string for the "error" field of a JSON-RPC response.
pub fn to_json(&self) -> String {
format!(
r#"{{"code":{},"message":"{}"}}"#,
self.code, self.message
)
}
}
impl std::fmt::Display for RpcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
+180
View File
@@ -0,0 +1,180 @@
//! Minimal HTTP/1.1 parser for nsigner's HTTP listener mode.
//!
//! Port of `http_listener.c`. Only supports POST with a JSON body.
//! No chunked encoding, no keep-alive, one request per connection.
use std::io::{self, Read, Write};
/// Read an HTTP POST request and return the JSON body.
///
/// Returns `Ok(body)` on success, `Err` on parse error / non-POST.
pub fn recv_request<R: Read>(reader: &mut R) -> io::Result<String> {
let mut line = String::new();
let mut content_length: usize = 0;
let mut is_post = false;
// Read header lines until empty line
loop {
line.clear();
read_line(reader, &mut line)?;
let trimmed = line.trim_end();
if trimmed.is_empty() {
break; // End of headers
}
if trimmed.starts_with("POST ") {
is_post = true;
} else if let Some(cl) = trimmed.strip_prefix("Content-Length: ").or_else(|| trimmed.strip_prefix("content-length: ")) {
content_length = cl.parse().unwrap_or(0);
}
}
if !is_post {
return Err(io::Error::new(io::ErrorKind::InvalidData, "not a POST request"));
}
if content_length == 0 || content_length > super::transport::MAX_MSG_SIZE {
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid content length"));
}
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body)?;
String::from_utf8(body).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid UTF-8"))
}
/// Send an HTTP 200 response with a JSON body and CORS headers.
pub fn send_response<W: Write>(writer: &mut W, json_body: &str) -> io::Result<()> {
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Connection: close\r\n\
\r\n\
{}",
json_body.len(),
json_body
);
writer.write_all(response.as_bytes())?;
writer.flush()?;
Ok(())
}
/// Send an HTTP error response.
pub fn send_error<W: Write>(writer: &mut W, code: u16, message: &str) -> io::Result<()> {
let response = format!(
"HTTP/1.1 {} {}\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n\
{{\"error\":\"{}\"}}",
code,
message,
message.len() + 12,
message
);
writer.write_all(response.as_bytes())?;
writer.flush()?;
Ok(())
}
/// Send a CORS preflight response (for OPTIONS requests).
pub fn send_cors_preflight<W: Write>(writer: &mut W) -> io::Result<()> {
let response = "HTTP/1.1 204 No Content\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Access-Control-Max-Age: 86400\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
writer.write_all(response.as_bytes())?;
writer.flush()?;
Ok(())
}
/// Read a line from a reader (up to \r\n).
fn read_line<R: Read>(reader: &mut R, buf: &mut String) -> io::Result<()> {
buf.clear();
let mut byte = [0u8; 1];
loop {
match reader.read(&mut byte) {
Ok(0) => break,
Ok(_) => {
if byte[0] == b'\n' {
buf.push('\n');
break;
}
buf.push(byte[0] as char);
}
Err(e) => return Err(e),
}
if buf.len() > 8192 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "header line too long"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_recv_post_request() {
let body = r#"{"id":"1","method":"get_info","params":[]}"#;
let request = format!(
"POST / HTTP/1.1\r\n\
Host: localhost\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
\r\n\
{}",
body.len(),
body
);
let mut cursor = Cursor::new(request.into_bytes());
let received = recv_request(&mut cursor).unwrap();
assert_eq!(received, body);
}
#[test]
fn test_recv_non_post_rejected() {
let request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
let mut cursor = Cursor::new(request.as_bytes());
assert!(recv_request(&mut cursor).is_err());
}
#[test]
fn test_send_response() {
let mut buf = Vec::new();
let body = r#"{"result":"ok"}"#;
send_response(&mut buf, body).unwrap();
let response = String::from_utf8(buf).unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK"));
assert!(response.contains("Content-Type: application/json"));
assert!(response.contains("Access-Control-Allow-Origin: *"));
assert!(response.contains(body));
}
#[test]
fn test_send_error() {
let mut buf = Vec::new();
send_error(&mut buf, 400, "Bad Request").unwrap();
let response = String::from_utf8(buf).unwrap();
assert!(response.starts_with("HTTP/1.1 400 Bad Request"));
}
#[test]
fn test_send_cors_preflight() {
let mut buf = Vec::new();
send_cors_preflight(&mut buf).unwrap();
let response = String::from_utf8(buf).unwrap();
assert!(response.starts_with("HTTP/1.1 204 No Content"));
assert!(response.contains("Access-Control-Allow-Origin: *"));
}
}
+479
View File
@@ -0,0 +1,479 @@
//! Key store — holds derived keys for all roles.
//!
//! Port of `key_store.c`. Uses `nostr_core_lib_rust` for secp256k1
//! operations and NIP-01/04/44 protocol functions.
use crate::mnemonic::MnemonicState;
use crate::pq_crypto::{self, CryptoAlg};
use crate::role_table::{self, RoleCurve, RoleEntry, RolePurpose, RoleTable};
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
/// Per-role derived key material (stored in secure memory).
pub struct DerivedKey {
pub private_key: SecureBuf,
pub public_key: SecureBuf,
pub pubkey_hex: String,
pub npub: String, // bech32 npub (secp256k1 only, empty for others)
pub alg: CryptoAlg,
pub valid: bool,
}
/// Key store — holds derived keys for all roles.
pub struct KeyStore {
pub keys: Vec<Option<DerivedKey>>,
}
impl KeyStore {
pub fn new() -> Self {
KeyStore { keys: Vec::new() }
}
/// Derive keys for all roles in the table using the loaded mnemonic.
/// Returns the number of keys derived.
pub fn derive_all(
&mut self,
table: &mut RoleTable,
mnemonic: &MnemonicState,
) -> Result<usize, NsignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
self.keys.clear();
self.keys.resize_with(table.entries.len(), || None);
let mut derived_count = 0;
for (i, role) in table.entries.iter_mut().enumerate() {
role.derived = false;
role.pubkey_hex.clear();
// Skip OTP roles (no derivation)
if role.curve == RoleCurve::Unknown && role.purpose == RolePurpose::Nostr {
continue;
}
// Template roles with no default index are skipped (derived on-demand)
if role.has_variable_path() && role.path_default_index < 0 {
continue;
}
// Substitute default index into template if needed
let path = if role.has_variable_path() && role.path_default_index >= 0 {
role.role_path.replace("%d", &role.path_default_index.to_string())
} else {
role.role_path.clone()
};
match derive_for_role(&path, role, phrase) {
Ok(dk) => {
role.pubkey_hex = dk.pubkey_hex.clone();
role.derived = true;
self.keys[i] = Some(dk);
derived_count += 1;
}
Err(_) => continue,
}
}
Ok(derived_count)
}
/// Derive key for exactly one role index.
pub fn derive_one(
&mut self,
table: &mut RoleTable,
mnemonic: &MnemonicState,
role_index: usize,
) -> Result<(), NsignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
let role = table
.entries
.get_mut(role_index)
.ok_or(NsignerError::InvalidInput)?;
role.derived = false;
role.pubkey_hex.clear();
let dk = derive_for_role(&role.role_path, role, phrase)?;
role.pubkey_hex = dk.pubkey_hex.clone();
role.derived = true;
if self.keys.len() <= role_index {
self.keys.resize_with(role_index + 1, || None);
}
self.keys[role_index] = Some(dk);
Ok(())
}
/// Get the derived private key for a role (by table index).
pub fn get_private_key(&self, role_index: usize) -> Option<&[u8]> {
self.keys.get(role_index)?.as_ref().map(|dk| dk.private_key.as_slice())
}
/// Get the derived public key hex for a role.
pub fn get_pubkey_hex(&self, role_index: usize) -> Option<&str> {
self.keys.get(role_index)?.as_ref().map(|dk| dk.pubkey_hex.as_str())
}
/// Sign a Nostr event. event_json is the unsigned event JSON string.
/// Returns the signed event JSON string.
pub fn sign_event(
&self,
role_index: usize,
event_json: &str,
) -> Result<String, NsignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
// Parse the unsigned event from JSON
let event: nostr_core::types::Event =
serde_json::from_str(event_json).map_err(|_| NsignerError::InvalidInput)?;
// Use NIP-01 to create and sign the event
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
let signed = nips::nip001::create_and_sign_event(
event.kind,
&event.content,
event.tags.clone(),
&sk,
event.created_at,
)
.map_err(|_| NsignerError::CryptoFailed)?;
serde_json::to_string(&signed).map_err(|_| NsignerError::InvalidInput)
}
/// NIP-44 encrypt.
pub fn nip44_encrypt(
&self,
role_index: usize,
recipient_pubkey_hex: &str,
plaintext: &str,
) -> Result<Vec<u8>, NsignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
nostr_core::crypto::nip44::nip44_encrypt(&sk, &recipient_pk, plaintext.as_bytes())
.map_err(|_| NsignerError::CryptoFailed)
}
/// NIP-44 decrypt.
pub fn nip44_decrypt(
&self,
role_index: usize,
sender_pubkey_hex: &str,
ciphertext: &[u8],
) -> Result<Vec<u8>, NsignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
nostr_core::crypto::nip44::nip44_decrypt(&sk, &sender_pk, ciphertext)
.map_err(|_| NsignerError::CryptoFailed)
}
/// NIP-04 encrypt.
pub fn nip04_encrypt(
&self,
role_index: usize,
recipient_pubkey_hex: &str,
plaintext: &str,
) -> Result<String, NsignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
nips::nip004::nip04_encrypt(&sk, &recipient_pk, plaintext)
.map_err(|_| NsignerError::CryptoFailed)
}
/// NIP-04 decrypt.
pub fn nip04_decrypt(
&self,
role_index: usize,
sender_pubkey_hex: &str,
ciphertext: &str,
) -> Result<String, NsignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
nips::nip004::nip04_decrypt(&sk, &sender_pk, ciphertext)
.map_err(|_| NsignerError::CryptoFailed)
}
/// Zeroize all derived keys.
pub fn wipe(&mut self) {
self.keys.clear();
}
}
// ── Derivation Helpers ───────────────────────────────────────────────────────
/// Derive a key for a single role into `DerivedKey`.
fn derive_for_role(
path: &str,
role: &RoleEntry,
mnemonic_phrase: &str,
) -> Result<DerivedKey, NsignerError> {
let alg = role_table::crypto_alg_from_role(role.curve, role.purpose);
// crypto_alg_from_role returns Unknown for OTP, but we skip OTP earlier
let alg = if alg == CryptoAlg::Unknown {
return Err(NsignerError::KeyDerivationFailed);
} else {
alg
};
let sizes = alg
.sizes()
.ok_or(NsignerError::KeyDerivationFailed)?;
// Derive the 32-byte seed from the mnemonic using the path
let seed = pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
match alg {
CryptoAlg::Secp256k1 => {
// BIP-32 derivation: seed → master key → derive path → private key
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic_phrase, "");
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
let path_indices = nips::nip006::parse_bip44_path(path)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
let (derived_key, _) = nips::nip006::bip32_derive_path(
&master_key,
&master_chain_code,
&path_indices,
)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&derived_key);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
let pk = nostr_core::crypto::keys::public_key_from_secret_key(&sk)
.map_err(|_| NsignerError::CryptoFailed)?;
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_arr);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(pk.as_bytes());
let pubkey_hex = hex::encode(pk.as_bytes());
let npub = String::new(); // TODO: bech32 npub via nips::nip019
Ok(DerivedKey {
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
npub,
alg,
valid: true,
})
}
CryptoAlg::Ed25519 => {
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed);
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(&pub_bytes);
let pubkey_hex = hex::encode(&pub_bytes);
Ok(DerivedKey {
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
npub: String::new(),
alg,
valid: true,
})
}
CryptoAlg::X25519 => {
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed);
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(&pub_bytes);
let pubkey_hex = hex::encode(&pub_bytes);
Ok(DerivedKey {
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
npub: String::new(),
alg,
valid: true,
})
}
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
// PQ algorithms — TODO: Phase 13
Err(NsignerError::NotYetImplemented)
}
CryptoAlg::Unknown => Err(NsignerError::KeyDerivationFailed),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::role_table::*;
fn make_mnemonic() -> MnemonicState {
let mut m = MnemonicState::new();
m.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
.unwrap();
m
}
fn make_table() -> RoleTable {
let mut t = RoleTable::new();
t.register_role_path(
"main",
"m/44'/1237'/0'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
-1, -1, -1, &[],
)
.unwrap();
t.register_role_path(
"ssh",
"m/44'/102001'/0'/0'/0'",
RolePurpose::Ssh,
RoleCurve::Ed25519,
-1, -1, -1, &[],
)
.unwrap();
t.register_role_path(
"age",
"m/44'/102002'/0'/0'/0'",
RolePurpose::Age,
RoleCurve::X25519,
-1, -1, -1, &[],
)
.unwrap();
t
}
#[test]
fn test_derive_all_secp256k1() {
let mnemonic = make_mnemonic();
let mut table = make_table();
let mut store = KeyStore::new();
let count = store.derive_all(&mut table, &mnemonic).unwrap();
assert!(count >= 1);
// main role should have a derived pubkey
let main_idx = table.entries.iter().position(|e| e.name == "main").unwrap();
let pubkey_hex = store.get_pubkey_hex(main_idx).unwrap();
assert!(!pubkey_hex.is_empty());
assert_eq!(pubkey_hex.len(), 64); // 32 bytes hex
}
#[test]
fn test_derive_all_ed25519() {
let mnemonic = make_mnemonic();
let mut table = make_table();
let mut store = KeyStore::new();
store.derive_all(&mut table, &mnemonic).unwrap();
let ssh_idx = table.entries.iter().position(|e| e.name == "ssh").unwrap();
let pubkey_hex = store.get_pubkey_hex(ssh_idx).unwrap();
assert_eq!(pubkey_hex.len(), 64); // 32 bytes hex
}
#[test]
fn test_derive_all_x25519() {
let mnemonic = make_mnemonic();
let mut table = make_table();
let mut store = KeyStore::new();
store.derive_all(&mut table, &mnemonic).unwrap();
let age_idx = table.entries.iter().position(|e| e.name == "age").unwrap();
let pubkey_hex = store.get_pubkey_hex(age_idx).unwrap();
assert_eq!(pubkey_hex.len(), 64); // 32 bytes hex
}
#[test]
fn test_derive_deterministic() {
let mnemonic = make_mnemonic();
// First derivation
let mut table1 = make_table();
let mut store1 = KeyStore::new();
store1.derive_all(&mut table1, &mnemonic).unwrap();
let pk1 = store1.get_pubkey_hex(0).unwrap().to_string();
// Second derivation with same mnemonic
let mut table2 = make_table();
let mut store2 = KeyStore::new();
store2.derive_all(&mut table2, &mnemonic).unwrap();
let pk2 = store2.get_pubkey_hex(0).unwrap().to_string();
assert_eq!(pk1, pk2);
}
#[test]
fn test_derive_without_mnemonic_fails() {
let mnemonic = MnemonicState::new(); // not loaded
let mut table = make_table();
let mut store = KeyStore::new();
assert!(store.derive_all(&mut table, &mnemonic).is_err());
}
#[test]
fn test_wipe() {
let mnemonic = make_mnemonic();
let mut table = make_table();
let mut store = KeyStore::new();
store.derive_all(&mut table, &mnemonic).unwrap();
assert!(!store.keys.is_empty());
store.wipe();
assert!(store.keys.is_empty());
}
}
+36
View File
@@ -0,0 +1,36 @@
//! # nsigner — Attended Nostr signing daemon
//!
//! Rust port of the C-based `n_signer`. Holds signing key material in
//! locked memory and signs on request via a JSON-RPC 2.0 API over
//! multiple transports (Unix socket, TCP, HTTP, stdio, qrexec).
//!
//! This is a **program, not a daemon**: no background process, no
//! runtime config files, no persistence. Closing the terminal or
//! quitting the program ends the trust session and destroys state.
pub mod secure_mem;
pub mod mnemonic;
pub mod role_table;
pub mod selector;
pub mod enforcement;
pub mod policy;
pub mod key_store;
pub mod alg_cache;
pub mod pq_crypto;
pub mod pq_drbg;
pub mod dispatcher;
pub mod server;
pub mod transport;
pub mod http;
pub mod auth_envelope;
pub mod miner;
pub mod otp_pad;
pub mod socket_name;
pub mod tui;
pub mod tui_continuous;
pub mod error;
pub use error::NsignerError;
/// Version string (matches C NSIGNER_VERSION).
pub const VERSION: &str = "v0.0.1";
+680
View File
@@ -0,0 +1,680 @@
//! nsigner — attended Nostr signing daemon.
//!
//! Port of `main.c`. Single binary that holds signing key material in
//! locked memory and signs on request via JSON-RPC 2.0.
use clap::{Parser, Subcommand};
use nsigner::{
alg_cache::AlgorithmKeyCache,
dispatcher::DispatcherContext,
key_store::KeyStore,
mnemonic::MnemonicState,
policy::{parse_preapprove_spec, PolicyTable},
role_table::{RoleCurve, RolePurpose, RoleTable},
server::{AuthMode, ListenMode, ServerContext},
NsignerError,
};
/// Command-line arguments.
#[derive(Parser, Debug)]
#[command(name = "nsigner", version = nsigner::VERSION, about = "Attended Nostr signing daemon")]
struct Cli {
/// Socket name (abstract namespace, without @ prefix)
#[arg(long, short = 'n', alias = "name")]
socket_name: Option<String>,
/// Listen mode: unix, stdio, qrexec, tcp:HOST:PORT, http:HOST:PORT
#[arg(long, short = 'l', default_value = "unix")]
listen: String,
/// Pre-approve a caller for a role (repeatable)
#[arg(long, short = 'p', value_name = "SPEC")]
preapprove: Vec<String>,
/// Register a named path-role non-interactively (repeatable)
#[arg(long, value_name = "SPEC")]
register_role: Vec<String>,
/// Auth envelope policy: off, optional, required
#[arg(long, short = 'a', default_value = "off")]
auth: String,
/// Read mnemonic from stdin (one line) at startup
#[arg(long)]
mnemonic_stdin: bool,
/// Read mnemonic from inherited fd N (one line) at startup
#[arg(long, value_name = "N")]
mnemonic_fd: Option<i32>,
/// Allow all policy prompts for this server session
#[arg(long, short = 'A')]
allow_all: bool,
/// Allow unlocked memory (development only)
#[arg(long)]
allow_unlocked_memory: bool,
/// Accept qrexec_source preamble on unix connections (bridge mode)
#[arg(long)]
bridge_source_trusted: bool,
/// OTP pad directory
#[arg(long, value_name = "DIR")]
otp_pad_dir: Option<String>,
/// OTP pad checksum or prefix
#[arg(long, value_name = "SPEC")]
otp_pad: Option<String>,
/// Allow pads on qvm-block (blkback) devices
#[arg(long)]
otp_allow_blkback: bool,
/// Subcommand
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Send a JSON-RPC request to a running signer
Client {
/// JSON-RPC request string, or "-" to read from stdin
request: String,
},
/// Stateless qrexec → unix-socket relay
Bridge {
/// Target socket name
#[arg(long)]
to: Option<String>,
},
/// List running nsigner abstract sockets
List,
}
fn main() {
let cli = Cli::parse();
// Handle subcommands first (no mnemonic needed)
if let Some(cmd) = &cli.command {
match cmd {
Commands::Client { request } => {
std::process::exit(client_main(request, &cli));
}
Commands::Bridge { to } => {
std::process::exit(bridge_main(to.as_deref(), &cli));
}
Commands::List => {
std::process::exit(list_main());
}
}
}
// Server mode
match server_main(&cli) {
Ok(()) => {}
Err(e) => {
eprintln!("nsigner: {}", e);
std::process::exit(1);
}
}
}
/// Server main: mnemonic → roles → transport → server start → TUI loop
fn server_main(cli: &Cli) -> Result<(), NsignerError> {
println!("nsigner {}", nsigner::VERSION);
if cli.allow_unlocked_memory {
nsigner::secure_mem::allow_unlocked();
}
// Install SIGWINCH handler for terminal resize detection
nsigner::tui_continuous::install_resize_handler();
// ── Mnemonic ──────────────────────────────────────────────────
let mut mnemonic = MnemonicState::new();
if cli.mnemonic_stdin {
// Read one line from 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 {
// Read from inherited 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)?;
} else {
// Interactive TUI prompt (cooked mode — normal read_line works)
load_mnemonic_tui(&mut mnemonic)?;
}
// ── Role table ────────────────────────────────────────────────
let mut role_table = RoleTable::new();
if !cli.register_role.is_empty() {
// Non-interactive: register from CLI specs
for spec in &cli.register_role {
register_role_from_spec(&mut role_table, spec)?;
}
} else if cli.mnemonic_stdin || cli.mnemonic_fd.is_some() {
// Non-interactive without --register-role: default "main" role
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()))?;
} else {
// Interactive: role wizard
nsigner::tui::role_wizard(&mut role_table)?;
}
// ── Key store & algorithm cache ───────────────────────────────
let mut key_store = KeyStore::new();
let mut alg_key_cache = AlgorithmKeyCache::new();
// ── Policy ────────────────────────────────────────────────────
let owner_uid = unsafe { libc::getuid() };
let mut policy = PolicyTable::new();
policy.init_default(owner_uid);
for spec in &cli.preapprove {
let entry = parse_preapprove_spec(spec)
.map_err(|_e| NsignerError::InvalidInput)?;
policy
.insert_before_last(entry)
.map_err(|e| NsignerError::Internal(e.to_string()))?;
}
if cli.allow_all {
nsigner::tui::set_prompt_always_allow(true);
}
// ── Derive keys ──────────────────────────────────────────────
let derived_count = key_store.derive_all(&mut role_table, &mnemonic)?;
// ── Transport selection ──────────────────────────────────────
let listen_mode = parse_listen_mode(&cli.listen);
let socket_name = cli
.socket_name
.clone()
.unwrap_or_else(|| {
// Generate random socket name for Unix mode
nsigner::socket_name::socket_name_random().unwrap_or_default()
});
// ── Start server ─────────────────────────────────────────────
let auth_mode = parse_auth_mode(&cli.auth);
let mut server = ServerContext::new(&socket_name, listen_mode, auth_mode);
server.start()?;
// ── Main loop ────────────────────────────────────────────────
match listen_mode {
ListenMode::Stdio | ListenMode::Qrexec => {
// One request over stdin/stdout
let mut dispatcher = DispatcherContext {
role_table: &mut role_table,
mnemonic: &mnemonic,
key_store: &mut key_store,
alg_key_cache: &mut alg_key_cache,
};
let _ = server.handle_one(&mut dispatcher, &mut policy);
server.stop();
}
ListenMode::Tcp | ListenMode::Http => {
// Poll loop (no TUI)
while server.running {
let mut dispatcher = DispatcherContext {
role_table: &mut role_table,
mnemonic: &mnemonic,
key_store: &mut key_store,
alg_key_cache: &mut alg_key_cache,
};
match server.handle_one(&mut dispatcher, &mut policy) {
Ok(true) => {}
Ok(false) => {
// Nothing pending — sleep briefly
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => {
eprintln!("server error: {}", e);
break;
}
}
}
}
ListenMode::Unix => {
// TUI + poll loop — poll for both socket connections and keypresses.
// Raw mode is enabled only here (after all setup prompts) so that
// prompt output above stays properly formatted (\n → \r\n).
let mut activity_log = nsigner::tui::ActivityLog::new();
activity_log.add("nsigner started");
nsigner::tui::init().ok();
nsigner::tui::render_status(
&role_table,
&mnemonic,
derived_count,
&socket_name,
&activity_log,
);
while server.running {
// Check for terminal resize (SIGWINCH)
if nsigner::tui_continuous::resize_pending() {
nsigner::tui::render_status(
&role_table,
&mnemonic,
derived_count,
&socket_name,
&activity_log,
);
}
// Poll for a keypress (non-blocking, short timeout)
match nsigner::tui::poll_key(50) {
nsigner::tui::TuiKey::Connections => {
// Show connection instructions
nsigner::tui::render_connections(
&role_table,
&mnemonic,
derived_count,
&socket_name,
);
// Wait for any key to dismiss (use tui_continuous::get_key
// so the wait survives EINTR / SIGWINCH)
let _ = nsigner::tui_continuous::get_key();
nsigner::tui::render_status(
&role_table,
&mnemonic,
derived_count,
&socket_name,
&activity_log,
);
}
nsigner::tui::TuiKey::Refresh => {
// 'r' — refresh the status display
nsigner::tui::render_status(
&role_table,
&mnemonic,
derived_count,
&socket_name,
&activity_log,
);
}
nsigner::tui::TuiKey::Lock => {
// 'l' — lock session: wipe keys, unload mnemonic,
// then prompt for mnemonic to re-unlock.
{
use std::io::Write;
let mut stdout = std::io::stdout();
let _ = write!(stdout, "\r\n[lock] Session locked. Re-enter mnemonic to unlock.\r\n");
let _ = stdout.flush();
}
key_store.wipe();
alg_key_cache.wipe();
mnemonic.unload();
// Temporarily exit raw mode for line-mode input
nsigner::tui_continuous::cleanup();
match load_mnemonic_tui(&mut mnemonic) {
Ok(()) => {
let new_count = key_store.derive_all(&mut role_table, &mnemonic);
match new_count {
Ok(n) => {
activity_log.add("session re-unlocked");
// Re-enter raw mode
nsigner::tui_continuous::init();
nsigner::tui::render_status(
&role_table,
&mnemonic,
n,
&socket_name,
&activity_log,
);
}
Err(e) => {
eprintln!("[lock] derivation failed: {}", e);
server.running = false;
}
}
}
Err(e) => {
eprintln!("[lock] unlock failed: {}", e);
server.running = false;
}
}
}
nsigner::tui::TuiKey::Quit => {
server.running = false;
break;
}
_ => {}
}
if !server.running {
break;
}
// Poll for socket connections
let mut dispatcher = DispatcherContext {
role_table: &mut role_table,
mnemonic: &mnemonic,
key_store: &mut key_store,
alg_key_cache: &mut alg_key_cache,
};
match server.handle_one(&mut dispatcher, &mut policy) {
Ok(true) => {
activity_log.add("request handled");
nsigner::tui::render_status(
&role_table,
&mnemonic,
derived_count,
&socket_name,
&activity_log,
);
}
Ok(false) => {
// Nothing pending — poll_key already slept 50ms
}
Err(e) => {
eprintln!("server error: {}", e);
break;
}
}
}
}
}
// ── Shutdown ─────────────────────────────────────────────────
server.stop();
key_store.wipe();
alg_key_cache.wipe();
mnemonic.unload();
nsigner::tui::cleanup().ok();
println!("Shutdown. All secrets wiped.");
Ok(())
}
/// Client subcommand: send a JSON-RPC request to a running signer.
fn client_main(request: &str, cli: &Cli) -> i32 {
use std::io::Read;
let socket_name = cli.socket_name.as_deref().unwrap_or("nsigner");
// Discover single socket if not explicit
let socket_name = if cli.socket_name.is_some() {
socket_name.to_string()
} else {
nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| socket_name.to_string())
};
let mut stream = match nsigner::transport::connect_abstract_unix(&socket_name) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to connect to {}: {}", socket_name, e);
return 1;
}
};
// Read from stdin if "-"
let request = if request == "-" {
let mut input = String::new();
if std::io::stdin().read_to_string(&mut input).is_err() {
eprintln!("Failed to read request from stdin");
return 1;
}
input
} else {
request.to_string()
};
// Send framed request
if nsigner::transport::send_framed(&mut stream, &request).is_err() {
eprintln!("Failed to send request");
return 1;
}
// Receive framed response
match nsigner::transport::recv_framed(&mut stream) {
Ok(response) => {
println!("{}", response);
0
}
Err(e) => {
eprintln!("Failed to receive response: {}", e);
1
}
}
}
/// Bridge subcommand: stateless qrexec → unix-socket relay.
fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
let target = to.unwrap_or("nsigner");
let target = if cli.socket_name.is_some() {
target.to_string()
} else {
nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| target.to_string())
};
// Read source qube from qrexec environment
let source_qube = std::env::var("QREXEC_REMOTE_DOMAIN").unwrap_or_default();
// Connect to persistent signer via abstract socket
let mut stream = match nsigner::transport::connect_abstract_unix(&target) {
Ok(s) => s,
Err(e) => {
eprintln!("bridge: cannot connect to {}: {}", target, e);
return 1;
}
};
// Send source-qube preamble
let preamble = format!(r#"{{"qrexec_source":"{}"}}"#, source_qube);
if nsigner::transport::send_framed(&mut stream, &preamble).is_err() {
eprintln!("bridge: failed to send preamble");
return 1;
}
// Read one framed request from stdin and forward
let mut stdin = std::io::stdin();
let request = match nsigner::transport::recv_framed(&mut stdin) {
Ok(r) => r,
Err(e) => {
eprintln!("bridge: failed to read request from stdin: {}", e);
return 1;
}
};
if nsigner::transport::send_framed(&mut stream, &request).is_err() {
eprintln!("bridge: failed to forward request");
return 1;
}
// Relay response to stdout
match nsigner::transport::recv_framed(&mut stream) {
Ok(response) => {
let mut stdout = std::io::stdout();
if nsigner::transport::send_framed(&mut stdout, &response).is_err() {
eprintln!("bridge: failed to relay response");
return 1;
}
0
}
Err(e) => {
eprintln!("bridge: failed to read response: {}", e);
1
}
}
}
/// List subcommand: list running nsigner sockets.
fn list_main() -> i32 {
let sockets = nsigner::socket_name::list_sockets();
if sockets.is_empty() {
println!("(none)");
} else {
for name in &sockets {
println!("{}", name);
}
}
0
}
/// Interactive mnemonic loading via TUI.
///
/// Uses tui_continuous primitives for consistent formatting (cooked mode —
/// raw mode is not yet enabled at this point).
fn load_mnemonic_tui(mnemonic: &mut MnemonicState) -> Result<(), NsignerError> {
use std::io::Write;
let frame = nsigner::tui_continuous::TuiFrame {
app_name: "nsigner",
app_version: nsigner::VERSION,
breadcrumb: "> Unlock",
};
nsigner::tui_continuous::render_content_screen(&frame, Some("Enter mnemonic phrase"));
nsigner::tui_continuous::print("Enter your BIP-39 mnemonic phrase, or 'g' to generate a new one.");
nsigner::tui_continuous::print("");
print!("> ");
let _ = std::io::stdout().flush();
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
let input = input.trim();
if input == "g" || input == "G" {
let phrase = mnemonic.generate(12)?;
nsigner::tui_continuous::print("");
nsigner::tui_continuous::print("^*Generated mnemonic (WRITE THIS DOWN — it will not be shown again)^:");
for (i, word) in phrase.split_whitespace().enumerate() {
println!("{:2}. {}", i + 1, word);
}
print!("Press Enter to continue: ");
let _ = std::io::stdout().flush();
let mut dummy = String::new();
let _ = std::io::stdin().read_line(&mut dummy);
} else {
mnemonic.load(input)?;
}
nsigner::tui_continuous::print("Seed phrase is valid and accepted.");
Ok(())
}
/// Parse a --register-role spec: `<name>:<curve>:<path-template>`
fn register_role_from_spec(
role_table: &mut RoleTable,
spec: &str,
) -> Result<(), NsignerError> {
let parts: Vec<&str> = spec.splitn(3, ':').collect();
if parts.len() != 3 {
return Err(NsignerError::InvalidInput);
}
let name = parts[0];
let curve_str = parts[1];
let path_token = parts[2];
if name.is_empty() || path_token.is_empty() {
return Err(NsignerError::InvalidInput);
}
// Resolve curve
let curve = if curve_str.is_empty() {
// Auto-detect from path
match nsigner::role_table::purpose_from_path(path_token) {
RolePurpose::Ssh => RoleCurve::Ed25519,
RolePurpose::Age => RoleCurve::X25519,
RolePurpose::PqSig => {
if path_token.starts_with("m/44'/102004'") {
RoleCurve::SlhDsa128s
} else {
RoleCurve::MlDsa65
}
}
RolePurpose::PqKem => RoleCurve::MlKem768,
_ => RoleCurve::Secp256k1,
}
} else {
RoleCurve::from_str(curve_str)
};
if curve == RoleCurve::Unknown {
return Err(NsignerError::InvalidInput);
}
let purpose = nsigner::role_table::purpose_from_path(path_token);
// Parse path template
let (template, range_lo, range_hi, allowed_indices) =
nsigner::role_table::parse_path_template(path_token)
.map_err(|_| NsignerError::InvalidInput)?;
role_table
.register_role_path(
name,
&template,
purpose,
curve,
range_lo,
range_hi,
-1, // no default index
&allowed_indices,
)
.map_err(|e| NsignerError::Internal(e.to_string()))?;
Ok(())
}
/// Parse listen mode from --listen string.
fn parse_listen_mode(s: &str) -> ListenMode {
if s.starts_with("tcp:") {
ListenMode::Tcp
} else if s.starts_with("http:") {
ListenMode::Http
} else {
match s {
"unix" => ListenMode::Unix,
"stdio" => ListenMode::Stdio,
"qrexec" => ListenMode::Qrexec,
_ => ListenMode::Unix,
}
}
}
/// Parse auth mode from --auth string.
fn parse_auth_mode(s: &str) -> AuthMode {
match s {
"off" => AuthMode::Off,
"optional" => AuthMode::Optional,
"required" => AuthMode::Required,
_ => AuthMode::Off,
}
}
+265
View File
@@ -0,0 +1,265 @@
//! NIP-13 proof-of-work mining coordinator.
//!
//! Port of `miner.c`. Multi-threaded best-effort mining that:
//! - Runs N worker threads, each trying nonces with a unique stride
//! - Tracks the best event (highest difficulty) across all threads
//! - Stops when target difficulty is reached OR timeout expires
//! - Always returns the best result found
use nostr_core::types::{Event, SecretKey, Tag};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
/// Mine result.
#[derive(Debug, Clone)]
pub struct MineResult {
pub best_event_json: String,
pub achieved_difficulty: i32,
pub target_difficulty: i32,
pub target_reached: bool,
pub elapsed_sec: u64,
pub total_attempts: u64,
}
/// Shared state across worker threads.
struct MineShared {
best_difficulty: i32,
best_nonce: u64,
total_attempts: u64,
target_reached: bool,
stop: bool,
}
/// Run the mining coordinator.
///
/// `event_json` — the unsigned event JSON (will be parsed and modified)
/// `private_key` — 32-byte secp256k1 private key (for signing the mined event)
/// `target_difficulty` — target leading zero bits (0 = no target, mine for full timeout)
/// `thread_count` — number of mining threads (1..32)
/// `timeout_sec` — time budget in seconds
pub fn miner_run(
event_json: &str,
private_key: &[u8; 32],
target_difficulty: i32,
thread_count: i32,
timeout_sec: u64,
) -> Result<MineResult, crate::NsignerError> {
let threads = thread_count.clamp(1, 32) as usize;
let timeout = if timeout_sec == 0 { 600 } else { timeout_sec };
let deadline = Instant::now() + Duration::from_secs(timeout);
// Parse the unsigned event
let mut event: Event =
serde_json::from_str(event_json).map_err(|_| crate::NsignerError::InvalidInput)?;
// Ensure there's a nonce tag (will be updated by workers)
let has_nonce = event.tags.iter().any(|t| t.kind() == "nonce");
if !has_nonce {
let mut tag = Tag::with_value("nonce", "0");
tag.0.push(target_difficulty.to_string());
event.tags.push(tag);
}
let shared = Arc::new(Mutex::new(MineShared {
best_difficulty: 0,
best_nonce: 0,
total_attempts: 0,
target_reached: false,
stop: false,
}));
let start = Instant::now();
let mut handles = Vec::new();
for thread_id in 0..threads {
let shared = Arc::clone(&shared);
let event = event.clone();
let target = target_difficulty;
let deadline = deadline;
let handle = thread::spawn(move || {
mine_worker(
thread_id as u64,
threads as u64,
&shared,
&event,
target,
deadline,
);
});
handles.push(handle);
}
for handle in handles {
let _ = handle.join();
}
let shared = Arc::try_unwrap(shared)
.map_err(|_| crate::NsignerError::Internal("mining thread still holds shared state".into()))?
.into_inner()
.map_err(|_| crate::NsignerError::Internal("mining shared state poisoned".into()))?;
let elapsed = start.elapsed().as_secs();
// Reconstruct the best event with the best nonce and sign it
let best_event_json = if shared.best_difficulty > 0 {
let nonce_tag_index = event.tags.iter().position(|t| t.kind() == "nonce");
if let Some(idx) = nonce_tag_index {
event.tags[idx] = Tag::with_value("nonce", &shared.best_nonce.to_string());
event.tags[idx].0.push(target_difficulty.to_string());
}
let sk = SecretKey::from_bytes(*private_key);
let signed = nips::nip001::create_and_sign_event(
event.kind,
&event.content,
event.tags.clone(),
&sk,
event.created_at,
)
.map_err(|_| crate::NsignerError::CryptoFailed)?;
serde_json::to_string(&signed).map_err(|_| crate::NsignerError::InvalidInput)?
} else {
// No event mined — return the original unsigned event
event_json.to_string()
};
Ok(MineResult {
best_event_json,
achieved_difficulty: shared.best_difficulty,
target_difficulty,
target_reached: shared.target_reached,
elapsed_sec: elapsed,
total_attempts: shared.total_attempts,
})
}
/// Per-worker mining loop.
fn mine_worker(
thread_id: u64,
thread_count: u64,
shared: &Arc<Mutex<MineShared>>,
event: &Event,
target_difficulty: i32,
deadline: Instant,
) {
let mut nonce: u64 = thread_id;
let stride = thread_count;
let nonce_tag_index = event.tags.iter().position(|t| t.kind() == "nonce");
let mut local_event = event.clone();
let mut local_best_difficulty: i32 = 0;
let mut local_best_nonce: u64 = 0;
let mut attempts: u64 = 0;
while Instant::now() < deadline {
// Check if we should stop (target reached by another thread)
{
let s = shared.lock().unwrap();
if s.stop {
// Flush our attempts
drop(s);
let mut s = shared.lock().unwrap();
s.total_attempts += attempts;
return;
}
}
// Update nonce tag
if let Some(idx) = nonce_tag_index {
local_event.tags[idx] = Tag::with_value("nonce", &nonce.to_string());
local_event.tags[idx].0.push(target_difficulty.to_string());
}
// Recompute event ID
if let Ok(new_id) = local_event.compute_id() {
let difficulty = nips::nip013::count_leading_zero_bits(&new_id) as i32;
if difficulty > local_best_difficulty {
local_best_difficulty = difficulty;
local_best_nonce = nonce;
// Check if target reached
if target_difficulty > 0 && difficulty >= target_difficulty {
let mut s = shared.lock().unwrap();
if local_best_difficulty > s.best_difficulty {
s.best_difficulty = local_best_difficulty;
s.best_nonce = local_best_nonce;
}
s.total_attempts += attempts;
s.target_reached = true;
s.stop = true;
return;
}
}
}
attempts += 1;
nonce += stride;
// Periodically flush to shared
if attempts % 10000 == 0 {
let mut s = shared.lock().unwrap();
s.total_attempts += 10000;
if local_best_difficulty > s.best_difficulty {
s.best_difficulty = local_best_difficulty;
s.best_nonce = local_best_nonce;
}
attempts = 0;
}
}
// Final flush
let mut s = shared.lock().unwrap();
s.total_attempts += attempts;
if local_best_difficulty > s.best_difficulty {
s.best_difficulty = local_best_difficulty;
s.best_nonce = local_best_nonce;
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_core::crypto::keys::generate_keypair;
use nostr_core::types::Kind;
#[test]
fn test_mine_low_difficulty() {
let (sk, pk) = generate_keypair();
let event = Event::new(pk, 1234567890, Kind::Text, vec![], "hello mining");
let event_json = serde_json::to_string(&event).unwrap();
let priv_bytes = sk.as_bytes();
let result = miner_run(&event_json, &priv_bytes, 4, 2, 5).unwrap();
assert!(result.achieved_difficulty >= 4, "achieved: {}", result.achieved_difficulty);
assert!(result.target_reached);
assert!(result.total_attempts > 0);
}
#[test]
fn test_mine_zero_target_returns_best() {
let (sk, pk) = generate_keypair();
let event = Event::new(pk, 1234567890, Kind::Text, vec![], "test zero target");
let event_json = serde_json::to_string(&event).unwrap();
let priv_bytes = sk.as_bytes();
// target=0 means mine for the full timeout, return best found
let result = miner_run(&event_json, &priv_bytes, 0, 2, 1).unwrap();
// Should have found something with at least 0 difficulty
assert!(result.achieved_difficulty >= 0);
assert!(!result.best_event_json.is_empty());
}
#[test]
fn test_mine_invalid_event_json() {
let (sk, _) = generate_keypair();
let priv_bytes = sk.as_bytes();
assert!(miner_run("not valid json", &priv_bytes, 4, 1, 5).is_err());
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Mnemonic state — holds the loaded BIP-39 mnemonic in secure memory.
//!
//! Port of `mnemonic.c`. Uses `nips::nip006` for BIP-39 validation,
//! generation, and seed conversion.
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
/// Maximum mnemonic length: 24 words * ~10 chars + spaces + null.
pub const MNEMONIC_MAX_LEN: usize = 256;
/// Mnemonic state — holds the loaded mnemonic in secure memory.
///
/// Only one mnemonic is active at a time per process.
pub struct MnemonicState {
buf: Option<SecureBuf>,
loaded: bool,
word_count: u8,
}
impl MnemonicState {
/// Create an empty mnemonic state (no mnemonic loaded).
pub fn new() -> Self {
MnemonicState {
buf: None,
loaded: false,
word_count: 0,
}
}
/// Load a mnemonic string into secure memory.
///
/// Validates word count (12/15/18/21/24) and BIP-39 checksum.
/// Returns `InvalidInput` on invalid mnemonic, `MemoryFailed` on alloc error.
pub fn load(&mut self, phrase: &str) -> Result<(), NsignerError> {
let words: Vec<&str> = phrase.split_whitespace().collect();
let count = words.len();
// Validate word count
if ![12, 15, 18, 21, 24].contains(&count) {
return Err(NsignerError::InvalidInput);
}
// Validate via nostr_core_lib_rust
if !nips::nip006::mnemonic_validate(phrase) {
return Err(NsignerError::InvalidInput);
}
// Store in secure memory
let phrase_bytes = phrase.as_bytes();
let mut buf = SecureBuf::alloc(phrase_bytes.len() + 1)?;
buf.copy_from(phrase_bytes);
// NUL-terminate for C-compatible access if needed
buf.as_mut_slice()[phrase_bytes.len()] = 0;
self.buf = Some(buf);
self.loaded = true;
self.word_count = count as u8;
Ok(())
}
/// Generate a new BIP-39 mnemonic phrase.
///
/// `word_count` must be 12, 15, 18, 21, or 24.
pub fn generate(&mut self, word_count: u8) -> Result<String, NsignerError> {
let entropy_bytes = match word_count {
12 => 16,
15 => 20,
18 => 24,
21 => 28,
24 => 32,
_ => return Err(NsignerError::InvalidInput),
};
let mut entropy = vec![0u8; entropy_bytes];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut entropy);
let phrase = nips::nip006::mnemonic_from_bytes(&entropy)
.map_err(|_| NsignerError::CryptoFailed)?;
// Zeroize entropy
use zeroize::Zeroize;
entropy.zeroize();
// Load into state
self.load(&phrase)?;
Ok(phrase)
}
/// Zeroize and unload the mnemonic. Idempotent.
pub fn unload(&mut self) {
self.buf = None; // Drop runs zeroize + munlock
self.loaded = false;
self.word_count = 0;
}
/// Check if a mnemonic is currently loaded.
pub fn is_loaded(&self) -> bool {
self.loaded
}
/// Get the mnemonic string (only valid while loaded).
pub fn phrase(&self) -> Option<&str> {
if !self.loaded {
return None;
}
let buf = self.buf.as_ref()?;
// Exclude trailing NUL
let len = buf.size().saturating_sub(1);
let bytes = &buf.as_slice()[..len];
std::str::from_utf8(bytes).ok()
}
/// Word count of the loaded mnemonic (0 if not loaded).
pub fn word_count(&self) -> u8 {
self.word_count
}
/// Convert the mnemonic to a 64-byte BIP-39 seed (PBKDF2, 2048 rounds).
///
/// Uses an empty passphrase. For passphrase support, use [`to_seed_with_passphrase`].
pub fn to_seed(&self) -> Option<[u8; 64]> {
let phrase = self.phrase()?;
Some(nips::nip006::mnemonic_to_seed(phrase, ""))
}
/// Convert the mnemonic to a 64-byte BIP-39 seed with a passphrase.
///
/// The passphrase is zeroized from local memory after use.
pub fn to_seed_with_passphrase(&self, passphrase: &str) -> Option<[u8; 64]> {
let phrase = self.phrase()?;
let seed = nips::nip006::mnemonic_to_seed(phrase, passphrase);
// Zeroize passphrase bytes in local stack copy
let mut pp_bytes = passphrase.as_bytes().to_vec();
use zeroize::Zeroize;
pp_bytes.zeroize();
Some(seed)
}
}
impl Default for MnemonicState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_valid_mnemonic() {
let mut state = MnemonicState::new();
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
assert!(state.load(phrase).is_ok());
assert!(state.is_loaded());
assert_eq!(state.word_count(), 12);
}
#[test]
fn test_load_invalid_word_count() {
let mut state = MnemonicState::new();
assert!(state.load("abandon abandon abandon").is_err()); // 3 words
assert!(!state.is_loaded());
}
#[test]
fn test_unload() {
let mut state = MnemonicState::new();
state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap();
state.unload();
assert!(!state.is_loaded());
assert!(state.phrase().is_none());
}
#[test]
fn test_generate() {
let mut state = MnemonicState::new();
let phrase = state.generate(12).unwrap();
assert!(state.is_loaded());
let words: Vec<&str> = phrase.split_whitespace().collect();
assert_eq!(words.len(), 12);
}
#[test]
fn test_to_seed() {
let mut state = MnemonicState::new();
state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap();
let seed = state.to_seed().unwrap();
assert_eq!(seed.len(), 64);
}
#[test]
fn test_to_seed_deterministic() {
// Same mnemonic + empty passphrase must always produce the same seed.
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let mut state = MnemonicState::new();
state.load(phrase).unwrap();
let seed1 = state.to_seed().unwrap();
let seed2 = state.to_seed().unwrap();
assert_eq!(seed1, seed2);
assert_eq!(seed1.len(), 64);
}
#[test]
fn test_to_seed_with_passphrase_differs() {
let mut state = MnemonicState::new();
state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap();
let seed_no_pp = state.to_seed().unwrap();
let seed_with_pp = state.to_seed_with_passphrase("test").unwrap();
assert_ne!(seed_no_pp, seed_with_pp);
}
#[test]
fn test_load_replaces_existing() {
let mut state = MnemonicState::new();
state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap();
// Load a different valid mnemonic
state.load("legal winner thank year wave sausage worth useful legal winner thank yellow").unwrap();
assert_eq!(state.word_count(), 12);
assert!(state.phrase().unwrap().starts_with("legal"));
}
#[test]
fn test_load_invalid_word() {
let mut state = MnemonicState::new();
// 12 words but one is not a BIP-39 word
assert!(state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon notaword").is_err());
}
#[test]
fn test_generate_all_word_counts() {
for &wc in &[12u8, 15, 18, 21, 24] {
let mut state = MnemonicState::new();
let phrase = state.generate(wc).unwrap();
let words: Vec<&str> = phrase.split_whitespace().collect();
assert_eq!(words.len(), wc as usize);
assert!(state.is_loaded());
}
}
#[test]
fn test_generate_invalid_word_count() {
let mut state = MnemonicState::new();
assert!(state.generate(13).is_err());
assert!(state.generate(0).is_err());
assert!(state.generate(25).is_err());
}
}
+332
View File
@@ -0,0 +1,332 @@
//! OTP pad — one-time pad encryption.
//!
//! Port of `otp_pad.c` + `libotppad.c`. One pad per session, bound at
//! startup. Pad offset advances monotonically across requests.
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
/// OTP pad state.
pub struct OtpPadState {
bound: bool,
pads_dir: String,
chksum: String,
pad_path: String,
pad_file: Option<File>,
pad_size: u64,
offset: u64,
scratch: Option<SecureBuf>,
}
impl OtpPadState {
pub fn new() -> Self {
OtpPadState {
bound: false,
pads_dir: String::new(),
chksum: String::new(),
pad_path: String::new(),
pad_file: None,
pad_size: 0,
offset: 0,
scratch: None,
}
}
/// Check if a pad is bound.
pub fn is_bound(&self) -> bool {
self.bound
}
/// Get the pad checksum (64 hex chars).
pub fn chksum(&self) -> Option<&str> {
if self.bound {
Some(&self.chksum)
} else {
None
}
}
/// Get the current pad offset.
pub fn current_offset(&self) -> u64 {
self.offset
}
/// Get the total pad size.
pub fn pad_size(&self) -> u64 {
self.pad_size
}
/// Bind a pad at startup.
///
/// `dir` — directory containing .pad and .state files
/// `spec` — pad checksum (64 hex) or unique prefix
/// `allow_blkback` — allow pads on qvm-block devices (not for production)
pub fn bind(&mut self, dir: &str, spec: &str, _allow_blkback: bool) -> Result<(), NsignerError> {
// Find the pad file matching the spec
let pad_filename = if spec.len() == 64 {
format!("{}/{}.pad", dir, spec)
} else {
// Prefix match — find a .pad file starting with spec
let entries = std::fs::read_dir(dir)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
let mut found = None;
for entry in entries {
if let Ok(entry) = entry {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(spec) && name_str.ends_with(".pad") {
found = Some(entry.path());
break;
}
}
}
found
.map(|p| p.to_string_lossy().to_string())
.ok_or(NsignerError::InvalidInput)?
};
let file = File::open(&pad_filename)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
let metadata = file
.metadata()
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
let size = metadata.len();
// Extract checksum from filename
let chksum = std::path::Path::new(&pad_filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
// Read offset from .state file
let state_path = format!("{}/{}.state", dir, chksum);
let offset = if let Ok(state_content) = std::fs::read_to_string(&state_path) {
state_content.trim().parse().unwrap_or(0)
} else {
0
};
// Allocate scratch buffer for XOR
let scratch = SecureBuf::alloc(4 * 1024 * 1024) // 4 MB max chunk
.map_err(|_| NsignerError::MemoryFailed)?;
self.bound = true;
self.pads_dir = dir.to_string();
self.chksum = chksum;
self.pad_path = pad_filename;
self.pad_file = Some(file);
self.pad_size = size;
self.offset = offset;
self.scratch = Some(scratch);
Ok(())
}
/// Unbind the pad and zeroize scratch buffer.
pub fn unbind(&mut self) {
self.bound = false;
self.pad_file = None;
self.scratch = None; // Drop runs zeroize
self.offset = 0;
self.pad_size = 0;
}
/// Encrypt plaintext using the OTP pad.
///
/// Returns (ciphertext, pad_offset_before, pad_offset_after).
/// TODO: Phase 12 — full ASCII armoring + binary encoding
pub fn encrypt(
&mut self,
plaintext: &[u8],
_encoding: Option<&str>,
) -> Result<(Vec<u8>, u64, u64), NsignerError> {
if !self.bound {
return Err(NsignerError::InvalidInput);
}
let off_before = self.offset;
let ct = self.xor_with_pad(plaintext)?;
let off_after = self.offset;
Ok((ct, off_before, off_after))
}
/// Decrypt ciphertext using the OTP pad.
///
/// Returns plaintext.
/// TODO: Phase 12 — full ASCII armoring + binary encoding
pub fn decrypt(
&mut self,
ciphertext: &[u8],
_encoding: Option<&str>,
) -> Result<Vec<u8>, NsignerError> {
if !self.bound {
return Err(NsignerError::InvalidInput);
}
self.xor_with_pad(ciphertext)
}
/// XOR data with pad bytes at the current offset, advancing the offset.
fn xor_with_pad(&mut self, data: &[u8]) -> Result<Vec<u8>, NsignerError> {
let file = self.pad_file.as_mut().ok_or(NsignerError::InvalidInput)?;
let scratch = self.scratch.as_mut().ok_or(NsignerError::InvalidInput)?;
let data_len = data.len();
if data_len > scratch.size() {
return Err(NsignerError::InvalidInput);
}
// Seek to current offset
file.seek(SeekFrom::Start(self.offset))
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
// Read pad bytes
let pad_slice = &mut scratch.as_mut_slice()[..data_len];
file.read_exact(pad_slice)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
// XOR
let result: Vec<u8> = data
.iter()
.zip(pad_slice.iter())
.map(|(d, p)| d ^ p)
.collect();
// Zeroize the pad slice we just used
crate::secure_mem::secure_memzero(&mut scratch.as_mut_slice()[..data_len]);
// Advance offset
self.offset += data_len as u64;
// Persist offset to .state file
let state_path = format!("{}/{}.state", self.pads_dir, self.chksum);
let _ = std::fs::write(&state_path, self.offset.to_string());
Ok(result)
}
}
impl Default for OtpPadState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn make_test_pad(dir: &std::path::Path, size: usize) -> String {
std::fs::create_dir_all(dir).unwrap();
// Create a deterministic pad: bytes 0,1,2,...,255,0,1,...
let pad_data: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
let chksum = hex::encode(&nostr_core::crypto::sha256::sha256(&pad_data));
let pad_path = dir.join(format!("{}.pad", chksum));
let mut file = File::create(&pad_path).unwrap();
file.write_all(&pad_data).unwrap();
chksum
}
#[test]
fn test_bind_and_encrypt() {
let dir = std::env::temp_dir().join("nsigner_otp_test_1");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
let mut otp = OtpPadState::new();
assert!(otp.bind(dir.to_str().unwrap(), &chksum, false).is_ok());
assert!(otp.is_bound());
assert_eq!(otp.pad_size(), 1024);
assert_eq!(otp.current_offset(), 0);
let plaintext = b"hello world";
let (ciphertext, off_before, off_after) = otp.encrypt(plaintext, None).unwrap();
assert_eq!(off_before, 0);
assert_eq!(off_after, plaintext.len() as u64);
assert_eq!(ciphertext.len(), plaintext.len());
// XOR with deterministic pad: plaintext[i] ^ (i % 256)
for i in 0..plaintext.len() {
assert_eq!(ciphertext[i], plaintext[i] ^ (i as u8));
}
// Cleanup
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let dir = std::env::temp_dir().join("nsigner_otp_test_2");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
let mut otp = OtpPadState::new();
otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap();
let plaintext = b"secret message for OTP encryption!";
let (ciphertext, _, _) = otp.encrypt(plaintext, None).unwrap();
// Decrypt: need to seek back to offset 0
// For this test, create a new pad state at offset 0
let mut otp2 = OtpPadState::new();
otp2.bind(dir.to_str().unwrap(), &chksum, false).unwrap();
// Reset offset to 0 by overwriting state file
let state_path = dir.join(format!("{}.state", chksum));
std::fs::write(&state_path, "0").unwrap();
otp2.offset = 0;
let decrypted = otp2.decrypt(&ciphertext, None).unwrap();
assert_eq!(decrypted, plaintext);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_encrypt_without_bind_fails() {
let mut otp = OtpPadState::new();
assert!(otp.encrypt(b"test", None).is_err());
}
#[test]
fn test_unbind() {
let dir = std::env::temp_dir().join("nsigner_otp_test_3");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
let mut otp = OtpPadState::new();
otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap();
assert!(otp.is_bound());
otp.unbind();
assert!(!otp.is_bound());
assert_eq!(otp.current_offset(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_offset_advances() {
let dir = std::env::temp_dir().join("nsigner_otp_test_4");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
let mut otp = OtpPadState::new();
otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap();
let (_, off1_before, off1_after) = otp.encrypt(b"first", None).unwrap();
assert_eq!(off1_before, 0);
assert_eq!(off1_after, 5);
let (_, off2_before, off2_after) = otp.encrypt(b"second", None).unwrap();
assert_eq!(off2_before, 5);
assert_eq!(off2_after, 11);
let _ = std::fs::remove_dir_all(&dir);
}
}
+461
View File
@@ -0,0 +1,461 @@
//! Policy — caller-based access control with pre-approval and session grants.
//!
//! Port of `policy.c`.
use crate::role_table::RoleEntry;
// ── Limits ───────────────────────────────────────────────────────────────────
pub const POLICY_MAX_ENTRIES: usize = 32;
pub const POLICY_MAX_VERBS: usize = 16;
pub const POLICY_MAX_ROLES: usize = 16;
pub const POLICY_MAX_ALGS: usize = 16;
pub const POLICY_CALLER_MAX_LEN: usize = 160;
// ── Prompt Behavior ──────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptMode {
Never,
FirstPerBoot,
EveryRequest,
Deny,
}
impl PromptMode {
pub fn from_str(s: &str) -> Self {
match s {
"never" => Self::Never,
"first" => Self::FirstPerBoot,
"every" => Self::EveryRequest,
"deny" => Self::Deny,
_ => Self::EveryRequest,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Never => "never",
Self::FirstPerBoot => "first",
Self::EveryRequest => "every",
Self::Deny => "deny",
}
}
}
// ── Policy Source ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicySource {
Default,
Preapprove,
SessionGrant,
}
// ── Policy Entry ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct PolicyEntry {
pub caller: String, // e.g. "uid:1000" or "*" for any
pub verbs: Vec<String>,
pub roles: Vec<String>,
pub purposes: Vec<String>,
pub algorithms: Vec<String>,
pub index_min: i32, // -1 = any
pub index_max: i32, // -1 = any
pub prompt: PromptMode,
pub source: PolicySource,
}
impl Default for PolicyEntry {
fn default() -> Self {
PolicyEntry {
caller: String::new(),
verbs: Vec::new(),
roles: Vec::new(),
purposes: Vec::new(),
algorithms: Vec::new(),
index_min: -1,
index_max: -1,
prompt: PromptMode::EveryRequest,
source: PolicySource::Default,
}
}
}
// ── Policy Check Result ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyResult {
Allow,
AllowSessionVerb,
AllowSessionAll,
Deny,
Prompt,
NoMatch,
}
// ── Policy Table ─────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
pub struct PolicyTable {
pub entries: Vec<PolicyEntry>,
}
impl PolicyTable {
pub fn new() -> Self {
Self::default()
}
/// Initialize default policy: allow same-uid, prompt for others.
pub fn init_default(&mut self, owner_uid: u32) {
self.entries.clear();
// Same-uid: prompt
let mut same_uid = PolicyEntry::default();
same_uid.caller = format!("uid:{}", owner_uid);
same_uid.prompt = PromptMode::EveryRequest;
same_uid.source = PolicySource::Default;
self.entries.push(same_uid);
// Catch-all: deny
let mut catch_all = PolicyEntry::default();
catch_all.caller = "*".to_string();
catch_all.prompt = PromptMode::Deny;
catch_all.source = PolicySource::Default;
self.entries.push(catch_all);
}
/// Add a policy entry.
pub fn add(&mut self, entry: PolicyEntry) -> Result<(), crate::NsignerError> {
if self.entries.len() >= POLICY_MAX_ENTRIES {
return Err(crate::NsignerError::Internal("policy table full".into()));
}
self.entries.push(entry);
Ok(())
}
/// Insert a pre-approve entry at the front of the table so it takes
/// priority over default entries (same-uid prompt, catch-all deny).
pub fn insert_before_last(&mut self, entry: PolicyEntry) -> Result<(), crate::NsignerError> {
if self.entries.len() >= POLICY_MAX_ENTRIES {
return Err(crate::NsignerError::Internal("policy table full".into()));
}
// Insert at front so preapprove rules are checked before defaults
self.entries.insert(0, entry);
Ok(())
}
/// Insert a session grant for caller+role+verb.
///
/// The grant allows the caller to execute `verb` on `role` for the
/// remainder of the session without prompting.
pub fn insert_session_grant(
&mut self,
caller: &str,
verb: &str,
role: &str,
) -> Result<(), crate::NsignerError> {
let mut entry = PolicyEntry::default();
entry.caller = caller.to_string();
entry.verbs.push(verb.to_string());
entry.roles.push(role.to_string());
entry.prompt = PromptMode::Never;
entry.source = PolicySource::SessionGrant;
self.insert_before_last(entry)
}
/// Insert a session grant for caller+role (all verbs).
///
/// The grant allows the caller to execute any verb on `role` for the
/// remainder of the session without prompting.
pub fn insert_session_grant_all(
&mut self,
caller: &str,
role: &str,
) -> Result<(), crate::NsignerError> {
let mut entry = PolicyEntry::default();
entry.caller = caller.to_string();
entry.roles.push(role.to_string());
entry.prompt = PromptMode::Never;
entry.source = PolicySource::SessionGrant;
self.insert_before_last(entry)
}
/// Role-based policy check.
pub fn check(
&self,
caller_id: &str,
verb: &str,
role_name: &str,
purpose: &str,
) -> (PolicyResult, PolicySource) {
for entry in &self.entries {
if !matches(&entry.caller, caller_id) {
continue;
}
if !entry.verbs.is_empty() && !entry.verbs.iter().any(|v| v == verb) {
continue;
}
if !entry.roles.is_empty() && !entry.roles.iter().any(|r| r == role_name) {
continue;
}
if !entry.purposes.is_empty() && !entry.purposes.iter().any(|p| p == purpose) {
continue;
}
// Match found
return match entry.prompt {
PromptMode::Never => (PolicyResult::Allow, entry.source),
PromptMode::Deny => (PolicyResult::Deny, entry.source),
_ => (PolicyResult::Prompt, entry.source),
};
}
(PolicyResult::NoMatch, PolicySource::Default)
}
/// Role-aware policy check: if role has requires_approval==0 (role-as-password),
/// returns Allow immediately without checking policy entries.
pub fn check_with_role(
&self,
caller_id: &str,
verb: &str,
role_name: &str,
purpose: &str,
role: Option<&RoleEntry>,
) -> (PolicyResult, PolicySource) {
// Role-as-password: skip policy if role doesn't require approval
if let Some(r) = role {
if !r.requires_approval {
return (PolicyResult::Allow, PolicySource::Default);
}
}
self.check(caller_id, verb, role_name, purpose)
}
/// Algorithm-based policy check.
pub fn check_algorithm(
&self,
caller_id: &str,
verb: &str,
algorithm: &str,
index: i32,
) -> (PolicyResult, PolicySource) {
for entry in &self.entries {
if !matches(&entry.caller, caller_id) {
continue;
}
if !entry.verbs.is_empty() && !entry.verbs.iter().any(|v| v == verb) {
continue;
}
if !entry.algorithms.is_empty() && !entry.algorithms.iter().any(|a| a == algorithm) {
continue;
}
if entry.index_min >= 0 && index < entry.index_min {
continue;
}
if entry.index_max >= 0 && index > entry.index_max {
continue;
}
return match entry.prompt {
PromptMode::Never => (PolicyResult::Allow, entry.source),
PromptMode::Deny => (PolicyResult::Deny, entry.source),
_ => (PolicyResult::Prompt, entry.source),
};
}
(PolicyResult::NoMatch, PolicySource::Default)
}
}
// ── Pre-approve Spec Parser ──────────────────────────────────────────────────
/// Parse a --preapprove spec into a policy entry.
///
/// Spec format: `caller=<id>,role=<name>,verb=sign,verify`
/// or: `caller=<id>,algorithm=ed25519,index=0-4,verb=sign,verify`
/// or: `caller=<id>,role=main,verb=nostr_sign_event,nostr_get_public_key`
pub fn parse_preapprove_spec(spec: &str) -> Result<PolicyEntry, crate::NsignerError> {
let mut entry = PolicyEntry::default();
entry.source = PolicySource::Preapprove;
for field in spec.split(',') {
let (key, value) = field
.split_once('=')
.ok_or_else(|| crate::NsignerError::InvalidInput)?;
match key.trim() {
"caller" => entry.caller = value.trim().to_string(),
"role" => entry.roles.push(value.trim().to_string()),
"verb" => {
for v in value.split('|') {
entry.verbs.push(v.trim().to_string());
}
}
"algorithm" => entry.algorithms.push(value.trim().to_string()),
"index" => {
// Parse "N" or "N-M"
let v = value.trim();
if let Some(dash) = v.find('-') {
entry.index_min = v[..dash]
.parse()
.map_err(|_| crate::NsignerError::InvalidInput)?;
entry.index_max = v[dash + 1..]
.parse()
.map_err(|_| crate::NsignerError::InvalidInput)?;
} else {
let idx: i32 = v
.parse()
.map_err(|_| crate::NsignerError::InvalidInput)?;
entry.index_min = idx;
entry.index_max = idx;
}
}
_ => return Err(crate::NsignerError::InvalidInput),
}
}
if entry.caller.is_empty() {
return Err(crate::NsignerError::InvalidInput);
}
// Default prompt mode for preapprove: never (auto-allow)
entry.prompt = PromptMode::Never;
Ok(entry)
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Check if a caller pattern matches a caller ID. "*" matches any.
fn matches(pattern: &str, caller_id: &str) -> bool {
pattern == "*" || pattern == caller_id
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_policy() {
let mut table = PolicyTable::new();
table.init_default(1000);
// Same-uid should prompt
let (result, _) = table.check("uid:1000", "sign", "main", "nostr");
assert_eq!(result, PolicyResult::Prompt);
// Different uid should deny (catch-all)
let (result, _) = table.check("uid:2000", "sign", "main", "nostr");
assert_eq!(result, PolicyResult::Deny);
}
#[test]
fn test_preapprove() {
let mut table = PolicyTable::new();
table.init_default(1000);
let entry = parse_preapprove_spec("caller=uid:1000,role=main,verb=nostr_sign_event").unwrap();
table.insert_before_last(entry).unwrap();
// Now uid:1000 with nostr_sign_event on main should be allowed
let (result, source) = table.check("uid:1000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Allow);
assert_eq!(source, PolicySource::Preapprove);
}
#[test]
fn test_preapprove_algorithm() {
let mut table = PolicyTable::new();
table.init_default(1000);
let entry =
parse_preapprove_spec("caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign").unwrap();
table.insert_before_last(entry).unwrap();
let (result, _) = table.check_algorithm("uid:1000", "sign", "ed25519", 2);
assert_eq!(result, PolicyResult::Allow);
let (result, _) = table.check_algorithm("uid:1000", "sign", "ed25519", 5);
assert_eq!(result, PolicyResult::Prompt); // out of range, falls to default
}
#[test]
fn test_role_as_password() {
let mut table = PolicyTable::new();
table.init_default(1000);
let mut role = RoleEntry::default();
role.name = "main".into();
role.requires_approval = false;
// Role-as-password: should allow without checking policy
let (result, _) =
table.check_with_role("uid:2000", "nostr_sign_event", "main", "nostr", Some(&role));
assert_eq!(result, PolicyResult::Allow);
}
#[test]
fn test_parse_preapprove_spec() {
let entry = parse_preapprove_spec("caller=uid:1000,role=main,verb=sign|verify").unwrap();
assert_eq!(entry.caller, "uid:1000");
assert_eq!(entry.roles, vec!["main"]);
assert_eq!(entry.verbs, vec!["sign", "verify"]);
}
#[test]
fn test_session_grant_verb() {
let mut table = PolicyTable::new();
table.init_default(1000);
// Without grant: same-uid prompts
let (result, _) = table.check("uid:1000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Prompt);
// Insert session grant for caller+role+verb
table
.insert_session_grant("uid:1000", "nostr_sign_event", "main")
.unwrap();
// Now allowed
let (result, source) = table.check("uid:1000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Allow);
assert_eq!(source, PolicySource::SessionGrant);
// Different verb still prompts
let (result, _) = table.check("uid:1000", "nostr_get_public_key", "main", "nostr");
assert_eq!(result, PolicyResult::Prompt);
}
#[test]
fn test_session_grant_all_verbs() {
let mut table = PolicyTable::new();
table.init_default(1000);
table
.insert_session_grant_all("uid:1000", "main")
.unwrap();
// Any verb on main is allowed
let (result, source) = table.check("uid:1000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Allow);
assert_eq!(source, PolicySource::SessionGrant);
let (result, _) = table.check("uid:1000", "nostr_get_public_key", "main", "nostr");
assert_eq!(result, PolicyResult::Allow);
// Different role still prompts
let (result, _) = table.check("uid:1000", "nostr_sign_event", "ssh", "ssh");
assert_eq!(result, PolicyResult::Prompt);
}
#[test]
fn test_session_grant_does_not_affect_other_callers() {
let mut table = PolicyTable::new();
table.init_default(1000);
table
.insert_session_grant("uid:1000", "nostr_sign_event", "main")
.unwrap();
// Different caller still denied by catch-all
let (result, _) = table.check("uid:2000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Deny);
}
}
+319
View File
@@ -0,0 +1,319 @@
//! Post-quantum crypto algorithm registry.
//!
//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum and size
//! constants for all six algorithms. Actual crypto operations
//! (ed25519, x25519, PQ) are implemented in Phase 13.
// ── Algorithm Identifiers ────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CryptoAlg {
Secp256k1,
Ed25519,
X25519,
MlDsa65,
SlhDsa128s,
MlKem768,
Unknown,
}
impl CryptoAlg {
pub fn from_str(s: &str) -> Self {
match s {
"secp256k1" => Self::Secp256k1,
"ed25519" => Self::Ed25519,
"x25519" => Self::X25519,
"ml-dsa-65" => Self::MlDsa65,
"slh-dsa-128s" => Self::SlhDsa128s,
"ml-kem-768" => Self::MlKem768,
_ => Self::Unknown,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Secp256k1 => "secp256k1",
Self::Ed25519 => "ed25519",
Self::X25519 => "x25519",
Self::MlDsa65 => "ml-dsa-65",
Self::SlhDsa128s => "slh-dsa-128s",
Self::MlKem768 => "ml-kem-768",
Self::Unknown => "unknown",
}
}
}
// ── Key Sizes ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
pub struct CryptoAlgSizes {
pub priv_key_len: usize,
pub pub_key_len: usize,
pub sig_len: usize, // 0 for KEM
pub ciphertext_len: usize, // 0 for signatures
pub shared_secret_len: usize, // 0 for signatures
}
impl CryptoAlg {
pub fn sizes(&self) -> Option<CryptoAlgSizes> {
match self {
Self::Secp256k1 => Some(CryptoAlgSizes {
priv_key_len: 32, pub_key_len: 32, sig_len: 64, ciphertext_len: 0, shared_secret_len: 0,
}),
Self::Ed25519 => Some(CryptoAlgSizes {
priv_key_len: 32, pub_key_len: 32, sig_len: 64, ciphertext_len: 0, shared_secret_len: 0,
}),
Self::X25519 => Some(CryptoAlgSizes {
priv_key_len: 32, pub_key_len: 32, sig_len: 0, ciphertext_len: 0, shared_secret_len: 32,
}),
Self::MlDsa65 => Some(CryptoAlgSizes {
priv_key_len: 4032, pub_key_len: 1952, sig_len: 3309, ciphertext_len: 0, shared_secret_len: 0,
}),
Self::SlhDsa128s => Some(CryptoAlgSizes {
priv_key_len: 64, pub_key_len: 32, sig_len: 7856, ciphertext_len: 0, shared_secret_len: 0,
}),
Self::MlKem768 => Some(CryptoAlgSizes {
priv_key_len: 2400, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32,
}),
Self::Unknown => None,
}
}
}
// ── Crypto Operations (stubs — Phase 13) ─────────────────────────────────────
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path (SLIP-0010).
///
/// For secp256k1: uses BIP-32 derivation.
/// For ed25519/x25519: uses SLIP-0010 (all-hardened).
/// For PQ: uses SLIP-0010 to get a 32-byte seed, then feeds DRBG for keygen.
pub fn derive_seed_from_mnemonic(
mnemonic: &str,
path: &str,
) -> Result<[u8; 32], crate::NsignerError> {
let seed = nips::nip006::mnemonic_to_seed(mnemonic, "");
// Parse the path
let path_indices = nips::nip006::parse_bip44_path(path)
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
// Determine if this is a secp256k1 path (BIP-32) or ed25519/x25519 path (SLIP-0010)
// by checking the purpose prefix.
if path.starts_with("m/44'/1237'") {
// BIP-32 derivation for secp256k1
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&seed);
let (derived_key, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &path_indices)
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
Ok(derived_key)
} else {
// SLIP-0010 derivation for ed25519/x25519/PQ
let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&seed);
let (derived_key, _) = nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices)
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
Ok(derived_key)
}
}
/// ed25519: derive keypair from a 32-byte seed.
pub fn ed25519_keygen_from_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
use ed25519_dalek::{SigningKey, VerifyingKey};
let signing = SigningKey::from_bytes(seed);
let public: VerifyingKey = signing.verifying_key();
(*seed, public.to_bytes())
}
/// ed25519: sign a message. priv is 32-byte private key.
/// Returns 64-byte signature.
pub fn ed25519_sign(priv_key: &[u8; 32], msg: &[u8]) -> [u8; 64] {
use ed25519_dalek::{Signer, SigningKey};
let signing = SigningKey::from_bytes(priv_key);
let sig = signing.sign(msg);
sig.to_bytes()
}
/// ed25519: verify a signature. pub is 32-byte public key.
/// Returns true on valid, false on invalid.
pub fn ed25519_verify(pub_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> bool {
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let verifying = match VerifyingKey::from_bytes(pub_key) {
Ok(k) => k,
Err(_) => return false,
};
let signature = Signature::from_bytes(sig);
verifying.verify(msg, &signature).is_ok()
}
/// x25519: derive keypair from a 32-byte seed.
pub fn x25519_keygen_from_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
let secret = x25519_dalek::StaticSecret::from(*seed);
let public = x25519_dalek::PublicKey::from(&secret);
(*seed, public.to_bytes())
}
/// x25519: derive shared secret from our private key and peer's public key.
pub fn x25519_ecdh(our_priv: &[u8; 32], peer_pub: &[u8; 32]) -> [u8; 32] {
let secret = x25519_dalek::StaticSecret::from(*our_priv);
let public = x25519_dalek::PublicKey::from(*peer_pub);
secret.diffie_hellman(&public).to_bytes()
}
// ── secp256k1 ECDSA (not just Schnorr) ──────────────────────────────────────
/// secp256k1 ECDSA sign arbitrary bytes.
/// Hashes the message with SHA-256 before signing.
/// Returns 64-byte compact signature (r || s).
pub fn secp256k1_ecdsa_sign(priv_key: &[u8; 32], msg: &[u8]) -> Result<[u8; 64], crate::NsignerError> {
use secp256k1::{Message, Secp256k1, SecretKey};
let secp = Secp256k1::new();
let sk = SecretKey::from_slice(priv_key).map_err(|_| crate::NsignerError::CryptoFailed)?;
let hash = sha256(msg);
let msg = Message::from_digest_slice(&hash).map_err(|_| crate::NsignerError::CryptoFailed)?;
let sig = secp.sign_ecdsa(&msg, &sk);
Ok(sig.serialize_compact())
}
/// secp256k1 ECDSA verify.
/// pub is 32-byte x-only pubkey (converted internally to compressed form).
/// sig is 64-byte compact (r || s).
pub fn secp256k1_ecdsa_verify(pub_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> bool {
use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature};
let secp = Secp256k1::new();
// x-only pubkey → compressed pubkey (prefix 0x02 for even)
let mut compressed = [0u8; 33];
compressed[0] = 0x02;
compressed[1..].copy_from_slice(pub_key);
let pk = match PublicKey::from_slice(&compressed) {
Ok(k) => k,
Err(_) => return false,
};
let hash = sha256(msg);
let msg = match Message::from_digest_slice(&hash) {
Ok(m) => m,
Err(_) => return false,
};
let signature = match Signature::from_compact(sig) {
Ok(s) => s,
Err(_) => return false,
};
// Also need the secret key to get the full public key for verification...
// Actually, we can verify with just the public key.
secp.verify_ecdsa(&msg, &signature, &pk).is_ok()
}
// ── PQ Crypto Stubs ──────────────────────────────────────────────────────────
//
// The pure Rust crates (ml-dsa, ml-kem, slh-dsa) are included as dependencies
// for future implementation. Their APIs use `TryCryptoRng`, `KeyExport`, and
// other traits that require careful integration with the SHAKE-256 DRBG.
//
// TODO: Wire up the crate APIs for deterministic keygen from seed, sign, verify,
// encapsulate, and decapsulate operations.
/// ML-DSA-65: generate keypair from a 32-byte seed (deterministic).
/// TODO: Wire up ml-dsa crate API.
pub fn ml_dsa_65_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
}
/// ML-DSA-65: sign a message.
/// TODO: Wire up ml-dsa crate API.
pub fn ml_dsa_65_sign(_priv: &[u8], _msg: &[u8]) -> Result<Vec<u8>, crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
}
/// ML-DSA-65: verify a signature.
/// TODO: Wire up ml-dsa crate API.
pub fn ml_dsa_65_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
false
}
/// SLH-DSA-128s: generate keypair from a 32-byte seed (deterministic).
/// TODO: Wire up slh-dsa crate API.
pub fn slh_dsa_128s_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
}
/// SLH-DSA-128s: sign a message.
/// TODO: Wire up slh-dsa crate API.
pub fn slh_dsa_128s_sign(_priv: &[u8], _msg: &[u8]) -> Result<Vec<u8>, crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
}
/// SLH-DSA-128s: verify a signature.
/// TODO: Wire up slh-dsa crate API.
pub fn slh_dsa_128s_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
false
}
/// ML-KEM-768: generate keypair from a 32-byte seed (deterministic).
/// TODO: Wire up ml-kem crate API.
pub fn ml_kem_768_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
}
/// ML-KEM-768: encapsulate. pub is 1184-byte public key.
/// Returns (ciphertext[1088], shared_secret[32]).
/// TODO: Wire up ml-kem crate API.
pub fn ml_kem_768_encaps(_pub: &[u8]) -> Result<(Vec<u8>, [u8; 32]), crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
}
/// ML-KEM-768: decapsulate. priv is 2400-byte secret key, ct is 1088-byte ciphertext.
/// Returns shared_secret[32].
/// TODO: Wire up ml-kem crate API.
pub fn ml_kem_768_decaps(_priv: &[u8], _ct: &[u8]) -> Result<[u8; 32], crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// SHA-256 hash (via nostr_core_lib_rust).
fn sha256(data: &[u8]) -> [u8; 32] {
nostr_core::crypto::sha256::sha256(data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alg_from_str() {
assert_eq!(CryptoAlg::from_str("secp256k1"), CryptoAlg::Secp256k1);
assert_eq!(CryptoAlg::from_str("ed25519"), CryptoAlg::Ed25519);
assert_eq!(CryptoAlg::from_str("ml-dsa-65"), CryptoAlg::MlDsa65);
assert_eq!(CryptoAlg::from_str("unknown"), CryptoAlg::Unknown);
}
#[test]
fn test_sizes() {
let s = CryptoAlg::Secp256k1.sizes().unwrap();
assert_eq!(s.priv_key_len, 32);
assert_eq!(s.pub_key_len, 32);
let s = CryptoAlg::MlKem768.sizes().unwrap();
assert_eq!(s.priv_key_len, 2400);
assert_eq!(s.pub_key_len, 1184);
assert_eq!(s.ciphertext_len, 1088);
}
#[test]
fn test_ed25519_sign_verify() {
let seed = [0x42u8; 32];
let (priv_key, pub_key) = ed25519_keygen_from_seed(&seed);
let msg = b"hello world";
let sig = ed25519_sign(&priv_key, msg);
assert!(ed25519_verify(&pub_key, msg, &sig));
assert!(!ed25519_verify(&pub_key, b"wrong message", &sig));
}
#[test]
fn test_x25519_ecdh() {
let seed_a = [0x01u8; 32];
let seed_b = [0x02u8; 32];
let (priv_a, pub_a) = x25519_keygen_from_seed(&seed_a);
let (priv_b, pub_b) = x25519_keygen_from_seed(&seed_b);
let shared_a = x25519_ecdh(&priv_a, &pub_b);
let shared_b = x25519_ecdh(&priv_b, &pub_a);
assert_eq!(shared_a, shared_b);
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Deterministic PRNG for post-quantum key generation.
//!
//! Port of `pq_drbg.c`. Implements a SHAKE-256-based deterministic PRNG
//! that replaces PQClean's `randombytes()` callback. Same seed → same output.
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
/// DRBG state (not global — per-instance for thread safety).
pub struct Drbg {
seed: [u8; 32],
counter: u64,
buffer: [u8; 168], // SHAKE-256 squeeze buffer
buffer_pos: usize,
}
impl Drbg {
/// Initialize the DRBG with a 32-byte seed.
pub fn new(seed: &[u8; 32]) -> Self {
Drbg {
seed: *seed,
counter: 0,
buffer: [0u8; 168],
buffer_pos: 168, // forces refill on first read
}
}
/// Squeeze more bytes from SHAKE-256(seed || counter_le_64).
fn refill(&mut self) {
let mut hasher = Shake256::default();
hasher.update(&self.seed);
hasher.update(&self.counter.to_le_bytes());
let mut reader = hasher.finalize_xof();
let mut out = [0u8; 168];
reader.read(&mut out);
self.buffer = out;
self.buffer_pos = 0;
self.counter += 1;
}
/// Fill `buf` with pseudo-random bytes.
pub fn randombytes(&mut self, buf: &mut [u8]) {
for byte in buf.iter_mut() {
if self.buffer_pos >= self.buffer.len() {
self.refill();
}
*byte = self.buffer[self.buffer_pos];
self.buffer_pos += 1;
}
}
/// Zeroize the seed.
pub fn zeroize(&mut self) {
use zeroize::Zeroize;
self.seed.zeroize();
self.buffer.zeroize();
self.counter = 0;
self.buffer_pos = 0;
}
}
impl Drop for Drbg {
fn drop(&mut self) {
self.zeroize();
}
}
/// RngCore wrapper for SHAKE-256 DRBG — can be consumed by PQ crypto keygen.
///
/// Implements `rand_core::RngCore` + `CryptoRng` so it can be passed to
/// PQ crypto crates that require a deterministic RNG for seed-based keygen.
#[derive(Clone)]
pub struct ShakeDrbgRng {
seed: [u8; 32],
counter: u64,
buffer: [u8; 168],
buffer_pos: usize,
}
impl ShakeDrbgRng {
/// Create a new SHAKE-256 DRBG RNG from a 32-byte seed.
pub fn new(seed: &[u8; 32]) -> Self {
ShakeDrbgRng {
seed: *seed,
counter: 0,
buffer: [0u8; 168],
buffer_pos: 168, // forces refill on first read
}
}
fn refill(&mut self) {
let mut hasher = Shake256::default();
hasher.update(&self.seed);
hasher.update(&self.counter.to_le_bytes());
let mut reader = hasher.finalize_xof();
let mut out = [0u8; 168];
reader.read(&mut out);
self.buffer = out;
self.buffer_pos = 0;
self.counter += 1;
}
}
impl rand_core::RngCore for ShakeDrbgRng {
fn next_u32(&mut self) -> u32 {
let mut buf = [0u8; 4];
self.fill_bytes(&mut buf);
u32::from_le_bytes(buf)
}
fn next_u64(&mut self) -> u64 {
let mut buf = [0u8; 8];
self.fill_bytes(&mut buf);
u64::from_le_bytes(buf)
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
for byte in dest.iter_mut() {
if self.buffer_pos >= self.buffer.len() {
self.refill();
}
*byte = self.buffer[self.buffer_pos];
self.buffer_pos += 1;
}
}
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
self.fill_bytes(dest);
Ok(())
}
}
impl rand_core::CryptoRng for ShakeDrbgRng {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_determinism() {
let seed = [0x42u8; 32];
let mut drbg1 = Drbg::new(&seed);
let mut drbg2 = Drbg::new(&seed);
let mut out1 = [0u8; 100];
let mut out2 = [0u8; 100];
drbg1.randombytes(&mut out1);
drbg2.randombytes(&mut out2);
assert_eq!(out1, out2);
}
#[test]
fn test_different_seeds() {
let mut drbg1 = Drbg::new(&[0x01u8; 32]);
let mut drbg2 = Drbg::new(&[0x02u8; 32]);
let mut out1 = [0u8; 32];
let mut out2 = [0u8; 32];
drbg1.randombytes(&mut out1);
drbg2.randombytes(&mut out2);
assert_ne!(out1, out2);
}
#[test]
fn test_large_request() {
let mut drbg = Drbg::new(&[0x42u8; 32]);
let mut out = [0u8; 500]; // larger than buffer (168)
drbg.randombytes(&mut out);
// Should not panic
}
}
+643
View File
@@ -0,0 +1,643 @@
//! Role table — binds role names to derivation path templates.
//!
//! Port of `role_table.c`. Each role entry maps a human-readable name
//! (acting as an access token) to a BIP-44 derivation path template.
//! The template may contain a `%d` placeholder for a variable index.
use crate::NsignerError;
use std::collections::HashSet;
// ── Limits ───────────────────────────────────────────────────────────────────
/// Map role_curve + role_purpose to crypto_alg.
pub fn crypto_alg_from_role(curve: RoleCurve, purpose: RolePurpose) -> crate::pq_crypto::CryptoAlg {
use crate::pq_crypto::CryptoAlg;
match (curve, purpose) {
(RoleCurve::Secp256k1, RolePurpose::Nostr) => CryptoAlg::Secp256k1,
(RoleCurve::Ed25519, RolePurpose::Ssh) => CryptoAlg::Ed25519,
(RoleCurve::X25519, RolePurpose::Age) => CryptoAlg::X25519,
(RoleCurve::MlDsa65, RolePurpose::PqSig) => CryptoAlg::MlDsa65,
(RoleCurve::SlhDsa128s, RolePurpose::PqSig) => CryptoAlg::SlhDsa128s,
(RoleCurve::MlKem768, RolePurpose::PqKem) => CryptoAlg::MlKem768,
_ => CryptoAlg::Unknown,
}
}
pub const ROLE_NAME_MAX: usize = 64;
pub const ROLE_PATH_MAX: usize = 128;
pub const ROLE_PURPOSE_MAX: usize = 32;
pub const ROLE_CURVE_MAX: usize = 16;
pub const ROLE_TABLE_MAX_ENTRIES: usize = 256;
pub const PATH_ALLOWED_MAX: usize = 64;
// ── Enums ────────────────────────────────────────────────────────────────────
/// Purpose enum for fast comparison (string form kept for display).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RolePurpose {
Nostr,
Bitcoin,
Ssh,
Age,
Fips,
PqSig,
PqKem,
Unknown,
}
impl RolePurpose {
pub fn from_str(s: &str) -> Self {
match s {
"nostr" => Self::Nostr,
"bitcoin" => Self::Bitcoin,
"ssh" => Self::Ssh,
"age" => Self::Age,
"fips" => Self::Fips,
"pq_sig" => Self::PqSig,
"pq_kem" => Self::PqKem,
_ => Self::Unknown,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Nostr => "nostr",
Self::Bitcoin => "bitcoin",
Self::Ssh => "ssh",
Self::Age => "age",
Self::Fips => "fips",
Self::PqSig => "pq_sig",
Self::PqKem => "pq_kem",
Self::Unknown => "unknown",
}
}
}
/// Curve enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RoleCurve {
Secp256k1,
Ed25519,
X25519,
MlDsa65,
SlhDsa128s,
MlKem768,
Unknown,
}
impl RoleCurve {
pub fn from_str(s: &str) -> Self {
match s {
"secp256k1" => Self::Secp256k1,
"ed25519" => Self::Ed25519,
"x25519" => Self::X25519,
"ml-dsa-65" => Self::MlDsa65,
"slh-dsa-128s" => Self::SlhDsa128s,
"ml-kem-768" => Self::MlKem768,
_ => Self::Unknown,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Secp256k1 => "secp256k1",
Self::Ed25519 => "ed25519",
Self::X25519 => "x25519",
Self::MlDsa65 => "ml-dsa-65",
Self::SlhDsa128s => "slh-dsa-128s",
Self::MlKem768 => "ml-kem-768",
Self::Unknown => "unknown",
}
}
}
/// Selector type — how this role's key is addressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoleSelectorType {
NostrIndex,
RolePath,
}
// ── Role Entry ───────────────────────────────────────────────────────────────
/// A single role entry.
#[derive(Debug, Clone)]
pub struct RoleEntry {
pub name: String,
pub purpose_str: String,
pub curve_str: String,
pub purpose: RolePurpose,
pub curve: RoleCurve,
pub selector_type: RoleSelectorType,
/// Valid if selector_type == NostrIndex.
pub nostr_index: i32,
/// Valid if selector_type == RolePath. May contain `%d` placeholder.
pub role_path: String,
/// Filled after derivation, empty until then.
pub pubkey_hex: String,
/// 1 if pubkey_hex has been populated.
pub derived: bool,
/// Inclusive lower bound for %d; -1 = fixed path (no variable).
pub path_range_lo: i32,
/// Inclusive upper bound; == path_range_lo for single.
pub path_range_hi: i32,
/// Default index when client sends {"role":...} without "index"; -1 = require explicit.
pub path_default_index: i32,
/// Explicit set of allowed indices (for sets); empty = use range.
pub path_allowed_indices: Vec<i32>,
/// 0 = role-as-password (no prompt), 1 = require interactive approval.
pub requires_approval: bool,
}
impl Default for RoleEntry {
fn default() -> Self {
RoleEntry {
name: String::new(),
purpose_str: String::new(),
curve_str: String::new(),
purpose: RolePurpose::Unknown,
curve: RoleCurve::Unknown,
selector_type: RoleSelectorType::RolePath,
nostr_index: -1,
role_path: String::new(),
pubkey_hex: String::new(),
derived: false,
path_range_lo: -1,
path_range_hi: -1,
path_default_index: -1,
path_allowed_indices: Vec::new(),
requires_approval: false,
}
}
}
impl RoleEntry {
/// Check if the role path contains a `%d` variable placeholder.
pub fn has_variable_path(&self) -> bool {
self.selector_type == RoleSelectorType::RolePath && self.role_path.contains("%d")
}
/// Check if a concrete derivation path matches this role's path template.
///
/// The template may contain a `%d` placeholder (with optional `'` hardened marker).
/// Returns the extracted index if matched, or None.
pub fn path_matches_template(&self, concrete: &str) -> Option<i32> {
role_path_matches_template(&concrete, &self.role_path)
}
/// Check whether a concrete path matches the template AND the
/// extracted index is within the role's allowed range/set.
pub fn path_matches_with_range(&self, concrete: &str) -> bool {
// Fixed path (no %d) — direct string comparison
if !self.has_variable_path() {
return concrete == self.role_path;
}
let index = match self.path_matches_template(concrete) {
Some(i) => i,
None => return false,
};
if !self.path_allowed_indices.is_empty() {
// Set form: check if index is in the allowed set
self.path_allowed_indices.contains(&index)
} else if self.path_range_lo >= 0 {
// Range form: check lo..hi
index >= self.path_range_lo && index <= self.path_range_hi
} else {
// Wildcard with no range restriction
true
}
}
}
// ── Role Table ───────────────────────────────────────────────────────────────
/// The role table.
#[derive(Debug, Default)]
pub struct RoleTable {
pub entries: Vec<RoleEntry>,
}
impl RoleTable {
pub fn new() -> Self {
RoleTable::default()
}
/// Add a role entry. Returns error if table full or name duplicate.
pub fn add(&mut self, entry: RoleEntry) -> Result<(), NsignerError> {
if self.entries.len() >= ROLE_TABLE_MAX_ENTRIES {
return Err(NsignerError::Internal("role table full".into()));
}
if self.find_by_name(&entry.name).is_some() {
return Err(NsignerError::Internal("duplicate role name".into()));
}
self.entries.push(entry);
Ok(())
}
/// Find a role by name.
pub fn find_by_name(&self, name: &str) -> Option<&RoleEntry> {
self.entries.iter().find(|e| e.name == name)
}
/// Find a role by name (mutable).
pub fn find_by_name_mut(&mut self, name: &str) -> Option<&mut RoleEntry> {
self.entries.iter_mut().find(|e| e.name == name)
}
/// Find a role by nostr_index.
pub fn find_by_nostr_index(&self, index: i32) -> Option<&RoleEntry> {
self.entries
.iter()
.find(|e| e.selector_type == RoleSelectorType::NostrIndex && e.nostr_index == index)
}
/// Get the default role (named "main").
pub fn get_default(&self) -> Option<&RoleEntry> {
self.find_by_name("main")
}
/// Number of entries.
pub fn count(&self) -> usize {
self.entries.len()
}
/// Register a nostr-index role if missing.
pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), NsignerError> {
if self.find_by_nostr_index(nostr_index).is_some() {
return Ok(());
}
let mut entry = RoleEntry::default();
entry.name = if nostr_index == 0 {
"main".to_string()
} else {
format!("nostr_idx_{}", nostr_index)
};
entry.purpose = RolePurpose::Nostr;
entry.purpose_str = "nostr".to_string();
entry.curve = RoleCurve::Secp256k1;
entry.curve_str = "secp256k1".to_string();
entry.selector_type = RoleSelectorType::NostrIndex;
entry.nostr_index = nostr_index;
entry.role_path = format!("m/44'/1237'/{}'/0/0", nostr_index);
entry.requires_approval = false;
self.add(entry)
}
/// Register a RolePath role bound to an explicit derivation path template.
#[allow(clippy::too_many_arguments)]
pub fn register_role_path(
&mut self,
name: &str,
path: &str,
purpose: RolePurpose,
curve: RoleCurve,
range_lo: i32,
range_hi: i32,
default_index: i32,
allowed_indices: &[i32],
) -> Result<(), NsignerError> {
let mut entry = RoleEntry::default();
entry.name = name.to_string();
entry.purpose = purpose;
entry.purpose_str = purpose.as_str().to_string();
entry.curve = curve;
entry.curve_str = curve.as_str().to_string();
entry.selector_type = RoleSelectorType::RolePath;
entry.role_path = path.to_string();
entry.nostr_index = -1;
entry.path_range_lo = range_lo;
entry.path_range_hi = range_hi;
entry.path_default_index = default_index;
entry.path_allowed_indices = allowed_indices.to_vec();
entry.requires_approval = false;
entry.derived = false;
self.add(entry)
}
}
// ── Path Template Matching ───────────────────────────────────────────────────
/// Check whether a concrete derivation path matches a role's path template.
///
/// The template may contain a `%d` placeholder (with optional `'` hardened marker).
/// Returns `Some(index)` if matched, or `None`.
pub fn role_path_matches_template(concrete: &str, template: &str) -> Option<i32> {
let template_segs: Vec<&str> = template.split('/').collect();
let concrete_segs: Vec<&str> = concrete.split('/').collect();
if template_segs.len() != concrete_segs.len() {
return None;
}
let mut extracted_index: Option<i32> = None;
for (tseg, cseg) in template_segs.iter().zip(concrete_segs.iter()) {
if *tseg == "%d" || *tseg == "%d'" {
// Variable segment — extract the index
let (num_part, hardened) = if let Some(stripped) = cseg
.strip_suffix('\'')
.or_else(|| cseg.strip_suffix('h'))
.or_else(|| cseg.strip_suffix('H'))
{
(stripped, true)
} else {
(*cseg, false)
};
// Template hardened marker must match
let template_hardened = tseg.ends_with('\'');
if template_hardened != hardened {
return None;
}
let val: i32 = num_part.parse().ok()?;
if val < 0 {
return None;
}
if extracted_index.is_some() {
// Only one %d per template
return None;
}
extracted_index = Some(val);
} else {
// Literal segment — must match exactly
if *tseg != *cseg {
return None;
}
}
}
extracted_index
}
/// Extract the numeric index from a concrete path matching a `%d` template.
/// Returns the index, or -1 if no `%d` or no match.
pub fn role_path_extract_index(concrete: &str, template: &str) -> i32 {
role_path_matches_template(concrete, template).unwrap_or(-1)
}
// ── Path Template Parser ─────────────────────────────────────────────────────
/// Parse a path template token (e.g. "m/44'/1237'/0-3/1/0" or
/// "m/44'/1237'/1+34+54/1/0") into a template with `%d` placeholder
/// and allowed indices.
///
/// On success:
/// - `template_out` — the path with `%d` replacing the numeric/range/set segment
/// - `range_lo`/`range_hi` — min/max of the allowed indices
/// - `allowed_indices` — explicit set (if set form was used); empty for pure range/single
/// - Returns `Ok(())` on success, `Err` on parse error
#[allow(clippy::too_many_arguments)]
pub fn parse_path_template(
token: &str,
) -> Result<(String, i32, i32, Vec<i32>), NsignerError> {
let segs: Vec<&str> = token.split('/').collect();
if segs.is_empty() {
return Err(NsignerError::InvalidInput);
}
let mut template_out = String::new();
let mut range_lo: i32 = 0;
let mut range_hi: i32 = 0;
let mut allowed_indices: Vec<i32> = Vec::new();
let mut found_variable = false;
for (i, seg) in segs.iter().enumerate() {
if i == 0 && (*seg == "m" || *seg == "M") {
template_out.push_str(seg);
template_out.push('/');
continue;
}
if !found_variable {
// Check for wildcard *
if *seg == "*" || *seg == "*'" || *seg == "*h" || *seg == "*H" {
let hardened = seg.contains('\'') || seg.contains('h') || seg.contains('H');
found_variable = true;
range_lo = 0;
range_hi = i32::MAX;
template_out.push_str("%d");
if hardened {
template_out.push('\'');
}
template_out.push('/');
continue;
}
// Check for range/set markers
let has_plus = seg.contains('+');
let has_dash = seg.contains('-');
let is_range_or_set = has_plus || has_dash;
// Strip hardened marker for range/set forms
let (seg_clean, seg_hardened) = if is_range_or_set {
if let Some(stripped) = seg
.strip_suffix('\'')
.or_else(|| seg.strip_suffix('h'))
.or_else(|| seg.strip_suffix('H'))
{
(stripped, true)
} else {
(*seg, false)
}
} else {
(*seg, false)
};
if has_plus {
// Set form: "1+34+54" or "1+3-5+10"
let mut set = HashSet::new();
for tok in seg_clean.split('+') {
if let Some(dash) = tok.find('-') {
let lo: i32 = tok[..dash].parse().map_err(|_| NsignerError::InvalidInput)?;
let hi: i32 = tok[dash + 1..].parse().map_err(|_| NsignerError::InvalidInput)?;
if lo < 0 || hi < 0 || lo > hi {
return Err(NsignerError::InvalidInput);
}
for v in lo..=hi {
set.insert(v);
}
} else {
let val: i32 = tok.parse().map_err(|_| NsignerError::InvalidInput)?;
if val < 0 {
return Err(NsignerError::InvalidInput);
}
set.insert(val);
}
}
if set.is_empty() {
// Not a valid set — treat as literal
template_out.push_str(seg);
template_out.push('/');
} else {
found_variable = true;
allowed_indices = set.iter().copied().collect();
allowed_indices.sort();
range_lo = *allowed_indices.first().unwrap();
range_hi = *allowed_indices.last().unwrap();
template_out.push_str("%d");
if seg_hardened {
template_out.push('\'');
}
template_out.push('/');
}
} else if has_dash {
// Range form: "N-M"
let dash_pos = seg_clean.find('-').unwrap();
let lo: i32 = seg_clean[..dash_pos]
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
let hi: i32 = seg_clean[dash_pos + 1..]
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
if lo < 0 || hi < 0 || lo > hi {
// Not a valid numeric range — treat as literal
template_out.push_str(seg);
template_out.push('/');
} else {
found_variable = true;
range_lo = lo;
range_hi = hi;
template_out.push_str("%d");
if seg_hardened {
template_out.push('\'');
}
template_out.push('/');
}
} else {
// Single number or literal — always treat as a literal segment.
// The variable is only introduced via wildcard (*), range (N-M),
// or set (N+M) forms. A plain number like "0" is a fixed literal.
template_out.push_str(seg);
template_out.push('/');
}
} else {
// Literal segment after the variable
template_out.push_str(seg);
template_out.push('/');
}
}
// Remove trailing '/'
if template_out.ends_with('/') {
template_out.pop();
}
if !found_variable {
// Fixed path — no variable segment. Treat as a single fixed key.
range_lo = -1;
range_hi = -1;
}
Ok((template_out, range_lo, range_hi, allowed_indices))
}
/// Auto-detect purpose from a derivation path prefix.
/// m/44'/1237' → nostr, m/44'/102001' → ssh, etc.
pub fn purpose_from_path(path: &str) -> RolePurpose {
if path.starts_with("m/44'/1237'") {
RolePurpose::Nostr
} else if path.starts_with("m/44'/102001'") {
RolePurpose::Ssh
} else if path.starts_with("m/44'/102002'") {
RolePurpose::Age
} else if path.starts_with("m/44'/102003'") {
RolePurpose::PqSig
} else if path.starts_with("m/44'/102004'") {
RolePurpose::PqSig
} else if path.starts_with("m/44'/102005'") {
RolePurpose::PqKem
} else if path.starts_with("m/84'") || path.starts_with("m/86'") {
RolePurpose::Bitcoin
} else {
RolePurpose::Nostr // default
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_and_find() {
let mut table = RoleTable::new();
let mut entry = RoleEntry::default();
entry.name = "main".to_string();
entry.purpose = RolePurpose::Nostr;
entry.curve = RoleCurve::Secp256k1;
entry.selector_type = RoleSelectorType::RolePath;
entry.role_path = "m/44'/1237'/0'/0/0".to_string();
table.add(entry).unwrap();
assert!(table.find_by_name("main").is_some());
assert!(table.find_by_name("nonexistent").is_none());
}
#[test]
fn test_duplicate_rejected() {
let mut table = RoleTable::new();
let mut e1 = RoleEntry::default();
e1.name = "main".to_string();
table.add(e1).unwrap();
let mut e2 = RoleEntry::default();
e2.name = "main".to_string();
assert!(table.add(e2).is_err());
}
#[test]
fn test_path_template_matching() {
let template = "m/44'/1237'/%d'/0/0";
assert_eq!(role_path_matches_template("m/44'/1237'/5'/0/0", template), Some(5));
assert_eq!(role_path_matches_template("m/44'/1237'/0'/0/0", template), Some(0));
assert_eq!(role_path_matches_template("m/44'/1237'/5/1/0", template), None); // wrong segment
}
#[test]
fn test_parse_path_template_range() {
let (tmpl, lo, hi, allowed) = parse_path_template("m/44'/1237'/0-3/0/0").unwrap();
assert_eq!(tmpl, "m/44'/1237'/%d/0/0");
assert_eq!(lo, 0);
assert_eq!(hi, 3);
assert!(allowed.is_empty());
}
#[test]
fn test_parse_path_template_wildcard() {
let (tmpl, lo, hi, _) = parse_path_template("m/44'/1237'/*'/0/0").unwrap();
assert_eq!(tmpl, "m/44'/1237'/%d'/0/0");
assert_eq!(lo, 0);
assert_eq!(hi, i32::MAX);
}
#[test]
fn test_parse_path_template_set() {
let (tmpl, lo, hi, allowed) = parse_path_template("m/44'/1237'/1+34+54/0/0").unwrap();
assert_eq!(tmpl, "m/44'/1237'/%d/0/0");
assert_eq!(lo, 1);
assert_eq!(hi, 54);
assert_eq!(allowed.len(), 3);
assert!(allowed.contains(&1));
assert!(allowed.contains(&34));
assert!(allowed.contains(&54));
}
#[test]
fn test_parse_path_template_fixed() {
let (tmpl, lo, hi, _) = parse_path_template("m/44'/1237'/0'/0/0").unwrap();
assert_eq!(tmpl, "m/44'/1237'/0'/0/0");
assert_eq!(lo, -1); // fixed path
assert_eq!(hi, -1);
}
#[test]
fn test_purpose_from_path() {
assert_eq!(purpose_from_path("m/44'/1237'/0'/0/0"), RolePurpose::Nostr);
assert_eq!(purpose_from_path("m/44'/102001'/0'/0'/0'"), RolePurpose::Ssh);
assert_eq!(purpose_from_path("m/44'/102002'/0'/0'/0'"), RolePurpose::Age);
assert_eq!(purpose_from_path("m/44'/102003'/0'/0'/0'"), RolePurpose::PqSig);
assert_eq!(purpose_from_path("m/44'/102005'/0'/0'/0'"), RolePurpose::PqKem);
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Secure memory buffer — mlock'd, zeroized on free.
//!
//! Port of `secure_mem.c`. Sensitive buffers (mnemonic, private keys)
//! live in mlock'd RAM and are zeroized on drop.
use std::alloc::{alloc, dealloc, Layout};
use std::sync::atomic::{AtomicBool, Ordering};
use zeroize::Zeroize;
/// Global flag permitting unlocked operation (when mlock fails).
static ALLOW_UNLOCKED: AtomicBool = AtomicBool::new(false);
/// Set the global flag permitting unlocked operation.
///
/// Call at startup when running in containers or environments with
/// limited `RLIMIT_MEMLOCK`. When set, `mlock` failures produce a
/// warning but do not abort allocation.
pub fn allow_unlocked() {
ALLOW_UNLOCKED.store(true, Ordering::SeqCst);
}
/// Whether unlocked operation is currently permitted.
pub fn is_unlocked_allowed() -> bool {
ALLOW_UNLOCKED.load(Ordering::SeqCst)
}
/// Secure memory buffer — mlock'd, zeroized on drop.
///
/// Holds sensitive material (mnemonic phrases, private keys). The
/// memory is locked with `mlock(2)` to prevent swap-out, and
/// zeroized with `explicit_bzero` semantics on free.
pub struct SecureBuf {
ptr: *mut u8,
size: usize,
locked: bool,
}
unsafe impl Send for SecureBuf {}
unsafe impl Sync for SecureBuf {}
impl SecureBuf {
/// Allocate a secure buffer of `size` bytes.
///
/// Returns `MemoryFailed` if allocation or mlock fails (unless
/// `allow_unlocked()` was called).
pub fn alloc(size: usize) -> Result<Self, crate::NsignerError> {
if size == 0 {
return Err(crate::NsignerError::InvalidInput);
}
let layout = Layout::from_size_align(size, 1)
.map_err(|_| crate::NsignerError::MemoryFailed)?;
let ptr = unsafe { alloc(layout) };
if ptr.is_null() {
return Err(crate::NsignerError::MemoryFailed);
}
// Zero-initialize
unsafe { std::ptr::write_bytes(ptr, 0, size) };
// Attempt mlock
let locked = unsafe { libc::mlock(ptr as *const libc::c_void, size) } == 0;
if !locked && !is_unlocked_allowed() {
// mlock failed and unlocked mode not permitted — fail hard
unsafe { dealloc(ptr, layout) };
return Err(crate::NsignerError::MemoryFailed);
}
Ok(SecureBuf { ptr, size, locked })
}
/// Usable size in bytes.
pub fn size(&self) -> usize {
self.size
}
/// Whether mlock succeeded.
pub fn is_locked(&self) -> bool {
self.locked
}
/// Read access to the buffer contents.
pub fn as_slice(&self) -> &[u8] {
unsafe { std::slice::from_raw_parts(self.ptr, self.size) }
}
/// Write access to the buffer contents.
pub fn as_mut_slice(&mut self) -> &mut [u8] {
unsafe { std::slice::from_raw_parts_mut(self.ptr, self.size) }
}
/// Copy data into the buffer (truncates to buffer size).
pub fn copy_from(&mut self, src: &[u8]) {
let len = src.len().min(self.size);
self.as_mut_slice()[..len].copy_from_slice(&src[..len]);
}
/// Copy data into the buffer from a slice (alias for compatibility).
pub fn copy_from_slice(&mut self, src: &[u8]) {
self.copy_from(src);
}
/// Zeroize the buffer contents in place.
pub fn clear(&mut self) {
self.as_mut_slice().zeroize();
}
/// Resize the buffer to `new_size`, preserving the prefix that fits.
///
/// If `new_size` is 0, returns `InvalidInput`. If allocation of the
/// new buffer fails, the original buffer is left intact and an error
/// is returned.
pub fn resize(&mut self, new_size: usize) -> Result<(), crate::NsignerError> {
if new_size == 0 {
return Err(crate::NsignerError::InvalidInput);
}
if new_size == self.size {
return Ok(());
}
let mut new_buf = SecureBuf::alloc(new_size)?;
let copy_len = self.size.min(new_size);
new_buf.as_mut_slice()[..copy_len].copy_from_slice(&self.as_slice()[..copy_len]);
// Swap internals
let old_ptr = self.ptr;
let old_size = self.size;
let old_locked = self.locked;
self.ptr = new_buf.ptr;
self.size = new_buf.size;
self.locked = new_buf.locked;
// Prevent new_buf's Drop from running on the moved-out pointer
new_buf.ptr = std::ptr::null_mut();
new_buf.size = 0;
new_buf.locked = false;
// Free old buffer
if !old_ptr.is_null() && old_size > 0 {
unsafe { std::ptr::write_bytes(old_ptr, 0, old_size) };
if old_locked {
unsafe { libc::munlock(old_ptr as *const libc::c_void, old_size) };
}
let layout = Layout::from_size_align(old_size, 1).unwrap();
unsafe { dealloc(old_ptr, layout) };
}
Ok(())
}
}
impl Drop for SecureBuf {
fn drop(&mut self) {
if !self.ptr.is_null() && self.size > 0 {
// Zeroize
unsafe { std::ptr::write_bytes(self.ptr, 0, self.size) };
// munlock if locked
if self.locked {
unsafe { libc::munlock(self.ptr as *const libc::c_void, self.size) };
}
// Dealloc
let layout = Layout::from_size_align(self.size, 1).unwrap();
unsafe { dealloc(self.ptr, layout) };
}
self.ptr = std::ptr::null_mut();
self.size = 0;
}
}
/// Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away.
pub fn secure_memzero(buf: &mut [u8]) {
buf.zeroize();
}
/// Constant-time comparison of two byte slices.
///
/// Returns `true` if the slices are equal. The comparison runs in time
/// proportional to the shorter slice's length (callers should ensure
/// equal lengths for full constant-time properties).
pub fn secure_compare(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alloc_free() {
let buf = SecureBuf::alloc(32).unwrap();
assert_eq!(buf.size(), 32);
// Drop runs zeroize + munlock + dealloc
}
#[test]
fn test_copy_from() {
let mut buf = SecureBuf::alloc(8).unwrap();
let data = [1u8, 2, 3, 4, 5, 6, 7, 8];
buf.copy_from(&data);
assert_eq!(buf.as_slice(), &data[..]);
}
#[test]
fn test_copy_from_truncates() {
let mut buf = SecureBuf::alloc(4).unwrap();
let data = [1u8, 2, 3, 4, 5, 6, 7, 8];
buf.copy_from(&data);
assert_eq!(buf.as_slice(), &[1, 2, 3, 4]);
}
#[test]
fn test_zero_size_rejected() {
assert!(SecureBuf::alloc(0).is_err());
}
#[test]
fn test_clear_zeroizes() {
let mut buf = SecureBuf::alloc(16).unwrap();
buf.copy_from(&[0xFF; 16]);
buf.clear();
assert!(buf.as_slice().iter().all(|&b| b == 0));
}
#[test]
fn test_resize_grows_preserving_prefix() {
let mut buf = SecureBuf::alloc(4).unwrap();
buf.copy_from(&[10, 20, 30, 40]);
buf.resize(8).unwrap();
assert_eq!(buf.size(), 8);
assert_eq!(&buf.as_slice()[..4], &[10, 20, 30, 40]);
assert_eq!(&buf.as_slice()[4..], &[0, 0, 0, 0]);
}
#[test]
fn test_resize_shrinks_preserving_prefix() {
let mut buf = SecureBuf::alloc(8).unwrap();
buf.copy_from(&[10, 20, 30, 40, 50, 60, 70, 80]);
buf.resize(3).unwrap();
assert_eq!(buf.size(), 3);
assert_eq!(buf.as_slice(), &[10, 20, 30]);
}
#[test]
fn test_resize_zero_rejected() {
let mut buf = SecureBuf::alloc(4).unwrap();
assert!(buf.resize(0).is_err());
}
#[test]
fn test_secure_memzero() {
let mut data = [0xABu8; 32];
secure_memzero(&mut data);
assert!(data.iter().all(|&b| b == 0));
}
#[test]
fn test_secure_compare_equal() {
assert!(secure_compare(&[1, 2, 3], &[1, 2, 3]));
}
#[test]
fn test_secure_compare_unequal() {
assert!(!secure_compare(&[1, 2, 3], &[1, 2, 4]));
}
#[test]
fn test_secure_compare_different_lengths() {
assert!(!secure_compare(&[1, 2, 3], &[1, 2]));
}
#[test]
fn test_allow_unlocked_flag() {
let prev = is_unlocked_allowed();
allow_unlocked();
assert!(is_unlocked_allowed());
// Restore for other tests
ALLOW_UNLOCKED.store(prev, Ordering::SeqCst);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Selector resolution — matches a request's role selector against the role table.
//!
//! Port of `selector.c`.
use crate::role_table::RoleTable;
// ── Error Codes ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectorError {
Ok = 0,
Ambiguous = -1,
NotFound = -2,
NoDefault = -3,
PathMismatch = -4,
NostrIndexDeprecated = -5,
IndexDeprecated = -6,
RoleRequired = -7,
PathRequired = -8,
}
impl SelectorError {
pub fn as_str(&self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Ambiguous => "ambiguous_role_selector",
Self::NotFound => "unknown_role",
Self::NoDefault => "no_default_role",
Self::PathMismatch => "path_not_allowed",
Self::NostrIndexDeprecated => "nostr_index_deprecated",
Self::IndexDeprecated => "index_deprecated",
Self::RoleRequired => "role_required",
Self::PathRequired => "path_required",
}
}
}
// ── Selector Request ─────────────────────────────────────────────────────────
/// Parsed selector from a request's options object.
#[derive(Debug, Clone, Default)]
pub struct SelectorRequest {
pub has_role: bool,
pub role_name: String,
pub has_nostr_index: bool,
pub nostr_index: i32,
pub has_role_path: bool,
pub role_path: String,
pub has_index: bool,
pub index: i32,
}
impl SelectorRequest {
pub fn new() -> Self {
Self::default()
}
}
// ── Resolution ───────────────────────────────────────────────────────────────
/// Resolve a selector request against the role table.
///
/// On success returns `Ok(role_index)` — the index into the table.
/// On failure returns the appropriate `SelectorError`.
pub fn selector_resolve(
req: &SelectorRequest,
table: &RoleTable,
) -> Result<usize, SelectorError> {
// Both role and role_path are required together (combined selector).
// No resolution order and no default role.
if !req.has_role && !req.has_nostr_index && !req.has_role_path {
// No selector given — check for default "main" role
if table.get_default().is_some() {
// Find the index of "main"
return Ok(table
.entries
.iter()
.position(|e| e.name == "main")
.ok_or(SelectorError::NoDefault)?);
}
return Err(SelectorError::NoDefault);
}
if req.has_role {
if !req.has_role_path {
// role without role_path — check if it's a fixed-path role
let entry = table.find_by_name(&req.role_name).ok_or(SelectorError::NotFound)?;
if entry.has_variable_path() {
// Variable path role needs role_path
return Err(SelectorError::PathRequired);
}
// Fixed path role — OK, return index
return Ok(table
.entries
.iter()
.position(|e| e.name == req.role_name)
.ok_or(SelectorError::NotFound)?);
}
// role + role_path: verify path matches the role's template
let entry = table.find_by_name(&req.role_name).ok_or(SelectorError::NotFound)?;
if !entry.path_matches_with_range(&req.role_path) {
return Err(SelectorError::PathMismatch);
}
return Ok(table
.entries
.iter()
.position(|e| e.name == req.role_name)
.ok_or(SelectorError::NotFound)?);
}
if req.has_nostr_index {
// nostr_index is deprecated — must use role + role_path
return Err(SelectorError::NostrIndexDeprecated);
}
if req.has_role_path && !req.has_role {
return Err(SelectorError::RoleRequired);
}
Err(SelectorError::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::role_table::*;
fn make_test_table() -> RoleTable {
let mut table = RoleTable::new();
// main: fixed path
table.register_role_path(
"main",
"m/44'/1237'/0'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
-1, -1, -1, &[],
).unwrap();
// nostr_range: variable path with range 0-3
table.register_role_path(
"nostr_range",
"m/44'/1237'/%d'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
0, 3, -1, &[],
).unwrap();
table
}
#[test]
fn test_role_fixed_path() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "main".to_string();
req.has_role_path = true;
req.role_path = "m/44'/1237'/0'/0/0".to_string();
assert!(selector_resolve(&req, &table).is_ok());
}
#[test]
fn test_role_variable_path_in_range() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "nostr_range".to_string();
req.has_role_path = true;
req.role_path = "m/44'/1237'/2'/0/0".to_string();
assert!(selector_resolve(&req, &table).is_ok());
}
#[test]
fn test_role_variable_path_out_of_range() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "nostr_range".to_string();
req.has_role_path = true;
req.role_path = "m/44'/1237'/5'/0/0".to_string();
assert_eq!(selector_resolve(&req, &table), Err(SelectorError::PathMismatch));
}
#[test]
fn test_role_without_path() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "nostr_range".to_string();
// Variable path role without role_path → PathRequired
assert_eq!(selector_resolve(&req, &table), Err(SelectorError::PathRequired));
}
#[test]
fn test_path_without_role() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role_path = true;
req.role_path = "m/44'/1237'/0'/0/0".to_string();
assert_eq!(selector_resolve(&req, &table), Err(SelectorError::RoleRequired));
}
#[test]
fn test_unknown_role() {
let table = make_test_table();
let mut req = SelectorRequest::new();
req.has_role = true;
req.role_name = "nonexistent".to_string();
req.has_role_path = true;
req.role_path = "m/44'/1237'/0'/0/0".to_string();
assert_eq!(selector_resolve(&req, &table), Err(SelectorError::NotFound));
}
#[test]
fn test_no_selector_with_default() {
let table = make_test_table();
let req = SelectorRequest::new();
// No selector — should find "main" as default
assert!(selector_resolve(&req, &table).is_ok());
}
}
+582
View File
@@ -0,0 +1,582 @@
//! Server — multi-transport server with poll loop.
//!
//! Port of `server.c`. Supports Unix abstract socket, TCP, HTTP,
//! stdio, and qrexec transports. Uses poll(2) for non-blocking I/O.
//!
//! The server is the security boundary: it identifies the caller,
//! verifies auth envelopes, resolves the role selector, checks the
//! policy table, and prompts for approval before dispatching any
//! request to the dispatcher.
use crate::auth_envelope::AuthNonceCache;
use crate::dispatcher::DispatcherContext;
use crate::policy::{PolicyResult, PolicyTable};
use crate::selector::{selector_resolve, SelectorRequest};
use crate::NsignerError;
use std::net::TcpListener;
use std::os::unix::net::UnixListener;
pub const SERVER_SOCKET_NAME_MAX: usize = 108;
/// Listen mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListenMode {
Unix,
Stdio,
Qrexec,
Tcp,
Http,
}
/// Auth mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMode {
Off,
Optional,
Required,
}
/// Caller identity.
#[derive(Debug, Clone)]
pub struct CallerIdentity {
pub uid: u32,
pub gid: u32,
pub pid: u32,
pub kind: ListenMode,
pub caller_id: String, // "uid:<n>", "qubes:<vm>", "tcp:[ip]:port", "pubkey:<hex>"
pub source_qube: String,
pub auth_present: bool,
pub auth_pubkey_hex: String,
pub auth_label: String,
}
impl CallerIdentity {
fn new(kind: ListenMode) -> Self {
CallerIdentity {
uid: 0,
gid: 0,
pid: 0,
kind,
caller_id: String::new(),
source_qube: String::new(),
auth_present: false,
auth_pubkey_hex: String::new(),
auth_label: String::new(),
}
}
}
/// Server context.
pub struct ServerContext {
pub socket_name: String,
pub listen_mode: ListenMode,
pub auth_mode: AuthMode,
pub auth_skew_seconds: i32,
pub bridge_source_trusted: bool,
pub listener: Option<UnixListener>,
pub tcp_listener: Option<TcpListener>,
pub running: bool,
pub auth_cache: AuthNonceCache,
}
impl ServerContext {
pub fn new(socket_name: &str, listen_mode: ListenMode, auth_mode: AuthMode) -> Self {
ServerContext {
socket_name: socket_name.to_string(),
listen_mode,
auth_mode,
auth_skew_seconds: 300,
bridge_source_trusted: false,
listener: None,
tcp_listener: None,
running: false,
auth_cache: AuthNonceCache::new(),
}
}
/// Start listening. Returns error on bind failure.
pub fn start(&mut self) -> Result<(), NsignerError> {
match self.listen_mode {
ListenMode::Unix => {
// Abstract namespace: bind via libc (sun_path[0] = '\0')
let listener = bind_abstract_unix(&self.socket_name)?;
listener
.set_nonblocking(true)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
self.listener = Some(listener);
self.running = true;
Ok(())
}
ListenMode::Tcp | ListenMode::Http => {
// Parse "tcp:host:port" or "http:host:port"
let addr = self
.socket_name
.strip_prefix("tcp:")
.or_else(|| self.socket_name.strip_prefix("http:"))
.unwrap_or(&self.socket_name);
let listener = TcpListener::bind(addr)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
listener
.set_nonblocking(true)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
self.tcp_listener = Some(listener);
self.running = true;
Ok(())
}
ListenMode::Stdio | ListenMode::Qrexec => {
// One request over stdin/stdout
self.running = true;
Ok(())
}
}
}
/// Stop the server.
pub fn stop(&mut self) {
self.listener = None;
self.tcp_listener = None;
self.running = false;
}
/// Handle one pending connection (non-blocking).
/// Returns Ok(true) if handled, Ok(false) if nothing pending.
pub fn handle_one(
&mut self,
dispatcher: &mut DispatcherContext,
policy: &mut PolicyTable,
) -> Result<bool, NsignerError> {
if let Some(ref listener) = self.listener {
match listener.accept() {
Ok((stream, _)) => {
let mut reader = stream
.try_clone()
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
let mut writer = stream;
// Read framed request
let request = match crate::transport::recv_framed(&mut reader) {
Ok(r) => r,
Err(_) => return Ok(true),
};
// Identify caller via SO_PEERCRED
let caller = identify_unix_caller(&reader);
// Process with policy enforcement
let response = self.process_request(dispatcher, policy, &request, &caller);
// Send framed response
if let Err(_) = crate::transport::send_framed(&mut writer, &response) {
// Client disconnected — ignore
}
return Ok(true);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(false); // Nothing pending
}
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
}
}
if let Some(ref listener) = self.tcp_listener {
match listener.accept() {
Ok((stream, _)) => {
// Identify caller via peer address before moving stream
let caller = identify_tcp_caller(&stream);
let mut reader = stream
.try_clone()
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
let mut writer = stream;
let request = if self.listen_mode == ListenMode::Http {
match crate::http::recv_request(&mut reader) {
Ok(r) => r,
Err(_) => return Ok(true),
}
} else {
match crate::transport::recv_framed(&mut reader) {
Ok(r) => r,
Err(_) => return Ok(true),
}
};
// Process with policy enforcement
let response = self.process_request(dispatcher, policy, &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);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(false);
}
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
}
}
Ok(false)
}
/// Process a request through the full security pipeline:
/// auth envelope → selector resolution → policy check → approval → dispatch.
fn process_request(
&mut self,
dispatcher: &mut DispatcherContext,
policy: &mut PolicyTable,
request: &str,
caller: &CallerIdentity,
) -> String {
// ── Auth envelope verification ─────────────────────────────
let mut caller = caller.clone();
if self.auth_mode != AuthMode::Off {
match crate::auth_envelope::verify_request(
request,
&mut self.auth_cache,
self.auth_skew_seconds,
) {
Ok((pubkey, label)) => {
caller.auth_present = true;
caller.auth_pubkey_hex = pubkey.clone();
caller.auth_label = label;
caller.caller_id = format!("pubkey:{}", pubkey);
}
Err((code, msg)) => {
if self.auth_mode == AuthMode::Required {
return make_auth_error(&request, code, msg);
}
// Optional: continue without auth
}
}
}
// ── Extract method and selector ────────────────────────────
let (method, selector_req) = match extract_method_and_selector(request) {
Some(v) => v,
None => {
// Malformed request — let the dispatcher produce the error
return crate::dispatcher::handle_request(dispatcher, request);
}
};
// get_info is metadata — no key material, no policy check
if method == crate::enforcement::VERB_GET_INFO {
return crate::dispatcher::handle_request(dispatcher, request);
}
// Algorithm-based verbs (bypass role table) — check algorithm policy
if crate::enforcement::is_algorithm_verb(&method) {
return self.process_algorithm_verb(dispatcher, policy, request, &caller, &method, &selector_req);
}
// OTP verbs
if method == crate::enforcement::VERB_ENCRYPT || method == crate::enforcement::VERB_DECRYPT {
return crate::dispatcher::handle_request(dispatcher, request);
}
// ── Resolve role selector ──────────────────────────────────
let role_index = match selector_resolve(&selector_req, dispatcher.role_table) {
Ok(i) => i,
Err(e) => {
return make_selector_error(&request, e);
}
};
let role = &dispatcher.role_table.entries[role_index];
let role_name = role.name.clone();
let purpose = role.purpose_str.clone();
// ── Policy check ───────────────────────────────────────────
let (result, _source) = policy.check_with_role(
&caller.caller_id,
&method,
&role_name,
&purpose,
Some(role),
);
let decision = match result {
PolicyResult::Allow => PolicyResult::Allow,
PolicyResult::Deny => PolicyResult::Deny,
PolicyResult::Prompt => {
// Prompt for approval
let d = crate::tui::approval_prompt(&caller.caller_id, &method, &role_name, &purpose);
match d {
PolicyResult::AllowSessionVerb => {
let _ = policy.insert_session_grant(&caller.caller_id, &method, &role_name);
PolicyResult::Allow
}
PolicyResult::AllowSessionAll => {
let _ = policy.insert_session_grant_all(&caller.caller_id, &role_name);
PolicyResult::Allow
}
other => other,
}
}
_ => PolicyResult::Deny,
};
if decision != PolicyResult::Allow {
return make_policy_denied(&request);
}
// ── Dispatch ───────────────────────────────────────────────
crate::dispatcher::handle_request(dispatcher, request)
}
/// Process an algorithm-based verb with algorithm policy check.
fn process_algorithm_verb(
&mut self,
dispatcher: &mut DispatcherContext,
policy: &mut PolicyTable,
request: &str,
caller: &CallerIdentity,
method: &str,
selector_req: &SelectorRequest,
) -> String {
// Extract algorithm and index from the request options
let (algorithm, index) = extract_algorithm_and_index(request);
let (result, _source) = policy.check_algorithm(
&caller.caller_id,
method,
&algorithm,
index,
);
let decision = match result {
PolicyResult::Allow => PolicyResult::Allow,
PolicyResult::Deny => PolicyResult::Deny,
PolicyResult::Prompt => {
let d = crate::tui::approval_prompt(&caller.caller_id, method, &algorithm, "algorithm");
match d {
PolicyResult::AllowSessionVerb => {
let _ = policy.insert_session_grant(&caller.caller_id, method, &algorithm);
PolicyResult::Allow
}
PolicyResult::AllowSessionAll => {
let _ = policy.insert_session_grant_all(&caller.caller_id, &algorithm);
PolicyResult::Allow
}
other => other,
}
}
_ => PolicyResult::Deny,
};
if decision != PolicyResult::Allow {
return make_policy_denied(request);
}
let _ = selector_req;
crate::dispatcher::handle_request(dispatcher, request)
}
}
/// Bind a Unix socket in the abstract namespace via libc.
///
/// Rust's safe `UnixListener::bind` rejects paths containing null bytes,
/// so abstract sockets (sun_path[0] = '\0') must be bound via libc.
fn bind_abstract_unix(name: &str) -> Result<UnixListener, NsignerError> {
use std::os::unix::io::FromRawFd;
if name.len() >= 107 {
return Err(NsignerError::InvalidInput);
}
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
if fd < 0 {
return Err(NsignerError::IoFailed("socket() failed".into()));
}
// Build sockaddr_un with abstract namespace (sun_path[0] = '\0')
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
// sun_path[0] = '\0' (already zeroed), then copy name bytes
let name_bytes = name.as_bytes();
for (i, &b) in name_bytes.iter().enumerate() {
addr.sun_path[i + 1] = b as libc::c_char;
}
let addrlen = (std::mem::size_of::<libc::sa_family_t>() + 1 + name_bytes.len()) as libc::socklen_t;
let rc = unsafe {
libc::bind(
fd,
&addr as *const libc::sockaddr_un as *const libc::sockaddr,
addrlen,
)
};
if rc != 0 {
let err = std::io::Error::last_os_error();
unsafe { libc::close(fd) };
return Err(NsignerError::IoFailed(format!("bind: {}", err)));
}
let rc = unsafe { libc::listen(fd, 16) };
if rc != 0 {
let err = std::io::Error::last_os_error();
unsafe { libc::close(fd) };
return Err(NsignerError::IoFailed(format!("listen: {}", err)));
}
// Wrap the raw fd in a UnixListener
let listener = unsafe { UnixListener::from_raw_fd(fd) };
Ok(listener)
}
/// Identify the caller on a Unix socket via SO_PEERCRED.
fn identify_unix_caller(stream: &std::os::unix::net::UnixStream) -> CallerIdentity {
use std::os::unix::io::AsRawFd;
let mut caller = CallerIdentity::new(ListenMode::Unix);
let fd = stream.as_raw_fd();
let mut cred: libc::ucred = unsafe { std::mem::zeroed() };
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut cred as *mut _ as *mut libc::c_void,
&mut len,
)
};
if rc == 0 {
caller.uid = cred.uid;
caller.gid = cred.gid;
caller.pid = cred.pid as u32;
caller.caller_id = format!("uid:{}", cred.uid);
} else {
// Fallback: current uid
caller.uid = unsafe { libc::getuid() };
caller.caller_id = format!("uid:{}", caller.uid);
}
caller
}
/// Identify the caller on a TCP connection via peer address.
fn identify_tcp_caller(stream: &std::net::TcpStream) -> CallerIdentity {
let mut caller = CallerIdentity::new(ListenMode::Tcp);
match stream.peer_addr() {
Ok(addr) => {
caller.caller_id = format!("tcp:{}", addr);
}
Err(_) => {
caller.caller_id = "tcp:unknown".to_string();
}
}
caller
}
/// Extract method and selector from a JSON-RPC request.
fn extract_method_and_selector(request: &str) -> Option<(String, SelectorRequest)> {
let root: serde_json::Value = serde_json::from_str(request).ok()?;
let method = root.get("method")?.as_str()?.to_string();
let mut sel = SelectorRequest::new();
if let Some(params) = root.get("params").and_then(|v| v.as_array()) {
if let Some(options) = params.last().and_then(|v| v.as_object()) {
if let Some(role) = options.get("role").and_then(|v| v.as_str()) {
sel.has_role = true;
sel.role_name = role.to_string();
}
if let Some(path) = options.get("role_path").and_then(|v| v.as_str()) {
sel.has_role_path = true;
sel.role_path = path.to_string();
}
if let Some(idx) = options.get("index").and_then(|v| v.as_i64()) {
sel.has_index = true;
sel.index = idx as i32;
}
if let Some(nidx) = options.get("nostr_index").and_then(|v| v.as_i64()) {
sel.has_nostr_index = true;
sel.nostr_index = nidx as i32;
}
}
}
Some((method, sel))
}
/// Extract algorithm and index from a JSON-RPC request's options.
fn extract_algorithm_and_index(request: &str) -> (String, i32) {
let root: serde_json::Value = serde_json::from_str(request).unwrap_or(serde_json::Value::Null);
let mut algorithm = String::new();
let mut index = 0;
if let Some(params) = root.get("params").and_then(|v| v.as_array()) {
if let Some(options) = params.last().and_then(|v| v.as_object()) {
if let Some(alg) = options.get("algorithm").and_then(|v| v.as_str()) {
algorithm = alg.to_string();
}
if let Some(idx) = options.get("index").and_then(|v| v.as_i64()) {
index = idx as i32;
}
}
}
(algorithm, index)
}
/// Build an auth error response.
fn make_auth_error(request: &str, code: i32, message: &str) -> String {
let id = extract_id(request);
format!(
r#"{{"id":"{}","error":{{"code":{},"message":"{}"}}}}"#,
id, code, message
)
}
/// Build a selector error response.
fn make_selector_error(request: &str, err: crate::selector::SelectorError) -> String {
use crate::selector::SelectorError;
let id = extract_id(request);
let (code, message) = match err {
SelectorError::Ambiguous => (1001, "ambiguous_role_selector"),
SelectorError::NotFound => (1002, "unknown_role"),
SelectorError::NoDefault => (1003, "no_default_role"),
SelectorError::PathMismatch => (2003, "path_not_allowed"),
SelectorError::RoleRequired => (2006, "role_required"),
SelectorError::PathRequired => (2007, "path_required"),
_ => (1002, "unknown_role"),
};
format!(
r#"{{"id":"{}","error":{{"code":{},"message":"{}"}}}}"#,
id, code, message
)
}
/// Build a policy-denied response.
fn make_policy_denied(request: &str) -> String {
let id = extract_id(request);
format!(
r#"{{"id":"{}","error":{{"code":2001,"message":"policy_denied"}}}}"#,
id
)
}
/// Extract the request id (or "null").
fn extract_id(request: &str) -> String {
let root: serde_json::Value = serde_json::from_str(request).unwrap_or(serde_json::Value::Null);
root.get("id")
.map(|v| {
if let Some(s) = v.as_str() {
s.to_string()
} else {
v.to_string()
}
})
.unwrap_or_else(|| "null".to_string())
}
+71
View File
@@ -0,0 +1,71 @@
//! Socket naming — random abstract socket name generation.
//!
//! Port of `socket_name.c`. Generates random names in the format
//! `nsigner_<word1>_<word2>` using the BIP-39 English wordlist.
use rand::seq::SliceRandom;
use rand::thread_rng;
/// Generate a random socket name: `nsigner_<word1>_<word2>`.
///
/// Uses two random words from the BIP-39 English wordlist.
pub fn socket_name_random() -> Result<String, crate::NsignerError> {
let wordlist = nips::nip006::bip39_wordlist();
let mut rng = thread_rng();
let word1 = wordlist
.choose(&mut rng)
.ok_or(crate::NsignerError::CryptoFailed)?;
let word2 = wordlist
.choose(&mut rng)
.ok_or(crate::NsignerError::CryptoFailed)?;
Ok(format!("nsigner_{}_{}", word1, word2))
}
/// List running nsigner abstract sockets by reading /proc/net/unix.
pub fn list_sockets() -> Vec<String> {
let mut found = Vec::new();
if let Ok(content) = std::fs::read_to_string("/proc/net/unix") {
for line in content.lines() {
// Look for @nsigner prefix in the path column
if let Some(pos) = line.find("@nsigner") {
let rest = &line[pos + 1..]; // skip @
// Extract the name (up to whitespace or end of line)
let name: String = rest
.chars()
.take_while(|c| !c.is_whitespace())
.collect();
if name.starts_with("nsigner_") {
found.push(name);
}
}
}
}
found
}
/// Discover a single running nsigner socket.
/// Returns Ok(name) if exactly one is found, Err if zero or multiple.
pub fn discover_single_socket() -> Result<String, crate::NsignerError> {
let sockets = list_sockets();
if sockets.len() == 1 {
Ok(sockets[0].clone())
} else {
Err(crate::NsignerError::NotFound)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_socket_name_random() {
let name = socket_name_random().unwrap();
assert!(name.starts_with("nsigner_"));
assert!(name.len() > 10); // nsigner_ + two words
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Transport framing — length-prefixed JSON over sockets.
//!
//! Port of `transport_frame.c`. 4-byte big-endian length prefix + payload.
use std::io::{self, Read, Write};
use std::os::unix::net::UnixStream;
pub const MAX_MSG_SIZE: usize = 16 * 1024 * 1024; // 16 MB
/// Send a framed JSON message: 4-byte BE length + payload.
pub fn send_framed<W: Write>(writer: &mut W, payload: &str) -> io::Result<()> {
let len = payload.len() as u32;
writer.write_all(&len.to_be_bytes())?;
writer.write_all(payload.as_bytes())?;
writer.flush()?;
Ok(())
}
/// Receive a framed JSON message: read 4-byte BE length, then payload.
pub fn recv_framed<R: Read>(reader: &mut R) -> io::Result<String> {
let mut header = [0u8; 4];
reader.read_exact(&mut header)?;
let len = u32::from_be_bytes(header) as usize;
if len == 0 || len > MAX_MSG_SIZE {
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid frame length"));
}
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf)?;
String::from_utf8(buf).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid UTF-8"))
}
/// Connect to a Unix socket in the abstract namespace via libc.
///
/// Rust's safe `UnixStream::connect` rejects paths containing null bytes,
/// so abstract sockets (sun_path[0] = '\0') must be connected via libc.
pub fn connect_abstract_unix(name: &str) -> io::Result<UnixStream> {
use std::os::unix::io::FromRawFd;
if name.len() >= 107 {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "socket name too long"));
}
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
let name_bytes = name.as_bytes();
for (i, &b) in name_bytes.iter().enumerate() {
addr.sun_path[i + 1] = b as libc::c_char;
}
let addrlen = (std::mem::size_of::<libc::sa_family_t>() + 1 + name_bytes.len()) as libc::socklen_t;
let rc = unsafe {
libc::connect(
fd,
&addr as *const libc::sockaddr_un as *const libc::sockaddr,
addrlen,
)
};
if rc != 0 {
let err = io::Error::last_os_error();
unsafe { libc::close(fd) };
return Err(err);
}
let stream = unsafe { UnixStream::from_raw_fd(fd) };
Ok(stream)
}
/// Send a framed message over a Unix socket.
pub fn send_framed_unix(stream: &UnixStream, payload: &str) -> io::Result<()> {
let mut writer = stream;
send_framed(&mut writer, payload)
}
/// Receive a framed message from a Unix socket.
pub fn recv_framed_unix(stream: &UnixStream) -> io::Result<String> {
let mut reader = stream;
recv_framed(&mut reader)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_framing_roundtrip() {
let mut buf = Vec::new();
let msg = r#"{"id":"1","method":"get_info","params":[]}"#;
send_framed(&mut buf, msg).unwrap();
let mut cursor = Cursor::new(buf);
let received = recv_framed(&mut cursor).unwrap();
assert_eq!(received, msg);
}
}
+788
View File
@@ -0,0 +1,788 @@
//! Terminal UI — interactive status display, role wizard, approval prompts.
//!
//! App-level TUI code that builds on the [`tui_continuous`] primitives.
//! Uses `tui_continuous` for all terminal rendering (top frame, tables,
//! menus, formatted print with hotkey markup).
use crate::policy::PolicyResult;
use crate::role_table::RoleTable;
use crate::tui_continuous::{
self, TuiColumn, TuiFrame, TuiMenu, TuiMenuItem, TuiTable,
};
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
// ────────────────────────────────────────────────────────────────────────────
// Activity log
// ────────────────────────────────────────────────────────────────────────────
/// Maximum number of activity log entries kept (matches C ACTIVITY_LOG_CAP).
pub const ACTIVITY_LOG_CAP: usize = 16;
/// Activity log — a ring buffer of timestamped messages.
pub struct ActivityLog {
lines: [String; ACTIVITY_LOG_CAP],
count: usize,
}
impl ActivityLog {
/// Create an empty activity log.
pub fn new() -> Self {
Self {
lines: std::array::from_fn(|_| String::new()),
count: 0,
}
}
/// Add a message to the log with a timestamp.
pub fn add(&mut self, message: &str) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ts = format_timestamp(now);
let idx = self.count % ACTIVITY_LOG_CAP;
self.lines[idx] = format!("{} {}", ts, message);
self.count += 1;
}
/// Return entries newest-first (up to ACTIVITY_LOG_CAP).
pub fn entries(&self) -> Vec<&str> {
if self.count == 0 {
return Vec::new();
}
let total = self.count.min(ACTIVITY_LOG_CAP);
let mut result = Vec::with_capacity(total);
for i in (0..total).rev() {
let idx = (self.count - 1 - i) % ACTIVITY_LOG_CAP;
result.push(self.lines[idx].as_str());
}
result
}
/// Clear the log.
pub fn clear(&mut self) {
for line in &mut self.lines {
line.clear();
}
self.count = 0;
}
}
impl Default for ActivityLog {
fn default() -> Self {
Self::new()
}
}
/// Format a Unix timestamp as `YYYY-MM-DD HH:MM:SS` (local time).
fn format_timestamp(epoch: u64) -> String {
let t = epoch as libc::time_t;
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
unsafe {
libc::localtime_r(&t, &mut tm);
}
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
tm.tm_year + 1900,
tm.tm_mon + 1,
tm.tm_mday,
tm.tm_hour,
tm.tm_min,
tm.tm_sec
)
}
// ────────────────────────────────────────────────────────────────────────────
// Global flags
// ────────────────────────────────────────────────────────────────────────────
/// Global flag: when set, approval prompts are auto-allowed (--allow-all).
static PROMPT_ALWAYS_ALLOW: AtomicBool = AtomicBool::new(false);
/// Set whether approval prompts should always be allowed (--allow-all).
pub fn set_prompt_always_allow(allow: bool) {
PROMPT_ALWAYS_ALLOW.store(allow, Ordering::SeqCst);
}
/// Whether approval prompts are currently auto-allowed.
pub fn prompt_always_allow() -> bool {
PROMPT_ALWAYS_ALLOW.load(Ordering::SeqCst)
}
// ────────────────────────────────────────────────────────────────────────────
// Key handling
// ────────────────────────────────────────────────────────────────────────────
/// Keys handled by the TUI main loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiKey {
/// 'l' — lock/reunlock
Lock,
/// 'r' — refresh
Refresh,
/// 'd' — show connection details
Connections,
/// 'q' or 'x' — quit
Quit,
/// Any other key (ignored)
Other,
/// No key pressed within the poll timeout
None,
}
/// Poll for a single keypress with a timeout (non-blocking).
///
/// Returns [`TuiKey::None`] if no key was pressed within `timeout_ms`.
/// Must be called while raw mode is enabled.
pub fn poll_key(timeout_ms: u64) -> TuiKey {
use crossterm::event::{poll, read, Event, KeyCode, KeyEvent};
if !poll(std::time::Duration::from_millis(timeout_ms)).unwrap_or(false) {
return TuiKey::None;
}
// Check for resize
if tui_continuous::resize_pending() {
return TuiKey::Other; // Will be handled by resize check in main loop
}
match read() {
Ok(Event::Key(KeyEvent {
code: KeyCode::Char('d' | 'D'),
..
})) => TuiKey::Connections,
Ok(Event::Key(KeyEvent {
code: KeyCode::Char('q' | 'Q' | 'x' | 'X'),
..
}))
| Ok(Event::Key(KeyEvent {
code: KeyCode::Esc,
..
})) => TuiKey::Quit,
Ok(Event::Key(KeyEvent {
code: KeyCode::Char('r' | 'R'),
..
})) => TuiKey::Refresh,
Ok(Event::Key(KeyEvent {
code: KeyCode::Char('l' | 'L'),
..
})) => TuiKey::Lock,
Ok(Event::Key(_)) => TuiKey::Other,
_ => TuiKey::Other,
}
}
/// Read a line of input in raw mode, handling Enter and Backspace.
///
/// Works when raw mode is enabled (where `stdin().read_line()` is broken
/// because Enter produces `\r` and there is no line editing).
pub fn read_line_raw() -> std::io::Result<String> {
use crossterm::event::{read, Event, KeyCode, KeyEvent, KeyModifiers};
let mut line = String::new();
let mut stdout = std::io::stdout();
loop {
match read() {
Ok(Event::Key(KeyEvent {
code: KeyCode::Enter,
..
})) => {
let _ = write!(stdout, "\r\n");
let _ = stdout.flush();
return Ok(line);
}
Ok(Event::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
..
})) => {
let _ = write!(stdout, "^C\r\n");
let _ = stdout.flush();
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"interrupted",
));
}
Ok(Event::Key(KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
..
})) => {
let _ = write!(stdout, "^D\r\n");
let _ = stdout.flush();
return Ok(line);
}
Ok(Event::Key(KeyEvent {
code: KeyCode::Char(c),
..
})) => {
// Treat \r and \n as Enter (handles piped input through PTY)
if c == '\r' || c == '\n' {
let _ = write!(stdout, "\r\n");
let _ = stdout.flush();
return Ok(line);
}
line.push(c);
let _ = write!(stdout, "{}", c);
let _ = stdout.flush();
}
Ok(Event::Key(KeyEvent {
code: KeyCode::Backspace,
..
})) => {
if line.pop().is_some() {
let _ = write!(stdout, "\x08 \x08");
let _ = stdout.flush();
}
}
_ => {}
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// Terminal init / cleanup
// ────────────────────────────────────────────────────────────────────────────
/// Initialize the terminal for TUI mode (raw mode + clear).
pub fn init() -> std::io::Result<()> {
tui_continuous::init();
Ok(())
}
/// Restore the terminal to normal mode.
pub fn cleanup() -> std::io::Result<()> {
tui_continuous::cleanup();
Ok(())
}
// ────────────────────────────────────────────────────────────────────────────
// Main menu items (matches C g_main_menu_items exactly)
// ────────────────────────────────────────────────────────────────────────────
/// The main menu items, matching the C `g_main_menu_items` exactly.
pub static MAIN_MENU_ITEMS: [TuiMenuItem; 4] = [
TuiMenuItem {
label: "^_l^: lock/reunlock",
shortcut: 'l',
},
TuiMenuItem {
label: "^_r^: refresh",
shortcut: 'r',
},
TuiMenuItem {
label: "^_d^: display connections",
shortcut: 'd',
},
TuiMenuItem {
label: "^_q^:/x quit",
shortcut: 'q',
},
];
/// Build the main menu reference.
pub fn main_menu() -> TuiMenu<'static> {
TuiMenu {
items: &MAIN_MENU_ITEMS,
}
}
// ────────────────────────────────────────────────────────────────────────────
// Screen rendering (matches C render_status / render_connections exactly)
// ────────────────────────────────────────────────────────────────────────────
/// The application frame for the main status screen.
fn main_frame() -> TuiFrame {
TuiFrame {
app_name: "nsigner",
app_version: crate::VERSION,
breadcrumb: "> Main Menu",
}
}
/// The application frame for the connections screen.
fn connections_frame() -> TuiFrame {
TuiFrame {
app_name: "nsigner",
app_version: crate::VERSION,
breadcrumb: "> Connection Instructions",
}
}
/// The application frame for the approval screen.
fn approval_frame() -> TuiFrame {
TuiFrame {
app_name: "nsigner",
app_version: crate::VERSION,
breadcrumb: "> Approval",
}
}
/// Render the status screen (roles table, activity log, status line, menu).
///
/// Matches the C `render_status()` layout exactly:
/// 1. Clear continuous + top frame
/// 2. "Roles" heading + table
/// 3. "Activity (latest first)" heading + log entries
/// 4. Status line (session/words/signer/derived)
/// 5. Menu items
/// 6. Anchor prompt
pub fn render_status(
role_table: &RoleTable,
mnemonic: &crate::mnemonic::MnemonicState,
derived_count: usize,
socket_name: &str,
activity_log: &ActivityLog,
) {
let size = tui_continuous::terminal_size();
let frame = main_frame();
let menu = main_menu();
let left_col: u16 = 0; // C uses left_col = 0 for render_status
tui_continuous::clear_continuous(size.height);
tui_continuous::render_top_frame(&frame, size.width);
// Roles heading + table
tui_continuous::print("^*Roles^:");
if role_table.count() == 0 {
tui_continuous::print("(none)");
} else {
let columns = [
TuiColumn { name: "Role", width: 20, right_align: false },
TuiColumn { name: "Purpose", width: 12, right_align: false },
TuiColumn { name: "Curve", width: 12, right_align: false },
TuiColumn { name: "Derivation path", width: 24, right_align: false },
];
let get_cell = |row: usize, col: usize| -> String {
let entry = &role_table.entries[row];
match col {
0 => entry.name.clone(),
1 => entry.purpose_str.to_string(),
2 => entry.curve_str.to_string(),
3 => entry.role_path.clone(),
_ => String::new(),
}
};
let table = TuiTable {
columns: &columns,
row_count: role_table.count(),
get_cell: &get_cell,
is_default: None,
prefix_len: None,
};
tui_continuous::render_table(&table);
}
// Activity log
tui_continuous::print("");
tui_continuous::print("^*Activity (latest first)^:");
let entries = activity_log.entries();
if entries.is_empty() {
tui_continuous::print("(none)");
} else {
for line in entries {
tui_continuous::print(line);
}
}
// Status line
tui_continuous::print("");
let session = if mnemonic.is_loaded() { "unlocked" } else { "locked" };
let word_count = mnemonic.word_count();
let status = format!(
"session={} ({} words) signer={} derived={}",
session, word_count, socket_name, derived_count
);
tui_continuous::print(&status);
// Menu
tui_continuous::print("");
tui_continuous::render_menu(&menu, left_col);
// Anchor prompt at bottom
tui_continuous::anchor_prompt(0, left_col);
let _ = std::io::stdout().flush();
}
/// Render the connection instructions display (press 'd').
///
/// Matches the C `render_connections()` layout: full screen clear,
/// top frame, then transport blocks with title/connection/example/extra.
pub fn render_connections(
role_table: &RoleTable,
mnemonic: &crate::mnemonic::MnemonicState,
derived_count: usize,
socket_name: &str,
) {
let size = tui_continuous::terminal_size();
let frame = connections_frame();
// Full screen clear (not clear_continuous) — connections may exceed terminal height
tui_continuous::clear_full_screen();
tui_continuous::render_top_frame(&frame, size.width);
tui_continuous::print("");
// Unix socket
tui_continuous::print("^*Unix socket^:");
tui_continuous::print("");
tui_continuous::print(&format!(" @{}", socket_name));
tui_continuous::print("");
tui_continuous::print(" Example:");
tui_continuous::print(&format!(
" nsigner client --socket {} '{{\"id\":\"1\",\"method\":\"get_info\",\"params\":[]}}'",
socket_name
));
tui_continuous::print("");
// HTTP (if applicable)
tui_continuous::print("^*HTTP^:");
tui_continuous::print("");
tui_continuous::print(" curl -X POST http://127.0.0.1:8080/ \\");
tui_continuous::print(" -H 'Content-Type: application/json' \\");
tui_continuous::print(" -d '{\"id\":\"1\",\"method\":\"get_info\",\"params\":[]}'");
tui_continuous::print("");
// Example: get_public_key
if let Some(main) = role_table.get_default() {
tui_continuous::print("^*Example — get public key for 'main' role^:");
tui_continuous::print("");
tui_continuous::print(&format!(
" nsigner client --socket {} '{{\"id\":\"1\",\"method\":\"nostr_get_public_key\",\"params\":[],\"options\":{{\"role\":\"{}\",\"role_path\":\"{}\"}}}}'",
socket_name, main.name, main.role_path
));
tui_continuous::print("");
}
// Status line
let session = if mnemonic.is_loaded() { "unlocked" } else { "locked" };
let status = format!(
"session={} ({} words) signer={} derived={}",
session,
mnemonic.word_count(),
socket_name,
derived_count
);
tui_continuous::print(&status);
tui_continuous::print("");
tui_continuous::print("Press any key to return");
tui_continuous::anchor_prompt(0, 0);
let _ = std::io::stdout().flush();
}
/// Interactive approval prompt.
///
/// Returns the policy decision:
/// - "y" → Allow once
/// - "n" → Deny
/// - "e" → Allow this caller+role+verb for session
/// - "a" → Allow this caller+role for session (all verbs)
///
/// Matches the C `tui_approval_cb()` layout exactly.
pub fn approval_prompt(
caller_id: &str,
method: &str,
role_name: &str,
purpose: &str,
) -> PolicyResult {
// --allow-all: auto-approve without prompting
if prompt_always_allow() {
return PolicyResult::Allow;
}
let frame = approval_frame();
tui_continuous::render_content_screen(&frame, Some("Approval required"));
tui_continuous::print(&format!("caller: {}", caller_id));
tui_continuous::print(&format!("method: {}", method));
tui_continuous::print(&format!("role: {}", role_name));
tui_continuous::print(&format!("purpose: {}", purpose));
tui_continuous::print("");
tui_continuous::print("^_y^: allow once");
tui_continuous::print("^_n^: deny");
tui_continuous::print("^_e^: allow this caller+role+verb for session");
tui_continuous::print("^_a^: allow this caller+role for session (all verbs)");
let mut stdout = std::io::stdout();
let _ = write!(stdout, "> ");
let _ = stdout.flush();
let input = match read_line_raw() {
Ok(s) => s,
Err(_) => return PolicyResult::Deny,
};
match input.trim().to_lowercase().as_str() {
"a" => PolicyResult::AllowSessionAll,
"e" => PolicyResult::AllowSessionVerb,
"y" => PolicyResult::Allow,
_ => PolicyResult::Deny,
}
}
// ────────────────────────────────────────────────────────────────────────────
// Role wizard (cooked mode — uses normal read_line)
// ────────────────────────────────────────────────────────────────────────────
/// Interactive role wizard — loop to add multiple roles, matching the C
/// `prompt_named_path_roles` which uses `for(;;)` to let you add roles
/// one at a time until you select "Done".
pub fn role_wizard(
role_table: &mut crate::role_table::RoleTable,
) -> Result<(), crate::NsignerError> {
use crate::role_table::*;
let frame = crate::tui_continuous::TuiFrame {
app_name: "nsigner",
app_version: crate::VERSION,
breadcrumb: "> Role Configuration",
};
// First iteration: if no roles yet, register default "main" automatically
if role_table.count() == 0 {
register_default(role_table)?;
}
loop {
crate::tui_continuous::render_content_screen(
&frame,
Some("Define a role — bind a role name to a derivation path template"),
);
// Show currently configured roles
if role_table.count() > 0 {
crate::tui_continuous::print("^*Current roles^:");
for entry in &role_table.entries {
crate::tui_continuous::print(&format!(
" {} — {} ({})",
entry.name, entry.role_path, entry.curve_str
));
}
crate::tui_continuous::print("");
}
crate::tui_continuous::print("Add a role:");
crate::tui_continuous::print(" [1] Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0");
crate::tui_continuous::print(" [2] Nostr range: secp256k1, m/44'/1237'/*'/0/0");
crate::tui_continuous::print(" [3] Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'");
crate::tui_continuous::print(" [4] SSH role: ed25519, m/44'/102001'/0'/0'/0'");
crate::tui_continuous::print(" [5] Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'");
crate::tui_continuous::print(" [6] ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'");
crate::tui_continuous::print(" [7] SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'");
crate::tui_continuous::print(" [8] ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'");
crate::tui_continuous::print(" [9] Custom path");
crate::tui_continuous::print(" [0] Done — finish role configuration");
crate::tui_continuous::print("");
print!(" Select: ");
let _ = std::io::stdout().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
break;
}
let choice = input.trim();
// Done / empty → finish
if choice == "0" || choice.is_empty() {
break;
}
// Preset definitions: (default_name, default_path, curve_str, purpose)
let preset: Option<(&str, &str, &str, RolePurpose)> = match choice {
"1" => Some(("main", "m/44'/1237'/0'/0/0", "secp256k1", RolePurpose::Nostr)),
"2" => Some(("nostr_range", "m/44'/1237'/*'/0/0", "secp256k1", RolePurpose::Nostr)),
"3" => Some(("nostr_agent", "m/44'/1237'/*'/1'/0'", "secp256k1", RolePurpose::Nostr)),
"4" => Some(("ssh", "m/44'/102001'/0'/0'/0'", "ed25519", RolePurpose::Ssh)),
"5" => Some(("age", "m/44'/102002'/0'/0'/0'", "x25519", RolePurpose::Age)),
"6" => Some(("ml_dsa_65", "m/44'/102003'/0'/0'/0'", "ml-dsa-65", RolePurpose::PqSig)),
"7" => Some(("slh_dsa_128s", "m/44'/102004'/0'/0'/0'", "slh-dsa-128s", RolePurpose::PqSig)),
"8" => Some(("ml_kem_768", "m/44'/102005'/0'/0'/0'", "ml-kem-768", RolePurpose::PqKem)),
"9" => None, // Custom
_ => {
crate::tui_continuous::print("Invalid selection, try again.");
continue;
}
};
if let Some((default_name, default_path, curve_str, purpose)) = preset {
// Prompt for role name with default
let mut name = String::new();
print!(" Role name [{}]: ", default_name);
let _ = std::io::stdout().flush();
if std::io::stdin().read_line(&mut name).is_err() {
break;
}
let name = name.trim().to_string();
let name = if name.is_empty() { default_name.to_string() } else { name };
// Check for duplicate
if role_table.find_by_name(&name).is_some() {
crate::tui_continuous::print(&format!(" Role '{}' already exists, skipping.", name));
continue;
}
// Resolve curve
let curve = RoleCurve::from_str(curve_str);
if curve == RoleCurve::Unknown {
crate::tui_continuous::print(" Invalid curve, skipping.");
continue;
}
// Parse path template
let (template, range_lo, range_hi, allowed_indices) =
crate::role_table::parse_path_template(default_path)
.map_err(|_| crate::NsignerError::InvalidInput)?;
role_table
.register_role_path(
&name,
&template,
purpose,
curve,
range_lo,
range_hi,
-1,
&allowed_indices,
)
.map_err(|e| crate::NsignerError::Internal(e.to_string()))?;
crate::tui_continuous::print(&format!(" Added role '{}'.", name));
} else {
// Custom role entry
match custom_role_entry(role_table) {
Ok(()) => {
crate::tui_continuous::print(" Custom role added.");
}
Err(e) => {
crate::tui_continuous::print(&format!(" Error: {}, try again.", e));
}
}
}
}
// Final summary
crate::tui_continuous::print("");
crate::tui_continuous::print(&format!("Configured {} role(s):", role_table.count()));
for entry in &role_table.entries {
crate::tui_continuous::print(&format!(
" {} — {} ({})",
entry.name, entry.role_path, entry.curve_str
));
}
Ok(())
}
/// Register the default "main" Nostr role.
fn register_default(role_table: &mut crate::role_table::RoleTable) -> Result<(), crate::NsignerError> {
use crate::role_table::*;
role_table.register_role_path(
"main",
"m/44'/1237'/0'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
-1, -1, -1, &[],
)?;
Ok(())
}
/// Custom role entry — prompt user for name, path, purpose, curve.
fn custom_role_entry(
role_table: &mut crate::role_table::RoleTable,
) -> Result<(), crate::NsignerError> {
use crate::role_table::*;
print!(" Role name: ");
let _ = std::io::stdout().flush();
let mut name = String::new();
std::io::stdin().read_line(&mut name).map_err(|e| crate::NsignerError::IoFailed(e.to_string()))?;
let name = name.trim().to_string();
if name.is_empty() {
return Err(crate::NsignerError::InvalidInput);
}
print!(" Derivation path (e.g. m/44'/1237'/0'/0/0): ");
let _ = std::io::stdout().flush();
let mut path = String::new();
std::io::stdin().read_line(&mut path).map_err(|e| crate::NsignerError::IoFailed(e.to_string()))?;
let path = path.trim().to_string();
if path.is_empty() {
return Err(crate::NsignerError::InvalidInput);
}
let purpose = purpose_from_path(&path);
let curve = match purpose {
RolePurpose::Nostr | RolePurpose::Bitcoin => RoleCurve::Secp256k1,
RolePurpose::Ssh => RoleCurve::Ed25519,
RolePurpose::Age => RoleCurve::X25519,
RolePurpose::PqSig => RoleCurve::MlDsa65,
RolePurpose::PqKem => RoleCurve::MlKem768,
_ => RoleCurve::Secp256k1,
};
role_table.register_role_path(
&name,
&path,
purpose,
curve,
-1, -1, -1, &[],
)?;
Ok(())
}
/// Interactive transport selection menu.
///
/// Returns a bitmask of selected transports.
pub fn transport_selection() -> u8 {
let frame = crate::tui_continuous::TuiFrame {
app_name: "nsigner",
app_version: crate::VERSION,
breadcrumb: "> Transport Selection",
};
crate::tui_continuous::render_content_screen(&frame, Some("Transport selection"));
crate::tui_continuous::print(" [1] Unix socket (default)");
crate::tui_continuous::print(" [2] TCP");
crate::tui_continuous::print(" [3] HTTP");
crate::tui_continuous::print(" [4] Unix + HTTP");
crate::tui_continuous::print("");
print!(" Select transport: ");
let _ = std::io::stdout().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return 0x01; // Unix
}
match input.trim() {
"2" => 0x04, // TCP
"3" => 0x08, // HTTP
"4" => 0x09, // Unix + HTTP
_ => 0x01, // Unix (default)
}
}
/// Prompt for mnemonic phrase input (interactive mode).
pub fn prompt_mnemonic() -> Result<String, crate::NsignerError> {
let frame = crate::tui_continuous::TuiFrame {
app_name: "nsigner",
app_version: crate::VERSION,
breadcrumb: "> Unlock",
};
crate::tui_continuous::render_content_screen(&frame, Some("Enter mnemonic phrase"));
crate::tui_continuous::print("");
print!(" > ");
let _ = std::io::stdout().flush();
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| crate::NsignerError::IoFailed(e.to_string()))?;
Ok(input.trim().to_string())
}
+915
View File
@@ -0,0 +1,915 @@
//! # tui_continuous — Terminal UI primitives
//!
//! Rust port of the vendored C library `tui_continuous` (v0.0.9).
//! Provides terminal UI primitives: formatted print with hotkey markup,
//! full-screen rendering, tables, menus, and single-key input.
//!
//! This module is intentionally self-contained and separable from the
//! rest of the project — it can be spun out into its own crate.
//!
//! ## Hotkey markup
//!
//! The [`print()`] function parses markup sequences in the input string:
//!
//! | Sequence | Effect | ANSI code |
//! |----------|---------------------|-------------|
//! | `^_` | Underline on | `\x1b[4m` |
//! | `^*` | Bold on | `\x1b[1m` |
//! | `^:` | Reset all formatting| `\x1b[0m` |
//! | `^^` | Literal `^` | `^` |
//!
//! ## Raw mode
//!
//! [`init()`] enables crossterm raw mode, which disables output post-processing
//! (OPOST). This means `\n` no longer produces `\r\n`. All output functions in
//! this module use `\r\n` explicitly for line endings.
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
// ────────────────────────────────────────────────────────────────────────────
// Constants
// ────────────────────────────────────────────────────────────────────────────
/// Library version (matches C TUI_CONTINUOUS_VERSION).
pub const VERSION: &str = "0.0.9";
// ────────────────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────────────────
/// Terminal dimensions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TuiSize {
pub width: u16,
pub height: u16,
}
/// A single menu item with a display label and keyboard shortcut.
///
/// The label may contain hotkey markup (see module docs).
/// `shortcut` is the lowercase character that selects this item, or `'\0'` if none.
#[derive(Debug, Clone, Copy)]
pub struct TuiMenuItem {
pub label: &'static str,
pub shortcut: char,
}
/// Application frame metadata — shown in the top banner.
#[derive(Debug, Clone, Copy)]
pub struct TuiFrame {
pub app_name: &'static str,
pub app_version: &'static str,
pub breadcrumb: &'static str,
}
/// A menu is a slice of menu items.
#[derive(Debug, Clone, Copy)]
pub struct TuiMenu<'a> {
pub items: &'a [TuiMenuItem],
}
/// Status line text (empty/None → no status row rendered).
#[derive(Debug, Clone, Copy)]
pub struct TuiStatus<'a> {
pub text: Option<&'a str>,
}
/// Table column definition.
#[derive(Debug, Clone, Copy)]
pub struct TuiColumn {
pub name: &'static str,
/// Fixed width in chars; 0 = auto (defaults to 12).
pub width: u16,
/// true = right-align, false = left-align.
pub right_align: bool,
}
/// Table definition with a cell-providing closure.
///
/// `get_cell(row, col)` returns the cell text.
/// `is_default(row)` optionally marks a row with `*`.
/// `prefix_len(row)` optionally underlines the first N chars of cell[row][0].
pub struct TuiTable<'a> {
pub columns: &'a [TuiColumn],
pub row_count: usize,
pub get_cell: &'a dyn Fn(usize, usize) -> String,
pub is_default: Option<&'a dyn Fn(usize) -> bool>,
pub prefix_len: Option<&'a dyn Fn(usize) -> usize>,
}
/// Result of [`get_key()`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiKey {
/// A character key was pressed.
Char(char),
/// Enter key.
Enter,
/// Escape key.
Esc,
/// Backspace key.
Backspace,
/// stdin was closed (EOF).
Eof,
/// SIGWINCH fired (terminal resized).
Resize,
/// Any other key event.
Other,
}
// ────────────────────────────────────────────────────────────────────────────
// SIGWINCH handling
// ────────────────────────────────────────────────────────────────────────────
/// Global flag set by the SIGWINCH signal handler.
static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
/// Whether raw mode is currently active.
static RAW_MODE_ACTIVE: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_sigwinch(_signum: i32) {
RESIZE_PENDING.store(true, Ordering::SeqCst);
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 1: Terminal info, raw mode, single-key input
// ────────────────────────────────────────────────────────────────────────────
/// Query terminal size. Falls back to 80×24 if unavailable.
pub fn terminal_size() -> TuiSize {
match crossterm::terminal::size() {
Ok((w, h)) if w > 0 && h > 0 => TuiSize { width: w, height: h },
_ => TuiSize { width: 80, height: 24 },
}
}
/// Install a SIGWINCH handler that sets the resize-pending flag.
///
/// Call once at startup. Uses `libc::sigaction` with `SA_RESTART`.
pub fn install_resize_handler() {
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = handle_sigwinch as *const () as usize;
sa.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut sa.sa_mask);
libc::sigaction(libc::SIGWINCH, &sa, std::ptr::null_mut());
}
}
/// Check and clear the resize-pending flag. Returns `true` if a resize occurred.
pub fn resize_pending() -> bool {
RESIZE_PENDING.swap(false, Ordering::SeqCst)
}
/// Enter raw input mode (cbreak, no echo). Safe to call multiple times.
pub fn init() {
if RAW_MODE_ACTIVE.load(Ordering::SeqCst) {
return;
}
if crossterm::terminal::enable_raw_mode().is_ok() {
RAW_MODE_ACTIVE.store(true, Ordering::SeqCst);
}
}
/// Restore original terminal settings. Must be called before exit.
pub fn cleanup() {
if !RAW_MODE_ACTIVE.load(Ordering::SeqCst) {
return;
}
let _ = crossterm::terminal::disable_raw_mode();
RAW_MODE_ACTIVE.store(false, Ordering::SeqCst);
}
/// Check if raw mode is currently active.
pub fn is_raw_mode() -> bool {
RAW_MODE_ACTIVE.load(Ordering::SeqCst)
}
/// Wait for and return a single key press.
///
/// Returns [`TuiKey::Resize`] if SIGWINCH fired, [`TuiKey::Eof`] on stdin close.
/// Does NOT require Enter. Requires [`init()`] to have been called.
pub fn get_key() -> TuiKey {
// Check for pending resize first
if resize_pending() {
return TuiKey::Resize;
}
use crossterm::event::{read, Event, KeyCode, KeyEvent};
match read() {
Ok(Event::Key(KeyEvent { code: KeyCode::Char(c), .. })) => {
// Map Enter-like chars
if c == '\r' || c == '\n' {
TuiKey::Enter
} else if c == '\x1b' {
TuiKey::Esc
} else {
TuiKey::Char(c)
}
}
Ok(Event::Key(KeyEvent { code: KeyCode::Enter, .. })) => TuiKey::Enter,
Ok(Event::Key(KeyEvent { code: KeyCode::Esc, .. })) => TuiKey::Esc,
Ok(Event::Key(KeyEvent { code: KeyCode::Backspace, .. })) => TuiKey::Backspace,
Ok(Event::Resize(_, _)) => TuiKey::Resize,
Ok(_) => TuiKey::Other,
Err(_) => TuiKey::Eof,
}
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 2: Formatted print and screen rendering
// ────────────────────────────────────────────────────────────────────────────
/// Write text with hotkey markup expansion, then a `\r\n` line ending.
///
/// Parses `^_` (underline on), `^*` (bold on), `^:` (reset), `^^` (literal `^`).
///
/// # Example
/// ```no_run
/// nsigner::tui_continuous::print("^_A^:dd relay");
/// // Prints "Add relay" with 'A' underlined, followed by \r\n
/// ```
pub fn print(text: &str) {
let mut stdout = io::stdout();
let _ = write_markup(&mut stdout, text);
// In raw mode (OPOST disabled), \n alone doesn't return to column 0.
// In cooked mode, \n is translated to \r\n by the terminal driver,
// so adding \r would produce \r\r\n (double CR). Use \r\n only in raw mode.
if is_raw_mode() {
let _ = write!(stdout, "\r\n");
} else {
let _ = write!(stdout, "\n");
}
let _ = stdout.flush();
}
/// Write text with hotkey markup expansion (no trailing newline).
fn write_markup<W: Write>(w: &mut W, text: &str) -> io::Result<()> {
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '^' {
if let Some(&next) = chars.peek() {
match next {
'_' => {
write!(w, "\x1b[4m")?;
chars.next();
continue;
}
'*' => {
write!(w, "\x1b[1m")?;
chars.next();
continue;
}
':' => {
write!(w, "\x1b[0m")?;
chars.next();
continue;
}
'^' => {
write!(w, "^")?;
chars.next();
continue;
}
_ => {}
}
}
}
write!(w, "{}", c)?;
}
Ok(())
}
/// Newline sequence appropriate for the current terminal mode.
fn newline() -> &'static str {
if is_raw_mode() { "\r\n" } else { "\n" }
}
/// Print `count` blank lines.
fn print_blank_lines(count: usize) {
if count == 0 {
return;
}
let mut stdout = io::stdout();
let nl = newline();
for _ in 0..count {
let _ = write!(stdout, "{}", nl);
}
let _ = stdout.flush();
}
/// Print a line of `ch` repeated `width` times, then a newline.
fn print_repeat_char(ch: char, width: usize) {
let mut stdout = io::stdout();
for _ in 0..width {
let _ = write!(stdout, "{}", ch);
}
let _ = write!(stdout, "{}", newline());
let _ = stdout.flush();
}
/// Print `text` centered within `width` columns, then a newline.
fn print_centered_line(text: &str, width: usize) {
let mut stdout = io::stdout();
let nl = newline();
if width == 0 {
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
return;
}
let len = text.chars().count();
if len >= width {
// Truncate to width
let truncated: String = text.chars().take(width).collect();
let _ = write!(stdout, "{}{}", truncated, nl);
let _ = stdout.flush();
return;
}
let left = (width - len) / 2;
let right = width - len - left;
for _ in 0..left {
let _ = write!(stdout, " ");
}
let _ = write!(stdout, "{}", text);
for _ in 0..right {
let _ = write!(stdout, " ");
}
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
}
/// Clear the continuous scrollback region.
///
/// Prints `\r`, then `term_height` blank lines, then moves cursor up
/// `term_height` lines and returns to column 0. This creates a clean
/// region for re-rendering without full screen clear.
pub fn clear_continuous(term_height: u16) {
let h = if term_height < 1 { 1 } else { term_height as usize };
let mut stdout = io::stdout();
let nl = newline();
let _ = write!(stdout, "\r");
for _ in 0..h {
let _ = write!(stdout, "{}", nl);
}
let _ = write!(stdout, "\x1b[{}A\r", h);
let _ = stdout.flush();
}
/// Full screen clear (for modal views that may exceed terminal height).
pub fn clear_full_screen() {
let mut stdout = io::stdout();
let _ = write!(stdout, "\x1b[2J\x1b[H");
let _ = stdout.flush();
}
/// Render the top frame: `====` header, centered title, `====`, breadcrumb, blank.
pub fn render_top_frame(frame: &TuiFrame, term_width: u16) {
let w = term_width as usize;
let title = format!("{} {}", frame.app_name, frame.app_version);
print_repeat_char('=', w);
print_centered_line(&title, w);
print_repeat_char('=', w);
let mut stdout = io::stdout();
let nl = newline();
let _ = write!(stdout, "{}{}", frame.breadcrumb, nl);
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
print_blank_lines(1);
}
/// Compute the left column for a centered menu based on the frame title.
pub fn menu_left_col(frame: &TuiFrame, term_width: u16) -> u16 {
let title_len = frame.app_name.chars().count()
+ 1
+ frame.app_version.chars().count();
let start = (term_width as usize).saturating_sub(title_len) / 2;
start as u16
}
/// Render each menu item via [`print()`], indented by `left_col` spaces.
pub fn render_menu(menu: &TuiMenu, left_col: u16) {
if menu.items.is_empty() {
return;
}
let indent = " ".repeat(left_col as usize);
for item in menu.items {
let mut stdout = io::stdout();
let _ = write!(stdout, "{}", indent);
let _ = stdout.flush();
print(item.label);
}
}
/// Render the status line (text + blank line) if non-empty.
pub fn render_status_line(status: &TuiStatus) {
match status.text {
Some(t) if !t.is_empty() => {
print(t);
print_blank_lines(1);
}
_ => {}
}
}
/// Position the cursor after filler lines and left-column padding.
///
/// Prints `filler_lines` blank lines, moves cursor back up, then prints
/// `left_col` spaces. This anchors the prompt at the bottom of the screen.
pub fn anchor_prompt(filler_lines: u16, left_col: u16) {
let mut stdout = io::stdout();
let nl = newline();
if filler_lines > 0 {
for _ in 0..filler_lines {
let _ = write!(stdout, "{}", nl);
}
let _ = write!(stdout, "\x1b[{}A\r", filler_lines as usize);
}
for _ in 0..left_col {
let _ = write!(stdout, " ");
}
let _ = stdout.flush();
}
/// Render a content screen: clear + top frame + optional bold title.
pub fn render_content_screen(frame: &TuiFrame, title: Option<&str>) {
let size = terminal_size();
clear_continuous(size.height);
render_top_frame(frame, size.width);
if let Some(t) = title {
if !t.is_empty() {
print(&format!("^*{}^:", t));
}
}
let _ = io::stdout().flush();
}
/// Full screen layout: clear + top frame + gap + menu + gap + status + anchor.
pub fn render_screen(frame: &TuiFrame, menu: Option<&TuiMenu>, status: Option<&TuiStatus>) {
let size = terminal_size();
let top_frame_lines = 6usize; // ===, title, ===, breadcrumb, blank, blank
let gap_header_to_menu = 1usize;
let gap_after_menu = 1usize;
let body_lines = menu.map(|m| m.items.len()).unwrap_or(0);
let status_lines = match status {
Some(s) => match s.text {
Some(t) if !t.is_empty() => 2,
_ => 0,
},
None => 0,
};
let base_lines_before_prompt =
top_frame_lines + gap_header_to_menu + body_lines + gap_after_menu + status_lines;
let filler_lines = (size.height as usize).saturating_sub(1).saturating_sub(base_lines_before_prompt);
let left_col = menu_left_col(frame, size.width);
clear_continuous(size.height);
render_top_frame(frame, size.width);
print_blank_lines(gap_header_to_menu);
if let Some(m) = menu {
render_menu(m, left_col);
}
print_blank_lines(gap_after_menu);
if let Some(s) = status {
render_status_line(s);
}
anchor_prompt(filler_lines as u16, left_col);
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 3: Table rendering
// ────────────────────────────────────────────────────────────────────────────
/// Effective column width (0 → default 12).
fn col_width(col: &TuiColumn) -> usize {
if col.width > 0 {
col.width as usize
} else {
12
}
}
/// Render a table with header, separator dashes, and aligned rows.
///
/// Respects terminal width; if too narrow, falls back to compact multi-line rows.
pub fn render_table(table: &TuiTable) {
if table.columns.is_empty() || table.row_count == 0 {
return;
}
let size = terminal_size();
let term_width = size.width as usize;
// Calculate total fixed width needed
let total_fixed: usize = table.columns.iter().map(|c| col_width(c) + 1).sum();
let compact = term_width < total_fixed;
let mut stdout = io::stdout();
let nl = newline();
if !compact {
// Print header
for col in table.columns {
let w = col_width(col);
if col.right_align {
let _ = write!(stdout, "{:>width$} ", col.name, width = w);
} else {
let _ = write!(stdout, "{:<width$} ", col.name, width = w);
}
}
let _ = write!(stdout, "{}", nl);
// Print dashes
for col in table.columns {
let w = col_width(col);
for _ in 0..w {
let _ = write!(stdout, "-");
}
let _ = write!(stdout, " ");
}
let _ = write!(stdout, "{}", nl);
}
let _ = stdout.flush();
// Print rows
for r in 0..table.row_count {
let is_def = table.is_default.map(|f| f(r)).unwrap_or(false);
let plen = table.prefix_len.map(|f| f(r)).unwrap_or(0);
if compact {
// Compact: first column on line 1, rest on line 2 indented
let cell = (table.get_cell)(r, 0);
let mut stdout = io::stdout();
let nl = newline();
if plen > 0 {
let plen = plen.min(cell.chars().count());
let prefix: String = cell.chars().take(plen).collect();
let rest: String = cell.chars().skip(plen).collect();
let _ = write!(stdout, "\x1b[4m{}\x1b[0m{}", prefix, rest);
} else {
let _ = write!(stdout, "{}", cell);
}
if is_def {
let _ = write!(stdout, " *");
}
let _ = write!(stdout, "{} ", nl);
for c in 1..table.columns.len() {
let cell = (table.get_cell)(r, c);
let _ = write!(stdout, "{} ", cell);
}
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
} else {
// Normal: all columns on one line
let mut stdout = io::stdout();
let nl = newline();
for (c, col) in table.columns.iter().enumerate() {
let w = col_width(col);
let cell = (table.get_cell)(r, c);
if c == 0 && plen > 0 {
// Underline the prefix portion
let cell_len = cell.chars().count();
let display_len = cell_len.min(w);
let ul = plen.min(display_len);
let underlined: String = cell.chars().take(ul).collect();
let remaining: String = cell
.chars()
.skip(ul)
.take(display_len - ul)
.collect();
let _ = write!(stdout, "\x1b[4m{}\x1b[0m{}", underlined, remaining);
// Pad to width
let pad = w.saturating_sub(display_len);
for _ in 0..pad {
let _ = write!(stdout, " ");
}
if is_def {
let _ = write!(stdout, "* ");
} else {
let _ = write!(stdout, " ");
}
} else {
if col.right_align {
let _ = write!(stdout, "{:>width$} ", cell, width = w);
} else {
let _ = write!(stdout, "{:<width$} ", cell, width = w);
}
}
}
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
}
}
}
/// Compute minimal unique prefix lengths for an array of string IDs.
///
/// Returns a vector where `out[i]` is the length of the shortest prefix of
/// `ids[i]` that is unique among all ids.
pub fn compute_unique_prefixes(ids: &[&str]) -> Vec<usize> {
let count = ids.len();
let mut result = Vec::with_capacity(count);
for i in 0..count {
let max_len = ids[i].chars().count();
let mut len = 1;
while len <= max_len {
let mut unique = true;
for j in 0..count {
if i != j {
let prefix_i: String = ids[i].chars().take(len).collect();
let prefix_j: String = ids[j].chars().take(len).collect();
if prefix_i == prefix_j {
unique = false;
break;
}
}
}
if unique {
break;
}
len += 1;
}
result.push(len);
}
result
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 4: Input helpers
// ────────────────────────────────────────────────────────────────────────────
/// Read a line from stdin (cooked mode), strip newline, lowercase.
///
/// Returns `true` on success, `false` on EOF/error.
/// Requires line-mode input (do not call while raw mode is active).
pub fn read_line(buf: &mut String) -> bool {
use std::io::BufRead;
buf.clear();
let stdin = io::stdin();
if stdin.lock().read_line(buf).is_err() {
return false;
}
// Strip trailing newline/CR
while buf.ends_with('\n') || buf.ends_with('\r') {
buf.pop();
}
// Lowercase
*buf = buf.to_lowercase();
true
}
/// Check if input is an escape/quit command: `q`, `x`, `exit`, `quit`, `esc`.
pub fn is_escape_input(input: &str) -> bool {
matches!(
input,
"x" | "q" | "exit" | "quit" | "esc"
)
}
/// Match a single-character input to a menu item's shortcut.
///
/// Returns `Some(index)` if the input matches a menu item's shortcut,
/// `None` otherwise. Input must be exactly one character.
pub fn menu_match_key(menu: &TuiMenu, input: &str) -> Option<usize> {
if input.len() != 1 {
return None;
}
let c = input.chars().next()?;
for (i, item) in menu.items.iter().enumerate() {
if item.shortcut != '\0' && item.shortcut == c {
return Some(i);
}
}
None
}
/// Display a `[y/n]` prompt and wait for response.
///
/// Works in both raw mode (single key) and line mode.
/// Returns `true` for yes, `false` for no.
pub fn confirm(prompt: &str) -> bool {
let mut stdout = io::stdout();
if is_raw_mode() {
let _ = write!(stdout, "{} [y/n] ", prompt);
let _ = stdout.flush();
let key = get_key();
let _ = write!(stdout, "{}", newline());
let _ = stdout.flush();
matches!(key, TuiKey::Char('y' | 'Y'))
} else {
let _ = write!(stdout, "{} [y/n]: ", prompt);
let _ = stdout.flush();
let mut buf = String::new();
if !read_line(&mut buf) {
return false;
}
buf.starts_with('y') || buf.starts_with('Y')
}
}
/// Prompt with a pre-filled default value.
///
/// User can press Enter to accept the default, or type a new value.
/// Temporarily exits raw mode if needed. Returns `Ok(())` on success,
/// `Err` on EOF.
pub fn prompt_default(prompt: &str, default: &str, out: &mut String) -> io::Result<()> {
let was_raw = is_raw_mode();
if was_raw {
cleanup();
}
let mut stdout = io::stdout();
let _ = write!(stdout, "{} [{}]: ", prompt, default);
let _ = stdout.flush();
out.clear();
use std::io::BufRead;
let stdin = io::stdin();
let n = stdin.lock().read_line(out)?;
if n == 0 {
if was_raw {
init();
}
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "EOF"));
}
// Strip newline
while out.ends_with('\n') || out.ends_with('\r') {
out.pop();
}
// If empty, use default
if out.is_empty() {
*out = default.to_string();
}
if was_raw {
init();
}
Ok(())
}
/// Print a message (or "Press Enter to continue...") and wait for any key.
pub fn press_enter(message: Option<&str>) {
let mut stdout = io::stdout();
let msg = message.unwrap_or("Press Enter to continue...");
let _ = write!(stdout, "{}", msg);
let _ = stdout.flush();
if is_raw_mode() {
let _ = get_key();
} else {
use std::io::BufRead;
let mut buf = String::new();
let _ = io::stdin().lock().read_line(&mut buf);
}
let _ = write!(stdout, "{}", newline());
let _ = stdout.flush();
}
/// Returns `true` if stdin is a pipe/redirect (not a terminal).
pub fn has_stdin_pipe() -> bool {
unsafe { libc::isatty(libc::STDIN_FILENO) == 0 }
}
// ────────────────────────────────────────────────────────────────────────────
// Tests
// ────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_size_fallback() {
// Should always return something positive
let size = terminal_size();
assert!(size.width > 0);
assert!(size.height > 0);
}
#[test]
fn test_compute_unique_prefixes_basic() {
let ids = ["abc", "abd", "xyz"];
let prefixes = compute_unique_prefixes(&ids);
// "abc" vs "abd": differ at position 3, so prefix=3
// "xyz": unique at position 1
assert_eq!(prefixes, vec![3, 3, 1]);
}
#[test]
fn test_compute_unique_prefixes_identical() {
let ids = ["abc", "abc"];
let prefixes = compute_unique_prefixes(&ids);
// Identical strings → no unique prefix, len exceeds max_len (3+1=4)
assert_eq!(prefixes, vec![4, 4]);
}
#[test]
fn test_compute_unique_prefixes_single() {
let ids = ["hello"];
let prefixes = compute_unique_prefixes(&ids);
assert_eq!(prefixes, vec![1]);
}
#[test]
fn test_compute_unique_prefixes_empty() {
let ids: &[&str] = &[];
let prefixes = compute_unique_prefixes(&ids);
assert!(prefixes.is_empty());
}
#[test]
fn test_is_escape_input() {
assert!(is_escape_input("q"));
assert!(is_escape_input("x"));
assert!(is_escape_input("exit"));
assert!(is_escape_input("quit"));
assert!(is_escape_input("esc"));
assert!(!is_escape_input("y"));
assert!(!is_escape_input(""));
assert!(!is_escape_input("hello"));
}
#[test]
fn test_menu_match_key() {
let items = [
TuiMenuItem { label: "^_l^: lock", shortcut: 'l' },
TuiMenuItem { label: "^_r^: refresh", shortcut: 'r' },
TuiMenuItem { label: "^_q^: quit", shortcut: 'q' },
];
let menu = TuiMenu { items: &items };
assert_eq!(menu_match_key(&menu, "l"), Some(0));
assert_eq!(menu_match_key(&menu, "r"), Some(1));
assert_eq!(menu_match_key(&menu, "q"), Some(2));
assert_eq!(menu_match_key(&menu, "x"), None);
assert_eq!(menu_match_key(&menu, ""), None);
assert_eq!(menu_match_key(&menu, "ab"), None);
}
#[test]
fn test_menu_match_key_no_shortcut() {
let items = [
TuiMenuItem { label: "item1", shortcut: '\0' },
TuiMenuItem { label: "item2", shortcut: 'b' },
];
let menu = TuiMenu { items: &items };
assert_eq!(menu_match_key(&menu, "a"), None);
assert_eq!(menu_match_key(&menu, "b"), Some(1));
}
#[test]
fn test_menu_left_col() {
let frame = TuiFrame {
app_name: "n_signer",
app_version: "v0.1.0",
breadcrumb: "> Main",
};
// title_len = 9 + 1 + 6 = 16
// left_col = (80 - 16) / 2 = 32
assert_eq!(menu_left_col(&frame, 80), 32);
assert_eq!(menu_left_col(&frame, 10), 0); // saturating
}
#[test]
fn test_col_width() {
let col = TuiColumn { name: "test", width: 20, right_align: false };
assert_eq!(col_width(&col), 20);
let col_auto = TuiColumn { name: "test", width: 0, right_align: false };
assert_eq!(col_width(&col_auto), 12);
}
#[test]
fn test_tuikey_equality() {
assert_eq!(TuiKey::Char('a'), TuiKey::Char('a'));
assert_ne!(TuiKey::Char('a'), TuiKey::Char('b'));
assert_eq!(TuiKey::Resize, TuiKey::Resize);
assert_eq!(TuiKey::Eof, TuiKey::Eof);
}
#[test]
fn test_resize_pending_initially_false() {
// Should be false initially (or whatever state was left by prior tests)
// Just verify it returns a bool without panic
let _ = resize_pending();
}
#[test]
fn test_has_stdin_pipe() {
// In test environment, stdin might or might not be a tty.
// Just verify it doesn't panic.
let _ = has_stdin_pipe();
}
}
+341
View File
@@ -0,0 +1,341 @@
//! Integration tests — end-to-end server + client over Unix socket.
//!
//! These tests verify the full security pipeline:
//! caller identification → policy check → approval → dispatch.
use nsigner::{
alg_cache::AlgorithmKeyCache,
dispatcher::DispatcherContext,
key_store::KeyStore,
mnemonic::MnemonicState,
policy::{parse_preapprove_spec, PolicyTable},
role_table::{RoleCurve, RolePurpose, RoleTable},
server::{AuthMode, ListenMode, ServerContext},
};
use std::os::unix::net::UnixListener;
use std::time::Duration;
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
/// Set up a server context with a test mnemonic and role table.
fn setup_server(socket_name: &str) -> (
ServerContext,
RoleTable,
MnemonicState,
KeyStore,
AlgorithmKeyCache,
) {
let mut mnemonic = MnemonicState::new();
mnemonic.load(TEST_MNEMONIC).unwrap();
let mut role_table = RoleTable::new();
role_table
.register_role_path(
"main",
"m/44'/1237'/0'/0/0",
RolePurpose::Nostr,
RoleCurve::Secp256k1,
-1, -1, -1, &[],
)
.unwrap();
let mut key_store = KeyStore::new();
key_store.derive_all(&mut role_table, &mnemonic).unwrap();
let mut server = ServerContext::new(socket_name, ListenMode::Unix, AuthMode::Off);
server.start().unwrap();
(server, role_table, mnemonic, key_store, AlgorithmKeyCache::new())
}
/// Run the server poll loop in a background thread.
///
/// Returns a handle and a stop flag. Set the stop flag to `true` to
/// terminate the loop (the thread will exit on the next iteration).
fn spawn_server_loop(
mut server: ServerContext,
mut role_table: RoleTable,
mnemonic: MnemonicState,
mut key_store: KeyStore,
mut alg_cache: AlgorithmKeyCache,
mut policy: PolicyTable,
) -> (std::thread::JoinHandle<()>, std::sync::Arc<std::sync::atomic::AtomicBool>) {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = stop.clone();
let handle = std::thread::spawn(move || {
while server.running && !stop_clone.load(std::sync::atomic::Ordering::SeqCst) {
let mut dispatcher = DispatcherContext {
role_table: &mut role_table,
mnemonic: &mnemonic,
key_store: &mut key_store,
alg_key_cache: &mut alg_cache,
};
match server.handle_one(&mut dispatcher, &mut policy) {
Ok(true) => {}
Ok(false) => {
std::thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
}
}
});
(handle, stop)
}
/// Send a framed request to a Unix socket and return the response.
fn send_request(socket_name: &str, request: &str) -> String {
let mut stream = nsigner::transport::connect_abstract_unix(socket_name).unwrap();
nsigner::transport::send_framed(&mut stream, request).unwrap();
nsigner::transport::recv_framed(&mut stream).unwrap()
}
/// Wait for the server socket to be ready.
fn wait_for_server(socket_name: &str, attempts: u32) {
for _ in 0..attempts {
if nsigner::transport::connect_abstract_unix(socket_name).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("server socket {} not ready", socket_name);
}
#[test]
fn test_get_info_no_policy_needed() {
let socket_name = format!("nsigner_test_info_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let mut policy = PolicyTable::new();
policy.init_default(unsafe { libc::getuid() });
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
wait_for_server(&socket_name, 100);
// get_info is metadata — should work without policy
let resp = send_request(&socket_name, r#"{"id":"1","method":"get_info","params":[]}"#);
assert!(resp.contains("\"result\""), "get_info failed: {}", resp);
// Cleanup: connect to unblock, then stop
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_role_as_password_allows_without_approval() {
let socket_name = format!("nsigner_test_deny_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
// Policy: catch-all deny (no same-uid prompt entry)
let mut policy = PolicyTable::new();
let mut catch_all = nsigner::policy::PolicyEntry::default();
catch_all.caller = "*".to_string();
catch_all.prompt = nsigner::policy::PromptMode::Deny;
policy.add(catch_all).unwrap();
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
wait_for_server(&socket_name, 100);
// The default "main" role has requires_approval=false (role-as-password),
// so knowing the role name is sufficient — no policy check, no prompt.
let resp = send_request(
&socket_name,
r#"{"id":"2","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
);
assert!(resp.contains("\"result\""), "role-as-password should allow, got: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_nostr_get_public_key_allowed_with_preapprove() {
let socket_name = format!("nsigner_test_allow_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let mut policy = PolicyTable::new();
policy.init_default(unsafe { libc::getuid() });
// Preapprove the caller for nostr_get_public_key on main
let entry = parse_preapprove_spec(
&format!(
"caller=uid:{},role=main,verb=nostr_get_public_key",
unsafe { libc::getuid() }
),
)
.unwrap();
policy.insert_before_last(entry).unwrap();
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
wait_for_server(&socket_name, 100);
let resp = send_request(
&socket_name,
r#"{"id":"3","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
);
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
// Result is a plain hex pubkey string (64 hex chars)
assert!(resp.contains("e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f"),
"expected pubkey in result, got: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_unknown_role_returns_selector_error() {
let socket_name = format!("nsigner_test_unknown_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let mut policy = PolicyTable::new();
policy.init_default(unsafe { libc::getuid() });
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
wait_for_server(&socket_name, 100);
// Unknown role should return unknown_role error before policy check
let resp = send_request(
&socket_name,
r#"{"id":"4","method":"nostr_get_public_key","params":[{"role":"nonexistent","role_path":"m/44'/1237'/0'/0/0"}]}"#,
);
assert!(resp.contains("unknown_role"), "expected unknown_role, got: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_ed25519_sign_denied_without_approval() {
let socket_name = format!("nsigner_test_alg_deny_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
// Catch-all deny
let mut policy = PolicyTable::new();
let mut catch_all = nsigner::policy::PolicyEntry::default();
catch_all.caller = "*".to_string();
catch_all.prompt = nsigner::policy::PromptMode::Deny;
policy.add(catch_all).unwrap();
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
wait_for_server(&socket_name, 100);
let msg_hex = hex::encode(b"hello");
let req = format!(
r#"{{"id":"5","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
msg_hex
);
let resp = send_request(&socket_name, &req);
assert!(resp.contains("policy_denied"), "expected policy_denied, got: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_ed25519_sign_allowed_with_preapprove() {
let socket_name = format!("nsigner_test_alg_allow_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let mut policy = PolicyTable::new();
policy.init_default(unsafe { libc::getuid() });
// Preapprove algorithm-based sign
let entry = parse_preapprove_spec(&format!(
"caller=uid:{},algorithm=ed25519,index=0-4,verb=sign",
unsafe { libc::getuid() }
))
.unwrap();
policy.insert_before_last(entry).unwrap();
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
wait_for_server(&socket_name, 100);
let msg_hex = hex::encode(b"hello");
let req = format!(
r#"{{"id":"6","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
msg_hex
);
let resp = send_request(&socket_name, &req);
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
assert!(resp.contains("signature"), "expected signature in result: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_session_grant_flow() {
let socket_name = format!("nsigner_test_session_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let mut policy = PolicyTable::new();
policy.init_default(unsafe { libc::getuid() });
// Simulate an approval that grants a session: insert a session grant
// for the caller (as if the user pressed 'e' at the prompt).
let caller_id = format!("uid:{}", unsafe { libc::getuid() });
policy
.insert_session_grant(&caller_id, "nostr_get_public_key", "main")
.unwrap();
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
wait_for_server(&socket_name, 100);
// First request: allowed by session grant
let resp = send_request(
&socket_name,
r#"{"id":"7","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
);
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
// Second request: still allowed (session grant persists)
let resp = send_request(
&socket_name,
r#"{"id":"8","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
);
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_allow_all_flag_skips_prompt() {
let socket_name = format!("nsigner_test_allowall_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
// Set --allow-all equivalent
nsigner::tui::set_prompt_always_allow(true);
let mut policy = PolicyTable::new();
policy.init_default(unsafe { libc::getuid() });
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
wait_for_server(&socket_name, 100);
// Same-uid would normally prompt — but --allow-all auto-approves
let resp = send_request(
&socket_name,
r#"{"id":"9","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
);
assert!(resp.contains("\"result\""), "expected success with allow-all, got: {}", resp);
// Reset flag
nsigner::tui::set_prompt_always_allow(false);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
// Keep UnixListener import used (for potential future filesystem socket tests)
#[allow(dead_code)]
fn _unused(_l: UnixListener) {}