From b84ce7ab110a062c48057b446931b21f3fd43765 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Tue, 18 Aug 2026 16:47:03 -0400 Subject: [PATCH] v0.0.2 - Created TUI flow redesign plan: single main screen with inline Transport and Roles sections, startup seed popup, AddRole popup, scrollable Help screen, collapsed borders, underlined-key-letter command hints, C-format activity log --- .gitmodules | 3 + Cargo.lock | 452 +++++++- Cargo.toml | 3 +- README.md | 3 + plans/menu_gap_analysis.md | 259 +++++ plans/ratatui_migration_plan.md | 296 +++++ plans/tui_flow_redesign.md | 591 ++++++++++ ratatui | 1 + src/lib.rs | 4 +- src/main.rs | 410 +++---- src/otp_pad.rs | 51 + src/policy.rs | 461 -------- src/role_table.rs | 39 + src/server.rs | 174 +-- src/socket_name.rs | 59 +- src/tui.rs | 1885 ++++++++++++++++++++----------- src/tui_continuous.rs | 915 --------------- tests/integration.rs | 193 +--- 18 files changed, 3201 insertions(+), 2598 deletions(-) create mode 100644 .gitmodules create mode 100644 README.md create mode 100644 plans/menu_gap_analysis.md create mode 100644 plans/ratatui_migration_plan.md create mode 100644 plans/tui_flow_redesign.md create mode 160000 ratatui delete mode 100644 src/policy.rs delete mode 100644 src/tui_continuous.rs diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1a8765f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ratatui"] + path = ratatui + url = https://github.com/ratatui/ratatui.git diff --git a/Cargo.lock b/Cargo.lock index 0cb48e2..f45128e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] @@ -1238,7 +1495,7 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 9b668d7..d0db013 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nsigner" -version = "0.0.1" +version = "0.0.2" 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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..73acf2a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# signer + +A Rust port of the [n_signer](https://github.com/lt/n_signer) project. diff --git a/plans/menu_gap_analysis.md b/plans/menu_gap_analysis.md new file mode 100644 index 0000000..4aca174 --- /dev/null +++ b/plans/menu_gap_analysis.md @@ -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 > 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 > Unlock`, title `"Enter mnemonic phrase"` +- Prompt: `Enter your BIP-39 mnemonic phrase, or 'g' to generate a new one.` +- `g`/`G` → generate, numbered, warning ✅ +- Otherwise → load as mnemonic (paste works implicitly) ⚠️ +- No `q`/`x` exit ❌ +- No 10-attempt limit ❌ +- No second "Enter mnemonic" screen ❌ +- No explicit paste-detection branch (works by accident) ⚠️ + +| Feature | C | Rust | Status | +|---|---|---|---| +| `[E]`/`[G]` prompt text | yes | different wording | ⚠️ | +| `q`/`x` to exit | yes | no | ❌ | +| Paste detection | yes | implicit | ⚠️ | +| 10-attempt limit | yes | no | ❌ | +| Generate + warning | yes | yes | ✅ | +| Second "Enter mnemonic" screen | yes | no | ❌ | + +--- + +## Menu 2 — Define a role / Role preset menu + +**C** ([`prompt_named_path_roles`](../n_signer/src/main.c:2031)): +- `for(;;)` loop, content screen title + `"Define a role — bind a role name to a derivation path template"` +- **10 presets** (1–10): 1=Standard Nostr, 2=Nostr range, 3=Nostr agent, + 4=SSH, 5=Age, 6=ML-DSA-65, 7=SLH-DSA-128s, 8=ML-KEM-768, + **9=OTP role**, **10=Custom path** +- `Select [1]:` (default 1 if empty) +- Role name prompt: ` Role name [%s]: ` with editable line + default +- Duplicate → ` Role '%s' already exists, skipping.` + continue +- **Choice 9 (OTP)**: prompts `OTP pad directory (e.g. /media/usb0):` and + `OTP pad name (e.g. mypad):`, binds pad immediately, registers role with + `curve_str="otp"`, `requires_approval=0` +- **Choice 10 (Custom)**: curve menu + ``` + Curve: + 1) secp256k1 (Nostr, Bitcoin) + 2) ed25519 (SSH) + 3) x25519 (key agreement, Age) + 4) ml-dsa-65 (post-quantum signatures) + 5) slh-dsa-128s (post-quantum signatures) + 6) ml-kem-768 (post-quantum KEM) + Select [1]: + ``` + then ` Path template [%s]: ` editable +- **`requires_approval` is HARDCODED to 0** — no prompt (doc is wrong) +- Confirmation: ` Role '%s' registered: curve=%s path=%s (fixed, requires_approval=0).` + or `(range %d-%d, requires_approval=0).` +- Loop: `Define another role? [y/N]:` → `y` continues, else break +- Mandatory: `if (roles_created == 0) { "At least one role must be defined." return -1; }` +- **No auto-register of default `main`** — user must pick preset 1 + +**Rust** ([`role_wizard`](../signer/src/tui.rs:534)): +- **Auto-registers default `main` first** before showing menu ❌ +- 9 presets (1–9) with 9=Custom, plus `0`=Done ❌ (no OTP preset) +- No OTP pad prompts ❌ +- Custom: prompts path only, **curve auto-detected from path** (no curve menu) ⚠️ +- No `requires_approval` prompt (matches C's hardcoded 0) ✅ +- No confirmation line ❌ +- Loop via `0`/Done instead of `Define another role? [y/N]` ⚠️ +- Mandatory ≥1 role satisfied by auto-register (divergent mechanism) ⚠️ + +| Feature | C | Rust | Status | +|---|---|---|---| +| 10 presets (incl. OTP) | yes | 9, no OTP | ❌ | +| Auto-register default main | no | yes | ❌ | +| Curve menu (custom) | yes | auto-detect | ⚠️ | +| OTP pad dir/name prompts | yes | no | ❌ | +| requires_approval prompt | no (hardcoded 0) | no | ✅ | +| Confirmation line | yes | no | ❌ | +| Loop `y/N` | yes | `0`/Done | ⚠️ | +| Mandatory ≥1 role | yes | yes (via auto-register) | ⚠️ | + +--- + +## Menu 3 — Transport selection + +**C** ([`prompt_transport_selection`](../n_signer/src/main.c:3075)): +- Content screen title `"Transport — how should other programs reach this signer?"` +- `Select one or more (type a number to toggle, 'a' for all, Enter to confirm):` +- **Multi-toggle checkboxes** `[x]`/`[ ]`: + 1. Local Unix socket + 2. **Qubes qrexec bridge** + 3. FIPS/TCP listener + 4. HTTP listener +- `[a] select all Enter = confirm` +- `1`-`4` toggles bits, `a` selects all, Enter confirms (≥1 required) +- Default: Unix socket only + +**Rust** ([`transport_selection`](../signer/src/tui.rs:742)): +- Title `"Transport selection"` +- **Single-select** (pick one of 4): Unix, TCP, HTTP, Unix+HTTP +- No Qubes qrexec ❌ +- No toggle/checkbox UI ❌ +- No `a` for all ❌ + +| Feature | C | Rust | Status | +|---|---|---|---| +| Multi-select toggle | yes | no (single) | ❌ | +| Qubes qrexec option | yes | no | ❌ | +| `a` select all | yes | no | ❌ | +| Enter to confirm | yes | no | ❌ | +| Default Unix | yes | yes | ✅ | + +--- + +## Menu 4 — Main status display + +**C** ([`render_status`](../n_signer/src/main.c:1898), [`g_main_menu_items`](../n_signer/src/main.c:895)): +- Frame `n_signer v > Main Menu` +- `^*Roles^:` + table (Role/Purpose/Curve/Derivation path) or `(none)` +- `^*Activity (latest first)^:` + log entries or `(none)` +- Status line: `session= ( words) signer= derived=` +- 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 > Approval`, title `"Approval required"` +- `caller: ` +- **`fips peer: ()`** 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: `^*^:`, 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. diff --git a/plans/ratatui_migration_plan.md b/plans/ratatui_migration_plan.md new file mode 100644 index 0000000..5c4603d --- /dev/null +++ b/plans/ratatui_migration_plan.md @@ -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`). diff --git a/plans/tui_flow_redesign.md b/plans/tui_flow_redesign.md new file mode 100644 index 0000000..6e77a8b --- /dev/null +++ b/plans/tui_flow_redesign.md @@ -0,0 +1,591 @@ +# 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: │ sign_event(main) ALLOWED:no-auth │ +│ nsigner01 │ 2026-08-18 15:05:30 unix:1000 │ +│ Qube address: │ get_info() ALLOWED:no-auth │ +│ (inactive) │ 2026-08-18 15:04:55 unix:1000 │ +│ FIPS address: │ nip44_encrypt(main) ALLOWED:no-auth │ +│ (inactive) │ │ +│ HTTP address: │ │ +│ (inactive) │ │ +│ OTP pad: chksum=a1b2c3 offset=128/4096 │ │ +│ │ nip44_encrypt(main) ALLOWED:no-auth │ +├──────────────────────────────────────────┤ 2026-08-18 15:03:12 unix:1000 │ +│ Transport │ sign_event(nostr_range,0) │ +│ │ ALLOWED:no-auth │ +│ ▸ [x] U̲nix Socket │ 2026-08-18 15:02:00 unix:1000 │ +│ [ ] Qube b̲ridge │ get_info() ALLOWED:no-auth │ +│ [ ] F̲IPS │ 2026-08-18 15:01:30 unix:1000 │ +│ [ ] H̲TTP │ sign_event(main) ALLOWED:no-auth │ +│ │ 2026-08-18 15:00:22 unix:1000 │ +├──────────────────────────────────────────┤ sign_event(main) ALLOWED:no-auth │ +│ Roles │ 2026-08-18 15:00:10 nsigner started │ +│ │ │ +│ 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 + (radio-button: one active at a time, 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 (radio-button: one active at a time, 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 + +The current Rust implementation logs only "request handled" — it does +not show what was actually handled. The C implementation logs detailed +request information. Copy the C format: + +``` +<caller_id> <method>(<role_name>[,<concrete_path>]) <verdict>:<source_label> +``` + +Examples: +- `unix:1000 sign_event(main) ALLOWED:no-auth` +- `unix:1000 sign_event(nostr_range,0) ALLOWED:no-auth` +- `unix:1000 get_info() ALLOWED:no-auth` +- `tcp:[::1]:12345 nip44_encrypt(main) ALLOWED:no-auth` + +**Implementation:** `ServerContext::process_request` must return an +activity description string alongside the JSON response. Change the +return type to `(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` +- `method` — the JSON-RPC method (e.g. `sign_event`, `get_info`) +- `role_name` — from the resolved selector (if a role was matched) +- `concrete_path` — if the role has a path template with `%d`, the + concrete index (e.g. `0`) +- `verdict` — `ALLOWED` (since we removed policy, all valid requests + are allowed; denied requests get `DENIED` with the error reason) +- `source_label` — `no-auth` (since we removed policy/authorization) + +The `service_server()` method in `App` passes this message to +`activity_log.add()` instead of the generic "request handled". + +### Roles section (on main screen) + +Roles are managed directly in the Roles section on the main screen — +there is no separate Roles overlay screen. The `▸` cursor shows the +selected role. + +- **Add** (`A`): opens the `AddRole` popup showing the role preset + menu (same 1–10 presets as current wizard). Select a preset (or + custom), then enter role name and path template via `InputField` + with pre-filled defaults. On confirm, register the role and derive + its key immediately, then return to the main screen. +- **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. + +### 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 screen uses radio-button behavior: only one transport can be +active at a time. Toggling one on turns the others off. This matches +the current C behavior and avoids server architecture changes. + +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 `cmd_cursor`, + `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. Remove the + Connections sub-panel. + +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 + (radio-button: one active at a time, 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 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. + +8. **Update key command bars** — each screen's bottom bar shows the + relevant key bindings for that screen. Use underlined-first-letter + word hints (e.g. `Q̲uit`, `R̲oles`) 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). + +9. **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". + +10. **Update `run()` loop** — service server only on `Main` screen + (all overlay screens pause server processing). Update the + `handle_key` dispatch for the new screen enum. + +11. **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. + +12. **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/remove, + verify Transport toggle (4 lines, tab navigation), verify Help + screen shows all 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. diff --git a/ratatui b/ratatui new file mode 160000 index 0000000..31809ba --- /dev/null +++ b/ratatui @@ -0,0 +1 @@ +Subproject commit 31809ba9d49df614f510254a124ea027a8be19f9 diff --git a/src/lib.rs b/src/lib.rs index 58e7f55..94635f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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.2"; diff --git a/src/main.rs b/src/main.rs index d273cdf..7677994 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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,52 @@ 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_override = 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_override.unwrap_or(ListenMode::Unix)); + } + + // Headless modes never show a TUI. + if let Some(mode) = listen_override { + 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 && listen_override.is_none() { + // ── Fully interactive: App handles everything ──────────── + let mut app = nsigner::tui::App::new( + RoleTable::new(), + MnemonicState::new(), + KeyStore::new(), + AlgorithmKeyCache::new(), + socket_name, + 0, + auth_mode, + None, + ); + let mut terminal = ratatui::init(); + let result = app.run(&mut terminal); + ratatui::restore(); + return result.map_err(|e| NsignerError::IoFailed(e.to_string())); + } + + // ── Non-interactive setup: mnemonic + roles ────────────────── 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 +186,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 +194,16 @@ 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 +213,119 @@ 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(); - - // ── 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); - } + let alg_key_cache = AlgorithmKeyCache::new(); // ── Derive keys ────────────────────────────────────────────── let derived_count = key_store.derive_all(&mut role_table, &mnemonic)?; - // ── Transport selection ────────────────────────────────────── - let listen_mode = parse_listen_mode(&cli.listen); + // ── Unix mode: TUI main screen (skip setup screens) ─────────── + let mode = listen_override.unwrap_or(ListenMode::Unix); + let mut app = nsigner::tui::App::new( + role_table, + mnemonic, + key_store, + alg_key_cache, + socket_name, + derived_count, + auth_mode, + Some(mode), + ); + let mut terminal = ratatui::init(); + let result = app.run(&mut terminal); + ratatui::restore(); + result.map_err(|e| NsignerError::IoFailed(e.to_string())) +} + +/// 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 { + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + let phrase = input.trim().to_string(); + mnemonic.load(&phrase)?; + } else if let Some(fd) = cli.mnemonic_fd { + use std::io::Read; + use std::os::unix::io::FromRawFd; + let mut file = unsafe { std::fs::File::from_raw_fd(fd) }; + let mut input = String::new(); + file.read_to_string(&mut input) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + let phrase = input.trim().to_string(); + mnemonic.load(&phrase)?; + } + + let mut role_table = RoleTable::new(); + if !cli.register_role.is_empty() { + for spec in &cli.register_role { + register_role_from_spec(&mut role_table, spec)?; + } + } else { + role_table + .register_role_path( + "main", + "m/44'/1237'/0'/0/0", + RolePurpose::Nostr, + RoleCurve::Secp256k1, + -1, -1, -1, &[], + ) + .map_err(|e| NsignerError::Internal(e.to_string()))?; + } + + let mut key_store = KeyStore::new(); + let mut alg_key_cache = AlgorithmKeyCache::new(); + key_store.derive_all(&mut role_table, &mnemonic)?; + 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(true) => {} + Ok(false) => { + 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 +333,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 +385,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 +457,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, diff --git a/src/otp_pad.rs b/src/otp_pad.rs index ee92796..022a34f 100644 --- a/src/otp_pad.rs +++ b/src/otp_pad.rs @@ -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::*; diff --git a/src/policy.rs b/src/policy.rs deleted file mode 100644 index bfcd5be..0000000 --- a/src/policy.rs +++ /dev/null @@ -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); - } -} diff --git a/src/role_table.rs b/src/role_table.rs index dd59758..92bf756 100644 --- a/src/role_table.rs +++ b/src/role_table.rs @@ -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). diff --git a/src/server.rs b/src/server.rs index dded04d..208788a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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; @@ -143,28 +141,36 @@ impl ServerContext { pub fn handle_one( &mut self, dispatcher: &mut DispatcherContext, - policy: &mut PolicyTable, ) -> Result<bool, NsignerError> { if let Some(ref listener) = self.listener { match listener.accept() { Ok((stream, _)) => { - + // 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(false) 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(false); + } + Err(_) => return Ok(false), }; // 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 = self.process_request(dispatcher, &request, &caller); // Send framed response if let Err(_) = crate::transport::send_framed(&mut writer, &response) { @@ -182,7 +188,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,17 +203,23 @@ 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(false); + } + Err(_) => return Ok(false), } } 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(false); + } + Err(_) => return Ok(false), } }; - // Process with policy enforcement - let response = self.process_request(dispatcher, policy, &request, &caller); + // Process request (role-name-as-password model: no authorization) + let response = self.process_request(dispatcher, &request, &caller); if self.listen_mode == ListenMode::Http { let _ = crate::http::send_response(&mut writer, &response); @@ -224,12 +238,17 @@ impl ServerContext { Ok(false) } - /// 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 { @@ -265,14 +284,14 @@ impl ServerContext { } }; - // 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); } - // 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); + return self.process_algorithm_verb(dispatcher, request, &selector_req); } // OTP verbs @@ -280,100 +299,29 @@ impl ServerContext { return crate::dispatcher::handle_request(dispatcher, request); } - // ── Resolve role selector ────────────────────────────────── - let role_index = match selector_resolve(&selector_req, dispatcher.role_table) { + // ── 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 role = &dispatcher.role_table.entries[role_index]; - let role_name = role.name.clone(); - let purpose = role.purpose_str.clone(); - - // ── Policy check ─────────────────────────────────────────── - let (result, _source) = policy.check_with_role( - &caller.caller_id, - &method, - &role_name, - &purpose, - Some(role), - ); - - let decision = match result { - PolicyResult::Allow => PolicyResult::Allow, - PolicyResult::Deny => PolicyResult::Deny, - PolicyResult::Prompt => { - // Prompt for approval - let d = crate::tui::approval_prompt(&caller.caller_id, &method, &role_name, &purpose); - match d { - PolicyResult::AllowSessionVerb => { - let _ = policy.insert_session_grant(&caller.caller_id, &method, &role_name); - PolicyResult::Allow - } - PolicyResult::AllowSessionAll => { - let _ = policy.insert_session_grant_all(&caller.caller_id, &role_name); - PolicyResult::Allow - } - other => other, - } - } - _ => PolicyResult::Deny, - }; - - if decision != PolicyResult::Allow { - return make_policy_denied(&request); - } - // ── Dispatch ─────────────────────────────────────────────── crate::dispatcher::handle_request(dispatcher, request) } - /// Process an algorithm-based verb with algorithm policy check. + /// 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 +458,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 +486,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 { diff --git a/src/socket_name.rs b/src/socket_name.rs index d13e486..1a87b08 100644 --- a/src/socket_name.rs +++ b/src/socket_name.rs @@ -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); } } diff --git a/src/tui.rs b/src/tui.rs index 10fef62..816cbe5 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,23 +1,67 @@ -//! Terminal UI — interactive status display, role wizard, approval prompts. +//! Terminal UI — ratatui-based interactive signer TUI. //! -//! App-level TUI code that builds on the [`tui_continuous`] primitives. -//! Uses `tui_continuous` for all terminal rendering (top frame, tables, -//! menus, formatted print with hotkey markup). +//! Provides the setup screens (unlock, role wizard, transport selection), +//! the main status display (Information / Roles / Activity / Commands), +//! the connections screen, and the lock screen. -use crate::policy::PolicyResult; -use crate::role_table::RoleTable; -use crate::tui_continuous::{ - self, TuiColumn, TuiFrame, TuiMenu, TuiMenuItem, TuiTable, +use crate::dispatcher::DispatcherContext; +use crate::key_store::KeyStore; +use crate::mnemonic::MnemonicState; +use crate::role_table::{RoleCurve, RolePurpose, RoleSelectorType, RoleTable}; +use crate::server::{AuthMode, ListenMode, ServerContext}; +use crate::NsignerError; + +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, }; -use std::io::Write; -use std::sync::atomic::{AtomicBool, Ordering}; +use ratatui::Frame; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +// ──────────────────────────────────────────────────────────────────────────── +// Transport flags +// ──────────────────────────────────────────────────────────────────────────── + +/// Transport bitmask flags — matches the C `TRANSPORT_*` constants. +pub const TRANSPORT_UNIX: u8 = 0x01; +pub const TRANSPORT_QREXEC_BRIDGE: u8 = 0x02; +pub const TRANSPORT_TCP: u8 = 0x04; +pub const TRANSPORT_HTTP: u8 = 0x08; + +fn transport_str(mask: u8) -> String { + let mut parts = Vec::new(); + if mask & TRANSPORT_UNIX != 0 { + parts.push("unix"); + } + if mask & TRANSPORT_QREXEC_BRIDGE != 0 { + parts.push("qrexec"); + } + if mask & TRANSPORT_TCP != 0 { + parts.push("tcp"); + } + if mask & TRANSPORT_HTTP != 0 { + parts.push("http"); + } + if parts.is_empty() { + "none".to_string() + } else { + parts.join(",") + } +} // ──────────────────────────────────────────────────────────────────────────── // Activity log // ──────────────────────────────────────────────────────────────────────────── -/// Maximum number of activity log entries kept (matches C ACTIVITY_LOG_CAP). -pub const ACTIVITY_LOG_CAP: usize = 16; +/// Maximum number of activity log entries kept. +/// +/// Raised from the C's 16 so the activity list fills the visible column +/// (and has room to scroll) instead of stopping halfway. +pub const ACTIVITY_LOG_CAP: usize = 200; /// Activity log — a ring buffer of timestamped messages. pub struct ActivityLog { @@ -94,552 +138,673 @@ fn format_timestamp(epoch: u64) -> String { } // ──────────────────────────────────────────────────────────────────────────── -// Global flags +// Screens // ──────────────────────────────────────────────────────────────────────────── -/// Global flag: when set, approval prompts are auto-allowed (--allow-all). -static PROMPT_ALWAYS_ALLOW: AtomicBool = AtomicBool::new(false); - -/// Set whether approval prompts should always be allowed (--allow-all). -pub fn set_prompt_always_allow(allow: bool) { - PROMPT_ALWAYS_ALLOW.store(allow, Ordering::SeqCst); -} - -/// Whether approval prompts are currently auto-allowed. -pub fn prompt_always_allow() -> bool { - PROMPT_ALWAYS_ALLOW.load(Ordering::SeqCst) -} - -// ──────────────────────────────────────────────────────────────────────────── -// Key handling -// ──────────────────────────────────────────────────────────────────────────── - -/// Keys handled by the TUI main loop. +/// The current screen the TUI is showing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TuiKey { - /// 'l' — lock/reunlock - Lock, - /// 'r' — refresh - Refresh, - /// 'd' — show connection details +pub enum Screen { + /// Unlock — mnemonic source (g = generate, else paste/enter) + Unlock, + /// Generated mnemonic display (press Enter to continue) + GenerateMnemonic, + /// Role wizard — preset menu and role entry + RoleWizard, + /// Transport selection — checkbox toggle + TransportSelection, + /// Main status display + Main, + /// Connection instructions Connections, - /// 'q' or 'x' — quit - Quit, - /// Any other key (ignored) - Other, - /// No key pressed within the poll timeout - None, + /// Lock — re-enter mnemonic + Lock, } -/// Poll for a single keypress with a timeout (non-blocking). +/// Stage within the role wizard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WizardStage { + /// Preset menu selection (1-10) + SelectPreset, + /// Role name entry (pre-filled) + RoleName, + /// Curve selection (custom only) + CurveSelect, + /// Path template entry (custom only, pre-filled) + PathEntry, + /// OTP pad directory entry + OtpDir, + /// OTP pad name entry + OtpName, + /// Confirmation + "Define another role?" prompt + ConfirmRegister, +} + +// ──────────────────────────────────────────────────────────────────────────── +// InputField widget +// ──────────────────────────────────────────────────────────────────────────── + +/// A text input field with cursor position. /// -/// Returns [`TuiKey::None`] if no key was pressed within `timeout_ms`. -/// Must be called while raw mode is enabled. -pub fn poll_key(timeout_ms: u64) -> TuiKey { - use crossterm::event::{poll, read, Event, KeyCode, KeyEvent}; +/// Defaults are pre-filled into the buffer with the cursor at the end — the +/// user can backspace to edit or press Enter to accept. +#[derive(Debug, Clone)] +pub struct InputField { + pub buffer: String, + pub cursor: usize, +} - if !poll(std::time::Duration::from_millis(timeout_ms)).unwrap_or(false) { - return TuiKey::None; +impl InputField { + pub fn new(prefill: &str) -> Self { + Self { + buffer: prefill.to_string(), + cursor: prefill.chars().count(), + } } - // Check for resize - if tui_continuous::resize_pending() { - return TuiKey::Other; // Will be handled by resize check in main loop + /// Insert a character at the cursor. + pub fn insert(&mut self, c: char) { + if self.cursor <= self.buffer.chars().count() { + self.buffer.insert(self.byte_idx(self.cursor), c); + self.cursor += 1; + } } - match read() { - Ok(Event::Key(KeyEvent { - code: KeyCode::Char('d' | 'D'), - .. - })) => TuiKey::Connections, - Ok(Event::Key(KeyEvent { - code: KeyCode::Char('q' | 'Q' | 'x' | 'X'), - .. - })) - | Ok(Event::Key(KeyEvent { - code: KeyCode::Esc, - .. - })) => TuiKey::Quit, - Ok(Event::Key(KeyEvent { - code: KeyCode::Char('r' | 'R'), - .. - })) => TuiKey::Refresh, - Ok(Event::Key(KeyEvent { - code: KeyCode::Char('l' | 'L'), - .. - })) => TuiKey::Lock, - Ok(Event::Key(_)) => TuiKey::Other, - _ => TuiKey::Other, + /// Backspace: delete the char before the cursor. + pub fn backspace(&mut self) { + if self.cursor > 0 { + let idx = self.byte_idx(self.cursor - 1); + self.buffer.remove(idx); + self.cursor -= 1; + } + } + + /// Delete: remove the char at the cursor. + pub fn delete(&mut self) { + let len = self.buffer.chars().count(); + if self.cursor < len { + let idx = self.byte_idx(self.cursor); + self.buffer.remove(idx); + } + } + + /// Move cursor left. + pub fn move_left(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + /// Move cursor right. + pub fn move_right(&mut self) { + let len = self.buffer.chars().count(); + if self.cursor < len { + self.cursor += 1; + } + } + + /// Move cursor to start. + pub fn home(&mut self) { + self.cursor = 0; + } + + /// Move cursor to end. + pub fn end(&mut self) { + self.cursor = self.buffer.chars().count(); + } + + /// Clear the field. + pub fn clear(&mut self) { + self.buffer.clear(); + self.cursor = 0; + } + + /// Byte index of a char-index position. + fn byte_idx(&self, char_idx: usize) -> usize { + self.buffer + .chars() + .take(char_idx) + .map(|c| c.len_utf8()) + .sum() } } -/// Read a line of input in raw mode, handling Enter and Backspace. -/// -/// Works when raw mode is enabled (where `stdin().read_line()` is broken -/// because Enter produces `\r` and there is no line editing). -pub fn read_line_raw() -> std::io::Result<String> { - use crossterm::event::{read, Event, KeyCode, KeyEvent, KeyModifiers}; - - let mut line = String::new(); - let mut stdout = std::io::stdout(); - - loop { - match read() { - Ok(Event::Key(KeyEvent { - code: KeyCode::Enter, - .. - })) => { - let _ = write!(stdout, "\r\n"); - let _ = stdout.flush(); - return Ok(line); - } - Ok(Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - .. - })) => { - let _ = write!(stdout, "^C\r\n"); - let _ = stdout.flush(); - return Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, - "interrupted", - )); - } - Ok(Event::Key(KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL, - .. - })) => { - let _ = write!(stdout, "^D\r\n"); - let _ = stdout.flush(); - return Ok(line); - } - Ok(Event::Key(KeyEvent { - code: KeyCode::Char(c), - .. - })) => { - // Treat \r and \n as Enter (handles piped input through PTY) - if c == '\r' || c == '\n' { - let _ = write!(stdout, "\r\n"); - let _ = stdout.flush(); - return Ok(line); +/// Handle a text-editing key against an `InputField`. +/// Returns true if the key was consumed as an editing action. +fn edit_key(field: &mut InputField, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char(c) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + match c { + 'a' => field.home(), + 'e' => field.end(), + 'u' => field.clear(), + _ => return false, } - line.push(c); - let _ = write!(stdout, "{}", c); - let _ = stdout.flush(); + } else { + field.insert(c); } - Ok(Event::Key(KeyEvent { - code: KeyCode::Backspace, - .. - })) => { - if line.pop().is_some() { - let _ = write!(stdout, "\x08 \x08"); - let _ = stdout.flush(); + true + } + KeyCode::Backspace => { + field.backspace(); + true + } + KeyCode::Delete => { + field.delete(); + true + } + KeyCode::Left => { + field.move_left(); + true + } + KeyCode::Right => { + field.move_right(); + true + } + KeyCode::Home => { + field.home(); + true + } + KeyCode::End => { + field.end(); + true + } + _ => false, + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// App state +// ──────────────────────────────────────────────────────────────────────────── + +/// The TUI application state. +pub struct App { + pub role_table: RoleTable, + pub mnemonic: MnemonicState, + pub key_store: KeyStore, + pub alg_key_cache: crate::alg_cache::AlgorithmKeyCache, + pub activity_log: ActivityLog, + pub socket_name: String, + pub derived_count: usize, + pub auth_mode: AuthMode, + /// If set (via --listen), skip the transport selection screen. + pub listen_override: Option<ListenMode>, + + /// Server is created lazily after transport selection. + pub server: Option<ServerContext>, + + pub screen: Screen, + pub should_quit: bool, + + // Unlock / lock + pub unlock_input: InputField, + pub unlock_attempts: i32, + pub unlock_mode: bool, // true if mnemonic entry phase (after E choice) + + // Role wizard + pub(crate) wizard_stage: WizardStage, + pub wizard_choice: i32, + pub wizard_default_name: String, + pub wizard_default_path: String, + pub wizard_default_curve: String, + pub wizard_default_purpose: RolePurpose, + pub wizard_role_name: InputField, + pub wizard_path: InputField, + pub wizard_otp_dir: InputField, + pub wizard_otp_name: InputField, + pub wizard_roles_created: usize, + + // Transport selection + pub transport_toggles: [bool; 4], + pub transport_mask: u8, + + // Generated mnemonic + pub generated_phrase: String, + + // Main screen + pub activity_scroll: usize, +} + +impl App { + /// Build the app state for the interactive TUI flow. + #[allow(clippy::too_many_arguments)] + pub fn new( + role_table: RoleTable, + mnemonic: MnemonicState, + key_store: KeyStore, + alg_key_cache: crate::alg_cache::AlgorithmKeyCache, + socket_name: String, + derived_count: usize, + auth_mode: AuthMode, + listen_override: Option<ListenMode>, + ) -> Self { + Self { + role_table, + mnemonic, + key_store, + alg_key_cache, + activity_log: ActivityLog::new(), + socket_name, + derived_count, + auth_mode, + listen_override, + server: None, + screen: Screen::Unlock, + should_quit: false, + unlock_input: InputField::new(""), + unlock_attempts: 0, + unlock_mode: false, + wizard_stage: WizardStage::SelectPreset, + wizard_choice: 1, + wizard_default_name: "main".to_string(), + wizard_default_path: "m/44'/1237'/0'/0/0".to_string(), + wizard_default_curve: "secp256k1".to_string(), + wizard_default_purpose: RolePurpose::Nostr, + wizard_role_name: InputField::new("main"), + wizard_path: InputField::new("m/44'/1237'/0'/0/0"), + wizard_otp_dir: InputField::new(""), + wizard_otp_name: InputField::new(""), + wizard_roles_created: 0, + transport_toggles: [true, false, false, false], + transport_mask: TRANSPORT_UNIX, + generated_phrase: String::new(), + activity_scroll: 0, + } + } + + /// Run the TUI event loop until quit. + pub fn run(&mut self, terminal: &mut ratatui::DefaultTerminal) -> std::io::Result<()> { + // If --listen was given, skip the setup screens and go straight to main. + if let Some(mode) = self.listen_override { + self.transport_toggles = match mode { + ListenMode::Unix => [true, false, false, false], + ListenMode::Qrexec => [false, true, false, false], + ListenMode::Tcp => [false, false, true, false], + ListenMode::Http => [false, false, false, true], + _ => [true, false, false, false], + }; + self.transport_mask_apply(); + if let Err(e) = self.start_server() { + eprintln!("server start failed: {}", e); + return Ok(()); + } + self.activity_log.add("nsigner started"); + self.screen = Screen::Main; + } + + loop { + terminal.draw(|f| self.draw(f))?; + + if crossterm::event::poll(Duration::from_millis(50))? { + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + self.handle_key(key); + if self.should_quit { + break; + } + } + } else if matches!( + self.screen, + Screen::Main | Screen::Connections | Screen::Lock + ) { + // No key — service any pending server connection. + self.service_server(); + } + } + Ok(()) + } + + /// Service one pending server request (if any) and update the log. + fn service_server(&mut self) { + let server = match self.server.as_mut() { + Some(s) => s, + None => return, + }; + let mut dispatcher = DispatcherContext { + role_table: &mut self.role_table, + mnemonic: &self.mnemonic, + key_store: &mut self.key_store, + alg_key_cache: &mut self.alg_key_cache, + }; + match server.handle_one(&mut dispatcher) { + Ok(true) => { + self.activity_log.add("request handled"); + } + Ok(false) => {} + Err(e) => { + self.activity_log.add(&format!("server error: {}", e)); + } + } + } + + /// Start the server after transport selection. + fn start_server(&mut self) -> Result<(), NsignerError> { + let listen_mode = if self.transport_toggles[0] { + ListenMode::Unix + } else if self.transport_toggles[1] { + ListenMode::Qrexec + } else if self.transport_toggles[2] { + ListenMode::Tcp + } else if self.transport_toggles[3] { + ListenMode::Http + } else { + ListenMode::Unix + }; + let mut server = ServerContext::new(&self.socket_name, listen_mode, self.auth_mode); + server.start()?; + self.server = Some(server); + Ok(()) + } + + /// Handle a single key event. + fn handle_key(&mut self, key: KeyEvent) { + match self.screen { + Screen::Unlock => self.handle_unlock_key(key), + Screen::GenerateMnemonic => self.handle_generate_key(key), + Screen::RoleWizard => self.handle_wizard_key(key), + Screen::TransportSelection => self.handle_transport_key(key), + Screen::Main | Screen::Connections => self.handle_main_key(key), + Screen::Lock => self.handle_lock_key(key), + } + } + + // ── Unlock ────────────────────────────────────────────────────────── + + fn handle_unlock_key(&mut self, key: KeyEvent) { + if edit_key(&mut self.unlock_input, key) { + return; + } + if key.code == KeyCode::Enter { + let input = self.unlock_input.buffer.trim().to_string(); + self.unlock_input = InputField::new(""); + if input == "g" || input == "G" { + match self.mnemonic.generate(12) { + Ok(phrase) => { + self.generated_phrase = phrase; + self.screen = Screen::GenerateMnemonic; + } + Err(_) => { + self.unlock_attempts += 1; + } + } + } else if matches!(input.as_str(), "q" | "Q" | "x" | "X") { + self.should_quit = true; + } else if !input.is_empty() { + match self.mnemonic.load(&input) { + Ok(()) => { + self.derived_count = + match self.key_store.derive_all(&mut self.role_table, &self.mnemonic) { + Ok(n) => n, + Err(_) => 0, + }; + self.screen = Screen::RoleWizard; + } + Err(_) => { + self.unlock_attempts += 1; + if self.unlock_attempts >= 10 { + self.should_quit = true; + } + } } } - _ => {} } } -} -// ──────────────────────────────────────────────────────────────────────────── -// Terminal init / cleanup -// ──────────────────────────────────────────────────────────────────────────── - -/// Initialize the terminal for TUI mode (raw mode + clear). -pub fn init() -> std::io::Result<()> { - tui_continuous::init(); - Ok(()) -} - -/// Restore the terminal to normal mode. -pub fn cleanup() -> std::io::Result<()> { - tui_continuous::cleanup(); - Ok(()) -} - -// ──────────────────────────────────────────────────────────────────────────── -// Main menu items (matches C g_main_menu_items exactly) -// ──────────────────────────────────────────────────────────────────────────── - -/// The main menu items, matching the C `g_main_menu_items` exactly. -pub static MAIN_MENU_ITEMS: [TuiMenuItem; 4] = [ - TuiMenuItem { - label: "^_l^: lock/reunlock", - shortcut: 'l', - }, - TuiMenuItem { - label: "^_r^: refresh", - shortcut: 'r', - }, - TuiMenuItem { - label: "^_d^: display connections", - shortcut: 'd', - }, - TuiMenuItem { - label: "^_q^:/x quit", - shortcut: 'q', - }, -]; - -/// Build the main menu reference. -pub fn main_menu() -> TuiMenu<'static> { - TuiMenu { - items: &MAIN_MENU_ITEMS, + fn handle_generate_key(&mut self, key: KeyEvent) { + if key.code == KeyCode::Enter { + self.derived_count = + match self.key_store.derive_all(&mut self.role_table, &self.mnemonic) { + Ok(n) => n, + Err(_) => 0, + }; + self.screen = Screen::RoleWizard; + } } -} -// ──────────────────────────────────────────────────────────────────────────── -// Screen rendering (matches C render_status / render_connections exactly) -// ──────────────────────────────────────────────────────────────────────────── + // ── Role wizard ───────────────────────────────────────────────────── -/// The application frame for the main status screen. -fn main_frame() -> TuiFrame { - TuiFrame { - app_name: "nsigner", - app_version: crate::VERSION, - breadcrumb: "> Main Menu", - } -} - -/// The application frame for the connections screen. -fn connections_frame() -> TuiFrame { - TuiFrame { - app_name: "nsigner", - app_version: crate::VERSION, - breadcrumb: "> Connection Instructions", - } -} - -/// The application frame for the approval screen. -fn approval_frame() -> TuiFrame { - TuiFrame { - app_name: "nsigner", - app_version: crate::VERSION, - breadcrumb: "> Approval", - } -} - -/// Render the status screen (roles table, activity log, status line, menu). -/// -/// Matches the C `render_status()` layout exactly: -/// 1. Clear continuous + top frame -/// 2. "Roles" heading + table -/// 3. "Activity (latest first)" heading + log entries -/// 4. Status line (session/words/signer/derived) -/// 5. Menu items -/// 6. Anchor prompt -pub fn render_status( - role_table: &RoleTable, - mnemonic: &crate::mnemonic::MnemonicState, - derived_count: usize, - socket_name: &str, - activity_log: &ActivityLog, -) { - let size = tui_continuous::terminal_size(); - let frame = main_frame(); - let menu = main_menu(); - let left_col: u16 = 0; // C uses left_col = 0 for render_status - - tui_continuous::clear_continuous(size.height); - tui_continuous::render_top_frame(&frame, size.width); - - // Roles heading + table - tui_continuous::print("^*Roles^:"); - if role_table.count() == 0 { - tui_continuous::print("(none)"); - } else { - let columns = [ - TuiColumn { name: "Role", width: 20, right_align: false }, - TuiColumn { name: "Purpose", width: 12, right_align: false }, - TuiColumn { name: "Curve", width: 12, right_align: false }, - TuiColumn { name: "Derivation path", width: 24, right_align: false }, - ]; - - let get_cell = |row: usize, col: usize| -> String { - let entry = &role_table.entries[row]; - match col { - 0 => entry.name.clone(), - 1 => entry.purpose_str.to_string(), - 2 => entry.curve_str.to_string(), - 3 => entry.role_path.clone(), - _ => String::new(), + fn handle_wizard_key(&mut self, key: KeyEvent) { + match self.wizard_stage { + WizardStage::SelectPreset => self.wizard_key_select(key), + WizardStage::RoleName => { + if edit_key(&mut self.wizard_role_name, key) { + return; + } + if key.code == KeyCode::Enter { + self.wizard_after_name(); + } } - }; - - let table = TuiTable { - columns: &columns, - row_count: role_table.count(), - get_cell: &get_cell, - is_default: None, - prefix_len: None, - }; - tui_continuous::render_table(&table); - } - - // Activity log - tui_continuous::print(""); - tui_continuous::print("^*Activity (latest first)^:"); - let entries = activity_log.entries(); - if entries.is_empty() { - tui_continuous::print("(none)"); - } else { - for line in entries { - tui_continuous::print(line); - } - } - - // Status line - tui_continuous::print(""); - let session = if mnemonic.is_loaded() { "unlocked" } else { "locked" }; - let word_count = mnemonic.word_count(); - let status = format!( - "session={} ({} words) signer={} derived={}", - session, word_count, socket_name, derived_count - ); - tui_continuous::print(&status); - - // Menu - tui_continuous::print(""); - tui_continuous::render_menu(&menu, left_col); - - // Anchor prompt at bottom - tui_continuous::anchor_prompt(0, left_col); - let _ = std::io::stdout().flush(); -} - -/// Render the connection instructions display (press 'd'). -/// -/// Matches the C `render_connections()` layout: full screen clear, -/// top frame, then transport blocks with title/connection/example/extra. -pub fn render_connections( - role_table: &RoleTable, - mnemonic: &crate::mnemonic::MnemonicState, - derived_count: usize, - socket_name: &str, -) { - let size = tui_continuous::terminal_size(); - let frame = connections_frame(); - - // Full screen clear (not clear_continuous) — connections may exceed terminal height - tui_continuous::clear_full_screen(); - tui_continuous::render_top_frame(&frame, size.width); - tui_continuous::print(""); - - // Unix socket - tui_continuous::print("^*Unix socket^:"); - tui_continuous::print(""); - tui_continuous::print(&format!(" @{}", socket_name)); - tui_continuous::print(""); - tui_continuous::print(" Example:"); - tui_continuous::print(&format!( - " nsigner client --socket {} '{{\"id\":\"1\",\"method\":\"get_info\",\"params\":[]}}'", - socket_name - )); - tui_continuous::print(""); - - // HTTP (if applicable) - tui_continuous::print("^*HTTP^:"); - tui_continuous::print(""); - tui_continuous::print(" curl -X POST http://127.0.0.1:8080/ \\"); - tui_continuous::print(" -H 'Content-Type: application/json' \\"); - tui_continuous::print(" -d '{\"id\":\"1\",\"method\":\"get_info\",\"params\":[]}'"); - tui_continuous::print(""); - - // Example: get_public_key - if let Some(main) = role_table.get_default() { - tui_continuous::print("^*Example — get public key for 'main' role^:"); - tui_continuous::print(""); - tui_continuous::print(&format!( - " nsigner client --socket {} '{{\"id\":\"1\",\"method\":\"nostr_get_public_key\",\"params\":[],\"options\":{{\"role\":\"{}\",\"role_path\":\"{}\"}}}}'", - socket_name, main.name, main.role_path - )); - tui_continuous::print(""); - } - - // Status line - let session = if mnemonic.is_loaded() { "unlocked" } else { "locked" }; - let status = format!( - "session={} ({} words) signer={} derived={}", - session, - mnemonic.word_count(), - socket_name, - derived_count - ); - tui_continuous::print(&status); - tui_continuous::print(""); - tui_continuous::print("Press any key to return"); - - tui_continuous::anchor_prompt(0, 0); - let _ = std::io::stdout().flush(); -} - -/// Interactive approval prompt. -/// -/// Returns the policy decision: -/// - "y" → Allow once -/// - "n" → Deny -/// - "e" → Allow this caller+role+verb for session -/// - "a" → Allow this caller+role for session (all verbs) -/// -/// Matches the C `tui_approval_cb()` layout exactly. -pub fn approval_prompt( - caller_id: &str, - method: &str, - role_name: &str, - purpose: &str, -) -> PolicyResult { - // --allow-all: auto-approve without prompting - if prompt_always_allow() { - return PolicyResult::Allow; - } - - let frame = approval_frame(); - tui_continuous::render_content_screen(&frame, Some("Approval required")); - - tui_continuous::print(&format!("caller: {}", caller_id)); - tui_continuous::print(&format!("method: {}", method)); - tui_continuous::print(&format!("role: {}", role_name)); - tui_continuous::print(&format!("purpose: {}", purpose)); - tui_continuous::print(""); - tui_continuous::print("^_y^: allow once"); - tui_continuous::print("^_n^: deny"); - tui_continuous::print("^_e^: allow this caller+role+verb for session"); - tui_continuous::print("^_a^: allow this caller+role for session (all verbs)"); - - let mut stdout = std::io::stdout(); - let _ = write!(stdout, "> "); - let _ = stdout.flush(); - - let input = match read_line_raw() { - Ok(s) => s, - Err(_) => return PolicyResult::Deny, - }; - - match input.trim().to_lowercase().as_str() { - "a" => PolicyResult::AllowSessionAll, - "e" => PolicyResult::AllowSessionVerb, - "y" => PolicyResult::Allow, - _ => PolicyResult::Deny, - } -} - -// ──────────────────────────────────────────────────────────────────────────── -// Role wizard (cooked mode — uses normal read_line) -// ──────────────────────────────────────────────────────────────────────────── - -/// Interactive role wizard — loop to add multiple roles, matching the C -/// `prompt_named_path_roles` which uses `for(;;)` to let you add roles -/// one at a time until you select "Done". -pub fn role_wizard( - role_table: &mut crate::role_table::RoleTable, -) -> Result<(), crate::NsignerError> { - use crate::role_table::*; - - let frame = crate::tui_continuous::TuiFrame { - app_name: "nsigner", - app_version: crate::VERSION, - breadcrumb: "> Role Configuration", - }; - - // First iteration: if no roles yet, register default "main" automatically - if role_table.count() == 0 { - register_default(role_table)?; - } - - loop { - crate::tui_continuous::render_content_screen( - &frame, - Some("Define a role — bind a role name to a derivation path template"), - ); - - // Show currently configured roles - if role_table.count() > 0 { - crate::tui_continuous::print("^*Current roles^:"); - for entry in &role_table.entries { - crate::tui_continuous::print(&format!( - " {} — {} ({})", - entry.name, entry.role_path, entry.curve_str - )); + WizardStage::CurveSelect => self.wizard_key_curve(key), + WizardStage::PathEntry => { + if edit_key(&mut self.wizard_path, key) { + return; + } + if key.code == KeyCode::Enter { + self.wizard_after_path(); + } + } + WizardStage::OtpDir => { + if edit_key(&mut self.wizard_otp_dir, key) { + return; + } + if key.code == KeyCode::Enter { + let dir = self.wizard_otp_dir.buffer.trim().to_string(); + if dir.is_empty() { + self.wizard_stage = WizardStage::SelectPreset; + } else { + self.wizard_stage = WizardStage::OtpName; + } + } + } + WizardStage::OtpName => { + if edit_key(&mut self.wizard_otp_name, key) { + return; + } + if key.code == KeyCode::Enter { + self.wizard_register_otp(); + } + } + WizardStage::ConfirmRegister => { + if key.code == KeyCode::Char('y') || key.code == KeyCode::Char('Y') { + // Define another role + self.wizard_reset_for_new(); + self.wizard_stage = WizardStage::SelectPreset; + } else if key.code == KeyCode::Enter + || key.code == KeyCode::Char('n') + || key.code == KeyCode::Char('N') + { + if self.wizard_roles_created == 0 { + // Mandatory at least one role — stay in wizard + self.wizard_reset_for_new(); + self.wizard_stage = WizardStage::SelectPreset; + } else { + self.screen = Screen::TransportSelection; + } + } } - crate::tui_continuous::print(""); } + } - crate::tui_continuous::print("Add a role:"); - crate::tui_continuous::print(" [1] Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0"); - crate::tui_continuous::print(" [2] Nostr range: secp256k1, m/44'/1237'/*'/0/0"); - crate::tui_continuous::print(" [3] Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'"); - crate::tui_continuous::print(" [4] SSH role: ed25519, m/44'/102001'/0'/0'/0'"); - crate::tui_continuous::print(" [5] Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'"); - crate::tui_continuous::print(" [6] ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'"); - crate::tui_continuous::print(" [7] SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'"); - crate::tui_continuous::print(" [8] ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'"); - crate::tui_continuous::print(" [9] Custom path"); - crate::tui_continuous::print(" [0] Done — finish role configuration"); - crate::tui_continuous::print(""); - print!(" Select: "); - let _ = std::io::stdout().flush(); - - let mut input = String::new(); - if std::io::stdin().read_line(&mut input).is_err() { - break; + fn wizard_key_select(&mut self, key: KeyEvent) { + if let KeyCode::Char(c) = key.code { + if let Some(d) = c.to_digit(10) { + if d >= 1 && d <= 10 { + self.wizard_choice = d as i32; + self.wizard_apply_preset(); + // OTP goes straight to pad dir; custom goes to curve; others go to name + if self.wizard_choice == 9 { + self.wizard_stage = WizardStage::OtpDir; + } else if self.wizard_choice == 10 { + self.wizard_stage = WizardStage::CurveSelect; + } else { + self.wizard_role_name = InputField::new(&self.wizard_default_name); + self.wizard_stage = WizardStage::RoleName; + } + } + } } + } - let choice = input.trim(); - - // Done / empty → finish - if choice == "0" || choice.is_empty() { - break; + fn wizard_key_curve(&mut self, key: KeyEvent) { + if let KeyCode::Char(c) = key.code { + if let Some(d) = c.to_digit(10) { + if d >= 1 && d <= 6 { + self.wizard_custom_curve(d as i32); + self.wizard_path = InputField::new(&self.wizard_default_path); + self.wizard_stage = WizardStage::PathEntry; + } + } } + } - // Preset definitions: (default_name, default_path, curve_str, purpose) - let preset: Option<(&str, &str, &str, RolePurpose)> = match choice { - "1" => Some(("main", "m/44'/1237'/0'/0/0", "secp256k1", RolePurpose::Nostr)), - "2" => Some(("nostr_range", "m/44'/1237'/*'/0/0", "secp256k1", RolePurpose::Nostr)), - "3" => Some(("nostr_agent", "m/44'/1237'/*'/1'/0'", "secp256k1", RolePurpose::Nostr)), - "4" => Some(("ssh", "m/44'/102001'/0'/0'/0'", "ed25519", RolePurpose::Ssh)), - "5" => Some(("age", "m/44'/102002'/0'/0'/0'", "x25519", RolePurpose::Age)), - "6" => Some(("ml_dsa_65", "m/44'/102003'/0'/0'/0'", "ml-dsa-65", RolePurpose::PqSig)), - "7" => Some(("slh_dsa_128s", "m/44'/102004'/0'/0'/0'", "slh-dsa-128s", RolePurpose::PqSig)), - "8" => Some(("ml_kem_768", "m/44'/102005'/0'/0'/0'", "ml-kem-768", RolePurpose::PqKem)), - "9" => None, // Custom + fn wizard_apply_preset(&mut self) { + use crate::role_table::RolePurpose; + let (name, path, curve, purpose) = match self.wizard_choice { + 2 => ( + "nostr_range", + "m/44'/1237'/*'/0/0", + "secp256k1", + RolePurpose::Nostr, + ), + 3 => ( + "nostr_agent", + "m/44'/1237'/*'/1'/0'", + "secp256k1", + RolePurpose::Nostr, + ), + 4 => ( + "ssh", + "m/44'/102001'/0'/0'/0'", + "ed25519", + RolePurpose::Ssh, + ), + 5 => ( + "age", + "m/44'/102002'/0'/0'/0'", + "x25519", + RolePurpose::Age, + ), + 6 => ( + "ml_dsa_65", + "m/44'/102003'/0'/0'/0'", + "ml-dsa-65", + RolePurpose::PqSig, + ), + 7 => ( + "slh_dsa_128s", + "m/44'/102004'/0'/0'/0'", + "slh-dsa-128s", + RolePurpose::PqSig, + ), + 8 => ( + "ml_kem_768", + "m/44'/102005'/0'/0'/0'", + "ml-kem-768", + RolePurpose::PqKem, + ), + 9 => ("otp", "", "otp", RolePurpose::Nostr), + 10 => ( + "custom", + "m/44'/1237'/0'/0/0", + "secp256k1", + RolePurpose::Nostr, + ), + _ => ( + "main", + "m/44'/1237'/0'/0/0", + "secp256k1", + RolePurpose::Nostr, + ), + }; + self.wizard_default_name = name.to_string(); + self.wizard_default_path = path.to_string(); + self.wizard_default_curve = curve.to_string(); + self.wizard_default_purpose = purpose; + } + + fn wizard_custom_curve(&mut self, cchoice: i32) { + use crate::role_table::RolePurpose; + match cchoice { + 2 => { + self.wizard_default_curve = "ed25519".into(); + self.wizard_default_path = "m/44'/102001'/0'/0'/0'".into(); + self.wizard_default_purpose = RolePurpose::Ssh; + } + 3 => { + self.wizard_default_curve = "x25519".into(); + self.wizard_default_path = "m/44'/102002'/0'/0'/0'".into(); + self.wizard_default_purpose = RolePurpose::Age; + } + 4 => { + self.wizard_default_curve = "ml-dsa-65".into(); + self.wizard_default_path = "m/44'/102003'/0'/0'/0'".into(); + self.wizard_default_purpose = RolePurpose::PqSig; + } + 5 => { + self.wizard_default_curve = "slh-dsa-128s".into(); + self.wizard_default_path = "m/44'/102004'/0'/0'/0'".into(); + self.wizard_default_purpose = RolePurpose::PqSig; + } + 6 => { + self.wizard_default_curve = "ml-kem-768".into(); + self.wizard_default_path = "m/44'/102005'/0'/0'/0'".into(); + self.wizard_default_purpose = RolePurpose::PqKem; + } _ => { - crate::tui_continuous::print("Invalid selection, try again."); - continue; + self.wizard_default_curve = "secp256k1".into(); + self.wizard_default_path = "m/44'/1237'/0'/0/0".into(); + self.wizard_default_purpose = RolePurpose::Nostr; + } + } + } + + fn wizard_after_name(&mut self) { + let name = { + let s = self.wizard_role_name.buffer.trim().to_string(); + if s.is_empty() { + self.wizard_default_name.clone() + } else { + s } }; + if self.role_table.find_by_name(&name).is_some() { + // Duplicate — back to preset + self.wizard_stage = WizardStage::SelectPreset; + return; + } + // Store the chosen name for registration + self.wizard_role_name = InputField::new(&name); + if self.wizard_choice == 10 { + self.wizard_stage = WizardStage::PathEntry; + } else { + self.wizard_register_path(&name); + } + } - if let Some((default_name, default_path, curve_str, purpose)) = preset { - // Prompt for role name with default - let mut name = String::new(); - print!(" Role name [{}]: ", default_name); - let _ = std::io::stdout().flush(); - if std::io::stdin().read_line(&mut name).is_err() { - break; + fn wizard_after_path(&mut self) { + let path = { + let s = self.wizard_path.buffer.trim().to_string(); + if s.is_empty() { + self.wizard_default_path.clone() + } else { + s } - let name = name.trim().to_string(); - let name = if name.is_empty() { default_name.to_string() } else { name }; + }; + let name = self.wizard_role_name.buffer.trim().to_string(); + self.wizard_register_path_with(name, &path); + } - // Check for duplicate - if role_table.find_by_name(&name).is_some() { - crate::tui_continuous::print(&format!(" Role '{}' already exists, skipping.", name)); - continue; - } + /// Register a path-based role (presets and custom). + fn wizard_register_path(&mut self, name: &str) { + let path = self.wizard_default_path.clone(); + self.wizard_register_path_with(name.to_string(), &path); + } - // Resolve curve - let curve = RoleCurve::from_str(curve_str); - if curve == RoleCurve::Unknown { - crate::tui_continuous::print(" Invalid curve, skipping."); - continue; - } - - // Parse path template - let (template, range_lo, range_hi, allowed_indices) = - crate::role_table::parse_path_template(default_path) - .map_err(|_| crate::NsignerError::InvalidInput)?; - - role_table - .register_role_path( + fn wizard_register_path_with(&mut self, name: String, path: &str) { + let curve = crate::role_table::RoleCurve::from_str(&self.wizard_default_curve); + let purpose = self.wizard_default_purpose; + match crate::role_table::parse_path_template(path) { + Ok((template, range_lo, range_hi, allowed_indices)) => { + match self.role_table.register_role_path( &name, &template, purpose, @@ -648,141 +813,585 @@ pub fn role_wizard( range_hi, -1, &allowed_indices, - ) - .map_err(|e| crate::NsignerError::Internal(e.to_string()))?; - - crate::tui_continuous::print(&format!(" Added role '{}'.", name)); - } else { - // Custom role entry - match custom_role_entry(role_table) { - Ok(()) => { - crate::tui_continuous::print(" Custom role added."); - } - Err(e) => { - crate::tui_continuous::print(&format!(" Error: {}, try again.", e)); + ) { + Ok(()) => { + self.wizard_roles_created += 1; + self.activity_log + .add(&format!("role '{}' registered", name)); + } + Err(_) => {} } + self.wizard_stage = WizardStage::ConfirmRegister; + } + Err(_) => { + // Invalid path — back to path entry + self.wizard_path = InputField::new(path); + self.wizard_stage = WizardStage::PathEntry; } } } - // Final summary - crate::tui_continuous::print(""); - crate::tui_continuous::print(&format!("Configured {} role(s):", role_table.count())); - for entry in &role_table.entries { - crate::tui_continuous::print(&format!( - " {} — {} ({})", - entry.name, entry.role_path, entry.curve_str + fn wizard_register_otp(&mut self) { + let name = self.wizard_role_name.buffer.trim().to_string(); + let dir = self.wizard_otp_dir.buffer.trim().to_string(); + let spec = self.wizard_otp_name.buffer.trim().to_string(); + if dir.is_empty() || spec.is_empty() { + self.wizard_stage = WizardStage::SelectPreset; + return; + } + match crate::otp_pad::bind_global(&dir, &spec, false) { + Ok(()) => { + let mut entry = crate::role_table::RoleEntry::default(); + entry.name = name.clone(); + entry.purpose_str = "nostr".to_string(); + entry.curve_str = "otp".to_string(); + entry.purpose = RolePurpose::Nostr; + entry.curve = RoleCurve::Unknown; + entry.selector_type = RoleSelectorType::RolePath; + entry.role_path = String::new(); + entry.nostr_index = -1; + entry.path_range_lo = -1; + entry.path_range_hi = -1; + entry.path_default_index = -1; + entry.requires_approval = false; + if self.role_table.add(entry).is_ok() { + self.wizard_roles_created += 1; + self.activity_log + .add(&format!("otp role '{}' registered", name)); + } + } + Err(_) => {} + } + self.wizard_stage = WizardStage::ConfirmRegister; + } + + fn wizard_reset_for_new(&mut self) { + self.wizard_choice = 1; + self.wizard_default_name = "main".to_string(); + self.wizard_default_path = "m/44'/1237'/0'/0/0".to_string(); + self.wizard_default_curve = "secp256k1".to_string(); + self.wizard_default_purpose = RolePurpose::Nostr; + self.wizard_role_name = InputField::new("main"); + self.wizard_path = InputField::new("m/44'/1237'/0'/0/0"); + self.wizard_otp_dir = InputField::new(""); + self.wizard_otp_name = InputField::new(""); + } + + // ── Transport selection ───────────────────────────────────────────── + + fn handle_transport_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('a') | KeyCode::Char('A') => { + self.transport_toggles = [true, true, true, true]; + } + KeyCode::Char('1') => self.transport_toggles[0] = !self.transport_toggles[0], + KeyCode::Char('2') => self.transport_toggles[1] = !self.transport_toggles[1], + KeyCode::Char('3') => self.transport_toggles[2] = !self.transport_toggles[2], + KeyCode::Char('4') => self.transport_toggles[3] = !self.transport_toggles[3], + KeyCode::Enter => { + let any = self.transport_toggles.iter().any(|&b| b); + if any { + self.transport_mask_apply(); + match self.start_server() { + Ok(()) => { + self.activity_log.add("nsigner started"); + self.screen = Screen::Main; + } + Err(e) => { + self.activity_log + .add(&format!("server start failed: {}", e)); + // Stay on transport screen + } + } + } + } + _ => {} + } + } + + fn transport_mask_apply(&mut self) { + let mask = self.transport_toggles.iter().enumerate().fold(0u8, |acc, (i, &on)| { + if on { + acc | (1u8 << i) + } else { + acc + } + }); + self.transport_mask = mask; + } + + // ── Main screen ───────────────────────────────────────────────────── + + fn handle_main_key(&mut self, key: KeyEvent) { + // If the connections sub-screen is showing, any key returns to main. + if self.screen == Screen::Connections { + self.screen = Screen::Main; + return; + } + match key.code { + KeyCode::Char('l') | KeyCode::Char('L') => { + // Lock: wipe keys, unload mnemonic + self.key_store.wipe(); + self.alg_key_cache.wipe(); + self.mnemonic.unload(); + self.unlock_input = InputField::new(""); + self.unlock_attempts = 0; + self.activity_log.add("session locked"); + self.screen = Screen::Lock; + } + KeyCode::Char('r') | KeyCode::Char('R') => { + // Refresh is implicit (redraw each loop). + } + KeyCode::Char('d') | KeyCode::Char('D') => { + self.screen = Screen::Connections; + } + KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Char('x') | KeyCode::Char('X') => { + self.should_quit = true; + } + KeyCode::Esc => self.should_quit = true, + KeyCode::Up | KeyCode::Char('k') => { + if self.activity_scroll > 0 { + self.activity_scroll -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + self.activity_scroll += 1; + } + _ => {} + } + } + + // ── Lock ───────────────────────────────────────────────────────────── + + fn handle_lock_key(&mut self, key: KeyEvent) { + if edit_key(&mut self.unlock_input, key) { + return; + } + if key.code == KeyCode::Enter { + let input = self.unlock_input.buffer.trim().to_string(); + if input == "q" || input == "Q" || input == "x" || input == "X" { + self.should_quit = true; + return; + } + match self.mnemonic.load(&input) { + Ok(()) => { + match self.key_store.derive_all(&mut self.role_table, &self.mnemonic) { + Ok(n) => { + self.derived_count = n; + self.activity_log.add("session re-unlocked"); + self.screen = Screen::Main; + } + Err(_) => { + self.screen = Screen::Main; + } + } + } + Err(_) => { + self.unlock_attempts += 1; + self.unlock_input = InputField::new(""); + if self.unlock_attempts >= 10 { + self.should_quit = true; + } + } + } + } + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Drawing +// ──────────────────────────────────────────────────────────────────────────── + +impl App { + /// Draw the current screen. + fn draw(&mut self, f: &mut Frame) { + match self.screen { + Screen::Unlock => self.draw_unlock(f), + Screen::GenerateMnemonic => self.draw_generate(f), + Screen::RoleWizard => self.draw_wizard(f), + Screen::TransportSelection => self.draw_transport(f), + // Connections and Lock are contained within the main menu screen. + Screen::Main | Screen::Connections | Screen::Lock => self.draw_main(f), + } + } + + /// A key binding span: key letter bold/underlined, rest normal. + fn key_span(key: &str, rest: &str) -> Span<'static> { + Span::styled( + format!("{}{}", key, rest), + Style::default(), + ) + } + + /// Draw the main status display. + /// + /// The main menu frame (title bar + commands) is always drawn. The body + /// shows the two-column layout, or the connections / lock content in the + /// right column when those sub-screens are active. + fn draw_main(&mut self, f: &mut Frame) { + let area = f.area(); + + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + + // Title bar (breadcrumb reflects the active sub-screen) + let sub = match self.screen { + Screen::Connections => " > Connection Instructions", + Screen::Lock => " > Lock", + _ => "", + }; + let title = format!(" signer {} > Main Menu{} ", crate::VERSION, sub); + f.render_widget(Block::default().borders(Borders::ALL).title(title), outer[0]); + + // Body: left column / right column + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(outer[1]); + + let left = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(8), Constraint::Min(0)]) + .split(body[0]); + + // ── Section 0: Information ────────────────────────────── + let session = if self.mnemonic.is_loaded() { "unlocked" } else { "locked" }; + let otp_status = crate::otp_pad::global_status(); + let mut info_lines = vec![ + Line::from(format!("session={} ({} words)", session, self.mnemonic.word_count())), + Line::from(format!("signer={} derived={}", self.socket_name, self.derived_count)), + Line::from(format!("socket=@{}", self.socket_name)), + Line::from(format!( + "transport={}", + transport_str(self.transport_mask) + )), + ]; + if !otp_status.is_empty() { + info_lines.push(Line::from(otp_status)); + } + let info = Paragraph::new(info_lines) + .block(Block::default().borders(Borders::ALL).title(" Information ")); + f.render_widget(info, left[0]); + + // ── Section 1: Roles ──────────────────────────────────── + let header = Row::new(vec!["Role", "Purpose", "Curve", "Derivation path"]) + .style(Style::default().add_modifier(Modifier::BOLD)); + let rows: Vec<Row> = self + .role_table + .entries + .iter() + .map(|e| { + Row::new(vec![ + e.name.clone(), + e.purpose_str.clone(), + e.curve_str.clone(), + e.display_path(), + ]) + }) + .collect(); + let widths = [ + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(20), + Constraint::Percentage(45), + ]; + let table = Table::new(rows, widths) + .header(header) + .block(Block::default().borders(Borders::ALL).title(" Roles ")); + f.render_widget(table, left[1]); + + // ── Right column ──────────────────────────────────────── + // If a sub-screen (connections / lock) is active, render its content + // in the right column instead of the activity list. + if self.screen == Screen::Connections { + self.draw_connections_in(body[1], f); + } else if self.screen == Screen::Lock { + self.draw_lock_in(body[1], f); + } else { + // ── Section 2: Activity (scrollable) ───────────────── + // Use a Paragraph (not a List) so entries fill the area from the + // top and only scroll when content overflows. A List with a + // selected item centers the selection, leaving the lower half + // empty until scrolling starts. + let activity_block = Block::default() + .borders(Borders::ALL) + .title(" Activity (latest first) "); + let entries: Vec<Line> = self + .activity_log + .entries() + .iter() + .map(|s| Line::from(s.to_string())) + .collect(); + let items = Paragraph::new(entries).block(activity_block); + + // Inner area (inside the block borders) height in rows. + let inner_height = body[1].height.saturating_sub(2) as usize; + let entry_count = self.activity_log.entries().len(); + let max_scroll = entry_count.saturating_sub(inner_height); + if self.activity_scroll > max_scroll { + self.activity_scroll = max_scroll; + } + // Only shift the content when the user has scrolled; at scroll=0 + // the Paragraph fills its area from the top. + let scroll_y = if self.activity_scroll > 0 { + self.activity_scroll as u16 + } else { + 0 + }; + f.render_widget(items.scroll((scroll_y, 0)), body[1]); + + let mut scrollbar_state = + ScrollbarState::new(entry_count).position(self.activity_scroll); + let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + f.render_stateful_widget(sb, body[1], &mut scrollbar_state); + } + + // ── Section 3: Commands ───────────────────────────────── + let cmd_line = Line::from(vec![ + App::key_span("l", " lock/reunlock "), + App::key_span("r", " refresh "), + App::key_span("d", " display connections "), + App::key_span("q", "/x quit"), + ]); + f.render_widget( + Paragraph::new(cmd_line).block(Block::default().borders(Borders::ALL).title(" Commands ")), + outer[2], + ); + } + + /// Draw connection instructions in a given area (used inside the main menu). + fn draw_connections_in(&self, area: ratatui::layout::Rect, f: &mut Frame) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Connections "); + let mut text = vec![Line::from("")]; + + if self.transport_mask & TRANSPORT_UNIX != 0 { + text.push(Line::from("Unix socket:")); + text.push(Line::from(format!(" @{}", self.socket_name))); + text.push(Line::from(" Example:")); + text.push(Line::from(format!( + " nsigner --socket-name {} client '{{\"id\":\"1\",\"method\":\"get_info\",\"params\":[]}}'", + self.socket_name + ))); + text.push(Line::from("")); + } + if self.transport_mask & TRANSPORT_QREXEC_BRIDGE != 0 { + text.push(Line::from("Qrexec:")); + text.push(Line::from(" qrexec (one request per invocation)")); + text.push(Line::from("")); + } + if self.transport_mask & TRANSPORT_TCP != 0 { + text.push(Line::from("FIPS:")); + text.push(Line::from(" tcp:[::]:11111")); + text.push(Line::from(" Example:")); + text.push(Line::from(" nsigner --listen tcp:[::]:11111 client '<json>'")); + text.push(Line::from("")); + } + if self.transport_mask & TRANSPORT_HTTP != 0 { + text.push(Line::from("HTTP:")); + text.push(Line::from(" http://127.0.0.1:8080")); + text.push(Line::from(" Example:")); + text.push(Line::from(" curl -X POST http://127.0.0.1:8080/ \\")); + text.push(Line::from(" -H 'Content-Type: application/json' \\")); + text.push(Line::from(" -d '{\"id\":\"1\",\"method\":\"get_info\",\"params\":[]}'")); + text.push(Line::from("")); + } + + let otp_status = crate::otp_pad::global_status(); + if !otp_status.is_empty() { + text.push(Line::from(otp_status)); + text.push(Line::from("")); + } + + text.push(Line::from("Press any key to return")); + f.render_widget(Paragraph::new(text).block(block), area); + } + + /// Draw the lock prompt in a given area (used inside the main menu). + fn draw_lock_in(&mut self, area: ratatui::layout::Rect, f: &mut Frame) { + let block = Block::default().borders(Borders::ALL).title(" Lock "); + let mut text = vec![ + Line::from("Session locked. Re-enter mnemonic to unlock."), + Line::from(""), + Line::from(Span::styled( + format!("> {}", self.unlock_input.buffer), + Style::default(), + )), + ]; + if self.unlock_attempts > 0 { + text.push(Line::from(format!( + "Invalid mnemonic. Attempts: {}/10", + self.unlock_attempts + ))); + } + let cursor_row = text.len().saturating_sub(1) as u16; + f.render_widget(Paragraph::new(text).block(block), area); + let cursor_col = 2 + self.unlock_input.cursor as u16; + f.set_cursor_position(( + cursor_col.min(area.width.saturating_sub(1)), + cursor_row.min(area.height.saturating_sub(1)), )); } - Ok(()) -} - -/// Register the default "main" Nostr role. -fn register_default(role_table: &mut crate::role_table::RoleTable) -> Result<(), crate::NsignerError> { - use crate::role_table::*; - role_table.register_role_path( - "main", - "m/44'/1237'/0'/0/0", - RolePurpose::Nostr, - RoleCurve::Secp256k1, - -1, -1, -1, &[], - )?; - Ok(()) -} - -/// Custom role entry — prompt user for name, path, purpose, curve. -fn custom_role_entry( - role_table: &mut crate::role_table::RoleTable, -) -> Result<(), crate::NsignerError> { - use crate::role_table::*; - - print!(" Role name: "); - let _ = std::io::stdout().flush(); - let mut name = String::new(); - std::io::stdin().read_line(&mut name).map_err(|e| crate::NsignerError::IoFailed(e.to_string()))?; - let name = name.trim().to_string(); - if name.is_empty() { - return Err(crate::NsignerError::InvalidInput); + /// Draw the unlock screen. + fn draw_unlock(&mut self, f: &mut Frame) { + let area = f.area(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" signer {} > Unlock ", crate::VERSION)); + let mut text = vec![ + Line::from("Mnemonic source: [E]nter existing or [G]enerate new"), + Line::from("Default is E; you can also paste full mnemonic here."), + Line::from(""), + ]; + if self.unlock_attempts > 0 { + text.push(Line::from(format!( + "Invalid mnemonic (must be 12/15/18/21/24 words). Attempts: {}/10", + self.unlock_attempts + ))); + text.push(Line::from("")); + } + text.push(Line::from("")); + text.push(Line::from(Span::styled( + format!("> {}", self.unlock_input.buffer), + Style::default(), + ))); + let cursor_row = (text.len().saturating_sub(1)) as u16; + f.render_widget(Paragraph::new(text).block(block), area); + let cursor_col = 2 + self.unlock_input.cursor as u16; + f.set_cursor_position(( + cursor_col.min(area.width.saturating_sub(1)), + cursor_row.min(area.height.saturating_sub(1)), + )); } - print!(" Derivation path (e.g. m/44'/1237'/0'/0/0): "); - let _ = std::io::stdout().flush(); - let mut path = String::new(); - std::io::stdin().read_line(&mut path).map_err(|e| crate::NsignerError::IoFailed(e.to_string()))?; - let path = path.trim().to_string(); - if path.is_empty() { - return Err(crate::NsignerError::InvalidInput); + /// Draw the generated-mnemonic screen. + fn draw_generate(&mut self, f: &mut Frame) { + let area = f.area(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" signer {} > Generated Mnemonic ", crate::VERSION)); + let mut text = vec![ + Line::from("Generated mnemonic (WRITE THIS DOWN - it will not be shown again):"), + Line::from(""), + ]; + for (i, word) in self.generated_phrase.split_whitespace().enumerate() { + text.push(Line::from(format!("{:2}. {}", i + 1, word))); + } + text.push(Line::from("")); + text.push(Line::from("Press Enter after writing down your mnemonic to continue.")); + f.render_widget(Paragraph::new(text).block(block), area); } - let purpose = purpose_from_path(&path); - let curve = match purpose { - RolePurpose::Nostr | RolePurpose::Bitcoin => RoleCurve::Secp256k1, - RolePurpose::Ssh => RoleCurve::Ed25519, - RolePurpose::Age => RoleCurve::X25519, - RolePurpose::PqSig => RoleCurve::MlDsa65, - RolePurpose::PqKem => RoleCurve::MlKem768, - _ => RoleCurve::Secp256k1, - }; + /// Draw the role wizard screen. + fn draw_wizard(&mut self, f: &mut Frame) { + let area = f.area(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" signer {} > Role Configuration ", crate::VERSION)); + let mut text: Vec<Line> = vec![ + Line::from(" "), + Line::from(" 1. Standard Nostr (NIP-06): secp256k1, m/44'/1237'/0'/0/0"), + Line::from(" 2. Standard Nostr range: secp256k1, m/44'/1237'/*'/0/0"), + Line::from(" 3. Nostr agent range (hardened): secp256k1, m/44'/1237'/*'/1'/0'"), + Line::from(" 4. SSH role: ed25519, m/44'/102001'/0'/0'/0'"), + Line::from(" 5. Age/x25519 role: x25519, m/44'/102002'/0'/0'/0'"), + Line::from(" 6. ML-DSA-65 role: post-quantum signatures, m/44'/102003'/0'/0'/0'"), + Line::from(" 7. SLH-DSA-128s role: post-quantum signatures, m/44'/102004'/0'/0'/0'"), + Line::from(" 8. ML-KEM-768 role: post-quantum KEM, m/44'/102005'/0'/0'/0'"), + Line::from(" 9. OTP role (one-time pad encryption)"), + Line::from(" 10. Custom path"), + Line::from(""), + ]; - role_table.register_role_path( - &name, - &path, - purpose, - curve, - -1, -1, -1, &[], - )?; + match self.wizard_stage { + WizardStage::SelectPreset => { + text.push(Line::from(Span::styled(" Select [1]: ", Style::default()))); + } + WizardStage::RoleName => { + text.push(Line::from(format!( + " Role name [{}]: {}", + self.wizard_default_name, self.wizard_role_name.buffer + ))); + } + WizardStage::CurveSelect => { + text.push(Line::from(" Curve:")); + text.push(Line::from(" 1) secp256k1 (Nostr, Bitcoin)")); + text.push(Line::from(" 2) ed25519 (SSH)")); + text.push(Line::from(" 3) x25519 (key agreement, Age)")); + text.push(Line::from(" 4) ml-dsa-65 (post-quantum signatures)")); + text.push(Line::from(" 5) slh-dsa-128s (post-quantum signatures)")); + text.push(Line::from(" 6) ml-kem-768 (post-quantum KEM)")); + text.push(Line::from(" Select [1]: ")); + } + WizardStage::PathEntry => { + text.push(Line::from(format!( + " Path template [{}]: {}", + self.wizard_default_path, self.wizard_path.buffer + ))); + } + WizardStage::OtpDir => { + text.push(Line::from(format!( + " OTP pad directory (e.g. /media/usb0): {}", + self.wizard_otp_dir.buffer + ))); + } + WizardStage::OtpName => { + text.push(Line::from(format!( + " OTP pad name (e.g. mypad): {}", + self.wizard_otp_name.buffer + ))); + } + WizardStage::ConfirmRegister => { + text.push(Line::from(format!( + " Role registered ({} role(s) configured).", + self.wizard_roles_created + ))); + text.push(Line::from(" Define another role? [y/N]: ")); + } + } - Ok(()) -} - -/// Interactive transport selection menu. -/// -/// Returns a bitmask of selected transports. -pub fn transport_selection() -> u8 { - let frame = crate::tui_continuous::TuiFrame { - app_name: "nsigner", - app_version: crate::VERSION, - breadcrumb: "> Transport Selection", - }; - crate::tui_continuous::render_content_screen(&frame, Some("Transport selection")); - crate::tui_continuous::print(" [1] Unix socket (default)"); - crate::tui_continuous::print(" [2] TCP"); - crate::tui_continuous::print(" [3] HTTP"); - crate::tui_continuous::print(" [4] Unix + HTTP"); - crate::tui_continuous::print(""); - print!(" Select transport: "); - let _ = std::io::stdout().flush(); - - let mut input = String::new(); - if std::io::stdin().read_line(&mut input).is_err() { - return 0x01; // Unix + f.render_widget(Paragraph::new(text).block(block), area); } - match input.trim() { - "2" => 0x04, // TCP - "3" => 0x08, // HTTP - "4" => 0x09, // Unix + HTTP - _ => 0x01, // Unix (default) + /// Draw the transport selection screen. + fn draw_transport(&mut self, f: &mut Frame) { + let area = f.area(); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" signer {} > Transport Selection ", crate::VERSION)); + let t = self.transport_toggles; + let text = vec![ + Line::from("Select one or more (type a number to toggle, 'a' for all, Enter to confirm):"), + Line::from(""), + Line::from(format!( + " [{}] 1. Local Unix socket (same machine/qube)", + if t[0] { "x" } else { " " } + )), + Line::from(format!( + " [{}] 2. Qubes qrexec bridge (other qubes via qrexec, no network)", + if t[1] { "x" } else { " " } + )), + Line::from(format!( + " [{}] 3. FIPS/TCP listener (framed JSON, FIPS mesh or local network)", + if t[2] { "x" } else { " " } + )), + Line::from(format!( + " [{}] 4. HTTP listener (curl-friendly, localhost by default)", + if t[3] { "x" } else { " " } + )), + Line::from(""), + Line::from(" [a] select all Enter = confirm"), + ]; + f.render_widget(Paragraph::new(text).block(block), area); } } - -/// Prompt for mnemonic phrase input (interactive mode). -pub fn prompt_mnemonic() -> Result<String, crate::NsignerError> { - let frame = crate::tui_continuous::TuiFrame { - app_name: "nsigner", - app_version: crate::VERSION, - breadcrumb: "> Unlock", - }; - crate::tui_continuous::render_content_screen(&frame, Some("Enter mnemonic phrase")); - crate::tui_continuous::print(""); - print!(" > "); - let _ = std::io::stdout().flush(); - - let mut input = String::new(); - std::io::stdin() - .read_line(&mut input) - .map_err(|e| crate::NsignerError::IoFailed(e.to_string()))?; - - Ok(input.trim().to_string()) -} diff --git a/src/tui_continuous.rs b/src/tui_continuous.rs deleted file mode 100644 index de5f3a8..0000000 --- a/src/tui_continuous.rs +++ /dev/null @@ -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(); - } -} diff --git a/tests/integration.rs b/tests/integration.rs index 2c50ab2..a717121 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -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,7 +70,7 @@ fn spawn_server_loop( key_store: &mut key_store, alg_key_cache: &mut alg_cache, }; - match server.handle_one(&mut dispatcher, &mut policy) { + match server.handle_one(&mut dispatcher) { Ok(true) => {} Ok(false) => { std::thread::sleep(Duration::from_millis(10)); @@ -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) {} \ No newline at end of file +fn _unused(_l: UnixListener) {}