22 Commits
Author SHA1 Message Date
Signer Developer 1e7e178c0f v0.0.23 - Verify SSH release push workflow 2026-08-30 16:01:18 -03:00
Signer Developer 892abe0ccf v0.0.22 - Build static musl binaries as signer and signer_client 2026-08-30 15:53:26 -03:00
Laan Tungir 782716c53b v0.0.21 - Activity log: show caller identity, role, curve, and actual requested key path 2026-08-22 07:57:19 -04:00
Laan Tungir b2f5d06570 v0.0.20 - Fixed blank activity log lines for algorithm-based verbs: all six algorithms now print method(algorithm,index path) with their standard derivation path; extracted standard_path() helper; added activity-format unit tests for all curves 2026-08-22 07:10:30 -04:00
Laan Tungir 03a977b774 v0.0.19 - Implemented Phase 13 PQ crypto: v2 FIPS seeded derivation (BIP-32 for PQ coin types 102003'-102005') with ML-DSA-65, SLH-DSA-128s (SHA2), and ML-KEM-768 keygen/sign/verify/encaps/decaps; fixed sign-verb key truncation; added encapsulate/decapsulate verbs; cross-implementation parity verified against nostr_quantum_preparation v2 vectors 2026-08-21 16:45:46 -04:00
Laan Tungir 9c762edbd2 v0.0.18 - Fix sign-event JSON escaping and variable-path role key caching (per-path re-derivation) 2026-08-20 18:34:14 -04:00
Laan Tungir b5b58ecb87 v0.0.17 - Fix qrexec bridge: server now consumes qrexec_source preamble frame before the JSON-RPC request 2026-08-20 18:27:45 -04:00
Laan Tungir a428d5e77b v0.0.16 - Release build with signer + signer-client binaries and updated install_signer.sh 2026-08-20 14:04:10 -04:00
Laan Tungir c685806aeb v0.0.15 - Updated install_signer.sh to pull from signer repo (was n_signer); installs signer + signer-client binaries 2026-08-20 13:55:40 -04:00
Laan Tungir a3e1d70fe8 v0.0.14 - Added common command examples to signer-client --help output 2026-08-20 13:49:57 -04:00
Laan Tungir 567985434d v0.0.13 - Renamed nsigner to signer throughout project; release build with signer + signer-client binaries 2026-08-20 11:28:57 -04:00
Laan Tungir a472e9785b v0.0.12 - Release v0.0.11: signer-client build assets 2026-08-20 11:21:57 -04:00
Laan Tungir 374b71cdf4 v0.0.11 - Added signer-client: Rust port of n_signer_client with full verb surface, 4 transports, and client-side auth envelope 2026-08-20 11:17:01 -04:00
Laan Tungir b3cc4704c3 v0.0.10 - Fix: derive variable-path roles using the concrete role_path from the request, not the template 2026-08-19 20:19:00 -04:00
Laan Tungir 5d3cc4e96d v0.0.9 - TUI: real-time per-word BIP-39 validation in seed entry popup 2026-08-19 15:17:15 -04:00
Laan Tungir 2f7f29ed99 v0.0.8 - Fix: Enter on empty buffer generates mnemonic; Enter on typed text loads it; no more 'g' key interception 2026-08-19 13:51:35 -04:00
Laan Tungir 7bc367e695 v0.0.7 - Fix: 'g' key only generates mnemonic when input buffer is empty, not while typing words 2026-08-19 13:45:01 -04:00
Laan Tungir 52ac2357f3 v0.0.6 - TUI: add Edit role command, Up/Down role selection for Edit/Delete, no highlight on Add 2026-08-19 13:32:10 -04:00
Laan Tungir 2cd64032ee v0.0.5 - TUI: add Left/Right arrow keys to cycle command focus alongside Tab 2026-08-19 13:18:11 -04:00
Laan Tungir ea11bb5a87 v0.0.4 - TUI: show correct Qube address with qrexec service name and nsigner_client command 2026-08-19 13:08:03 -04:00
Laan Tungir 1a48f40b99 v0.0.3 - TUI: unified Tab-focus model, content-sized popups, correct FIPS address, mandatory role-name entry, Help screen exit fix, adapted README 2026-08-19 09:51:33 -04:00
Laan Tungir b84ce7ab11 v0.0.2 - Created TUI flow redesign plan: single main screen with inline Transport and Roles sections, startup seed popup, AddRole popup, scrollable Help screen, collapsed borders, underlined-key-letter command hints, C-format activity log 2026-08-18 16:47:03 -04:00
52 changed files with 10449 additions and 2951 deletions
+5
View File
@@ -0,0 +1,5 @@
signer/target
signer/dist
signer/.git
nostr_core_lib_rust/target
nostr_core_lib_rust/.git
+1
View File
@@ -1,3 +1,4 @@
/target/
/dist/
*.log
*.tar.gz
+3
View File
@@ -0,0 +1,3 @@
[submodule "ratatui"]
path = ratatui
url = https://github.com/ratatui/ratatui.git
Generated
+479 -31
View File
@@ -23,6 +23,12 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.6"
@@ -82,6 +88,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -172,12 +187,27 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -303,6 +333,19 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"static_assertions",
"zmij",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -315,6 +358,15 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -375,6 +427,24 @@ dependencies = [
"winapi",
]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio 1.2.2",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
@@ -442,6 +512,40 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.3",
]
[[package]]
name = "darling_macro"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
dependencies = [
"darling_core",
"quote",
"syn 3.0.3",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -462,6 +566,34 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -495,6 +627,15 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -520,6 +661,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -569,6 +716,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -684,11 +837,27 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
@@ -962,6 +1131,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -990,7 +1165,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
@@ -1003,6 +1187,19 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instability"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -1015,6 +1212,24 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1032,6 +1247,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kasuari"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror",
]
[[package]]
name = "keccak"
version = "0.1.6"
@@ -1067,6 +1293,21 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "line-clipping"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
dependencies = [
"bitflags",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -1079,6 +1320,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1094,6 +1341,15 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -1125,6 +1381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -1189,7 +1446,7 @@ dependencies = [
[[package]]
name = "nostr-core"
version = "0.0.2"
version = "0.1.0"
dependencies = [
"aes",
"base64",
@@ -1211,7 +1468,7 @@ dependencies = [
[[package]]
name = "nostr-nips"
version = "0.0.2"
version = "0.1.0"
dependencies = [
"aes",
"block-modes",
@@ -1232,34 +1489,10 @@ dependencies = [
]
[[package]]
name = "nsigner"
version = "0.0.1"
dependencies = [
"base64",
"chacha20poly1305",
"clap",
"crossterm",
"ed25519-dalek",
"hex",
"hmac 0.12.1",
"libc",
"ml-dsa",
"ml-kem",
"nostr-core",
"nostr-nips",
"rand",
"rand_core 0.6.4",
"secp256k1",
"serde",
"serde_json",
"sha2 0.10.9",
"sha3 0.10.9",
"slh-dsa",
"tempfile",
"thiserror",
"x25519-dalek",
"zeroize",
]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
@@ -1270,6 +1503,15 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1331,6 +1573,39 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "palette"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
dependencies = [
"approx",
"libm",
"palette_derive",
"palette_math",
]
[[package]]
name = "palette_derive"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "palette_math"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
dependencies = [
"libm",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1403,6 +1678,12 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "potential_utf"
version = "0.1.6"
@@ -1412,6 +1693,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1481,6 +1768,64 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "ratatui"
version = "0.30.2"
dependencies = [
"instability",
"ratatui-core",
"ratatui-crossterm",
"ratatui-widgets",
"serde",
]
[[package]]
name = "ratatui-core"
version = "0.1.2"
dependencies = [
"bitflags",
"compact_str",
"hashbrown 0.17.1",
"itertools 0.15.0",
"kasuari",
"lru",
"palette",
"serde",
"strum",
"thiserror",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
[[package]]
name = "ratatui-crossterm"
version = "0.1.2"
dependencies = [
"cfg-if",
"crossterm 0.29.0",
"instability",
"ratatui-core",
]
[[package]]
name = "ratatui-widgets"
version = "0.3.2"
dependencies = [
"bitflags",
"hashbrown 0.17.1",
"indoc",
"instability",
"itertools 0.15.0",
"line-clipping",
"ratatui-core",
"serde",
"strum",
"time",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1827,6 +2172,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio 0.8.11",
"mio 1.2.2",
"signal-hook",
]
@@ -1859,6 +2205,37 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "signer"
version = "0.0.22"
dependencies = [
"base64",
"chacha20poly1305",
"clap",
"crossterm 0.27.0",
"ed25519-dalek",
"hex",
"hmac 0.12.1",
"libc",
"ml-dsa",
"ml-kem",
"nostr-core",
"nostr-nips",
"rand",
"rand_core 0.6.4",
"ratatui",
"secp256k1",
"serde",
"serde_json",
"sha2 0.10.9",
"sha3 0.10.9",
"slh-dsa",
"tempfile",
"thiserror",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -1932,12 +2309,39 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -2040,6 +2444,27 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -2192,6 +2617,29 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools 0.14.0",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "universal-hash"
version = "0.5.1"
+9 -4
View File
@@ -1,16 +1,20 @@
[package]
name = "nsigner"
version = "0.0.1"
name = "signer"
version = "0.0.23"
edition = "2021"
license = "MIT"
description = "Attended Nostr signing daemon — Rust port of n_signer"
[[bin]]
name = "nsigner"
name = "signer"
path = "src/main.rs"
[[bin]]
name = "signer-client"
path = "src/client/main.rs"
[lib]
name = "nsigner"
name = "signer"
path = "src/lib.rs"
[dependencies]
@@ -48,6 +52,7 @@ clap = { version = "4", features = ["derive"] }
# TUI
crossterm = "0.27"
ratatui = { path = "ratatui/ratatui", default-features = false, features = ["crossterm"] }
# Encoding
base64 = "0.22"
+28
View File
@@ -0,0 +1,28 @@
FROM rust:1.88.0-alpine3.20
RUN apk add --no-cache \
build-base \
binutils \
file \
musl-dev \
openssl-dev \
openssl-libs-static \
perl \
pkgconf \
linux-headers \
&& rustup target add x86_64-unknown-linux-musl
ENV CARGO_NET_OFFLINE=false \
RUSTFLAGS=-Ctarget-cpu=x86-64
WORKDIR /workspace/signer
COPY nostr_core_lib_rust /workspace/nostr_core_lib_rust
COPY signer /workspace/signer
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN file target/x86_64-unknown-linux-musl/release/signer \
&& file target/x86_64-unknown-linux-musl/release/signer-client \
&& ! readelf -l target/x86_64-unknown-linux-musl/release/signer | grep -q 'INTERP'
CMD ["sh", "-c", "mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/signer /out/signer && cp target/x86_64-unknown-linux-musl/release/signer-client /out/signer_client"]
+833
View File
@@ -0,0 +1,833 @@
# signer
`signer` is a single statically-linked program that holds signing key material in locked memory and signs on request.
It runs in the foreground, attached to your terminal. The terminal is the trust anchor and control surface. Nothing touches disk at runtime. If the process crashes or exits, all in-memory state is gone.
This is a **program, not a daemon**:
- no hidden background process
- no detached service lifecycle
- no runtime config or state files
- no persistence to recover after compromise
## 1. What it is
`signer` is one binary that combines:
- mnemonic handling (BIP-39)
- role selection and derivation (BIP-32 / SLIP-0010)
- purpose/curve enforcement
- request dispatch
- interactive terminal UI (ratatui)
- transport adapter(s) (Unix socket, qrexec, FIPS/TCP, HTTP, stdio)
- OTP one-time pad encryption (optional, with USB pad)
- post-quantum algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
## 2. Security model
### 2.1 Zero filesystem footprint
At runtime, `signer` writes nothing to disk: no config files, no logs, no PID files, no lock files, no socket pathname artifacts. On Linux desktop, local IPC uses abstract namespace Unix sockets (`@name` semantics) that exist only in kernel memory and disappear with process/kernel namespace lifetime.
### 2.2 Crash = total wipe
All sensitive and operational state exists only in-process RAM (mlock'd where applicable): mnemonic-derived key material, role table, activity display buffer. If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design. Sensitive buffers are zeroized with [`zeroize`](src/secure_mem.rs) on drop.
### 2.3 Single binary, no external runtime dependencies
The release build (`opt-level = "z"`, `lto = true`, `panic = "abort"`, `strip = true`) produces one optimized binary. No shared libraries are required at runtime beyond the system libc.
### 2.4 Always-attended operation
`signer` is intentionally human-attended. It stays attached to a terminal and the role-name-as-password model means a caller must already know the role name (the "password") to reach a key. Human presence is part of the security model.
### 2.5 Secret memory backing: `mlock`
Sensitive buffers (mnemonic, master seed, per-role private keys) live in `mlock`'d RAM and are zeroized on free. This gives swap protection and crash-wipe semantics on every supported platform, including Qubes OS Xen guests.
## 3. How it works
### 3.1 Startup phase (TUI input mode)
When started interactively, `signer` immediately enters a startup popup:
1. **Seed entry popup** — enter an existing BIP-39 mnemonic, or type `g` to generate a fresh 12-word mnemonic (displayed numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then press Enter to continue).
2. On successful load, derive keys for any pre-registered roles (the default `main` role is auto-registered), start the server with the default transport (Unix), and transition to the main screen.
3. Invalid mnemonics show an error line and retry (max 10 attempts).
No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash.
For parent-process launchers, startup can also be non-interactive:
- `--mnemonic-stdin`: read one mnemonic line from stdin at startup, then continue normally.
- `--mnemonic-fd N`: read one mnemonic line from inherited file descriptor `N` at startup.
These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. Non-interactive modes run headless (no TUI).
### 3.2 Running phase (main screen)
After unlock, the terminal becomes a live status and control console. The title `Signer v0.0.1` is centered on its own line. Below it, collapsed-border panes show:
- **Information** — signer name, transport addresses (Unix / Qube / FIPS / HTTP), OTP pad status.
- **Transport** — four toggle lines (`[x] U̲nix Socket`, `[ ] Qube b̲ridge`, `[ ] F̲IPS`, `[ ] H̲TTP`). The `[x]` / `[ ]` indicator shows on/off state. The underlined key letter toggles a transport on/off. The last active transport cannot be disabled. Toggling restarts the server immediately.
- **Roles** — table of registered roles (Role, Purpose, Curve). `A̲dd` opens the add-role popup; `D̲elete` removes the selected role immediately.
- **Activity** — scrollable, newest first. Each entry is a timestamped log line in the format `<caller_id> <curve> <key_path>`. `Cl̲ear` wipes the log.
- **Bottom bar** — `He̲lp Q̲uit`.
Command hints show only the word with the key letter underlined (e.g. "Quit" with Q underlined, "Qube bridge" with B underlined). The underlined letter is the actual key binding, which may not be the first letter.
**Focus and activation:** `Tab` / `Shift-Tab` cycles focus through all nine commands on the main screen — the four transports, Add, Delete, Clear, Help, Quit. The focused command is reverse-highlighted. `Enter` activates the focused command. Pressing a command's underlined key letter fires it directly regardless of focus. `Up`/`Down` (or `j`/`k`) scroll the activity log.
### 3.3 Help screen
Press `L` from the main screen to open a full-screen, scrollable Help overlay describing what the app does, what transports are, what roles are, and listing the key commands. A Navigation section at the end explains how to leave the screen:
- `Up`/`Down` or `j`/`k` — scroll
- `Page Up` / `Page Down` — scroll by a page
- `ESC` or `B` — back to the main screen
- `Q` — quit the program
The bottom bar shows `Ba̲ck Q̲uit` so the exit path is always visible.
### 3.4 Add-role popup
Press `A` from the main screen to open the add-role popup. The flow is:
1. **Preset menu** — choose from 10 presets (Standard Nostr, hardened range, agent range, SSH, Age, ML-DSA-65, SLH-DSA-128s, ML-KEM-768, OTP, Custom).
2. **Name entry** — InputField pre-filled with a default role name.
3. **Curve select** — custom roles only.
4. **Path entry** — InputField pre-filled with the preset's derivation path template.
5. **OTP dir / name** — OTP roles only.
6. **Confirm** — register the role, derive its key immediately, and return to the main screen automatically.
`ESC` at any stage cancels and returns to the main screen.
### 3.5 Shutdown
- `Q` (or `ESC` on the main screen) quits the process: all session state is destroyed.
- Terminal close or process termination has the same effect: total state wipe.
## 4. API
`signer` exposes a JSON-RPC 2.0-style request/response protocol. Every request is a single JSON object; every response is a single JSON object. This section is the complete, authoritative description of the API.
### 4.1 Request format
```json
{ "id": "<string>", "method": "<verb>", "params": [ <arg0>, <arg1>, ..., { <options> } ] }
```
- `id` — caller-supplied string echoed verbatim in the response. Used to match requests to responses.
- `method` — the verb name (see [§4.3](#43-verbs)).
- `params` — a JSON array. Positional arguments come first; the **last array element** is conventionally an options object. The options object is optional for most verbs.
### 4.2 Response format
Success:
```json
{ "id": "<string>", "result": <value> }
```
`result` is a JSON string. For structured verbs the string is itself a serialized JSON object — clients should `JSON.parse` it.
Error:
```json
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
```
Error codes:
| Code | Message | Meaning |
|-------|-------------------------------|--------------------------------------------------------------------|
| -32700| `parse_error` | Request was not valid JSON. |
| -32600| `invalid_request` | Missing `id`, `method`, or `params`, or `params` is not an array. |
| -32601| `method_not_found` | Unknown verb, or verb not valid for the selected algorithm. |
| -32602| `invalid_params` | Malformed arguments (bad hex, wrong length, missing field, etc.). |
| 1001 | `ambiguous_role_selector` | More than one role selector was supplied. |
| 1002 | `unknown_role` | No role matched the selector. |
| 1003 | `no_default_role` | No selector given and no `main` role exists. |
| 1004 | `purpose_mismatch` | Role's purpose is not valid for this verb. |
| 1005 | `curve_mismatch` | Role's curve is not valid for this verb. |
| 1006 | `mnemonic_not_loaded` | No mnemonic is loaded in the signer. |
| 1007 | `no_termination_condition` | `nostr_mine_event` called without `difficulty` or `timeout_sec`. |
| 1008 | `mining_failed` | Internal error during proof-of-work mining. |
| 1009 | `not_yet_implemented` | Verb+algorithm combination is reserved but not yet implemented. |
| 1010 | `algorithm_not_supported_for_verb` | The `algorithm` value is not valid for this verb. |
| 2003 | `path_not_allowed` | `role_path` does not match any registered role or allowed path. |
| 2005 | `index_out_of_range` | `index` outside the named role's `[lo,hi]` range. |
| 2008 | `role_required` | `role` is required when using `role_path`. |
| 2009 | `path_required` | `role_path` is required for roles with variable path templates. |
### 4.3 Verbs
All verbs take their arguments as positional `params` and their options in a trailing options object. Most verbs select a key via the `algorithm` + `index` options (see [§4.4](#44-algorithms)). The `nostr_*` verbs select a secp256k1 NIP-06 key via `role` + `role_path` and implement Nostr-protocol-specific serialization on top of the raw crypto.
| Verb | Algorithms | Positional params | Options |
|-------------------------|-----------------------------------------------|----------------------------------|----------------------------------|
| `get_info` | n/a (metadata) | — | — |
| `get_public_key` | all key-deriving algorithms | — | `algorithm`, `index` |
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>` | `algorithm`, `index`, `scheme`* |
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | `<message_hex>`, `<signature_hex>` | `algorithm`, `index`, `scheme`* |
| `encapsulate` | ml-kem-768 | `<peer_pubkey_hex>` | `algorithm` |
| `decapsulate` | ml-kem-768 | `<ciphertext_hex>` | `algorithm`, `index` |
| `derive_shared_secret` | x25519 | `<peer_pubkey_hex>` | `algorithm`, `index` |
| `derive` | secp256k1 | `<data>` | `algorithm`, `index` (required) |
| `encrypt` | otp | `<plaintext_base64>` | `algorithm`, `encoding` |
| `decrypt` | otp | `<ciphertext>` | `algorithm`, `encoding` |
| `nostr_get_public_key` | secp256k1 (NIP-06) | — | `role`, `role_path`, `format` |
| `nostr_sign_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path` |
| `nostr_mine_event` | secp256k1 (NIP-06) | `<event_json>` | `role`, `role_path`, `difficulty`, `timeout_sec`, `threads` |
| `nostr_nip04_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
| `nostr_nip04_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
| `nostr_nip44_encrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<plaintext>` | `role`, `role_path` |
| `nostr_nip44_decrypt` | secp256k1 (NIP-06) | `<peer_pubkey_hex>`, `<ciphertext>` | `role`, `role_path` |
\* `scheme` is secp256k1-only: `"schnorr"` (default, BIP-340) or `"ecdsa"`.
#### Enforcement matrix
| Verb | Valid algorithms |
|----------------------------|-----------------------------------------------|
| `sign` / `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s |
| `encapsulate` / `decapsulate` | ml-kem-768 |
| `derive_shared_secret` | x25519 |
| `derive` | secp256k1 |
| `encrypt` / `decrypt` | otp |
| `get_public_key` | all key-deriving algorithms |
| `nostr_*` | secp256k1 (Nostr protocol) |
Any unlisted `(verb, algorithm)` pair is rejected with `algorithm_not_supported_for_verb` (1010).
### 4.4 Algorithms
All keys derive deterministically from the loaded BIP-39 mnemonic. The caller selects an algorithm by name and a derivation `index` (an integer `<n>` substituted into the algorithm's derivation path). OTP is the exception — it does not derive a key, it consumes a bound one-time pad (see [§4.4.3](#443-otp)).
#### 4.4.1 Algorithm table
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|-----------------|-----------------|---------------|----------------------------------------|-------------------------------|
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0'/0'` (BIP-32) | 32 / 1952 |
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0'/0'` (BIP-32) | 64 / 32 |
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0'/0'` (BIP-32) | 64 / 1184 |
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
#### 4.4.2 Key derivation
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use the **v2 FIPS seeded derivation** (see [`plans/pq_seeded_derivation_plan.md`](plans/pq_seeded_derivation_plan.md)): BIP-32 child bytes at the exact seed length required by each algorithm feed the seeded keygen APIs directly — no DRBG expansion. ML-DSA-65 takes one 32-byte child; SLH-DSA-128s takes two children concatenated (first 48 of 64 bytes, split as sk.seed ∥ sk.prf ∥ pk.seed); ML-KEM-768 takes two children concatenated (all 64 bytes, split as d ∥ z). Same mnemonic, same index, same key pair every time — and the same keys as the nostr_quantum_preparation web app (verified against its pinned test vectors by `tests/pq_conformance.rs`). PQ private keys are stored in seed form. The PQ implementations are the pure-Rust crates [`ml-dsa`](https://crates.io/crates/ml-dsa), [`ml-kem`](https://crates.io/crates/ml-kem), and [`slh-dsa`](https://crates.io/crates/slh-dsa) (SLH-DSA uses the SHA2-128s parameter set). The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
#### 4.4.3 OTP
The `otp` algorithm is a stream-style one-time pad, not a key-derivation scheme. It is selected like any other algorithm via `{"algorithm":"otp"}` and works with the `encrypt` / `decrypt` verbs. One pad per session; the pad offset advances monotonically across requests and is reported in every response.
### 4.5 Examples
#### `get_info`
Returns signer metadata: `name`, `implementation`, `version`, `api`, and the supported `verbs` / `algorithms` arrays. Safe to call before the mnemonic is loaded — clients use it to feature-detect.
```json
{ "id": "0", "method": "get_info", "params": [] }
```
#### `get_public_key`
```json
{ "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] }
```
Response:
```json
{ "id": "1", "result": "{\"algorithm\":\"ml-dsa-65\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}" }
```
`key_id` is the first 16 hex characters of the public key — a short display identifier.
#### `sign`
```json
{ "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] }
```
Response:
```json
{ "id": "2", "result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\",\"key_id\":\"<16 hex>\"}" }
```
The first positional argument is the message as hex. For `secp256k1` the `scheme` option selects `"schnorr"` (default, BIP-340) or `"ecdsa"`:
```json
{ "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] }
```
#### `verify`
```json
{ "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] }
```
Response:
```json
{ "id": "4", "result": "{\"valid\":true,\"algorithm\":\"ed25519\"}" }
```
The signer derives its own public key from `(algorithm, index)` and verifies against it. To verify an arbitrary third-party key, use a client-side library.
#### `encapsulate` (ML-KEM-768)
```json
{ "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] }
```
Response:
```json
{ "id": "5", "result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
```
`peer_pubkey_hex` is the recipient's ML-KEM-768 public key (1184 bytes → 2368 hex chars). Send the returned `ciphertext` to the recipient; both sides end up with the same `shared_secret`.
#### `decapsulate` (ML-KEM-768)
```json
{ "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] }
```
Response:
```json
{ "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
```
#### `derive_shared_secret` (X25519)
```json
{ "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] }
```
Response:
```json
{ "id": "7", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"x25519\"}" }
```
`peer_pubkey_hex` is the peer's 32-byte X25519 public key (64 hex chars). Feed the returned `shared_secret` into your own symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305).
#### `derive` (secp256k1 HMAC-SHA256)
```json
{ "id": "10", "method": "derive", "params": [ "<data>", { "algorithm": "secp256k1", "index": 0 } ] }
```
Response:
```json
{ "id": "10", "result": "{\"algorithm\":\"secp256k1\",\"key_id\":\"<16hex>\",\"digest\":\"<64hex>\"}" }
```
Computes `HMAC-SHA256(privkey, data)` where `privkey` is the secp256k1 private key derived on demand at `(algorithm: "secp256k1", index: N)`. `data` is an arbitrary caller-supplied UTF-8 string. Returns the 32-byte digest as 64 lowercase hex chars.
`index` is **required** (no default) — forces conscious selection of which derived key to use as the HMAC key. Omitting it returns `invalid_params`.
This is a generic key-derived MAC primitive. Callers domain-separate by prefixing their own label into `data` (e.g. `"myapp/identifier-v1:<path>"`). The private key never leaves the signer; only the digest is returned. Use cases include deterministic, per-user, opaque identifiers for NIP-33 parameterized-replaceable events (e.g. bookmark folder `d` tags) where the same logical name must produce the same `d` tag across devices.
#### `encrypt` / `decrypt` (OTP)
```json
{ "id": "8", "method": "encrypt", "params": [ "<plaintext_base64>", { "algorithm": "otp", "encoding": "ascii" } ] }
{ "id": "9", "method": "decrypt", "params": [ "<ciphertext>", { "algorithm": "otp", "encoding": "ascii" } ] }
```
`encoding` is `"ascii"` (ASCII-armored, default) or `"binary"` (base64-encoded raw `.otp` blob). If omitted on `decrypt`, auto-detection by magic bytes is used.
`encrypt` response:
```json
{
"id": "8",
"result": "{\"ciphertext\":\"<ascii-armor-or-base64-blob>\",\"encoding\":\"ascii\",\"pad_chksum\":\"<64 hex>\",\"pad_offset_before\":288,\"pad_offset_after\":416}"
}
```
`decrypt` response:
```json
{ "id": "9", "result": "{\"plaintext\":\"<base64>\",\"pad_chksum\":\"<64 hex>\"}" }
```
If no pad is bound at startup, the error is `-32601` `method_not_found` (otp_pad_not_bound).
#### `nostr_get_public_key`
```json
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "role": "main" } ] }
```
Response (default): a plain 64-hex-char secp256k1 public key string.
Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`.
#### `nostr_sign_event`
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
```json
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "role": "main" } ] }
```
`<event_json>` is the unsigned event object:
```json
{ "pubkey": "...", "created_at": 1234567890, "kind": 1, "tags": [], "content": "hello" }
```
Response: the signed event JSON string, with `id` and `sig` populated.
#### `nostr_mine_event`
Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step. Mining runs in a detached thread so the signer stays responsive.
```json
{
"id": "12",
"method": "nostr_mine_event",
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "role": "main" } ]
}
```
| Option | Required | Default | Meaning |
|---------------|-------------------------------|---------|---------------------------------------------------------------|
| `difficulty` | one of `difficulty`/`timeout` | 0 | Target leading zero bits. Stops early if reached. |
| `timeout_sec` | one of `difficulty`/`timeout` | 600 | Time budget in seconds. Always returns the best event found. |
| `threads` | no | 1 | Mining threads (clamped to 1..32). |
At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when **either** condition is met. Timeout is never an error — the best event found is always returned.
Response:
```json
{
"id": "12",
"result": "{\"event\":\"<signed event JSON with nonce tag>\",\"achieved_difficulty\":18,\"target_difficulty\":20,\"target_reached\":false,\"elapsed_sec\":30,\"attempts\":4523456}"
}
```
Errors:
- `1007` `no_termination_condition` — neither `difficulty` nor `timeout_sec` given.
- `1008` `mining_failed` — internal mining error.
#### `nostr_nip04_encrypt` / `nostr_nip04_decrypt`
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
```json
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
```
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
#### `nostr_nip44_encrypt` / `nostr_nip44_decrypt`
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
```json
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
```
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
### 4.6 Role-based selectors (Nostr verbs)
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Supported selectors:
| Selector | Meaning |
|----------------|--------------------------------------------------|
| `role` | Name of a pre-registered role entry (required) |
| `role_path` | Full BIP-44 derivation path (required) |
**Selector resolution**: both `role` and `role_path` are required together — they form a single combined selector. The server verifies that the supplied `role_path` matches the role's registered template (expanding any wildcard). There is no resolution order and no default role: omitting either field is rejected (`2008 role_required` / `2009 path_required`). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
#### Named path-roles
In the add-role popup, you define **named path-roles** that bind a role name (which acts as an access token for clients) to a derivation path template. The derivation path template is hidden from clients — they only know the role name and send the full concrete `role_path` with each request.
The popup presents a **preset menu** of 10 options covering the common role types. You can still define custom roles manually via the "Custom path" option.
```
Preset menu:
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
9. OTP role (one-time pad encryption)
10. Custom path
```
Purpose is auto-detected from the path prefix (e.g. `m/44'/1237'` → nostr, `m/44'/102001'` → ssh). The path template is pre-filled from the chosen preset and can be edited inline.
**Path template syntax:**
- **Wildcard**: `m/44'/1237'/*'/0'/0'` — any non-negative integer, hardened. No range limit.
- **Range**: `m/44'/1237'/0-3/1/0` — index 0..3, hardened if segment ends with `'` (e.g. `0-3'`)
- **Set**: `m/44'/1237'/1+34+54/1/0` — specific indices 1, 34, 54
- **Fixed path**: `m/44'/1237'/0'/0/0` — no variable segment, single fixed key
- The first segment that is a plain number, range (`N-M`), set (`A+B+C`), or wildcard (`*`) becomes the variable. Segments with `'` (like `44'`, `1237'`) are treated as literal hardened constants.
The role name itself acts as a password: any caller that knows the role name and supplies a matching `role_path` is served without attendant interaction.
Clients request keys by supplying both `role` and the full concrete `role_path`:
```json
{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/0'/1/0"}]}
```
→ derives `m/44'/1237'/0'/1/0`, verified against the `myrole` template.
```json
{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/5'/1/0"}]}
```
`2003 path_not_allowed` (5 is outside the registered template, if the template was a fixed path or limited range).
```json
{"id":"3","method":"nostr_get_public_key","params":[{"role":"unknown","role_path":"m/44'/1237'/0'/0/0"}]}
```
`1002 unknown_role` (name not registered).
```json
{"id":"4","method":"nostr_get_public_key","params":[{"role":"myrole"}]}
```
`2009 path_required` (`role_path` is required).
```json
{"id":"5","method":"nostr_get_public_key","params":[{"role_path":"m/44'/1237'/0'/0/0"}]}
```
`2008 role_required` (`role` is required when using `role_path`).
### 4.7 Non-interactive role registration
Roles can be registered from the command line with `--register-role` (repeatable), avoiding the popup entirely. The spec format is `<name>:<curve>:<path-template>`. If `<curve>` is empty, the curve is auto-detected from the path prefix.
```bash
signer --register-role main:secp256k1:m/44'/1237'/0'/0/0
signer --register-role nostr_range::m/44'/1237'/*'/0/0
signer --register-role ssh::m/44'/102001'/0'/0'/0'
```
If no `--register-role` is given in non-interactive mode, a default `main` role (`m/44'/1237'/0'/0/0`, secp256k1, nostr) is auto-registered.
## 5. Transports
The API is transport-independent. The same JSON request works over every transport; only the framing differs.
| Transport | `--listen` flag | Framing | Caller identity |
|-----------|--------------------------------|------------------------------------------|----------------------------|
| Unix socket (abstract) | `unix` (default on desktop) | Length-prefixed framed JSON | `SO_PEERCRED``uid:<n>` |
| stdio | `stdio` | One framed request/response over stdin/stdout | inherited uid |
| qrexec | `qrexec` | Same as stdio; caller from `QREXEC_REMOTE_DOMAIN` | `qubes:<vm>` |
| FIPS/TCP | `tcp:[host]:port` | Length-prefixed framed JSON | (transport-defined) |
| HTTP | `http:host:port` | Standard HTTP POST, JSON body, no custom framing. CORS enabled. | (transport-defined) |
### 5.1 HTTP examples
Start the signer:
```bash
signer --listen http:127.0.0.1:11111 --mnemonic-stdin
```
Get a public key:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
```
Sign a Nostr event:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
```
OTP encrypt:
```bash
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
-d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"algorithm":"otp","encoding":"ascii"}]}'
```
### 5.2 Unix socket examples (framed mode)
```bash
# get_public_key
signer --socket-name signer01 client \
'{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
# Sign a Nostr event
signer --socket-name signer01 client \
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
# ed25519 sign
signer --socket-name signer01 client \
'{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}'
```
### 5.3 Linux desktop: abstract namespace Unix socket
Primary local transport is AF_UNIX abstract namespace. Each running `signer` process binds to a unique abstract name of the form `@signer_<word1>_<word2>`, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. `@signer_hairy_dog`). This lets multiple signers coexist on one host.
Properties: no pathname in filesystem; endpoint lifetime bound to process/kernel namespace; no stale socket files; caller identity via peer credentials (`SO_PEERCRED`); per-launch random name avoids collisions and leaks no seed-derived identifier.
Naming rules:
- Default: random pick at startup, displayed in the Information section.
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name.
Discovery:
- `signer list` enumerates currently bound `signer_*` abstract sockets by reading `/proc/net/unix`.
- `signer --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
- `signer --listen qrexec` is the same stdio framing, but caller identity comes from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
- `signer --listen tcp:[::]:11111` enables FIPS/TCP listening (framed JSON, not HTTP).
- `signer --listen http:127.0.0.1:11111` enables HTTP listening for curl-friendly access. CORS headers included for browser access. Defaults to localhost; pass `http:0.0.0.0:PORT` to expose externally.
- `signer bridge --to <socket-name>` is a stateless relay for Qubes qrexec: reads one framed request from stdin, forwards it to a persistent signer's abstract unix socket, and relays the response to stdout. Used as the `qubes.signerRpc` service entrypoint.
- `--bridge-source-trusted` (unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed `{"qrexec_source":"<vm>"}` preamble before the request, and the caller identity is composed as `qubes:<vm>`.
### 5.4 Caller verification
Every transport must provide concrete caller identity before policy evaluation.
- Linux AF_UNIX: map peer credentials to caller identity.
- Relay session: bind remote peer/session identity before allowing signer verbs.
Identity verification and the role-name-as-password gate are separate layers. Passing identity checks does not bypass the role-name requirement.
## 6. Platform targets
### 6.1 Linux desktop (primary)
Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
### 6.2 Qubes OS qube
Qubes deployment runs `signer` in a dedicated signer qube (e.g. `nostr_signer`) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation).
Three transport paths are supported:
**FIPS/TCP** — the signer listens on `tcp:[::]:11111` and FIPS carries traffic between qubes as an IPv6 mesh substrate.
**HTTP** — the signer listens on `http:127.0.0.1:11111` for curl-friendly access within the same qube. No auth envelopes required (relies on localhost binding + the role-name-as-password gate).
**Qubes qrexec bridge** (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless `signer bridge` relay (the `qubes.signerRpc` qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is `qubes:<source-vm>`.
#### Qrexec bridge setup
**In the signer qube** (`nostr_signer`):
```bash
signer --listen unix --socket-name signer --bridge-source-trusted
```
**From a caller qube** (via the qrexec service):
```bash
signer bridge --to signer
```
## 7. Usage
### 7.1 Run the program
```bash
signer
```
Program starts in attached foreground mode and shows the seed-entry popup. After mnemonic acceptance, the main screen shows the randomly assigned signer name and its abstract socket address.
To force a specific socket name (e.g. for scripted clients):
```bash
signer --name my_test_signer
```
Other transport modes:
```bash
signer --listen qrexec # Qubes qrexec (single framed request over stdin/stdout)
signer --listen stdio # Generic stdio (single framed request over stdin/stdout)
signer --listen tcp:[::]:11111 # FIPS/TCP (framed JSON, no TUI)
signer --listen http:127.0.0.1:11111 # HTTP (curl-friendly, no TUI)
```
With OTP pad bound:
```bash
signer --listen http:127.0.0.1:11111 --otp-pad-dir /media/user/Music/pads --otp-pad 333e9902db839d9d --mnemonic-stdin
```
Qrexec bridge mode (stateless relay to a persistent signer's unix socket):
```bash
signer bridge --to signer
```
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
```bash
signer --listen unix --socket-name signer --bridge-source-trusted
```
### 7.2 Send a request (client mode)
The `signer client` subcommand sends a hand-built JSON-RPC object over the socket:
```bash
signer --socket-name signer01 client \
'{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
```
Read the request from stdin with `-`:
```bash
echo '{"id":"1","method":"get_info","params":[]}' | signer client -
```
If only one signer is running you can omit the `--socket-name` override and the client will use the default discovery rule.
### 7.3 List running signers
```bash
signer list
```
Prints the names of any currently running `signer` instances, e.g.:
```text
signer_hairy_dog
signer_brave_canyon
```
### 7.4 Example session
Terminal A:
```text
$ signer
signer v0.0.2
[seed entry popup → enter mnemonic]
[main screen shows: signer name signer_hairy_dog, Unix address active]
```
Terminal B:
```text
$ signer --socket-name signer_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
{"id":"2","result":"<signed_event_json>"}
```
## 8. Build
### 8.1 Dependencies
The build expects the local [`nostr_core_lib_rust`](../nostr_core_lib_rust) checkout (sibling directory) for the `nostr-core` and `nostr-nips` path dependencies, and the vendored [`ratatui`](ratatui) submodule for the TUI.
```bash
git submodule update --init ratatui
```
### 8.2 Local dev build
Native builds are intended for local development and use the host Rust toolchain and libc:
```bash
cargo build
./target/debug/signer --version
```
### 8.3 Portable static musl release build
Portable release binaries are built in Docker for x86_64 Linux using the `x86_64-unknown-linux-musl` target. This avoids a runtime dependency on the target system's glibc version. The build expects the sibling `nostr_core_lib_rust` checkout described above.
```bash
./build_musl.sh
```
The artifacts are written directly to `dist/`:
- `signer`
- `signer_client`
Verify the output:
```bash
file dist/signer
ldd dist/signer
./dist/signer --version
```
`ldd` should report that the executable is not dynamically linked. Static musl removes the glibc runtime dependency, but the binaries still require a compatible Linux kernel, x86_64 CPU, terminal environment, Qubes/qrexec environment where applicable, and sufficient `RLIMIT_MEMLOCK` for locked secret memory.
To deploy the portable binaries locally:
```bash
./deploy_local.sh --musl
```
### 8.4 Native release build
The release profile is tuned for a small, optimized, stripped binary:
```toml
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
```
```bash
cargo build --release
./target/release/signer --version
```
### 8.5 Tests
```bash
cargo test
```
The portable build runs the release compilation and static-linkage checks inside Docker. Runtime smoke tests should be performed on the intended Qubes/Linux deployment environment, including Unix sockets, TCP/HTTP, qrexec, TUI startup, signing, and `mlock` behavior.
## 9. Project layout
| Path | Purpose |
|------|---------|
| [`src/main.rs`](src/main.rs:1) | CLI parsing, subcommands (`client`, `bridge`, `list`), server startup |
| [`src/lib.rs`](src/lib.rs:1) | Crate root, module declarations, `VERSION` |
| [`src/tui.rs`](src/tui.rs:1) | ratatui TUI: seed-entry popup, main screen, add-role popup, help overlay |
| [`src/server.rs`](src/server.rs:1) | Multi-transport server with poll loop, caller identity, request framing |
| [`src/dispatcher.rs`](src/dispatcher.rs:1) | Verb dispatch and JSON-RPC response construction |
| [`src/role_table.rs`](src/role_table.rs:1) | Role registry, path-template parsing, purpose/curve enforcement |
| [`src/selector.rs`](src/selector.rs:1) | Role selector resolution (`role` + `role_path`) |
| [`src/enforcement.rs`](src/enforcement.rs:1) | Verb/algorithm/purpose/curve enforcement matrix |
| [`Dockerfile.musl`](Dockerfile.musl:1) | Reproducible Docker environment for static musl releases |
| [`build_musl.sh`](build_musl.sh:1) | Builds and validates portable x86_64 musl binaries |
| [`src/key_store.rs`](src/key_store.rs:1) | BIP-32 / SLIP-0010 key derivation and storage |
| [`src/mnemonic.rs`](src/mnemonic.rs:1) | BIP-39 mnemonic loading and seed derivation |
| [`src/pq_crypto.rs`](src/pq_crypto.rs:1) | Post-quantum keygen (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) |
| [`src/pq_drbg.rs`](src/pq_drbg.rs:1) | SHAKE-256 DRBG (retained port; not used for PQ keygen — see the v2 seeded derivation) |
| [`src/alg_cache.rs`](src/alg_cache.rs:1) | Per-algorithm derived-key cache |
| [`src/otp_pad.rs`](src/otp_pad.rs:1) | One-time pad binding, offset tracking, encrypt/decrypt |
| [`src/miner.rs`](src/miner.rs:1) | NIP-13 proof-of-work mining for `nostr_mine_event` |
| [`src/auth_envelope.rs`](src/auth_envelope.rs:1) | Auth envelope verification and nonce cache |
| [`src/transport.rs`](src/transport.rs:1) | Length-prefixed framing, abstract Unix socket connect |
| [`src/http.rs`](src/http.rs:1) | HTTP listener transport |
| [`src/socket_name.rs`](src/socket_name.rs:1) | Random socket-name generation and discovery |
| [`src/secure_mem.rs`](src/secure_mem.rs:1) | `mlock` / `zeroize` helpers |
| [`src/error.rs`](src/error.rs:1) | `signerError` enum |
| [`ratatui/`](ratatui:1) | Vendored ratatui submodule (TUI framework) |
| [`plans/`](plans:1) | Design and migration plans |
## 10. Differences from the C `n_signer`
This Rust port preserves the security model and API of the original C `n_signer` while differing in implementation details:
- **Language**: Rust instead of C. Memory safety is enforced by the type system; sensitive buffers use [`zeroize`](https://crates.io/crates/zeroize) instead of hand-rolled `secure_memzero`.
- **PQ crypto**: pure-Rust crates (`ml-dsa`, `ml-kem`, `slh-dsa`) instead of vendored PQClean C.
- **TUI**: [`ratatui`](https://github.com/ratatui/ratatui) instead of the vendored `tui_continuous` C component. The TUI flow is redesigned (see [`plans/tui_flow_redesign.md`](plans/tui_flow_redesign.md:1)): a single main screen with a startup seed-entry popup, an add-role popup, and a scrollable Help overlay, replacing the original linear setup wizard.
- **Approval prompts**: the C version's interactive approval prompts (`y/n/e/a`) are not yet ported; the Rust port uses the role-name-as-password gate as the primary access control. Interactive approval is a future addition.
- **No firmware targets**: the C project also targets ESP32 / MCU hardware via TinyUSB. This Rust port targets Linux desktop and Qubes OS only.
- **No `--preapprove` flag**: pre-approval entries are not yet implemented in the Rust port.
- **No `--allow-all` flag**: the Rust port does not carry the C project's `--allow-all` development shortcut; access control is the role-name-as-password gate plus caller identity.
Executable
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Build portable static x86_64 Linux binaries in a pinned musl container.
# The Docker build context is the parent directory because Cargo.toml uses
# ../nostr_core_lib_rust as a path dependency. Set SIGNER_NOSTR_CORE_DIR
# when the sibling checkout is stored elsewhere.
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_NAME="$(basename "${PROJECT_DIR}")"
PARENT_DIR="$(dirname "${PROJECT_DIR}")"
IMAGE_NAME="${SIGNER_MUSL_IMAGE:-signer-musl-build:rust-1.88.0-alpine3.20}"
OUTPUT_DIR="${SIGNER_MUSL_OUTPUT_DIR:-${PROJECT_DIR}/dist}"
DEPENDENCY_DIR="${SIGNER_NOSTR_CORE_DIR:-${PARENT_DIR}/nostr_core_lib_rust}"
if ! command -v docker >/dev/null 2>&1; then
echo "error: Docker is required for the musl build" >&2
exit 1
fi
if [[ ! -f "${PROJECT_DIR}/Cargo.toml" ]]; then
echo "error: Cargo.toml not found in ${PROJECT_DIR}" >&2
exit 1
fi
if [[ ! -f "${DEPENDENCY_DIR}/core/Cargo.toml" || ! -f "${DEPENDENCY_DIR}/nips/Cargo.toml" ]]; then
echo "error: invalid nostr_core_lib_rust checkout: ${DEPENDENCY_DIR}" >&2
echo "expected core/Cargo.toml and nips/Cargo.toml" >&2
echo "hint: clone the complete sibling checkout or set SIGNER_NOSTR_CORE_DIR" >&2
exit 1
fi
mkdir -p "${OUTPUT_DIR}"
rm -f "${OUTPUT_DIR}/signer" \
"${OUTPUT_DIR}/signer_client"
CONTAINER_NAME="signer-musl-build-$$-${RANDOM}"
cleanup() {
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
printf '[INFO] Building musl image %s\n' "${IMAGE_NAME}"
if [[ "${DEPENDENCY_DIR}" != "${PARENT_DIR}/nostr_core_lib_rust" ]]; then
echo "error: SIGNER_NOSTR_CORE_DIR must point to the sibling ../nostr_core_lib_rust directory when using Docker" >&2
exit 1
fi
docker build \
--file "${PROJECT_DIR}/Dockerfile.musl" \
--tag "${IMAGE_NAME}" \
"${PARENT_DIR}"
printf '[INFO] Extracting portable binaries\n'
docker create --name "${CONTAINER_NAME}" "${IMAGE_NAME}" >/dev/null
docker cp "${CONTAINER_NAME}:/workspace/signer/target/x86_64-unknown-linux-musl/release/signer" \
"${OUTPUT_DIR}/signer"
docker cp "${CONTAINER_NAME}:/workspace/signer/target/x86_64-unknown-linux-musl/release/signer-client" \
"${OUTPUT_DIR}/signer_client"
chmod 0755 "${OUTPUT_DIR}/signer" \
"${OUTPUT_DIR}/signer_client"
for binary in \
"${OUTPUT_DIR}/signer" \
"${OUTPUT_DIR}/signer_client"; do
file "${binary}"
if ! file "${binary}" | grep -Eq 'ELF 64-bit LSB (pie )?executable, x86-64'; then
echo "error: ${binary} is not an x86_64 ELF executable" >&2
exit 1
fi
if ldd "${binary}" 2>&1 | grep -Eqi 'not a dynamic executable|statically linked'; then
:
else
echo "error: ${binary} appears to be dynamically linked" >&2
ldd "${binary}" 2>&1 || true
exit 1
fi
"${binary}" --version
done
printf '[SUCCESS] Portable binaries written to %s\n' "${OUTPUT_DIR}"
+143
View File
@@ -0,0 +1,143 @@
#!/bin/bash
set -e
# signer (Rust) — Local Deploy Script
#
# Builds binaries and installs them to /usr/local/bin/.
#
# USAGE:
# ./deploy_local.sh # native release build + install
# ./deploy_local.sh --musl # portable static musl release + install
# ./deploy_local.sh --debug # native debug build + install
# ./deploy_local.sh -h, --help
#
# Installs:
# /usr/local/bin/signer (the signing daemon)
# /usr/local/bin/signer-client (the CLI client)
# 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; }
PROFILE="release"
CARGO_FLAG="--release"
TARGET_DIR="target/release"
BUILD_KIND="native"
show_usage() {
echo "signer (Rust) Local Deploy Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS]"
echo ""
echo "OPTIONS:"
echo " --musl Build static x86_64 musl binaries in Docker"
echo " --debug Build native debug profile instead of release"
echo " -h, --help Show this help message"
echo ""
echo "Installs to /usr/local/bin/:"
echo " signer (the signing daemon)"
echo " signer-client (the CLI client)"
}
while [[ $# -gt 0 ]]; do
case $1 in
--musl)
BUILD_KIND="musl"
PROFILE="release"
CARGO_FLAG=""
TARGET_DIR="dist"
shift
;;
--debug)
if [[ "$BUILD_KIND" == "musl" ]]; then
print_error "--musl currently supports release builds only"
exit 1
fi
PROFILE="debug"
CARGO_FLAG=""
TARGET_DIR="target/debug"
shift
;;
-h|--help)
show_usage
exit 0
;;
*)
print_error "Unknown option: $1"
show_usage
exit 1
;;
esac
done
# Check we're in the project root (Cargo.toml present)
if [[ ! -f "Cargo.toml" ]]; then
print_error "Cargo.toml not found. Run this script from the project root."
exit 1
fi
# Check the binaries we expect are declared
if ! grep -q 'name = "signer"' Cargo.toml || ! grep -q 'name = "signer-client"' Cargo.toml; then
print_error "Expected binaries 'signer' and 'signer-client' not found in Cargo.toml"
exit 1
fi
# Build
if [[ "$BUILD_KIND" == "musl" ]]; then
print_status "Building static musl release binaries in Docker..."
./build_musl.sh
SIGNER_BIN="${TARGET_DIR}/signer-linux-x86_64-musl"
CLIENT_BIN="${TARGET_DIR}/signer-client-linux-x86_64-musl"
else
print_status "Building ${PROFILE} binaries (cargo build ${CARGO_FLAG})..."
cargo build ${CARGO_FLAG} 2>&1 | tail -5 || {
print_error "Build failed"
exit 1
}
SIGNER_BIN="${TARGET_DIR}/signer"
CLIENT_BIN="${TARGET_DIR}/signer-client"
fi
for bin in "$SIGNER_BIN" "$CLIENT_BIN"; do
if [[ ! -f "$bin" ]]; then
print_error "Built binary not found: $bin"
exit 1
fi
done
print_success "Binaries built: $SIGNER_BIN, $CLIENT_BIN"
# Install to /usr/local/bin
DEST_DIR="/usr/local/bin"
if [[ ! -d "$DEST_DIR" ]]; then
print_status "Creating $DEST_DIR..."
sudo mkdir -p "$DEST_DIR"
fi
install_binary() {
local src="$1"
local name="$2"
print_status "Installing $name to $DEST_DIR/..."
sudo install -m 0755 "$src" "$DEST_DIR/$name"
print_success "Installed: $DEST_DIR/$name"
}
install_binary "$SIGNER_BIN" "signer"
install_binary "$CLIENT_BIN" "signer-client"
# Verify
print_status "Verification:"
"$DEST_DIR/signer" --version || true
"$DEST_DIR/signer-client" --version || true
print_success "Local deploy completed (${PROFILE} ${BUILD_KIND} profile)"
+27 -8
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
# nsigner (Rust) — Increment and Push Script
# signer (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
@@ -34,7 +34,7 @@ RELEASE_MODE=false
VERSION_INCREMENT_TYPE="patch"
show_usage() {
echo "nsigner (Rust) Increment and Push Script"
echo "signer (Rust) Increment and Push Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS] \"commit message\""
@@ -169,18 +169,24 @@ verify_binary_version() {
}
build_release_binary() {
print_status "Building release binary (cargo build --release)..."
cargo build --release 2>&1 | tail -5 || return 1
print_status "Building static musl release binaries..."
./build_musl.sh || return 1
local bin_path="target/release/nsigner"
local bin_path="dist/signer"
verify_binary_version "$bin_path" "$NEW_VERSION" || return 1
print_success "Release binary built: $bin_path"
local client_path="dist/signer_client"
if [[ -f "$client_path" ]]; then
verify_binary_version "$client_path" "$NEW_VERSION" || return 1
print_success "Portable client binary built: $client_path"
fi
print_success "Portable signer binary built: $bin_path"
return 0
}
create_source_tarball() {
local tarball_name="nsigner-${NEW_VERSION#v}.tar.gz"
local tarball_name="signer-${NEW_VERSION#v}.tar.gz"
if tar -czf "$tarball_name" \
--exclude='target/*' \
@@ -279,7 +285,8 @@ main() {
git_commit_and_push
local binary_path="target/release/nsigner"
local binary_path="dist/signer"
local client_path="dist/signer_client"
local tarball_path=""
tarball_path=$(create_source_tarball || true)
@@ -288,6 +295,18 @@ main() {
if [[ -n "$release_id" ]]; then
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
# Also upload the signer-client binary if it exists
if [[ -f "$client_path" ]]; then
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"
print_status "Uploading signer-client..."
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$client_path;filename=signer_client" \
-F "name=signer_client" > /dev/null
fi
fi
print_success "Release flow completed: $NEW_VERSION"
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env bash
set -euo pipefail
# User-only installer for Qubes AppVM persistence model.
# Nothing is written to /usr, /etc, or other root-owned paths.
#
# Installs into $HOME:
# - signer -> ~/.local/bin/signer
# - signer-client -> ~/.local/bin/signer-client
# - startup helper -> ~/start_signer.sh
#
# Usage:
# bash install_signer.sh
# bash install_signer.sh --help
#
# Optional env vars:
# SIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
# SIGNER_GITEA_TOKEN=<token> # if signer release assets are private
# SIGNER_BINARY_URL=<direct url to signer binary>
# SIGNER_CLIENT_BINARY_URL=<direct url to signer-client binary>
# SIGNER_BINARY_ASSET=<asset name> # optional release asset override
# SIGNER_CLIENT_BINARY_ASSET=<asset name> # optional release asset override
SIGNER_VERSION="${SIGNER_VERSION:-}"
SIGNER_BINARY_ASSET="${SIGNER_BINARY_ASSET:-signer}"
SIGNER_CLIENT_BINARY_ASSET="${SIGNER_CLIENT_BINARY_ASSET:-signer_client}"
PREFIX_BIN="${HOME}/.local/bin"
log() { printf "\033[1;34m[INFO]\033[0m %s\n" "$*"; }
warn() { printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
err() { printf "\033[1;31m[ERR ]\033[0m %s\n" "$*"; }
show_help() {
cat <<EOF
Usage: bash install_signer.sh [options]
User-only install (Qubes AppVM friendly):
- signer ${SIGNER_VERSION:-(latest)}
- signer-client ${SIGNER_VERSION:-(latest)}
- signer startup helper script
Options:
-h, --help Show this help and exit
Optional env vars:
SIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
SIGNER_GITEA_TOKEN=<token> # required if signer release assets are private
SIGNER_BINARY_URL=<direct url to signer binary>
SIGNER_CLIENT_BINARY_URL=<direct url to signer-client binary>
SIGNER_BINARY_ASSET=<asset name> # defaults to signer-linux-x86_64-musl
SIGNER_CLIENT_BINARY_ASSET=<asset name> # defaults to signer-client-linux-x86_64-musl
Install paths:
~/.local/bin/signer
~/.local/bin/signer-client
~/start_signer.sh
EOF
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
err "Missing command: $1"
exit 1
}
}
install_runtime_deps() {
if command -v apt-get >/dev/null 2>&1; then
log "Installing runtime dependencies via apt"
sudo apt-get update
sudo apt-get install -y ca-certificates curl jq
elif command -v dnf >/dev/null 2>&1; then
log "Installing runtime dependencies via dnf"
sudo dnf install -y ca-certificates curl jq
else
err "Unsupported distro: need apt-get or dnf to install runtime dependencies"
exit 1
fi
}
prepare_dirs() {
mkdir -p "${PREFIX_BIN}"
}
resolve_signer_version() {
local headers=()
local latest_tag=""
if [[ -n "${SIGNER_VERSION}" ]]; then
return 0
fi
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
headers=(-H "Authorization: token ${SIGNER_GITEA_TOKEN}")
fi
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/signer/releases" \
| jq -r '.[0].tag_name // empty' || true)"
if [[ -z "${latest_tag}" ]]; then
err "Could not resolve latest signer release tag from API."
err "Set SIGNER_VERSION explicitly (e.g. SIGNER_VERSION=v0.0.14)."
exit 1
fi
SIGNER_VERSION="${latest_tag}"
}
# Resolve a release asset URL by asset name suffix.
# $1 = asset name to match exactly (e.g. "signer-linux-x86_64-musl")
download_signer_asset_url() {
local asset_name="$1"
local headers=()
local api_tag_url="https://git.laantungir.net/api/v1/repos/laantungir/signer/releases/tags/${SIGNER_VERSION}"
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
headers=(-H "Authorization: token ${SIGNER_GITEA_TOKEN}")
fi
curl -fsSL "${headers[@]}" "${api_tag_url}" \
| jq -r '.assets[]?.browser_download_url // empty' \
| grep -E "/${asset_name}$" \
| head -n1 || true
}
verify_installed_version() {
local expected="$1"
local bin_path="$2"
local got_line=""
local got_ver=""
if [[ ! -x "${bin_path}" ]]; then
err "Installed binary missing: ${bin_path}"
exit 1
fi
got_line="$("${bin_path}" --version 2>/dev/null || true)"
got_ver="$(printf '%s\n' "${got_line}" | awk '{print $2}')"
if [[ -z "${got_ver}" ]]; then
err "Could not determine installed signer version from: ${got_line}"
exit 1
fi
if [[ "${got_ver}" != "${expected}" ]]; then
err "Downloaded binary version mismatch: expected ${expected}, got ${got_ver}"
err "Release asset appears stale or mislabeled."
err "Use SIGNER_BINARY_URL to pin a known-good binary, or wait for a rebuilt release artifact."
exit 1
fi
}
# Download a single binary asset.
# $1 = asset name (for URL resolution fallback)
# $2 = override URL env var name (e.g. SIGNER_BINARY_URL)
# $3 = output path
download_binary() {
local asset_name="$1"
local override_env="$2"
local out_path="$3"
local asset_url=""
# Resolve override from env var
eval "asset_url=\"\${${override_env}:-}\""
if [[ -z "${asset_url}" ]]; then
asset_url="$(download_signer_asset_url "${asset_name}")"
fi
# Older releases used unqualified asset names. Keep that fallback while
# preferring explicit portable musl assets for new releases.
if [[ -z "${asset_url}" && "${asset_name}" == *-linux-x86_64-musl ]]; then
asset_url="$(download_signer_asset_url "${asset_name%-linux-x86_64-musl}")"
fi
if [[ -z "${asset_url}" ]]; then
err "Could not find downloadable ${asset_name} release binary for ${SIGNER_VERSION}."
err "Provide ${override_env} or SIGNER_GITEA_TOKEN so the release asset can be resolved."
exit 1
fi
log "Using ${asset_name} binary URL: ${asset_url}"
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
curl -fL -H "Authorization: token ${SIGNER_GITEA_TOKEN}" -o "${out_path}" "${asset_url}"
else
curl -fL -o "${out_path}" "${asset_url}"
fi
chmod 0755 "${out_path}"
}
install_signer() {
local release_page=""
resolve_signer_version
release_page="https://git.laantungir.net/laantungir/signer/releases/tag/${SIGNER_VERSION}"
log "Installing signer ${SIGNER_VERSION}"
log "Release page: ${release_page}"
# Prefer the statically linked musl artifact; fall back to legacy names.
download_binary "${SIGNER_BINARY_ASSET}" "SIGNER_BINARY_URL" "${PREFIX_BIN}/signer"
verify_installed_version "${SIGNER_VERSION}" "${PREFIX_BIN}/signer"
log "Installed ${PREFIX_BIN}/signer from release binary"
# Install the signer-client CLI binary (best-effort: older releases may not have it)
if [[ -z "${SIGNER_CLIENT_BINARY_URL:-}" ]] && ! download_signer_asset_url "${SIGNER_CLIENT_BINARY_ASSET}" >/dev/null 2>&1 && ! download_signer_asset_url "signer-client" >/dev/null 2>&1; then
warn "No signer-client asset found for ${SIGNER_VERSION}; skipping client install."
else
download_binary "${SIGNER_CLIENT_BINARY_ASSET}" "SIGNER_CLIENT_BINARY_URL" "${PREFIX_BIN}/signer-client"
log "Installed ${PREFIX_BIN}/signer-client from release binary"
fi
}
write_signer_start_script() {
local script_path="${HOME}/start_signer.sh"
cat >"${script_path}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
LISTEN_TARGET="${SIGNER_LISTEN_TARGET:-tcp:[::]:8080}"
echo "=== signer startup ==="
echo "listen target: ${LISTEN_TARGET}"
# Optional: print current FIPS identity info if fipsctl is available.
if command -v fipsctl >/dev/null 2>&1; then
if fipsctl show status >/dev/null 2>&1; then
STATUS_JSON="$(fipsctl show status)"
elif sudo -n fipsctl show status >/dev/null 2>&1; then
STATUS_JSON="$(sudo -n fipsctl show status)"
else
STATUS_JSON=""
fi
if [[ -n "${STATUS_JSON}" ]]; then
FIPS_IPV6="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"ipv6_addr": "\([^"]*\)".*/\1/p')"
FIPS_NPUB="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"npub": "\([^"]*\)".*/\1/p')"
LISTEN_PORT="$(printf '%s\n' "${LISTEN_TARGET}" | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p')"
[[ -n "${FIPS_IPV6}" ]] && echo "fips ipv6: ${FIPS_IPV6}"
[[ -n "${FIPS_NPUB}" ]] && echo "fips npub: ${FIPS_NPUB}"
if [[ -n "${FIPS_NPUB}" && -n "${LISTEN_PORT}" ]]; then
echo "fips address: http://${FIPS_NPUB}.fips:${LISTEN_PORT}"
fi
else
echo "fips status: unavailable (run as user in fips group or with sudo)"
fi
fi
echo
echo "Starting signer..."
echo "On first remote request, approve in prompt with [y] or [a]."
exec "$HOME/.local/bin/signer" --listen "${LISTEN_TARGET}"
EOF
chmod 0755 "${script_path}"
log "Wrote ${script_path}"
}
post_checks() {
export PATH="${PREFIX_BIN}:${PATH}"
log "Running post-install checks"
require_cmd signer
signer --version || true
if [[ -x "${PREFIX_BIN}/signer-client" ]]; then
signer-client --version || true
fi
log "User binaries installed in: ${PREFIX_BIN}"
log "If needed, add to shell PATH: export PATH=\"${PREFIX_BIN}:\$PATH\""
}
main() {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
show_help
exit 0
fi
if [[ $# -gt 0 ]]; then
err "Unknown option: $1"
show_help
exit 1
fi
install_runtime_deps
prepare_dirs
install_signer
write_signer_start_script
post_checks
log "Completed user-only install of signer"
log "Start signer with: ~/start_signer.sh"
}
main "$@"
+259
View File
@@ -0,0 +1,259 @@
# Menu Gap Analysis: C `main.c` vs Rust `signer`
**Source of truth:** the C code in [`src/main.c`](../n_signer/src/main.c:1), NOT
[`documents/signer_menus.md`](../n_signer/documents/signer_menus.md:1) (which is
stale — e.g. it claims the wizard prompts `Require interactive approval? [Y/n]`, but
the actual C code hardcodes `requires_approval = 0` and never prompts).
This document compares every interactive menu/screen in the C implementation
against the current Rust port in [`src/tui.rs`](../signer/src/tui.rs:1) and
[`src/main.rs`](../signer/src/main.rs:1).
Legend: ✅ matches, ⚠️ partial, ❌ missing/divergent.
---
## Menu 1 — Unlock / Mnemonic source
**C** ([`prompt_load_mnemonic_tui`](../n_signer/src/main.c:2789)):
- Frame: `n_signer v<ver> > Unlock`, content screen title `"Load mnemonic"`
- Prompt: `Mnemonic source: [E]nter existing or [G]enerate new`
- `Default is E; you can also paste full mnemonic here.`
- `q`/`Q`/`x`/`X` (single char) → exit with error
- **Paste detection**: if input contains a space and doesn't start with `g`/`G`,
treat as mnemonic and validate directly
- `g`/`G` → generate 12-word, print numbered `"%2d. %s"`, warning
`Generated mnemonic (WRITE THIS DOWN - it will not be shown again):`,
then `Press Enter after writing down your mnemonic to continue.`
- Otherwise → second screen `> Unlock` / `"Enter mnemonic"`,
`Enter mnemonic (12/15/18/21/24 words):`, `q`/`x` to exit
- **10 invalid attempts** then `Too many invalid mnemonic attempts (10). Exiting.`
- Success: `Seed phrase is valid and accepted.`
**Rust** ([`load_mnemonic_tui`](../signer/src/main.rs:549)):
- Frame: `signer v<ver> > Unlock`, title `"Enter mnemonic phrase"`
- Prompt: `Enter your BIP-39 mnemonic phrase, or 'g' to generate a new one.`
- `g`/`G` → generate, numbered, warning ✅
- Otherwise → load as mnemonic (paste works implicitly) ⚠️
- No `q`/`x` exit ❌
- No 10-attempt limit ❌
- No second "Enter mnemonic" screen ❌
- No explicit paste-detection branch (works by accident) ⚠️
| Feature | C | Rust | Status |
|---|---|---|---|
| `[E]`/`[G]` prompt text | yes | different wording | ⚠️ |
| `q`/`x` to exit | yes | no | ❌ |
| Paste detection | yes | implicit | ⚠️ |
| 10-attempt limit | yes | no | ❌ |
| Generate + warning | yes | yes | ✅ |
| Second "Enter mnemonic" screen | yes | no | ❌ |
---
## Menu 2 — Define a role / Role preset menu
**C** ([`prompt_named_path_roles`](../n_signer/src/main.c:2031)):
- `for(;;)` loop, content screen title
`"Define a role — bind a role name to a derivation path template"`
- **10 presets** (110): 1=Standard Nostr, 2=Nostr range, 3=Nostr agent,
4=SSH, 5=Age, 6=ML-DSA-65, 7=SLH-DSA-128s, 8=ML-KEM-768,
**9=OTP role**, **10=Custom path**
- `Select [1]:` (default 1 if empty)
- Role name prompt: ` Role name [%s]: ` with editable line + default
- Duplicate → ` Role '%s' already exists, skipping.` + continue
- **Choice 9 (OTP)**: prompts `OTP pad directory (e.g. /media/usb0):` and
`OTP pad name (e.g. mypad):`, binds pad immediately, registers role with
`curve_str="otp"`, `requires_approval=0`
- **Choice 10 (Custom)**: curve menu
```
Curve:
1) secp256k1 (Nostr, Bitcoin)
2) ed25519 (SSH)
3) x25519 (key agreement, Age)
4) ml-dsa-65 (post-quantum signatures)
5) slh-dsa-128s (post-quantum signatures)
6) ml-kem-768 (post-quantum KEM)
Select [1]:
```
then ` Path template [%s]: ` editable
- **`requires_approval` is HARDCODED to 0** — no prompt (doc is wrong)
- Confirmation: ` Role '%s' registered: curve=%s path=%s (fixed, requires_approval=0).`
or `(range %d-%d, requires_approval=0).`
- Loop: `Define another role? [y/N]:` → `y` continues, else break
- Mandatory: `if (roles_created == 0) { "At least one role must be defined." return -1; }`
- **No auto-register of default `main`** — user must pick preset 1
**Rust** ([`role_wizard`](../signer/src/tui.rs:534)):
- **Auto-registers default `main` first** before showing menu ❌
- 9 presets (19) with 9=Custom, plus `0`=Done ❌ (no OTP preset)
- No OTP pad prompts ❌
- Custom: prompts path only, **curve auto-detected from path** (no curve menu) ⚠️
- No `requires_approval` prompt (matches C's hardcoded 0) ✅
- No confirmation line ❌
- Loop via `0`/Done instead of `Define another role? [y/N]` ⚠️
- Mandatory ≥1 role satisfied by auto-register (divergent mechanism) ⚠️
| Feature | C | Rust | Status |
|---|---|---|---|
| 10 presets (incl. OTP) | yes | 9, no OTP | ❌ |
| Auto-register default main | no | yes | ❌ |
| Curve menu (custom) | yes | auto-detect | ⚠️ |
| OTP pad dir/name prompts | yes | no | ❌ |
| requires_approval prompt | no (hardcoded 0) | no | ✅ |
| Confirmation line | yes | no | ❌ |
| Loop `y/N` | yes | `0`/Done | ⚠️ |
| Mandatory ≥1 role | yes | yes (via auto-register) | ⚠️ |
---
## Menu 3 — Transport selection
**C** ([`prompt_transport_selection`](../n_signer/src/main.c:3075)):
- Content screen title `"Transport — how should other programs reach this signer?"`
- `Select one or more (type a number to toggle, 'a' for all, Enter to confirm):`
- **Multi-toggle checkboxes** `[x]`/`[ ]`:
1. Local Unix socket
2. **Qubes qrexec bridge**
3. FIPS/TCP listener
4. HTTP listener
- `[a] select all Enter = confirm`
- `1`-`4` toggles bits, `a` selects all, Enter confirms (≥1 required)
- Default: Unix socket only
**Rust** ([`transport_selection`](../signer/src/tui.rs:742)):
- Title `"Transport selection"`
- **Single-select** (pick one of 4): Unix, TCP, HTTP, Unix+HTTP
- No Qubes qrexec ❌
- No toggle/checkbox UI ❌
- No `a` for all ❌
| Feature | C | Rust | Status |
|---|---|---|---|
| Multi-select toggle | yes | no (single) | ❌ |
| Qubes qrexec option | yes | no | ❌ |
| `a` select all | yes | no | ❌ |
| Enter to confirm | yes | no | ❌ |
| Default Unix | yes | yes | ✅ |
---
## Menu 4 — Main status display
**C** ([`render_status`](../n_signer/src/main.c:1898), [`g_main_menu_items`](../n_signer/src/main.c:895)):
- Frame `n_signer v<ver> > Main Menu`
- `^*Roles^:` + table (Role/Purpose/Curve/Derivation path) or `(none)`
- `^*Activity (latest first)^:` + log entries or `(none)`
- Status line: `session=<locked|unlocked> (<N> words) signer=<name> derived=<count>`
- Menu: `^_l^: lock/reunlock`, `^_r^: refresh`, `^_d^: display connections`, `^_q^:/x quit`
**Rust** ([`render_status`](../signer/src/tui.rs:332), [`MAIN_MENU_ITEMS`](../signer/src/tui.rs:266)):
| Feature | C | Rust | Status |
|---|---|---|---|
| Top frame + breadcrumb | yes | yes | ✅ |
| Roles table (4 cols) | yes | yes | ✅ |
| Activity log | yes | yes | ✅ |
| Status line | yes | yes | ✅ |
| Menu items l/r/d/q | yes | yes | ✅ |
---
## Menu 5 — Approval prompt
**C** ([`tui_approval_cb`](../n_signer/src/main.c:1963)):
- Frame `n_signer v<ver> > Approval`, title `"Approval required"`
- `caller: <id>`
- **`fips peer: <npub> (<name>)`** if `req->fips_peer_npub` present
- `method:`, `role:`, `purpose:`
- **`** NEW IDENTITY — will be derived if approved **`** if `pending_derivation`
- `^_y^: allow once`, `^_n^: deny`,
`^_e^: allow this caller+role+verb for session`,
`^_a^: allow this caller+role for session (all verbs)`
- Reads first char, `a`/`e`/`y` → respective policy, else DENY
**Rust** ([`approval_prompt`](../signer/src/tui.rs:486)):
| Feature | C | Rust | Status |
|---|---|---|---|
| caller/method/role/purpose | yes | yes | ✅ |
| fips peer field | yes | no | ❌ |
| NEW IDENTITY line | yes | no | ❌ |
| y/n/e/a options | yes | yes | ✅ |
---
## Menu 6 — Display connections
**C** ([`render_connections`](../n_signer/src/main.c:1804)):
- Full screen clear, top frame
- Iterates `connection_info` entries built from **actual active transports**
([main.c:4086-4190](../n_signer/src/main.c:4086)): Unix, FIPS/TCP, HTTP, Qrexec, Stdio
- Each block: `^*<title>^:`, connection string, ` Example:` + example, optional extra
- OTP pad status line if bound
- Status line + `Press any key to return`
**Rust** ([`render_connections`](../signer/src/tui.rs:415)):
- **Hardcoded** Unix + HTTP blocks regardless of active transports ❌
- No Qubes qrexec, no FIPS/TCP, no Stdio blocks ❌
- No OTP pad status line ❌
- "Press any key to return" ✅
| Feature | C | Rust | Status |
|---|---|---|---|
| Reflects active transports | yes | hardcoded | ❌ |
| Qubes qrexec block | yes | no | ❌ |
| FIPS/TCP block | yes | no | ❌ |
| OTP pad status line | yes | no | ❌ |
| Example client commands | yes | partial | ⚠️ |
---
## Summary of divergences (from code, not doc)
```mermaid
flowchart TD
M1[Menu 1 Unlock] --> D1[No q/x exit, no 10-try limit, no second screen]
M2[Menu 2 Role wizard] --> D2[Auto-registers main, no OTP preset, no curve menu, no confirm line]
M3[Menu 3 Transport] --> D3[Single-select not multi-toggle, no Qubes]
M4[Menu 4 Status] --> D4[Matches]
M5[Menu 5 Approval] --> D5[No NEW IDENTITY line, no fips peer]
M6[Menu 6 Connections] --> D6[Hardcoded, not transport-aware, no OTP status]
```
### Highest-impact gaps (behavioral divergence from C code)
1. **Menu 2 — auto-registers default `main`** before the wizard. C requires the
user to pick preset 1 themselves; the Rust port silently creates `main` and
then offers to add more. This changes the first-run UX.
2. **Menu 2 — OTP preset (choice 9) missing entirely.** Cannot create OTP roles
interactively in Rust.
3. **Menu 2 — no curve menu for Custom (choice 10).** C shows a 6-option curve
menu; Rust auto-detects from path.
4. **Menu 3 — Qubes qrexec missing** and single-select instead of multi-toggle.
5. **Menu 1 — no `q`/`x` exit, no 10-attempt limit, no second "Enter mnemonic"
screen.**
6. **Menu 5 — missing `fips peer` field and `** NEW IDENTITY **` line.**
7. **Menu 6 — hardcoded blocks** instead of reflecting actual active transports;
no OTP pad status line.
### How to spot differences going forward (code-based, not doc-based)
1. Treat [`src/main.c`](../n_signer/src/main.c:1) as the source of truth. The
functions to compare against are:
- [`prompt_load_mnemonic_tui`](../n_signer/src/main.c:2789) — Menu 1
- [`prompt_named_path_roles`](../n_signer/src/main.c:2031) — Menu 2
- [`prompt_transport_selection`](../n_signer/src/main.c:3075) — Menu 3
- [`render_status`](../n_signer/src/main.c:1898) — Menu 4
- [`tui_approval_cb`](../n_signer/src/main.c:1963) — Menu 5
- [`render_connections`](../n_signer/src/main.c:1804) — Menu 6
2. Keep this file ([`plans/menu_gap_analysis.md`](plans/menu_gap_analysis.md:1))
as the living checklist; tick rows as the Rust port converges.
3. **Recommended automated check**: add an integration test that pipes canned
stdin through each Rust menu and asserts the rendered output contains the
exact prompt strings from the C `printf`/`tui_print` calls above (e.g.
`Mnemonic source: [E]nter existing or [G]enerate new`,
`Define a role:`, ` 9. OTP role (one-time pad encryption)`,
`Select one or more (type a number to toggle, 'a' for all, Enter to confirm):`,
`** NEW IDENTITY — will be derived if approved **`). This catches drift
mechanically without re-reading the C source each time.
+152
View File
@@ -0,0 +1,152 @@
# Static musl portability plan
## Objective
Produce portable x86_64 Linux release binaries that do not depend on the target machine's glibc version. The primary release artifacts will be statically linked against musl and will remain compatible with the project's Linux, Qubes, Unix-socket, qrexec, TCP/HTTP, terminal, and secure-memory requirements.
## Current findings
- The development host is x86_64 Ubuntu 22.04 with glibc 2.35.
- Rust 1.80.1 and Cargo 1.80.1 are installed, but `rustup` is unavailable.
- Docker is available, so the build can be isolated from the host toolchain and libc.
- The project directly uses conventional Linux APIs through `libc`: `mlock`, `munlock`, `AF_UNIX`, `SO_PEERCRED`, `getuid`, `close`, `listen`, and `localtime_r`.
- Cryptography is implemented through Rust crates; no mandatory OpenSSL dependency was found in this repository.
- The sibling `nostr_core_lib_rust` checkout is a path dependency declared by `Cargo.toml` and must be present in the container build context.
- The vendored `ratatui` submodule and its crossterm backend must compile for the musl target.
- Existing release scripts build directly with the host Cargo installation and therefore do not guarantee libc portability.
## Compatibility policy
- Primary portable target: `x86_64-unknown-linux-musl`.
- Release artifacts are intended for x86_64 Linux systems regardless of the installed glibc version, subject to Linux kernel, CPU, terminal, qrexec, and resource-limit requirements.
- Do not use `-C target-cpu=native`; build for a conservative x86_64 baseline.
- Keep the normal host-native build for development and debugging.
- Treat `RLIMIT_MEMLOCK` separately from libc portability; static linking does not remove the need for appropriate memory-lock limits.
## Implementation phases
### 1. Add a reproducible musl build image
Create a pinned Docker build definition, preferably using a stable musl-based Rust image or a pinned Alpine image with an explicitly installed Rust toolchain.
The image must provide:
- Rust and Cargo versions compatible with the project's `Cargo.toml` and lockfile.
- The `x86_64-unknown-linux-musl` target.
- A C compiler/linker suitable for musl.
- Required build utilities and certificate configuration.
- No dependency on the host's Rust installation or glibc-linked build output.
Ensure the container can access both the project and the sibling `nostr_core_lib_rust` path dependency. The build must initialize or use the existing `ratatui` submodule consistently with normal project setup.
### 2. Add a portable build entry point
Create `build_musl.sh` with explicit behavior:
1. Validate that Docker is available.
2. Validate that the project root and required sibling path dependency exist.
3. Build both `signer` and `signer-client` for `x86_64-unknown-linux-musl` in release mode.
4. Use a dedicated output directory such as `dist/musl-x86_64`.
5. Copy the binaries using explicit names:
- `signer-linux-x86_64-musl`
- `signer-client-linux-x86_64-musl`
6. Avoid copying host-native binaries into the portable output.
7. Run binary validation and version checks before reporting success.
8. Return a nonzero status for missing dependencies, failed builds, invalid ELF output, dynamic linkage, or version mismatch.
Keep the script usable from the repository root and make its Docker invocation safe for ordinary user development.
### 3. Validate static linkage and runtime behavior
Add validation to the build flow using tools available in the container:
- `file` must identify x86_64 ELF executables.
- `readelf` must show the expected musl/static characteristics and no glibc loader requirement.
- `ldd` must not identify unresolved dynamic runtime dependencies.
- `signer --version` and `signer-client --version` must report the Cargo package version.
- Run `cargo test --target x86_64-unknown-linux-musl` where the test environment supports it.
Perform smoke checks for:
- stdio framing;
- Unix abstract socket bind/connect and peer identity;
- TCP and HTTP startup;
- qrexec subprocess integration where the host provides qrexec;
- mnemonic input and key derivation;
- representative signing and verification operations;
- TUI initialization in a terminal;
- `mlock` success or the documented unlocked-memory fallback behavior.
If a dependency cannot support musl, record the failure and determine whether it is optional, can be feature-gated, or requires retaining a glibc artifact.
### 4. Integrate release automation
Update `increment_and_push.sh` so release mode invokes the portable musl builder rather than the host-native `cargo build --release` path.
Preserve:
- version incrementing;
- source version updates;
- binary version verification;
- tagging and pushing;
- Gitea release creation;
- source tarball generation.
Extend asset upload handling to upload both musl binaries with their platform-specific names. Do not silently publish a host-linked binary under an ambiguous name.
Decide whether the existing unqualified assets remain as compatibility aliases. The safer default is to publish explicit musl names and retain legacy fallback handling only for older releases.
### 5. Update local deployment
Extend `deploy_local.sh` with an explicit portable option, such as `--musl`.
Suggested behavior:
- default development/debug builds remain native;
- `--musl` invokes the Docker build and installs the resulting portable binaries;
- release deployment should clearly report whether the installed binary is native or musl;
- installation continues to use `/usr/local/bin/` only when the user has the required privileges.
Add checks so the script does not mistake a musl artifact for a native build or install an absent/stale binary.
### 6. Update the installer
Update `install_signer.sh` to prefer the explicit musl release assets:
- `signer-linux-x86_64-musl`
- `signer-client-linux-x86_64-musl`
Retain support for existing older releases whose assets are named simply `signer` or `signer-client`. Add an override for users who need a different asset URL or a native glibc build.
Continue verifying the installed program version, and add target/artifact validation where practical so a mislabeled asset is rejected.
### 7. Update documentation
Update the build and platform sections of `README.md` to explain:
- native builds are for local development;
- portable release builds use static musl;
- the portable target is x86_64 Linux;
- the artifact names and output directory;
- how to verify static linkage with `file`, `readelf`, and `ldd`;
- static musl removes the glibc runtime dependency but not Linux kernel, CPU, terminal, qrexec, or `RLIMIT_MEMLOCK` requirements;
- Qubes users should use the musl release artifact unless a native glibc build is specifically required.
Update the project layout section with the new build definition and script.
## Acceptance criteria
- A clean Docker invocation builds both release binaries without using the host Rust compiler or host glibc.
- The resulting binaries are x86_64 and statically linked against musl.
- Neither binary requires the glibc dynamic loader or a minimum GLIBC symbol version.
- Both binaries pass version checks and the relevant test suite.
- Representative Unix, stdio, TCP/HTTP, crypto, TUI, and secure-memory paths are validated.
- Release automation uploads unambiguous musl artifacts.
- The installer can select the musl artifacts and remains compatible with older release naming.
- Documentation explains how to build, verify, install, and deploy the portable binaries.
## Deferred options
- Publishing a glibc-2.17-compatible artifact can be added later if a target integration requires glibc behavior.
- Additional architectures can be added later, but must have separate build images, artifact names, and validation.
- Musl should not be made the only local development target until the smoke tests demonstrate that all supported transports and terminal behavior are equivalent for the project's deployment environments.
+3 -3
View File
@@ -2,7 +2,7 @@
## 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."
The Rust `signer` 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)
@@ -148,10 +148,10 @@ 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>;
pub fn insert_session_grant(&mut self, caller: &str, verb: &str, role: &str) -> Result<(), SignerError>;
/// Insert a session grant for caller+role (all verbs).
pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), NsignerError>;
pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), SignerError>;
}
```
+4 -4
View File
@@ -101,7 +101,7 @@ graph TB
### 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.
- **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`)
@@ -323,7 +323,7 @@ signer/
│ ├── otp_pad.rs # One-time pad encryption
│ ├── socket_name.rs # Abstract socket naming
│ ├── tui.rs # Terminal UI
│ └── error.rs # NsignerError enum
│ └── error.rs # SignerError enum
├── tests/
│ ├── integration_test.rs
│ ├── algorithm_test.rs
@@ -337,7 +337,7 @@ signer/
```toml
[package]
name = "nsigner"
name = "signer"
version = "0.1.0"
edition = "2021"
@@ -372,7 +372,7 @@ tempfile = "3"
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
6. **Abstract socket names**: `@signer_<word1>_<word2>` format preserved
## Open Questions
+152
View File
@@ -0,0 +1,152 @@
# PQ Seeded Derivation Migration Plan (signer)
## Status
**Implemented** (signer). Companion to the v2 hardened-derivation design in
`nostr_quantum_preparation/plans/v2-hardened-derivation.md`. That project is the
only one with users; it keeps a v1→v2 migration path. signer (and n_signer)
have no users, so we are free to align to the FIPS seeded interface directly.
Implementation notes (divergences from the original proposal, all consistent
with its intent):
- `derive_pq_seed(mnemonic, coin, indices)` was realized as
`derive_pq_seed_from_path(mnemonic, path, seed_len)` — the sibling child is
derived by incrementing the last path level, so callers pass a single path.
- PQ private keys are stored in seed form (ML-DSA-65 32 B, ML-KEM-768 64 B,
SLH-DSA-128s 64 B sk serialization); `CryptoAlg::sizes()` reflects this.
- SLH-DSA-128s uses the SHA2 parameter set (`slh_dsa::Sha2_128s`), matching
the web app's `slh_dsa_sha2_128s`.
- The v2 conformance test (`tests/pq_conformance.rs`) reproduces
`seed-to-pubkeys.v2.json` exactly for all three algorithms.
- The dispatcher's `encapsulate`/`decapsulate` verbs and the sign-verb
private-key truncation bug were fixed as part of this work.
- n_signer (C) still needs the same migration — filed separately there.
## Background
signer is the Rust port of n_signer and inherited two PQ derivation choices:
1. **SHAKE-256 DRBG pipeline** ([`src/pq_drbg.rs`](../src/pq_drbg.rs)): the
BIP-44-derived 32-byte seed feeds a SHAKE-256 DRBG that stands in for
PQClean's `randombytes()` callback during keygen. This was an API artifact
of the C PQClean integration, not a cryptographic choice.
2. **SLIP-0010 derivation for PQ paths**
([`src/pq_crypto.rs:90`](../src/pq_crypto.rs)): `derive_seed_from_mnemonic()`
branches on the path prefix — BIP-32 for `m/44'/1237'`, SLIP-0010 for
everything else (ed25519, x25519, PQ).
The nostr_quantum_preparation web app (the project with actual users) has
standardized v2 on:
- **Per-algorithm coin types** in the unregistered SLIP-44 `102XXX'` range
(102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768 — matching
signer's existing allocations — plus new 102006' ML-DSA-44 and 102007'
Falcon-512).
- **FIPS seeded keygen**: BIP32 child bytes (exact length: 32 B ML-DSA,
48 B SLH-DSA-128s, 64 B ML-KEM) → `keygen(seed)`. No DRBG.
- **BIP32 (not SLIP-0010)** for the PQ paths, via `@scure/bip32` `HDKey`.
## Problem
Two divergences prevent cross-project key parity (same mnemonic + same path →
same PQ keys):
| Divergence | signer today | nostr_quantum_preparation v2 |
|---|---|---|
| Seed expansion | SHAKE-256 DRBG → RNG-fed keygen | exact-length seed → seeded keygen |
| Derivation function for PQ paths | SLIP-0010 | BIP32 |
SLIP-0010 and BIP32 produce different master keys (different HMAC keys) and
different child derivation, so even identical paths yield unrelated seeds.
The DRBG pipeline additionally means signer can never reproduce FIPS-seeded
keys regardless of derivation function.
Neither divergence is a security weakness — both are deterministic expansions
of secret material — but parity matters operationally: a user should be able
to derive the same PQ identity in the web app and on signer hardware from one
mnemonic.
## Solution
Migrate signer's PQ keygen to the FIPS seeded interface and PQ path derivation
to BIP32, keeping ed25519/x25519 on SLIP-0010 (correct for those curves).
### Target pipeline
```
mnemonic → BIP39 seed → BIP32 master → m/44'/<coin>'/0'/0'/<n>' → child bytes
→ concatenate/truncate to algorithm seed length → seeded keygen
```
| Algorithm | Coin type | Path | Seed len | RustCrypto API |
|---|---|---|---|---|
| ML-DSA-65 | `102003'` | `m/44'/102003'/0'/0'/0'` | 32 B | `ml_dsa::SigningKey::from_seed(&[u8; 32])` |
| SLH-DSA-128s | `102004'` | `m/44'/102004'/0'/0'/0'` + `/1'` | 48 B | `slh_dsa` seeded keygen (verify exact API at rc version in use) |
| ML-KEM-768 | `102005'` | `m/44'/102005'/0'/0'/0'` + `/1'` | 64 B | `ml_kem::DecapsulationKey::from_seed(&[u8; 64])` |
| ML-DSA-44 | `102006'` | `m/44'/102006'/0'/0'/0'` | 32 B | future — add with `ml-dsa` crate |
| Falcon-512 | `102007'` | `m/44'/102007'/0'/0'/0'` + `/1'` | 48 B | future — no stable RustCrypto crate; rejection sampling makes cross-library determinism impossible anyway |
48/64-byte seeds come from two hardened children concatenated (first 48 of 64
used where 48 B is required) — matching the web app's construction exactly.
### Falcon caveat
Falcon keygen is rejection-sampling-based with no universally implemented seed
interface. Even with identical seeds, implementations disagree. The web app
pins noble's behavior in test vectors and flags Falcon as per-library in its
NIP proposal; signer should do the same when Falcon support lands, and should
not promise parity for it.
## Changes
### 1. `src/pq_crypto.rs`
- `derive_seed_from_mnemonic()`: route PQ coin types (102003'102007') through
BIP-32 (same branch as `m/44'/1237'`), keeping SLIP-0010 only for
ed25519/x25519 (102001'/102002').
- Add `derive_pq_seed(mnemonic, coin_type, indices) -> Vec<u8>` implementing
the concatenate/truncate-to-length construction.
- Replace DRBG-fed keygen call sites with the seeded APIs above.
### 2. `src/pq_drbg.rs`
- Keep the module (it is a faithful port and may serve future PQClean-style
integrations) but remove it from the PQ keygen path. Mark as not-used-for-
derivation in the module doc.
### 3. `src/alg_cache.rs`, `src/role_table.rs`, `src/tui.rs`, `src/main.rs`
- No path changes needed for 102003'102005' (already correct).
- Add `MlDsa44` (`102006'`) to `CryptoAlg`, path formatting, purpose mapping
(`PqSig`), and TUI presets when the `ml-dsa` crate's ML-DSA-44 variant is
wired in. Falcon (`102007'`) waits on a viable crate.
### 4. Tests
- Cross-implementation conformance: reproduce the web app's
`seed-to-pubkeys.v2.json` vector (same fixed mnemonic) for ML-DSA-65,
SLH-DSA-128s, and ML-KEM-768. This is the acceptance test for parity.
- Regression: DRBG removal does not change ed25519/x25519 derivation.
- Unit: 48/64-byte seed construction matches the two-children concatenation.
### 5. Docs
- `README.md` / `documents/` equivalent: document the seeded pipeline, the
BIP32-for-PQ decision, the coin-type registry (102003'102005' existing,
102006'102007' reserved), and the Falcon caveat.
- Note for n_signer (C): same migration applies; file it there separately.
## What we are explicitly NOT doing
- Not changing secp256k1 NIP-06 derivation (`m/44'/1237'/n'/0/0`, BIP-32).
- Not changing ed25519/x25519 SLIP-0010 derivation (correct for those curves).
- Not preserving DRBG-derived PQ keys (no users; clean break is the point).
- Not implementing Falcon now (no stable crate; determinism caveat).
## Acceptance criteria
1. `cargo test` passes with the seeded pipeline.
2. The web app's v2 vector reproduces exactly for ML-DSA-65, SLH-DSA-128s,
ML-KEM-768 (same mnemonic → same pubkeys in Rust and JS).
3. ed25519/x25519 pubkeys unchanged from pre-migration for the same mnemonic.
+296
View File
@@ -0,0 +1,296 @@
# Plan: Migrate TUI to ratatui
## Goal
Replace the hand-rolled `tui_continuous` + `tui.rs` rendering with
[`ratatui`](https://github.com/ratatui/ratatui) (already added as a git
submodule and Cargo path dependency). The main status screen becomes a
ratatui app with four sections, and the interactive setup menus (mnemonic,
role wizard, transport selection) become ratatui screens too.
## Current state
- [`src/tui_continuous.rs`](../src/tui_continuous.rs:1) — 916 lines. Hand-rolled
port of the C `tui_continuous` library: raw mode, `tui_print` with `^_`/`^*`/`^:`
markup, `render_top_frame`, `render_table`, `render_menu`, `render_content_screen`,
`anchor_prompt`, `get_key`, `poll_key`, SIGWINCH handler.
- [`src/tui.rs`](../src/tui.rs:1) — 1062 lines. App-level: `ActivityLog`,
`render_status`, `render_connections`, `role_wizard`, `transport_selection`,
`read_line_editable`, `read_line_raw`, `poll_key`, `init`/`cleanup`.
- [`src/main.rs`](../src/main.rs:269) — the `ListenMode::Unix` branch runs the
TUI loop: `init()``render_status()` → poll for keys (`l`/`r`/`d`/`q`) →
re-render. Server connections are handled between key polls.
## Target layout — main status screen
Two-column layout: left column has Information (top) and Roles (bottom);
right column has Activity (full height, scrollable). Commands span the
full width at the bottom.
```
┌──────────────────────────────────────────────────────────────────────┐
│ signer v0.0.1 > Main Menu │
├──────────────────────────────────────┬───────────────────────────────┤
│ Information │ Activity (latest first) ▲ │
│ session=unlocked (12 words) │ 2026-08-18 08:00:15 req… │
│ signer=signer01 derived=2 │ 2026-08-18 08:00:01 start │
│ socket=@signer01 transport=unix │ │
│ OTP pad: chksum=abc… offset=128 │ │
├──────────────────────────────────────┤ │
│ Roles │ │
│ Role Purpose Curve │ │
│ ───────────── ─────── ────────── │ │
│ main nostr secp256k1 │ │
│ nostr_range nostr secp256k1 │ │
│ │ ▼ │
├──────────────────────────────────────┴───────────────────────────────┤
│ l lock/reunlock r refresh d display connections q/x quit │
└──────────────────────────────────────────────────────────────────────┘
```
### Layout structure (ratatui `Layout`)
```
Vertical:
[Top frame: title bar] — 3 lines
[Body: horizontal split] — flex
Left column (50%):
[Section 0: Information] — auto height
[Section 1: Roles] — flex
Right column (50%):
[Section 2: Activity] — flex, scrollable
[Section 3: Commands] — 3 lines
```
### Section 0 — Information
A `Block` with title "Information" containing a `Paragraph` of key-value lines:
| Line | Source |
|------|--------|
| `session=<locked\|unlocked> (<N> words)` | `mnemonic.is_loaded()`, `mnemonic.word_count()` |
| `signer=<socket_name>` | `socket_name` |
| `derived=<count>` | `derived_count` |
| `socket=@<socket_name>` | `socket_name` |
| `transport=<unix\|tcp\|http\|qrexec>` | `listen_mode` |
| `OTP pad: chksum=… offset=N/M` | `otp_pad::global_status()` (only if bound) |
### Section 1 — Roles
A `Block` with title "Roles" containing a `Table` with 4 columns
(`Role`, `Purpose`, `Curve`, `Derivation path`) and one row per
`role_table.entries[i]`. The "Derivation path" cell uses the same
display logic as the C `role_table_view_get_cell` (fixed path vs
`%d` with range/set).
### Section 2 — Activity (right column, scrollable)
A `Block` with title "Activity (latest first)" containing a `List` of
`ActivityLog::entries()` (newest first, up to 16). Each entry is a
`ListItem` with the timestamped message. The list is scrollable — when
entries exceed the visible height, a scrollbar is shown (ratatui
`List::scrollbar` or a `Scrollbar` widget overlay). The `App` struct
tracks an `activity_scroll` offset; `Up`/`Down` arrow keys (or `k`/`j`)
scroll the activity list when it has focus.
### Section 3 — Commands
A `Block` with title "Commands" (or a footer bar) containing a row of
keybindings: `l lock/reunlock`, `r refresh`, `d display connections`,
`q/x quit`. Rendered as a `Paragraph` with styled spans (the key letter
underlined/bold, the rest normal) — replaces the `^_` markup approach.
## Architecture
```mermaid
flowchart TD
Main[main.rs ListenMode::Unix branch] --> App[App struct in tui.rs]
App --> Terminal[ratatui Terminal over crossterm]
App --> State[AppState: role_table, mnemonic, activity_log, socket_name, derived_count, transport_mask, otp_status]
App --> Draw[draw function: builds 4 sections]
Draw --> Info[Section 0: Information Block + Paragraph]
Draw --> Roles[Section 1: Roles Block + Table]
Draw --> Activity[Section 2: Activity Block + List]
Draw --> Commands[Section 3: Commands Block + Paragraph]
App --> Events[event loop: crossterm poll + server handle_one]
Events --> KeyHandler[l/r/d/q key handlers]
KeyHandler --> LockScreen[Lock screen: mnemonic re-entry]
KeyHandler --> ConnScreen[Connections screen]
KeyHandler --> Quit[quit]
```
## App struct
```rust
pub struct App {
pub role_table: RoleTable,
pub mnemonic: MnemonicState,
pub key_store: KeyStore,
pub alg_key_cache: AlgorithmKeyCache,
pub activity_log: ActivityLog,
pub socket_name: String,
pub derived_count: usize,
pub transport_mask: u8,
pub server: ServerContext,
pub should_quit: bool,
pub current_screen: Screen,
pub activity_scroll: usize, // scroll offset for the Activity list
}
pub enum Screen {
Main,
Connections,
Lock,
}
```
## Event loop
The main loop changes from "render once, poll key, re-render" to ratatui's
standard event-driven loop:
1. `terminal.draw(|f| ui::draw(f, &app))` — draws the current screen.
2. `crossterm::event::poll(timeout)` — non-blocking, 50ms timeout (same as
current `poll_key(50)`).
3. If a key event arrives, handle it (`l`/`r`/`d`/`q`/`Esc`).
4. If no key within 50ms, call `server.handle_one(&mut dispatcher)` to
process any pending socket connection (same as current loop).
5. After handling a request, add to `activity_log` and re-draw.
6. SIGWINCH is handled automatically by ratatui/crossterm — no manual
`resize_pending()` check needed.
## Setup screens (ratatui input widgets)
The setup screens (mnemonic entry, role wizard, transport selection) also
use ratatui — the terminal is initialized at program start, before any
prompts. Each setup screen is a ratatui screen with input fields that
support pre-filled defaults and full line editing (backspace, arrows,
Ctrl-A/E/U, insert) — replacing the hand-rolled `read_line_editable`.
### Input widget
A reusable `InputField` struct wraps a `String` buffer + cursor position,
rendered as a ratatui `Paragraph` with a cursor block. It handles:
| Key | Action |
|-----|--------|
| Printable char | Insert at cursor |
| Backspace | Delete before cursor |
| Delete | Delete at cursor |
| Left/Right | Move cursor |
| Home/Ctrl-A | Move to start |
| End/Ctrl-E | Move to end |
| Ctrl-U | Clear field |
| Enter | Submit (return field contents) |
When a field has a default value, it is pre-filled into the buffer with the
cursor at the end — the user can backspace to edit or just press Enter to
accept. This replaces `read_line_editable` entirely.
### Screen flow
```mermaid
flowchart TD
Start[Start] --> Unlock[Screen: Unlock<br/>InputField for mnemonic<br/>E or G key to choose mode]
Unlock -->|G| GenShow[Screen: Show generated mnemonic<br/>Press Enter to continue]
GenShow --> Roles
Unlock -->|E or paste| Roles
Roles[Screen: Role preset menu<br/>1-10 selection + InputField for name<br/>Custom: curve menu + InputField for path]
Roles -->|Define another? y| Roles
Roles -->|N or Done| Transport
Transport[Screen: Transport selection<br/>checkbox toggle 1-4, a for all<br/>Enter to confirm]
Transport --> Main[Screen: Main status display]
```
### Screen enum (updated)
```rust
pub enum Screen {
Unlock,
GenerateMnemonic,
RoleWizard,
TransportSelection,
Main,
Connections,
Lock,
}
```
Each setup screen has its own `draw` function and event handler. The
`App::run()` loop dispatches to the appropriate handler based on
`current_screen`. Once setup is complete, `current_screen` transitions to
`Screen::Main` and the main status loop takes over.
## Files to change
| File | Change |
|------|--------|
| [`src/tui.rs`](../src/tui.rs:1) | Full rewrite: `App` struct, `InputField` widget, `Screen` enum, `draw()` for each screen (Unlock, GenerateMnemonic, RoleWizard, TransportSelection, Main, Connections, Lock), event loop. Keep `ActivityLog`. Remove everything else. |
| [`src/main.rs`](../src/main.rs:269) | Move all setup + main-loop logic into `App::run()`. The `ListenMode::Unix` branch just constructs `App` and calls `run()`. Remove `load_mnemonic_tui`. |
| [`src/tui_continuous.rs`](../src/tui_continuous.rs:1) | Delete entirely. |
| [`src/lib.rs`](../src/lib.rs:1) | No change (modules stay the same). |
## Implementation steps
1. **Add `App` struct and `Screen` enum** to `tui.rs` with all the state
fields currently passed to `render_status`.
2. **Write `draw()` function** — builds the two-column layout:
- **Outer vertical split**: title bar (3 lines) / body (flex) / commands (3 lines).
- **Body horizontal split**: left column (50%) / right column (50%).
- **Left column vertical split**: Information (auto height) / Roles (flex).
- **Right column**: Activity list (flex, scrollable with `Scrollbar`).
- Section 0 (Information): `Paragraph` with info lines in a bordered `Block`.
- Section 1 (Roles): `Table` with `Row`s from `role_table.entries` in a bordered `Block`.
- Section 2 (Activity): `List` from `activity_log.entries()` in a bordered `Block`,
with `List::scrollbar` or a `Scrollbar` widget showing position. Uses
`activity_scroll` for the offset.
- Section 3 (Commands): `Paragraph` with styled keybinding spans (key letter
bold/underlined via `Span::styled`) in a bordered `Block` spanning full width.
3. **Write `App::run()`** — the event loop:
- `enable_raw_mode()` + `EnterAlternateScreen` (ratatui standard init).
- `terminal.draw(|f| draw(f, self))`.
- `event::poll(50ms)` → handle key or `server.handle_one()`.
- On quit: `disable_raw_mode()` + `LeaveAlternateScreen`.
4. **Connections screen** — when `d` is pressed, switch `current_screen` to
`Screen::Connections` and draw a full-screen `Paragraph` with the
transport blocks (same content as current `render_connections`). Any key
returns to `Screen::Main`.
5. **Lock screen** — when `l` is pressed, switch to `Screen::Lock` which
shows an `InputField` for mnemonic re-entry (same `InputField` widget as
the Unlock screen). On submit, re-derive keys, update `derived_count`,
add to activity log, return to `Screen::Main`.
6. **Setup screens with `InputField`** — implement the Unlock, GenerateMnemonic,
RoleWizard, and TransportSelection screens using ratatui rendering and
`InputField` for all text entry. Pre-fill defaults into the `InputField`
buffer (role name, path template). The terminal is initialized at program
start, before any prompts — no cooked-mode `read_line` anywhere.
7. **Update `main.rs`** — the `ListenMode::Unix` branch constructs `App` and
calls `run()`. Move `key_store`, `alg_key_cache`, `role_table`, `mnemonic`,
`activity_log`, `server` into the `App` struct. Remove `load_mnemonic_tui`.
8. **Remove old code** — delete `render_status`, `render_connections`,
`poll_key`, `TuiKey`, `MAIN_MENU_ITEMS`, frame helpers, `read_line_editable`,
`role_wizard`, `transport_selection`, `load_mnemonic_tui`. Delete
`tui_continuous.rs` entirely.
9. **Test**`cargo test` (unit tests don't touch the TUI). Manual test:
start signer, verify setup screens work with InputField, verify 4-section
main screen renders, press `d`/`l`/`r`/`q`, connect with `signer_client`.
## What stays the same
- `ActivityLog` struct and its ring-buffer logic.
- All non-TUI code: `server.rs`, `dispatcher.rs`, `role_table.rs`, etc.
## What gets removed
- `tui_continuous.rs` (entire file, 916 lines) — replaced by ratatui.
- `render_status`, `render_connections` in `tui.rs`.
- `poll_key`, `TuiKey` enum in `tui.rs`.
- `MAIN_MENU_ITEMS`, `main_frame`, `connections_frame` in `tui.rs`.
- `read_line_editable` in `tui.rs` — replaced by `InputField` widget.
- `role_wizard` in `tui.rs` — replaced by `Screen::RoleWizard` ratatui screen.
- `transport_selection` in `tui.rs` — replaced by `Screen::TransportSelection`.
- `load_mnemonic_tui` in `main.rs` — replaced by `Screen::Unlock` / `Screen::GenerateMnemonic`.
- `tui_continuous::init`/`cleanup`/`install_resize_handler`/`resize_pending`
calls in `main.rs`.
- The `^_`/`^*`/`^:` hotkey markup system (ratatui uses styled spans instead).
- All `println!`/`print!`/`read_line` calls in setup flow (replaced by ratatui rendering + `InputField`).
+287
View File
@@ -0,0 +1,287 @@
# Plan: `signer-client` — Rust CLI for the signer daemon
## Goal
A standalone Rust command-line client `signer-client` that connects to a running
`signer` process over its framed transports (Unix abstract socket, TCP, serial,
qrexec) and exposes the full JSON-RPC verb surface over stdin/stdout so that
signed events can be piped directly into `nak publish`.
This is a **Rust port** of the C [`n_signer_client.c`](../n_signer/client/n_signer_client.c:1)
(~855 lines). It reuses the existing `signer` library crate for transport
framing, socket discovery, and verb/error constants — the new code is the
typed-verb client layer + CLI parsing + non-Unix transports.
## Deliverable & placement
- New binary target `signer-client` declared in [`Cargo.toml`](../Cargo.toml:1):
```toml
[[bin]]
name = "signer-client"
path = "src/client/main.rs"
```
- New module tree under `src/client/`:
- [`src/client/main.rs`](src/client/main.rs:1) — entry point, CLI parse, dispatch
- [`src/client/cli.rs`](src/client/cli.rs:1) — clap `Cli` / `Verb` structs + usage text
- [`src/client/transport.rs`](src/client/transport.rs:1) — `ClientTransport` enum (Unix/Tcp/Serial/Qrexec) + open/connect helpers
- [`src/client/rpc.rs`](src/client/rpc.rs:1) — low-level `SignerClient` (send framed JSON-RPC, recv, parse result/error)
- [`src/client/signer.rs`](src/client/signer.rs:1) — high-level `SignerSigner` typed-verb wrappers (mirrors C `nostr_signer_t`)
- [`src/client/auth.rs`](src/client/auth.rs:1) — client-side auth envelope builder for TCP/qrexec
- New doc: [`src/client/README.md`](src/client/README.md:1) — usage, verbs, pipe-to-nak recipes (port of `n_signer_client_README.md`).
- The existing `client` subcommand in [`src/main.rs`](src/main.rs:276) stays as a thin raw-passthrough convenience; it is **not** removed.
## What already exists (reuse, don't re-port)
| Concern | Existing Rust module | Reuse |
|---|---|---|
| 4-byte BE framing | [`transport.rs`](src/transport.rs:11) `send_framed` / `recv_framed` | yes, generic over `Read`/`Write` |
| Abstract Unix connect | [`transport.rs`](src/transport.rs:36) `connect_abstract_unix` | yes |
| Socket list / discover | [`socket_name.rs`](src/socket_name.rs:45) `list_sockets` / `discover_single_socket` | yes |
| Verb constants | [`enforcement.rs`](src/enforcement.rs:22) `VERB_*` | yes (import, don't redeclare) |
| RPC error codes | [`error.rs`](src/error.rs:57) `RpcError` constants | yes (for interpreting server errors) |
| Auth envelope verify (server) | [`auth_envelope.rs`](src/auth_envelope.rs:1) | reference only — client needs the **build** side |
## What is new (the port)
1. **CLI parsing** — clap `derive` structs mirroring the C `argv` loop in
[`n_signer_client.c`](../n_signer/client/n_signer_client.c:326): global
options, selector options, algorithm options, mine-event options, and a
`Verb` enum.
2. **`ClientTransport`** — enum wrapping the four connection types behind a
unified `send`/`recv` interface (the C `signer_transport_t` vtable).
- Unix: `connect_abstract_unix` (already in crate).
- TCP: `std::net::TcpStream` + framed I/O (server already speaks framed
JSON over TCP per [`server.rs`](src/server.rs:108)).
- Serial: `std::fs::OpenOptions` on `/dev/ttyACM*` + framed I/O over the
file handle (matches C `signer_transport_open_serial`).
- Qrexec: spawn `qrexec-client-vm <qube> <service>` via `std::process`,
pipe framed JSON over its stdin/stdout (matches C
`signer_transport_open_qrexec`).
3. **`SignerClient`** — low-level RPC caller: builds `{"id","method","params"}`
JSON, sends framed, receives framed, splits `result` vs `error`, holds
`last_error`. Mirrors C `signer_client_t` / `signer_client_call`.
4. **`SignerSigner`** — high-level typed-verb layer. Holds a `SignerClient`
plus the resolved selector (`role` + `role_path`) and auth state. One
method per verb, each building the correct `params` array + options object
and parsing the typed result. Mirrors C `nostr_signer_t` /
`nostr_signer_signer_from_client`.
5. **Client-side auth envelope builder** — for TCP/qrexec: construct a
NIP-42 kind-22242 auth event from the `--auth-privkey`, sign it, and
prepend it to the request frame. The server-side verifier in
[`auth_envelope.rs`](src/auth_envelope.rs:1) defines the wire shape; the
builder produces the matching shape.
6. **stdin/stdout contract** — per-verb payload sourcing (argv-or-stdin) and
single-line newline-terminated output, exactly as in the C client.
## CLI shape
```
signer-client [global options] <verb> [verb args...]
```
### Global options
| Flag | Default | Meaning |
|---|---|---|
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
| `--timeout <ms>` | `5000` | Transport timeout |
| `--tcp <host:port>` | none | TCP transport (requires `--auth-privkey`) |
| `--serial <device>` | none | USB CDC-ACM serial transport |
| `--qrexec <qube:service>` | none | Qubes qrexec transport |
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
| `--auth-label <text>` | none | Auth envelope label |
### Selector options (nostr verbs)
| Flag | Meaning | JSON emitted |
|---|---|---|
| `--role <name>` | Named path-role | `{"role":"<name>"}` |
| `--path <path>` | Full BIP-44 derivation path | `{"role_path":"<path>"}` |
### Algorithm options (algorithm verbs)
| Flag | Default | Meaning |
|---|---|---|
| `--algorithm`, `-a <alg>` | none | secp256k1/ed25519/x25519/ml-dsa-65/slh-dsa-128s/ml-kem-768/otp |
| `--index <N>` | `0` | Algorithm derivation index |
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 sign/verify only |
| `--encoding <ascii\|binary>` | `ascii` | OTP encrypt/decrypt only |
| `--format <plain\|structured>` | `plain` | get-public-key output shape |
### Mine-event options
| Flag | Meaning |
|---|---|
| `--difficulty <N>` | Target leading zero bits |
| `--threads <N>` | Mining threads (default 1) |
| `--timeout-sec <N>` | Mining timeout in seconds |
## Verb surface (full)
Mirrors [`n_signer_client.c`](../n_signer/client/n_signer_client.c:522) dispatch
and the verb table in [`enforcement.rs`](src/enforcement.rs:22).
### Utility
| Verb | stdout |
|---|---|
| `list` | Running signer abstract sockets (one per line) |
### Metadata
| Verb | RPC method | stdout |
|---|---|---|
| `get-info` | `get_info` | raw result JSON |
### Nostr verbs (role-based; require `--role` + `--path`)
| Verb | RPC method | stdin/argv | stdout |
|---|---|---|---|
| `get-public-key` | `nostr_get_public_key` | none | pubkey hex (or structured JSON with `--format structured`) |
| `sign-event` | `nostr_sign_event` | event JSON argv or stdin | signed event JSON |
| `mine-event` | `nostr_mine_event` | event JSON argv or stdin | signed mined event JSON |
| `nip04-encrypt <peer>` | `nostr_nip04_encrypt` | plaintext argv or stdin | ciphertext |
| `nip04-decrypt <peer>` | `nostr_nip04_decrypt` | ciphertext argv or stdin | plaintext |
| `nip44-encrypt <peer>` | `nostr_nip44_encrypt` | plaintext argv or stdin | ciphertext |
| `nip44-decrypt <peer>` | `nostr_nip44_decrypt` | ciphertext argv or stdin | plaintext |
### Algorithm-based verbs (use `--algorithm` + `--index`)
| Verb | RPC method | argv | stdout |
|---|---|---|---|
| `get-public-key` | `get_public_key` | none | structured JSON |
| `sign <msg-hex>` | `sign` | hex bytes | structured JSON |
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid`/`invalid` (exit 0/1) |
| `derive <data>` | `derive` | UTF-8 argv or stdin | structured JSON |
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | structured JSON |
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | structured JSON |
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
| `encrypt <plaintext>` | `encrypt` | plaintext argv or stdin | ciphertext |
| `decrypt <ciphertext>` | `decrypt` | ciphertext argv or stdin | plaintext |
### Generic escape hatch
| Verb | RPC method | input | stdout |
|---|---|---|---|
| `call <method>` | `<method>` | JSON params array stdin or argv | raw result JSON |
## stdin/stdout contract (pipe-friendly)
- All payload output → stdout, single line, newline-terminated.
- All diagnostics → stderr.
- Exit codes: `0` success, `1` invalid (verify only), `2` error.
- `sign-event`, `nip04-*`, `nip44-*`, `derive`, `encrypt`, `decrypt` read
payload from argv if present, else one stdin line.
- `sign`, `verify`, `encapsulate`, `decapsulate`, `derive-shared-secret`
take hex from argv only.
- `call` reads JSON params array from stdin (one line) or argv.
## Selector handling
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object
(trailing element of `params`):
- `--role <name> --path <path>` → `{"role":"<name>","role_path":"<path>"}`
(both required for nostr verbs; client-side error if either missing).
- Algorithm verbs: `--algorithm` + `--index` populate the options object
instead; `--scheme` adds `"scheme"` for secp256k1 sign/verify; `--encoding`
adds `"encoding"` for OTP encrypt/decrypt.
- `--index` is only valid with `--algorithm` (client rejects otherwise).
## Transport
- **Unix** (default): `connect_abstract_unix(name)`. Auto-discover via
`discover_single_socket()` when no `--socket-name` and no explicit
transport given; error if zero or >1 found.
- **TCP**: `TcpStream::connect((host, port))`; requires `--auth-privkey`
(32-byte hex). Auth envelope built client-side and prepended.
- **Serial**: open `/dev/ttyACM*` via `OpenOptions::read_write` + framed
I/O over the file.
- **Qrexec**: spawn `qrexec-client-vm <qube> <service>`, pipe framed JSON
over child stdin/stdout.
- All four share the same `send_framed`/`recv_framed` after construction.
## Auth envelope (client side)
For TCP (and qrexec when `--auth-privkey` is given):
1. Derive secp256k1 keypair from the 32-byte `--auth-privkey`.
2. Build a NIP-42 kind-22242 event with:
- `created_at` = now
- `tags` = `[["challenge","<request-hash>"]]` (or label tag from
`--auth-label`)
- content = the JSON-RPC request body (or its sha256, per server contract
in [`auth_envelope.rs`](src/auth_envelope.rs:1)).
3. Sign the event (Schnorr), serialize, and send as a framed preamble before
the actual request frame.
The exact envelope shape is read from the server-side verifier
[`auth_envelope.rs`](src/auth_envelope.rs:81) to guarantee wire
compatibility.
## Architecture
```mermaid
flowchart TD
CLI[cli.rs<br/>clap parse] --> Main[main.rs]
Main -->|open| Tr[transport.rs<br/>ClientTransport enum]
Tr -->|unix| Unix[connect_abstract_unix]
Tr -->|tcp| Tcp[TcpStream + auth.rs]
Tr -->|serial| Serial[OpenOptions /dev/ttyACM]
Tr -->|qrexec| Qrexec[spawn qrexec-client-vm]
Tr --> Rpc[rpc.rs<br/>SignerClient]
Rpc --> Signer[signer.rs<br/>SignerSigner typed verbs]
Signer -->|build params| Disp[signer daemon<br/>dispatcher.rs]
Disp -->|result/error| Signer
Signer -->|one line| Stdout[stdout]
```
## Implementation order (todos for Code mode)
1. Add `[[bin]]` target to `Cargo.toml`; create `src/client/` skeleton with
`mod` declarations in `src/client/main.rs`.
2. Implement `cli.rs`: clap `Cli` + `Verb` enum + all option structs +
`print_usage`.
3. Implement `transport.rs`: `ClientTransport` enum with `open_unix`,
`open_tcp`, `open_serial`, `open_qrexec`; unified `send`/`recv` via the
existing `send_framed`/`recv_framed`. Include `parse_host_port` and
`parse_qube_service` helpers.
4. Implement `rpc.rs`: `SignerClient` struct holding the transport,
`call(method, params) -> Result<Value, String>`, `last_error`, and
framed send/recv using `serde_json`.
5. Implement `signer.rs`: `SignerSigner` with selector state + one method
per verb (get_info, get_public_key, sign_event, mine_event, nip04/44
encrypt/decrypt, sign, verify, derive, encapsulate, decapsulate,
derive_shared_secret, otp encrypt/decrypt). Each builds the `params`
array + options object and parses the typed result.
6. Implement `auth.rs`: client-side auth envelope builder (secp256k1
keypair from hex, kind-22242 event, sign, serialize) matching the server
verifier shape.
7. Implement `main.rs`: wire CLI → transport → client → signer → verb
dispatch → stdout. Include stdin-line reader, hex helpers, exit codes
(0/1/2), and the `list` / `call` verbs.
8. Write `src/client/README.md` (port of `n_signer_client_README.md`).
9. Smoke test: build, run `signer-client list`, `get-info`,
`--role main --path ... get-public-key`, `sign-event` pipe-to-stdout,
`--algorithm ed25519 sign`, `verify` valid/invalid exit codes.
## Testing
- **Unit tests** in `rpc.rs` / `signer.rs`: build-params correctness using
`serde_json::json!` assertions (no socket needed).
- **Integration test** `tests/client_smoke.rs`: spawn `signer
--mnemonic-stdin --listen unix --socket-name signer_test` with a fixed
test mnemonic in a thread, then run the client verbs against it and
assert stdout shape + exit codes. Tear down the server.
- Manual pipe-to-`nak` check for `sign-event`.
## Out of scope
- No TUI, no approval UI — the human attendant lives in the running
`signer` process; the client is a thin wire caller.
- No key storage, no mnemonic handling.
- No HTTP-listener client (HTTP is a server-side listener mode; the client
uses the framed transports).
- No NIP-46 bunker mode.
- No changes to the existing `client` subcommand in `src/main.rs` (kept as
a raw-passthrough convenience).
+609
View File
@@ -0,0 +1,609 @@
# Plan: TUI Flow Redesign
## Goal
Simplify the signer TUI from a multi-screen setup wizard into a single
main screen with full-screen overlays. The seed phrase is entered once
at startup in a popup; all subsequent editing (roles, transport, lock)
is done from full-screen overlays reached from the main screen. Borders
are collapsed for a cleaner look. A dedicated Commands screen provides
keyboard navigation through all available actions with a visible
cursor. Command hints show only the word with the key letter
underlined (e.g. "Quit" with Q underlined, not "Q quit").
## Current state
- [`src/tui.rs`](../src/tui.rs:1) — 1398 lines. Seven screens:
`Unlock`, `GenerateMnemonic`, `RoleWizard`, `TransportSelection`,
`Main`, `Connections`, `Lock`. Setup is a linear wizard: Unlock →
(Generate) → RoleWizard → TransportSelection → Main. The main screen
has a title bar reading `signer v0.0.1 > Main Menu`, a two-column
body (Information + Roles on the left, Activity on the right), and a
Commands bar at the bottom. Connections and Lock are rendered as
sub-panels inside the main screen's right column.
- [`src/main.rs`](../src/main.rs:129) — `server_main` constructs `App`
and calls `run()`. Non-interactive mode uses `run_headless()`.
- [`src/server.rs`](../src/server.rs:80) — `ServerContext` supports
`Unix`, `Qrexec`, `Tcp`, `Http`, `Stdio` listen modes. Only one mode
active at a time (the `start_server` method picks the first toggled
transport).
## Key design decisions
### 1. Rename "client name" → "signer name"
The user asked whether "client name" should be renamed since the
signer is more a server than a client. **Decision: rename to "signer
name".** The field shows the socket name (e.g. `signer01`), which is
the name clients use to connect. Calling it "signer name" is clearer
than "client name" and consistent with the program name.
### 2. Single main screen, no setup wizard
The current linear wizard (Unlock → RoleWizard → TransportSelection →
Main) is replaced by:
1. **Startup popup** — seed phrase entry (and optional generation).
This is the *only* popup in the entire TUI.
2. **Main screen** — shows Information, Roles, Activity, and a Commands
bar. From here the user can:
- Open the Roles screen (add/remove roles) — full-screen overlay
- Open the Transport screen (toggle transports on/off at any time) —
full-screen overlay
- Lock the session — full-screen overlay
- Open the Commands screen — full-screen overlay
- Quit
### 3. Collapsed borders
Use ratatui's `MergeStrategy::Exact` with `Spacing::Overlap(1)` so
adjacent blocks share borders instead of drawing double lines. The
selected/focused pane gets a thick border for visual distinction. See
[`ratatui/ratatui-widgets/examples/collapsed-borders.rs`](../ratatui/ratatui-widgets/examples/collapsed-borders.rs:1)
for the reference implementation.
### 4. Command hint style
Command hints at the bottom of each screen show only the word with the
key command letter underlined — not "Q quit" but "Quit" with the Q
underlined. The underlined letter is the actual key binding, which may
not be the first letter. For example, if `b` is the key for "Qube
bridge", it would be rendered as "Qube bridge" with the `b`
underlined. This is achieved with
`Span::raw("Qube ")` + `Span::styled("b", Style::default().add_modifier(Modifier::UNDERLINED))`
+ `Span::raw("ridge")`.
Each command hint is a word (or short phrase) with exactly one letter
underlined — the letter the user presses to activate that command.
## Target layout
### Startup popup — seed phrase entry
A centered popup (not full screen) over a blank terminal. This is the
only popup in the entire TUI:
```
┌──────────────────────────────────────────────┐
│ Signer v0.0.1 │
│ │
│ Enter seed phrase or G to generate new: │
│ > abandon abandon abandon abandon abandon │
│ abandon abandon abandon abandon abandon │
│ abandon abandon │
│ │
│ Invalid mnemonic. Attempts: 1/10 │
└──────────────────────────────────────────────┘
```
- If the user types `g` and presses Enter, generate a 12-word
mnemonic, display it in the same popup, then press Enter to
continue.
- On successful load, derive keys for any pre-registered roles (the
default `main` role is auto-registered), start the server with
default transport (Unix), and transition to the main screen.
- Invalid mnemonic shows an error line and retries (max 10 attempts).
### Main screen — full mockup
Title is `Signer v0.0.1` centered on its own line — no border box
around it, no "Main Menu" text. Use ratatui's `Line::from(...).centered()`
to center the title. Collapsed borders between the body sections.
Left column has three sections: Information (top), Transport (middle),
Roles (bottom). Right column has Activity (scrollable, newest first).
Commands bar at the bottom with key command letters underlined.
Below is the full main screen showing all sections in detail. The
`▸` cursor in the Transport section shows the currently selected
transport line. Active transports are shown in **bold** (rendered as
reversed video or bold in the actual TUI). The Activity column shows
the C-format log entries (newest first).
```
Signer v0.0.1
├──────────────────────────────────────────┬──────────────────────────────────────────┤
│ Information │ Activity │
│ │ │
│ signer name: signer01 │ 2026-08-18 15:05:42 unix:1000 │
│ Unix address: │ secp256k1 m/44'/1237'/0'/0/0 │
│ signer01 │ 2026-08-18 15:05:30 unix:1000 │
│ Qube address: │ - - │
│ (inactive) │ 2026-08-18 15:04:55 unix:1000 │
│ FIPS address: │ secp256k1 m/44'/1237'/0'/0/0 │
│ (inactive) │ │
│ HTTP address: │ │
│ (inactive) │ │
│ OTP pad: chksum=a1b2c3 offset=128/4096 │ │
│ │ 2026-08-18 15:03:12 unix:1000 │
├──────────────────────────────────────────┤ secp256k1 m/44'/1237'/*'/0/0 │
│ Transport │ 2026-08-18 15:02:00 unix:1000 │
│ │ - - │
│ ▸ [x] U̲nix Socket │ 2026-08-18 15:01:30 unix:1000 │
│ [ ] Qube b̲ridge │ secp256k1 m/44'/1237'/0'/0/0 │
│ [ ] F̲IPS │ 2026-08-18 15:00:22 unix:1000 │
│ [ ] H̲TTP │ secp256k1 m/44'/1237'/0'/0/0 │
│ │ 2026-08-18 15:00:10 signer started │
├──────────────────────────────────────────┤ │
│ Roles │ │
│ │ │
│ Role Purpose Curve │ │
│ ───────────── ──────── ──────────── │ │
│ main nostr secp256k1 │ │
│ nostr_range nostr secp256k1 │ │
│ ssh ssh ed25519 │ │
│ │ │
│ A̲dd D̲elete │ Cl̲ear ▲ │
├──────────────────────────────────────────┴──────────────────────────────────────────┤
│ He̲lp Q̲uit │
└─────────────────────────────────────────────────────────────────────────────────────┘
```
Each section has its own commands on the bottom line, left-aligned:
- **Information**: no commands (display only)
- **Transport**: no separate command — each transport line is a toggle
button. The `[x]` / `[ ]` indicator shows on/off state. The key
command letter is underlined in each label (`U̲nix Socket`,
`Q̲ube bridge`, `F̲IPS`, `H̲TTP`). Tab or Up/Down moves between
lines, Enter or the underlined key toggles that transport on/off
(independent checkbox: toggling one only flips itself; the last
active transport cannot be disabled; server restarts immediately).
Active transport is also shown in bold/reversed.
- **Roles**: `A̲dd D̲elete` — add a new role, delete the selected role
- **Activity**: `Cl̲ear` — clear the activity log (with a blank row
above the command)
- **Bottom bar**: `He̲lp Q̲uit` — global navigation (Help opens a
help screen, Quit exits)
**Section details:**
**Information** — each transport address is shown as a label row
followed by an indented value row (since addresses can be long):
| Label row | Indented value row | Source |
|-----------|-------------------|--------|
| `signer name: <socket_name>` | (same line) | `self.socket_name` |
| `Unix address:` | ` <socket_name>` (without @) | if Unix active, else ` (inactive)` |
| `Qube address:` | ` (one request per invocation)` | if Qrexec active, else ` (inactive)` |
| `FIPS address:` | ` <bind_addr>` | if TCP active, else ` (inactive)` |
| `HTTP address:` | ` <bind_addr>` | if HTTP active, else ` (inactive)` |
| `OTP pad: chksum=… offset=N/M` | (same line) | `otp_pad::global_status()` (only if bound) |
Note: Unix addresses are always displayed without the `@` symbol.
The indented value row uses 2-space indentation.
**Transport** — 4 toggle-button lines, each showing `[x]` or `[ ]`
indicator plus the transport name with the key letter underlined
(`U̲nix Socket`, `Qube b̲ridge`, `F̲IPS`, `H̲TTP`). No separate
command line — each line is its own toggle. Tab or Up/Down moves
between lines, Enter or the underlined key letter toggles that
transport on/off (independent checkbox: toggling one only flips
itself; the last active transport cannot be disabled; server restarts
immediately). Active transport also shown in bold/reversed.
Key assignments (all unique across the main screen — this is the
canonical set shown in the mockup):
- `U` — Unix Socket
- `B` — Qube bridge
- `F` — FIPS
- `H` — HTTP
- `A` — Add role
- `D` — Delete role
- `C` — Clear activity log
- `L` — Help screen
- `Q` — Quit
**Roles** — table with columns: Role, Purpose, Curve. (Derivation path
is omitted from the main screen to save space — it's visible on the
Roles overlay screen.) Shows all registered roles. A blank row
separates the table from the commands at the bottom: `A̲dd D̲elete`.
**Activity** — scrollable, newest first. Each entry is a timestamped
log line in the C format (see "Activity log format" below). Scrollbar
on the right. A blank row separates the log from the commands at the
bottom: `Cl̲ear`.
**Bottom bar**`He̲lp Q̲uit` with key letters underlined.
### Activity log format
Each activity entry shows four fields: `time uid curve path`. The
timestamp is added by `ActivityLog::add()`; the message itself is
`uid curve path`.
```
<caller_id> <curve> <key_path>
```
Examples:
- `unix:1000 secp256k1 m/44'/1237'/0'/0/0`
- `unix:1000 ed25519 m/44'/102001'/0'/0'/0'`
- `unix:1000 - -` (get_info / algorithm verbs — no role)
**Implementation:** `ServerContext::process_request` returns
`(String, String)` — the response and the activity log message. The
activity message is constructed from:
- `caller.caller_id` — e.g. `unix:1000` or `tcp:[::1]:12345`
- `curve` — the role's curve string (e.g. `secp256k1`, `ed25519`)
- `key_path` — the role's derivation path via `RoleEntry::display_path()`
(e.g. `m/44'/1237'/0'/0/0` or `m/44'/1237'/*'/0/0 [0-99]`)
- For requests without a role (get_info, algorithm verbs, OTP), the
curve and path are `-`.
The `service_server()` method in `App` passes this message to
`activity_log.add()` instead of the generic "request handled".
### Roles section (on main screen)
Roles are managed directly in the Roles section on the main screen —
there is no separate Roles overlay screen. The `▸` cursor shows the
selected role.
- **Add** (`A`): opens the `AddRole` popup showing the role preset
menu (same 110 presets as current wizard). Select a preset (or
custom), then enter role name and path template via `InputField`
with pre-filled defaults. On confirm, register the role, derive its
key immediately, and return to the main screen automatically — no
extra Enter needed.
- **Delete** (`D`): deletes the currently selected role immediately —
no confirmation overlay. The role is removed from the table and its
derived key is wiped. The selection moves to the next role.
- **Select**: Up/Down arrows or Tab move selection through the role
list. The `▸` cursor shows the selected role.
- **Columns**: Role, Purpose, Curve, Key path (derivation path via
`RoleEntry::display_path()`, e.g. `m/44'/1237'/0'/0/0` or
`m/44'/1237'/*'/0/0 [0-99]`).
### Help screen
Opened by pressing `L` from the main screen. Full-screen overlay that
describes what the app does, what transports are, what roles are, and
lists the key commands at the end. It is a scrollable screen (the
content can exceed the visible height). Commands at the bottom:
```
Signer v0.0.1
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ Signer is an attended Nostr signing daemon. It holds your keys and signs │
│ requests from clients over one or more transports. │
│ │
│ Transports │
│ ────────── │
│ A transport is a way for clients to reach the signer. │
│ - Unix Socket: local same-machine access via an abstract socket. │
│ - Qube bridge: access from other Qubes via qrexec. │
│ - FIPS: TCP listener for framed JSON over a network. │
│ - HTTP: HTTP listener for curl-friendly requests. │
│ │
│ Roles │
│ ───── │
│ A role binds a name to a derivation path and curve. Clients address │
│ requests by role name, which also serves as the password. │
│ │
│ Key commands │
│ ───────────── │
│ U Toggle Unix Socket transport │
│ B Toggle Qube bridge transport │
│ F Toggle FIPS transport │
│ H Toggle HTTP transport │
│ A Add a role │
│ D Delete the selected role │
│ C Clear the activity log │
│ L Open the Help screen │
│ Q Quit │
│ │
├──────────────────────────────────────────────────────────────────────────────────────┤
│ B̲ack │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
- The content is scrollable — Up/Down arrows (or Page Up/Down) scroll
through the help text. A scrollbar is shown on the right when the
content overflows.
- ESC / `B` (Back): return to main screen.
- This is a reference screen — no actions are executed from here.
## Screen enum (updated)
```rust
pub enum Screen {
/// Startup popup — seed phrase entry
SeedEntry,
/// Startup popup — showing generated mnemonic
SeedDisplay,
/// Main status screen (Information + Transport + Roles + Activity + bottom bar)
Main,
/// Add-role popup — role preset selection (over the Main screen)
AddRole,
/// Help — full-screen overlay describing the app and key commands
Help,
}
```
`Connections`, `Transport`, `Lock`, and `Commands` screens are removed.
Transport is now a section on the main screen (between Information and
Roles). Connection info is shown inline in the Information section
(transport addresses). Lock functionality is removed entirely. The
Commands screen is replaced by a Help screen. Only `SeedEntry` and
`SeedDisplay` are popups; all other screens are full-screen overlays.
## App struct changes
```rust
pub struct App {
// ... existing fields ...
/// Scroll offset for the Help screen content
pub help_scroll: usize,
/// Currently selected role index in the Roles section
pub role_cursor: usize,
/// Currently selected transport line index in the Transport section
pub transport_cursor: usize,
/// Whether the seed entry popup is in "generate" mode
pub seed_generate_mode: bool,
// Remove: wizard_stage, wizard_choice, wizard_default_*,
// wizard_role_name, wizard_path, wizard_otp_dir,
// wizard_otp_name, wizard_roles_created
// Add: role_add_stage (for the add-role popup flow)
pub role_add_stage: RoleAddStage,
pub role_add_input: InputField,
pub role_add_choice: i32,
}
pub enum RoleAddStage {
PresetMenu,
NameEntry,
CurveSelect,
PathEntry,
OtpDir,
OtpName,
Confirm,
}
```
## Server changes
Currently `ServerContext` supports only one listen mode at a time. The
transport toggles are independent checkboxes — toggling one only flips
itself, and multiple transports can be checked at once. The server
listens on the first active transport by priority (Unix > Qrexec > TCP
> HTTP). The last active transport cannot be disabled. This avoids
server architecture changes while allowing the user to select which
transport is active.
Additionally, `process_request` must return an activity log message
alongside the JSON response (see "Activity log format" above). Change
the return type from `String` to `(String, String)` where the first
element is the JSON response and the second is the activity description.
`handle_one` returns this message to the caller so the TUI can log it.
## Event loop changes
The `run()` loop stays the same structure (draw → poll key → service
server). The key change is that `service_server()` is called only when
the screen is `Main`. The overlay screens (`AddRole`, `Help`) pause
server processing while the user is actively configuring. The startup
popups (`SeedEntry`, `SeedDisplay`) also pause server processing since
the server hasn't started yet.
```mermaid
flowchart TD
Start[Start] --> SeedEntry[Popup: Seed Entry]
SeedEntry -->|g| SeedDisplay[Popup: Show Generated Mnemonic]
SeedDisplay -->|Enter| Main
SeedEntry -->|Enter valid phrase| Main[Main Screen]
Main -->|A| AddRole[Popup: Add Role preset menu]
AddRole -->|confirm| Main
Main -->|L| Help[Help Screen - full screen]
Help -->|ESC/B| Main
Main -->|Q| Quit[Quit]
```
Note: Transport and Roles are not separate screens — they are sections
on the main screen. The user tabs/arrow-keys between the 4 transport
lines and toggles them directly on the main screen. Roles are added
and deleted directly in the Roles section.
## Key bindings summary
### Main screen — global commands
| Key | Action |
|-----|--------|
| `L` | Open Help screen |
| `Q` / `ESC` | Quit |
| `↑` / `↓` | Scroll activity log |
### Transport section (on main screen)
| Key | Action |
|-----|--------|
| `TAB` / `↑` / `↓` | Move between transport lines |
| `ENTER` | Toggle selected transport on/off |
| `U` | Toggle Unix Socket on/off |
| `B` | Toggle Qube bridge on/off |
| `F` | Toggle FIPS on/off |
| `H` | Toggle HTTP on/off |
### Roles section (on main screen)
| Key | Action |
|-----|--------|
| `A` | Add role (opens AddRole popup with preset menu) |
| `D` | Delete the currently selected role (immediate, no confirmation) |
| `↑` / `↓` / `TAB` | Select role |
### Activity section (on main screen)
| Key | Action |
|-----|--------|
| `C` | Clear the activity log |
| `↑` / `↓` | Scroll activity log |
### Help screen
| Key | Action |
|-----|--------|
| `↑` / `↓` | Scroll help content |
| `PAGE UP` / `PAGE DOWN` | Scroll help content by page |
| `ESC` / `B` | Back to main |
## Files to change
| File | Change |
|------|--------|
| [`src/tui.rs`](../src/tui.rs:1) | Major rewrite: new `Screen` enum, startup popup for seed entry, AddRole popup, Help overlay, Transport and Roles as inline sections on main screen with toggle buttons, collapsed borders, underlined-key-letter command hints at bottom of each section, centered title. Remove `WizardStage`/wizard flow, `Connections` screen, Lock screen, Commands screen, Roles overlay screen. Remove session/derived from Information. Unix addresses without @. |
| [`src/main.rs`](../src/main.rs:129) | Minor: `App::new` call stays the same. The `listen_override` path may need adjustment since transport is now chosen from the main screen, not a setup screen. |
| [`src/server.rs`](../src/server.rs:80) | Change `process_request` return type to `(String, String)` — JSON response + activity log message. `handle_one` returns the activity message to the caller. Construct the activity message from caller_id, method, role_name, concrete_path, verdict, and source_label (matching C format). |
## Implementation steps
1. **Update `Screen` enum** — replace the seven screens with the new
set: `SeedEntry`, `SeedDisplay`, `Main`, `AddRole`, `Help`.
(No separate Transport, Roles, Lock, or Commands screen — Transport
and Roles are sections on Main, Lock is removed entirely, Commands
replaced by Help.)
2. **Update `App` struct** — remove wizard fields, add `help_scroll`,
`role_cursor`, `transport_cursor`, `seed_generate_mode`,
`RoleAddStage` enum and fields. Update `App::new` to start on
`Screen::SeedEntry`.
3. **Implement seed entry popup**`draw_seed_entry()` renders a
centered popup. `handle_seed_key()` processes input: `g` → generate,
Enter → load mnemonic, derive keys, start server, transition to
`Main`. `draw_seed_display()` shows the generated phrase.
4. **Rewrite `draw_main`** — title is `Signer v0.0.1` centered on its
own line (use `Line::from(...).centered()`, no border box, no "Main
Menu"). Use `MergeStrategy::Exact` + `Spacing::Overlap(1)` for
collapsed borders. Left column has three sections: Information (top),
Transport (middle), Roles (bottom). Right column has Activity
(scrollable, newest first). Information section shows "signer name"
(renamed from "client name") + transport addresses (each on an
indented row beneath the label, without `@` for Unix). Remove the
Connections sub-panel. Roles and Activity sections have a blank row
above their command lines at the bottom.
5. **Implement Transport section on main screen** — renders 4
toggle-button lines, each showing `[x]` or `[ ]` indicator plus
the transport name with the key letter underlined (`U̲nix Socket`,
`Qube b̲ridge`, `F̲IPS`, `H̲TTP`). The `▸` cursor shows the
selected line (`transport_cursor`). Active transport is also shown
in bold/reversed. Tab/Up/Down moves between lines, Enter or the
underlined key letter (`U`/`B`/`F`/`H`) toggles that transport
(independent checkbox: only flips itself; last active cannot be
disabled; toggling restarts the server). No separate command line
for this section. All key commands on the
main screen must be unique: U, B, F, H (transport), A, D (roles),
C (clear activity), L (help), Q (quit).
6. **Implement Roles section on main screen** — renders the role table
with a selection cursor (`role_cursor`). `A` opens the `AddRole`
popup (preset menu → name → path → confirm), `D` deletes the
currently selected role immediately (no confirmation), `↑`/`↓`/Tab
moves the cursor. On add/delete, re-derive keys.
7. **Implement AddRole popup**`draw_add_role()` renders a centered
popup over the Main screen showing the role preset menu (same 110
presets as current wizard). `handle_add_role_key()` processes the
multi-stage flow: `PresetMenu``NameEntry` (InputField with
pre-filled default) → `CurveSelect` (custom only) → `PathEntry`
(InputField with pre-filled default) → `OtpDir`/`OtpName` (OTP
only) → `Confirm`. On confirm, register the role, derive its key,
and return to `Screen::Main`. ESC cancels and returns to Main.
8. **Implement Help screen**`draw_help()` renders a scrollable
`Paragraph` describing what the app does, what transports are, what
roles are, and listing the key commands at the end. Track a
`help_scroll` offset. `handle_help_key()`: Up/Down (and Page
Up/Down) scroll the content, ESC/B returns to main. A scrollbar is
shown when content overflows. This is a reference screen — no
actions executed from here.
9. **Update key command bars** — each section's bottom line shows the
relevant key bindings for that section. Use underlined-key-letter
word hints (e.g. `Q̲uit`, `He̲lp`, `A̲dd`, `D̲elete`, `Cl̲ear`)
instead of "Q quit" style. Replace the old `key_span` helper with a
new `cmd_hint` helper that produces a `Span` with the key command
letter underlined (which may not be the first letter of the word).
10. **Update activity log format** — change `ServerContext::process_request`
to return `(String, String)` (response + activity message). Construct
the activity message from `caller_id`, `method`, `role_name`,
`concrete_path`, `verdict`, and `source_label` matching the C format:
`<caller_id> <method>(<role>[,<path>]) <verdict>:<source>`. Update
`handle_one` to return the activity message. Update
`service_server()` in `App` to log this message instead of
"request handled".
11. **Update `run()` loop** — service server only on `Main` screen
(overlay screens `AddRole` and `Help` pause server processing).
Update the `handle_key` dispatch for the new screen enum.
12. **Update `main.rs`** — adjust `App::new` call if needed. The
`listen_override` path: if `--listen` is given, skip seed entry
popup and go straight to main with the specified transport. But
still need a mnemonic — so `--listen` with interactive mode should
still show the seed entry popup, then go to main with the transport
pre-selected.
13. **Test**`cargo test` (unit tests unaffected). Manual test:
start signer, verify seed entry popup, verify main screen with
collapsed borders and centered title, verify Roles add/delete,
verify AddRole popup preset menu flow, verify Transport toggle
(4 lines, tab navigation), verify Help screen is scrollable and
shows app description + key commands, verify activity log shows
detailed request info, connect with `signer_client`.
## What stays the same
- `ActivityLog` struct and ring-buffer logic.
- `InputField` widget and `edit_key` helper.
- All non-TUI code: `dispatcher.rs`, `role_table.rs`, `mnemonic.rs`,
`key_store.rs`, etc. (server.rs gets a return-type change but its
logic stays the same).
- The event loop structure (draw → poll → service).
- Non-interactive / headless mode (`run_headless`).
## What gets removed
- `Screen::Unlock`, `Screen::GenerateMnemonic`, `Screen::RoleWizard`,
`Screen::TransportSelection`, `Screen::Connections`, `Screen::Lock`,
`Screen::Commands`, `Screen::Roles` — replaced by the startup popup,
the AddRole popup, the Help overlay, and inline sections. Lock
functionality is removed entirely. Commands screen replaced by Help
screen. Roles screen replaced by an inline Roles section.
- `WizardStage` enum and all wizard-related fields/methods.
- `draw_unlock`, `draw_generate`, `draw_wizard`, `draw_transport`
(old full-screen versions), `draw_connections_in`, `draw_lock_in`,
`handle_lock_key`, `draw_roles` (old overlay version).
- The `> Main Menu` text in the title bar.
- The bordered title bar box — title is now a plain line.
- The old `key_span` helper that produced "Q quit" style hints —
replaced by underlined-key-letter word hints.
- Session and derived count rows from the Information section.
- The `@` symbol prefix from Unix address display.
- The `R` (open Roles screen) and `E` (clear) key commands — replaced
by inline Roles management and `C` for clear.
Executable
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# qdiag.sh — read-only Qubes dom0 diagnostics for SignerRpc qrexec issues.
# Run as root in dom0: sudo bash qdiag.sh
# Output: qdiag.txt in the current directory. No system changes are made.
OUT="qdiag.txt"
: >"$OUT"
sec() { printf '\n===== %s =====\n' "$1" >>"$OUT"; }
run() { printf '\n$ %s\n' "$*" >>"$OUT"; "$@" >>"$OUT" 2>&1 || true; }
runsh() { printf '\n$ %s\n' "$1" >>"$OUT"; bash -c "$1" >>"$OUT" 2>&1 || true; }
sec "1. Identity / versions"
run hostname
run id
run uname -a
run cat /etc/qubes-release
run qubesctl --version
run rpm -q qubes-core-admin-linux qubes-core-qrexec 2>/dev/null
sec "2. Qubes version detail"
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE 2>/dev/null
run qvm-ls --running 2>/dev/null
sec "3. Policy directories inventory"
run ls -la /etc/qubes/policy.d/
run ls -la /etc/qubes-rpc/policy/ 2>/dev/null
run ls -la /usr/share/qubes/policy.d/ 2>/dev/null
run ls -la /etc/qubes-rpc/ 2>/dev/null
sec "4. Policy file ownership/permissions"
runsh 'find /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d -maxdepth 1 -type f -printf "%M %u:%g %s %p\n" 2>/dev/null | sort'
sec "5. Symlinks in policy dirs"
runsh 'find /etc/qubes/policy.d /etc/qubes-rpc /etc/qubes-rpc/policy -maxdepth 2 -type l -printf "%p -> %l\n" 2>/dev/null'
sec "6. Search for SignerRpc / nsigner / signer policy files"
runsh 'find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -iname "*signer*" -print 2>/dev/null | grep -vi "^/home" | head -50'
runsh 'grep -RIl "SignerRpc\|NsignerRpc\|nsigner" /etc/qubes /usr/share/qubes 2>/dev/null | head -30'
sec "7. Policy contents (signer-related)"
runsh 'for f in $(grep -RIl "SignerRpc\|NsignerRpc\|nsigner\|signer" /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d 2>/dev/null); do echo "--- $f ---"; cat "$f"; echo; done'
sec "8. Clipboard policy contents"
runsh 'for f in $(grep -RIl "ClipboardPaste" /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d 2>/dev/null); do echo "--- $f ---"; cat "$f"; echo; done'
sec "9. Full policy.d listing with contents (all files)"
runsh 'for f in /etc/qubes/policy.d/*; do echo "--- $f ---"; cat "$f" 2>/dev/null; echo; done'
sec "10. Effective policy query (if tools exist)"
runsh 'command -v qrexec-policy-graph && qrexec-policy-graph --include-ask 2>&1 | grep -iE "signer|clipboard" | head -30'
runsh 'command -v qvm-tags && qvm-tags dom0 2>/dev/null | head -20'
sec "11. qrexec policy daemon status"
run systemctl status qrexec-policy-daemon --no-pager -l 2>&1
run systemctl is-active qrexec-policy-daemon 2>&1
sec "12. Target qube info (nostr_signer)"
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE,NETVM nostr_signer 2>/dev/null
run qvm-features nostr_signer 2>/dev/null
run qvm-prefs nostr_signer 2>/dev/null
sec "13. Caller qube info (ai)"
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE,NETVM ai 2>/dev/null
run qvm-features ai 2>/dev/null
sec "14. Recent qrexec/policy journal errors"
runsh 'journalctl -b --no-pager 2>/dev/null | grep -iE "qrexec|policy|SignerRpc|NsignerRpc|clipboard" | tail -80'
sec "15. qrexec service definitions in target qube (via qvm-run, read-only)"
runsh 'qvm-run -p nostr_signer "ls -la /etc/qubes-rpc/ 2>/dev/null; echo ---; ls -la /rw/config/qubes-rpc/ 2>/dev/null; echo ---; cat /rw/config/rc.local 2>/dev/null" 2>&1 | head -60'
sec "16. Test qrexec call to nostr_signer (read-only get_info)"
runsh 'echo "{\"id\":\"diag\",\"method\":\"get_info\",\"params\":[]}" | timeout 10 qrexec-client-vm nostr_signer qubes.SignerRpc 2>&1; echo "exit=$?"'
runsh 'echo "{\"id\":\"diag\",\"method\":\"get_info\",\"params\":[]}" | timeout 10 qrexec-client-vm nostr_signer qubes.NsignerRpc 2>&1; echo "exit=$?"'
sec "17. Qubes global config files"
runsh 'ls -la /etc/qubes/ | head -30'
runsh 'cat /etc/qubes/policy.d/50-config-input.policy 2>/dev/null'
runsh 'cat /etc/qubes/policy.d/50-config-updates.policy 2>/dev/null'
sec "DONE"
printf 'Diagnostics complete. Output saved to %s\n' "$OUT"
exit 0
+1445
View File
File diff suppressed because it is too large Load Diff
Executable
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# qfix.sh — repair dom0 qrexec policy + signer handler. Run as root in dom0:
# sudo bash qfix.sh
# Fixes (with backups saved to ~/qfix-backup-<ts>/):
# 1. Removes invalid old-format file /etc/qubes-rpc/policy/qubes.SignerRpc
# (its "* * * allow" content breaks ALL qrexec policy loading)
# 2. Rewrites /etc/qubes/policy.d/45-signer.policy with direct targets
# 3. Sets the signer-signer tag on nostr_signer (optional, for tag rules)
# 4. Creates /etc/qubes-rpc/qubes.SignerRpc handler inside nostr_signer
# (modeled on the existing qubes.NsignerRpc handler)
# 5. Verifies policy loads cleanly
set -u
TS=$(date +%Y%m%d-%H%M%S)
BK=~/qfix-backup-$TS
mkdir -p "$BK"
ok() { printf '\033[1;32m[OK]\033[0m %s\n' "$*"; }
info(){ printf '\033[1;34m[..]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[ERR]\033[0m %s\n' "$*"; }
[[ $EUID -eq 0 ]] || { err "Run as root: sudo bash qfix.sh"; exit 1; }
# ── 1. Remove the invalid old-format policy file ─────────────────────
if [[ -f /etc/qubes-rpc/policy/qubes.SignerRpc ]]; then
cp /etc/qubes-rpc/policy/qubes.SignerRpc "$BK/" 2>/dev/null
rm -f /etc/qubes-rpc/policy/qubes.SignerRpc
ok "removed invalid /etc/qubes-rpc/policy/qubes.SignerRpc (backed up)"
else
ok "no invalid old-format file present"
fi
# ── 2. Rewrite 45-signer.policy with direct target rules ─────────────
SIGNER_POLICY=/etc/qubes/policy.d/45-signer.policy
if [[ -f "$SIGNER_POLICY" ]]; then
cp "$SIGNER_POLICY" "$BK/45-signer.policy"
fi
cat > "$SIGNER_POLICY" <<'EOF'
# Qubes OS qrexec policy for signer (qubes.SignerRpc)
# Direct rules: caller -> nostr_signer
qubes.SignerRpc * ai nostr_signer allow
qubes.SignerRpc * nostr nostr_signer allow
qubes.SignerRpc * @anyvm @anyvm ask default_target=nostr_signer
EOF
chown root:root "$SIGNER_POLICY"
chmod 0644 "$SIGNER_POLICY"
ok "rewrote $SIGNER_POLICY"
# ── 3. Tag nostr_signer (enables @tag:signer-signer rules if ever used) ──
qvm-tags nostr_signer add signer-signer 2>/dev/null \
&& ok "tagged nostr_signer with signer-signer" \
|| info "tag set skipped (non-fatal)"
# ── 4. Create the qrexec handler inside nostr_signer ─────────────────
info "inspecting existing handlers in nostr_signer..."
qvm-run -p nostr_signer 'cat /etc/qubes-rpc/qubes.NsignerRpc 2>/dev/null' || true
# Detect signer binary location in the target qube
BIN=$(qvm-run -p nostr_signer \
'for b in $HOME/.local/bin/signer /usr/local/bin/signer; do [ -x "$b" ] && echo "$b" && break; done' 2>/dev/null | tr -d '\r')
[[ -n "$BIN" ]] || { err "signer binary not found in nostr_signer (run install_signer.sh there first)"; BIN="/home/user/.local/bin/signer"; }
info "signer binary in nostr_signer: $BIN"
qvm-run -u root -p nostr_signer "printf '#!/bin/sh\nexec $BIN bridge\n' > /etc/qubes-rpc/qubes.SignerRpc && chmod 0755 /etc/qubes-rpc/qubes.SignerRpc" \
&& ok "created /etc/qubes-rpc/qubes.SignerRpc in nostr_signer" \
|| err "handler creation failed (create manually)"
# Show what we created
qvm-run -p nostr_signer 'ls -la /etc/qubes-rpc/qubes.SignerRpc; cat /etc/qubes-rpc/qubes.SignerRpc' || true
# ── 5. Verify policy loads cleanly ───────────────────────────────────
info "verifying policy syntax..."
if qrexec-policy-graph --include-ask >/dev/null 2>"$BK/policy-graph.err"; then
ok "policy loads cleanly (no syntax errors)"
else
err "policy still has errors:"
cat "$BK/policy-graph.err"
fi
echo
ok "repair complete. Backups in $BK"
echo "Now test from the ai qube:"
echo " signer-client --qrexec nostr_signer:qubes.SignerRpc --role main --path \"m/44'/1237'/0'/0/0\" get-public-key"
echo "Clipboard (dom0 -> ai) should also work again: Ctrl+Shift+C in dom0, Ctrl+Shift+V in ai"
Submodule
+1
Submodule ratatui added at 31809ba9d4
+93 -27
View File
@@ -6,7 +6,7 @@
use crate::mnemonic::MnemonicState;
use crate::pq_crypto::CryptoAlg;
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
use crate::SignerError;
pub const ALG_KEY_CACHE_MAX: usize = 32;
@@ -47,9 +47,9 @@ impl AlgorithmKeyCache {
mnemonic: &MnemonicState,
alg: CryptoAlg,
index: i32,
) -> Result<(), NsignerError> {
) -> Result<(), SignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
return Err(SignerError::MnemonicNotLoaded);
}
// Already cached?
@@ -62,19 +62,9 @@ impl AlgorithmKeyCache {
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 phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
let path = standard_path(alg, index)?;
let entry = derive_alg_key(phrase, &path, alg, index)?;
self.entries.push(entry);
Ok(())
@@ -92,18 +82,48 @@ impl Default for AlgorithmKeyCache {
}
}
/// The standard derivation path for an algorithm at a given index.
///
/// secp256k1 uses the NIP-06 path; ed25519/x25519/PQ use their
/// per-algorithm SLIP-44 coin types (102001'102005').
pub fn standard_path(alg: CryptoAlg, index: i32) -> Result<String, SignerError> {
match alg {
CryptoAlg::Secp256k1 => Ok(format!("m/44'/1237'/{}'/0/0", index)),
CryptoAlg::Ed25519 => Ok(format!("m/44'/102001'/{}'/0'/0'", index)),
CryptoAlg::X25519 => Ok(format!("m/44'/102002'/{}'/0'/0'", index)),
CryptoAlg::MlDsa65 => Ok(format!("m/44'/102003'/{}'/0'/0'", index)),
CryptoAlg::SlhDsa128s => Ok(format!("m/44'/102004'/{}'/0'/0'", index)),
CryptoAlg::MlKem768 => Ok(format!("m/44'/102005'/{}'/0'/0'", index)),
CryptoAlg::Unknown => Err(SignerError::InvalidInput),
}
}
/// Derive a single algorithm key entry.
fn derive_alg_key(
mnemonic_phrase: &str,
path: &str,
alg: CryptoAlg,
index: i32,
) -> Result<AlgKeyEntry, NsignerError> {
) -> Result<AlgKeyEntry, SignerError> {
let sizes = alg
.sizes()
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let seed = crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
// PQ algorithms use the v2 seeded derivation: exact-length seed
// (32/48/64 B) from BIP-32 children, fed to the seeded keygen APIs.
// Classical algorithms use the plain 32-byte derived seed.
let seed = match alg {
CryptoAlg::MlDsa65 => {
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 32)?
}
CryptoAlg::SlhDsa128s => {
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 48)?
}
CryptoAlg::MlKem768 => {
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 64)?
}
_ => crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?.to_vec(),
};
match alg {
CryptoAlg::Secp256k1 => {
@@ -111,19 +131,19 @@ fn derive_alg_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)?;
.map_err(|_| SignerError::KeyDerivationFailed)?;
let (derived_key, _) = nips::nip006::bip32_derive_path(
&master_key,
&master_chain_code,
&path_indices,
)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
.map_err(|_| SignerError::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)?;
.map_err(|_| SignerError::CryptoFailed)?;
let pubkey_hex = hex::encode(pk.as_bytes());
let key_id = if pubkey_hex.len() >= 16 {
@@ -149,7 +169,9 @@ fn derive_alg_key(
})
}
CryptoAlg::Ed25519 => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed);
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed_arr);
let pubkey_hex = hex::encode(&pub_bytes);
let key_id = if pubkey_hex.len() >= 16 {
pubkey_hex[..16].to_string()
@@ -174,7 +196,9 @@ fn derive_alg_key(
})
}
CryptoAlg::X25519 => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed);
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed_arr);
let pubkey_hex = hex::encode(&pub_bytes);
let key_id = if pubkey_hex.len() >= 16 {
pubkey_hex[..16].to_string()
@@ -198,10 +222,52 @@ fn derive_alg_key(
valid: true,
})
}
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
// PQ algorithms — TODO: Phase 13
Err(NsignerError::NotYetImplemented)
CryptoAlg::MlDsa65 => {
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = crate::pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr)?;
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
}
CryptoAlg::Unknown => Err(NsignerError::InvalidInput),
CryptoAlg::SlhDsa128s => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::slh_dsa_128s_keygen_from_seed(&seed)?;
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
}
CryptoAlg::MlKem768 => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::ml_kem_768_keygen_from_seed(&seed)?;
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
}
CryptoAlg::Unknown => Err(SignerError::InvalidInput),
}
}
/// Build an `AlgKeyEntry` from PQ keygen output (seed-form private key).
fn finish_pq_entry(
alg: CryptoAlg,
index: i32,
sizes: crate::pq_crypto::CryptoAlgSizes,
priv_bytes: Vec<u8>,
pub_bytes: Vec<u8>,
) -> Result<AlgKeyEntry, SignerError> {
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,
})
}
+4 -4
View File
@@ -132,17 +132,17 @@ pub fn verify_request(
return Err((AUTH_ERR_KIND_INVALID, "auth_kind_invalid"));
}
// Extract tags: nsigner_rpc, nsigner_method, nsigner_body_hash
// Extract tags: signer_rpc, signer_method, signer_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" {
if tag.kind() == "signer_rpc" {
tag_rpc = tag.get(1).map(|s| s.to_string());
} else if tag.kind() == "nsigner_method" {
} else if tag.kind() == "signer_method" {
tag_method = tag.get(1).map(|s| s.to_string());
} else if tag.kind() == "nsigner_body_hash" {
} else if tag.kind() == "signer_body_hash" {
tag_body_hash = tag.get(1).map(|s| s.to_string());
}
}
+180
View File
@@ -0,0 +1,180 @@
# `signer-client` — Rust CLI for signer
A standalone command-line client that connects to a running [`signer`](../..)
process and calls its JSON-RPC verbs over stdin/stdout. Designed for
pipe-to-`nak` workflows. Rust port of the C
[`n_signer_client.c`](../../n_signer/client/n_signer_client.c).
## Build
```bash
cargo build --bin signer-client
```
Produces `target/debug/signer-client` (or `target/release/signer-client`).
## Usage
```
signer-client [global options] <verb> [verb args...]
```
### Global options
| Flag | Default | Meaning |
|------|---------|---------|
| `--socket-name`, `-n <name>` | auto-discover | Abstract socket name without `@` |
| `--timeout <ms>` | `5000` | Transport timeout |
| `--tcp <host:port>` | none | TCP transport (requires `--auth-privkey`) |
| `--serial <device>` | none | USB CDC-ACM serial transport |
| `--qrexec <qube:service>` | none | Qubes qrexec transport |
| `--auth-privkey <32-byte hex>` | none | Auth envelope privkey for TCP |
| `--auth-label <text>` | none | Auth envelope label |
### Selector options (nostr verbs)
| Flag | Meaning | JSON emitted |
|------|---------|-------------|
| `--role <name>` | Named path-role registered in the signer | `{"role":"<name>"}` |
| `--path <path>` | Full BIP-44 derivation path | `{"role_path":"<path>"}` |
### Algorithm options (algorithm verbs)
| Flag | Default | Meaning |
|------|---------|---------|
| `--algorithm`, `-a <alg>` | none | `secp256k1`/`ed25519`/`x25519`/`ml-dsa-65`/`slh-dsa-128s`/`ml-kem-768`/`otp` |
| `--index <N>` | `0` | Algorithm derivation index |
| `--scheme <schnorr\|ecdsa>` | `schnorr` | secp256k1 `sign`/`verify` only |
| `--encoding <ascii\|binary>` | `ascii` | OTP `encrypt`/`decrypt` only |
| `--format <plain\|structured>` | `plain` | `get-public-key` output shape |
### Mine-event options
| Flag | Meaning |
|------|---------|
| `--difficulty <N>` | Target leading zero bits |
| `--threads <N>` | Mining threads (default 1) |
| `--timeout-sec <N>` | Mining timeout in seconds |
## Verb reference
### Utility
| Verb | stdout |
|------|--------|
| `list` | Lists running signer abstract sockets (one per line) |
### Metadata
| Verb | RPC method | stdout |
|------|------------|--------|
| `get-info` | `get_info` | raw result JSON (name, version, verbs, algorithms) |
### Nostr verbs (role-based; require `--role` + `--path`)
| Verb | RPC method | stdin/argv | stdout |
|------|------------|------------|--------|
| `get-public-key` | `nostr_get_public_key` | none | pubkey hex (or structured JSON with `--format structured`) |
| `sign-event` | `nostr_sign_event` | event JSON from argv or stdin | signed event JSON |
| `mine-event` | `nostr_mine_event` | event JSON from argv or stdin | signed mined event JSON |
| `nip04-encrypt <peer>` | `nostr_nip04_encrypt` | plaintext from argv or stdin | ciphertext |
| `nip04-decrypt <peer>` | `nostr_nip04_decrypt` | ciphertext from argv or stdin | plaintext |
| `nip44-encrypt <peer>` | `nostr_nip44_encrypt` | plaintext from argv or stdin | ciphertext |
| `nip44-decrypt <peer>` | `nostr_nip44_decrypt` | ciphertext from argv or stdin | plaintext |
### Algorithm-based verbs (use `--algorithm` + `--index`)
| Verb | RPC method | argv | stdout |
|------|------------|------|--------|
| `get-public-key` | `get_public_key` | none | structured JSON `{"algorithm":...,"public_key":...,"key_id":...}` |
| `sign <msg-hex>` | `sign` | hex bytes | structured JSON `{"signature":...,"algorithm":...,"key_id":...}` |
| `verify <msg-hex> <sig-hex>` | `verify` | hex bytes | `valid` / `invalid` (exit 0/1) |
| `derive <data>` | `derive` | UTF-8 data (argv or stdin) | structured JSON |
| `encapsulate <peer-pubkey-hex>` | `encapsulate` | hex | structured JSON |
| `decapsulate <ciphertext-hex>` | `decapsulate` | hex | structured JSON |
| `derive-shared-secret <peer-pubkey-hex>` | `derive_shared_secret` | hex | shared secret hex |
| `encrypt <plaintext>` | `encrypt` | plaintext (argv or stdin) | ciphertext |
| `decrypt <ciphertext>` | `decrypt` | ciphertext (argv or stdin) | plaintext |
### Generic escape hatch
| Verb | RPC method | input | stdout |
|------|------------|-------|--------|
| `call <method>` | `<method>` | JSON params array from stdin or argv | raw result JSON |
## Selector explanation
The `nostr_*` verbs select a key via the options object using both
`--role` and `--path`:
- **`--role <name> --path <path>`** — Both required for all `nostr_*` verbs.
Sends `{"role":"<name>","role_path":"<path>"}` to the server.
- **`--role` without `--path`** — Client-side error: `--path is required`.
- **`--path` without `--role`** — Client-side error: `--role is required`.
For algorithm verbs, `--algorithm` and `--index` populate the options object
instead.
## Pipe-to-nak recipes
```bash
# Get public key
signer-client --role main --path "m/44'/1237'/0'/0/0" get-public-key
# Sign an event and publish via nak
echo '{"kind":1,"content":"hello nostr","tags":[],"created_at":1700000000}' \
| signer-client --role main --path "m/44'/1237'/0'/0/0" sign-event \
| nak publish
# Algorithm-based signing
signer-client --algorithm ed25519 --index 0 sign 68656c6c6f
# Verify a signature
signer-client --algorithm secp256k1 verify <msg-hex> <sig-hex> && echo "valid"
# Get signer info
signer-client get-info
```
## Transport options
| Transport | Flag | Notes |
|-----------|------|-------|
| UNIX abstract socket | `--socket-name <name>` or auto-discover | Default. Auto-discovers if exactly one `signer*` socket exists. |
| TCP | `--tcp <host:port>` | Requires `--auth-privkey` for auth envelope. |
| Serial (USB CDC-ACM) | `--serial <device>` | e.g. `--serial /dev/ttyACM0` |
| Qubes qrexec | `--qrexec <qube:service>` | e.g. `--qrexec sys-signer:qubes.SignerRpc` |
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Invalid (verify verb only — signature is invalid) |
| 2 | Error (transport, RPC, or usage error) |
## stdin/stdout contract
- All payload output goes to stdout as a single line, newline-terminated.
- All diagnostics (errors, warnings) go to stderr.
- `sign-event`, `nip04-*`, `nip44-*`, `derive`, `encrypt`, `decrypt` read
their payload from argv if present, otherwise from stdin (one line).
- `sign`, `verify`, `encapsulate`, `decapsulate`, `derive-shared-secret`
take hex from argv only (binary payloads).
- `call` reads a JSON params array from stdin (one line) or argv.
## Module layout
| File | Role |
|------|------|
| [`main.rs`](main.rs:1) | Entry point, CLI parse, verb dispatch, stdin/stdout |
| [`cli.rs`](cli.rs:1) | clap `Cli` / `Verb` structs + usage text |
| [`transport.rs`](transport.rs:1) | `ClientTransport` enum (Unix/Tcp/Serial/Qrexec) |
| [`rpc.rs`](rpc.rs:1) | Low-level `SignerClient` (framed JSON-RPC send/recv) |
| [`signer.rs`](signer.rs:1) | High-level `SignerSigner` typed-verb wrappers |
| [`auth.rs`](auth.rs:1) | Client-side auth envelope builder (NIP-42 kind 22242) |
## See also
- [`plans/signer_client_plan.md`](../../plans/signer_client_plan.md:1) — implementation plan
- [`README.md`](../../README.md:1) — signer main documentation
+100
View File
@@ -0,0 +1,100 @@
//! Client-side auth envelope builder for TCP/qrexec transports.
//!
//! Builds a NIP-42 kind-22242 auth event matching the wire shape verified by
//! [`signer::auth_envelope::verify_request`]. The event is attached as a
//! top-level `"auth"` field on the JSON-RPC request.
//!
//! Tag contract (must match the server verifier):
//! - `["signer_rpc", <request id>]`
//! - `["signer_method", <method>]`
//! - `["signer_body_hash", <hex sha256 of compact params JSON>]`
//! - `content` = auth label
use nostr_core::types::{Event, Kind, SecretKey, Tag};
use serde_json::Value;
/// NIP-42 auth event kind (matches server `AUTH_EVENT_KIND`).
const AUTH_EVENT_KIND: u64 = 22242;
/// Build a signed auth event for a JSON-RPC request.
///
/// `request_id` is the JSON-RPC `id`, `method` is the verb, `params` is the
/// params array (will be serialized compactly for the body hash), and
/// `label` becomes the event `content`.
pub fn build_auth_event(
privkey_hex: &str,
request_id: &str,
method: &str,
params: &Value,
label: &str,
) -> Result<Event, String> {
// Decode 32-byte privkey
let priv_bytes = hex::decode(privkey_hex)
.map_err(|_| "--auth-privkey must be valid hex".to_string())?;
if priv_bytes.len() != 32 {
return Err("--auth-privkey must be 32 bytes (64 hex chars)".into());
}
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes);
let sk = SecretKey::from_bytes(priv_arr);
// Body hash = SHA-256 of compact params JSON (matches server)
let params_compact = serde_json::to_string(params)
.map_err(|e| format!("params serialize failed: {}", e))?;
let body_hash = nostr_core::crypto::sha256::sha256(params_compact.as_bytes());
let body_hash_hex = hex::encode(&body_hash);
// Tags (two-element: kind + value)
let tags = vec![
Tag::with_value("signer_rpc", request_id),
Tag::with_value("signer_method", method),
Tag::with_value("signer_body_hash", &body_hash_hex),
];
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let kind = Kind::from_u64(AUTH_EVENT_KIND);
nips::nip001::create_and_sign_event(kind, label, tags, &sk, created_at)
.map_err(|e| format!("auth event sign failed: {}", e))
}
/// Attach a signed auth event to a JSON-RPC request value (in place).
///
/// The request must be a JSON object with `id`, `method`, `params` fields.
pub fn attach_auth(
request: &mut Value,
privkey_hex: &str,
label: &str,
) -> Result<(), String> {
let id = request
.get("id")
.ok_or("request missing id")?
.to_string();
let id_str = if let Some(s) = request.get("id").and_then(|v| v.as_str()) {
s.to_string()
} else {
id
};
let method = request
.get("method")
.and_then(|v| v.as_str())
.ok_or("request missing method")?
.to_string();
let params = request
.get("params")
.ok_or("request missing params")?;
let event = build_auth_event(privkey_hex, &id_str, &method, params, label)?;
let event_val = serde_json::to_value(&event)
.map_err(|e| format!("auth event serialize failed: {}", e))?;
if let Some(obj) = request.as_object_mut() {
obj.insert("auth".to_string(), event_val);
Ok(())
} else {
Err("request is not an object".into())
}
}
+301
View File
@@ -0,0 +1,301 @@
//! CLI parsing — clap structs mirroring the C `n_signer_client.c` argv loop.
use clap::{Parser, Subcommand};
/// Command-line arguments for signer-client.
#[derive(Parser, Debug)]
#[command(
name = "signer-client",
version = ::signer::VERSION,
about = "Standalone CLI for the signer JSON-RPC API",
after_help = "EXAMPLES:
# List running signer sockets
signer-client list
# Get signer metadata (auto-discovers the single running socket)
signer-client get-info
# Get a Nostr public key by role + path
signer-client --role main --path \"m/44'/1237'/0'/0/0\" get-public-key
# Get a public key by algorithm + index
signer-client --algorithm ed25519 --index 0 get-public-key
# Sign a raw message (hex) with ed25519
signer-client --algorithm ed25519 --index 0 sign 68656c6c6f
# Verify a signature (exit 0 = valid, 1 = invalid)
signer-client --algorithm ed25519 --index 0 verify <msg-hex> <sig-hex>
# Sign a Nostr event from stdin and pipe to nak for publishing
echo '{\"kind\":1,\"content\":\"hello nostr\",\"tags\":[],\"created_at\":1700000000}' \\
| signer-client --role main --path \"m/44'/1237'/0'/0/0\" sign-event \\
| nak publish
# NIP-44 encrypt a message to a peer
signer-client --role main --path \"m/44'/1237'/0'/0/0\" nip44-encrypt <peer-pubkey> 'secret'
# NIP-44 decrypt
signer-client --role main --path \"m/44'/1237'/0'/0/0\" nip44-decrypt <peer-pubkey> <ciphertext>
# Derive an HMAC digest (secp256k1)
signer-client --algorithm secp256k1 --index 0 derive testdata
# Raw JSON-RPC passthrough
echo '[]' | signer-client call get_info
# Explicit socket name
signer-client -n signer01 get-info
# TCP transport (requires auth envelope privkey)
signer-client --tcp host:port --auth-privkey <64-hex> get-info
# Serial transport (USB CDC-ACM)
signer-client --serial /dev/ttyACM0 get-info
# Qubes qrexec transport
signer-client --qrexec sys-signer:qubes.SignerRpc get-info
Run 'signer-client <verb> --help' for verb-specific details."
)]
pub struct Cli {
/// Abstract socket name (without @ prefix). Default: auto-discover.
#[arg(long, short = 'n', value_name = "name", global = true)]
pub socket_name: Option<String>,
/// Transport timeout in milliseconds (default 5000).
#[arg(long, value_name = "ms", global = true, default_value = "5000")]
pub timeout: u64,
/// TCP transport: host:port (requires --auth-privkey).
#[arg(long, value_name = "host:port", global = true)]
pub tcp: Option<String>,
/// USB CDC-ACM serial transport: device path.
#[arg(long, value_name = "device", global = true)]
pub serial: Option<String>,
/// Qubes qrexec transport: qube:service.
#[arg(long, value_name = "qube:service", global = true)]
pub qrexec: Option<String>,
/// Auth envelope privkey (32 bytes hex) for TCP/qrexec.
#[arg(long, value_name = "hex", global = true)]
pub auth_privkey: Option<String>,
/// Auth envelope label.
#[arg(long, value_name = "text", global = true)]
pub auth_label: Option<String>,
// ── Selector options (nostr verbs) ───────────────────────────────
/// Named path-role.
#[arg(long, value_name = "name", global = true)]
pub role: Option<String>,
/// Full BIP-44 derivation path.
#[arg(long, value_name = "path", global = true)]
pub path: Option<String>,
// ── Algorithm options ────────────────────────────────────────────
/// Algorithm: secp256k1/ed25519/x25519/ml-dsa-65/slh-dsa-128s/ml-kem-768/otp.
#[arg(long, short = 'a', value_name = "alg", global = true)]
pub algorithm: Option<String>,
/// Algorithm derivation index (default 0).
#[arg(long, value_name = "N", global = true)]
pub index: Option<i64>,
/// secp256k1 sign/verify scheme: schnorr|ecdsa (default schnorr).
#[arg(long, value_name = "s", global = true)]
pub scheme: Option<String>,
/// OTP encoding: ascii|binary (default ascii).
#[arg(long, value_name = "enc", global = true)]
pub encoding: Option<String>,
/// get-public-key output format: plain|structured (default plain).
#[arg(long, value_name = "fmt", global = true)]
pub format: Option<String>,
// ── Mine-event options ───────────────────────────────────────────
/// Target leading zero bits for mine-event.
#[arg(long, value_name = "N", global = true)]
pub difficulty: Option<u32>,
/// Mining threads (default 1).
#[arg(long, value_name = "N", global = true)]
pub threads: Option<u32>,
/// Mining timeout in seconds.
#[arg(long, value_name = "N", global = true)]
pub timeout_sec: Option<u64>,
/// Verb subcommand.
#[command(subcommand)]
pub verb: Verb,
}
/// Verb subcommands.
#[derive(Subcommand, Debug)]
pub enum Verb {
/// List running signer abstract sockets.
List,
/// Get signer metadata.
GetInfo,
/// Get a public key (nostr role-based or algorithm-based).
GetPublicKey,
/// Sign a Nostr event (event JSON from argv or stdin).
SignEvent,
/// Mine a Nostr event with proof-of-work.
MineEvent,
/// NIP-04 encrypt: <peer-pubkey> [plaintext]
Nip04Encrypt {
/// Peer public key (hex).
peer: String,
/// Plaintext (read from stdin if omitted).
plaintext: Option<String>,
},
/// NIP-04 decrypt: <peer-pubkey> [ciphertext]
Nip04Decrypt {
/// Peer public key (hex).
peer: String,
/// Ciphertext (read from stdin if omitted).
ciphertext: Option<String>,
},
/// NIP-44 encrypt: <peer-pubkey> [plaintext]
Nip44Encrypt {
/// Peer public key (hex).
peer: String,
/// Plaintext (read from stdin if omitted).
plaintext: Option<String>,
},
/// NIP-44 decrypt: <peer-pubkey> [ciphertext]
Nip44Decrypt {
/// Peer public key (hex).
peer: String,
/// Ciphertext (read from stdin if omitted).
ciphertext: Option<String>,
},
/// Sign a raw message: <msg-hex>
Sign {
/// Message bytes (hex).
msg_hex: String,
},
/// Verify a signature: <msg-hex> <sig-hex>
Verify {
/// Message bytes (hex).
msg_hex: String,
/// Signature bytes (hex).
sig_hex: String,
},
/// Derive an HMAC or algorithm digest: [data]
Derive {
/// Data (UTF-8; read from stdin if omitted).
data: Option<String>,
},
/// ML-KEM encapsulate: <peer-pubkey-hex>
Encapsulate {
/// Peer public key (hex).
peer_pubkey_hex: String,
},
/// ML-KEM decapsulate: <ciphertext-hex>
Decapsulate {
/// Ciphertext (hex).
ciphertext_hex: String,
},
/// X25519 derive-shared-secret: <peer-pubkey-hex>
DeriveSharedSecret {
/// Peer public key (hex).
peer_pubkey_hex: String,
},
/// OTP encrypt: [plaintext]
Encrypt {
/// Plaintext (read from stdin if omitted).
plaintext: Option<String>,
},
/// OTP decrypt: [ciphertext]
Decrypt {
/// Ciphertext (read from stdin if omitted).
ciphertext: Option<String>,
},
/// Raw passthrough: <method> [params-json...]
Call {
/// JSON-RPC method name.
method: String,
/// JSON params (joined; read from stdin if omitted).
params: Vec<String>,
},
}
/// Print usage to stderr (used for --help fallback / errors).
#[allow(dead_code)]
pub fn print_usage(prog: &str) {
eprintln!(
"Usage: {prog} [global options] <verb> [verb args...]\n\
\n\
Global options:\n\
\x20 -n, --socket-name <name> Abstract socket name (default: auto-discover)\n\
\x20 --timeout <ms> Transport timeout (default 5000)\n\
\x20 --tcp <host:port> TCP transport (requires --auth-privkey)\n\
\x20 --serial <device> USB CDC-ACM serial transport\n\
\x20 --qrexec <qube:svc> Qubes qrexec transport\n\
\x20 --auth-privkey <hex> Auth envelope privkey (32 bytes hex)\n\
\x20 --auth-label <text> Auth envelope label\n\
\n\
Selector options (nostr verbs):\n\
\x20 --role <name> Named path-role\n\
\x20 --path <path> Full BIP-44 derivation path\n\
\n\
Algorithm options:\n\
\x20 -a, --algorithm <alg> secp256k1/ed25519/x25519/ml-dsa-65/slh-dsa-128s/ml-kem-768/otp\n\
\x20 --scheme <schnorr|ecdsa> secp256k1 sign/verify scheme (default schnorr)\n\
\x20 --encoding <ascii|binary> OTP encoding (default ascii)\n\
\x20 --format <plain|structured> get-public-key output (default plain)\n\
\x20 --index <N> Algorithm derivation index\n\
\n\
Mine-event options:\n\
\x20 --difficulty <N> Target leading zero bits\n\
\x20 --threads <N> Mining threads (default 1)\n\
\x20 --timeout-sec <N> Mining timeout in seconds\n\
\n\
Verbs:\n\
\x20 list List running signer sockets\n\
\x20 get-info\n\
\x20 get-public-key\n\
\x20 sign-event\n\
\x20 mine-event\n\
\x20 nip04-encrypt <peer-pubkey>\n\
\x20 nip04-decrypt <peer-pubkey>\n\
\x20 nip44-encrypt <peer-pubkey>\n\
\x20 nip44-decrypt <peer-pubkey>\n\
\x20 sign <msg-hex>\n\
\x20 verify <msg-hex> <sig-hex>\n\
\x20 derive <data>\n\
\x20 encapsulate <peer-pubkey-hex>\n\
\x20 decapsulate <ciphertext-hex>\n\
\x20 derive-shared-secret <peer-pubkey-hex>\n\
\x20 encrypt <plaintext>\n\
\x20 decrypt <ciphertext>\n\
\x20 call <method>\n\
\n\
Run '{prog} --help' for full clap-generated usage."
);
}
+551
View File
@@ -0,0 +1,551 @@
//! signer-client — standalone CLI for the signer JSON-RPC API.
//!
//! Port of the C [`n_signer_client.c`](../../n_signer/client/n_signer_client.c).
//! Connects to a running `signer` process over its framed transports
//! (Unix / TCP / serial / qrexec) and exposes the full verb surface over
//! stdin/stdout so that signed events can be piped directly into `nak publish`.
mod auth;
mod cli;
mod rpc;
mod signer;
mod transport;
use clap::Parser;
use cli::{Cli, Verb};
use crate::signer::{result_to_line, AlgOptions, SignerSigner};
fn main() {
let cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => {
// clap prints its own usage on --help / errors; exit accordingly.
e.exit();
}
};
std::process::exit(run(cli));
}
/// Entry point. Returns the exit code.
fn run(cli: Cli) -> i32 {
// ── list verb (no connection needed) ──────────────────────────────
if matches!(cli.verb, Verb::List) {
let sockets = ::signer::socket_name::list_sockets();
if sockets.is_empty() {
println!("no signer sockets found");
} else {
for name in &sockets {
println!("{}", name);
}
}
return 0;
}
// ── validate --index usage (algorithm-only) ───────────────────────
let is_algorithm_verb = cli.algorithm.is_some();
if cli.index.is_some() && !is_algorithm_verb {
eprintln!("error: --index is only valid with --algorithm (for algorithm verbs)");
return 2;
}
// ── validate --role / --path for nostr verbs ──────────────────────
let is_nostr_verb = matches!(
cli.verb,
Verb::GetPublicKey
| Verb::SignEvent
| Verb::MineEvent
| Verb::Nip04Encrypt { .. }
| Verb::Nip04Decrypt { .. }
| Verb::Nip44Encrypt { .. }
| Verb::Nip44Decrypt { .. }
);
if is_nostr_verb && !is_algorithm_verb {
if cli.role.is_none() {
eprintln!("error: --role is required for nostr verbs");
return 2;
}
if cli.path.is_none() {
eprintln!("error: --path is required for nostr verbs");
return 2;
}
}
// ── open transport ────────────────────────────────────────────────
let (transport, _resolved_name) = match transport::open_from_cli(
cli.socket_name.as_deref(),
cli.timeout,
cli.tcp.as_deref(),
cli.serial.as_deref(),
cli.qrexec.as_deref(),
) {
Ok(t) => t,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
// ── TCP requires auth-privkey ─────────────────────────────────────
if cli.tcp.is_some() && cli.auth_privkey.is_none() {
eprintln!("error: --tcp requires --auth-privkey");
return 2;
}
let mut signer = SignerSigner::new(rpc::SignerClient::new(transport));
// ── set selector for nostr verbs ──────────────────────────────────
if is_nostr_verb && !is_algorithm_verb {
signer.set_selector(cli.role.clone(), cli.path.clone());
}
// ── set auth envelope if configured ───────────────────────────────
if let Some(privkey) = &cli.auth_privkey {
// Validate hex length up front.
if hex::decode(privkey).map(|b| b.len()).unwrap_or(0) != 32 {
eprintln!("error: --auth-privkey must be 32 bytes (64 hex chars)");
return 2;
}
signer.set_auth(privkey.clone(), cli.auth_label.clone().unwrap_or_default());
}
// ── build algorithm options if --algorithm is set ─────────────────
let alg_opts = if is_algorithm_verb {
Some(AlgOptions {
algorithm: cli.algorithm.clone().unwrap_or_else(|| "secp256k1".into()),
index: cli.index.unwrap_or(0),
scheme: cli.scheme.clone(),
encoding: cli.encoding.clone(),
format: cli.format.clone(),
})
} else {
None
};
// ── dispatch verb ─────────────────────────────────────────────────
match cli.verb {
Verb::List => unreachable!("handled above"),
Verb::GetInfo => {
match signer.get_info() {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::GetPublicKey => {
if is_algorithm_verb {
let opts = alg_opts.as_ref().unwrap();
match signer.alg_get_public_key(opts) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
} else {
let want_structured = cli.format.as_deref() == Some("structured");
match signer.nostr_get_public_key(want_structured) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
}
Verb::SignEvent => {
let event_json = match read_event_json(None) {
Ok(j) => j,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
match signer.nostr_sign_event(&event_json) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::MineEvent => {
let event_json = match read_event_json(None) {
Ok(j) => j,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
match signer.nostr_mine_event(
&event_json,
cli.difficulty.unwrap_or(0),
cli.threads.unwrap_or(1),
cli.timeout_sec.unwrap_or(0),
) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Nip04Encrypt { peer, plaintext } => {
let pt = match read_payload(plaintext) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
match signer.nip04_encrypt(&peer, &pt) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Nip04Decrypt { peer, ciphertext } => {
let ct = match read_payload(ciphertext) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
match signer.nip04_decrypt(&peer, &ct) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Nip44Encrypt { peer, plaintext } => {
let pt = match read_payload(plaintext) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
match signer.nip44_encrypt(&peer, &pt) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Nip44Decrypt { peer, ciphertext } => {
let ct = match read_payload(ciphertext) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
match signer.nip44_decrypt(&peer, &ct) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Sign { msg_hex } => {
if hex::decode(&msg_hex).is_err() {
eprintln!("error: invalid hex message");
return 2;
}
let fallback = AlgOptions {
algorithm: "secp256k1".into(),
index: 0,
scheme: cli.scheme.clone(),
encoding: None,
format: None,
};
let opts = alg_opts.as_ref().unwrap_or(&fallback);
match signer.alg_sign(opts, &msg_hex) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Verify { msg_hex, sig_hex } => {
if hex::decode(&msg_hex).is_err() {
eprintln!("error: invalid hex message");
return 2;
}
if hex::decode(&sig_hex).is_err() {
eprintln!("error: invalid hex signature");
return 2;
}
let fallback = AlgOptions {
algorithm: "secp256k1".into(),
index: 0,
scheme: cli.scheme.clone(),
encoding: None,
format: None,
};
let opts = alg_opts.as_ref().unwrap_or(&fallback);
match signer.alg_verify(opts, &msg_hex, &sig_hex) {
Ok(valid) => {
println!("{}", if valid { "valid" } else { "invalid" });
if valid {
0
} else {
1
}
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Derive { data } => {
let data = match read_payload(data) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
if is_algorithm_verb {
let opts = alg_opts.as_ref().unwrap();
match signer.alg_derive(opts, &data) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
} else {
// Nostr derive (HMAC) — use the nostr selector with the derive verb.
let opts = signer.selector.to_options();
match signer.call_raw_method("derive", serde_json::json!([data, opts])) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
}
Verb::Encapsulate { peer_pubkey_hex } => {
let opts = alg_opts.as_ref().unwrap();
match signer.alg_encapsulate(opts, &peer_pubkey_hex) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Decapsulate { ciphertext_hex } => {
let opts = alg_opts.as_ref().unwrap();
match signer.alg_decapsulate(opts, &ciphertext_hex) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::DeriveSharedSecret { peer_pubkey_hex } => {
let opts = alg_opts.as_ref().unwrap();
match signer.alg_derive_shared_secret(opts, &peer_pubkey_hex) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Encrypt { plaintext } => {
let pt = match read_payload(plaintext) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
let fallback = AlgOptions {
algorithm: "otp".into(),
index: 0,
scheme: None,
encoding: cli.encoding.clone(),
format: None,
};
let opts = alg_opts.as_ref().unwrap_or(&fallback);
match signer.otp_encrypt(opts, &pt) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Decrypt { ciphertext } => {
let ct = match read_payload(ciphertext) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
let fallback = AlgOptions {
algorithm: "otp".into(),
index: 0,
scheme: None,
encoding: cli.encoding.clone(),
format: None,
};
let opts = alg_opts.as_ref().unwrap_or(&fallback);
match signer.otp_decrypt(opts, &ct) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
Verb::Call { method, params } => {
let params_value = match build_call_params(params) {
Ok(v) => v,
Err(e) => {
eprintln!("error: {}", e);
return 2;
}
};
match signer.call_raw_method(&method, params_value) {
Ok(result) => {
println!("{}", result_to_line(&result));
0
}
Err(e) => {
eprintln!("error: {}", e);
2
}
}
}
}
}
/// Read one line from stdin (newline stripped).
fn read_stdin_line() -> Result<String, String> {
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| format!("stdin read failed: {}", e))?;
let trimmed = input.trim_end_matches('\n').to_string();
if trimmed.is_empty() {
Err("no input on stdin".into())
} else {
Ok(trimmed)
}
}
/// Read a payload: use `argv` if provided, else one stdin line.
fn read_payload(argv: Option<String>) -> Result<String, String> {
if let Some(s) = argv {
Ok(s)
} else {
read_stdin_line()
}
}
/// Read event JSON: from argv (passed in) or one stdin line.
fn read_event_json(argv: Option<String>) -> Result<String, String> {
read_payload(argv)
}
/// Build a JSON params value for the `call` verb.
///
/// If argv params are given, join them with spaces and parse as JSON.
/// Otherwise read one line from stdin and parse.
fn build_call_params(params: Vec<String>) -> Result<serde_json::Value, String> {
if params.is_empty() {
let line = read_stdin_line()?;
serde_json::from_str(&line)
.map_err(|e| format!("failed to parse params JSON from stdin: {}", e))
} else {
let joined = params.join(" ");
serde_json::from_str(&joined)
.map_err(|e| format!("failed to parse params JSON from argv: {}", e))
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Low-level JSON-RPC 2.0 client — framed send/recv over a transport.
//!
//! Port of the C `signer_client_t` / `signer_client_call`. Builds a
//! `{"id","method","params"}` request, sends it framed, receives the framed
//! response, and splits `result` vs `error`.
use serde_json::{json, Value};
use super::transport::ClientTransport;
/// Low-level signer RPC client. Owns the transport.
pub struct SignerClient {
transport: ClientTransport,
last_error: String,
next_id: u64,
}
impl SignerClient {
/// Wrap an open transport.
pub fn new(transport: ClientTransport) -> Self {
SignerClient {
transport,
last_error: String::new(),
next_id: 1,
}
}
/// Last error message from a failed call.
#[allow(dead_code)]
pub fn last_error(&self) -> &str {
&self.last_error
}
/// Allocate the next request id.
fn next_request_id(&mut self) -> String {
let id = self.next_id.to_string();
self.next_id += 1;
id
}
/// Send a raw JSON-RPC request value and return the parsed response value.
///
/// The caller is responsible for attaching an `auth` field if needed.
pub fn call_raw(&mut self, request: &Value) -> Result<Value, String> {
let request_str = serde_json::to_string(request)
.map_err(|e| format!("request serialize failed: {}", e))?;
self.transport
.send(&request_str)
.map_err(|e| format!("send failed: {}", e))?;
let response_str = self
.transport
.recv()
.map_err(|e| format!("recv failed: {}", e))?;
let response: Value = serde_json::from_str(&response_str)
.map_err(|e| format!("response parse failed: {}", e))?;
Ok(response)
}
/// Call a method with the given params array (no auth).
///
/// On success, returns the `result` value. On error, sets `last_error`
/// and returns `Err(message)`.
pub fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_request_id();
let request = json!({
"id": id,
"method": method,
"params": params,
});
let response = self.call_raw(&request)?;
self.extract_result(&response)
}
/// Call a method with an auth envelope attached.
pub fn call_with_auth(
&mut self,
method: &str,
params: Value,
auth_privkey: &str,
auth_label: &str,
) -> Result<Value, String> {
let id = self.next_request_id();
let mut request = json!({
"id": id,
"method": method,
"params": params,
});
super::auth::attach_auth(&mut request, auth_privkey, auth_label)?;
let response = self.call_raw(&request)?;
self.extract_result(&response)
}
/// Extract `result` from a JSON-RPC response, or capture the error.
fn extract_result(&mut self, response: &Value) -> Result<Value, String> {
if let Some(err) = response.get("error") {
let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(0);
let msg = err
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown_error");
self.last_error = format!("{} (code {})", msg, code);
Err(self.last_error.clone())
} else if let Some(result) = response.get("result") {
Ok(result.clone())
} else {
self.last_error = "response has neither result nor error".into();
Err(self.last_error.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_result_success() {
let mut client = SignerClient {
transport: ClientTransport::Unix(
std::os::unix::net::UnixStream::pair().unwrap().0,
),
last_error: String::new(),
next_id: 1,
};
let resp = json!({"id":"1","result":"deadbeef"});
let r = client.extract_result(&resp).unwrap();
assert_eq!(r, json!("deadbeef"));
}
#[test]
fn test_extract_result_error() {
let mut client = SignerClient {
transport: ClientTransport::Unix(
std::os::unix::net::UnixStream::pair().unwrap().0,
),
last_error: String::new(),
next_id: 1,
};
let resp = json!({"id":"1","error":{"code":-32601,"message":"method_not_found"}});
let r = client.extract_result(&resp);
assert!(r.is_err());
assert!(client.last_error().contains("method_not_found"));
assert!(client.last_error().contains("-32601"));
}
}
+318
View File
@@ -0,0 +1,318 @@
//! High-level typed-verb layer — one method per JSON-RPC verb.
//!
//! Port of the C `nostr_signer_t` wrappers. Each method builds the correct
//! `params` array + options object, calls the low-level [`SignerClient`],
//! and parses the typed result.
use serde_json::{json, Value};
use super::rpc::SignerClient;
/// Selector state for nostr verbs.
#[derive(Debug, Clone, Default)]
pub struct Selector {
pub role: Option<String>,
pub role_path: Option<String>,
}
impl Selector {
/// Build the options object for a nostr verb.
pub fn to_options(&self) -> Value {
let mut opts = serde_json::Map::new();
if let Some(r) = &self.role {
opts.insert("role".into(), json!(r));
}
if let Some(p) = &self.role_path {
opts.insert("role_path".into(), json!(p));
}
Value::Object(opts)
}
}
/// Algorithm options for algorithm-based verbs.
#[derive(Debug, Clone, Default)]
pub struct AlgOptions {
pub algorithm: String,
pub index: i64,
pub scheme: Option<String>,
pub encoding: Option<String>,
pub format: Option<String>,
}
impl AlgOptions {
/// Build the options object for an algorithm verb.
pub fn to_options(&self) -> Value {
let mut opts = serde_json::Map::new();
opts.insert("algorithm".into(), json!(self.algorithm));
opts.insert("index".into(), json!(self.index));
if let Some(s) = &self.scheme {
opts.insert("scheme".into(), json!(s));
}
if let Some(e) = &self.encoding {
opts.insert("encoding".into(), json!(e));
}
if let Some(f) = &self.format {
opts.insert("format".into(), json!(f));
}
Value::Object(opts)
}
}
/// High-level signer signer. Wraps a low-level client and holds selector +
/// auth state used across typed verbs.
pub struct SignerSigner {
pub client: SignerClient,
pub selector: Selector,
pub auth_privkey: Option<String>,
pub auth_label: String,
}
impl SignerSigner {
pub fn new(client: SignerClient) -> Self {
SignerSigner {
client,
selector: Selector::default(),
auth_privkey: None,
auth_label: String::new(),
}
}
/// Set the role/path selector for nostr verbs.
pub fn set_selector(&mut self, role: Option<String>, role_path: Option<String>) {
self.selector = Selector { role, role_path };
}
/// Set auth envelope credentials (for TCP/qrexec).
pub fn set_auth(&mut self, privkey_hex: String, label: String) {
self.auth_privkey = Some(privkey_hex);
self.auth_label = label;
}
/// Dispatch a call, attaching auth if configured.
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
if let Some(privkey) = &self.auth_privkey.clone() {
self.client
.call_with_auth(method, params, privkey, &self.auth_label)
} else {
self.client.call(method, params)
}
}
// ── Metadata ─────────────────────────────────────────────────────
/// `get_info` → raw info object.
pub fn get_info(&mut self) -> Result<Value, String> {
self.call("get_info", json!([]))
}
// ── Nostr verbs (role-based) ──────────────────────────────────────
/// `nostr_get_public_key` → pubkey hex string (plain) or structured object.
pub fn nostr_get_public_key(&mut self, want_structured: bool) -> Result<Value, String> {
let mut opts = self.selector.to_options();
if want_structured {
if let Some(obj) = opts.as_object_mut() {
obj.insert("format".into(), json!("structured"));
}
}
self.call("nostr_get_public_key", json!([opts]))
}
/// `nostr_sign_event` → signed event JSON string.
pub fn nostr_sign_event(&mut self, event_json: &str) -> Result<Value, String> {
let opts = self.selector.to_options();
self.call("nostr_sign_event", json!([event_json, opts]))
}
/// `nostr_mine_event` → signed mined event JSON string.
pub fn nostr_mine_event(
&mut self,
event_json: &str,
difficulty: u32,
threads: u32,
timeout_sec: u64,
) -> Result<Value, String> {
let mut opts = self.selector.to_options();
if let Some(obj) = opts.as_object_mut() {
if difficulty > 0 {
obj.insert("difficulty".into(), json!(difficulty));
}
if threads > 0 {
obj.insert("threads".into(), json!(threads));
}
if timeout_sec > 0 {
obj.insert("timeout_sec".into(), json!(timeout_sec));
}
}
self.call("nostr_mine_event", json!([event_json, opts]))
}
/// `nostr_nip04_encrypt` → ciphertext string.
pub fn nip04_encrypt(&mut self, peer: &str, plaintext: &str) -> Result<Value, String> {
let opts = self.selector.to_options();
self.call("nostr_nip04_encrypt", json!([peer, plaintext, opts]))
}
/// `nostr_nip04_decrypt` → plaintext string.
pub fn nip04_decrypt(&mut self, peer: &str, ciphertext: &str) -> Result<Value, String> {
let opts = self.selector.to_options();
self.call("nostr_nip04_decrypt", json!([peer, ciphertext, opts]))
}
/// `nostr_nip44_encrypt` → base64 ciphertext string.
pub fn nip44_encrypt(&mut self, peer: &str, plaintext: &str) -> Result<Value, String> {
let opts = self.selector.to_options();
self.call("nostr_nip44_encrypt", json!([peer, plaintext, opts]))
}
/// `nostr_nip44_decrypt` → base64 plaintext string.
pub fn nip44_decrypt(&mut self, peer: &str, ciphertext_b64: &str) -> Result<Value, String> {
let opts = self.selector.to_options();
self.call("nostr_nip44_decrypt", json!([peer, ciphertext_b64, opts]))
}
// ── Algorithm-based verbs ─────────────────────────────────────────
/// `get_public_key` (algorithm) → structured object.
pub fn alg_get_public_key(&mut self, alg: &AlgOptions) -> Result<Value, String> {
let opts = alg.to_options();
self.call("get_public_key", json!([opts]))
}
/// `sign` (algorithm) → structured object with `signature`.
pub fn alg_sign(&mut self, alg: &AlgOptions, msg_hex: &str) -> Result<Value, String> {
let opts = alg.to_options();
self.call("sign", json!([msg_hex, opts]))
}
/// `verify` (algorithm) → bool (from the `valid` field).
pub fn alg_verify(
&mut self,
alg: &AlgOptions,
msg_hex: &str,
sig_hex: &str,
) -> Result<bool, String> {
let opts = alg.to_options();
let result = self.call("verify", json!([msg_hex, sig_hex, opts]))?;
Ok(result
.get("valid")
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
/// `derive` (algorithm) → structured object with `digest`.
pub fn alg_derive(&mut self, alg: &AlgOptions, data: &str) -> Result<Value, String> {
let opts = alg.to_options();
self.call("derive", json!([data, opts]))
}
/// `encapsulate` (ML-KEM) → structured object.
pub fn alg_encapsulate(&mut self, alg: &AlgOptions, peer_pubkey_hex: &str) -> Result<Value, String> {
let opts = alg.to_options();
self.call("encapsulate", json!([peer_pubkey_hex, opts]))
}
/// `decapsulate` (ML-KEM) → structured object.
pub fn alg_decapsulate(&mut self, alg: &AlgOptions, ciphertext_hex: &str) -> Result<Value, String> {
let opts = alg.to_options();
self.call("decapsulate", json!([ciphertext_hex, opts]))
}
/// `derive_shared_secret` (X25519) → shared secret hex string.
pub fn alg_derive_shared_secret(
&mut self,
alg: &AlgOptions,
peer_pubkey_hex: &str,
) -> Result<Value, String> {
let opts = alg.to_options();
self.call("derive_shared_secret", json!([peer_pubkey_hex, opts]))
}
/// `encrypt` (OTP) → ciphertext string.
pub fn otp_encrypt(&mut self, alg: &AlgOptions, plaintext: &str) -> Result<Value, String> {
let opts = alg.to_options();
self.call("encrypt", json!([plaintext, opts]))
}
/// `decrypt` (OTP) → plaintext string.
pub fn otp_decrypt(&mut self, alg: &AlgOptions, ciphertext: &str) -> Result<Value, String> {
let opts = alg.to_options();
self.call("decrypt", json!([ciphertext, opts]))
}
// ── Generic escape hatch ──────────────────────────────────────────
/// Raw passthrough: call an arbitrary method with a params array.
pub fn call_raw_method(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.call(method, params)
}
}
// ── Result extraction helpers ─────────────────────────────────────────
/// Coerce a result `Value` into a single output line.
///
/// - Strings → the raw string content.
/// - Other JSON → compact JSON serialization.
pub fn result_to_line(result: &Value) -> String {
if let Some(s) = result.as_str() {
s.to_string()
} else {
serde_json::to_string(result).unwrap_or_else(|_| "null".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_selector_options_both() {
let s = Selector {
role: Some("main".into()),
role_path: Some("m/44'/1237'/0'/0/0".into()),
};
let opts = s.to_options();
assert_eq!(opts["role"], json!("main"));
assert_eq!(opts["role_path"], json!("m/44'/1237'/0'/0/0"));
}
#[test]
fn test_alg_options_defaults() {
let a = AlgOptions {
algorithm: "ed25519".into(),
index: 0,
scheme: None,
encoding: None,
format: None,
};
let opts = a.to_options();
assert_eq!(opts["algorithm"], json!("ed25519"));
assert_eq!(opts["index"], json!(0));
assert!(opts.get("scheme").is_none());
}
#[test]
fn test_alg_options_scheme() {
let a = AlgOptions {
algorithm: "secp256k1".into(),
index: 1,
scheme: Some("ecdsa".into()),
encoding: None,
format: None,
};
let opts = a.to_options();
assert_eq!(opts["scheme"], json!("ecdsa"));
}
#[test]
fn test_result_to_line_string() {
assert_eq!(result_to_line(&json!("deadbeef")), "deadbeef");
}
#[test]
fn test_result_to_line_object() {
let v = json!({"signature": "abc"});
assert_eq!(result_to_line(&v), r#"{"signature":"abc"}"#);
}
}
+199
View File
@@ -0,0 +1,199 @@
//! Client transport — unified framed I/O over Unix / TCP / Serial / Qrexec.
//!
//! Port of the C `signer_transport_t` vtable. All four transports share the
//! same `send_framed` / `recv_framed` path after construction (defined in
//! [`::signer::transport`]).
use std::io;
use std::process::{Child, ChildStdin, ChildStdout};
/// A connected client transport. Owns the underlying handle (and, for
/// qrexec, the child process).
pub enum ClientTransport {
/// Unix abstract socket.
Unix(std::os::unix::net::UnixStream),
/// TCP stream.
Tcp(std::net::TcpStream),
/// Serial device file (read/write).
Serial(std::fs::File),
/// Qubes qrexec child: stdin + stdout + child handle.
Qrexec {
stdin: ChildStdin,
stdout: ChildStdout,
child: Child,
},
}
impl ClientTransport {
/// Open a Unix abstract-socket transport by name (without `@`).
pub fn open_unix(name: &str, _timeout_ms: u64) -> io::Result<Self> {
let stream = ::signer::transport::connect_abstract_unix(name)?;
Ok(ClientTransport::Unix(stream))
}
/// Open a TCP transport to `host:port`.
pub fn open_tcp(host: &str, port: u16, timeout_ms: u64) -> io::Result<Self> {
let addr = format!("{}:{}", host, port);
let stream = std::net::TcpStream::connect(&addr)?;
let dur = std::time::Duration::from_millis(timeout_ms);
stream.set_read_timeout(Some(dur))?;
stream.set_write_timeout(Some(dur))?;
Ok(ClientTransport::Tcp(stream))
}
/// Open a USB CDC-ACM serial transport at `device` (e.g. `/dev/ttyACM0`).
pub fn open_serial(device: &str, _timeout_ms: u64) -> io::Result<Self> {
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK)
.open(device)?;
Ok(ClientTransport::Serial(file))
}
/// Open a Qubes qrexec transport to `qube:service`.
///
/// Spawns `qrexec-client-vm <qube> <service>` and pipes framed JSON
/// over its stdin/stdout.
pub fn open_qrexec(qube: &str, service: &str, _timeout_ms: u64) -> io::Result<Self> {
let mut child = std::process::Command::new("qrexec-client-vm")
.arg(qube)
.arg(service)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.spawn()?;
let stdin = child.stdin.take().ok_or_else(|| {
io::Error::new(io::ErrorKind::Other, "qrexec: no stdin")
})?;
let stdout = child.stdout.take().ok_or_else(|| {
io::Error::new(io::ErrorKind::Other, "qrexec: no stdout")
})?;
Ok(ClientTransport::Qrexec { stdin, stdout, child })
}
/// Send a framed JSON message.
pub fn send(&mut self, payload: &str) -> io::Result<()> {
match self {
ClientTransport::Unix(s) => ::signer::transport::send_framed(s, payload),
ClientTransport::Tcp(s) => ::signer::transport::send_framed(s, payload),
ClientTransport::Serial(f) => ::signer::transport::send_framed(f, payload),
ClientTransport::Qrexec { stdin, .. } => {
::signer::transport::send_framed(stdin, payload)
}
}
}
/// Receive a framed JSON message.
pub fn recv(&mut self) -> io::Result<String> {
match self {
ClientTransport::Unix(s) => ::signer::transport::recv_framed(s),
ClientTransport::Tcp(s) => ::signer::transport::recv_framed(s),
ClientTransport::Serial(f) => ::signer::transport::recv_framed(f),
ClientTransport::Qrexec { stdout, .. } => {
::signer::transport::recv_framed(stdout)
}
}
}
}
impl Drop for ClientTransport {
fn drop(&mut self) {
if let ClientTransport::Qrexec { child, .. } = self {
// Best-effort: wait for the qrexec child to exit.
let _ = child.wait();
}
}
}
/// Parse `host:port` into `(host, port)`.
pub fn parse_host_port(s: &str) -> Result<(String, u16), String> {
let colon = s.rfind(':').ok_or("missing ':' in host:port")?;
if colon == 0 {
return Err("empty host".into());
}
let host = s[..colon].to_string();
let port: u16 = s[colon + 1..]
.parse()
.map_err(|_| "invalid port")?;
Ok((host, port))
}
/// Parse `qube:service` into `(qube, service)`.
pub fn parse_qube_service(s: &str) -> Result<(String, String), String> {
let colon = s.find(':').ok_or("missing ':' in qube:service")?;
if colon == 0 {
return Err("empty qube".into());
}
let qube = s[..colon].to_string();
let service = s[colon + 1..].to_string();
if service.is_empty() {
return Err("empty service".into());
}
Ok((qube, service))
}
/// Open a transport based on the CLI flags. Returns the transport and the
/// resolved socket name (for diagnostics).
pub fn open_from_cli(
socket_name: Option<&str>,
timeout_ms: u64,
tcp: Option<&str>,
serial: Option<&str>,
qrexec: Option<&str>,
) -> Result<(ClientTransport, String), String> {
let transport_count =
tcp.is_some() as usize + serial.is_some() as usize + qrexec.is_some() as usize
+ socket_name.is_some() as usize;
if transport_count > 1 {
return Err(
"--tcp, --serial, --qrexec, and --socket-name are mutually exclusive".into(),
);
}
if let Some(tcp_arg) = tcp {
let (host, port) = parse_host_port(tcp_arg)?;
let t = ClientTransport::open_tcp(&host, port, timeout_ms)
.map_err(|e| format!("cannot open TCP transport to {}: {}", tcp_arg, e))?;
return Ok((t, tcp_arg.to_string()));
}
if let Some(dev) = serial {
let t = ClientTransport::open_serial(dev, timeout_ms)
.map_err(|e| format!("cannot open serial transport on {}: {}", dev, e))?;
return Ok((t, dev.to_string()));
}
if let Some(qr) = qrexec {
let (qube, service) = parse_qube_service(qr)?;
let t = ClientTransport::open_qrexec(&qube, &service, timeout_ms)
.map_err(|e| format!("cannot open qrexec transport to {}: {}", qr, e))?;
return Ok((t, qr.to_string()));
}
if let Some(name) = socket_name {
let t = ClientTransport::open_unix(name, timeout_ms)
.map_err(|e| format!("cannot open unix transport {}: {}", name, e))?;
return Ok((t, name.to_string()));
}
// Auto-discover: enumerate abstract UNIX sockets.
let sockets = ::signer::socket_name::list_sockets();
if sockets.is_empty() {
return Err("no signer sockets found. Is signer running?".into());
}
if sockets.len() > 1 {
let mut msg = String::from(
"multiple signer sockets found. Use --socket-name to select one:\n",
);
for n in &sockets {
msg.push_str(&format!(" {}\n", n));
}
return Err(msg);
}
let name = sockets[0].clone();
let t = ClientTransport::open_unix(&name, timeout_ms)
.map_err(|e| format!("cannot open unix transport {}: {}", name, e))?;
Ok((t, name))
}
+246 -22
View File
@@ -11,7 +11,7 @@ use crate::mnemonic::MnemonicState;
use crate::pq_crypto::CryptoAlg;
use crate::role_table::RoleTable;
use crate::selector::{selector_resolve, SelectorRequest};
use crate::NsignerError;
use crate::SignerError;
use serde_json::{json, Value};
@@ -179,7 +179,7 @@ fn handle_algorithm_verb(
}
let priv_slice = key_entry.private_key.as_slice();
let sig = sign_with_alg(alg, &priv_slice[..32].try_into().unwrap(), &msg_bytes);
let sig = sign_with_alg(alg, priv_slice, &msg_bytes);
match sig {
Ok(s) => {
let sig_hex = hex::encode(&s);
@@ -286,13 +286,79 @@ fn handle_algorithm_verb(
}
enforcement::VERB_ENCAPSULATE => {
// ML-KEM-768 only — TODO: Phase 13
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
// params[0] = peer public key hex (1184 bytes → 2368 hex chars).
let pub_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let pub_bytes = match hex::decode(pub_hex) {
Ok(b) => b,
Err(_) => return make_error_response(
id,
RpcError { code: -32602, message: "invalid_pubkey_hex" },
),
};
let sizes = alg.sizes().unwrap();
if pub_bytes.len() != sizes.pub_key_len {
return make_error_response(
id,
RpcError { code: -32602, message: "invalid_pubkey_length" },
);
}
match crate::pq_crypto::ml_kem_768_encaps(&pub_bytes) {
Ok((ct, ss)) => {
let result = json!({
"ciphertext": hex::encode(&ct),
"shared_secret": hex::encode(&ss),
"algorithm": "ml-kem-768",
});
make_success_response(id, &result.to_string())
}
Err(_) => make_error_response(
id,
RpcError { code: -32602, message: "encaps_failed" },
),
}
}
enforcement::VERB_DECAPSULATE => {
// ML-KEM-768 only — TODO: Phase 13
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
// params[0] = ciphertext hex (1088 bytes → 2176 hex chars).
let ct_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ct_bytes = match hex::decode(ct_hex) {
Ok(b) => b,
Err(_) => return make_error_response(
id,
RpcError { code: -32602, message: "invalid_ciphertext_hex" },
),
};
let sizes = alg.sizes().unwrap();
if ct_bytes.len() != sizes.ciphertext_len {
return make_error_response(
id,
RpcError { code: -32602, message: "invalid_ciphertext_length" },
);
}
let priv_slice = key_entry.private_key.as_slice();
match crate::pq_crypto::ml_kem_768_decaps(priv_slice, &ct_bytes) {
Ok(ss) => {
let result = json!({
"shared_secret": hex::encode(&ss),
"algorithm": "ml-kem-768",
});
make_success_response(id, &result.to_string())
}
Err(_) => make_error_response(
id,
RpcError { code: -32602, message: "decaps_failed" },
),
}
}
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
@@ -357,12 +423,26 @@ fn handle_nostr_verb(
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)
{
// Ensure key is derived. For variable-path roles, use the concrete
// path supplied by the client; for fixed-path roles, use the stored
// template. Variable-path roles are re-derived whenever the requested
// path differs from the currently-derived one (the cache is per-path,
// not per-role).
let has_variable = role.has_variable_path();
let needs_derive = if has_variable && sel.has_role_path {
role.derived_path.as_deref() != Some(sel.role_path.as_str())
} else {
!role.derived
};
if needs_derive {
let result = if has_variable && sel.has_role_path {
ctx.key_store
.derive_one_with_path(ctx.role_table, ctx.mnemonic, role_index, &sel.role_path)
} else {
ctx.key_store
.derive_one(ctx.role_table, ctx.mnemonic, role_index)
};
if let Err(_) = result {
return make_error_response(
id,
RpcError {
@@ -407,7 +487,12 @@ fn handle_nostr_verb(
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)),
// The signed event is itself JSON; serialize it as a proper
// JSON string value so embedded quotes are escaped.
Ok(signed) => {
let wrapped = serde_json::to_string(&signed).unwrap_or_else(|_| "\"\"".into());
make_success_response(id, &wrapped)
}
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
@@ -544,23 +629,31 @@ fn is_nostr_verb(verb: &str) -> bool {
)
}
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8; 32], msg: &[u8]) -> Result<Vec<u8>, NsignerError> {
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, SignerError> {
match alg {
CryptoAlg::Secp256k1 => {
if priv_key.len() != 32 {
return Err(SignerError::CryptoFailed);
}
let arr: [u8; 32] = priv_key.try_into().unwrap();
// Check for scheme option (schnorr default, ecdsa alternative)
// For now, default to schnorr
let sk = nostr_core::types::SecretKey::from_bytes(*priv_key);
let sk = nostr_core::types::SecretKey::from_bytes(arr);
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);
if priv_key.len() != 32 {
return Err(SignerError::CryptoFailed);
}
let arr: [u8; 32] = priv_key.try_into().unwrap();
let sig = crate::pq_crypto::ed25519_sign(&arr, 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),
_ => Err(SignerError::CryptoFailed),
}
}
@@ -719,11 +812,6 @@ mod tests {
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
@@ -754,4 +842,140 @@ mod tests {
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"error\""));
}
// ── PQ algorithm verbs (v2 seeded derivation) ────────────────────
#[test]
fn test_ml_dsa_65_sign_verify() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// get_public_key must succeed (was key_derivation_failed before).
let req = r#"{"id":"p1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 1952 * 2);
// sign
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"p2","method":"sign","params":["{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
msg_hex
);
let resp = handle_request(&mut ctx, &sign_req);
assert!(resp.contains("\"result\""), "sign: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
assert_eq!(sig_hex.len(), 3309 * 2);
// verify (valid)
let verify_req = format!(
r#"{{"id":"p3","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
msg_hex, sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
// verify (wrong message)
let verify_req = format!(
r#"{{"id":"p4","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
hex::encode(b"wrong message"),
sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":false"), "verify wrong: {}", resp);
}
#[test]
fn test_slh_dsa_128s_sign_verify() {
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":"s1","method":"get_public_key","params":[{"algorithm":"slh-dsa-128s","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 32 * 2);
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"s2","method":"sign","params":["{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
msg_hex
);
let resp = handle_request(&mut ctx, &sign_req);
assert!(resp.contains("\"result\""), "sign: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
assert_eq!(sig_hex.len(), 7856 * 2);
let verify_req = format!(
r#"{{"id":"s3","method":"verify","params":["{}","{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
msg_hex, sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
}
#[test]
fn test_ml_kem_768_encaps_decaps() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// Derive our ML-KEM keypair to get a public key to encapsulate to.
let req = r#"{"id":"k1","method":"get_public_key","params":[{"algorithm":"ml-kem-768","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 1184 * 2);
// encapsulate
let enc_req = format!(
r#"{{"id":"k2","method":"encapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
pub_hex
);
let resp = handle_request(&mut ctx, &enc_req);
assert!(resp.contains("\"result\""), "encapsulate: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let ct_hex = resp_json["result"]["ciphertext"].as_str().unwrap();
let ss_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
assert_eq!(ct_hex.len(), 1088 * 2);
assert_eq!(ss_hex.len(), 32 * 2);
// decapsulate
let dec_req = format!(
r#"{{"id":"k3","method":"decapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
ct_hex
);
let resp = handle_request(&mut ctx, &dec_req);
assert!(resp.contains("\"result\""), "decapsulate: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let ss2_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
assert_eq!(ss_hex, ss2_hex, "shared secrets must match");
}
#[test]
fn test_ml_kem_768_encaps_bad_pubkey_length() {
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":"k4","method":"encapsulate","params":["00ff",{"algorithm":"ml-kem-768","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("invalid_pubkey_length"), "resp: {}", resp);
}
#[test]
fn test_pq_key_derivation_deterministic() {
// Same mnemonic + index → same pubkey across dispatcher calls.
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":"d1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
let resp1 = handle_request(&mut ctx, req);
let resp2 = handle_request(&mut ctx, req);
assert_eq!(resp1, resp2);
}
}
+9 -9
View File
@@ -1,13 +1,13 @@
//! Error types for nsigner.
//! Error types for signer.
//!
//! JSON-RPC error codes are preserved exactly for wire compatibility
//! with the C n_signer.
use thiserror::Error;
/// nsigner-specific errors (internal operations).
/// signer-specific errors (internal operations).
#[derive(Error, Debug, Clone)]
pub enum NsignerError {
pub enum SignerError {
#[error("invalid input")]
InvalidInput,
#[error("memory allocation failed (mlock)")]
@@ -32,13 +32,13 @@ pub enum NsignerError {
Internal(String),
}
impl From<nostr_core::error::NostrError> for NsignerError {
impl From<nostr_core::error::NostrError> for SignerError {
fn from(e: nostr_core::error::NostrError) -> Self {
// Map NostrError to NsignerError
// Map NostrError to SignerError
match e {
nostr_core::error::NostrError::InvalidInput => NsignerError::InvalidInput,
nostr_core::error::NostrError::CryptoFailed => NsignerError::CryptoFailed,
_ => NsignerError::CryptoFailed,
nostr_core::error::NostrError::InvalidInput => SignerError::InvalidInput,
nostr_core::error::NostrError::CryptoFailed => SignerError::CryptoFailed,
_ => SignerError::CryptoFailed,
}
}
}
@@ -60,7 +60,7 @@ impl RpcError {
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 ──────────────────────────────────────
// ── signer-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" };
+1 -1
View File
@@ -1,4 +1,4 @@
//! Minimal HTTP/1.1 parser for nsigner's HTTP listener mode.
//! Minimal HTTP/1.1 parser for signer'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.
+148 -43
View File
@@ -7,7 +7,7 @@ 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;
use crate::SignerError;
/// Per-role derived key material (stored in secure memory).
pub struct DerivedKey {
@@ -35,12 +35,12 @@ impl KeyStore {
&mut self,
table: &mut RoleTable,
mnemonic: &MnemonicState,
) -> Result<usize, NsignerError> {
) -> Result<usize, SignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
return Err(SignerError::MnemonicNotLoaded);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
self.keys.clear();
self.keys.resize_with(table.entries.len(), || None);
@@ -86,16 +86,16 @@ impl KeyStore {
table: &mut RoleTable,
mnemonic: &MnemonicState,
role_index: usize,
) -> Result<(), NsignerError> {
) -> Result<(), SignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
return Err(SignerError::MnemonicNotLoaded);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
let role = table
.entries
.get_mut(role_index)
.ok_or(NsignerError::InvalidInput)?;
.ok_or(SignerError::InvalidInput)?;
role.derived = false;
role.pubkey_hex.clear();
@@ -111,6 +111,42 @@ impl KeyStore {
Ok(())
}
/// Derive key for a role using a concrete path (substituting the
/// variable index into the template). Used for variable-path roles
/// where the client supplies the full concrete `role_path`.
pub fn derive_one_with_path(
&mut self,
table: &mut RoleTable,
mnemonic: &MnemonicState,
role_index: usize,
concrete_path: &str,
) -> Result<(), SignerError> {
if !mnemonic.is_loaded() {
return Err(SignerError::MnemonicNotLoaded);
}
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
let role = table
.entries
.get_mut(role_index)
.ok_or(SignerError::InvalidInput)?;
role.derived = false;
role.pubkey_hex.clear();
role.derived_path = None;
let dk = derive_for_role(concrete_path, role, phrase)?;
role.pubkey_hex = dk.pubkey_hex.clone();
role.derived = true;
role.derived_path = Some(concrete_path.to_string());
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())
@@ -127,14 +163,14 @@ impl KeyStore {
&self,
role_index: usize,
event_json: &str,
) -> Result<String, NsignerError> {
) -> Result<String, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
// Parse the unsigned event from JSON
let event: nostr_core::types::Event =
serde_json::from_str(event_json).map_err(|_| NsignerError::InvalidInput)?;
serde_json::from_str(event_json).map_err(|_| SignerError::InvalidInput)?;
// Use NIP-01 to create and sign the event
let mut priv_arr = [0u8; 32];
@@ -148,9 +184,9 @@ impl KeyStore {
&sk,
event.created_at,
)
.map_err(|_| NsignerError::CryptoFailed)?;
.map_err(|_| SignerError::CryptoFailed)?;
serde_json::to_string(&signed).map_err(|_| NsignerError::InvalidInput)
serde_json::to_string(&signed).map_err(|_| SignerError::InvalidInput)
}
/// NIP-44 encrypt.
@@ -159,20 +195,20 @@ impl KeyStore {
role_index: usize,
recipient_pubkey_hex: &str,
plaintext: &str,
) -> Result<Vec<u8>, NsignerError> {
) -> Result<Vec<u8>, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::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)
.map_err(|_| SignerError::CryptoFailed)
}
/// NIP-44 decrypt.
@@ -181,20 +217,20 @@ impl KeyStore {
role_index: usize,
sender_pubkey_hex: &str,
ciphertext: &[u8],
) -> Result<Vec<u8>, NsignerError> {
) -> Result<Vec<u8>, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::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)
.map_err(|_| SignerError::CryptoFailed)
}
/// NIP-04 encrypt.
@@ -203,20 +239,20 @@ impl KeyStore {
role_index: usize,
recipient_pubkey_hex: &str,
plaintext: &str,
) -> Result<String, NsignerError> {
) -> Result<String, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::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)
.map_err(|_| SignerError::CryptoFailed)
}
/// NIP-04 decrypt.
@@ -225,20 +261,20 @@ impl KeyStore {
role_index: usize,
sender_pubkey_hex: &str,
ciphertext: &str,
) -> Result<String, NsignerError> {
) -> Result<String, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::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)
.map_err(|_| SignerError::CryptoFailed)
}
/// Zeroize all derived keys.
@@ -254,21 +290,28 @@ fn derive_for_role(
path: &str,
role: &RoleEntry,
mnemonic_phrase: &str,
) -> Result<DerivedKey, NsignerError> {
) -> Result<DerivedKey, SignerError> {
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);
return Err(SignerError::KeyDerivationFailed);
} else {
alg
};
let sizes = alg
.sizes()
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
// Derive the 32-byte seed from the mnemonic using the path
let seed = pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
// PQ algorithms use the v2 seeded derivation: exact-length seed
// (32/48/64 B) from BIP-32 children, fed to the seeded keygen APIs.
// Classical algorithms use the plain 32-byte derived seed.
let seed = match alg {
CryptoAlg::MlDsa65 => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 32)?,
CryptoAlg::SlhDsa128s => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 48)?,
CryptoAlg::MlKem768 => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 64)?,
_ => pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?.to_vec(),
};
match alg {
CryptoAlg::Secp256k1 => {
@@ -276,19 +319,19 @@ fn derive_for_role(
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)?;
.map_err(|_| SignerError::KeyDerivationFailed)?;
let (derived_key, _) = nips::nip006::bip32_derive_path(
&master_key,
&master_chain_code,
&path_indices,
)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
.map_err(|_| SignerError::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)?;
.map_err(|_| SignerError::CryptoFailed)?;
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_arr);
@@ -309,7 +352,9 @@ fn derive_for_role(
})
}
CryptoAlg::Ed25519 => {
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed);
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed_arr);
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
@@ -329,7 +374,9 @@ fn derive_for_role(
})
}
CryptoAlg::X25519 => {
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed);
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed_arr);
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
@@ -348,11 +395,69 @@ fn derive_for_role(
valid: true,
})
}
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
// PQ algorithms — TODO: Phase 13
Err(NsignerError::NotYetImplemented)
CryptoAlg::MlDsa65 => {
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr)?;
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::Unknown => Err(NsignerError::KeyDerivationFailed),
CryptoAlg::SlhDsa128s => {
let (priv_bytes, pub_bytes) = pq_crypto::slh_dsa_128s_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::MlKem768 => {
let (priv_bytes, pub_bytes) = pq_crypto::ml_kem_768_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::Unknown => Err(SignerError::KeyDerivationFailed),
}
}
+3 -5
View File
@@ -1,4 +1,4 @@
//! # nsigner — Attended Nostr signing daemon
//! # signer — 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
@@ -13,7 +13,6 @@ 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;
@@ -27,10 +26,9 @@ 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;
pub use error::SignerError;
/// Version string (matches C NSIGNER_VERSION).
pub const VERSION: &str = "v0.0.1";
pub const VERSION: &str = "v0.0.23";
+125 -308
View File
@@ -1,35 +1,33 @@
//! nsigner — attended Nostr signing daemon.
//! signer — 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::{
use signer::{
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,
SignerError,
};
/// Command-line arguments.
#[derive(Parser, Debug)]
#[command(name = "nsigner", version = nsigner::VERSION, about = "Attended Nostr signing daemon")]
#[command(name = "signer", version = signer::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>,
///
/// If omitted in interactive (TUI) mode, the transport selection menu
/// is shown. If omitted in non-interactive mode, defaults to unix.
#[arg(long, short = 'l')]
listen: Option<String>,
/// Register a named path-role non-interactively (repeatable)
#[arg(long, value_name = "SPEC")]
@@ -47,10 +45,6 @@ struct Cli {
#[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,
@@ -91,7 +85,7 @@ enum Commands {
to: Option<String>,
},
/// List running nsigner abstract sockets
/// List running signer abstract sockets
List,
}
@@ -117,60 +111,106 @@ fn main() {
match server_main(&cli) {
Ok(()) => {}
Err(e) => {
eprintln!("nsigner: {}", e);
eprintln!("signer: {}", 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);
/// Server main.
///
/// Two paths:
/// - **Interactive (TUI)**: mnemonic → roles → transport → server → main
/// are all handled by the ratatui `App` (setup screens + main screen).
/// - **Non-interactive** (`--mnemonic-stdin` / `--mnemonic-fd` /
/// `--register-role` / `--listen`): mnemonic and roles are set up here,
/// then the App runs with `listen_override` so it goes straight to the
/// main screen. Headless modes (stdio/qrexec/tcp/http) never show a TUI.
fn server_main(cli: &Cli) -> Result<(), SignerError> {
println!("signer {}", signer::VERSION);
if cli.allow_unlocked_memory {
nsigner::secure_mem::allow_unlocked();
signer::secure_mem::allow_unlocked();
}
// Install SIGWINCH handler for terminal resize detection
nsigner::tui_continuous::install_resize_handler();
let interactive = !cli.mnemonic_stdin && cli.mnemonic_fd.is_none();
let listen_mode = cli.listen.as_deref().map(parse_listen_mode);
// ── Mnemonic ──────────────────────────────────────────────────
// Non-interactive (--mnemonic-stdin / --mnemonic-fd) always runs headless,
// even for Unix mode — the TUI needs a real TTY.
if !interactive {
return run_headless(cli, listen_mode.unwrap_or(ListenMode::Unix));
}
// Headless modes never show a TUI.
if let Some(mode) = listen_mode {
if mode != ListenMode::Unix {
return run_headless(cli, mode);
}
}
let socket_name = cli
.socket_name
.clone()
.unwrap_or_else(|| {
signer::socket_name::socket_name_random().unwrap_or_default()
});
let auth_mode = parse_auth_mode(&cli.auth);
if interactive {
// ── Fully interactive: App handles everything ────────────
// The seed entry popup is always shown; if --listen was given
// (Unix only — non-Unix modes are headless above), the transport
// is pre-selected on the main screen. Pass the raw --listen string
// so the App can adopt an explicit tcp:/http: bind address.
let mut app = signer::tui::App::new(
RoleTable::new(),
MnemonicState::new(),
KeyStore::new(),
AlgorithmKeyCache::new(),
socket_name,
0,
auth_mode,
cli.listen.clone(),
);
let mut terminal = ratatui::init();
let result = app.run(&mut terminal);
ratatui::restore();
result.map_err(|e| SignerError::IoFailed(e.to_string()))
} else {
// Unreachable: non-interactive modes return headless above.
Ok(())
}
}
/// Run a headless server (stdio, qrexec, tcp, http) — no TUI.
fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), SignerError> {
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()))?;
.map_err(|e| SignerError::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()))?;
.map_err(|e| SignerError::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
} else {
role_table
.register_role_path(
"main",
@@ -179,238 +219,60 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
RoleCurve::Secp256k1,
-1, -1, -1, &[],
)
.map_err(|e| NsignerError::Internal(e.to_string()))?;
} else {
// Interactive: role wizard
nsigner::tui::role_wizard(&mut role_table)?;
.map_err(|e| SignerError::Internal(e.to_string()))?;
}
// ── Key store & algorithm cache ───────────────────────────────
let mut key_store = KeyStore::new();
let mut alg_key_cache = AlgorithmKeyCache::new();
key_store.derive_all(&mut role_table, &mnemonic)?;
// ── 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 ─────────────────────────────────────────────
.unwrap_or_else(|| "headless".to_string());
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
if matches!(listen_mode, ListenMode::Stdio | ListenMode::Qrexec) {
let mut dispatcher = DispatcherContext {
role_table: &mut role_table,
mnemonic: &mnemonic,
key_store: &mut key_store,
alg_key_cache: &mut alg_key_cache,
};
if let Ok(Some(activity)) = server.handle_one(&mut dispatcher) {
println!("{}", activity);
}
server.stop();
} else {
// Tcp / Http poll loop
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,
};
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;
}
match server.handle_one(&mut dispatcher) {
Ok(Some(activity)) => {
println!("{}", activity);
}
}
}
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,
);
Ok(None) => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
// 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 {
Err(e) => {
eprintln!("server error: {}", e);
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(())
}
@@ -418,16 +280,16 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
fn client_main(request: &str, cli: &Cli) -> i32 {
use std::io::Read;
let socket_name = cli.socket_name.as_deref().unwrap_or("nsigner");
let socket_name = cli.socket_name.as_deref().unwrap_or("signer01");
// 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())
signer::socket_name::discover_single_socket().unwrap_or_else(|_| socket_name.to_string())
};
let mut stream = match nsigner::transport::connect_abstract_unix(&socket_name) {
let mut stream = match signer::transport::connect_abstract_unix(&socket_name) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to connect to {}: {}", socket_name, e);
@@ -448,13 +310,13 @@ fn client_main(request: &str, cli: &Cli) -> i32 {
};
// Send framed request
if nsigner::transport::send_framed(&mut stream, &request).is_err() {
if signer::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) {
match signer::transport::recv_framed(&mut stream) {
Ok(response) => {
println!("{}", response);
0
@@ -470,18 +332,18 @@ fn client_main(request: &str, cli: &Cli) -> i32 {
fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
let target = to.unwrap_or("nsigner");
let target = to.unwrap_or("signer01");
let target = if cli.socket_name.is_some() {
target.to_string()
} else {
nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| target.to_string())
signer::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) {
let mut stream = match signer::transport::connect_abstract_unix(&target) {
Ok(s) => s,
Err(e) => {
eprintln!("bridge: cannot connect to {}: {}", target, e);
@@ -491,14 +353,14 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
// Send source-qube preamble
let preamble = format!(r#"{{"qrexec_source":"{}"}}"#, source_qube);
if nsigner::transport::send_framed(&mut stream, &preamble).is_err() {
if signer::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) {
let request = match signer::transport::recv_framed(&mut stdin) {
Ok(r) => r,
Err(e) => {
eprintln!("bridge: failed to read request from stdin: {}", e);
@@ -506,17 +368,17 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
}
};
if nsigner::transport::send_framed(&mut stream, &request).is_err() {
if signer::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) {
match signer::transport::recv_framed(&mut stream) {
Ok(response) => {
let mut stdout = std::io::stdout();
if nsigner::transport::send_framed(&mut stdout, &response).is_err() {
if signer::transport::send_framed(&mut stdout, &response).is_err() {
eprintln!("bridge: failed to relay response");
return 1;
}
@@ -529,9 +391,9 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
}
}
/// List subcommand: list running nsigner sockets.
/// List subcommand: list running signer sockets.
fn list_main() -> i32 {
let sockets = nsigner::socket_name::list_sockets();
let sockets = signer::socket_name::list_sockets();
if sockets.is_empty() {
println!("(none)");
} else {
@@ -542,59 +404,14 @@ fn list_main() -> i32 {
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> {
) -> Result<(), SignerError> {
let parts: Vec<&str> = spec.splitn(3, ':').collect();
if parts.len() != 3 {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let name = parts[0];
@@ -602,13 +419,13 @@ fn register_role_from_spec(
let path_token = parts[2];
if name.is_empty() || path_token.is_empty() {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
// Resolve curve
let curve = if curve_str.is_empty() {
// Auto-detect from path
match nsigner::role_table::purpose_from_path(path_token) {
match signer::role_table::purpose_from_path(path_token) {
RolePurpose::Ssh => RoleCurve::Ed25519,
RolePurpose::Age => RoleCurve::X25519,
RolePurpose::PqSig => {
@@ -626,15 +443,15 @@ fn register_role_from_spec(
};
if curve == RoleCurve::Unknown {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let purpose = nsigner::role_table::purpose_from_path(path_token);
let purpose = signer::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)?;
signer::role_table::parse_path_template(path_token)
.map_err(|_| SignerError::InvalidInput)?;
role_table
.register_role_path(
@@ -647,7 +464,7 @@ fn register_role_from_spec(
-1, // no default index
&allowed_indices,
)
.map_err(|e| NsignerError::Internal(e.to_string()))?;
.map_err(|e| SignerError::Internal(e.to_string()))?;
Ok(())
}
+6 -6
View File
@@ -44,14 +44,14 @@ pub fn miner_run(
target_difficulty: i32,
thread_count: i32,
timeout_sec: u64,
) -> Result<MineResult, crate::NsignerError> {
) -> Result<MineResult, crate::SignerError> {
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)?;
serde_json::from_str(event_json).map_err(|_| crate::SignerError::InvalidInput)?;
// Ensure there's a nonce tag (will be updated by workers)
let has_nonce = event.tags.iter().any(|t| t.kind() == "nonce");
@@ -96,9 +96,9 @@ pub fn miner_run(
}
let shared = Arc::try_unwrap(shared)
.map_err(|_| crate::NsignerError::Internal("mining thread still holds shared state".into()))?
.map_err(|_| crate::SignerError::Internal("mining thread still holds shared state".into()))?
.into_inner()
.map_err(|_| crate::NsignerError::Internal("mining shared state poisoned".into()))?;
.map_err(|_| crate::SignerError::Internal("mining shared state poisoned".into()))?;
let elapsed = start.elapsed().as_secs();
@@ -117,8 +117,8 @@ pub fn miner_run(
&sk,
event.created_at,
)
.map_err(|_| crate::NsignerError::CryptoFailed)?;
serde_json::to_string(&signed).map_err(|_| crate::NsignerError::InvalidInput)?
.map_err(|_| crate::SignerError::CryptoFailed)?;
serde_json::to_string(&signed).map_err(|_| crate::SignerError::InvalidInput)?
} else {
// No event mined — return the original unsigned event
event_json.to_string()
+7 -7
View File
@@ -4,7 +4,7 @@
//! generation, and seed conversion.
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
use crate::SignerError;
/// Maximum mnemonic length: 24 words * ~10 chars + spaces + null.
pub const MNEMONIC_MAX_LEN: usize = 256;
@@ -32,18 +32,18 @@ impl MnemonicState {
///
/// 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> {
pub fn load(&mut self, phrase: &str) -> Result<(), SignerError> {
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);
return Err(SignerError::InvalidInput);
}
// Validate via nostr_core_lib_rust
if !nips::nip006::mnemonic_validate(phrase) {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
// Store in secure memory
@@ -62,14 +62,14 @@ impl MnemonicState {
/// 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> {
pub fn generate(&mut self, word_count: u8) -> Result<String, SignerError> {
let entropy_bytes = match word_count {
12 => 16,
15 => 20,
18 => 24,
21 => 28,
24 => 32,
_ => return Err(NsignerError::InvalidInput),
_ => return Err(SignerError::InvalidInput),
};
let mut entropy = vec![0u8; entropy_bytes];
@@ -77,7 +77,7 @@ impl MnemonicState {
rand::thread_rng().fill_bytes(&mut entropy);
let phrase = nips::nip006::mnemonic_from_bytes(&entropy)
.map_err(|_| NsignerError::CryptoFailed)?;
.map_err(|_| SignerError::CryptoFailed)?;
// Zeroize entropy
use zeroize::Zeroize;
+72 -21
View File
@@ -4,7 +4,7 @@
//! startup. Pad offset advances monotonically across requests.
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
use crate::SignerError;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
@@ -63,14 +63,14 @@ impl OtpPadState {
/// `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> {
pub fn bind(&mut self, dir: &str, spec: &str, _allow_blkback: bool) -> Result<(), SignerError> {
// 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()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let mut found = None;
for entry in entries {
@@ -85,15 +85,15 @@ impl OtpPadState {
}
found
.map(|p| p.to_string_lossy().to_string())
.ok_or(NsignerError::InvalidInput)?
.ok_or(SignerError::InvalidInput)?
};
let file = File::open(&pad_filename)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let metadata = file
.metadata()
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let size = metadata.len();
// Extract checksum from filename
@@ -113,7 +113,7 @@ impl OtpPadState {
// Allocate scratch buffer for XOR
let scratch = SecureBuf::alloc(4 * 1024 * 1024) // 4 MB max chunk
.map_err(|_| NsignerError::MemoryFailed)?;
.map_err(|_| SignerError::MemoryFailed)?;
self.bound = true;
self.pads_dir = dir.to_string();
@@ -144,9 +144,9 @@ impl OtpPadState {
&mut self,
plaintext: &[u8],
_encoding: Option<&str>,
) -> Result<(Vec<u8>, u64, u64), NsignerError> {
) -> Result<(Vec<u8>, u64, u64), SignerError> {
if !self.bound {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let off_before = self.offset;
@@ -164,32 +164,32 @@ impl OtpPadState {
&mut self,
ciphertext: &[u8],
_encoding: Option<&str>,
) -> Result<Vec<u8>, NsignerError> {
) -> Result<Vec<u8>, SignerError> {
if !self.bound {
return Err(NsignerError::InvalidInput);
return Err(SignerError::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)?;
fn xor_with_pad(&mut self, data: &[u8]) -> Result<Vec<u8>, SignerError> {
let file = self.pad_file.as_mut().ok_or(SignerError::InvalidInput)?;
let scratch = self.scratch.as_mut().ok_or(SignerError::InvalidInput)?;
let data_len = data.len();
if data_len > scratch.size() {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
// Seek to current offset
file.seek(SeekFrom::Start(self.offset))
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::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()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
// XOR
let result: Vec<u8> = data
@@ -218,6 +218,57 @@ impl Default for OtpPadState {
}
}
// ── Global OTP pad state ─────────────────────────────────────────────────────
//
// The C version keeps a global `g_otp_pad` that is bound once at startup
// (either via --otp-pad-dir or via the role wizard's OTP preset) and shared
// by the dispatcher for otp_encrypt/otp_decrypt requests. We mirror that with
// a thread-safe global here.
use std::sync::Mutex;
static GLOBAL_OTP_PAD: Mutex<Option<OtpPadState>> = Mutex::new(None);
/// Bind the global OTP pad. Called from the role wizard (OTP preset) or from
/// `--otp-pad-dir` CLI handling. Replaces any previously bound pad.
pub fn bind_global(dir: &str, spec: &str, allow_blkback: bool) -> Result<(), SignerError> {
let mut pad = OtpPadState::new();
pad.bind(dir, spec, allow_blkback)?;
let mut guard = GLOBAL_OTP_PAD.lock().map_err(|e| {
SignerError::Internal(format!("global otp pad lock poisoned: {}", e))
})?;
*guard = Some(pad);
Ok(())
}
/// Check whether the global OTP pad is bound.
pub fn is_global_bound() -> bool {
GLOBAL_OTP_PAD
.lock()
.map(|g| g.as_ref().map(|p| p.is_bound()).unwrap_or(false))
.unwrap_or(false)
}
/// Status string for the global OTP pad, shown on the connections screen.
/// Empty if no pad is bound.
pub fn global_status() -> String {
let guard = match GLOBAL_OTP_PAD.lock() {
Ok(g) => g,
Err(_) => return String::new(),
};
match guard.as_ref() {
Some(p) if p.is_bound() => {
format!(
"OTP pad bound: chksum={} offset={}/{}",
p.chksum().unwrap_or(""),
p.current_offset(),
p.pad_size()
)
}
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -236,7 +287,7 @@ mod tests {
#[test]
fn test_bind_and_encrypt() {
let dir = std::env::temp_dir().join("nsigner_otp_test_1");
let dir = std::env::temp_dir().join("signer_otp_test_1");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
@@ -262,7 +313,7 @@ mod tests {
#[test]
fn test_encrypt_decrypt_roundtrip() {
let dir = std::env::temp_dir().join("nsigner_otp_test_2");
let dir = std::env::temp_dir().join("signer_otp_test_2");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
@@ -295,7 +346,7 @@ mod tests {
#[test]
fn test_unbind() {
let dir = std::env::temp_dir().join("nsigner_otp_test_3");
let dir = std::env::temp_dir().join("signer_otp_test_3");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
@@ -312,7 +363,7 @@ mod tests {
#[test]
fn test_offset_advances() {
let dir = std::env::temp_dir().join("nsigner_otp_test_4");
let dir = std::env::temp_dir().join("signer_otp_test_4");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
-461
View File
@@ -1,461 +0,0 @@
//! 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);
}
}
+390 -67
View File
@@ -1,8 +1,13 @@
//! Post-quantum crypto algorithm registry.
//! Post-quantum crypto algorithm registry and operations.
//!
//! 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.
//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum, size constants,
//! and crypto operations for all six algorithms.
//!
//! PQ keygen uses the v2 FIPS seeded derivation scheme (see
//! `plans/pq_seeded_derivation_plan.md`): BIP-32 child bytes at the exact
//! seed length required by each algorithm feed the seeded keygen APIs
//! directly — no DRBG expansion. This matches the nostr_quantum_preparation
//! web app byte-for-byte (same mnemonic + path → same pubkeys).
// ── Algorithm Identifiers ────────────────────────────────────────────────────
@@ -55,6 +60,13 @@ pub struct CryptoAlgSizes {
}
impl CryptoAlg {
/// Sizes for each algorithm.
///
/// PQ private keys are stored in **seed form** (the preferred
/// serialization of the RustCrypto crates): ML-DSA-65 as the 32-byte ξ
/// seed, ML-KEM-768 as the 64-byte d ∥ z seed, SLH-DSA-128s as the
/// 64-byte sk serialization (sk.seed ∥ sk.prf ∥ pk). Public key,
/// signature, and ciphertext lengths are the standard FIPS sizes.
pub fn sizes(&self) -> Option<CryptoAlgSizes> {
match self {
Self::Secp256k1 => Some(CryptoAlgSizes {
@@ -67,53 +79,126 @@ impl CryptoAlg {
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,
priv_key_len: 32, 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,
priv_key_len: 64, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32,
}),
Self::Unknown => None,
}
}
}
// ── Crypto Operations (stubs — Phase 13) ─────────────────────────────────────
// ── Crypto Operations ────────────────────────────────────────────────────────
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path (SLIP-0010).
/// BIP-32 path prefixes routed through BIP-32 derivation.
///
/// For secp256k1: uses BIP-32 derivation.
/// - `m/44'/1237'` — Nostr secp256k1 (NIP-06)
/// - `m/44'/102003'` … `m/44'/102007'` — PQ coin types (v2 seeded scheme;
/// 102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768,
/// 102006' ML-DSA-44 and 102007' Falcon-512 reserved)
///
/// Everything else — ed25519 (`102001'`) and x25519 (`102002'`) — uses
/// SLIP-0010, which is the correct derivation for those curves.
const BIP32_PATH_PREFIXES: &[&str] = &[
"m/44'/1237'",
"m/44'/102003'",
"m/44'/102004'",
"m/44'/102005'",
"m/44'/102006'",
"m/44'/102007'",
];
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path.
///
/// For secp256k1 and PQ coin types: 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.
///
/// Note: PQ keygen should use [`derive_pq_seed_from_path`] instead — it
/// produces the exact-length seed (32/48/64 bytes) required by the FIPS
/// seeded keygen APIs.
pub fn derive_seed_from_mnemonic(
mnemonic: &str,
path: &str,
) -> Result<[u8; 32], crate::NsignerError> {
) -> Result<[u8; 32], crate::SignerError> {
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)?;
.map_err(|_| crate::SignerError::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
// BIP-32 for secp256k1 (NIP-06) and PQ coin types (v2 seeded scheme);
// SLIP-0010 for ed25519/x25519 (correct for those curves).
if BIP32_PATH_PREFIXES.iter().any(|p| path.starts_with(p)) {
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)?;
.map_err(|_| crate::SignerError::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)?;
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
Ok(derived_key)
}
}
/// Derive a PQ keygen seed of `seed_len` bytes (32/48/64) from a BIP-32 path.
///
/// v2 seeded construction (matches nostr_quantum_preparation exactly):
/// - 32-byte seeds: the child private key at `path`.
/// - 48/64-byte seeds: the children at `path` and at the sibling path (last
/// level incremented by 1, hardened bit preserved) concatenated to 64
/// bytes, then truncated to the FIRST `seed_len` bytes.
///
/// The truncation rule is normative: taking the last 48 bytes or
/// concatenating in the opposite order produces different keys and breaks
/// seed-phrase recoverability.
pub fn derive_pq_seed_from_path(
mnemonic: &str,
path: &str,
seed_len: usize,
) -> Result<Vec<u8>, crate::SignerError> {
if !matches!(seed_len, 32 | 48 | 64) {
return Err(crate::SignerError::InvalidInput);
}
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic, "");
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
let indices = nips::nip006::parse_bip44_path(path)
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
if indices.is_empty() {
return Err(crate::SignerError::KeyDerivationFailed);
}
let (child0, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &indices)
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
if seed_len == 32 {
return Ok(child0.to_vec());
}
// Sibling path: last level + 1 (a plain u32 increment preserves the
// hardened bit: 0x80000000 + 1 = 0x80000001, i.e. hardened 1').
let mut sibling_indices = indices.clone();
let last = sibling_indices
.last_mut()
.ok_or(crate::SignerError::KeyDerivationFailed)?;
*last = last.wrapping_add(1);
let (child1, _) =
nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &sibling_indices)
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
let mut combined = [0u8; 64];
combined[..32].copy_from_slice(&child0);
combined[32..].copy_from_slice(&child1);
Ok(combined[..seed_len].to_vec())
}
/// 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};
@@ -162,12 +247,12 @@ pub fn x25519_ecdh(our_priv: &[u8; 32], peer_pub: &[u8; 32]) -> [u8; 32] {
/// 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> {
pub fn secp256k1_ecdsa_sign(priv_key: &[u8; 32], msg: &[u8]) -> Result<[u8; 64], crate::SignerError> {
use secp256k1::{Message, Secp256k1, SecretKey};
let secp = Secp256k1::new();
let sk = SecretKey::from_slice(priv_key).map_err(|_| crate::NsignerError::CryptoFailed)?;
let sk = SecretKey::from_slice(priv_key).map_err(|_| crate::SignerError::CryptoFailed)?;
let hash = sha256(msg);
let msg = Message::from_digest_slice(&hash).map_err(|_| crate::NsignerError::CryptoFailed)?;
let msg = Message::from_digest_slice(&hash).map_err(|_| crate::SignerError::CryptoFailed)?;
let sig = secp.sign_ecdsa(&msg, &sk);
Ok(sig.serialize_compact())
}
@@ -200,69 +285,146 @@ pub fn secp256k1_ecdsa_verify(pub_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) ->
secp.verify_ecdsa(&msg, &signature, &pk).is_ok()
}
// ── PQ Crypto Stubs ──────────────────────────────────────────────────────────
// ── PQ Crypto (FIPS seeded keygen — v2 scheme) ───────────────────────────────
//
// 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.
// Keygen consumes the exact-length seed derived by `derive_pq_seed_from_path`
// (32 B ML-DSA, 48 B SLH-DSA, 64 B ML-KEM) via the RustCrypto seeded APIs.
// Private keys are stored in seed form (see `CryptoAlg::sizes`).
/// 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)
///
/// Returns (private_key = 32-byte ξ seed, public_key = 1952 bytes).
pub fn ml_dsa_65_keygen_from_seed(seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
use ml_dsa::{MlDsa65, SigningKey, signature::Keypair};
let sk = SigningKey::<MlDsa65>::from_seed(seed.into());
let vk = sk.verifying_key();
Ok((sk.to_seed().to_vec(), vk.encode().to_vec()))
}
/// 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: sign a message. priv is the 32-byte ξ seed.
/// Returns the 3309-byte signature (deterministic FIPS 204 variant).
pub fn ml_dsa_65_sign(priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
use ml_dsa::{MlDsa65, Seed, SigningKey, signature::Signer};
if priv_key.len() != 32 {
return Err(crate::SignerError::InvalidInput);
}
let seed: Seed = priv_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
let sk = SigningKey::<MlDsa65>::from_seed(&seed);
let sig = sk.sign(msg);
Ok(sig.encode().to_vec())
}
/// 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
/// ML-DSA-65: verify a signature. pub is the 1952-byte public key.
pub fn ml_dsa_65_verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
use ml_dsa::{MlDsa65, Signature, VerifyingKey, signature::Verifier};
if pub_key.len() != 1952 {
return false;
}
let enc: ml_dsa::EncodedVerifyingKey<MlDsa65> =
match pub_key.try_into() {
Ok(e) => e,
Err(_) => return false,
};
let vk = VerifyingKey::<MlDsa65>::decode(&enc);
let signature = match Signature::<MlDsa65>::try_from(sig) {
Ok(s) => s,
Err(_) => return false,
};
vk.verify(msg, &signature).is_ok()
}
/// 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 (SHA2 small): generate keypair from a 48-byte seed.
///
/// The seed splits as sk.seed(16) ∥ sk.prf(16) ∥ pk.seed(16) — matching
/// noble's `slh_dsa_sha2_128s.keygen(seed)` exactly.
/// Returns (private_key = 64-byte sk serialization, public_key = 32 bytes).
pub fn slh_dsa_128s_keygen_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
use slh_dsa::{Sha2_128s, SigningKey, signature::Keypair};
if seed.len() != 48 {
return Err(crate::SignerError::InvalidInput);
}
let sk = SigningKey::<Sha2_128s>::slh_keygen_internal(&seed[..16], &seed[16..32], &seed[32..48]);
let vk = sk.verifying_key();
Ok((sk.to_bytes().to_vec(), vk.to_bytes().to_vec()))
}
/// 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: sign a message. priv is the 64-byte sk serialization.
/// Returns the 7856-byte signature (deterministic: opt_rand = pk.seed).
pub fn slh_dsa_128s_sign(priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
use slh_dsa::{Sha2_128s, SigningKey, signature::Signer};
let sk = SigningKey::<Sha2_128s>::try_from(priv_key)
.map_err(|_| crate::SignerError::InvalidInput)?;
let sig = sk.sign(msg);
Ok(sig.to_vec())
}
/// 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
/// SLH-DSA-128s: verify a signature. pub is the 32-byte public key.
pub fn slh_dsa_128s_verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
use slh_dsa::{Sha2_128s, Signature, VerifyingKey, signature::Verifier};
let vk = match VerifyingKey::<Sha2_128s>::try_from(pub_key) {
Ok(k) => k,
Err(_) => return false,
};
let signature = match Signature::<Sha2_128s>::try_from(sig) {
Ok(s) => s,
Err(_) => return false,
};
vk.verify(msg, &signature).is_ok()
}
/// 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: generate keypair from a 64-byte seed (deterministic).
///
/// The seed splits as d(32) ∥ z(32) — matching noble's `ml_kem768.keygen(seed)`.
/// Returns (private_key = 64-byte seed, public_key = 1184 bytes).
pub fn ml_kem_768_keygen_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
use ml_kem::ml_kem_768::DecapsulationKey;
use ml_kem::{KeyExport, Seed};
if seed.len() != 64 {
return Err(crate::SignerError::InvalidInput);
}
let seed: Seed = seed.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
let dk = DecapsulationKey::from_seed(seed);
let ek = dk.encapsulation_key();
Ok((dk.to_seed().ok_or(crate::SignerError::CryptoFailed)?.to_vec(), ek.to_bytes().to_vec()))
}
/// 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: encapsulate. pub is the 1184-byte public key.
/// Returns (ciphertext[1088], shared_secret[32]). Uses OS randomness —
/// each encapsulation produces a different ciphertext, by design.
pub fn ml_kem_768_encaps(pub_key: &[u8]) -> Result<(Vec<u8>, [u8; 32]), crate::SignerError> {
use ml_kem::ml_kem_768::EncapsulationKey;
use ml_kem::kem::Encapsulate;
let ek = EncapsulationKey::new(
pub_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?,
)
.map_err(|_| crate::SignerError::InvalidInput)?;
let (ct, ss) = ek.encapsulate();
Ok((ct.to_vec(), ss.into()))
}
/// 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)
/// ML-KEM-768: decapsulate. priv is the 64-byte seed, ct is the 1088-byte
/// ciphertext. Returns shared_secret[32].
pub fn ml_kem_768_decaps(priv_key: &[u8], ct: &[u8]) -> Result<[u8; 32], crate::SignerError> {
use ml_kem::ml_kem_768::DecapsulationKey;
use ml_kem::kem::Decapsulate;
use ml_kem::Seed;
if priv_key.len() != 64 {
return Err(crate::SignerError::InvalidInput);
}
let seed: Seed = priv_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
let dk = DecapsulationKey::from_seed(seed);
let ct_arr = ct.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
let ss = dk.decapsulate(&ct_arr);
Ok(ss.into())
}
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -276,6 +438,9 @@ fn sha256(data: &[u8]) -> [u8; 32] {
mod tests {
use super::*;
/// The fixed test mnemonic used by nostr_quantum_preparation's vectors.
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
#[test]
fn test_alg_from_str() {
assert_eq!(CryptoAlg::from_str("secp256k1"), CryptoAlg::Secp256k1);
@@ -290,10 +455,22 @@ mod tests {
assert_eq!(s.priv_key_len, 32);
assert_eq!(s.pub_key_len, 32);
// PQ private keys are stored in seed form.
let s = CryptoAlg::MlDsa65.sizes().unwrap();
assert_eq!(s.priv_key_len, 32);
assert_eq!(s.pub_key_len, 1952);
assert_eq!(s.sig_len, 3309);
let s = CryptoAlg::SlhDsa128s.sizes().unwrap();
assert_eq!(s.priv_key_len, 64);
assert_eq!(s.pub_key_len, 32);
assert_eq!(s.sig_len, 7856);
let s = CryptoAlg::MlKem768.sizes().unwrap();
assert_eq!(s.priv_key_len, 2400);
assert_eq!(s.priv_key_len, 64);
assert_eq!(s.pub_key_len, 1184);
assert_eq!(s.ciphertext_len, 1088);
assert_eq!(s.shared_secret_len, 32);
}
#[test]
@@ -316,4 +493,150 @@ mod tests {
let shared_b = x25519_ecdh(&priv_b, &pub_a);
assert_eq!(shared_a, shared_b);
}
// ── v2 seeded derivation ──────────────────────────────────────────
#[test]
fn test_pq_seed_lengths() {
// 32-byte seed: single child.
let s32 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
assert_eq!(s32.len(), 32);
// 48-byte seed: two children concatenated, first 48 of 64.
let s48 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102004'/0'/0'/0'", 48).unwrap();
assert_eq!(s48.len(), 48);
// 64-byte seed: two children concatenated, all 64.
let s64 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102005'/0'/0'/0'", 64).unwrap();
assert_eq!(s64.len(), 64);
// The 48-byte seed is a prefix of the 64-byte seed only when the
// paths share the same coin type — here they differ, so just check
// prefix consistency within the same coin type.
let s48b = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102005'/0'/0'/0'", 48).unwrap();
assert_eq!(&s64[..48], s48b.as_slice());
}
#[test]
fn test_pq_seed_determinism() {
let a = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
let b = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
assert_eq!(a, b);
// Different index → different seed.
let c = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/1'", 32).unwrap();
assert_ne!(a, c);
}
#[test]
fn test_pq_seed_invalid_length() {
assert!(derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 33).is_err());
}
#[test]
fn test_pq_paths_use_bip32() {
// PQ coin types must route through BIP-32 (v2 scheme), not SLIP-0010.
// The 32-byte PQ seed equals the BIP-32 child at the same path.
let pq_seed =
derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
let bip32_seed =
derive_seed_from_mnemonic(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'").unwrap();
assert_eq!(pq_seed, bip32_seed.to_vec());
}
#[test]
fn test_ed25519_path_still_slip10() {
// ed25519 (102001') must remain SLIP-0010 — regression guard.
let seed = derive_seed_from_mnemonic(TEST_MNEMONIC, "m/44'/102001'/0'/0'/0'").unwrap();
let bip39_seed = nips::nip006::mnemonic_to_seed(TEST_MNEMONIC, "");
let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&bip39_seed);
let path_indices = nips::nip006::parse_bip44_path("m/44'/102001'/0'/0'/0'").unwrap();
let (expected, _) =
nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices)
.unwrap();
assert_eq!(seed, expected);
}
// ── PQ keygen / sign / verify / KEM roundtrips ────────────────────
#[test]
fn test_ml_dsa_65_roundtrip() {
let seed = [0x42u8; 32];
let (priv_key, pub_key) = ml_dsa_65_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key.len(), 32);
assert_eq!(pub_key.len(), 1952);
// Determinism: same seed → same keypair.
let (priv2, pub2) = ml_dsa_65_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key, priv2);
assert_eq!(pub_key, pub2);
let msg = b"hello world";
let sig = ml_dsa_65_sign(&priv_key, msg).unwrap();
assert_eq!(sig.len(), 3309);
assert!(ml_dsa_65_verify(&pub_key, msg, &sig));
assert!(!ml_dsa_65_verify(&pub_key, b"wrong message", &sig));
// Different seed → different key → verify fails.
let (_, pub_other) = ml_dsa_65_keygen_from_seed(&[0x99u8; 32]).unwrap();
assert!(!ml_dsa_65_verify(&pub_other, msg, &sig));
}
#[test]
fn test_slh_dsa_128s_roundtrip() {
let seed = [0x42u8; 48];
let (priv_key, pub_key) = slh_dsa_128s_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key.len(), 64);
assert_eq!(pub_key.len(), 32);
// Determinism.
let (priv2, pub2) = slh_dsa_128s_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key, priv2);
assert_eq!(pub_key, pub2);
let msg = b"hello world";
let sig = slh_dsa_128s_sign(&priv_key, msg).unwrap();
assert_eq!(sig.len(), 7856);
assert!(slh_dsa_128s_verify(&pub_key, msg, &sig));
assert!(!slh_dsa_128s_verify(&pub_key, b"wrong message", &sig));
// Deterministic signing: same key + msg → same signature
// (opt_rand defaults to pk.seed).
let sig2 = slh_dsa_128s_sign(&priv_key, msg).unwrap();
assert_eq!(sig, sig2);
}
#[test]
fn test_ml_kem_768_roundtrip() {
let seed = [0x42u8; 64];
let (priv_key, pub_key) = ml_kem_768_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key.len(), 64);
assert_eq!(pub_key.len(), 1184);
// Determinism.
let (priv2, pub2) = ml_kem_768_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key, priv2);
assert_eq!(pub_key, pub2);
// Encaps/decaps roundtrip.
let (ct, ss_send) = ml_kem_768_encaps(&pub_key).unwrap();
assert_eq!(ct.len(), 1088);
assert_eq!(ss_send.len(), 32);
let ss_recv = ml_kem_768_decaps(&priv_key, &ct).unwrap();
assert_eq!(ss_send, ss_recv);
// Encapsulation is randomized: two calls → different ciphertexts.
let (ct2, ss2) = ml_kem_768_encaps(&pub_key).unwrap();
assert_ne!(ct, ct2);
assert_ne!(ss_send.to_vec(), ss2.to_vec());
assert_eq!(ml_kem_768_decaps(&priv_key, &ct2).unwrap(), ss2);
}
#[test]
fn test_ml_kem_768_invalid_inputs() {
assert!(ml_kem_768_keygen_from_seed(&[0u8; 32]).is_err());
assert!(ml_kem_768_decaps(&[0u8; 64], &[0u8; 1087]).is_err());
assert!(ml_kem_768_encaps(&[0u8; 1183]).is_err());
}
}
+9 -2
View File
@@ -1,7 +1,14 @@
//! Deterministic PRNG for post-quantum key generation.
//! Deterministic PRNG (NOT used for PQ key derivation).
//!
//! Port of `pq_drbg.c`. Implements a SHAKE-256-based deterministic PRNG
//! that replaces PQClean's `randombytes()` callback. Same seed → same output.
//! that replaced PQClean's `randombytes()` callback in the C n_signer.
//! Same seed → same output.
//!
//! **Not used for derivation**: PQ keygen now uses the v2 FIPS seeded
//! interface (see `plans/pq_seeded_derivation_plan.md`) — BIP-32 child
//! bytes at the exact seed length feed the seeded keygen APIs directly.
//! This module is retained as a faithful port for any future
//! PQClean-style integration that needs an RNG-fed keygen.
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
+57 -15
View File
@@ -4,7 +4,7 @@
//! (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 crate::SignerError;
use std::collections::HashSet;
// ── Limits ───────────────────────────────────────────────────────────────────
@@ -137,6 +137,8 @@ pub struct RoleEntry {
pub pubkey_hex: String,
/// 1 if pubkey_hex has been populated.
pub derived: bool,
/// The concrete path the key was last derived for (variable-path roles).
pub derived_path: Option<String>,
/// Inclusive lower bound for %d; -1 = fixed path (no variable).
pub path_range_lo: i32,
/// Inclusive upper bound; == path_range_lo for single.
@@ -162,6 +164,7 @@ impl Default for RoleEntry {
role_path: String::new(),
pubkey_hex: String::new(),
derived: false,
derived_path: None,
path_range_lo: -1,
path_range_hi: -1,
path_default_index: -1,
@@ -177,6 +180,45 @@ impl RoleEntry {
self.selector_type == RoleSelectorType::RolePath && self.role_path.contains("%d")
}
/// Display the derivation path, replacing `%d` with the applicable range
/// or set description (matches the C `role_table_view_get_cell`).
///
/// - Fixed path (no `%d`): returned as-is.
/// - Set form: `1+34+54`.
/// - Single index: `N`.
/// - Range: `lo-hi`.
pub fn display_path(&self) -> String {
if self.selector_type == RoleSelectorType::NostrIndex {
return format!("m/44'/1237'/{}'/0/0", self.nostr_index);
}
if self.path_range_lo < 0 && self.path_allowed_indices.is_empty() {
// Fixed path (no %d placeholder)
return self.role_path.clone();
}
// Build the range/set description
let range_str = if !self.path_allowed_indices.is_empty() {
self.path_allowed_indices
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("+")
} else if self.path_range_lo == self.path_range_hi {
self.path_range_lo.to_string()
} else {
format!("{}-{}", self.path_range_lo, self.path_range_hi)
};
// Replace the first %d in role_path with range_str
if let Some(pct) = self.role_path.find("%d") {
let prefix = &self.role_path[..pct];
let tail = &self.role_path[pct + 2..];
format!("{}{}{}", prefix, range_str, tail)
} else {
self.role_path.clone()
}
}
/// Check if a concrete derivation path matches this role's path template.
///
/// The template may contain a `%d` placeholder (with optional `'` hardened marker).
@@ -225,12 +267,12 @@ impl RoleTable {
}
/// Add a role entry. Returns error if table full or name duplicate.
pub fn add(&mut self, entry: RoleEntry) -> Result<(), NsignerError> {
pub fn add(&mut self, entry: RoleEntry) -> Result<(), SignerError> {
if self.entries.len() >= ROLE_TABLE_MAX_ENTRIES {
return Err(NsignerError::Internal("role table full".into()));
return Err(SignerError::Internal("role table full".into()));
}
if self.find_by_name(&entry.name).is_some() {
return Err(NsignerError::Internal("duplicate role name".into()));
return Err(SignerError::Internal("duplicate role name".into()));
}
self.entries.push(entry);
Ok(())
@@ -264,7 +306,7 @@ impl RoleTable {
}
/// Register a nostr-index role if missing.
pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), NsignerError> {
pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), SignerError> {
if self.find_by_nostr_index(nostr_index).is_some() {
return Ok(());
}
@@ -297,7 +339,7 @@ impl RoleTable {
range_hi: i32,
default_index: i32,
allowed_indices: &[i32],
) -> Result<(), NsignerError> {
) -> Result<(), SignerError> {
let mut entry = RoleEntry::default();
entry.name = name.to_string();
entry.purpose = purpose;
@@ -392,10 +434,10 @@ pub fn role_path_extract_index(concrete: &str, template: &str) -> i32 {
#[allow(clippy::too_many_arguments)]
pub fn parse_path_template(
token: &str,
) -> Result<(String, i32, i32, Vec<i32>), NsignerError> {
) -> Result<(String, i32, i32, Vec<i32>), SignerError> {
let segs: Vec<&str> = token.split('/').collect();
if segs.is_empty() {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let mut template_out = String::new();
@@ -451,18 +493,18 @@ pub fn parse_path_template(
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)?;
let lo: i32 = tok[..dash].parse().map_err(|_| SignerError::InvalidInput)?;
let hi: i32 = tok[dash + 1..].parse().map_err(|_| SignerError::InvalidInput)?;
if lo < 0 || hi < 0 || lo > hi {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
for v in lo..=hi {
set.insert(v);
}
} else {
let val: i32 = tok.parse().map_err(|_| NsignerError::InvalidInput)?;
let val: i32 = tok.parse().map_err(|_| SignerError::InvalidInput)?;
if val < 0 {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
set.insert(val);
}
@@ -488,10 +530,10 @@ pub fn parse_path_template(
let dash_pos = seg_clean.find('-').unwrap();
let lo: i32 = seg_clean[..dash_pos]
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::InvalidInput)?;
let hi: i32 = seg_clean[dash_pos + 1..]
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::InvalidInput)?;
if lo < 0 || hi < 0 || lo > hi {
// Not a valid numeric range — treat as literal
template_out.push_str(seg);
+7 -7
View File
@@ -43,17 +43,17 @@ impl SecureBuf {
///
/// Returns `MemoryFailed` if allocation or mlock fails (unless
/// `allow_unlocked()` was called).
pub fn alloc(size: usize) -> Result<Self, crate::NsignerError> {
pub fn alloc(size: usize) -> Result<Self, crate::SignerError> {
if size == 0 {
return Err(crate::NsignerError::InvalidInput);
return Err(crate::SignerError::InvalidInput);
}
let layout = Layout::from_size_align(size, 1)
.map_err(|_| crate::NsignerError::MemoryFailed)?;
.map_err(|_| crate::SignerError::MemoryFailed)?;
let ptr = unsafe { alloc(layout) };
if ptr.is_null() {
return Err(crate::NsignerError::MemoryFailed);
return Err(crate::SignerError::MemoryFailed);
}
// Zero-initialize
@@ -64,7 +64,7 @@ impl SecureBuf {
if !locked && !is_unlocked_allowed() {
// mlock failed and unlocked mode not permitted — fail hard
unsafe { dealloc(ptr, layout) };
return Err(crate::NsignerError::MemoryFailed);
return Err(crate::SignerError::MemoryFailed);
}
Ok(SecureBuf { ptr, size, locked })
@@ -111,9 +111,9 @@ impl SecureBuf {
/// 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> {
pub fn resize(&mut self, new_size: usize) -> Result<(), crate::SignerError> {
if new_size == 0 {
return Err(crate::NsignerError::InvalidInput);
return Err(crate::SignerError::InvalidInput);
}
if new_size == self.size {
return Ok(());
+292 -151
View File
@@ -4,15 +4,13 @@
//! 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.
//! verifies auth envelopes, resolves the role selector (role-name-as-password),
//! and dispatches the request to the dispatcher. No policy table or approval.
use crate::auth_envelope::AuthNonceCache;
use crate::dispatcher::DispatcherContext;
use crate::policy::{PolicyResult, PolicyTable};
use crate::selector::{selector_resolve, SelectorRequest};
use crate::NsignerError;
use crate::SignerError;
use std::net::TcpListener;
use std::os::unix::net::UnixListener;
@@ -64,6 +62,38 @@ impl CallerIdentity {
auth_label: String::new(),
}
}
/// Build a rich identity string for the activity log — as much as can be
/// identified about the caller.
///
/// - Unix socket: `uid:<n> gid:<n> pid:<n>`
/// - Qrexec: `qubes:<vm>`
/// - TCP/HTTP: `tcp:<addr>`
/// - Auth envelope verified: `pubkey:<hex> label:<label>` appended.
pub fn identity_str(&self) -> String {
let mut parts: Vec<String> = Vec::new();
match self.kind {
ListenMode::Unix => {
parts.push(format!("uid:{}", self.uid));
if self.gid != 0 {
parts.push(format!("gid:{}", self.gid));
}
if self.pid != 0 {
parts.push(format!("pid:{}", self.pid));
}
}
_ => {
parts.push(self.caller_id.clone());
}
}
if self.auth_present {
parts.push(format!("pubkey:{}", self.auth_pubkey_hex));
if !self.auth_label.is_empty() {
parts.push(format!("label:{}", self.auth_label));
}
}
parts.join(" ")
}
}
/// Server context.
@@ -95,14 +125,14 @@ impl ServerContext {
}
/// Start listening. Returns error on bind failure.
pub fn start(&mut self) -> Result<(), NsignerError> {
pub fn start(&mut self) -> Result<(), SignerError> {
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()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
self.listener = Some(listener);
self.running = true;
Ok(())
@@ -115,10 +145,10 @@ impl ServerContext {
.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()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
listener
.set_nonblocking(true)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
self.tcp_listener = Some(listener);
self.running = true;
Ok(())
@@ -139,100 +169,146 @@ impl ServerContext {
}
/// Handle one pending connection (non-blocking).
/// Returns Ok(true) if handled, Ok(false) if nothing pending.
/// Returns Ok(Some(activity_msg)) if a request was handled,
/// Ok(None) if nothing pending.
pub fn handle_one(
&mut self,
dispatcher: &mut DispatcherContext,
policy: &mut PolicyTable,
) -> Result<bool, NsignerError> {
) -> Result<Option<String>, SignerError> {
if let Some(ref listener) = self.listener {
match listener.accept() {
Ok((stream, _)) => {
// Make the accepted stream non-blocking so a client that
// connects but sends nothing (e.g. the C client's reconnect
// probe) doesn't block the server.
let _ = stream.set_nonblocking(true);
let mut reader = stream
.try_clone()
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let mut writer = stream;
// Read framed request
let request = match crate::transport::recv_framed(&mut reader) {
// Read framed request. A connection with no data yet
// (WouldBlock) or an empty/closed probe is not a handled
// request — return Ok(None) so we don't log it as handled.
let mut request = match crate::transport::recv_framed(&mut reader) {
Ok(r) => r,
Err(_) => return Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(None);
}
Err(_) => return Ok(None),
};
// Identify caller via SO_PEERCRED
let caller = identify_unix_caller(&reader);
let mut caller = identify_unix_caller(&reader);
// Process with policy enforcement
let response = self.process_request(dispatcher, policy, &request, &caller);
// Bridge preamble: `signer bridge` (qrexec relay) sends a
// {"qrexec_source":"<qube>"} frame before the actual
// JSON-RPC request. Consume it, record the source qube,
// and read the real request that follows.
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&request) {
if v.get("qrexec_source").is_some() && v.get("method").is_none() {
caller.source_qube = v
.get("qrexec_source")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
if !caller.source_qube.is_empty() {
caller.caller_id = format!("qubes:{}", caller.source_qube);
}
request = match crate::transport::recv_framed(&mut reader) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(None);
}
Err(_) => return Ok(None),
};
}
}
// Process request (role-name-as-password model: no authorization)
let (response, activity) = self.process_request(dispatcher, &request, &caller);
// Send framed response
if let Err(_) = crate::transport::send_framed(&mut writer, &response) {
// Client disconnected — ignore
}
return Ok(true);
return Ok(Some(activity));
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(false); // Nothing pending
return Ok(None); // Nothing pending
}
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
Err(e) => return Err(SignerError::IoFailed(e.to_string())),
}
}
if let Some(ref listener) = self.tcp_listener {
match listener.accept() {
Ok((stream, _)) => {
// Make the accepted stream non-blocking so a client that
// connects but sends nothing doesn't block the server.
let _ = stream.set_nonblocking(true);
// 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()))?;
.map_err(|e| SignerError::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),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(None);
}
Err(_) => return Ok(None),
}
} else {
match crate::transport::recv_framed(&mut reader) {
Ok(r) => r,
Err(_) => return Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(None);
}
Err(_) => return Ok(None),
}
};
// Process with policy enforcement
let response = self.process_request(dispatcher, policy, &request, &caller);
// Process request (role-name-as-password model: no authorization)
let (response, activity) = self.process_request(dispatcher, &request, &caller);
if self.listen_mode == ListenMode::Http {
let _ = crate::http::send_response(&mut writer, &response);
} else {
let _ = crate::transport::send_framed(&mut writer, &response);
}
return Ok(true);
return Ok(Some(activity));
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(false);
return Ok(None);
}
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
Err(e) => return Err(SignerError::IoFailed(e.to_string())),
}
}
Ok(false)
Ok(None)
}
/// Process a request through the full security pipeline:
/// auth envelope → selector resolution → policy check → approval → dispatch.
/// Process a request.
///
/// Authorization model: the role name serves as a password. If the caller
/// knows a valid role name (resolved via the selector), the request is
/// allowed. There is no policy table, no approval prompt, and no per-caller
/// access control. Auth-envelope verification is still applied when
/// `--auth` is enabled (that authenticates the caller identity, not
/// authorization).
fn process_request(
&mut self,
dispatcher: &mut DispatcherContext,
policy: &mut PolicyTable,
request: &str,
caller: &CallerIdentity,
) -> String {
) -> (String, String) {
// ── Auth envelope verification ─────────────────────────────
let mut caller = caller.clone();
if self.auth_mode != AuthMode::Off {
@@ -249,7 +325,9 @@ impl ServerContext {
}
Err((code, msg)) => {
if self.auth_mode == AuthMode::Required {
return make_auth_error(&request, code, msg);
let response = make_auth_error(&request, code, msg);
let activity = format!("{} DENIED:{}", caller.identity_str(), msg);
return (response, activity);
}
// Optional: continue without auth
}
@@ -261,119 +339,93 @@ impl ServerContext {
Some(v) => v,
None => {
// Malformed request — let the dispatcher produce the error
return crate::dispatcher::handle_request(dispatcher, request);
let response = crate::dispatcher::handle_request(dispatcher, request);
let activity = format!("{} DENIED:malformed", caller.identity_str());
return (response, activity);
}
};
// get_info is metadata — no key material, no policy check
// get_info is metadata — no key material
if method == crate::enforcement::VERB_GET_INFO {
return crate::dispatcher::handle_request(dispatcher, request);
let response = crate::dispatcher::handle_request(dispatcher, request);
let activity = format!("{} {}()", caller.identity_str(), method);
return (response, activity);
}
// Algorithm-based verbs (bypass role table) — check algorithm policy
// Algorithm-based verbs (bypass role table) — no authorization
if crate::enforcement::is_algorithm_verb(&method) {
return self.process_algorithm_verb(dispatcher, policy, request, &caller, &method, &selector_req);
let response = self.process_algorithm_verb(dispatcher, request, &selector_req);
// Activity: caller method(algorithm,index) — the algorithm's
// standard derivation path identifies the key material.
let (alg_name, path) = extract_algorithm_and_path(request);
let activity = match (alg_name, path) {
(Some(a), Some(p)) => format!("{} {}({},{} {})", caller.identity_str(), method, a, selector_req.index, p),
(Some(a), None) => format!("{} {}({})", caller.identity_str(), method, a),
_ => format!("{} {}()", caller.identity_str(), method),
};
return (response, activity);
}
// OTP verbs
if method == crate::enforcement::VERB_ENCRYPT || method == crate::enforcement::VERB_DECRYPT {
return crate::dispatcher::handle_request(dispatcher, request);
let response = crate::dispatcher::handle_request(dispatcher, request);
let activity = format!("{} {}()", caller.identity_str(), method);
return (response, activity);
}
// ── Resolve role selector ──────────────────────────────────
// ── Resolve role selector (the "password" check) ───────────
// If the role name doesn't exist, the request is rejected here.
let role_index = match selector_resolve(&selector_req, dispatcher.role_table) {
Ok(i) => i,
Err(e) => {
return make_selector_error(&request, e);
let response = make_selector_error(&request, e);
let activity = format!(
"{} {}() DENIED:{}",
caller.identity_str(),
method,
e.as_str()
);
return (response, activity);
}
};
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,
// Role entry from the resolved selector — used for the activity
// message (role name, curve, and the concrete key path requested).
let role_entry = &dispatcher.role_table.entries[role_index];
let role_name = role_entry.name.clone();
let curve = role_entry.curve_str.clone();
// The actual key path the caller requested/accessed — the concrete
// role_path from the request when supplied, otherwise the role's
// fixed/derived path. (Not the allowed range.)
let actual_path = if selector_req.has_role_path {
selector_req.role_path.clone()
} else {
role_entry.display_path()
};
if decision != PolicyResult::Allow {
return make_policy_denied(&request);
}
// ── Dispatch ───────────────────────────────────────────────
crate::dispatcher::handle_request(dispatcher, request)
let response = crate::dispatcher::handle_request(dispatcher, request);
// Activity format: uid role curve path (timestamp is added by the log).
let activity = format!(
"{} {} {} {}",
caller.identity_str(),
role_name,
curve,
actual_path
);
(response, activity)
}
/// Process an algorithm-based verb with algorithm policy check.
/// Process an algorithm-based verb.
///
/// No authorization — the request is dispatched directly. (Algorithm verbs
/// are addressed by algorithm + index, not by role name.)
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)
}
@@ -383,16 +435,16 @@ impl ServerContext {
///
/// 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> {
fn bind_abstract_unix(name: &str) -> Result<UnixListener, SignerError> {
use std::os::unix::io::FromRawFd;
if name.len() >= 107 {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
if fd < 0 {
return Err(NsignerError::IoFailed("socket() failed".into()));
return Err(SignerError::IoFailed("socket() failed".into()));
}
// Build sockaddr_un with abstract namespace (sun_path[0] = '\0')
@@ -416,14 +468,14 @@ fn bind_abstract_unix(name: &str) -> Result<UnixListener, NsignerError> {
if rc != 0 {
let err = std::io::Error::last_os_error();
unsafe { libc::close(fd) };
return Err(NsignerError::IoFailed(format!("bind: {}", err)));
return Err(SignerError::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)));
return Err(SignerError::IoFailed(format!("listen: {}", err)));
}
// Wrap the raw fd in a UnixListener
@@ -510,24 +562,38 @@ fn extract_method_and_selector(request: &str) -> Option<(String, SelectorRequest
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;
}
}
/// Extract the algorithm name and its standard derivation path from an
/// algorithm-verb request's options (for the activity log).
///
/// Returns `(algorithm_name, Some(path))` when the request carries a valid
/// algorithm; `(None, None)` otherwise.
fn extract_algorithm_and_path(request: &str) -> (Option<String>, Option<String>) {
let root: serde_json::Value = match serde_json::from_str(request) {
Ok(v) => v,
Err(_) => return (None, None),
};
let alg_str = root
.get("params")
.and_then(|p| p.as_array())
.and_then(|p| p.last())
.and_then(|o| o.get("algorithm"))
.and_then(|v| v.as_str());
let Some(alg_str) = alg_str else {
return (None, None);
};
let alg = crate::pq_crypto::CryptoAlg::from_str(alg_str);
if alg == crate::pq_crypto::CryptoAlg::Unknown {
return (Some(alg_str.to_string()), None);
}
(algorithm, index)
let index = root
.get("params")
.and_then(|p| p.as_array())
.and_then(|p| p.last())
.and_then(|o| o.get("index"))
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
let path = crate::alg_cache::standard_path(alg, index).ok();
(Some(alg.as_str().to_string()), path)
}
/// Build an auth error response.
@@ -558,14 +624,6 @@ fn make_selector_error(request: &str, err: crate::selector::SelectorError) -> St
)
}
/// 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 {
@@ -580,3 +638,86 @@ fn extract_id(request: &str) -> String {
})
.unwrap_or_else(|| "null".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// Build the activity line for an algorithm-verb request, exactly as
/// `process_request` does.
fn activity_for(request: &str) -> String {
let (method, selector_req) =
extract_method_and_selector(request).expect("valid request");
assert!(crate::enforcement::is_algorithm_verb(&method));
let (alg_name, path) = extract_algorithm_and_path(request);
let caller_id = "uid:1000";
match (alg_name, path) {
(Some(a), Some(p)) => {
format!("{} {}({},{} {})", caller_id, method, a, selector_req.index, p)
}
(Some(a), None) => format!("{} {}({})", caller_id, method, a),
_ => format!("{} {}()", caller_id, method),
}
}
#[test]
fn test_activity_all_algorithms() {
// Every algorithm must produce a non-blank activity line with its
// curve name and standard derivation path.
let cases = [
("secp256k1", "m/44'/1237'/0'/0/0"),
("ed25519", "m/44'/102001'/0'/0'/0'"),
("x25519", "m/44'/102002'/0'/0'/0'"),
("ml-dsa-65", "m/44'/102003'/0'/0'/0'"),
("slh-dsa-128s", "m/44'/102004'/0'/0'/0'"),
("ml-kem-768", "m/44'/102005'/0'/0'/0'"),
];
for (alg, expected_path) in cases {
let req = format!(
r#"{{"id":"1","method":"get_public_key","params":[{{"algorithm":"{}","index":0}}]}}"#,
alg
);
let activity = activity_for(&req);
assert!(
activity.contains(alg),
"activity '{}' must contain algorithm '{}'",
activity,
alg
);
assert!(
activity.contains(expected_path),
"activity '{}' must contain path '{}'",
activity,
expected_path
);
assert!(
!activity.contains("- -"),
"activity '{}' must not contain the blank placeholder",
activity
);
}
}
#[test]
fn test_activity_index_substituted() {
let req = r#"{"id":"1","method":"sign","params":["00ff",{"algorithm":"ml-dsa-65","index":7}]}"#;
let activity = activity_for(req);
assert!(activity.contains("m/44'/102003'/7'/0'/0'"), "activity: {}", activity);
assert!(activity.contains("sign(ml-dsa-65,7"), "activity: {}", activity);
}
#[test]
fn test_activity_unknown_algorithm() {
let req = r#"{"id":"1","method":"sign","params":["00ff",{"algorithm":"bogus","index":0}]}"#;
let activity = activity_for(req);
// Unknown algorithm: name echoed, no path (dispatcher will reject).
assert!(activity.contains("sign(bogus)"), "activity: {}", activity);
}
#[test]
fn test_activity_missing_algorithm() {
let req = r#"{"id":"1","method":"sign","params":["00ff",{}]}"#;
let activity = activity_for(req);
assert!(activity.contains("sign()"), "activity: {}", activity);
}
}
+45 -26
View File
@@ -1,43 +1,61 @@
//! Socket naming — random abstract socket name generation.
//! Socket naming — sequential abstract socket name generation.
//!
//! Port of `socket_name.c`. Generates random names in the format
//! `nsigner_<word1>_<word2>` using the BIP-39 English wordlist.
//! Generates names in the format `signer01`, `signer02`, … incrementing
//! until an unused name is found (by checking /proc/net/unix).
//!
//! The `signer` prefix is required for compatibility with the C
//! `signer_client` / `signer_transport_list_unix`, which scans
//! /proc/net/unix for the literal prefix `@signer`.
use rand::seq::SliceRandom;
use rand::thread_rng;
/// Prefix for generated socket names.
pub const SOCKET_NAME_PREFIX: &str = "signer";
/// Generate a random socket name: `nsigner_<word1>_<word2>`.
/// Generate a socket name: `signer01`, `signer02`, …
///
/// 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();
/// Scans /proc/net/unix for already-running signer sockets and picks the
/// lowest unused number (starting at 1, zero-padded to 2 digits).
pub fn socket_name_random() -> Result<String, crate::SignerError> {
let in_use = list_sockets();
let word1 = wordlist
.choose(&mut rng)
.ok_or(crate::NsignerError::CryptoFailed)?;
let word2 = wordlist
.choose(&mut rng)
.ok_or(crate::NsignerError::CryptoFailed)?;
// Try signer01, signer02, … up to signer99
for n in 1..=99u32 {
let candidate = format!("{}{:02}", SOCKET_NAME_PREFIX, n);
if !in_use.contains(&candidate) {
return Ok(candidate);
}
}
Ok(format!("nsigner_{}_{}", word1, word2))
// Fallback: signer100, signer101, … (no zero-padding beyond 99)
for n in 100..=9999u32 {
let candidate = format!("{}{}", SOCKET_NAME_PREFIX, n);
if !in_use.contains(&candidate) {
return Ok(candidate);
}
}
Err(crate::SignerError::Internal(
"no available socket name (signer01..signer9999 all in use)".into(),
))
}
/// List running nsigner abstract sockets by reading /proc/net/unix.
/// List running signer abstract sockets by reading /proc/net/unix.
///
/// Matches the C `signer_transport_list_unix` scan: looks for the literal
/// prefix `@signer` in the path column.
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") {
// Look for @signer prefix in the path column (matches C client scan)
if let Some(pos) = line.find("@signer") {
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_") {
if name.starts_with(SOCKET_NAME_PREFIX) {
found.push(name);
}
}
@@ -47,14 +65,14 @@ pub fn list_sockets() -> Vec<String> {
found
}
/// Discover a single running nsigner socket.
/// Discover a single running signer socket.
/// Returns Ok(name) if exactly one is found, Err if zero or multiple.
pub fn discover_single_socket() -> Result<String, crate::NsignerError> {
pub fn discover_single_socket() -> Result<String, crate::SignerError> {
let sockets = list_sockets();
if sockets.len() == 1 {
Ok(sockets[0].clone())
} else {
Err(crate::NsignerError::NotFound)
Err(crate::SignerError::NotFound)
}
}
@@ -65,7 +83,8 @@ mod tests {
#[test]
fn test_socket_name_random() {
let name = socket_name_random().unwrap();
assert!(name.starts_with("nsigner_"));
assert!(name.len() > 10); // nsigner_ + two words
assert!(name.starts_with("signer"));
// Should be signer01..signer99 (8 chars) or signer100+ (9+ chars)
assert!(name.len() >= 8);
}
}
+1708 -629
View File
File diff suppressed because it is too large Load Diff
-915
View File
@@ -1,915 +0,0 @@
//! # 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();
}
}
+46 -175
View File
@@ -1,14 +1,14 @@
//! Integration tests — end-to-end server + client over Unix socket.
//!
//! These tests verify the full security pipeline:
//! caller identification → policy check → approval → dispatch.
//! These tests verify the role-name-as-password model:
//! caller sends a request with a role name → selector resolution → dispatch.
//! No policy table, no approval prompt. Knowing a valid role name is sufficient.
use nsigner::{
use signer::{
alg_cache::AlgorithmKeyCache,
dispatcher::DispatcherContext,
key_store::KeyStore,
mnemonic::MnemonicState,
policy::{parse_preapprove_spec, PolicyTable},
role_table::{RoleCurve, RolePurpose, RoleTable},
server::{AuthMode, ListenMode, ServerContext},
};
@@ -58,7 +58,6 @@ fn spawn_server_loop(
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();
@@ -71,9 +70,9 @@ fn spawn_server_loop(
key_store: &mut key_store,
alg_key_cache: &mut alg_cache,
};
match server.handle_one(&mut dispatcher, &mut policy) {
Ok(true) => {}
Ok(false) => {
match server.handle_one(&mut dispatcher) {
Ok(Some(_activity)) => {}
Ok(None) => {
std::thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
@@ -86,15 +85,15 @@ fn spawn_server_loop(
/// 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()
let mut stream = signer::transport::connect_abstract_unix(socket_name).unwrap();
signer::transport::send_framed(&mut stream, request).unwrap();
signer::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() {
if signer::transport::connect_abstract_unix(socket_name).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(20));
@@ -103,239 +102,111 @@ fn wait_for_server(socket_name: &str, attempts: u32) {
}
#[test]
fn test_get_info_no_policy_needed() {
let socket_name = format!("nsigner_test_info_{}", std::process::id());
fn test_get_info_works() {
let socket_name = format!("signer_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);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
wait_for_server(&socket_name, 100);
// get_info is metadata — should work without policy
// get_info is metadata — no role needed
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);
let _ = signer::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());
fn test_role_as_password_allows_with_valid_role() {
let socket_name = format!("signer_test_allow_{}", 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);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
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.
// Knowing the "main" role name is sufficient — no authorization.
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);
let _ = signer::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 socket_name = format!("signer_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);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
wait_for_server(&socket_name, 100);
// Unknown role should return unknown_role error before policy check
// Unknown role name → selector error (the "password" is wrong)
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"}]}"#,
r#"{"id":"3","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);
let _ = signer::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());
fn test_ed25519_sign_allowed_no_authorization() {
let socket_name = format!("signer_test_alg_{}", 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);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
wait_for_server(&socket_name, 100);
// Algorithm-based verbs have no authorization — dispatched directly.
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}}]}}"#,
r#"{{"id":"4","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);
let _ = signer::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());
fn test_repeated_requests_all_allowed() {
let socket_name = format!("signer_test_repeat_{}", 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);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
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);
// Multiple requests with the same valid role — all allowed (no session state needed)
for i in 5..=7 {
let req = format!(
r#"{{"id":"{}","method":"nostr_get_public_key","params":[{{"role":"main","role_path":"m/44'/1237'/0'/0/0"}}]}}"#,
i
);
let resp = send_request(&socket_name, &req);
assert!(resp.contains("\"result\""), "request {} failed: {}", i, 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);
let _ = signer::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) {}
fn _unused(_l: UnixListener) {}
+104
View File
@@ -0,0 +1,104 @@
//! Cross-implementation PQ keygen conformance.
//!
//! Validates the v2 seeded derivation against the pinned vectors from
//! nostr_quantum_preparation (`test/vectors/seed-to-pubkeys.v2.json`):
//! the same fixed mnemonic must produce the same PQ public keys in the
//! web app (noble) and here (RustCrypto crates).
//!
//! The test is skipped (passes with a note) when the vector file does not
//! exist yet — coordinate generation with the web app project
//! (`test/vectors/generate-vectors.mjs`).
use signer::pq_crypto;
const VECTOR_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../nostr_quantum_preparation/test/vectors/seed-to-pubkeys.v2.json"
);
#[derive(serde::Deserialize)]
struct Vector {
mnemonic: String,
#[serde(rename = "derivedPublicKeys")]
derived_public_keys: DerivedPublicKeys,
}
#[derive(serde::Deserialize)]
struct DerivedPublicKeys {
#[serde(rename = "ml-dsa-65")]
ml_dsa_65: KeyEntry,
#[serde(rename = "slh-dsa-128s")]
slh_dsa_128s: KeyEntry,
#[serde(rename = "ml-kem-768")]
ml_kem_768: KeyEntry,
}
#[derive(serde::Deserialize)]
struct KeyEntry {
#[serde(rename = "derivationPath")]
derivation_path: String,
#[serde(rename = "publicKeyHex")]
public_key_hex: String,
}
/// Extract the base path (strip the trailing leaf index and any sibling
/// notation like "+ m/44'/102004'/0'/0'/1'").
fn base_path(full: &str) -> String {
full.split(" + ").next().unwrap_or(full).to_string()
}
#[test]
fn pq_pubkeys_match_web_app_v2_vectors() {
let data = match std::fs::read_to_string(VECTOR_PATH) {
Ok(d) => d,
Err(_) => {
eprintln!("SKIP: {VECTOR_PATH} not found — generate it in nostr_quantum_preparation");
return;
}
};
let vector: Vector = serde_json::from_str(&data)
.expect("valid v2 vector JSON");
// ML-DSA-65: 32-byte seed from one child.
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.ml_dsa_65.derivation_path),
32,
)
.unwrap();
let seed_arr: [u8; 32] = seed.as_slice().try_into().unwrap();
let (_, pub_key) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.ml_dsa_65.public_key_hex,
"ml-dsa-65 pubkey must match the web app vector"
);
// SLH-DSA-128s: 48-byte seed from two children (first 48 of 64).
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.slh_dsa_128s.derivation_path),
48,
)
.unwrap();
let (_, pub_key) = pq_crypto::slh_dsa_128s_keygen_from_seed(&seed).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.slh_dsa_128s.public_key_hex,
"slh-dsa-128s pubkey must match the web app vector"
);
// ML-KEM-768: 64-byte seed from two children.
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.ml_kem_768.derivation_path),
64,
)
.unwrap();
let (_, pub_key) = pq_crypto::ml_kem_768_keygen_from_seed(&seed).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.ml_kem_768.public_key_hex,
"ml-kem-768 pubkey must match the web app vector"
);
}