Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52ac2357f3 | ||
|
|
2cd64032ee | ||
|
|
ea11bb5a87 | ||
|
|
1a48f40b99 | ||
|
|
b84ce7ab11 |
@@ -0,0 +1,3 @@
|
||||
[submodule "ratatui"]
|
||||
path = ratatui
|
||||
url = https://github.com/ratatui/ratatui.git
|
||||
Generated
+451
-3
@@ -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",
|
||||
]
|
||||
@@ -1233,12 +1490,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nsigner"
|
||||
version = "0.0.1"
|
||||
version = "0.0.5"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"crossterm 0.27.0",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"hmac 0.12.1",
|
||||
@@ -1249,6 +1506,7 @@ dependencies = [
|
||||
"nostr-nips",
|
||||
"rand",
|
||||
"rand_core 0.6.4",
|
||||
"ratatui",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1261,6 +1519,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1270,6 +1534,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 +1604,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 +1709,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 +1724,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 +1799,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 +2203,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio 0.8.11",
|
||||
"mio 1.2.2",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nsigner"
|
||||
version = "0.0.1"
|
||||
version = "0.0.6"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Attended Nostr signing daemon — Rust port of n_signer"
|
||||
@@ -48,6 +48,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# TUI
|
||||
crossterm = "0.27"
|
||||
ratatui = { path = "ratatui/ratatui", default-features = false, features = ["crossterm"] }
|
||||
|
||||
# Encoding
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
# nsigner
|
||||
|
||||
`nsigner` is a Rust port of the [n_signer](https://github.com/lt/n_signer) project — a single statically-linked program that holds signing key material in locked memory and signs on request.
|
||||
|
||||
It runs in the foreground, attached to your terminal. The terminal is the trust anchor and control surface. Nothing touches disk at runtime. If the process crashes or exits, all in-memory state is gone.
|
||||
|
||||
This is a **program, not a daemon**:
|
||||
|
||||
- no hidden background process
|
||||
- no detached service lifecycle
|
||||
- no runtime config or state files
|
||||
- no persistence to recover after compromise
|
||||
|
||||
## 1. What it is
|
||||
|
||||
`nsigner` is one binary that combines:
|
||||
|
||||
- mnemonic handling (BIP-39)
|
||||
- role selection and derivation (BIP-32 / SLIP-0010)
|
||||
- purpose/curve enforcement
|
||||
- request dispatch
|
||||
- interactive terminal UI (ratatui)
|
||||
- transport adapter(s) (Unix socket, qrexec, FIPS/TCP, HTTP, stdio)
|
||||
- OTP one-time pad encryption (optional, with USB pad)
|
||||
- post-quantum algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
|
||||
|
||||
You run it when you need signing. You stop it when you are done. Closing the terminal or quitting the program ends the trust session and destroys state.
|
||||
|
||||
## 2. Security model
|
||||
|
||||
### 2.1 Zero filesystem footprint
|
||||
|
||||
At runtime, `nsigner` writes nothing to disk: no config files, no logs, no PID files, no lock files, no socket pathname artifacts. On Linux desktop, local IPC uses abstract namespace Unix sockets (`@name` semantics) that exist only in kernel memory and disappear with process/kernel namespace lifetime.
|
||||
|
||||
### 2.2 Crash = total wipe
|
||||
|
||||
All sensitive and operational state exists only in-process RAM (mlock'd where applicable): mnemonic-derived key material, role table, activity display buffer. If the process dies (fault, kill, exploit, power loss), state is unrecoverable by design. Sensitive buffers are zeroized with [`zeroize`](src/secure_mem.rs) on drop.
|
||||
|
||||
### 2.3 Single binary, no external runtime dependencies
|
||||
|
||||
The release build (`opt-level = "z"`, `lto = true`, `panic = "abort"`, `strip = true`) produces one optimized binary. No shared libraries are required at runtime beyond the system libc.
|
||||
|
||||
### 2.4 Always-attended operation
|
||||
|
||||
`nsigner` is intentionally human-attended. It stays attached to a terminal and the role-name-as-password model means a caller must already know the role name (the "password") to reach a key. Human presence is part of the security model.
|
||||
|
||||
### 2.5 Secret memory backing: `mlock`
|
||||
|
||||
Sensitive buffers (mnemonic, master seed, per-role private keys) live in `mlock`'d RAM and are zeroized on free. This gives swap protection and crash-wipe semantics on every supported platform, including Qubes OS Xen guests.
|
||||
|
||||
## 3. How it works
|
||||
|
||||
### 3.1 Startup phase (TUI input mode)
|
||||
|
||||
When started interactively, `nsigner` immediately enters a startup popup:
|
||||
|
||||
1. **Seed entry popup** — enter an existing BIP-39 mnemonic, or type `g` to generate a fresh 12-word mnemonic (displayed numbered with a "WRITE THIS DOWN — IT WILL NOT BE SHOWN AGAIN" warning, then press Enter to continue).
|
||||
2. On successful load, derive keys for any pre-registered roles (the default `main` role is auto-registered), start the server with the default transport (Unix), and transition to the main screen.
|
||||
3. Invalid mnemonics show an error line and retry (max 10 attempts).
|
||||
|
||||
No startup files are read or written. The mnemonic — typed or generated — lives only in `mlock`'d memory and is zeroized on shutdown or crash.
|
||||
|
||||
For parent-process launchers, startup can also be non-interactive:
|
||||
|
||||
- `--mnemonic-stdin`: read one mnemonic line from stdin at startup, then continue normally.
|
||||
- `--mnemonic-fd N`: read one mnemonic line from inherited file descriptor `N` at startup.
|
||||
|
||||
These modes avoid putting mnemonic material in argv/environment and are designed for supervised spawners. Non-interactive modes run headless (no TUI).
|
||||
|
||||
### 3.2 Running phase (main screen)
|
||||
|
||||
After unlock, the terminal becomes a live status and control console. The title `Signer v0.0.1` is centered on its own line. Below it, collapsed-border panes show:
|
||||
|
||||
- **Information** — signer name, transport addresses (Unix / Qube / FIPS / HTTP), OTP pad status.
|
||||
- **Transport** — four toggle lines (`[x] U̲nix Socket`, `[ ] Qube b̲ridge`, `[ ] F̲IPS`, `[ ] H̲TTP`). The `[x]` / `[ ]` indicator shows on/off state. The underlined key letter toggles a transport on/off. The last active transport cannot be disabled. Toggling restarts the server immediately.
|
||||
- **Roles** — table of registered roles (Role, Purpose, Curve). `A̲dd` opens the add-role popup; `D̲elete` removes the selected role immediately.
|
||||
- **Activity** — scrollable, newest first. Each entry is a timestamped log line in the format `<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
|
||||
|
||||
`nsigner` exposes a JSON-RPC 2.0-style request/response protocol. Every request is a single JSON object; every response is a single JSON object. This section is the complete, authoritative description of the API.
|
||||
|
||||
### 4.1 Request format
|
||||
|
||||
```json
|
||||
{ "id": "<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'` → DRBG | 4032 / 1952 |
|
||||
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 |
|
||||
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 |
|
||||
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
|
||||
|
||||
#### 4.4.2 Key derivation
|
||||
|
||||
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
|
||||
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
|
||||
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces the RNG during keygen. Same mnemonic, same index, same key pair every time. The PQ implementations are the pure-Rust crates [`ml-dsa`](https://crates.io/crates/ml-dsa), [`ml-kem`](https://crates.io/crates/ml-kem), and [`slh-dsa`](https://crates.io/crates/slh-dsa). The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
|
||||
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
|
||||
|
||||
#### 4.4.3 OTP
|
||||
|
||||
The `otp` algorithm is a stream-style one-time pad, not a key-derivation scheme. It is selected like any other algorithm via `{"algorithm":"otp"}` and works with the `encrypt` / `decrypt` verbs. One pad per session; the pad offset advances monotonically across requests and is reported in every response.
|
||||
|
||||
### 4.5 Examples
|
||||
|
||||
#### `get_info`
|
||||
|
||||
Returns signer metadata: `name`, `implementation`, `version`, `api`, and the supported `verbs` / `algorithms` arrays. Safe to call before the mnemonic is loaded — clients use it to feature-detect.
|
||||
|
||||
```json
|
||||
{ "id": "0", "method": "get_info", "params": [] }
|
||||
```
|
||||
|
||||
#### `get_public_key`
|
||||
|
||||
```json
|
||||
{ "id": "1", "method": "get_public_key", "params": [ { "algorithm": "ml-dsa-65", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "1", "result": "{\"algorithm\":\"ml-dsa-65\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}" }
|
||||
```
|
||||
|
||||
`key_id` is the first 16 hex characters of the public key — a short display identifier.
|
||||
|
||||
#### `sign`
|
||||
|
||||
```json
|
||||
{ "id": "2", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "ed25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "2", "result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\",\"key_id\":\"<16 hex>\"}" }
|
||||
```
|
||||
|
||||
The first positional argument is the message as hex. For `secp256k1` the `scheme` option selects `"schnorr"` (default, BIP-340) or `"ecdsa"`:
|
||||
|
||||
```json
|
||||
{ "id": "3", "method": "sign", "params": [ "68656c6c6f", { "algorithm": "secp256k1", "index": 0, "scheme": "ecdsa" } ] }
|
||||
```
|
||||
|
||||
#### `verify`
|
||||
|
||||
```json
|
||||
{ "id": "4", "method": "verify", "params": [ "<message_hex>", "<signature_hex>", { "algorithm": "ed25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "4", "result": "{\"valid\":true,\"algorithm\":\"ed25519\"}" }
|
||||
```
|
||||
|
||||
The signer derives its own public key from `(algorithm, index)` and verifies against it. To verify an arbitrary third-party key, use a client-side library.
|
||||
|
||||
#### `encapsulate` (ML-KEM-768)
|
||||
|
||||
```json
|
||||
{ "id": "5", "method": "encapsulate", "params": [ "<peer_pubkey_hex>", { "algorithm": "ml-kem-768" } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "5", "result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
|
||||
```
|
||||
|
||||
`peer_pubkey_hex` is the recipient's ML-KEM-768 public key (1184 bytes → 2368 hex chars). Send the returned `ciphertext` to the recipient; both sides end up with the same `shared_secret`.
|
||||
|
||||
#### `decapsulate` (ML-KEM-768)
|
||||
|
||||
```json
|
||||
{ "id": "6", "method": "decapsulate", "params": [ "<ciphertext_hex>", { "algorithm": "ml-kem-768", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "6", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}" }
|
||||
```
|
||||
|
||||
#### `derive_shared_secret` (X25519)
|
||||
|
||||
```json
|
||||
{ "id": "7", "method": "derive_shared_secret", "params": [ "<peer_pubkey_hex>", { "algorithm": "x25519", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "7", "result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"x25519\"}" }
|
||||
```
|
||||
|
||||
`peer_pubkey_hex` is the peer's 32-byte X25519 public key (64 hex chars). Feed the returned `shared_secret` into your own symmetric cipher (e.g. AES-GCM, ChaCha20-Poly1305).
|
||||
|
||||
#### `derive` (secp256k1 HMAC-SHA256)
|
||||
|
||||
```json
|
||||
{ "id": "10", "method": "derive", "params": [ "<data>", { "algorithm": "secp256k1", "index": 0 } ] }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "10", "result": "{\"algorithm\":\"secp256k1\",\"key_id\":\"<16hex>\",\"digest\":\"<64hex>\"}" }
|
||||
```
|
||||
|
||||
Computes `HMAC-SHA256(privkey, data)` where `privkey` is the secp256k1 private key derived on demand at `(algorithm: "secp256k1", index: N)`. `data` is an arbitrary caller-supplied UTF-8 string. Returns the 32-byte digest as 64 lowercase hex chars.
|
||||
|
||||
`index` is **required** (no default) — forces conscious selection of which derived key to use as the HMAC key. Omitting it returns `invalid_params`.
|
||||
|
||||
This is a generic key-derived MAC primitive. Callers domain-separate by prefixing their own label into `data` (e.g. `"myapp/identifier-v1:<path>"`). The private key never leaves the signer; only the digest is returned. Use cases include deterministic, per-user, opaque identifiers for NIP-33 parameterized-replaceable events (e.g. bookmark folder `d` tags) where the same logical name must produce the same `d` tag across devices.
|
||||
|
||||
#### `encrypt` / `decrypt` (OTP)
|
||||
|
||||
```json
|
||||
{ "id": "8", "method": "encrypt", "params": [ "<plaintext_base64>", { "algorithm": "otp", "encoding": "ascii" } ] }
|
||||
{ "id": "9", "method": "decrypt", "params": [ "<ciphertext>", { "algorithm": "otp", "encoding": "ascii" } ] }
|
||||
```
|
||||
|
||||
`encoding` is `"ascii"` (ASCII-armored, default) or `"binary"` (base64-encoded raw `.otp` blob). If omitted on `decrypt`, auto-detection by magic bytes is used.
|
||||
|
||||
`encrypt` response:
|
||||
```json
|
||||
{
|
||||
"id": "8",
|
||||
"result": "{\"ciphertext\":\"<ascii-armor-or-base64-blob>\",\"encoding\":\"ascii\",\"pad_chksum\":\"<64 hex>\",\"pad_offset_before\":288,\"pad_offset_after\":416}"
|
||||
}
|
||||
```
|
||||
|
||||
`decrypt` response:
|
||||
```json
|
||||
{ "id": "9", "result": "{\"plaintext\":\"<base64>\",\"pad_chksum\":\"<64 hex>\"}" }
|
||||
```
|
||||
|
||||
If no pad is bound at startup, the error is `-32601` `method_not_found` (otp_pad_not_bound).
|
||||
|
||||
#### `nostr_get_public_key`
|
||||
|
||||
```json
|
||||
{ "id": "10", "method": "nostr_get_public_key", "params": [ { "role": "main" } ] }
|
||||
```
|
||||
|
||||
Response (default): a plain 64-hex-char secp256k1 public key string.
|
||||
Response with `{"format":"structured"}` in options: `{"algorithm":"secp256k1","public_key":"<hex>","key_id":"<16 hex>"}`.
|
||||
|
||||
#### `nostr_sign_event`
|
||||
|
||||
Serializes the event to canonical form (`[0, pubkey, created_at, kind, tags, content]`), SHA-256 hashes it to produce the event `id`, signs the hash with BIP-340 Schnorr, and returns the complete signed event.
|
||||
|
||||
```json
|
||||
{ "id": "11", "method": "nostr_sign_event", "params": [ "<event_json>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`<event_json>` is the unsigned event object:
|
||||
```json
|
||||
{ "pubkey": "...", "created_at": 1234567890, "kind": 1, "tags": [], "content": "hello" }
|
||||
```
|
||||
|
||||
Response: the signed event JSON string, with `id` and `sig` populated.
|
||||
|
||||
#### `nostr_mine_event`
|
||||
|
||||
Mines NIP-13 proof-of-work (adds a `nonce` tag) and signs the event in one step. Mining runs in a detached thread so the signer stays responsive.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "12",
|
||||
"method": "nostr_mine_event",
|
||||
"params": [ "<event_json>", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "role": "main" } ]
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Required | Default | Meaning |
|
||||
|---------------|-------------------------------|---------|---------------------------------------------------------------|
|
||||
| `difficulty` | one of `difficulty`/`timeout` | 0 | Target leading zero bits. Stops early if reached. |
|
||||
| `timeout_sec` | one of `difficulty`/`timeout` | 600 | Time budget in seconds. Always returns the best event found. |
|
||||
| `threads` | no | 1 | Mining threads (clamped to 1..32). |
|
||||
|
||||
At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when **either** condition is met. Timeout is never an error — the best event found is always returned.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "12",
|
||||
"result": "{\"event\":\"<signed event JSON with nonce tag>\",\"achieved_difficulty\":18,\"target_difficulty\":20,\"target_reached\":false,\"elapsed_sec\":30,\"attempts\":4523456}"
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
- `1007` `no_termination_condition` — neither `difficulty` nor `timeout_sec` given.
|
||||
- `1008` `mining_failed` — internal mining error.
|
||||
|
||||
#### `nostr_nip04_encrypt` / `nostr_nip04_decrypt`
|
||||
|
||||
NIP-04 encryption (deprecated in Nostr but still widely used): ECDH + AES-256-CBC, base64 payload.
|
||||
|
||||
```json
|
||||
{ "id": "13", "method": "nostr_nip04_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "14", "method": "nostr_nip04_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-04 ciphertext string; `decrypt` returns the plaintext string.
|
||||
|
||||
#### `nostr_nip44_encrypt` / `nostr_nip44_decrypt`
|
||||
|
||||
NIP-44 encryption (current Nostr standard): ECDH + HKDF + ChaCha20-Poly1305 + specific payload format.
|
||||
|
||||
```json
|
||||
{ "id": "15", "method": "nostr_nip44_encrypt", "params": [ "<peer_pubkey_hex>", "<plaintext>", { "role": "main" } ] }
|
||||
{ "id": "16", "method": "nostr_nip44_decrypt", "params": [ "<peer_pubkey_hex>", "<ciphertext>", { "role": "main" } ] }
|
||||
```
|
||||
|
||||
`encrypt` returns the NIP-44 ciphertext string; `decrypt` returns the plaintext string.
|
||||
|
||||
### 4.6 Role-based selectors (Nostr verbs)
|
||||
|
||||
The `nostr_*` verbs select a secp256k1 NIP-06 key via the options object. Supported selectors:
|
||||
|
||||
| Selector | Meaning |
|
||||
|----------------|--------------------------------------------------|
|
||||
| `role` | Name of a pre-registered role entry (required) |
|
||||
| `role_path` | Full BIP-44 derivation path (required) |
|
||||
|
||||
**Selector resolution**: both `role` and `role_path` are required together — they form a single combined selector. The server verifies that the supplied `role_path` matches the role's registered template (expanding any wildcard). There is no resolution order and no default role: omitting either field is rejected (`2008 role_required` / `2009 path_required`). The role's `(purpose, curve)` must be `(nostr, secp256k1)` — any other combination is rejected with `purpose_mismatch` (1004) or `curve_mismatch` (1005).
|
||||
|
||||
#### Named path-roles
|
||||
|
||||
In the add-role popup, you define **named path-roles** that bind a role name (which acts as an access token for clients) to a derivation path template. The derivation path template is hidden from clients — they only know the role name and send the full concrete `role_path` with each request.
|
||||
|
||||
The popup presents a **preset menu** of 10 options covering the common role types. You can still define custom roles manually via the "Custom path" option.
|
||||
|
||||
```
|
||||
Preset menu:
|
||||
1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0
|
||||
2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0
|
||||
3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'
|
||||
4. SSH role: ed25519, m/44'/102001'/0'/0'/0'
|
||||
5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'
|
||||
6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'
|
||||
7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'
|
||||
8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'
|
||||
9. OTP role (one-time pad encryption)
|
||||
10. Custom path
|
||||
```
|
||||
|
||||
Purpose is auto-detected from the path prefix (e.g. `m/44'/1237'` → nostr, `m/44'/102001'` → ssh). The path template is pre-filled from the chosen preset and can be edited inline.
|
||||
|
||||
**Path template syntax:**
|
||||
- **Wildcard**: `m/44'/1237'/*'/0'/0'` — any non-negative integer, hardened. No range limit.
|
||||
- **Range**: `m/44'/1237'/0-3/1/0` — index 0..3, hardened if segment ends with `'` (e.g. `0-3'`)
|
||||
- **Set**: `m/44'/1237'/1+34+54/1/0` — specific indices 1, 34, 54
|
||||
- **Fixed path**: `m/44'/1237'/0'/0/0` — no variable segment, single fixed key
|
||||
- The first segment that is a plain number, range (`N-M`), set (`A+B+C`), or wildcard (`*`) becomes the variable. Segments with `'` (like `44'`, `1237'`) are treated as literal hardened constants.
|
||||
|
||||
The role name itself acts as a password: any caller that knows the role name and supplies a matching `role_path` is served without attendant interaction.
|
||||
|
||||
Clients request keys by supplying both `role` and the full concrete `role_path`:
|
||||
|
||||
```json
|
||||
{"id":"1","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/0'/1/0"}]}
|
||||
```
|
||||
|
||||
→ derives `m/44'/1237'/0'/1/0`, verified against the `myrole` template.
|
||||
|
||||
```json
|
||||
{"id":"2","method":"nostr_get_public_key","params":[{"role":"myrole","role_path":"m/44'/1237'/5'/1/0"}]}
|
||||
```
|
||||
|
||||
→ `2003 path_not_allowed` (5 is outside the registered template, if the template was a fixed path or limited range).
|
||||
|
||||
```json
|
||||
{"id":"3","method":"nostr_get_public_key","params":[{"role":"unknown","role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
|
||||
→ `1002 unknown_role` (name not registered).
|
||||
|
||||
```json
|
||||
{"id":"4","method":"nostr_get_public_key","params":[{"role":"myrole"}]}
|
||||
```
|
||||
|
||||
→ `2009 path_required` (`role_path` is required).
|
||||
|
||||
```json
|
||||
{"id":"5","method":"nostr_get_public_key","params":[{"role_path":"m/44'/1237'/0'/0/0"}]}
|
||||
```
|
||||
|
||||
→ `2008 role_required` (`role` is required when using `role_path`).
|
||||
|
||||
### 4.7 Non-interactive role registration
|
||||
|
||||
Roles can be registered from the command line with `--register-role` (repeatable), avoiding the popup entirely. The spec format is `<name>:<curve>:<path-template>`. If `<curve>` is empty, the curve is auto-detected from the path prefix.
|
||||
|
||||
```bash
|
||||
nsigner --register-role main:secp256k1:m/44'/1237'/0'/0/0
|
||||
nsigner --register-role nostr_range::m/44'/1237'/*'/0/0
|
||||
nsigner --register-role ssh::m/44'/102001'/0'/0'/0'
|
||||
```
|
||||
|
||||
If no `--register-role` is given in non-interactive mode, a default `main` role (`m/44'/1237'/0'/0/0`, secp256k1, nostr) is auto-registered.
|
||||
|
||||
## 5. Transports
|
||||
|
||||
The API is transport-independent. The same JSON request works over every transport; only the framing differs.
|
||||
|
||||
| Transport | `--listen` flag | Framing | Caller identity |
|
||||
|-----------|--------------------------------|------------------------------------------|----------------------------|
|
||||
| Unix socket (abstract) | `unix` (default on desktop) | Length-prefixed framed JSON | `SO_PEERCRED` → `uid:<n>` |
|
||||
| stdio | `stdio` | One framed request/response over stdin/stdout | inherited uid |
|
||||
| qrexec | `qrexec` | Same as stdio; caller from `QREXEC_REMOTE_DOMAIN` | `qubes:<vm>` |
|
||||
| FIPS/TCP | `tcp:[host]:port` | Length-prefixed framed JSON | (transport-defined) |
|
||||
| HTTP | `http:host:port` | Standard HTTP POST, JSON body, no custom framing. CORS enabled. | (transport-defined) |
|
||||
|
||||
### 5.1 HTTP examples
|
||||
|
||||
Start the signer:
|
||||
```bash
|
||||
nsigner --listen http:127.0.0.1:11111 --mnemonic-stdin
|
||||
```
|
||||
|
||||
Get a public key:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
|
||||
```
|
||||
|
||||
Sign a Nostr event:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
```
|
||||
|
||||
OTP encrypt:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:11111/ -H 'Content-Type: application/json' \
|
||||
-d '{"id":"1","method":"encrypt","params":["SGVsbG8sIE9UUCB3b3JsZCE=",{"algorithm":"otp","encoding":"ascii"}]}'
|
||||
```
|
||||
|
||||
### 5.2 Unix socket examples (framed mode)
|
||||
|
||||
```bash
|
||||
# get_public_key
|
||||
nsigner --socket-name nsigner01 client \
|
||||
'{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}'
|
||||
|
||||
# Sign a Nostr event
|
||||
nsigner --socket-name nsigner01 client \
|
||||
'{"id":"2","method":"nostr_sign_event","params":[{"pubkey":"...","created_at":1234567890,"kind":1,"tags":[],"content":"hello"},{"role":"main"}]}'
|
||||
|
||||
# ed25519 sign
|
||||
nsigner --socket-name nsigner01 client \
|
||||
'{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}'
|
||||
```
|
||||
|
||||
### 5.3 Linux desktop: abstract namespace Unix socket
|
||||
|
||||
Primary local transport is AF_UNIX abstract namespace. Each running `nsigner` process binds to a unique abstract name of the form `@nsigner_<word1>_<word2>`, where the two words are picked at random from the BIP-39 English wordlist at startup (e.g. `@nsigner_hairy_dog`). This lets multiple signers coexist on one host.
|
||||
|
||||
Properties: no pathname in filesystem; endpoint lifetime bound to process/kernel namespace; no stale socket files; caller identity via peer credentials (`SO_PEERCRED`); per-launch random name avoids collisions and leaks no seed-derived identifier.
|
||||
|
||||
Naming rules:
|
||||
- Default: random pick at startup, displayed in the Information section.
|
||||
- Override: `--socket-name <name>` (alias: `--name <name>` / `-n <name>`) forces a specific name.
|
||||
|
||||
Discovery:
|
||||
- `nsigner list` enumerates currently bound `nsigner_*` abstract sockets by reading `/proc/net/unix`.
|
||||
- `nsigner --listen stdio` runs one framed JSON-RPC request/response over stdin/stdout.
|
||||
- `nsigner --listen qrexec` is the same stdio framing, but caller identity comes from `QREXEC_REMOTE_DOMAIN` (displayed as `qubes:<source-vm>`).
|
||||
- `nsigner --listen tcp:[::]:11111` enables FIPS/TCP listening (framed JSON, not HTTP).
|
||||
- `nsigner --listen http:127.0.0.1:11111` enables HTTP listening for curl-friendly access. CORS headers included for browser access. Defaults to localhost; pass `http:0.0.0.0:PORT` to expose externally.
|
||||
- `nsigner bridge --to <socket-name>` is a stateless relay for Qubes qrexec: reads one framed request from stdin, forwards it to a persistent signer's abstract unix socket, and relays the response to stdout. Used as the `qubes.NsignerRpc` service entrypoint.
|
||||
- `--bridge-source-trusted` (unix listener only): marks the socket as a trusted bridge endpoint. Each connection sends a framed `{"qrexec_source":"<vm>"}` preamble before the request, and the caller identity is composed as `qubes:<vm>`.
|
||||
|
||||
### 5.4 Caller verification
|
||||
|
||||
Every transport must provide concrete caller identity before policy evaluation.
|
||||
|
||||
- Linux AF_UNIX: map peer credentials to caller identity.
|
||||
- Relay session: bind remote peer/session identity before allowing signer verbs.
|
||||
|
||||
Identity verification and the role-name-as-password gate are separate layers. Passing identity checks does not bypass the role-name requirement.
|
||||
|
||||
## 6. Platform targets
|
||||
|
||||
### 6.1 Linux desktop (primary)
|
||||
|
||||
Primary deployment is a local, foreground terminal program with abstract namespace socket transport.
|
||||
|
||||
### 6.2 Qubes OS qube
|
||||
|
||||
Qubes deployment runs `nsigner` in a dedicated signer qube (e.g. `nostr_signer`) as a foreground process under explicit user session control. The mnemonic lives only in mlock'd RAM in that qube — a compromised agent in a caller qube cannot read it (hypervisor-enforced memory isolation).
|
||||
|
||||
Three transport paths are supported:
|
||||
|
||||
**FIPS/TCP** — the signer listens on `tcp:[::]:11111` and FIPS carries traffic between qubes as an IPv6 mesh substrate.
|
||||
|
||||
**HTTP** — the signer listens on `http:127.0.0.1:11111` for curl-friendly access within the same qube. No auth envelopes required (relies on localhost binding + the role-name-as-password gate).
|
||||
|
||||
**Qubes qrexec bridge** (recommended for no-network deployments) — a persistent signer listens on an abstract unix socket, and a stateless `nsigner bridge` relay (the `qubes.NsignerRpc` qrexec service) forwards one request per qrexec invocation. No network, no FIPS — pure intra-host IPC. Caller identity is `qubes:<source-vm>`.
|
||||
|
||||
#### Qrexec bridge setup
|
||||
|
||||
**In the signer qube** (`nostr_signer`):
|
||||
```bash
|
||||
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
```
|
||||
|
||||
**From a caller qube** (via the qrexec service):
|
||||
```bash
|
||||
nsigner bridge --to nsigner
|
||||
```
|
||||
|
||||
## 7. Usage
|
||||
|
||||
### 7.1 Run the program
|
||||
|
||||
```bash
|
||||
nsigner
|
||||
```
|
||||
|
||||
Program starts in attached foreground mode and shows the seed-entry popup. After mnemonic acceptance, the main screen shows the randomly assigned signer name and its abstract socket address.
|
||||
|
||||
To force a specific socket name (e.g. for scripted clients):
|
||||
```bash
|
||||
nsigner --name my_test_signer
|
||||
```
|
||||
|
||||
Other transport modes:
|
||||
```bash
|
||||
nsigner --listen qrexec # Qubes qrexec (single framed request over stdin/stdout)
|
||||
nsigner --listen stdio # Generic stdio (single framed request over stdin/stdout)
|
||||
nsigner --listen tcp:[::]:11111 # FIPS/TCP (framed JSON, no TUI)
|
||||
nsigner --listen http:127.0.0.1:11111 # HTTP (curl-friendly, no TUI)
|
||||
```
|
||||
|
||||
With OTP pad bound:
|
||||
```bash
|
||||
nsigner --listen http:127.0.0.1:11111 --otp-pad-dir /media/user/Music/pads --otp-pad 333e9902db839d9d --mnemonic-stdin
|
||||
```
|
||||
|
||||
Qrexec bridge mode (stateless relay to a persistent signer's unix socket):
|
||||
```bash
|
||||
nsigner bridge --to nsigner
|
||||
```
|
||||
|
||||
Persistent signer for qrexec bridge (unix listener with trusted source-qube preamble):
|
||||
```bash
|
||||
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
|
||||
```
|
||||
|
||||
### 7.2 Send a request (client mode)
|
||||
|
||||
The `nsigner client` subcommand sends a hand-built JSON-RPC object over the socket:
|
||||
|
||||
```bash
|
||||
nsigner --socket-name nsigner01 client \
|
||||
'{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
|
||||
```
|
||||
|
||||
Read the request from stdin with `-`:
|
||||
```bash
|
||||
echo '{"id":"1","method":"get_info","params":[]}' | nsigner client -
|
||||
```
|
||||
|
||||
If only one signer is running you can omit the `--socket-name` override and the client will use the default discovery rule.
|
||||
|
||||
### 7.3 List running signers
|
||||
|
||||
```bash
|
||||
nsigner list
|
||||
```
|
||||
|
||||
Prints the names of any currently running `nsigner` instances, e.g.:
|
||||
```text
|
||||
nsigner_hairy_dog
|
||||
nsigner_brave_canyon
|
||||
```
|
||||
|
||||
### 7.4 Example session
|
||||
|
||||
Terminal A:
|
||||
```text
|
||||
$ nsigner
|
||||
nsigner v0.0.2
|
||||
[seed entry popup → enter mnemonic]
|
||||
[main screen shows: signer name nsigner_hairy_dog, Unix address active]
|
||||
```
|
||||
|
||||
Terminal B:
|
||||
```text
|
||||
$ nsigner --socket-name nsigner_hairy_dog client '{"id":"2","method":"nostr_sign_event","params":["<event_json>",{"role":"main","role_path":"m/44'"'"'1237'"'"'/0'"'"'/0'"'"'/0"}]}'
|
||||
{"id":"2","result":"<signed_event_json>"}
|
||||
```
|
||||
|
||||
## 8. Build
|
||||
|
||||
### 8.1 Dependencies
|
||||
|
||||
The build expects the local [`nostr_core_lib_rust`](../nostr_core_lib_rust) checkout (sibling directory) for the `nostr-core` and `nostr-nips` path dependencies, and the vendored [`ratatui`](ratatui) submodule for the TUI.
|
||||
|
||||
```bash
|
||||
git submodule update --init ratatui
|
||||
```
|
||||
|
||||
### 8.2 Local dev build
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
./target/debug/nsigner --version
|
||||
```
|
||||
|
||||
### 8.3 Release build
|
||||
|
||||
The release profile is tuned for a small, optimized, stripped binary:
|
||||
|
||||
```toml
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
./target/release/nsigner --version
|
||||
```
|
||||
|
||||
### 8.4 Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
## 9. Project layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| [`src/main.rs`](src/main.rs:1) | CLI parsing, subcommands (`client`, `bridge`, `list`), server startup |
|
||||
| [`src/lib.rs`](src/lib.rs:1) | Crate root, module declarations, `VERSION` |
|
||||
| [`src/tui.rs`](src/tui.rs:1) | ratatui TUI: seed-entry popup, main screen, add-role popup, help overlay |
|
||||
| [`src/server.rs`](src/server.rs:1) | Multi-transport server with poll loop, caller identity, request framing |
|
||||
| [`src/dispatcher.rs`](src/dispatcher.rs:1) | Verb dispatch and JSON-RPC response construction |
|
||||
| [`src/role_table.rs`](src/role_table.rs:1) | Role registry, path-template parsing, purpose/curve enforcement |
|
||||
| [`src/selector.rs`](src/selector.rs:1) | Role selector resolution (`role` + `role_path`) |
|
||||
| [`src/enforcement.rs`](src/enforcement.rs:1) | Verb/algorithm/purpose/curve enforcement matrix |
|
||||
| [`src/key_store.rs`](src/key_store.rs:1) | BIP-32 / SLIP-0010 key derivation and storage |
|
||||
| [`src/mnemonic.rs`](src/mnemonic.rs:1) | BIP-39 mnemonic loading and seed derivation |
|
||||
| [`src/pq_crypto.rs`](src/pq_crypto.rs:1) | Post-quantum keygen (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) |
|
||||
| [`src/pq_drbg.rs`](src/pq_drbg.rs:1) | SHAKE-256 DRBG for PQ keygen |
|
||||
| [`src/alg_cache.rs`](src/alg_cache.rs:1) | Per-algorithm derived-key cache |
|
||||
| [`src/otp_pad.rs`](src/otp_pad.rs:1) | One-time pad binding, offset tracking, encrypt/decrypt |
|
||||
| [`src/miner.rs`](src/miner.rs:1) | NIP-13 proof-of-work mining for `nostr_mine_event` |
|
||||
| [`src/auth_envelope.rs`](src/auth_envelope.rs:1) | Auth envelope verification and nonce cache |
|
||||
| [`src/transport.rs`](src/transport.rs:1) | Length-prefixed framing, abstract Unix socket connect |
|
||||
| [`src/http.rs`](src/http.rs:1) | HTTP listener transport |
|
||||
| [`src/socket_name.rs`](src/socket_name.rs:1) | Random socket-name generation and discovery |
|
||||
| [`src/secure_mem.rs`](src/secure_mem.rs:1) | `mlock` / `zeroize` helpers |
|
||||
| [`src/error.rs`](src/error.rs:1) | `NsignerError` enum |
|
||||
| [`ratatui/`](ratatui:1) | Vendored ratatui submodule (TUI framework) |
|
||||
| [`plans/`](plans:1) | Design and migration plans |
|
||||
|
||||
## 10. Differences from the C `n_signer`
|
||||
|
||||
This Rust port preserves the security model and API of the original C `n_signer` while differing in implementation details:
|
||||
|
||||
- **Language**: Rust instead of C. Memory safety is enforced by the type system; sensitive buffers use [`zeroize`](https://crates.io/crates/zeroize) instead of hand-rolled `secure_memzero`.
|
||||
- **PQ crypto**: pure-Rust crates (`ml-dsa`, `ml-kem`, `slh-dsa`) instead of vendored PQClean C.
|
||||
- **TUI**: [`ratatui`](https://github.com/ratatui/ratatui) instead of the vendored `tui_continuous` C component. The TUI flow is redesigned (see [`plans/tui_flow_redesign.md`](plans/tui_flow_redesign.md:1)): a single main screen with a startup seed-entry popup, an add-role popup, and a scrollable Help overlay, replacing the original linear setup wizard.
|
||||
- **Approval prompts**: the C version's interactive approval prompts (`y/n/e/a`) are not yet ported; the Rust port uses the role-name-as-password gate as the primary access control. Interactive approval is a future addition.
|
||||
- **No firmware targets**: the C project also targets ESP32 / MCU hardware via TinyUSB. This Rust port targets Linux desktop and Qubes OS only.
|
||||
- **No `--preapprove` flag**: pre-approval entries are not yet implemented in the Rust port.
|
||||
- **No `--allow-all` flag**: the Rust port does not carry the C project's `--allow-all` development shortcut; access control is the role-name-as-password gate plus caller identity.
|
||||
@@ -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/nsigner_menus.md`](../n_signer/documents/nsigner_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: `nsigner 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** (1–10): 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 (1–9) 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.
|
||||
@@ -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=nsigner01 derived=2 │ 2026-08-18 08:00:01 start │
|
||||
│ socket=@nsigner01 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 `nsigner_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`).
|
||||
@@ -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. `nsigner01`), 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: nsigner01 │ 2026-08-18 15:05:42 unix:1000 │
|
||||
│ Unix address: │ secp256k1 m/44'/1237'/0'/0/0 │
|
||||
│ nsigner01 │ 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 nsigner 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 1–10 presets as current wizard). Select a preset (or
|
||||
custom), then enter role name and path template via `InputField`
|
||||
with pre-filled defaults. On confirm, register the role, 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 1–10
|
||||
presets as current wizard). `handle_add_role_key()` processes the
|
||||
multi-stage flow: `PresetMenu` → `NameEntry` (InputField with
|
||||
pre-filled default) → `CurveSelect` (custom only) → `PathEntry`
|
||||
(InputField with pre-filled default) → `OtpDir`/`OtpName` (OTP
|
||||
only) → `Confirm`. On confirm, register the role, derive its key,
|
||||
and return to `Screen::Main`. ESC cancels and returns to Main.
|
||||
|
||||
8. **Implement Help screen** — `draw_help()` renders a scrollable
|
||||
`Paragraph` describing what the app does, what transports are, what
|
||||
roles are, and listing the key commands at the end. Track a
|
||||
`help_scroll` offset. `handle_help_key()`: Up/Down (and Page
|
||||
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 `nsigner_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.
|
||||
Submodule
+1
Submodule ratatui added at 31809ba9d4
+1
-3
@@ -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;
|
||||
|
||||
/// Version string (matches C NSIGNER_VERSION).
|
||||
pub const VERSION: &str = "v0.0.1";
|
||||
pub const VERSION: &str = "v0.0.6";
|
||||
|
||||
+88
-275
@@ -9,7 +9,6 @@ use nsigner::{
|
||||
dispatcher::DispatcherContext,
|
||||
key_store::KeyStore,
|
||||
mnemonic::MnemonicState,
|
||||
policy::{parse_preapprove_spec, PolicyTable},
|
||||
role_table::{RoleCurve, RolePurpose, RoleTable},
|
||||
server::{AuthMode, ListenMode, ServerContext},
|
||||
NsignerError,
|
||||
@@ -24,12 +23,11 @@ struct Cli {
|
||||
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,
|
||||
@@ -123,7 +117,15 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Server main: mnemonic → roles → transport → server start → TUI loop
|
||||
/// 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<(), NsignerError> {
|
||||
println!("nsigner {}", nsigner::VERSION);
|
||||
|
||||
@@ -131,15 +133,61 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
nsigner::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(|| {
|
||||
nsigner::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 = nsigner::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| NsignerError::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<(), NsignerError> {
|
||||
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)
|
||||
@@ -147,7 +195,6 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
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) };
|
||||
@@ -156,21 +203,14 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
|
||||
let phrase = input.trim().to_string();
|
||||
mnemonic.load(&phrase)?;
|
||||
} else {
|
||||
// Interactive TUI prompt (cooked mode — normal read_line works)
|
||||
load_mnemonic_tui(&mut mnemonic)?;
|
||||
}
|
||||
|
||||
// ── Role table ────────────────────────────────────────────────
|
||||
let mut role_table = RoleTable::new();
|
||||
|
||||
if !cli.register_role.is_empty() {
|
||||
// Non-interactive: register from CLI specs
|
||||
for spec in &cli.register_role {
|
||||
register_role_from_spec(&mut role_table, spec)?;
|
||||
}
|
||||
} else if cli.mnemonic_stdin || cli.mnemonic_fd.is_some() {
|
||||
// Non-interactive without --register-role: default "main" role
|
||||
} else {
|
||||
role_table
|
||||
.register_role_path(
|
||||
"main",
|
||||
@@ -180,237 +220,55 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
|
||||
-1, -1, -1, &[],
|
||||
)
|
||||
.map_err(|e| NsignerError::Internal(e.to_string()))?;
|
||||
} else {
|
||||
// Interactive: role wizard
|
||||
nsigner::tui::role_wizard(&mut role_table)?;
|
||||
}
|
||||
|
||||
// ── Key store & algorithm cache ───────────────────────────────
|
||||
let mut key_store = KeyStore::new();
|
||||
let mut alg_key_cache = AlgorithmKeyCache::new();
|
||||
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,
|
||||
};
|
||||
let _ = server.handle_one(&mut dispatcher);
|
||||
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)) => {}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
ListenMode::Unix => {
|
||||
// TUI + poll loop — poll for both socket connections and keypresses.
|
||||
// Raw mode is enabled only here (after all setup prompts) so that
|
||||
// prompt output above stays properly formatted (\n → \r\n).
|
||||
let mut activity_log = nsigner::tui::ActivityLog::new();
|
||||
activity_log.add("nsigner started");
|
||||
|
||||
nsigner::tui::init().ok();
|
||||
nsigner::tui::render_status(
|
||||
&role_table,
|
||||
&mnemonic,
|
||||
derived_count,
|
||||
&socket_name,
|
||||
&activity_log,
|
||||
);
|
||||
|
||||
while server.running {
|
||||
// Check for terminal resize (SIGWINCH)
|
||||
if nsigner::tui_continuous::resize_pending() {
|
||||
nsigner::tui::render_status(
|
||||
&role_table,
|
||||
&mnemonic,
|
||||
derived_count,
|
||||
&socket_name,
|
||||
&activity_log,
|
||||
);
|
||||
}
|
||||
|
||||
// Poll for a keypress (non-blocking, short timeout)
|
||||
match nsigner::tui::poll_key(50) {
|
||||
nsigner::tui::TuiKey::Connections => {
|
||||
// Show connection instructions
|
||||
nsigner::tui::render_connections(
|
||||
&role_table,
|
||||
&mnemonic,
|
||||
derived_count,
|
||||
&socket_name,
|
||||
);
|
||||
// Wait for any key to dismiss (use tui_continuous::get_key
|
||||
// so the wait survives EINTR / SIGWINCH)
|
||||
let _ = nsigner::tui_continuous::get_key();
|
||||
nsigner::tui::render_status(
|
||||
&role_table,
|
||||
&mnemonic,
|
||||
derived_count,
|
||||
&socket_name,
|
||||
&activity_log,
|
||||
);
|
||||
}
|
||||
nsigner::tui::TuiKey::Refresh => {
|
||||
// 'r' — refresh the status display
|
||||
nsigner::tui::render_status(
|
||||
&role_table,
|
||||
&mnemonic,
|
||||
derived_count,
|
||||
&socket_name,
|
||||
&activity_log,
|
||||
);
|
||||
}
|
||||
nsigner::tui::TuiKey::Lock => {
|
||||
// 'l' — lock session: wipe keys, unload mnemonic,
|
||||
// then prompt for mnemonic to re-unlock.
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut stdout = std::io::stdout();
|
||||
let _ = write!(stdout, "\r\n[lock] Session locked. Re-enter mnemonic to unlock.\r\n");
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
key_store.wipe();
|
||||
alg_key_cache.wipe();
|
||||
mnemonic.unload();
|
||||
|
||||
// Temporarily exit raw mode for line-mode input
|
||||
nsigner::tui_continuous::cleanup();
|
||||
|
||||
match load_mnemonic_tui(&mut mnemonic) {
|
||||
Ok(()) => {
|
||||
let new_count = key_store.derive_all(&mut role_table, &mnemonic);
|
||||
match new_count {
|
||||
Ok(n) => {
|
||||
activity_log.add("session re-unlocked");
|
||||
// Re-enter raw mode
|
||||
nsigner::tui_continuous::init();
|
||||
nsigner::tui::render_status(
|
||||
&role_table,
|
||||
&mnemonic,
|
||||
n,
|
||||
&socket_name,
|
||||
&activity_log,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[lock] derivation failed: {}", e);
|
||||
server.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[lock] unlock failed: {}", e);
|
||||
server.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
nsigner::tui::TuiKey::Quit => {
|
||||
server.running = false;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !server.running {
|
||||
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,7 +276,7 @@ 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("nsigner01");
|
||||
|
||||
// Discover single socket if not explicit
|
||||
let socket_name = if cli.socket_name.is_some() {
|
||||
@@ -470,7 +328,7 @@ 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("nsigner01");
|
||||
let target = if cli.socket_name.is_some() {
|
||||
target.to_string()
|
||||
} else {
|
||||
@@ -542,51 +400,6 @@ 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,
|
||||
|
||||
@@ -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<(), NsignerError> {
|
||||
let mut pad = OtpPadState::new();
|
||||
pad.bind(dir, spec, allow_blkback)?;
|
||||
let mut guard = GLOBAL_OTP_PAD.lock().map_err(|e| {
|
||||
NsignerError::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::*;
|
||||
|
||||
-461
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,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).
|
||||
|
||||
+86
-139
@@ -4,13 +4,11 @@
|
||||
//! 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 std::net::TcpListener;
|
||||
@@ -139,41 +137,50 @@ 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>, NsignerError> {
|
||||
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()))?;
|
||||
let mut writer = stream;
|
||||
|
||||
// Read framed request
|
||||
// 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 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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())),
|
||||
}
|
||||
@@ -182,7 +189,9 @@ impl ServerContext {
|
||||
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);
|
||||
@@ -195,44 +204,55 @@ impl ServerContext {
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
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 +269,9 @@ impl ServerContext {
|
||||
}
|
||||
Err((code, msg)) => {
|
||||
if self.auth_mode == AuthMode::Required {
|
||||
return make_auth_error(&request, code, msg);
|
||||
let response = make_auth_error(&request, code, msg);
|
||||
let activity = format!("{} DENIED:{}", caller.caller_id, msg);
|
||||
return (response, activity);
|
||||
}
|
||||
// Optional: continue without auth
|
||||
}
|
||||
@@ -261,119 +283,72 @@ impl ServerContext {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// Malformed request — let the dispatcher produce the error
|
||||
return crate::dispatcher::handle_request(dispatcher, request);
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} DENIED:malformed", caller.caller_id);
|
||||
return (response, activity);
|
||||
}
|
||||
};
|
||||
|
||||
// get_info is metadata — no key material, 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.caller_id);
|
||||
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);
|
||||
let activity = format!("{} - -", caller.caller_id);
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
// OTP verbs
|
||||
if method == crate::enforcement::VERB_ENCRYPT || method == crate::enforcement::VERB_DECRYPT {
|
||||
return crate::dispatcher::handle_request(dispatcher, request);
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
let activity = format!("{} - -", caller.caller_id);
|
||||
return (response, activity);
|
||||
}
|
||||
|
||||
// ── Resolve role selector ──────────────────────────────────
|
||||
// ── 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.caller_id,
|
||||
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,
|
||||
};
|
||||
|
||||
if decision != PolicyResult::Allow {
|
||||
return make_policy_denied(&request);
|
||||
}
|
||||
// Role entry from the resolved selector — used for the activity
|
||||
// message (curve + key path).
|
||||
let role_entry = &dispatcher.role_table.entries[role_index];
|
||||
let curve = role_entry.curve_str.clone();
|
||||
let path = role_entry.display_path();
|
||||
|
||||
// ── Dispatch ───────────────────────────────────────────────
|
||||
crate::dispatcher::handle_request(dispatcher, request)
|
||||
let response = crate::dispatcher::handle_request(dispatcher, request);
|
||||
// Activity format: uid curve path (timestamp is added by the log).
|
||||
let activity = format!("{} {} {}", caller.caller_id, curve, path);
|
||||
(response, activity)
|
||||
}
|
||||
|
||||
/// Process an algorithm-based verb 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)
|
||||
}
|
||||
@@ -510,26 +485,6 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(algorithm, index)
|
||||
}
|
||||
|
||||
/// Build an auth error response.
|
||||
fn make_auth_error(request: &str, code: i32, message: &str) -> String {
|
||||
let id = extract_id(request);
|
||||
@@ -558,14 +513,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 {
|
||||
|
||||
+39
-20
@@ -1,35 +1,53 @@
|
||||
//! 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 `nsigner01`, `nsigner02`, … incrementing
|
||||
//! until an unused name is found (by checking /proc/net/unix).
|
||||
//!
|
||||
//! The `nsigner` prefix is required for compatibility with the C
|
||||
//! `nsigner_client` / `nsigner_transport_list_unix`, which scans
|
||||
//! /proc/net/unix for the literal prefix `@nsigner`.
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::thread_rng;
|
||||
/// Prefix for generated socket names.
|
||||
pub const SOCKET_NAME_PREFIX: &str = "nsigner";
|
||||
|
||||
/// Generate a random socket name: `nsigner_<word1>_<word2>`.
|
||||
/// Generate a socket name: `nsigner01`, `nsigner02`, …
|
||||
///
|
||||
/// Uses two random words from the BIP-39 English wordlist.
|
||||
/// Scans /proc/net/unix for already-running nsigner sockets and picks the
|
||||
/// lowest unused number (starting at 1, zero-padded to 2 digits).
|
||||
pub fn socket_name_random() -> Result<String, crate::NsignerError> {
|
||||
let wordlist = nips::nip006::bip39_wordlist();
|
||||
let mut rng = thread_rng();
|
||||
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 nsigner01, nsigner02, … up to nsigner99
|
||||
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: nsigner100, nsigner101, … (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::NsignerError::Internal(
|
||||
"no available socket name (nsigner01..nsigner9999 all in use)".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// List running nsigner abstract sockets by reading /proc/net/unix.
|
||||
///
|
||||
/// Matches the C `nsigner_transport_list_unix` scan: looks for the literal
|
||||
/// prefix `@nsigner` 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
|
||||
// Look for @nsigner prefix in the path column (matches C client scan)
|
||||
if let Some(pos) = line.find("@nsigner") {
|
||||
let rest = &line[pos + 1..]; // skip @
|
||||
// Extract the name (up to whitespace or end of line)
|
||||
@@ -37,7 +55,7 @@ pub fn list_sockets() -> Vec<String> {
|
||||
.chars()
|
||||
.take_while(|c| !c.is_whitespace())
|
||||
.collect();
|
||||
if name.starts_with("nsigner_") {
|
||||
if name.starts_with(SOCKET_NAME_PREFIX) {
|
||||
found.push(name);
|
||||
}
|
||||
}
|
||||
@@ -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("nsigner"));
|
||||
// Should be nsigner01..nsigner99 (8 chars) or nsigner100+ (9+ chars)
|
||||
assert!(name.len() >= 8);
|
||||
}
|
||||
}
|
||||
|
||||
+1668
-636
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
+34
-163
@@ -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::{
|
||||
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,
|
||||
@@ -103,80 +102,36 @@ fn wait_for_server(socket_name: &str, attempts: u32) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_info_no_policy_needed() {
|
||||
fn test_get_info_works() {
|
||||
let socket_name = format!("nsigner_test_info_{}", std::process::id());
|
||||
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
||||
|
||||
let mut policy = PolicyTable::new();
|
||||
policy.init_default(unsafe { libc::getuid() });
|
||||
|
||||
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
||||
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);
|
||||
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!("nsigner_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);
|
||||
@@ -191,16 +146,13 @@ fn test_unknown_role_returns_selector_error() {
|
||||
let socket_name = format!("nsigner_test_unknown_{}", std::process::id());
|
||||
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
||||
|
||||
let mut policy = PolicyTable::new();
|
||||
policy.init_default(unsafe { libc::getuid() });
|
||||
|
||||
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy);
|
||||
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);
|
||||
|
||||
@@ -210,55 +162,17 @@ fn test_unknown_role_returns_selector_error() {
|
||||
}
|
||||
|
||||
#[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!("nsigner_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);
|
||||
@@ -271,65 +185,22 @@ fn test_ed25519_sign_allowed_with_preapprove() {
|
||||
}
|
||||
|
||||
#[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!("nsigner_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);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
|
||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
@@ -338,4 +209,4 @@ fn test_allow_all_flag_skips_prompt() {
|
||||
|
||||
// Keep UnixListener import used (for potential future filesystem socket tests)
|
||||
#[allow(dead_code)]
|
||||
fn _unused(_l: UnixListener) {}
|
||||
fn _unused(_l: UnixListener) {}
|
||||
|
||||
Reference in New Issue
Block a user