Files
signer/plans/port_nsigner_to_rust.md

19 KiB

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

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 SignerError 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                # SignerError enum
├── tests/
│   ├── integration_test.rs
│   ├── algorithm_test.rs
│   ├── policy_test.rs
│   └── transport_test.rs
└── resources/
    └── pqclean/                # Vendored PQClean (if FFI path)

Cargo.toml (Draft)

[package]
name = "signer"
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: @signer_<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?