5 Commits
18 changed files with 4425 additions and 2616 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "ratatui"]
path = ratatui
url = https://github.com/ratatui/ratatui.git
Generated
+451 -3
View File
@@ -23,6 +23,12 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.6"
@@ -82,6 +88,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -172,12 +187,27 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -303,6 +333,19 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"static_assertions",
"zmij",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -315,6 +358,15 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -375,6 +427,24 @@ dependencies = [
"winapi",
]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio 1.2.2",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
@@ -442,6 +512,40 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.3",
]
[[package]]
name = "darling_macro"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
dependencies = [
"darling_core",
"quote",
"syn 3.0.3",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -462,6 +566,34 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -495,6 +627,15 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -520,6 +661,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -569,6 +716,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -684,11 +837,27 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
@@ -962,6 +1131,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -990,7 +1165,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
@@ -1003,6 +1187,19 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instability"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -1015,6 +1212,24 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1032,6 +1247,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kasuari"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror",
]
[[package]]
name = "keccak"
version = "0.1.6"
@@ -1067,6 +1293,21 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "line-clipping"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
dependencies = [
"bitflags",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -1079,6 +1320,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1094,6 +1341,15 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -1125,6 +1381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -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
View File
@@ -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"
+798
View File
@@ -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.
+259
View File
@@ -0,0 +1,259 @@
# Menu Gap Analysis: C `main.c` vs Rust `signer`
**Source of truth:** the C code in [`src/main.c`](../n_signer/src/main.c:1), NOT
[`documents/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** (110): 1=Standard Nostr, 2=Nostr range, 3=Nostr agent,
4=SSH, 5=Age, 6=ML-DSA-65, 7=SLH-DSA-128s, 8=ML-KEM-768,
**9=OTP role**, **10=Custom path**
- `Select [1]:` (default 1 if empty)
- Role name prompt: ` Role name [%s]: ` with editable line + default
- Duplicate → ` Role '%s' already exists, skipping.` + continue
- **Choice 9 (OTP)**: prompts `OTP pad directory (e.g. /media/usb0):` and
`OTP pad name (e.g. mypad):`, binds pad immediately, registers role with
`curve_str="otp"`, `requires_approval=0`
- **Choice 10 (Custom)**: curve menu
```
Curve:
1) secp256k1 (Nostr, Bitcoin)
2) ed25519 (SSH)
3) x25519 (key agreement, Age)
4) ml-dsa-65 (post-quantum signatures)
5) slh-dsa-128s (post-quantum signatures)
6) ml-kem-768 (post-quantum KEM)
Select [1]:
```
then ` Path template [%s]: ` editable
- **`requires_approval` is HARDCODED to 0** — no prompt (doc is wrong)
- Confirmation: ` Role '%s' registered: curve=%s path=%s (fixed, requires_approval=0).`
or `(range %d-%d, requires_approval=0).`
- Loop: `Define another role? [y/N]:` → `y` continues, else break
- Mandatory: `if (roles_created == 0) { "At least one role must be defined." return -1; }`
- **No auto-register of default `main`** — user must pick preset 1
**Rust** ([`role_wizard`](../signer/src/tui.rs:534)):
- **Auto-registers default `main` first** before showing menu ❌
- 9 presets (19) with 9=Custom, plus `0`=Done ❌ (no OTP preset)
- No OTP pad prompts ❌
- Custom: prompts path only, **curve auto-detected from path** (no curve menu) ⚠️
- No `requires_approval` prompt (matches C's hardcoded 0) ✅
- No confirmation line ❌
- Loop via `0`/Done instead of `Define another role? [y/N]` ⚠️
- Mandatory ≥1 role satisfied by auto-register (divergent mechanism) ⚠️
| Feature | C | Rust | Status |
|---|---|---|---|
| 10 presets (incl. OTP) | yes | 9, no OTP | ❌ |
| Auto-register default main | no | yes | ❌ |
| Curve menu (custom) | yes | auto-detect | ⚠️ |
| OTP pad dir/name prompts | yes | no | ❌ |
| requires_approval prompt | no (hardcoded 0) | no | ✅ |
| Confirmation line | yes | no | ❌ |
| Loop `y/N` | yes | `0`/Done | ⚠️ |
| Mandatory ≥1 role | yes | yes (via auto-register) | ⚠️ |
---
## Menu 3 — Transport selection
**C** ([`prompt_transport_selection`](../n_signer/src/main.c:3075)):
- Content screen title `"Transport — how should other programs reach this signer?"`
- `Select one or more (type a number to toggle, 'a' for all, Enter to confirm):`
- **Multi-toggle checkboxes** `[x]`/`[ ]`:
1. Local Unix socket
2. **Qubes qrexec bridge**
3. FIPS/TCP listener
4. HTTP listener
- `[a] select all Enter = confirm`
- `1`-`4` toggles bits, `a` selects all, Enter confirms (≥1 required)
- Default: Unix socket only
**Rust** ([`transport_selection`](../signer/src/tui.rs:742)):
- Title `"Transport selection"`
- **Single-select** (pick one of 4): Unix, TCP, HTTP, Unix+HTTP
- No Qubes qrexec ❌
- No toggle/checkbox UI ❌
- No `a` for all ❌
| Feature | C | Rust | Status |
|---|---|---|---|
| Multi-select toggle | yes | no (single) | ❌ |
| Qubes qrexec option | yes | no | ❌ |
| `a` select all | yes | no | ❌ |
| Enter to confirm | yes | no | ❌ |
| Default Unix | yes | yes | ✅ |
---
## Menu 4 — Main status display
**C** ([`render_status`](../n_signer/src/main.c:1898), [`g_main_menu_items`](../n_signer/src/main.c:895)):
- Frame `n_signer v<ver> > Main Menu`
- `^*Roles^:` + table (Role/Purpose/Curve/Derivation path) or `(none)`
- `^*Activity (latest first)^:` + log entries or `(none)`
- Status line: `session=<locked|unlocked> (<N> words) signer=<name> derived=<count>`
- Menu: `^_l^: lock/reunlock`, `^_r^: refresh`, `^_d^: display connections`, `^_q^:/x quit`
**Rust** ([`render_status`](../signer/src/tui.rs:332), [`MAIN_MENU_ITEMS`](../signer/src/tui.rs:266)):
| Feature | C | Rust | Status |
|---|---|---|---|
| Top frame + breadcrumb | yes | yes | ✅ |
| Roles table (4 cols) | yes | yes | ✅ |
| Activity log | yes | yes | ✅ |
| Status line | yes | yes | ✅ |
| Menu items l/r/d/q | yes | yes | ✅ |
---
## Menu 5 — Approval prompt
**C** ([`tui_approval_cb`](../n_signer/src/main.c:1963)):
- Frame `n_signer v<ver> > Approval`, title `"Approval required"`
- `caller: <id>`
- **`fips peer: <npub> (<name>)`** if `req->fips_peer_npub` present
- `method:`, `role:`, `purpose:`
- **`** NEW IDENTITY — will be derived if approved **`** if `pending_derivation`
- `^_y^: allow once`, `^_n^: deny`,
`^_e^: allow this caller+role+verb for session`,
`^_a^: allow this caller+role for session (all verbs)`
- Reads first char, `a`/`e`/`y` → respective policy, else DENY
**Rust** ([`approval_prompt`](../signer/src/tui.rs:486)):
| Feature | C | Rust | Status |
|---|---|---|---|
| caller/method/role/purpose | yes | yes | ✅ |
| fips peer field | yes | no | ❌ |
| NEW IDENTITY line | yes | no | ❌ |
| y/n/e/a options | yes | yes | ✅ |
---
## Menu 6 — Display connections
**C** ([`render_connections`](../n_signer/src/main.c:1804)):
- Full screen clear, top frame
- Iterates `connection_info` entries built from **actual active transports**
([main.c:4086-4190](../n_signer/src/main.c:4086)): Unix, FIPS/TCP, HTTP, Qrexec, Stdio
- Each block: `^*<title>^:`, connection string, ` Example:` + example, optional extra
- OTP pad status line if bound
- Status line + `Press any key to return`
**Rust** ([`render_connections`](../signer/src/tui.rs:415)):
- **Hardcoded** Unix + HTTP blocks regardless of active transports ❌
- No Qubes qrexec, no FIPS/TCP, no Stdio blocks ❌
- No OTP pad status line ❌
- "Press any key to return" ✅
| Feature | C | Rust | Status |
|---|---|---|---|
| Reflects active transports | yes | hardcoded | ❌ |
| Qubes qrexec block | yes | no | ❌ |
| FIPS/TCP block | yes | no | ❌ |
| OTP pad status line | yes | no | ❌ |
| Example client commands | yes | partial | ⚠️ |
---
## Summary of divergences (from code, not doc)
```mermaid
flowchart TD
M1[Menu 1 Unlock] --> D1[No q/x exit, no 10-try limit, no second screen]
M2[Menu 2 Role wizard] --> D2[Auto-registers main, no OTP preset, no curve menu, no confirm line]
M3[Menu 3 Transport] --> D3[Single-select not multi-toggle, no Qubes]
M4[Menu 4 Status] --> D4[Matches]
M5[Menu 5 Approval] --> D5[No NEW IDENTITY line, no fips peer]
M6[Menu 6 Connections] --> D6[Hardcoded, not transport-aware, no OTP status]
```
### Highest-impact gaps (behavioral divergence from C code)
1. **Menu 2 — auto-registers default `main`** before the wizard. C requires the
user to pick preset 1 themselves; the Rust port silently creates `main` and
then offers to add more. This changes the first-run UX.
2. **Menu 2 — OTP preset (choice 9) missing entirely.** Cannot create OTP roles
interactively in Rust.
3. **Menu 2 — no curve menu for Custom (choice 10).** C shows a 6-option curve
menu; Rust auto-detects from path.
4. **Menu 3 — Qubes qrexec missing** and single-select instead of multi-toggle.
5. **Menu 1 — no `q`/`x` exit, no 10-attempt limit, no second "Enter mnemonic"
screen.**
6. **Menu 5 — missing `fips peer` field and `** NEW IDENTITY **` line.**
7. **Menu 6 — hardcoded blocks** instead of reflecting actual active transports;
no OTP pad status line.
### How to spot differences going forward (code-based, not doc-based)
1. Treat [`src/main.c`](../n_signer/src/main.c:1) as the source of truth. The
functions to compare against are:
- [`prompt_load_mnemonic_tui`](../n_signer/src/main.c:2789) — Menu 1
- [`prompt_named_path_roles`](../n_signer/src/main.c:2031) — Menu 2
- [`prompt_transport_selection`](../n_signer/src/main.c:3075) — Menu 3
- [`render_status`](../n_signer/src/main.c:1898) — Menu 4
- [`tui_approval_cb`](../n_signer/src/main.c:1963) — Menu 5
- [`render_connections`](../n_signer/src/main.c:1804) — Menu 6
2. Keep this file ([`plans/menu_gap_analysis.md`](plans/menu_gap_analysis.md:1))
as the living checklist; tick rows as the Rust port converges.
3. **Recommended automated check**: add an integration test that pipes canned
stdin through each Rust menu and asserts the rendered output contains the
exact prompt strings from the C `printf`/`tui_print` calls above (e.g.
`Mnemonic source: [E]nter existing or [G]enerate new`,
`Define a role:`, ` 9. OTP role (one-time pad encryption)`,
`Select one or more (type a number to toggle, 'a' for all, Enter to confirm):`,
`** NEW IDENTITY — will be derived if approved **`). This catches drift
mechanically without re-reading the C source each time.
+296
View File
@@ -0,0 +1,296 @@
# Plan: Migrate TUI to ratatui
## Goal
Replace the hand-rolled `tui_continuous` + `tui.rs` rendering with
[`ratatui`](https://github.com/ratatui/ratatui) (already added as a git
submodule and Cargo path dependency). The main status screen becomes a
ratatui app with four sections, and the interactive setup menus (mnemonic,
role wizard, transport selection) become ratatui screens too.
## Current state
- [`src/tui_continuous.rs`](../src/tui_continuous.rs:1) — 916 lines. Hand-rolled
port of the C `tui_continuous` library: raw mode, `tui_print` with `^_`/`^*`/`^:`
markup, `render_top_frame`, `render_table`, `render_menu`, `render_content_screen`,
`anchor_prompt`, `get_key`, `poll_key`, SIGWINCH handler.
- [`src/tui.rs`](../src/tui.rs:1) — 1062 lines. App-level: `ActivityLog`,
`render_status`, `render_connections`, `role_wizard`, `transport_selection`,
`read_line_editable`, `read_line_raw`, `poll_key`, `init`/`cleanup`.
- [`src/main.rs`](../src/main.rs:269) — the `ListenMode::Unix` branch runs the
TUI loop: `init()``render_status()` → poll for keys (`l`/`r`/`d`/`q`) →
re-render. Server connections are handled between key polls.
## Target layout — main status screen
Two-column layout: left column has Information (top) and Roles (bottom);
right column has Activity (full height, scrollable). Commands span the
full width at the bottom.
```
┌──────────────────────────────────────────────────────────────────────┐
│ signer v0.0.1 > Main Menu │
├──────────────────────────────────────┬───────────────────────────────┤
│ Information │ Activity (latest first) ▲ │
│ session=unlocked (12 words) │ 2026-08-18 08:00:15 req… │
│ signer=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`).
+609
View File
@@ -0,0 +1,609 @@
# Plan: TUI Flow Redesign
## Goal
Simplify the signer TUI from a multi-screen setup wizard into a single
main screen with full-screen overlays. The seed phrase is entered once
at startup in a popup; all subsequent editing (roles, transport, lock)
is done from full-screen overlays reached from the main screen. Borders
are collapsed for a cleaner look. A dedicated Commands screen provides
keyboard navigation through all available actions with a visible
cursor. Command hints show only the word with the key letter
underlined (e.g. "Quit" with Q underlined, not "Q quit").
## Current state
- [`src/tui.rs`](../src/tui.rs:1) — 1398 lines. Seven screens:
`Unlock`, `GenerateMnemonic`, `RoleWizard`, `TransportSelection`,
`Main`, `Connections`, `Lock`. Setup is a linear wizard: Unlock →
(Generate) → RoleWizard → TransportSelection → Main. The main screen
has a title bar reading `signer v0.0.1 > Main Menu`, a two-column
body (Information + Roles on the left, Activity on the right), and a
Commands bar at the bottom. Connections and Lock are rendered as
sub-panels inside the main screen's right column.
- [`src/main.rs`](../src/main.rs:129) — `server_main` constructs `App`
and calls `run()`. Non-interactive mode uses `run_headless()`.
- [`src/server.rs`](../src/server.rs:80) — `ServerContext` supports
`Unix`, `Qrexec`, `Tcp`, `Http`, `Stdio` listen modes. Only one mode
active at a time (the `start_server` method picks the first toggled
transport).
## Key design decisions
### 1. Rename "client name" → "signer name"
The user asked whether "client name" should be renamed since the
signer is more a server than a client. **Decision: rename to "signer
name".** The field shows the socket name (e.g. `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 110 presets as current wizard). Select a preset (or
custom), then enter role name and path template via `InputField`
with pre-filled defaults. On confirm, register the role, derive its
key immediately, and return to the main screen automatically — no
extra Enter needed.
- **Delete** (`D`): deletes the currently selected role immediately —
no confirmation overlay. The role is removed from the table and its
derived key is wiped. The selection moves to the next role.
- **Select**: Up/Down arrows or Tab move selection through the role
list. The `▸` cursor shows the selected role.
- **Columns**: Role, Purpose, Curve, Key path (derivation path via
`RoleEntry::display_path()`, e.g. `m/44'/1237'/0'/0/0` or
`m/44'/1237'/*'/0/0 [0-99]`).
### Help screen
Opened by pressing `L` from the main screen. Full-screen overlay that
describes what the app does, what transports are, what roles are, and
lists the key commands at the end. It is a scrollable screen (the
content can exceed the visible height). Commands at the bottom:
```
Signer v0.0.1
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ Signer is an attended Nostr signing daemon. It holds your keys and signs │
│ requests from clients over one or more transports. │
│ │
│ Transports │
│ ────────── │
│ A transport is a way for clients to reach the signer. │
│ - Unix Socket: local same-machine access via an abstract socket. │
│ - Qube bridge: access from other Qubes via qrexec. │
│ - FIPS: TCP listener for framed JSON over a network. │
│ - HTTP: HTTP listener for curl-friendly requests. │
│ │
│ Roles │
│ ───── │
│ A role binds a name to a derivation path and curve. Clients address │
│ requests by role name, which also serves as the password. │
│ │
│ Key commands │
│ ───────────── │
│ U Toggle Unix Socket transport │
│ B Toggle Qube bridge transport │
│ F Toggle FIPS transport │
│ H Toggle HTTP transport │
│ A Add a role │
│ D Delete the selected role │
│ C Clear the activity log │
│ L Open the Help screen │
│ Q Quit │
│ │
├──────────────────────────────────────────────────────────────────────────────────────┤
│ B̲ack │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
- The content is scrollable — Up/Down arrows (or Page Up/Down) scroll
through the help text. A scrollbar is shown on the right when the
content overflows.
- ESC / `B` (Back): return to main screen.
- This is a reference screen — no actions are executed from here.
## Screen enum (updated)
```rust
pub enum Screen {
/// Startup popup — seed phrase entry
SeedEntry,
/// Startup popup — showing generated mnemonic
SeedDisplay,
/// Main status screen (Information + Transport + Roles + Activity + bottom bar)
Main,
/// Add-role popup — role preset selection (over the Main screen)
AddRole,
/// Help — full-screen overlay describing the app and key commands
Help,
}
```
`Connections`, `Transport`, `Lock`, and `Commands` screens are removed.
Transport is now a section on the main screen (between Information and
Roles). Connection info is shown inline in the Information section
(transport addresses). Lock functionality is removed entirely. The
Commands screen is replaced by a Help screen. Only `SeedEntry` and
`SeedDisplay` are popups; all other screens are full-screen overlays.
## App struct changes
```rust
pub struct App {
// ... existing fields ...
/// Scroll offset for the Help screen content
pub help_scroll: usize,
/// Currently selected role index in the Roles section
pub role_cursor: usize,
/// Currently selected transport line index in the Transport section
pub transport_cursor: usize,
/// Whether the seed entry popup is in "generate" mode
pub seed_generate_mode: bool,
// Remove: wizard_stage, wizard_choice, wizard_default_*,
// wizard_role_name, wizard_path, wizard_otp_dir,
// wizard_otp_name, wizard_roles_created
// Add: role_add_stage (for the add-role popup flow)
pub role_add_stage: RoleAddStage,
pub role_add_input: InputField,
pub role_add_choice: i32,
}
pub enum RoleAddStage {
PresetMenu,
NameEntry,
CurveSelect,
PathEntry,
OtpDir,
OtpName,
Confirm,
}
```
## Server changes
Currently `ServerContext` supports only one listen mode at a time. The
transport toggles are independent checkboxes — toggling one only flips
itself, and multiple transports can be checked at once. The server
listens on the first active transport by priority (Unix > Qrexec > TCP
> HTTP). The last active transport cannot be disabled. This avoids
server architecture changes while allowing the user to select which
transport is active.
Additionally, `process_request` must return an activity log message
alongside the JSON response (see "Activity log format" above). Change
the return type from `String` to `(String, String)` where the first
element is the JSON response and the second is the activity description.
`handle_one` returns this message to the caller so the TUI can log it.
## Event loop changes
The `run()` loop stays the same structure (draw → poll key → service
server). The key change is that `service_server()` is called only when
the screen is `Main`. The overlay screens (`AddRole`, `Help`) pause
server processing while the user is actively configuring. The startup
popups (`SeedEntry`, `SeedDisplay`) also pause server processing since
the server hasn't started yet.
```mermaid
flowchart TD
Start[Start] --> SeedEntry[Popup: Seed Entry]
SeedEntry -->|g| SeedDisplay[Popup: Show Generated Mnemonic]
SeedDisplay -->|Enter| Main
SeedEntry -->|Enter valid phrase| Main[Main Screen]
Main -->|A| AddRole[Popup: Add Role preset menu]
AddRole -->|confirm| Main
Main -->|L| Help[Help Screen - full screen]
Help -->|ESC/B| Main
Main -->|Q| Quit[Quit]
```
Note: Transport and Roles are not separate screens — they are sections
on the main screen. The user tabs/arrow-keys between the 4 transport
lines and toggles them directly on the main screen. Roles are added
and deleted directly in the Roles section.
## Key bindings summary
### Main screen — global commands
| Key | Action |
|-----|--------|
| `L` | Open Help screen |
| `Q` / `ESC` | Quit |
| `↑` / `↓` | Scroll activity log |
### Transport section (on main screen)
| Key | Action |
|-----|--------|
| `TAB` / `↑` / `↓` | Move between transport lines |
| `ENTER` | Toggle selected transport on/off |
| `U` | Toggle Unix Socket on/off |
| `B` | Toggle Qube bridge on/off |
| `F` | Toggle FIPS on/off |
| `H` | Toggle HTTP on/off |
### Roles section (on main screen)
| Key | Action |
|-----|--------|
| `A` | Add role (opens AddRole popup with preset menu) |
| `D` | Delete the currently selected role (immediate, no confirmation) |
| `↑` / `↓` / `TAB` | Select role |
### Activity section (on main screen)
| Key | Action |
|-----|--------|
| `C` | Clear the activity log |
| `↑` / `↓` | Scroll activity log |
### Help screen
| Key | Action |
|-----|--------|
| `↑` / `↓` | Scroll help content |
| `PAGE UP` / `PAGE DOWN` | Scroll help content by page |
| `ESC` / `B` | Back to main |
## Files to change
| File | Change |
|------|--------|
| [`src/tui.rs`](../src/tui.rs:1) | Major rewrite: new `Screen` enum, startup popup for seed entry, AddRole popup, Help overlay, Transport and Roles as inline sections on main screen with toggle buttons, collapsed borders, underlined-key-letter command hints at bottom of each section, centered title. Remove `WizardStage`/wizard flow, `Connections` screen, Lock screen, Commands screen, Roles overlay screen. Remove session/derived from Information. Unix addresses without @. |
| [`src/main.rs`](../src/main.rs:129) | Minor: `App::new` call stays the same. The `listen_override` path may need adjustment since transport is now chosen from the main screen, not a setup screen. |
| [`src/server.rs`](../src/server.rs:80) | Change `process_request` return type to `(String, String)` — JSON response + activity log message. `handle_one` returns the activity message to the caller. Construct the activity message from caller_id, method, role_name, concrete_path, verdict, and source_label (matching C format). |
## Implementation steps
1. **Update `Screen` enum** — replace the seven screens with the new
set: `SeedEntry`, `SeedDisplay`, `Main`, `AddRole`, `Help`.
(No separate Transport, Roles, Lock, or Commands screen — Transport
and Roles are sections on Main, Lock is removed entirely, Commands
replaced by Help.)
2. **Update `App` struct** — remove wizard fields, add `help_scroll`,
`role_cursor`, `transport_cursor`, `seed_generate_mode`,
`RoleAddStage` enum and fields. Update `App::new` to start on
`Screen::SeedEntry`.
3. **Implement seed entry popup**`draw_seed_entry()` renders a
centered popup. `handle_seed_key()` processes input: `g` → generate,
Enter → load mnemonic, derive keys, start server, transition to
`Main`. `draw_seed_display()` shows the generated phrase.
4. **Rewrite `draw_main`** — title is `Signer v0.0.1` centered on its
own line (use `Line::from(...).centered()`, no border box, no "Main
Menu"). Use `MergeStrategy::Exact` + `Spacing::Overlap(1)` for
collapsed borders. Left column has three sections: Information (top),
Transport (middle), Roles (bottom). Right column has Activity
(scrollable, newest first). Information section shows "signer name"
(renamed from "client name") + transport addresses (each on an
indented row beneath the label, without `@` for Unix). Remove the
Connections sub-panel. Roles and Activity sections have a blank row
above their command lines at the bottom.
5. **Implement Transport section on main screen** — renders 4
toggle-button lines, each showing `[x]` or `[ ]` indicator plus
the transport name with the key letter underlined (`U̲nix Socket`,
`Qube b̲ridge`, `F̲IPS`, `H̲TTP`). The `▸` cursor shows the
selected line (`transport_cursor`). Active transport is also shown
in bold/reversed. Tab/Up/Down moves between lines, Enter or the
underlined key letter (`U`/`B`/`F`/`H`) toggles that transport
(independent checkbox: only flips itself; last active cannot be
disabled; toggling restarts the server). No separate command line
for this section. All key commands on the
main screen must be unique: U, B, F, H (transport), A, D (roles),
C (clear activity), L (help), Q (quit).
6. **Implement Roles section on main screen** — renders the role table
with a selection cursor (`role_cursor`). `A` opens the `AddRole`
popup (preset menu → name → path → confirm), `D` deletes the
currently selected role immediately (no confirmation), `↑`/`↓`/Tab
moves the cursor. On add/delete, re-derive keys.
7. **Implement AddRole popup**`draw_add_role()` renders a centered
popup over the Main screen showing the role preset menu (same 110
presets as current wizard). `handle_add_role_key()` processes the
multi-stage flow: `PresetMenu``NameEntry` (InputField with
pre-filled default) → `CurveSelect` (custom only) → `PathEntry`
(InputField with pre-filled default) → `OtpDir`/`OtpName` (OTP
only) → `Confirm`. On confirm, register the role, derive its key,
and return to `Screen::Main`. ESC cancels and returns to Main.
8. **Implement Help screen**`draw_help()` renders a scrollable
`Paragraph` describing what the app does, what transports are, what
roles are, and listing the key commands at the end. Track a
`help_scroll` offset. `handle_help_key()`: Up/Down (and Page
Up/Down) scroll the content, ESC/B returns to main. A scrollbar is
shown when content overflows. This is a reference screen — no
actions executed from here.
9. **Update key command bars** — each section's bottom line shows the
relevant key bindings for that section. Use underlined-key-letter
word hints (e.g. `Q̲uit`, `He̲lp`, `A̲dd`, `D̲elete`, `Cl̲ear`)
instead of "Q quit" style. Replace the old `key_span` helper with a
new `cmd_hint` helper that produces a `Span` with the key command
letter underlined (which may not be the first letter of the word).
10. **Update activity log format** — change `ServerContext::process_request`
to return `(String, String)` (response + activity message). Construct
the activity message from `caller_id`, `method`, `role_name`,
`concrete_path`, `verdict`, and `source_label` matching the C format:
`<caller_id> <method>(<role>[,<path>]) <verdict>:<source>`. Update
`handle_one` to return the activity message. Update
`service_server()` in `App` to log this message instead of
"request handled".
11. **Update `run()` loop** — service server only on `Main` screen
(overlay screens `AddRole` and `Help` pause server processing).
Update the `handle_key` dispatch for the new screen enum.
12. **Update `main.rs`** — adjust `App::new` call if needed. The
`listen_override` path: if `--listen` is given, skip seed entry
popup and go straight to main with the specified transport. But
still need a mnemonic — so `--listen` with interactive mode should
still show the seed entry popup, then go to main with the transport
pre-selected.
13. **Test**`cargo test` (unit tests unaffected). Manual test:
start signer, verify seed entry popup, verify main screen with
collapsed borders and centered title, verify Roles add/delete,
verify AddRole popup preset menu flow, verify Transport toggle
(4 lines, tab navigation), verify Help screen is scrollable and
shows app description + key commands, verify activity log shows
detailed request info, connect with `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
View File
@@ -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
View File
@@ -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,
+51
View File
@@ -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
View File
@@ -1,461 +0,0 @@
//! Policy — caller-based access control with pre-approval and session grants.
//!
//! Port of `policy.c`.
use crate::role_table::RoleEntry;
// ── Limits ───────────────────────────────────────────────────────────────────
pub const POLICY_MAX_ENTRIES: usize = 32;
pub const POLICY_MAX_VERBS: usize = 16;
pub const POLICY_MAX_ROLES: usize = 16;
pub const POLICY_MAX_ALGS: usize = 16;
pub const POLICY_CALLER_MAX_LEN: usize = 160;
// ── Prompt Behavior ──────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptMode {
Never,
FirstPerBoot,
EveryRequest,
Deny,
}
impl PromptMode {
pub fn from_str(s: &str) -> Self {
match s {
"never" => Self::Never,
"first" => Self::FirstPerBoot,
"every" => Self::EveryRequest,
"deny" => Self::Deny,
_ => Self::EveryRequest,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Never => "never",
Self::FirstPerBoot => "first",
Self::EveryRequest => "every",
Self::Deny => "deny",
}
}
}
// ── Policy Source ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicySource {
Default,
Preapprove,
SessionGrant,
}
// ── Policy Entry ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct PolicyEntry {
pub caller: String, // e.g. "uid:1000" or "*" for any
pub verbs: Vec<String>,
pub roles: Vec<String>,
pub purposes: Vec<String>,
pub algorithms: Vec<String>,
pub index_min: i32, // -1 = any
pub index_max: i32, // -1 = any
pub prompt: PromptMode,
pub source: PolicySource,
}
impl Default for PolicyEntry {
fn default() -> Self {
PolicyEntry {
caller: String::new(),
verbs: Vec::new(),
roles: Vec::new(),
purposes: Vec::new(),
algorithms: Vec::new(),
index_min: -1,
index_max: -1,
prompt: PromptMode::EveryRequest,
source: PolicySource::Default,
}
}
}
// ── Policy Check Result ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyResult {
Allow,
AllowSessionVerb,
AllowSessionAll,
Deny,
Prompt,
NoMatch,
}
// ── Policy Table ─────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
pub struct PolicyTable {
pub entries: Vec<PolicyEntry>,
}
impl PolicyTable {
pub fn new() -> Self {
Self::default()
}
/// Initialize default policy: allow same-uid, prompt for others.
pub fn init_default(&mut self, owner_uid: u32) {
self.entries.clear();
// Same-uid: prompt
let mut same_uid = PolicyEntry::default();
same_uid.caller = format!("uid:{}", owner_uid);
same_uid.prompt = PromptMode::EveryRequest;
same_uid.source = PolicySource::Default;
self.entries.push(same_uid);
// Catch-all: deny
let mut catch_all = PolicyEntry::default();
catch_all.caller = "*".to_string();
catch_all.prompt = PromptMode::Deny;
catch_all.source = PolicySource::Default;
self.entries.push(catch_all);
}
/// Add a policy entry.
pub fn add(&mut self, entry: PolicyEntry) -> Result<(), crate::NsignerError> {
if self.entries.len() >= POLICY_MAX_ENTRIES {
return Err(crate::NsignerError::Internal("policy table full".into()));
}
self.entries.push(entry);
Ok(())
}
/// Insert a pre-approve entry at the front of the table so it takes
/// priority over default entries (same-uid prompt, catch-all deny).
pub fn insert_before_last(&mut self, entry: PolicyEntry) -> Result<(), crate::NsignerError> {
if self.entries.len() >= POLICY_MAX_ENTRIES {
return Err(crate::NsignerError::Internal("policy table full".into()));
}
// Insert at front so preapprove rules are checked before defaults
self.entries.insert(0, entry);
Ok(())
}
/// Insert a session grant for caller+role+verb.
///
/// The grant allows the caller to execute `verb` on `role` for the
/// remainder of the session without prompting.
pub fn insert_session_grant(
&mut self,
caller: &str,
verb: &str,
role: &str,
) -> Result<(), crate::NsignerError> {
let mut entry = PolicyEntry::default();
entry.caller = caller.to_string();
entry.verbs.push(verb.to_string());
entry.roles.push(role.to_string());
entry.prompt = PromptMode::Never;
entry.source = PolicySource::SessionGrant;
self.insert_before_last(entry)
}
/// Insert a session grant for caller+role (all verbs).
///
/// The grant allows the caller to execute any verb on `role` for the
/// remainder of the session without prompting.
pub fn insert_session_grant_all(
&mut self,
caller: &str,
role: &str,
) -> Result<(), crate::NsignerError> {
let mut entry = PolicyEntry::default();
entry.caller = caller.to_string();
entry.roles.push(role.to_string());
entry.prompt = PromptMode::Never;
entry.source = PolicySource::SessionGrant;
self.insert_before_last(entry)
}
/// Role-based policy check.
pub fn check(
&self,
caller_id: &str,
verb: &str,
role_name: &str,
purpose: &str,
) -> (PolicyResult, PolicySource) {
for entry in &self.entries {
if !matches(&entry.caller, caller_id) {
continue;
}
if !entry.verbs.is_empty() && !entry.verbs.iter().any(|v| v == verb) {
continue;
}
if !entry.roles.is_empty() && !entry.roles.iter().any(|r| r == role_name) {
continue;
}
if !entry.purposes.is_empty() && !entry.purposes.iter().any(|p| p == purpose) {
continue;
}
// Match found
return match entry.prompt {
PromptMode::Never => (PolicyResult::Allow, entry.source),
PromptMode::Deny => (PolicyResult::Deny, entry.source),
_ => (PolicyResult::Prompt, entry.source),
};
}
(PolicyResult::NoMatch, PolicySource::Default)
}
/// Role-aware policy check: if role has requires_approval==0 (role-as-password),
/// returns Allow immediately without checking policy entries.
pub fn check_with_role(
&self,
caller_id: &str,
verb: &str,
role_name: &str,
purpose: &str,
role: Option<&RoleEntry>,
) -> (PolicyResult, PolicySource) {
// Role-as-password: skip policy if role doesn't require approval
if let Some(r) = role {
if !r.requires_approval {
return (PolicyResult::Allow, PolicySource::Default);
}
}
self.check(caller_id, verb, role_name, purpose)
}
/// Algorithm-based policy check.
pub fn check_algorithm(
&self,
caller_id: &str,
verb: &str,
algorithm: &str,
index: i32,
) -> (PolicyResult, PolicySource) {
for entry in &self.entries {
if !matches(&entry.caller, caller_id) {
continue;
}
if !entry.verbs.is_empty() && !entry.verbs.iter().any(|v| v == verb) {
continue;
}
if !entry.algorithms.is_empty() && !entry.algorithms.iter().any(|a| a == algorithm) {
continue;
}
if entry.index_min >= 0 && index < entry.index_min {
continue;
}
if entry.index_max >= 0 && index > entry.index_max {
continue;
}
return match entry.prompt {
PromptMode::Never => (PolicyResult::Allow, entry.source),
PromptMode::Deny => (PolicyResult::Deny, entry.source),
_ => (PolicyResult::Prompt, entry.source),
};
}
(PolicyResult::NoMatch, PolicySource::Default)
}
}
// ── Pre-approve Spec Parser ──────────────────────────────────────────────────
/// Parse a --preapprove spec into a policy entry.
///
/// Spec format: `caller=<id>,role=<name>,verb=sign,verify`
/// or: `caller=<id>,algorithm=ed25519,index=0-4,verb=sign,verify`
/// or: `caller=<id>,role=main,verb=nostr_sign_event,nostr_get_public_key`
pub fn parse_preapprove_spec(spec: &str) -> Result<PolicyEntry, crate::NsignerError> {
let mut entry = PolicyEntry::default();
entry.source = PolicySource::Preapprove;
for field in spec.split(',') {
let (key, value) = field
.split_once('=')
.ok_or_else(|| crate::NsignerError::InvalidInput)?;
match key.trim() {
"caller" => entry.caller = value.trim().to_string(),
"role" => entry.roles.push(value.trim().to_string()),
"verb" => {
for v in value.split('|') {
entry.verbs.push(v.trim().to_string());
}
}
"algorithm" => entry.algorithms.push(value.trim().to_string()),
"index" => {
// Parse "N" or "N-M"
let v = value.trim();
if let Some(dash) = v.find('-') {
entry.index_min = v[..dash]
.parse()
.map_err(|_| crate::NsignerError::InvalidInput)?;
entry.index_max = v[dash + 1..]
.parse()
.map_err(|_| crate::NsignerError::InvalidInput)?;
} else {
let idx: i32 = v
.parse()
.map_err(|_| crate::NsignerError::InvalidInput)?;
entry.index_min = idx;
entry.index_max = idx;
}
}
_ => return Err(crate::NsignerError::InvalidInput),
}
}
if entry.caller.is_empty() {
return Err(crate::NsignerError::InvalidInput);
}
// Default prompt mode for preapprove: never (auto-allow)
entry.prompt = PromptMode::Never;
Ok(entry)
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Check if a caller pattern matches a caller ID. "*" matches any.
fn matches(pattern: &str, caller_id: &str) -> bool {
pattern == "*" || pattern == caller_id
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_policy() {
let mut table = PolicyTable::new();
table.init_default(1000);
// Same-uid should prompt
let (result, _) = table.check("uid:1000", "sign", "main", "nostr");
assert_eq!(result, PolicyResult::Prompt);
// Different uid should deny (catch-all)
let (result, _) = table.check("uid:2000", "sign", "main", "nostr");
assert_eq!(result, PolicyResult::Deny);
}
#[test]
fn test_preapprove() {
let mut table = PolicyTable::new();
table.init_default(1000);
let entry = parse_preapprove_spec("caller=uid:1000,role=main,verb=nostr_sign_event").unwrap();
table.insert_before_last(entry).unwrap();
// Now uid:1000 with nostr_sign_event on main should be allowed
let (result, source) = table.check("uid:1000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Allow);
assert_eq!(source, PolicySource::Preapprove);
}
#[test]
fn test_preapprove_algorithm() {
let mut table = PolicyTable::new();
table.init_default(1000);
let entry =
parse_preapprove_spec("caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign").unwrap();
table.insert_before_last(entry).unwrap();
let (result, _) = table.check_algorithm("uid:1000", "sign", "ed25519", 2);
assert_eq!(result, PolicyResult::Allow);
let (result, _) = table.check_algorithm("uid:1000", "sign", "ed25519", 5);
assert_eq!(result, PolicyResult::Prompt); // out of range, falls to default
}
#[test]
fn test_role_as_password() {
let mut table = PolicyTable::new();
table.init_default(1000);
let mut role = RoleEntry::default();
role.name = "main".into();
role.requires_approval = false;
// Role-as-password: should allow without checking policy
let (result, _) =
table.check_with_role("uid:2000", "nostr_sign_event", "main", "nostr", Some(&role));
assert_eq!(result, PolicyResult::Allow);
}
#[test]
fn test_parse_preapprove_spec() {
let entry = parse_preapprove_spec("caller=uid:1000,role=main,verb=sign|verify").unwrap();
assert_eq!(entry.caller, "uid:1000");
assert_eq!(entry.roles, vec!["main"]);
assert_eq!(entry.verbs, vec!["sign", "verify"]);
}
#[test]
fn test_session_grant_verb() {
let mut table = PolicyTable::new();
table.init_default(1000);
// Without grant: same-uid prompts
let (result, _) = table.check("uid:1000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Prompt);
// Insert session grant for caller+role+verb
table
.insert_session_grant("uid:1000", "nostr_sign_event", "main")
.unwrap();
// Now allowed
let (result, source) = table.check("uid:1000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Allow);
assert_eq!(source, PolicySource::SessionGrant);
// Different verb still prompts
let (result, _) = table.check("uid:1000", "nostr_get_public_key", "main", "nostr");
assert_eq!(result, PolicyResult::Prompt);
}
#[test]
fn test_session_grant_all_verbs() {
let mut table = PolicyTable::new();
table.init_default(1000);
table
.insert_session_grant_all("uid:1000", "main")
.unwrap();
// Any verb on main is allowed
let (result, source) = table.check("uid:1000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Allow);
assert_eq!(source, PolicySource::SessionGrant);
let (result, _) = table.check("uid:1000", "nostr_get_public_key", "main", "nostr");
assert_eq!(result, PolicyResult::Allow);
// Different role still prompts
let (result, _) = table.check("uid:1000", "nostr_sign_event", "ssh", "ssh");
assert_eq!(result, PolicyResult::Prompt);
}
#[test]
fn test_session_grant_does_not_affect_other_callers() {
let mut table = PolicyTable::new();
table.init_default(1000);
table
.insert_session_grant("uid:1000", "nostr_sign_event", "main")
.unwrap();
// Different caller still denied by catch-all
let (result, _) = table.check("uid:2000", "nostr_sign_event", "main", "nostr");
assert_eq!(result, PolicyResult::Deny);
}
}
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-915
View File
@@ -1,915 +0,0 @@
//! # tui_continuous — Terminal UI primitives
//!
//! Rust port of the vendored C library `tui_continuous` (v0.0.9).
//! Provides terminal UI primitives: formatted print with hotkey markup,
//! full-screen rendering, tables, menus, and single-key input.
//!
//! This module is intentionally self-contained and separable from the
//! rest of the project — it can be spun out into its own crate.
//!
//! ## Hotkey markup
//!
//! The [`print()`] function parses markup sequences in the input string:
//!
//! | Sequence | Effect | ANSI code |
//! |----------|---------------------|-------------|
//! | `^_` | Underline on | `\x1b[4m` |
//! | `^*` | Bold on | `\x1b[1m` |
//! | `^:` | Reset all formatting| `\x1b[0m` |
//! | `^^` | Literal `^` | `^` |
//!
//! ## Raw mode
//!
//! [`init()`] enables crossterm raw mode, which disables output post-processing
//! (OPOST). This means `\n` no longer produces `\r\n`. All output functions in
//! this module use `\r\n` explicitly for line endings.
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
// ────────────────────────────────────────────────────────────────────────────
// Constants
// ────────────────────────────────────────────────────────────────────────────
/// Library version (matches C TUI_CONTINUOUS_VERSION).
pub const VERSION: &str = "0.0.9";
// ────────────────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────────────────
/// Terminal dimensions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TuiSize {
pub width: u16,
pub height: u16,
}
/// A single menu item with a display label and keyboard shortcut.
///
/// The label may contain hotkey markup (see module docs).
/// `shortcut` is the lowercase character that selects this item, or `'\0'` if none.
#[derive(Debug, Clone, Copy)]
pub struct TuiMenuItem {
pub label: &'static str,
pub shortcut: char,
}
/// Application frame metadata — shown in the top banner.
#[derive(Debug, Clone, Copy)]
pub struct TuiFrame {
pub app_name: &'static str,
pub app_version: &'static str,
pub breadcrumb: &'static str,
}
/// A menu is a slice of menu items.
#[derive(Debug, Clone, Copy)]
pub struct TuiMenu<'a> {
pub items: &'a [TuiMenuItem],
}
/// Status line text (empty/None → no status row rendered).
#[derive(Debug, Clone, Copy)]
pub struct TuiStatus<'a> {
pub text: Option<&'a str>,
}
/// Table column definition.
#[derive(Debug, Clone, Copy)]
pub struct TuiColumn {
pub name: &'static str,
/// Fixed width in chars; 0 = auto (defaults to 12).
pub width: u16,
/// true = right-align, false = left-align.
pub right_align: bool,
}
/// Table definition with a cell-providing closure.
///
/// `get_cell(row, col)` returns the cell text.
/// `is_default(row)` optionally marks a row with `*`.
/// `prefix_len(row)` optionally underlines the first N chars of cell[row][0].
pub struct TuiTable<'a> {
pub columns: &'a [TuiColumn],
pub row_count: usize,
pub get_cell: &'a dyn Fn(usize, usize) -> String,
pub is_default: Option<&'a dyn Fn(usize) -> bool>,
pub prefix_len: Option<&'a dyn Fn(usize) -> usize>,
}
/// Result of [`get_key()`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiKey {
/// A character key was pressed.
Char(char),
/// Enter key.
Enter,
/// Escape key.
Esc,
/// Backspace key.
Backspace,
/// stdin was closed (EOF).
Eof,
/// SIGWINCH fired (terminal resized).
Resize,
/// Any other key event.
Other,
}
// ────────────────────────────────────────────────────────────────────────────
// SIGWINCH handling
// ────────────────────────────────────────────────────────────────────────────
/// Global flag set by the SIGWINCH signal handler.
static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
/// Whether raw mode is currently active.
static RAW_MODE_ACTIVE: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_sigwinch(_signum: i32) {
RESIZE_PENDING.store(true, Ordering::SeqCst);
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 1: Terminal info, raw mode, single-key input
// ────────────────────────────────────────────────────────────────────────────
/// Query terminal size. Falls back to 80×24 if unavailable.
pub fn terminal_size() -> TuiSize {
match crossterm::terminal::size() {
Ok((w, h)) if w > 0 && h > 0 => TuiSize { width: w, height: h },
_ => TuiSize { width: 80, height: 24 },
}
}
/// Install a SIGWINCH handler that sets the resize-pending flag.
///
/// Call once at startup. Uses `libc::sigaction` with `SA_RESTART`.
pub fn install_resize_handler() {
unsafe {
let mut sa: libc::sigaction = std::mem::zeroed();
sa.sa_sigaction = handle_sigwinch as *const () as usize;
sa.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut sa.sa_mask);
libc::sigaction(libc::SIGWINCH, &sa, std::ptr::null_mut());
}
}
/// Check and clear the resize-pending flag. Returns `true` if a resize occurred.
pub fn resize_pending() -> bool {
RESIZE_PENDING.swap(false, Ordering::SeqCst)
}
/// Enter raw input mode (cbreak, no echo). Safe to call multiple times.
pub fn init() {
if RAW_MODE_ACTIVE.load(Ordering::SeqCst) {
return;
}
if crossterm::terminal::enable_raw_mode().is_ok() {
RAW_MODE_ACTIVE.store(true, Ordering::SeqCst);
}
}
/// Restore original terminal settings. Must be called before exit.
pub fn cleanup() {
if !RAW_MODE_ACTIVE.load(Ordering::SeqCst) {
return;
}
let _ = crossterm::terminal::disable_raw_mode();
RAW_MODE_ACTIVE.store(false, Ordering::SeqCst);
}
/// Check if raw mode is currently active.
pub fn is_raw_mode() -> bool {
RAW_MODE_ACTIVE.load(Ordering::SeqCst)
}
/// Wait for and return a single key press.
///
/// Returns [`TuiKey::Resize`] if SIGWINCH fired, [`TuiKey::Eof`] on stdin close.
/// Does NOT require Enter. Requires [`init()`] to have been called.
pub fn get_key() -> TuiKey {
// Check for pending resize first
if resize_pending() {
return TuiKey::Resize;
}
use crossterm::event::{read, Event, KeyCode, KeyEvent};
match read() {
Ok(Event::Key(KeyEvent { code: KeyCode::Char(c), .. })) => {
// Map Enter-like chars
if c == '\r' || c == '\n' {
TuiKey::Enter
} else if c == '\x1b' {
TuiKey::Esc
} else {
TuiKey::Char(c)
}
}
Ok(Event::Key(KeyEvent { code: KeyCode::Enter, .. })) => TuiKey::Enter,
Ok(Event::Key(KeyEvent { code: KeyCode::Esc, .. })) => TuiKey::Esc,
Ok(Event::Key(KeyEvent { code: KeyCode::Backspace, .. })) => TuiKey::Backspace,
Ok(Event::Resize(_, _)) => TuiKey::Resize,
Ok(_) => TuiKey::Other,
Err(_) => TuiKey::Eof,
}
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 2: Formatted print and screen rendering
// ────────────────────────────────────────────────────────────────────────────
/// Write text with hotkey markup expansion, then a `\r\n` line ending.
///
/// Parses `^_` (underline on), `^*` (bold on), `^:` (reset), `^^` (literal `^`).
///
/// # Example
/// ```no_run
/// nsigner::tui_continuous::print("^_A^:dd relay");
/// // Prints "Add relay" with 'A' underlined, followed by \r\n
/// ```
pub fn print(text: &str) {
let mut stdout = io::stdout();
let _ = write_markup(&mut stdout, text);
// In raw mode (OPOST disabled), \n alone doesn't return to column 0.
// In cooked mode, \n is translated to \r\n by the terminal driver,
// so adding \r would produce \r\r\n (double CR). Use \r\n only in raw mode.
if is_raw_mode() {
let _ = write!(stdout, "\r\n");
} else {
let _ = write!(stdout, "\n");
}
let _ = stdout.flush();
}
/// Write text with hotkey markup expansion (no trailing newline).
fn write_markup<W: Write>(w: &mut W, text: &str) -> io::Result<()> {
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '^' {
if let Some(&next) = chars.peek() {
match next {
'_' => {
write!(w, "\x1b[4m")?;
chars.next();
continue;
}
'*' => {
write!(w, "\x1b[1m")?;
chars.next();
continue;
}
':' => {
write!(w, "\x1b[0m")?;
chars.next();
continue;
}
'^' => {
write!(w, "^")?;
chars.next();
continue;
}
_ => {}
}
}
}
write!(w, "{}", c)?;
}
Ok(())
}
/// Newline sequence appropriate for the current terminal mode.
fn newline() -> &'static str {
if is_raw_mode() { "\r\n" } else { "\n" }
}
/// Print `count` blank lines.
fn print_blank_lines(count: usize) {
if count == 0 {
return;
}
let mut stdout = io::stdout();
let nl = newline();
for _ in 0..count {
let _ = write!(stdout, "{}", nl);
}
let _ = stdout.flush();
}
/// Print a line of `ch` repeated `width` times, then a newline.
fn print_repeat_char(ch: char, width: usize) {
let mut stdout = io::stdout();
for _ in 0..width {
let _ = write!(stdout, "{}", ch);
}
let _ = write!(stdout, "{}", newline());
let _ = stdout.flush();
}
/// Print `text` centered within `width` columns, then a newline.
fn print_centered_line(text: &str, width: usize) {
let mut stdout = io::stdout();
let nl = newline();
if width == 0 {
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
return;
}
let len = text.chars().count();
if len >= width {
// Truncate to width
let truncated: String = text.chars().take(width).collect();
let _ = write!(stdout, "{}{}", truncated, nl);
let _ = stdout.flush();
return;
}
let left = (width - len) / 2;
let right = width - len - left;
for _ in 0..left {
let _ = write!(stdout, " ");
}
let _ = write!(stdout, "{}", text);
for _ in 0..right {
let _ = write!(stdout, " ");
}
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
}
/// Clear the continuous scrollback region.
///
/// Prints `\r`, then `term_height` blank lines, then moves cursor up
/// `term_height` lines and returns to column 0. This creates a clean
/// region for re-rendering without full screen clear.
pub fn clear_continuous(term_height: u16) {
let h = if term_height < 1 { 1 } else { term_height as usize };
let mut stdout = io::stdout();
let nl = newline();
let _ = write!(stdout, "\r");
for _ in 0..h {
let _ = write!(stdout, "{}", nl);
}
let _ = write!(stdout, "\x1b[{}A\r", h);
let _ = stdout.flush();
}
/// Full screen clear (for modal views that may exceed terminal height).
pub fn clear_full_screen() {
let mut stdout = io::stdout();
let _ = write!(stdout, "\x1b[2J\x1b[H");
let _ = stdout.flush();
}
/// Render the top frame: `====` header, centered title, `====`, breadcrumb, blank.
pub fn render_top_frame(frame: &TuiFrame, term_width: u16) {
let w = term_width as usize;
let title = format!("{} {}", frame.app_name, frame.app_version);
print_repeat_char('=', w);
print_centered_line(&title, w);
print_repeat_char('=', w);
let mut stdout = io::stdout();
let nl = newline();
let _ = write!(stdout, "{}{}", frame.breadcrumb, nl);
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
print_blank_lines(1);
}
/// Compute the left column for a centered menu based on the frame title.
pub fn menu_left_col(frame: &TuiFrame, term_width: u16) -> u16 {
let title_len = frame.app_name.chars().count()
+ 1
+ frame.app_version.chars().count();
let start = (term_width as usize).saturating_sub(title_len) / 2;
start as u16
}
/// Render each menu item via [`print()`], indented by `left_col` spaces.
pub fn render_menu(menu: &TuiMenu, left_col: u16) {
if menu.items.is_empty() {
return;
}
let indent = " ".repeat(left_col as usize);
for item in menu.items {
let mut stdout = io::stdout();
let _ = write!(stdout, "{}", indent);
let _ = stdout.flush();
print(item.label);
}
}
/// Render the status line (text + blank line) if non-empty.
pub fn render_status_line(status: &TuiStatus) {
match status.text {
Some(t) if !t.is_empty() => {
print(t);
print_blank_lines(1);
}
_ => {}
}
}
/// Position the cursor after filler lines and left-column padding.
///
/// Prints `filler_lines` blank lines, moves cursor back up, then prints
/// `left_col` spaces. This anchors the prompt at the bottom of the screen.
pub fn anchor_prompt(filler_lines: u16, left_col: u16) {
let mut stdout = io::stdout();
let nl = newline();
if filler_lines > 0 {
for _ in 0..filler_lines {
let _ = write!(stdout, "{}", nl);
}
let _ = write!(stdout, "\x1b[{}A\r", filler_lines as usize);
}
for _ in 0..left_col {
let _ = write!(stdout, " ");
}
let _ = stdout.flush();
}
/// Render a content screen: clear + top frame + optional bold title.
pub fn render_content_screen(frame: &TuiFrame, title: Option<&str>) {
let size = terminal_size();
clear_continuous(size.height);
render_top_frame(frame, size.width);
if let Some(t) = title {
if !t.is_empty() {
print(&format!("^*{}^:", t));
}
}
let _ = io::stdout().flush();
}
/// Full screen layout: clear + top frame + gap + menu + gap + status + anchor.
pub fn render_screen(frame: &TuiFrame, menu: Option<&TuiMenu>, status: Option<&TuiStatus>) {
let size = terminal_size();
let top_frame_lines = 6usize; // ===, title, ===, breadcrumb, blank, blank
let gap_header_to_menu = 1usize;
let gap_after_menu = 1usize;
let body_lines = menu.map(|m| m.items.len()).unwrap_or(0);
let status_lines = match status {
Some(s) => match s.text {
Some(t) if !t.is_empty() => 2,
_ => 0,
},
None => 0,
};
let base_lines_before_prompt =
top_frame_lines + gap_header_to_menu + body_lines + gap_after_menu + status_lines;
let filler_lines = (size.height as usize).saturating_sub(1).saturating_sub(base_lines_before_prompt);
let left_col = menu_left_col(frame, size.width);
clear_continuous(size.height);
render_top_frame(frame, size.width);
print_blank_lines(gap_header_to_menu);
if let Some(m) = menu {
render_menu(m, left_col);
}
print_blank_lines(gap_after_menu);
if let Some(s) = status {
render_status_line(s);
}
anchor_prompt(filler_lines as u16, left_col);
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 3: Table rendering
// ────────────────────────────────────────────────────────────────────────────
/// Effective column width (0 → default 12).
fn col_width(col: &TuiColumn) -> usize {
if col.width > 0 {
col.width as usize
} else {
12
}
}
/// Render a table with header, separator dashes, and aligned rows.
///
/// Respects terminal width; if too narrow, falls back to compact multi-line rows.
pub fn render_table(table: &TuiTable) {
if table.columns.is_empty() || table.row_count == 0 {
return;
}
let size = terminal_size();
let term_width = size.width as usize;
// Calculate total fixed width needed
let total_fixed: usize = table.columns.iter().map(|c| col_width(c) + 1).sum();
let compact = term_width < total_fixed;
let mut stdout = io::stdout();
let nl = newline();
if !compact {
// Print header
for col in table.columns {
let w = col_width(col);
if col.right_align {
let _ = write!(stdout, "{:>width$} ", col.name, width = w);
} else {
let _ = write!(stdout, "{:<width$} ", col.name, width = w);
}
}
let _ = write!(stdout, "{}", nl);
// Print dashes
for col in table.columns {
let w = col_width(col);
for _ in 0..w {
let _ = write!(stdout, "-");
}
let _ = write!(stdout, " ");
}
let _ = write!(stdout, "{}", nl);
}
let _ = stdout.flush();
// Print rows
for r in 0..table.row_count {
let is_def = table.is_default.map(|f| f(r)).unwrap_or(false);
let plen = table.prefix_len.map(|f| f(r)).unwrap_or(0);
if compact {
// Compact: first column on line 1, rest on line 2 indented
let cell = (table.get_cell)(r, 0);
let mut stdout = io::stdout();
let nl = newline();
if plen > 0 {
let plen = plen.min(cell.chars().count());
let prefix: String = cell.chars().take(plen).collect();
let rest: String = cell.chars().skip(plen).collect();
let _ = write!(stdout, "\x1b[4m{}\x1b[0m{}", prefix, rest);
} else {
let _ = write!(stdout, "{}", cell);
}
if is_def {
let _ = write!(stdout, " *");
}
let _ = write!(stdout, "{} ", nl);
for c in 1..table.columns.len() {
let cell = (table.get_cell)(r, c);
let _ = write!(stdout, "{} ", cell);
}
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
} else {
// Normal: all columns on one line
let mut stdout = io::stdout();
let nl = newline();
for (c, col) in table.columns.iter().enumerate() {
let w = col_width(col);
let cell = (table.get_cell)(r, c);
if c == 0 && plen > 0 {
// Underline the prefix portion
let cell_len = cell.chars().count();
let display_len = cell_len.min(w);
let ul = plen.min(display_len);
let underlined: String = cell.chars().take(ul).collect();
let remaining: String = cell
.chars()
.skip(ul)
.take(display_len - ul)
.collect();
let _ = write!(stdout, "\x1b[4m{}\x1b[0m{}", underlined, remaining);
// Pad to width
let pad = w.saturating_sub(display_len);
for _ in 0..pad {
let _ = write!(stdout, " ");
}
if is_def {
let _ = write!(stdout, "* ");
} else {
let _ = write!(stdout, " ");
}
} else {
if col.right_align {
let _ = write!(stdout, "{:>width$} ", cell, width = w);
} else {
let _ = write!(stdout, "{:<width$} ", cell, width = w);
}
}
}
let _ = write!(stdout, "{}", nl);
let _ = stdout.flush();
}
}
}
/// Compute minimal unique prefix lengths for an array of string IDs.
///
/// Returns a vector where `out[i]` is the length of the shortest prefix of
/// `ids[i]` that is unique among all ids.
pub fn compute_unique_prefixes(ids: &[&str]) -> Vec<usize> {
let count = ids.len();
let mut result = Vec::with_capacity(count);
for i in 0..count {
let max_len = ids[i].chars().count();
let mut len = 1;
while len <= max_len {
let mut unique = true;
for j in 0..count {
if i != j {
let prefix_i: String = ids[i].chars().take(len).collect();
let prefix_j: String = ids[j].chars().take(len).collect();
if prefix_i == prefix_j {
unique = false;
break;
}
}
}
if unique {
break;
}
len += 1;
}
result.push(len);
}
result
}
// ────────────────────────────────────────────────────────────────────────────
// Phase 4: Input helpers
// ────────────────────────────────────────────────────────────────────────────
/// Read a line from stdin (cooked mode), strip newline, lowercase.
///
/// Returns `true` on success, `false` on EOF/error.
/// Requires line-mode input (do not call while raw mode is active).
pub fn read_line(buf: &mut String) -> bool {
use std::io::BufRead;
buf.clear();
let stdin = io::stdin();
if stdin.lock().read_line(buf).is_err() {
return false;
}
// Strip trailing newline/CR
while buf.ends_with('\n') || buf.ends_with('\r') {
buf.pop();
}
// Lowercase
*buf = buf.to_lowercase();
true
}
/// Check if input is an escape/quit command: `q`, `x`, `exit`, `quit`, `esc`.
pub fn is_escape_input(input: &str) -> bool {
matches!(
input,
"x" | "q" | "exit" | "quit" | "esc"
)
}
/// Match a single-character input to a menu item's shortcut.
///
/// Returns `Some(index)` if the input matches a menu item's shortcut,
/// `None` otherwise. Input must be exactly one character.
pub fn menu_match_key(menu: &TuiMenu, input: &str) -> Option<usize> {
if input.len() != 1 {
return None;
}
let c = input.chars().next()?;
for (i, item) in menu.items.iter().enumerate() {
if item.shortcut != '\0' && item.shortcut == c {
return Some(i);
}
}
None
}
/// Display a `[y/n]` prompt and wait for response.
///
/// Works in both raw mode (single key) and line mode.
/// Returns `true` for yes, `false` for no.
pub fn confirm(prompt: &str) -> bool {
let mut stdout = io::stdout();
if is_raw_mode() {
let _ = write!(stdout, "{} [y/n] ", prompt);
let _ = stdout.flush();
let key = get_key();
let _ = write!(stdout, "{}", newline());
let _ = stdout.flush();
matches!(key, TuiKey::Char('y' | 'Y'))
} else {
let _ = write!(stdout, "{} [y/n]: ", prompt);
let _ = stdout.flush();
let mut buf = String::new();
if !read_line(&mut buf) {
return false;
}
buf.starts_with('y') || buf.starts_with('Y')
}
}
/// Prompt with a pre-filled default value.
///
/// User can press Enter to accept the default, or type a new value.
/// Temporarily exits raw mode if needed. Returns `Ok(())` on success,
/// `Err` on EOF.
pub fn prompt_default(prompt: &str, default: &str, out: &mut String) -> io::Result<()> {
let was_raw = is_raw_mode();
if was_raw {
cleanup();
}
let mut stdout = io::stdout();
let _ = write!(stdout, "{} [{}]: ", prompt, default);
let _ = stdout.flush();
out.clear();
use std::io::BufRead;
let stdin = io::stdin();
let n = stdin.lock().read_line(out)?;
if n == 0 {
if was_raw {
init();
}
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "EOF"));
}
// Strip newline
while out.ends_with('\n') || out.ends_with('\r') {
out.pop();
}
// If empty, use default
if out.is_empty() {
*out = default.to_string();
}
if was_raw {
init();
}
Ok(())
}
/// Print a message (or "Press Enter to continue...") and wait for any key.
pub fn press_enter(message: Option<&str>) {
let mut stdout = io::stdout();
let msg = message.unwrap_or("Press Enter to continue...");
let _ = write!(stdout, "{}", msg);
let _ = stdout.flush();
if is_raw_mode() {
let _ = get_key();
} else {
use std::io::BufRead;
let mut buf = String::new();
let _ = io::stdin().lock().read_line(&mut buf);
}
let _ = write!(stdout, "{}", newline());
let _ = stdout.flush();
}
/// Returns `true` if stdin is a pipe/redirect (not a terminal).
pub fn has_stdin_pipe() -> bool {
unsafe { libc::isatty(libc::STDIN_FILENO) == 0 }
}
// ────────────────────────────────────────────────────────────────────────────
// Tests
// ────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_size_fallback() {
// Should always return something positive
let size = terminal_size();
assert!(size.width > 0);
assert!(size.height > 0);
}
#[test]
fn test_compute_unique_prefixes_basic() {
let ids = ["abc", "abd", "xyz"];
let prefixes = compute_unique_prefixes(&ids);
// "abc" vs "abd": differ at position 3, so prefix=3
// "xyz": unique at position 1
assert_eq!(prefixes, vec![3, 3, 1]);
}
#[test]
fn test_compute_unique_prefixes_identical() {
let ids = ["abc", "abc"];
let prefixes = compute_unique_prefixes(&ids);
// Identical strings → no unique prefix, len exceeds max_len (3+1=4)
assert_eq!(prefixes, vec![4, 4]);
}
#[test]
fn test_compute_unique_prefixes_single() {
let ids = ["hello"];
let prefixes = compute_unique_prefixes(&ids);
assert_eq!(prefixes, vec![1]);
}
#[test]
fn test_compute_unique_prefixes_empty() {
let ids: &[&str] = &[];
let prefixes = compute_unique_prefixes(&ids);
assert!(prefixes.is_empty());
}
#[test]
fn test_is_escape_input() {
assert!(is_escape_input("q"));
assert!(is_escape_input("x"));
assert!(is_escape_input("exit"));
assert!(is_escape_input("quit"));
assert!(is_escape_input("esc"));
assert!(!is_escape_input("y"));
assert!(!is_escape_input(""));
assert!(!is_escape_input("hello"));
}
#[test]
fn test_menu_match_key() {
let items = [
TuiMenuItem { label: "^_l^: lock", shortcut: 'l' },
TuiMenuItem { label: "^_r^: refresh", shortcut: 'r' },
TuiMenuItem { label: "^_q^: quit", shortcut: 'q' },
];
let menu = TuiMenu { items: &items };
assert_eq!(menu_match_key(&menu, "l"), Some(0));
assert_eq!(menu_match_key(&menu, "r"), Some(1));
assert_eq!(menu_match_key(&menu, "q"), Some(2));
assert_eq!(menu_match_key(&menu, "x"), None);
assert_eq!(menu_match_key(&menu, ""), None);
assert_eq!(menu_match_key(&menu, "ab"), None);
}
#[test]
fn test_menu_match_key_no_shortcut() {
let items = [
TuiMenuItem { label: "item1", shortcut: '\0' },
TuiMenuItem { label: "item2", shortcut: 'b' },
];
let menu = TuiMenu { items: &items };
assert_eq!(menu_match_key(&menu, "a"), None);
assert_eq!(menu_match_key(&menu, "b"), Some(1));
}
#[test]
fn test_menu_left_col() {
let frame = TuiFrame {
app_name: "n_signer",
app_version: "v0.1.0",
breadcrumb: "> Main",
};
// title_len = 9 + 1 + 6 = 16
// left_col = (80 - 16) / 2 = 32
assert_eq!(menu_left_col(&frame, 80), 32);
assert_eq!(menu_left_col(&frame, 10), 0); // saturating
}
#[test]
fn test_col_width() {
let col = TuiColumn { name: "test", width: 20, right_align: false };
assert_eq!(col_width(&col), 20);
let col_auto = TuiColumn { name: "test", width: 0, right_align: false };
assert_eq!(col_width(&col_auto), 12);
}
#[test]
fn test_tuikey_equality() {
assert_eq!(TuiKey::Char('a'), TuiKey::Char('a'));
assert_ne!(TuiKey::Char('a'), TuiKey::Char('b'));
assert_eq!(TuiKey::Resize, TuiKey::Resize);
assert_eq!(TuiKey::Eof, TuiKey::Eof);
}
#[test]
fn test_resize_pending_initially_false() {
// Should be false initially (or whatever state was left by prior tests)
// Just verify it returns a bool without panic
let _ = resize_pending();
}
#[test]
fn test_has_stdin_pipe() {
// In test environment, stdin might or might not be a tty.
// Just verify it doesn't panic.
let _ = has_stdin_pipe();
}
}
+34 -163
View File
@@ -1,14 +1,14 @@
//! Integration tests — end-to-end server + client over Unix socket.
//!
//! These tests verify the full security pipeline:
//! caller identification → policy check → approval → dispatch.
//! These tests verify the role-name-as-password model:
//! caller sends a request with a role name → selector resolution → dispatch.
//! No policy table, no approval prompt. Knowing a valid role name is sufficient.
use nsigner::{
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) {}