v0.0.1 - Port tui_continuous library to Rust, integrate with signer TUI, add versioning scheme
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target/
|
||||
*.log
|
||||
*.tar.gz
|
||||
Generated
+2706
File diff suppressed because it is too large
Load Diff
+66
@@ -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
|
||||
Executable
+309
@@ -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
|
||||
@@ -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)
|
||||
@@ -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?
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: *"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
Reference in New Issue
Block a user