From cf1c353ce7720ddb480f31b30d8d42c0c4043403 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Mon, 17 Aug 2026 14:44:36 -0400 Subject: [PATCH] v0.0.1 - Port tui_continuous library to Rust, integrate with signer TUI, add versioning scheme --- .gitignore | 3 + Cargo.lock | 2706 ++++++++++++++++++++++++++++++ Cargo.toml | 66 + increment_and_push.sh | 309 ++++ plans/policy_enforcement_plan.md | 220 +++ plans/port_nsigner_to_rust.md | 385 +++++ plans/port_tui_continuous.md | 155 ++ plans/tui_gap_analysis.md | 150 ++ src/alg_cache.rs | 207 +++ src/auth_envelope.rs | 284 ++++ src/dispatcher.rs | 757 +++++++++ src/enforcement.rs | 191 +++ src/error.rs | 93 + src/http.rs | 180 ++ src/key_store.rs | 479 ++++++ src/lib.rs | 36 + src/main.rs | 680 ++++++++ src/miner.rs | 265 +++ src/mnemonic.rs | 251 +++ src/otp_pad.rs | 332 ++++ src/policy.rs | 461 +++++ src/pq_crypto.rs | 319 ++++ src/pq_drbg.rs | 170 ++ src/role_table.rs | 643 +++++++ src/secure_mem.rs | 290 ++++ src/selector.rs | 223 +++ src/server.rs | 582 +++++++ src/socket_name.rs | 71 + src/transport.rs | 101 ++ src/tui.rs | 788 +++++++++ src/tui_continuous.rs | 915 ++++++++++ tests/integration.rs | 341 ++++ 32 files changed, 12653 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100755 increment_and_push.sh create mode 100644 plans/policy_enforcement_plan.md create mode 100644 plans/port_nsigner_to_rust.md create mode 100644 plans/port_tui_continuous.md create mode 100644 plans/tui_gap_analysis.md create mode 100644 src/alg_cache.rs create mode 100644 src/auth_envelope.rs create mode 100644 src/dispatcher.rs create mode 100644 src/enforcement.rs create mode 100644 src/error.rs create mode 100644 src/http.rs create mode 100644 src/key_store.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/miner.rs create mode 100644 src/mnemonic.rs create mode 100644 src/otp_pad.rs create mode 100644 src/policy.rs create mode 100644 src/pq_crypto.rs create mode 100644 src/pq_drbg.rs create mode 100644 src/role_table.rs create mode 100644 src/secure_mem.rs create mode 100644 src/selector.rs create mode 100644 src/server.rs create mode 100644 src/socket_name.rs create mode 100644 src/transport.rs create mode 100644 src/tui.rs create mode 100644 src/tui_continuous.rs create mode 100644 tests/integration.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce04f2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +*.log +*.tar.gz diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0cb48e2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2706 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "ctutils", + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", +] + +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nostr-core" +version = "0.0.2" +dependencies = [ + "aes", + "base64", + "bech32", + "block-modes", + "chacha20poly1305", + "chrono", + "hex", + "hmac 0.12.1", + "rand", + "secp256k1", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tracing", + "zeroize", +] + +[[package]] +name = "nostr-nips" +version = "0.0.2" +dependencies = [ + "aes", + "block-modes", + "cbc", + "ed25519-dalek", + "hex", + "nostr-core", + "rand", + "reqwest", + "ripemd", + "secp256k1", + "serde", + "serde_json", + "sha1", + "sha2 0.10.9", + "thiserror", + "tracing", +] + +[[package]] +name = "nsigner" +version = "0.0.1" +dependencies = [ + "base64", + "chacha20poly1305", + "clap", + "crossterm", + "ed25519-dalek", + "hex", + "hmac 0.12.1", + "libc", + "ml-dsa", + "ml-kem", + "nostr-core", + "nostr-nips", + "rand", + "rand_core 0.6.4", + "secp256k1", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3 0.10.9", + "slh-dsa", + "tempfile", + "thiserror", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.1", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.1", + "sponge-cursor", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slh-dsa" +version = "0.2.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "371c02fe34044d8866ddf7cb0e8204a87ef31a39f0408bed41c4253ea9dd61ed" +dependencies = [ + "const-oid 0.10.2", + "digest 0.11.3", + "hmac 0.13.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "sha3 0.11.0", + "signature 3.0.0", + "typenum", + "zerocopy", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio 1.2.2", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9b668d7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "nsigner" +version = "0.0.1" +edition = "2021" +license = "MIT" +description = "Attended Nostr signing daemon — Rust port of n_signer" + +[[bin]] +name = "nsigner" +path = "src/main.rs" + +[lib] +name = "nsigner" +path = "src/lib.rs" + +[dependencies] +# nostr_core_lib_rust — local path dependencies +nostr-core = { path = "../nostr_core_lib_rust/core" } +nips = { package = "nostr-nips", path = "../nostr_core_lib_rust/nips" } + +# Crypto +secp256k1 = { version = "0.29", features = ["rand-std", "hashes", "global-context"] } +sha2 = "0.10" +hmac = "0.12" +hex = "0.4" +zeroize = { version = "1", features = ["zeroize_derive"] } +ed25519-dalek = { version = "2", features = ["rand_core"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +rand = "0.8" +rand_core = "0.6" +sha3 = "0.10" +chacha20poly1305 = "0.10" + +# Post-quantum crypto (pure Rust implementations) +ml-dsa = { version = "0.1", features = ["rand_core"] } +ml-kem = { version = "0.3", features = ["getrandom"] } +slh-dsa = "0.2.0-rc.5" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# System +libc = "0.2" + +# CLI +clap = { version = "4", features = ["derive"] } + +# TUI +crossterm = "0.27" + +# Encoding +base64 = "0.22" + +# Error handling +thiserror = "2" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/increment_and_push.sh b/increment_and_push.sh new file mode 100755 index 0000000..234b262 --- /dev/null +++ b/increment_and_push.sh @@ -0,0 +1,309 @@ +#!/bin/bash +set -e + +# nsigner (Rust) — Increment and Push Script +# +# Increments the version (patch/minor/major), updates Cargo.toml and +# src/lib.rs, commits, tags, and pushes. Optionally creates a release +# build and uploads assets to Gitea. +# +# USAGE: +# ./increment_and_push.sh [OPTIONS] "commit message" +# +# OPTIONS: +# -p, --patch Increment patch version (default) +# -m, --minor Increment minor version +# -M, --major Increment major version +# -r, --release Create release build and upload assets +# -h, --help Show this help message + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; } +print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +COMMIT_MESSAGE="" +RELEASE_MODE=false +VERSION_INCREMENT_TYPE="patch" + +show_usage() { + echo "nsigner (Rust) Increment and Push Script" + echo "" + echo "USAGE:" + echo " $0 [OPTIONS] \"commit message\"" + echo "" + echo "OPTIONS:" + echo " -p, --patch Increment patch version (default)" + echo " -m, --minor Increment minor version" + echo " -M, --major Increment major version" + echo " -r, --release Create release build and upload assets" + echo " -h, --help Show this help message" +} + +while [[ $# -gt 0 ]]; do + case $1 in + -r|--release) + RELEASE_MODE=true + shift + ;; + -p|--patch) + VERSION_INCREMENT_TYPE="patch" + shift + ;; + -m|--minor) + VERSION_INCREMENT_TYPE="minor" + shift + ;; + -M|--major) + VERSION_INCREMENT_TYPE="major" + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + if [[ -z "$COMMIT_MESSAGE" ]]; then + COMMIT_MESSAGE="$1" + fi + shift + ;; + esac +done + +if [[ -z "$COMMIT_MESSAGE" ]]; then + print_error "Commit message is required" + show_usage + exit 1 +fi + +check_git_repo() { + if ! git rev-parse --git-dir > /dev/null 2>&1; then + print_error "Not in a git repository" + exit 1 + fi +} + +# Update version in Cargo.toml and src/lib.rs +update_version_in_source() { + local new_version="$1" # e.g. "v0.0.2" + local new_version_no_v="${new_version#v}" # e.g. "0.0.2" + + # Update Cargo.toml + sed -i "s/^version = \".*\"/version = \"$new_version_no_v\"/" Cargo.toml + print_success "Updated Cargo.toml to $new_version_no_v" + + # Update src/lib.rs (VERSION constant) + sed -i "s/pub const VERSION: \&str = \"v[0-9]*\.[0-9]*\.[0-9]*\"/pub const VERSION: \&str = \"$new_version\"/" src/lib.rs + print_success "Updated src/lib.rs to $new_version" +} + +increment_version() { + local increment_type="$1" + + LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "") + if [[ -z "$LATEST_TAG" ]]; then + LATEST_TAG="v0.0.0" + print_warning "No version tags found, starting from $LATEST_TAG" + fi + + VERSION=${LATEST_TAG#v} + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + MAJOR=${BASH_REMATCH[1]} + MINOR=${BASH_REMATCH[2]} + PATCH=${BASH_REMATCH[3]} + else + print_error "Invalid version format in tag: $LATEST_TAG" + exit 1 + fi + + if [[ "$increment_type" == "major" ]]; then + NEW_VERSION="v$((MAJOR + 1)).0.0" + elif [[ "$increment_type" == "minor" ]]; then + NEW_VERSION="v${MAJOR}.$((MINOR + 1)).0" + else + NEW_VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))" + fi + + update_version_in_source "$NEW_VERSION" + export NEW_VERSION +} + +git_commit_and_push() { + git add . + + if ! git diff --staged --quiet; then + git commit -m "$NEW_VERSION - $COMMIT_MESSAGE" + else + print_warning "No changes to commit" + fi + + git push + git push origin "$NEW_VERSION" 2>/dev/null || git push --force origin "$NEW_VERSION" 2>/dev/null || true +} + +verify_binary_version() { + local bin_path="$1" + local expected="$2" + + if [[ ! -x "$bin_path" ]]; then + print_error "Binary not found or not executable: $bin_path" + return 1 + fi + + local got + got="$($bin_path --version 2>/dev/null | awk '{print $2}')" + if [[ "$got" != "$expected" ]]; then + print_error "Binary version mismatch: expected $expected, got ${got:-}" + return 1 + fi + + return 0 +} + +build_release_binary() { + print_status "Building release binary (cargo build --release)..." + cargo build --release 2>&1 | tail -5 || return 1 + + local bin_path="target/release/nsigner" + verify_binary_version "$bin_path" "$NEW_VERSION" || return 1 + + print_success "Release binary built: $bin_path" + return 0 +} + +create_source_tarball() { + local tarball_name="nsigner-${NEW_VERSION#v}.tar.gz" + + if tar -czf "$tarball_name" \ + --exclude='target/*' \ + --exclude='.git*' \ + --exclude='*.log' \ + --exclude='*.tar.gz' \ + . > /dev/null 2>&1; then + echo "$tarball_name" + else + return 1 + fi +} + +create_gitea_release() { + if [[ ! -f "$HOME/.gitea_token" ]]; then + print_warning "No ~/.gitea_token found. Skipping release creation." + return 0 + fi + + local token + token=$(cat "$HOME/.gitea_token" | tr -d '\n\r') + local api_url="https://git.laantungir.net/api/v1/repos/laantungir/signer" + + local response + response=$(curl -s -X POST "$api_url/releases" \ + -H "Authorization: token $token" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$NEW_VERSION\", \"target_commitish\": \"master\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\", \"draft\": false, \"prerelease\": false}") + + if echo "$response" | grep -q '"id"'; then + local release_id + release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) + + # Work around Gitea bug: releases created via API get created_unix=0 + local now_unix + now_unix=$(date +%s) + print_status "Fixing release timestamp in Gitea database (workaround for Gitea bug)..." + ssh -o ConnectTimeout=10 ubuntu@laantungir.net \ + "sudo sqlite3 /data/gitea/gitea.db \"UPDATE release SET created_unix=$now_unix WHERE id=$release_id;\"" \ + 2>/dev/null || print_warning "Could not fix release timestamp via SSH (release may not appear in web UI)" + + echo "$release_id" + else + print_error "Failed to create Gitea release: $response" + return 1 + fi +} + +upload_release_assets() { + local release_id="$1" + local binary_path="$2" + local tarball_path="$3" + + if [[ ! -f "$HOME/.gitea_token" ]]; then + print_warning "No ~/.gitea_token found. Skipping asset uploads." + return 0 + fi + + local token + token=$(cat "$HOME/.gitea_token" | tr -d '\n\r') + local api_url="https://git.laantungir.net/api/v1/repos/laantungir/signer" + local assets_url="$api_url/releases/$release_id/assets" + + upload_asset() { + local path="$1" + if [[ -f "$path" ]]; then + print_status "Uploading $(basename "$path")..." + curl -s -X POST "$assets_url" \ + -H "Authorization: token $token" \ + -F "attachment=@$path;filename=$(basename "$path")" \ + -F "name=$(basename "$path")" > /dev/null + fi + } + + upload_asset "$binary_path" + upload_asset "$tarball_path" +} + +main() { + check_git_repo + + if [[ "$RELEASE_MODE" == true ]]; then + increment_version "$VERSION_INCREMENT_TYPE" + + if git tag "$NEW_VERSION" > /dev/null 2>&1; then + print_success "Created tag: $NEW_VERSION" + else + git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true + git tag "$NEW_VERSION" > /dev/null 2>&1 + fi + + if ! build_release_binary; then + print_error "Release build failed; aborting before push/upload" + exit 1 + fi + + git_commit_and_push + + local binary_path="target/release/nsigner" + local tarball_path="" + tarball_path=$(create_source_tarball || true) + + local release_id="" + release_id=$(create_gitea_release || true) + + if [[ -n "$release_id" ]]; then + upload_release_assets "$release_id" "$binary_path" "$tarball_path" + fi + + print_success "Release flow completed: $NEW_VERSION" + else + increment_version "$VERSION_INCREMENT_TYPE" + + if git tag "$NEW_VERSION" > /dev/null 2>&1; then + print_success "Created tag: $NEW_VERSION" + else + git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true + git tag "$NEW_VERSION" > /dev/null 2>&1 + fi + + git_commit_and_push + print_success "Increment and push completed: $NEW_VERSION" + fi +} + +main diff --git a/plans/policy_enforcement_plan.md b/plans/policy_enforcement_plan.md new file mode 100644 index 0000000..81429d1 --- /dev/null +++ b/plans/policy_enforcement_plan.md @@ -0,0 +1,220 @@ +# Plan: Wire Policy Enforcement into the Server Loop + +## Problem + +The Rust `nsigner` library modules are complete and tested (92 tests pass), but the server loop in [`server.rs`](src/server.rs:117) accepts connections, reads requests, dispatches them, and sends responses **without any policy enforcement**. The `policy: &mut PolicyTable` parameter is accepted but never used. This means the daemon would sign anything for anyone without prompting — it is not an "attended signer." + +## What the C version does (server.c) + +The C `server_handle_one()` implements a full security pipeline before dispatching: + +1. **Caller identification** (`server_get_caller()`): + - Unix socket: `SO_PEERCRED` → `uid:` + - TCP: `getpeername()` → `tcp::` + - stdio/qrexec: `QREXEC_REMOTE_DOMAIN` env → `qubes:` or `uid:` + +2. **Auth envelope verification** (if `--auth optional|required`): + - Extracts `auth` field from JSON-RPC request + - Verifies NIP-42-style event signature, timestamp skew, replay protection + - Composes caller_id as `pubkey:` for authenticated callers + +3. **Selector resolution** (`extract_method_and_selector()` + `selector_resolve()`): + - Parses `method`, `role`, `role_path`, `index`, `nostr_index` from request + - Resolves against role table, handles fixed-path vs template roles + - Detects `pending_derivation` (new identity that will be derived if approved) + +4. **Policy check** (`policy_check_with_role()`): + - Role-as-password: if `role.requires_approval == 0`, allow immediately + - Otherwise check policy table entries (caller, verb, role, purpose, algorithm, index range) + - Returns `Allow`, `Deny`, `Prompt`, or `NoMatch` + +5. **Approval prompt** (`prompt_for_policy_decision()`): + - If `Prompt`, shows TUI prompt with caller, method, role, purpose + - Returns `Allow`, `Deny`, `AllowSessionVerb`, or `AllowSessionAll` + - Session grants are inserted into the policy table for subsequent requests + +6. **Pending derivation** (if approved and `pending_derivation`): + - Derives the key for the requested role/index before dispatching + +7. **Dispatch** — only if all checks pass + +## Current Rust state + +| Component | Status | Notes | +|-----------|--------|-------| +| [`policy.rs`](src/policy.rs) | ✅ Complete | `PolicyTable`, `check()`, `check_with_role()`, `check_algorithm()`, `parse_preapprove_spec()` | +| [`auth_envelope.rs`](src/auth_envelope.rs) | ✅ Complete | `AuthNonceCache`, `verify_request()` | +| [`selector.rs`](src/selector.rs) | ✅ Complete | `SelectorRequest`, `selector_resolve()` | +| [`tui.rs`](src/tui.rs) | ✅ Complete | `approval_prompt()` with y/n/e/a | +| [`server.rs`](src/server.rs) | ❌ **Missing** | `handle_one()` accepts `policy` but never uses it | +| [`main.rs`](src/main.rs) | ⚠️ Partial | Creates `PolicyTable`, adds preapprove entries, but no session grant support | + +## Implementation plan + +### Step 1: Add caller identification to `server.rs` + +Add a `CallerIdentity` struct and `identify_caller()` function: + +```rust +pub struct CallerIdentity { + pub uid: u32, + pub gid: u32, + pub pid: u32, + pub kind: ListenMode, + pub caller_id: String, // "uid:1000", "tcp:127.0.0.1:8080", "qubes:work" + pub source_qube: String, // qrexec only + pub auth_present: bool, + pub auth_pubkey_hex: String, + pub auth_label: String, +} +``` + +- Unix: `getsockopt(SO_PEERCRED)` via `libc::getsockopt` on the `UnixStream` fd +- TCP: `getpeername()` via `TcpStream::peer_addr()` +- stdio/qrexec: `std::env::var("QREXEC_REMOTE_DOMAIN")` + +### Step 2: Add auth envelope verification to `server.rs` + +In `handle_one()`, after reading the request but before dispatch: + +```rust +if self.auth_mode != AuthMode::Off { + let mut cache = AuthNonceCache::new(); // or store in ServerContext + match auth_envelope::verify_request(&request, &mut cache, self.auth_skew_seconds) { + Ok((pubkey, label)) => { + caller.auth_present = true; + caller.auth_pubkey_hex = pubkey; + caller.auth_label = label; + caller.caller_id = format!("pubkey:{}", pubkey); + } + Err((code, msg)) => { + if self.auth_mode == AuthMode::Required { + return Ok(send_auth_error(code, msg)); + } + // Optional: continue without auth + } + } +} +``` + +### Step 3: Add selector extraction and policy check to `server.rs` + +Before calling `dispatcher::handle_request()`: + +```rust +// Extract method and selector from request JSON +let (method, selector_req) = extract_method_and_selector(&request)?; + +// Resolve selector against role table +let role_index = selector_resolve(&selector_req, dispatcher.role_table)?; +let role = &dispatcher.role_table.entries[role_index]; + +// Check policy +let (result, source) = policy.check_with_role( + &caller.caller_id, + method, + &role.name, + role.purpose_str(), + Some(role), +); + +match result { + PolicyResult::Allow => { /* proceed to dispatch */ } + PolicyResult::Deny => { /* send policy_denied error */ } + PolicyResult::Prompt => { + let decision = tui::approval_prompt(&caller.caller_id, method, &role.name, role.purpose_str()); + match decision { + PolicyResult::Allow => { /* proceed */ } + PolicyResult::AllowSessionVerb => { + // Insert session grant into policy table + policy.insert_session_grant(&caller.caller_id, method, &role.name); + /* proceed */ + } + PolicyResult::AllowSessionAll => { + policy.insert_session_grant_all(&caller.caller_id, &role.name); + /* proceed */ + } + _ => { /* deny */ } + } + } + _ => { /* deny */ } +} +``` + +### Step 4: Add session grant support to `policy.rs` + +Add methods to insert session grants: + +```rust +impl PolicyTable { + /// Insert a session grant for caller+role+verb. + pub fn insert_session_grant(&mut self, caller: &str, verb: &str, role: &str) -> Result<(), NsignerError>; + + /// Insert a session grant for caller+role (all verbs). + pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), NsignerError>; +} +``` + +These create `PolicyEntry` with `source: PolicySource::SessionGrant` and `prompt: PromptMode::Never`, inserted before the catch-all deny rule. + +### Step 5: Add `extract_method_and_selector()` helper + +Port the C function that parses a JSON-RPC request to extract: +- `method` (string) +- `role` (from last params object) +- `role_path` (from last params object) +- `index` (from last params object) +- `nostr_index` (from last params object) + +Returns `(method, SelectorRequest)`. + +### Step 6: Add `pending_derivation` support + +When the selector resolves to a role that hasn't been derived yet (or a template role with a new index), set `pending_derivation = true`. After policy approval, derive the key before dispatching: + +```rust +if pending_derivation { + key_store.derive_one(role_table, mnemonic, role_index)?; +} +``` + +### Step 7: Add `--allow-all` flag support + +In `main.rs`, when `cli.allow_all` is true, set a global flag that makes `approval_prompt()` return `Allow` immediately (matching C's `g_prompt_always_allow`). + +### Step 8: Add `policy` to `DispatcherContext` or pass separately + +The dispatcher currently doesn't know about policy. Options: +- **Option A**: Pass `&mut PolicyTable` to `handle_request()` — changes dispatcher API +- **Option B**: Do policy check in `server.rs` before calling dispatcher — cleaner separation + +**Recommendation**: Option B. The server is the security boundary; the dispatcher is just a router. + +### Step 9: Integration tests + +Add tests in `tests/` that: +1. Start a server on a Unix socket with a test mnemonic +2. Connect a client and send a `get_info` request (should work without policy) +3. Send a `nostr_get_public_key` request without preapproval (should prompt/deny) +4. Send with preapproval (should allow) +5. Test session grants (approve once, second request should not prompt) + +## File changes + +| File | Changes | +|------|---------| +| [`src/server.rs`](src/server.rs) | Add `CallerIdentity`, `identify_caller()`, auth envelope check, selector extraction, policy check, approval prompt call, pending derivation | +| [`src/policy.rs`](src/policy.rs) | Add `insert_session_grant()`, `insert_session_grant_all()` | +| [`src/main.rs`](src/main.rs) | Add `--allow-all` flag handling, pass `AuthNonceCache` to server | +| [`src/tui.rs`](src/tui.rs) | Add `prompt_always_allow` flag support | +| [`src/dispatcher.rs`](src/dispatcher.rs) | No changes needed (policy stays in server) | +| [`tests/integration.rs`](tests/integration.rs) | New file: end-to-end server+client tests | + +## Verification + +After implementation: +1. `cargo test` — all existing tests still pass +2. `cargo test --test integration` — new integration tests pass +3. Manual test: start server, connect client, verify prompt appears for unapproved requests +4. Manual test: verify preapproved requests skip prompt +5. Manual test: verify session grants work (approve once, no re-prompt) diff --git a/plans/port_nsigner_to_rust.md b/plans/port_nsigner_to_rust.md new file mode 100644 index 0000000..8fccb66 --- /dev/null +++ b/plans/port_nsigner_to_rust.md @@ -0,0 +1,385 @@ +# n_signer → Rust Port Implementation Plan + +## Overview + +Port the C-based `n_signer` (located at `~/lt/n_signer`) to Rust, targeting **x86_64 Linux** only. Microcontroller implementations (ESP32, KB2040, etc.) are excluded. The Rust port will use `~/lt/nostr_core_lib_rust` as the crypto/Nostr protocol library. + +## Architecture + +```mermaid +graph TB + subgraph "n_signer_rust" + Main[main.rs
CLI + startup + TUI loop] + Main --> SecureMem + Main --> Mnemonic + Main --> RoleWizard + Main --> Server + Main --> Policy + + SecureMem[secure_mem.rs
mlock + zeroize] + Mnemonic[mnemonic.rs
BIP-39 via nips::nip006] + + RoleTable[role_table.rs
role entries + path templates] + Selector[selector.rs
role resolution] + Enforcement[enforcement.rs
verb/algorithm validation] + Policy[policy.rs
caller access control] + + Dispatcher[dispatcher.rs
JSON-RPC 2.0 routing] + Dispatcher --> KeyStore + Dispatcher --> AlgCache + Dispatcher --> OtpPad + Dispatcher --> Miner + + KeyStore[key_store.rs
derived keys via nostr_core] + AlgCache[alg_cache.rs
on-demand key derivation] + OtpPad[otp_pad.rs
one-time pad encrypt/decrypt] + Miner[miner.rs
NIP-13 PoW via nips::nip013] + + Server[server.rs
multi-transport poll loop] + Server --> Transport + Server --> AuthEnvelope + Server --> Policy + Server --> Dispatcher + + Transport[transport.rs
framed JSON + HTTP] + AuthEnvelope[auth_envelope.rs
request authentication] + + PQCrypto[pq_crypto.rs
ed25519, x25519, PQ algorithms] + Tui[tui.rs
terminal UI] + end + + subgraph "nostr_core_lib_rust" + Core[core::
keys, sha256, hmac, nip44] + Nips[nips::
nip001, nip004, nip006, nip013, nip019, nip044] + SignerLib[signer::
NostrSigner trait] + end + + KeyStore --> Core + KeyStore --> Nips + Dispatcher --> Nips + Miner --> Nips + PQCrypto --> Core +``` + +## Dependency Mapping: C → Rust + +| C Module | Rust Module | nostr_core_lib_rust Usage | External Crates | +|----------|-------------|--------------------------|------------------| +| `secure_mem.c` | `secure_mem.rs` | — | `zeroize`, `libc` (mlock/munlock) | +| `mnemonic.c` | `mnemonic.rs` | `nips::nip006` (BIP-39 wordlist, mnemonic_to_seed) | — | +| `role_table.c` | `role_table.rs` | — | — | +| `selector.c` | `selector.rs` | — | — | +| `enforcement.c` | `enforcement.rs` | — | — | +| `policy.c` | `policy.rs` | — | — | +| `key_store.c` | `key_store.rs` | `core::crypto::keys`, `nips::nip001`, `nips::nip004`, `nips::nip044` | `secp256k1` (via nostr_core) | +| `pq_crypto.c` | `pq_crypto.rs` | — | `ed25519-dalek`, `x25519-dalek`, `pqcrypto` (or vendored PQClean) | +| `pq_drbg.c` | `pq_drbg.rs` | — | `sha3` (SHAKE-256) | +| `dispatcher.c` | `dispatcher.rs` | `nips::nip001`, `nips::nip004`, `nips::nip044`, `nips::nip013` | `serde_json` | +| `server.c` | `server.rs` | — | `libc` (sockets, SO_PEERCRED) | +| `transport_frame.c` | `transport.rs` | — | — | +| `http_listener.c` | `http.rs` | — | — | +| `auth_envelope.c` | `auth_envelope.rs` | `core::crypto::keys` (verify) | — | +| `miner.c` | `miner.rs` | `nips::nip013` | `std::thread` | +| `otp_pad.c` | `otp_pad.rs` | — | — | +| `socket_name.c` | `socket_name.rs` | `nips::nip006` (BIP-39 wordlist for random names) | — | +| `main.c` (TUI) | `tui.rs` | — | `crossterm` or `ratatui` | +| `main.c` (CLI) | `main.rs` | — | `clap` | + +## Key Design Decisions + +### 1. Memory Safety +- **C**: Manual `mlock`/`munlock` + `explicit_bzero` via `secure_buf_t` +- **Rust**: `zeroize` crate with `ZeroizeOnDrop` derive. Wrap sensitive buffers in a `SecureBuf` type that calls `mlock` on alloc and `munlock`+`zeroize` on drop. Use `libc::mlock`/`libc::munlock` for the syscall. + +### 2. Concurrency Model +- **C**: Single-threaded poll loop with detached pthread for `nostr_mine_event` +- **Rust**: Same model — single-threaded `poll(2)` loop via `mio` or raw `libc::poll`. Mining runs in `std::thread::spawn`. No async runtime needed (the C version is sync). + +### 3. JSON Handling +- **C**: cJSON (manual parse/build) +- **Rust**: `serde_json` with typed structs for request/response + +### 4. Error Handling +- **C**: Integer error codes + string messages +- **Rust**: `thiserror`-based `NsignerError` enum. The JSON-RPC error codes are preserved exactly for wire compatibility. + +### 5. Transport +- **C**: Raw syscalls (`socket`, `bind`, `accept`, `SO_PEERCRED`) +- **Rust**: `std::os::unix::net::UnixListener` + `libc` for `SO_PEERCRED`. TCP via `std::net::TcpListener`. HTTP via a minimal hand-rolled parser (same as C — no framework). + +### 6. TUI +- **C**: `tui_continuous` vendored library +- **Rust**: `crossterm` for terminal control + custom rendering (or `ratatui` if it fits the continuous-redraw model). Start with `crossterm` + manual rendering to match the C behavior closely. + +### 7. PQ Crypto +- **C**: PQClean vendored sources + custom DRBG +- **Rust**: Use `ed25519-dalek` and `x25519-dalek` for classic curves. For PQ algorithms (ML-DSA-65, SLH-DSA-128s, ML-KEM-768), either: + - **Option A**: FFI to the existing PQClean C code (fastest path to working) + - **Option B**: Use `pqcrypto` crate or vendor the PQClean Rust ports + - **Recommendation**: Start with Option A (FFI) for Phase 13, migrate to pure Rust later + +### 8. nostr_core_lib_rust Integration +The Rust library provides: +- `core::crypto::keys` — secp256k1 key generation, Schnorr sign/verify, ECDH +- `core::crypto::hmac` — HMAC-SHA256/512, HKDF +- `core::crypto::nip44` — NIP-44 encryption (ChaCha20-Poly1305) +- `core::types` — Event, PublicKey, SecretKey, Signature, etc. +- `nips::nip001` — `create_and_sign_event`, `validate_event` +- `nips::nip004` — NIP-04 encrypt/decrypt +- `nips::nip006` — BIP-39 mnemonic generation, validation, `mnemonic_to_seed`, `keypair_from_seed` +- `nips::nip013` — NIP-13 proof-of-work mining +- `nips::nip019` — bech32 encoding (npub, nsec) +- `signer::NostrSigner` trait — can be used for the signer abstraction + +**Gap**: `nips::nip006::keypair_from_seed` currently uses the master key directly rather than doing full BIP-32 derivation through `m/44'/1237'/0'/0/0`. The port needs to implement proper BIP-32 HD derivation (either add it to `nostr_core_lib_rust` or implement locally in n_signer). + +## Phased Implementation + +### Phase 1: Project Scaffolding +- [ ] Create `Cargo.toml` workspace with path dependency on `~/lt/nostr_core_lib_rust` +- [ ] Create `src/` module structure matching the C source layout +- [ ] Add dependencies: `serde`, `serde_json`, `libc`, `zeroize`, `clap`, `crossterm`, `hex`, `base64`, `thiserror`, `secp256k1` (transitive via nostr_core) +- [ ] Create `build.rs` if PQClean FFI is needed +- [ ] Verify `cargo build` compiles with empty module stubs + +### Phase 2: Secure Memory (`secure_mem.rs`) +- [ ] `SecureBuf` struct: holds `Vec`, `locked: bool` +- [ ] `SecureBuf::alloc(size)` — malloc + `libc::mlock` + zero init +- [ ] `SecureBuf::free()` — `zeroize` + `libc::munlock` + drop +- [ ] `secure_memzero()` — use `zeroize::zeroize()` +- [ ] `allow_unlocked()` flag for dev mode +- [ ] Implement `Drop` trait for automatic cleanup +- [ ] Unit tests: alloc/free, zeroization verification + +### Phase 3: Mnemonic (`mnemonic.rs`) +- [ ] `MnemonicState` struct: holds `SecureBuf`, `loaded: bool`, `word_count: u8` +- [ ] `mnemonic_load(phrase)` — validate via `nips::nip006::mnemonic_validate`, store in `SecureBuf` +- [ ] `mnemonic_generate(word_count)` — use `nips::nip006::mnemonic_from_bytes` with `rand::thread_rng` entropy +- [ ] `mnemonic_unload()` — wipe `SecureBuf` +- [ ] Unit tests: valid/invalid mnemonics, word count validation + +### Phase 4: Role Table, Selector, Enforcement +- [ ] `RolePurpose` enum: Nostr, Bitcoin, Ssh, Age, Fips, PqSig, PqKem +- [ ] `RoleCurve` enum: Secp256k1, Ed25519, X25519, MlDsa65, SlhDsa128s, MlKem768 +- [ ] `RoleEntry` struct: name, purpose, curve, selector_type, path, range, allowed_indices, requires_approval +- [ ] `RoleTable` struct: Vec of entries, add/find/register methods +- [ ] Path template parser: wildcard (`*`), range (`N-M`), set (`A+B+C`), fixed path +- [ ] `SelectorRequest` struct: has_role, has_role_path, has_index +- [ ] `selector_resolve()` — match role+path against table +- [ ] `enforce_verb_role()` — check purpose==Nostr && curve==Secp256k1 for nostr verbs +- [ ] `enforce_verb_algorithm()` — check verb+algorithm validity +- [ ] Unit tests: path template parsing, selector resolution, enforcement matrix + +### Phase 5: Policy (`policy.rs`) +- [ ] `PromptMode` enum: Never, FirstPerBoot, EveryRequest, Deny +- [ ] `PolicyEntry` struct: caller, verbs, roles, purposes, algorithms, index range, prompt mode, source +- [ ] `PolicyTable` struct: Vec of entries +- [ ] `policy_check()` — role-based check +- [ ] `policy_check_algorithm()` — algorithm-based check +- [ ] `policy_check_with_role()` — role-as-password shortcut (requires_approval==0 → allow) +- [ ] `parse_preapprove_spec()` — parse `caller=uid:1000,role=main,verb=sign` +- [ ] Session grants: insert before catch-all +- [ ] Unit tests: policy matching, preapprove parsing, session grants + +### Phase 6: Key Store & Crypto (`key_store.rs` + `alg_cache.rs`) +- [ ] `DerivedKey` struct: private_key (SecureBuf), public_key (SecureBuf), pubkey_hex, npub, alg +- [ ] `KeyStore` struct: Vec of derived keys per role +- [ ] BIP-32 HD derivation: implement proper path derivation (`m/44'/1237'/'/0/0`) + - Either add BIP-32 to `nostr_core_lib_rust` or implement locally using `secp256k1` crate +- [ ] SLIP-0010 derivation for ed25519/x25519 (HMAC-SHA512, all-hardened paths) +- [ ] `crypto_derive_all()` — derive keys for all roles +- [ ] `crypto_derive_one()` — derive for a single role (on-demand) +- [ ] `crypto_sign_event()` — via `nips::nip001::create_and_sign_event` +- [ ] `crypto_nip04_encrypt/decrypt()` — via `nips::nip004` +- [ ] `crypto_nip44_encrypt/decrypt()` — via `core::crypto::nip44` +- [ ] `AlgorithmKeyCache` — FIFO cache of on-demand derived keys (alg, index) → keypair +- [ ] Unit tests: derivation determinism, sign/verify roundtrip + +### Phase 7: Dispatcher (`dispatcher.rs`) +- [ ] `DispatcherContext` struct: references to role_table, mnemonic, key_store, alg_cache +- [ ] `dispatcher_handle_request(json)` → response JSON string +- [ ] Parse JSON-RPC: id, method, params, options +- [ ] Route algorithm verbs: `get_public_key`, `sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`, `derive` +- [ ] Route nostr verbs: `nostr_get_public_key`, `nostr_sign_event`, `nostr_mine_event`, `nostr_nip04_*`, `nostr_nip44_*` +- [ ] Route OTP verbs: `encrypt`, `decrypt` +- [ ] Route metadata: `get_info` +- [ ] Error response builder with exact error codes from C API +- [ ] Unit tests: each verb with valid/invalid params + +### Phase 8: Transport (`transport.rs` + `http.rs`) +- [ ] `transport_send_framed(fd, payload)` — 4-byte BE length prefix + data +- [ ] `transport_recv_framed(fd)` — read length, then payload +- [ ] `http_recv_request(fd)` — minimal HTTP/1.1 POST parser +- [ ] `http_send_response(fd, json)` — HTTP 200 + CORS headers +- [ ] `http_send_error(fd, code, message)` +- [ ] `http_send_cors_preflight(fd)` — OPTIONS response +- [ ] Unit tests: framing roundtrip, HTTP parse + +### Phase 9: Server (`server.rs`) +- [ ] `ListenMode` enum: Unix, Stdio, Qrexec, Tcp, Http +- [ ] `CallerIdentity` struct: uid, gid, pid, kind, caller_id, source_qube, auth fields +- [ ] `ServerContext` struct: socket_name, listen_fd, listen_mode, dispatcher, policy, auth_mode, whitelists +- [ ] `server_start()` — bind socket (abstract Unix / TCP / HTTP) +- [ ] `server_handle_one()` — accept, read request, auth verify, policy check, dispatch, send response +- [ ] `server_get_caller()` — `SO_PEERCRED` for Unix, `getpeername` for TCP +- [ ] Bridge-source-trusted preamble handling +- [ ] Approval callback mechanism +- [ ] Non-blocking poll loop integration +- [ ] Integration tests: Unix socket roundtrip, HTTP roundtrip + +### Phase 10: Miner (`miner.rs`) +- [ ] `MineResult` struct: best_event, achieved_difficulty, target_difficulty, target_reached, elapsed_sec, total_attempts +- [ ] `miner_run(event, private_key, target_difficulty, thread_count, timeout_sec)` → MineResult +- [ ] Multi-threaded: each thread tries nonces with unique stride +- [ ] Stop on target reached or timeout +- [ ] Use `nips::nip013` for PoW computation +- [ ] Unit tests: difficulty achievement, timeout behavior + +### Phase 11: Auth Envelope (`auth_envelope.rs`) +- [ ] `AuthNonceCache` — per-pubkey monotonic timestamp + event ID tracking +- [ ] `auth_envelope_verify_request()` — verify auth field in JSON-RPC request +- [ ] Replay protection: check created_at > max_seen, check event_id uniqueness +- [ ] Unit tests: valid/invalid auth, replay detection + +### Phase 12: OTP Pad (`otp_pad.rs`) +- [ ] `OtpPadState` struct: bound, pads_dir, chksum, pad_path, pad_file, pad_size, offset, scratch buffer +- [ ] `otp_pad_bind(dir, spec, allow_blkback)` — open pad file, read checksum, verify +- [ ] `otp_pad_encrypt(plaintext, encoding)` — XOR with pad bytes, advance offset +- [ ] `otp_pad_decrypt(ciphertext, encoding)` — reverse XOR +- [ ] ASCII armor encoding + binary encoding +- [ ] Pad offset persistence (`.state` file) +- [ ] Unit tests: encrypt/decrypt roundtrip, offset advancement + +### Phase 13: PQ Crypto (`pq_crypto.rs` + `pq_drbg.rs`) +- [ ] `CryptoAlg` enum: Secp256k1, Ed25519, X25519, MlDsa65, SlhDsa128s, MlKem768 +- [ ] `CryptoAlgSizes` struct: priv/pub/sig/ciphertext/shared_secret lengths +- [ ] ed25519: `ed25519-dalek` crate +- [ ] x25519: `x25519-dalek` crate +- [ ] SHAKE-256 DRBG: `sha3` crate +- [ ] ML-DSA-65, SLH-DSA-128s, ML-KEM-768: FFI to PQClean (initial), pure Rust later +- [ ] `crypto_alg_from_role()`, `crypto_alg_from_str()`, `crypto_alg_to_str()` +- [ ] Keygen, sign, verify, encaps, decaps for each algorithm +- [ ] Unit tests: keygen determinism, sign/verify roundtrip + +### Phase 14: TUI (`tui.rs`) +- [ ] Terminal setup via `crossterm` +- [ ] `render_status()` — roles table, activity log, status line, menu +- [ ] `render_connections()` — on-demand connection display (press `d`) +- [ ] Approval prompt screen +- [ ] Role wizard (preset menu, path editor) +- [ ] Transport selection menu +- [ ] Mnemonic entry screen (echo disabled) +- [ ] Mnemonic generation display +- [ ] Index whitelist prompt +- [ ] OTP pad selection prompt +- [ ] Hotkeys: `l` (lock), `r` (refresh), `d` (connections), `q` (quit) +- [ ] Terminal resize handling + +### Phase 15: Main (`main.rs`) +- [ ] CLI parsing with `clap`: `--socket-name`, `--listen`, `--preapprove`, `--register-role`, `--auth`, `--mnemonic-stdin`, `--mnemonic-fd`, `--allow-all`, `--bridge-source-trusted`, `--otp-pad-dir`, `--otp-pad` +- [ ] Subcommands: `client`, `bridge`, `list` +- [ ] Startup flow: mnemonic → roles → transport → socket name → server start → TUI loop +- [ ] Session lock/re-unlock +- [ ] Signal handling: SIGINT, SIGTERM, SIGPIPE (ignore) +- [ ] Shutdown: wipe all secrets, close sockets +- [ ] Non-interactive mode: `--mnemonic-stdin` / `--mnemonic-fd` / `--register-role` + +### Phase 16: Integration Tests & Build +- [ ] End-to-end test: start signer, send `get_info`, `get_public_key`, `nostr_sign_event` +- [ ] Algorithm verb tests: `sign`/`verify` for each algorithm +- [ ] NIP-04/NIP-44 encrypt/decrypt roundtrip +- [ ] NIP-13 mining test +- [ ] Policy enforcement tests: allow/deny/prompt +- [ ] Multi-transport test: Unix + HTTP simultaneously +- [ ] OTP encrypt/decrypt test +- [ ] `cargo build --release` verification +- [ ] Static build target (musl) investigation + +## File Structure + +``` +signer/ +├── Cargo.toml +├── build.rs # PQClean FFI build script (Phase 13) +├── src/ +│ ├── main.rs # CLI + startup + TUI loop +│ ├── lib.rs # Public API re-exports +│ ├── secure_mem.rs # SecureBuf, mlock, zeroize +│ ├── mnemonic.rs # BIP-39 mnemonic state +│ ├── role_table.rs # Role entries, path templates +│ ├── selector.rs # Role selector resolution +│ ├── enforcement.rs # Verb/role/algorithm enforcement +│ ├── policy.rs # Caller access control +│ ├── key_store.rs # Derived key storage +│ ├── alg_cache.rs # On-demand algorithm key cache +│ ├── pq_crypto.rs # ed25519, x25519, PQ algorithms +│ ├── pq_drbg.rs # SHAKE-256 DRBG for PQ keygen +│ ├── dispatcher.rs # JSON-RPC 2.0 request routing +│ ├── server.rs # Multi-transport server +│ ├── transport.rs # Length-prefixed framing +│ ├── http.rs # Minimal HTTP/1.1 parser +│ ├── auth_envelope.rs # Request authentication +│ ├── miner.rs # NIP-13 PoW mining +│ ├── otp_pad.rs # One-time pad encryption +│ ├── socket_name.rs # Abstract socket naming +│ ├── tui.rs # Terminal UI +│ └── error.rs # NsignerError enum +├── tests/ +│ ├── integration_test.rs +│ ├── algorithm_test.rs +│ ├── policy_test.rs +│ └── transport_test.rs +└── resources/ + └── pqclean/ # Vendored PQClean (if FFI path) +``` + +## Cargo.toml (Draft) + +```toml +[package] +name = "nsigner" +version = "0.1.0" +edition = "2021" + +[dependencies] +nostr-core = { path = "../nostr_core_lib_rust/nostr-core" } +nips = { path = "../nostr_core_lib_rust/nips" } +serde = { workspace = true } +serde_json = { workspace = true } +zeroize = { workspace = true } +libc = "0.2" +clap = { version = "4", features = ["derive"] } +crossterm = "0.27" +hex = { workspace = true } +base64 = { workspace = true } +thiserror = { workspace = true } +secp256k1 = { workspace = true } +sha2 = { workspace = true } +hmac = { workspace = true } +rand = { workspace = true } +ed25519-dalek = "2" +x25519-dalek = "2" +sha3 = "0.10" + +[dev-dependencies] +tempfile = "3" +``` + +## Compatibility Requirements + +1. **Wire protocol**: JSON-RPC 2.0 request/response format must be byte-identical to C version +2. **Error codes**: All error codes (-32700, -32600, 1001-2009) must match exactly +3. **Derivation paths**: BIP-32/SLIP-0010 paths must produce identical keys from the same mnemonic +4. **Socket protocol**: Length-prefixed framing (4-byte BE) must be compatible +5. **HTTP**: Same minimal HTTP/1.1 POST-only parser behavior +6. **Abstract socket names**: `@nsigner__` format preserved + +## Open Questions + +1. **BIP-32 derivation**: `nostr_core_lib_rust` `nips::nip006::keypair_from_seed` does not do full BIP-32 HD derivation through the path. Should we: + - (a) Add proper BIP-32 to `nostr_core_lib_rust`, or + - (b) Implement BIP-32 locally in n_signer using the `secp256k1` crate directly? + +2. **PQ crypto**: Should we FFI to PQClean C code initially, or find/use Rust PQ crates? + +3. **TUI scope**: Full TUI parity with C version (role wizard, transport menu, approval prompts, connection display), or start with a simpler CLI? diff --git a/plans/port_tui_continuous.md b/plans/port_tui_continuous.md new file mode 100644 index 0000000..71c7f23 --- /dev/null +++ b/plans/port_tui_continuous.md @@ -0,0 +1,155 @@ +# Port `tui_continuous` C library to Rust + +## Overview + +Port the vendored C library `resources/tui_continuous/tui_continuous.c` (536 lines, ~16 public functions) to Rust as a standalone, well-documented module. Keep it separate from the main `tui.rs` so it can be spun out as its own crate later. + +## File structure + +``` +src/ + tui_continuous.rs <-- The ported library (new file) + tui.rs <-- Existing app-level TUI code (will call tui_continuous) +``` + +The existing `tui.rs` will be refactored to call `tui_continuous` primitives instead of using raw `println!`/`tprint!`/`crossterm`. + +## C API surface (16 functions) + +### Types to port +| C type | Rust equivalent | +|--------|----------------| +| `TuiSize { width, height }` | `pub struct TuiSize { pub width: u16, pub height: u16 }` | +| `TuiMenuItem { label, shortcut }` | `pub struct TuiMenuItem { pub label: &'static str, pub shortcut: char }` | +| `TuiFrame { app_name, app_version, breadcrumb }` | `pub struct TuiFrame { pub app_name: &'static str, pub app_version: &'static str, pub breadcrumb: &'static str }` | +| `TuiMenu { items, count }` | `pub struct TuiMenu<'a> { pub items: &'a [TuiMenuItem] }` | +| `TuiStatus { text }` | `pub struct TuiStatus<'a> { pub text: Option<&'a str> }` | +| `TuiColumn { name, width, right_align }` | `pub struct TuiColumn { pub name: &'static str, pub width: u16, pub right_align: bool }` | +| `TuiTable { columns, get_cell, ... }` | `pub struct TuiTable<'a, F> { pub columns: &'a [TuiColumn], pub row_count: usize, pub get_cell: F }` where `F: Fn(usize, usize, &mut [u8])` | + +### Functions to port (in order) + +| # | C function | Rust signature | Notes | +|---|-----------|---------------|-------| +| 1 | `tui_terminal_size()` | `pub fn terminal_size() -> TuiSize` | Use `crossterm::terminal::size()` | +| 2 | `tui_install_resize_handler()` | `pub fn install_resize_handler()` | Register SIGWINCH → set `g_resize_pending` flag | +| 3 | `tui_resize_pending()` | `pub fn resize_pending() -> bool` | Check and clear `g_resize_pending` | +| 4 | `tui_init()` | `pub fn init()` | Calls `crossterm::terminal::enable_raw_mode()` | +| 5 | `tui_cleanup()` | `pub fn cleanup()` | Calls `crossterm::terminal::disable_raw_mode()` | +| 6 | `tui_get_key()` | `pub fn get_key() -> Result` | Returns `TuiKey::Char(c)`, `TuiKey::Resize`, `TuiKey::Eof` | +| 7 | `tui_print()` | `pub fn print(fmt: std::fmt::Arguments)` | Parse `^_`→underline, `^*`→bold, `^:`→reset, `^^`→literal `^` | +| 8 | `tui_clear_continuous()` | `pub fn clear_continuous(term_height: u16)` | ANSI escape sequence to clear scrollback region | +| 9 | `tui_render_top_frame()` | `pub fn render_top_frame(frame: &TuiFrame)` | Draw `====` header with centered title + breadcrumb | +| 10 | `tui_menu_left_col()` | `pub fn menu_left_col(frame: &TuiFrame) -> u16` | Compute left column for centered menu | +| 11 | `tui_render_menu()` | `pub fn render_menu(menu: &TuiMenu, left_col: u16)` | Render each menu item via `tui_print()` | +| 12 | `tui_render_status()` | `pub fn render_status_line(status: &TuiStatus)` | Print status text if non-empty | +| 13 | `tui_anchor_prompt()` | `pub fn anchor_prompt(filler_lines: u16, left_col: u16)` | Fill blank lines and position cursor | +| 14 | `tui_render_content_screen()` | `pub fn render_content_screen(frame: &TuiFrame, title: Option<&str>)` | `clear_continuous` + `render_top_frame` + optional title | +| 15 | `tui_render_screen()` | `pub fn render_screen(frame: &TuiFrame, menu: Option<&TuiMenu>, status: Option<&TuiStatus>)` | Full screen layout with filler lines | +| 16 | `tui_render_table()` | `pub fn render_table(table: &TuiTable)` | Column-aligned table with compact mode fallback | +| 17 | `tui_read_line()` | `pub fn read_line(buf: &mut [u8]) -> Result` | Read line with `fgets`-like semantics (cooked mode) | +| 18 | `tui_is_escape_input()` | `pub fn is_escape_input(input: &str) -> bool` | Check for `q`/`x`/`exit`/`quit`/`esc` | +| 19 | `tui_menu_match_key()` | `pub fn menu_match_key(menu: &TuiMenu, input: &str) -> Option` | Match single-char input to menu item shortcut | +| 20 | `tui_compute_unique_prefixes()` | `pub fn compute_unique_prefixes(ids: &[&str]) -> Vec` | Compute minimal unique prefix lengths | +| 21 | `tui_confirm()` | `pub fn confirm(prompt: &str) -> bool` | `[y/n]` with single-key in raw mode, line fallback | +| 22 | `tui_prompt_default()` | `pub fn prompt_default(prompt: &str, default: &str, out: &mut String) -> io::Result<()>` | Prompt with default value | +| 23 | `tui_press_enter()` | `pub fn press_enter(message: Option<&str>)` | Wait for any key with `tui_get_key()` | +| 24 | `tui_has_stdin_pipe()` | `pub fn has_stdin_pipe() -> bool` | Check `isatty(STDIN_FILENO)` via libc | + +## Implementation details + +### Hotkey markup (`tui_print`) +The `^_X^` → `\033[4mX\033[0m` (underline) and `^*X^` → `\033[1mX\033[0m` (bold) markup is central to how the C TUI renders labels. The Rust `tui_print` must: +1. Write `\r\n` at end (raw mode needs explicit CR) +2. Parse `^_` / `^*` / `^:` / `^^` sequences +3. Use `\x1b[4m` / `\x1b[1m` / `\x1b[0m` ANSI escape codes + +### SIGWINCH handling +The `g_resize_pending` static flag is set by a signal handler. In Rust, use `std::sync::atomic::AtomicBool`: + +```rust +use std::sync::atomic::{AtomicBool, Ordering}; +static RESIZE_PENDING: AtomicBool = AtomicBool::new(false); + +extern "C" fn handle_sigwinch(_: i32) { + RESIZE_PENDING.store(true, Ordering::SeqCst); +} +``` + +### `tui_get_key()` in Rust +```rust +pub enum TuiKey { + Char(char), + Eof, + Resize, +} + +pub fn get_key() -> io::Result { + use crossterm::event::{read, Event, KeyCode, KeyEvent}; + // Check for SIGWINCH first + if RESIZE_PENDING.swap(false, Ordering::SeqCst) { + return Ok(TuiKey::Resize); + } + // Block on crossterm::event::read() + match read()? { + Event::Key(KeyEvent { code: KeyCode::Char(c), .. }) => Ok(TuiKey::Char(c)), + Event::Resize(..) => Ok(TuiKey::Resize), + _ => Ok(TuiKey::Eof), + } +} +``` + +### `tui_render_table()` with callbacks +The C version uses a callback `get_cell(row, col, out, out_size, user_data)`. In Rust, use a closure: + +```rust +pub struct TuiTable<'a, F: Fn(usize, usize) -> String> { + pub columns: &'a [TuiColumn], + pub row_count: usize, + pub get_cell: F, +} +``` + +## Refactoring main.rs + +After the port is done, update `main.rs` to: +1. Remove `tui::init()`/`cleanup()` calls — use `tui_continuous::init()`/`cleanup()` +2. Use `tui_continuous::render_top_frame()` and `tui_continuous::render_table()` for the status screen +3. Use `tui_continuous::get_key()` instead of `tui::poll_key()` +4. Add `tui_continuous::install_resize_handler()` at startup +5. Add `tui_continuous::resize_pending()` check in the main loop +6. Add 'r' (refresh) and 'l' (lock/reunlock) key handlers +7. Add activity log + +## Dependencies + +No new dependencies needed. Uses: +- `crossterm` (already in Cargo.toml) for terminal size, raw mode +- `libc` (already in Cargo.toml) for SIGWINCH, isatty +- `std::sync::atomic` for resize flag + +## Phase plan + +### Phase 1: Core types and utilities +- `TuiSize`, `TuiMenuItem`, `TuiFrame`, `TuiMenu`, `TuiStatus`, `TuiColumn`, `TuiTable` types +- `terminal_size()`, `install_resize_handler()`, `resize_pending()` +- `init()`, `cleanup()`, `get_key()` + +### Phase 2: Print and rendering +- `print()` with hotkey markup +- `clear_continuous()`, `render_top_frame()`, `menu_left_col()` +- `render_menu()`, `render_status_line()`, `anchor_prompt()` +- `render_content_screen()`, `render_screen()` + +### Phase 3: Table rendering +- `render_table()` with compact mode fallback +- `compute_unique_prefixes()` + +### Phase 4: Input helpers +- `read_line()`, `is_escape_input()`, `menu_match_key()` +- `confirm()`, `prompt_default()`, `press_enter()`, `has_stdin_pipe()` + +### Phase 5: Integration +- Update `main.rs` to use `tui_continuous` +- Add activity log, lock/reunlock, refresh, SIGWINCH handling +- Remove or reduce `tui.rs` to just the high-level app screens \ No newline at end of file diff --git a/plans/tui_gap_analysis.md b/plans/tui_gap_analysis.md new file mode 100644 index 0000000..8e5c4e4 --- /dev/null +++ b/plans/tui_gap_analysis.md @@ -0,0 +1,150 @@ +# TUI Analysis: C `tui_continuous` vs Rust `tui.rs` + +## C TUI architecture + +The C version uses a vendored library `tui_continuous` (v0.0.9) from `resources/tui_continuous/`. It provides: + +### Rendering primitives +- `tui_clear_continuous(height)` — clear scrollback +- `tui_render_top_frame(&frame, width)` — draws a full-width box (╔═╗) with app name, version, breadcrumb +- `tui_render_table(&table)` — renders a table with header, dashed separator, aligned columns +- `tui_render_menu(&menu, left_col)` — renders menu items with shortcut keys +- `tui_render_content_screen(&frame, title)` — renders a content screen (approval, unlock, wizard) +- `tui_anchor_prompt(filler_lines, left_col)` — positions the input cursor +- `tui_print(fmt, ...)` — printf-like with hotkey markup (`^_X^` = underline, `^*X^` = bold, `^:` = reset) + +### Input primitives +- `tui_init()` / `tui_cleanup()` — raw mode management +- `tui_get_key()` — single key press (returns TUI_KEY_EOF, TUI_KEY_RESIZE, or 0-255) +- `tui_read_line(buf, len)` — read a line with editing +- `tui_resize_pending()` — check for SIGWINCH +- `tui_terminal_size()` — get terminal dimensions +- `tui_install_resize_handler()` — install SIGWINCH handler + +### Key types +- `TuiFrame` — `{app_name, app_version, breadcrumb}` +- `TuiMenu` — `{items[], count}` +- `TuiMenuItem` — `{label, shortcut}` +- `TuiTable` — `{columns, column_count, row_count, get_cell, ...}` +- `TuiColumn` — `{name, width, right_align}` +- `TuiSize` — `{width, height}` + +## Current Rust state + +The Rust `tui.rs` and `main.rs` use simple `println!` and `tprint!` (custom macro) with `crossterm` for key input. It does NOT use the `tui_continuous` conventions. + +## Gap analysis + +### 1. Frame rendering — missing +C calls `tui_render_top_frame(&frame, size.width)` which draws: +``` +╔══════════════════════════════════════════════════════════════╗ +║ n_signer v0.1.0 > Main Menu ║ +╚══════════════════════════════════════════════════════════════╝ +``` + +Rust does this manually with `tprint!` — the box art is there but not to spec. + +**Fix**: Port `tui_render_top_frame()` to Rust. The C source is in `resources/tui_continuous/tui_continuous.c`. + +### 2. Table rendering — missing +C uses `tui_render_table()` with `TuiColumn` config. The status screen shows: +``` +Role Purpose Curve Derivation path +---- ------- ----- --------------- +main nostr secp256k1 m/44'/1237'/0'/0/0 +``` + +Rust uses fixed-format `println!` without proper column alignment logic. + +**Fix**: Port `tui_render_table()` to Rust. + +### 3. Menu rendering — missing +C uses `tui_render_menu()` which renders: +``` +^_l^: lock/reunlock ^_r^: refresh ^_d^: display connections ^_q^:/x quit +``` + +Rust uses a plain `tprint!("[d] connection details [q] quit")` without menu struct or hotkey marking. + +**Fix**: Port `tui_render_menu()` to Rust. + +### 4. Content screen rendering — missing +C uses `tui_render_content_screen()` for approval/unlock/wizard screens. This draws a full-screen box with breadcrumb title. + +**Fix**: Port `tui_render_content_screen()` to Rust. + +### 5. Hotkey markup (`^_X^`, `^*X^`, `^:`) — missing +C parses `^_` → underline, `^*` → bold, `^:` → reset. All menu labels use this. Rust doesn't support markup. + +**Fix**: Port `tui_print()` with markup parsing to Rust. + +### 6. `tui_get_key()` with resize detection — missing +C's `tui_get_key()` returns `TUI_KEY_RESIZE` on SIGWINCH. Rust's `poll_key()` doesn't handle this — it just returns `TuiKey::Other`. + +**Fix**: Add resize detection to `poll_key()`. + +### 7. Activity log — missing +C maintains a `g_activity_log` with timestamps (e.g., "2024-01-15 10:30:45 signed event"). The status screen shows "Activity (latest first):". Rust doesn't have this. + +**Fix**: Add an `ActivityLog` struct and integrate it into the render loop. + +### 8. Lock/reunlock ('l' key) — missing +C supports pressing 'l' to lock the session (wipe keys, unload mnemonic) and re-prompt for the mnemonic. Rust doesn't have this. + +**Fix**: Add the 'l' key handler in the main loop. + +### 9. Refresh ('r' key) — missing +C re-renders the status screen on 'r'. Rust doesn't handle 'r'. + +**Fix**: Add the 'r' key handler. + +### 10. `tui_read_line()` — missing +C has `tui_read_line()` for line input in raw mode. Rust has `read_line_raw()` which is a basic implementation. C's version handles backspace, Ctrl-U (kill line), Ctrl-W (kill word), etc. + +**Fix**: Enhance `read_line_raw()` to match C's `tui_read_line()`. + +### 11. Connection info struct — missing +C uses `connection_info_entry_t` with `title`, `connection_string`, `example`, `extra` fields. Rust's `render_connections()` is hardcoded. + +**Fix**: Port the connection info struct and builder functions. + +### 12. `tui_confirm()` — missing +C has `tui_confirm()` for [y/n] prompts. Rust doesn't have this. + +**Fix**: Port `tui_confirm()` to Rust. + +### 13. `tui_press_enter()` — missing +C has `tui_press_enter()` for "Press Enter to continue..." prompts. Rust uses inline `read_line()`. + +**Fix**: Port `tui_press_enter()` to Rust. + +### 14. `tui_has_stdin_pipe()` — missing +C detects if stdin is a pipe vs TTY. Rust doesn't have this check. + +**Fix**: Port `tui_has_stdin_pipe()` to Rust. + +## Summary of changes needed + +| # | Feature | C function | Rust status | Effort | +|---|---------|-----------|-------------|--------| +| 1 | Frame rendering | `tui_render_top_frame()` | Manual box art | Medium | +| 2 | Table rendering | `tui_render_table()` | Fixed `println!` | Medium | +| 3 | Menu rendering | `tui_render_menu()` | Hardcoded string | Small | +| 4 | Content screen | `tui_render_content_screen()` | Missing | Medium | +| 5 | Hotkey markup | `tui_print()` | Missing | Medium | +| 6 | Key input with resize | `tui_get_key()` | Basic `poll_key()` | Small | +| 7 | Activity log | `activity_log_t` | Missing | Medium | +| 8 | Lock/reunlock | 'l' key handler | Missing | Medium | +| 9 | Refresh | 'r' key handler | Missing | Small | +| 10 | Line input | `tui_read_line()` | Basic `read_line_raw()` | Small | +| 11 | Connection info struct | `connection_info_entry_t` | Hardcoded | Small | +| 12 | Confirm prompt | `tui_confirm()` | Missing | Small | +| 13 | Press Enter | `tui_press_enter()` | Inline code | Small | +| 14 | Pipe detection | `tui_has_stdin_pipe()` | Missing | Small | +| 15 | SIGWINCH handling | `tui_install_resize_handler()` | Missing | Small | +| 16 | Approval callback | `tui_approval_cb()` | Inline in server.rs | Medium | + +## Implementation strategy + +The cleanest approach is to port the `tui_continuous` C library to Rust as a self-contained module, then update `tui.rs` and `main.rs` to use it. This avoids the formatting mismatch because the C version's `tui_print()` with `\r\n` is the correct approach for raw mode. \ No newline at end of file diff --git a/src/alg_cache.rs b/src/alg_cache.rs new file mode 100644 index 0000000..154c1f9 --- /dev/null +++ b/src/alg_cache.rs @@ -0,0 +1,207 @@ +//! Algorithm key cache — on-demand key derivation by (algorithm, index). +//! +//! Port of the algorithm_key_cache from `key_store.c`. Holds up to +//! ALG_KEY_CACHE_MAX derived keys in secure memory. FIFO eviction. + +use crate::mnemonic::MnemonicState; +use crate::pq_crypto::CryptoAlg; +use crate::secure_mem::SecureBuf; +use crate::NsignerError; + +pub const ALG_KEY_CACHE_MAX: usize = 32; + +/// A cached algorithm key entry. +pub struct AlgKeyEntry { + pub alg: CryptoAlg, + pub index: i32, + pub private_key: SecureBuf, + pub public_key: SecureBuf, + pub pubkey_hex: String, + pub key_id: String, // first 16 hex chars of pubkey + pub valid: bool, +} + +/// Algorithm key cache — FIFO eviction when full. +pub struct AlgorithmKeyCache { + entries: Vec, +} + +impl AlgorithmKeyCache { + pub fn new() -> Self { + AlgorithmKeyCache { + entries: Vec::with_capacity(ALG_KEY_CACHE_MAX), + } + } + + /// Look up a cached entry by (alg, index). + pub fn get(&self, alg: CryptoAlg, index: i32) -> Option<&AlgKeyEntry> { + self.entries + .iter() + .find(|e| e.alg == alg && e.index == index && e.valid) + } + + /// Derive a key on-demand by (alg, index) and store it in the cache. + /// Uses the standard derivation path for the algorithm. + pub fn derive( + &mut self, + mnemonic: &MnemonicState, + alg: CryptoAlg, + index: i32, + ) -> Result<(), NsignerError> { + if !mnemonic.is_loaded() { + return Err(NsignerError::MnemonicNotLoaded); + } + + // Already cached? + if self.get(alg, index).is_some() { + return Ok(()); + } + + // Evict oldest if full (FIFO) + if self.entries.len() >= ALG_KEY_CACHE_MAX { + self.entries.remove(0); + } + + let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?; + + // Build the standard derivation path for this algorithm + let path = match alg { + CryptoAlg::Secp256k1 => format!("m/44'/1237'/{}'/0/0", index), + CryptoAlg::Ed25519 => format!("m/44'/102001'/{}'/0'/0'", index), + CryptoAlg::X25519 => format!("m/44'/102002'/{}'/0'/0'", index), + CryptoAlg::MlDsa65 => format!("m/44'/102003'/{}'/0'/0'", index), + CryptoAlg::SlhDsa128s => format!("m/44'/102004'/{}'/0'/0'", index), + CryptoAlg::MlKem768 => format!("m/44'/102005'/{}'/0'/0'", index), + CryptoAlg::Unknown => return Err(NsignerError::InvalidInput), + }; + + let entry = derive_alg_key(phrase, &path, alg, index)?; + self.entries.push(entry); + Ok(()) + } + + /// Zeroize and free all entries. + pub fn wipe(&mut self) { + self.entries.clear(); + } +} + +impl Default for AlgorithmKeyCache { + fn default() -> Self { + Self::new() + } +} + +/// Derive a single algorithm key entry. +fn derive_alg_key( + mnemonic_phrase: &str, + path: &str, + alg: CryptoAlg, + index: i32, +) -> Result { + let sizes = alg + .sizes() + .ok_or(NsignerError::KeyDerivationFailed)?; + + let seed = crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?; + + match alg { + CryptoAlg::Secp256k1 => { + // Full BIP-32 derivation + let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic_phrase, ""); + let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed); + let path_indices = nips::nip006::parse_bip44_path(path) + .map_err(|_| NsignerError::KeyDerivationFailed)?; + let (derived_key, _) = nips::nip006::bip32_derive_path( + &master_key, + &master_chain_code, + &path_indices, + ) + .map_err(|_| NsignerError::KeyDerivationFailed)?; + + let mut priv_arr = [0u8; 32]; + priv_arr.copy_from_slice(&derived_key); + let sk = nostr_core::types::SecretKey::from_bytes(priv_arr); + let pk = nostr_core::crypto::keys::public_key_from_secret_key(&sk) + .map_err(|_| NsignerError::CryptoFailed)?; + + let pubkey_hex = hex::encode(pk.as_bytes()); + let key_id = if pubkey_hex.len() >= 16 { + pubkey_hex[..16].to_string() + } else { + pubkey_hex.clone() + }; + + let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?; + priv_buf.copy_from(&priv_arr); + + let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?; + pub_buf.copy_from_slice(pk.as_bytes()); + + Ok(AlgKeyEntry { + alg, + index, + private_key: priv_buf, + public_key: pub_buf, + pubkey_hex, + key_id, + valid: true, + }) + } + CryptoAlg::Ed25519 => { + let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed); + let pubkey_hex = hex::encode(&pub_bytes); + let key_id = if pubkey_hex.len() >= 16 { + pubkey_hex[..16].to_string() + } else { + pubkey_hex.clone() + }; + + let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?; + priv_buf.copy_from(&priv_bytes); + + let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?; + pub_buf.copy_from(&pub_bytes); + + Ok(AlgKeyEntry { + alg, + index, + private_key: priv_buf, + public_key: pub_buf, + pubkey_hex, + key_id, + valid: true, + }) + } + CryptoAlg::X25519 => { + let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed); + let pubkey_hex = hex::encode(&pub_bytes); + let key_id = if pubkey_hex.len() >= 16 { + pubkey_hex[..16].to_string() + } else { + pubkey_hex.clone() + }; + + let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?; + priv_buf.copy_from(&priv_bytes); + + let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?; + pub_buf.copy_from(&pub_bytes); + + Ok(AlgKeyEntry { + alg, + index, + private_key: priv_buf, + public_key: pub_buf, + pubkey_hex, + key_id, + valid: true, + }) + } + CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => { + // PQ algorithms — TODO: Phase 13 + Err(NsignerError::NotYetImplemented) + } + CryptoAlg::Unknown => Err(NsignerError::InvalidInput), + } +} diff --git a/src/auth_envelope.rs b/src/auth_envelope.rs new file mode 100644 index 0000000..6610607 --- /dev/null +++ b/src/auth_envelope.rs @@ -0,0 +1,284 @@ +//! Auth envelope — request authentication for TCP/qrexec transports. +//! +//! Port of `auth_envelope.c`. Verifies a NIP-42-style auth envelope +//! in JSON-RPC requests. Checks signature, created_at freshness, +//! and replay protection. + +use nostr_core::types::Event; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// NIP-42 auth event kind. +pub const AUTH_EVENT_KIND: u64 = 22242; + +/// Default timestamp skew tolerance (seconds). +pub const AUTH_DEFAULT_SKEW_SECONDS: i32 = 300; + +/// Auth nonce cache for replay protection. +/// +/// Tracks per-pubkey monotonic timestamps + event IDs for same-second requests. +pub struct AuthNonceCache { + // pubkey_hex → (max_created_at, event_ids) + entries: HashMap)>, +} + +impl AuthNonceCache { + pub fn new() -> Self { + AuthNonceCache { + entries: HashMap::new(), + } + } + + /// Check and update replay protection. + /// + /// Returns true if the (pubkey, created_at, event_id) tuple is acceptable. + /// Returns false if it's a replay. + pub fn check_and_update( + &mut self, + pubkey_hex: &str, + created_at: i64, + event_id: &[u8; 32], + ) -> bool { + let entry = self + .entries + .entry(pubkey_hex.to_string()) + .or_insert((0, Vec::new())); + + if created_at > entry.0 { + // Newer timestamp: update max, clear event ID set, accept + entry.0 = created_at; + entry.1.clear(); + entry.1.push(*event_id); + true + } else if created_at == entry.0 { + // Same timestamp: check event ID set for duplicates + if entry.1.contains(event_id) { + false // duplicate event ID + } else { + entry.1.push(*event_id); + true + } + } else { + // Older timestamp: reject as replay + false + } + } +} + +impl Default for AuthNonceCache { + fn default() -> Self { + Self::new() + } +} + +/// Auth error codes. +pub const AUTH_ERR_ENVELOPE_MALFORMED: i32 = 3001; +pub const AUTH_ERR_SIGNATURE_INVALID: i32 = 3002; +pub const AUTH_ERR_KIND_INVALID: i32 = 3003; +pub const AUTH_ERR_ENVELOPE_MISMATCH: i32 = 3004; +pub const AUTH_ERR_BODY_MISMATCH: i32 = 3005; +pub const AUTH_ERR_ENVELOPE_STALE: i32 = 3006; +pub const AUTH_ERR_REPLAY_DETECTED: i32 = 3007; +pub const AUTH_ERR_ENVELOPE_REQUIRED: i32 = 3008; + +/// Verify an auth envelope in a JSON-RPC request. +/// +/// On success, returns (pubkey_hex, label). +/// On failure, returns (error_code, error_message). +pub fn verify_request( + request_json: &str, + cache: &mut AuthNonceCache, + skew_seconds: i32, +) -> Result<(String, String), (i32, &'static str)> { + let root: serde_json::Value = serde_json::from_str(request_json) + .map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?; + + // Extract method and id from the request + let method = root + .get("method") + .and_then(|v| v.as_str()) + .ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?; + + let request_id = root + .get("id") + .map(|v| { + if let Some(s) = v.as_str() { + s.to_string() + } else { + v.to_string() + } + }) + .ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?; + + // Extract auth envelope + let auth = root + .get("auth") + .ok_or((AUTH_ERR_ENVELOPE_REQUIRED, "auth_envelope_required"))?; + + // Parse auth as a Nostr event + let auth_event: Event = serde_json::from_value(auth.clone()) + .map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?; + + // Validate event structure + nips::nip001::validate_event_structure(&auth_event) + .map_err(|_| (AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?; + + // Verify event signature + nips::nip001::verify_event_signature(&auth_event) + .map_err(|_| (AUTH_ERR_SIGNATURE_INVALID, "auth_signature_invalid"))?; + + // Check kind is AUTH_EVENT_KIND (22242) + if auth_event.kind.as_u64() != AUTH_EVENT_KIND { + return Err((AUTH_ERR_KIND_INVALID, "auth_kind_invalid")); + } + + // Extract tags: nsigner_rpc, nsigner_method, nsigner_body_hash + let mut tag_rpc: Option = None; + let mut tag_method: Option = None; + let mut tag_body_hash: Option = None; + + for tag in &auth_event.tags { + if tag.kind() == "nsigner_rpc" { + tag_rpc = tag.get(1).map(|s| s.to_string()); + } else if tag.kind() == "nsigner_method" { + tag_method = tag.get(1).map(|s| s.to_string()); + } else if tag.kind() == "nsigner_body_hash" { + tag_body_hash = tag.get(1).map(|s| s.to_string()); + } + } + + let tag_rpc = tag_rpc.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?; + let tag_method = + tag_method.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?; + let tag_body_hash = + tag_body_hash.ok_or((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))?; + + // Verify tags match request + if tag_rpc != request_id || tag_method != method { + return Err((AUTH_ERR_ENVELOPE_MISMATCH, "auth_envelope_mismatch")); + } + + // Compute body hash (SHA-256 of the params array) + let params = root.get("params").unwrap_or(&serde_json::Value::Null); + let params_compact = serde_json::to_string(params).unwrap_or_default(); + let body_hash = nostr_core::crypto::sha256::sha256(params_compact.as_bytes()); + let body_hash_hex = hex::encode(&body_hash); + + if !tag_body_hash.eq_ignore_ascii_case(&body_hash_hex) { + return Err((AUTH_ERR_BODY_MISMATCH, "auth_body_mismatch")); + } + + // Check timestamp skew + let skew = if skew_seconds <= 0 { + AUTH_DEFAULT_SKEW_SECONDS + } else { + skew_seconds + }; + + let now = current_timestamp(); + let created = auth_event.created_at as i64; + if (now - created).abs() > skew as i64 { + return Err((AUTH_ERR_ENVELOPE_STALE, "auth_envelope_stale")); + } + + // Extract pubkey + let pubkey_hex = auth_event.pubkey.to_string(); + + // Extract event ID for replay protection + let event_id = match &auth_event.id { + Some(id) => id.as_bytes(), + None => return Err((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed")), + }; + + // Replay protection + if !cache.check_and_update(&pubkey_hex, created, event_id) { + return Err((AUTH_ERR_REPLAY_DETECTED, "auth_replay_detected")); + } + + // Extract label from content + let label = auth_event.content.clone(); + + Ok((pubkey_hex, label)) +} + +/// Get current Unix timestamp in seconds. +fn current_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nonce_cache_new_timestamp() { + let mut cache = AuthNonceCache::new(); + let event_id = [1u8; 32]; + assert!(cache.check_and_update("pubkey1", 1000, &event_id)); + } + + #[test] + fn test_nonce_cache_same_timestamp_different_id() { + let mut cache = AuthNonceCache::new(); + let id1 = [1u8; 32]; + let id2 = [2u8; 32]; + assert!(cache.check_and_update("pubkey1", 1000, &id1)); + assert!(cache.check_and_update("pubkey1", 1000, &id2)); + } + + #[test] + fn test_nonce_cache_replay_rejected() { + let mut cache = AuthNonceCache::new(); + let event_id = [1u8; 32]; + assert!(cache.check_and_update("pubkey1", 1000, &event_id)); + // Same timestamp + same event ID = replay + assert!(!cache.check_and_update("pubkey1", 1000, &event_id)); + } + + #[test] + fn test_nonce_cache_older_timestamp_rejected() { + let mut cache = AuthNonceCache::new(); + let event_id = [1u8; 32]; + assert!(cache.check_and_update("pubkey1", 1000, &event_id)); + // Older timestamp = replay + assert!(!cache.check_and_update("pubkey1", 999, &event_id)); + } + + #[test] + fn test_nonce_cache_newer_timestamp_clears() { + let mut cache = AuthNonceCache::new(); + let id1 = [1u8; 32]; + let id2 = [2u8; 32]; + assert!(cache.check_and_update("pubkey1", 1000, &id1)); + // Newer timestamp clears the set, so id1 is acceptable again + assert!(cache.check_and_update("pubkey1", 1001, &id1)); + assert!(cache.check_and_update("pubkey1", 1001, &id2)); + } + + #[test] + fn test_nonce_cache_different_pubkeys_independent() { + let mut cache = AuthNonceCache::new(); + let event_id = [1u8; 32]; + assert!(cache.check_and_update("pubkey1", 1000, &event_id)); + // Different pubkey with same timestamp + event ID is fine + assert!(cache.check_and_update("pubkey2", 1000, &event_id)); + } + + #[test] + fn test_verify_request_no_auth() { + let mut cache = AuthNonceCache::new(); + let request = r#"{"id":"1","method":"get_info","params":[]}"#; + let result = verify_request(request, &mut cache, 300); + assert_eq!(result, Err((AUTH_ERR_ENVELOPE_REQUIRED, "auth_envelope_required"))); + } + + #[test] + fn test_verify_request_malformed_json() { + let mut cache = AuthNonceCache::new(); + let result = verify_request("not valid json", &mut cache, 300); + assert_eq!(result, Err((AUTH_ERR_ENVELOPE_MALFORMED, "auth_envelope_malformed"))); + } +} diff --git a/src/dispatcher.rs b/src/dispatcher.rs new file mode 100644 index 0000000..fcb3fff --- /dev/null +++ b/src/dispatcher.rs @@ -0,0 +1,757 @@ +//! Dispatcher — JSON-RPC 2.0 request routing. +//! +//! Port of `dispatcher.c`. Routes verbs to the appropriate handler +//! (algorithm-based, nostr, OTP, or metadata). + +use crate::alg_cache::AlgorithmKeyCache; +use crate::enforcement; +use crate::error::RpcError; +use crate::key_store::KeyStore; +use crate::mnemonic::MnemonicState; +use crate::pq_crypto::CryptoAlg; +use crate::role_table::RoleTable; +use crate::selector::{selector_resolve, SelectorRequest}; +use crate::NsignerError; + +use serde_json::{json, Value}; + +use base64::Engine; + +/// Dispatcher context — holds references to shared state. +pub struct DispatcherContext<'a> { + pub role_table: &'a mut RoleTable, + pub mnemonic: &'a MnemonicState, + pub key_store: &'a mut KeyStore, + pub alg_key_cache: &'a mut AlgorithmKeyCache, +} + +/// Process a JSON-RPC request string and produce a JSON-RPC response string. +/// +/// Response format on success: `{"id":"...","result":"..."}` +/// Response format on error: `{"id":"...","error":{"code":N,"message":"..."}}` +pub fn handle_request(ctx: &mut DispatcherContext, json_request: &str) -> String { + let root: Value = match serde_json::from_str(json_request) { + Ok(v) => v, + Err(_) => return make_error_response("null", RpcError::PARSE_ERROR), + }; + + let id = root + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("null") + .to_string(); + + let method = match root.get("method").and_then(|v| v.as_str()) { + Some(m) => m, + None => return make_error_response(&id, RpcError::INVALID_REQUEST), + }; + + let params = match root.get("params") { + Some(p) if p.is_array() => p.as_array().unwrap(), + _ => return make_error_response(&id, RpcError::INVALID_REQUEST), + }; + + // Extract options (last param if it's an object) + let options = params + .last() + .and_then(|v| if v.is_object() { Some(v) } else { None }); + + // ── Route ────────────────────────────────────────────────────────── + + // Algorithm-based verbs (bypass role table) + if enforcement::is_algorithm_verb(method) { + return handle_algorithm_verb(ctx, &id, method, params, options); + } + + // get_info (metadata; no key material) + if method == enforcement::VERB_GET_INFO { + return handle_get_info(&id); + } + + // OTP verbs (encrypt/decrypt with algorithm:"otp") + if method == enforcement::VERB_ENCRYPT || method == enforcement::VERB_DECRYPT { + if let Some(opts) = options { + if opts.get("algorithm").and_then(|v| v.as_str()) == Some("otp") { + return handle_otp_verb(&id, method, params); + } + } + // Non-OTP encrypt/decrypt with other algorithms + return make_error_response(&id, RpcError::ALGORITHM_NOT_SUPPORTED); + } + + // Nostr verbs (role-based) + if is_nostr_verb(method) { + return handle_nostr_verb(ctx, &id, method, params, options); + } + + make_error_response(&id, RpcError::METHOD_NOT_FOUND) +} + +// ── Algorithm Verb Handler ─────────────────────────────────────────────────── + +fn handle_algorithm_verb( + ctx: &mut DispatcherContext, + id: &str, + method: &str, + params: &[Value], + options: Option<&Value>, +) -> String { + let alg = match options.and_then(|o| o.get("algorithm")).and_then(|v| v.as_str()) { + Some(s) => CryptoAlg::from_str(s), + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + + if alg == CryptoAlg::Unknown { + return make_error_response( + id, + RpcError { + code: -32602, + message: "missing_or_invalid_algorithm", + }, + ); + } + + let index = options + .and_then(|o| o.get("index")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + + // Enforcement: check verb+algorithm validity + if let Err(_) = enforcement::enforce_verb_algorithm(method, alg) { + return make_error_response(id, RpcError::ALGORITHM_NOT_SUPPORTED); + } + + // Mnemonic must be loaded + if !ctx.mnemonic.is_loaded() { + return make_error_response(id, RpcError::MNEMONIC_NOT_LOADED); + } + + // Derive the key on demand + if let Err(_) = ctx.alg_key_cache.derive(ctx.mnemonic, alg, index) { + return make_error_response( + id, + RpcError { + code: -32602, + message: "key_derivation_failed", + }, + ); + } + + let key_entry = match ctx.alg_key_cache.get(alg, index) { + Some(e) => e, + None => { + return make_error_response( + id, + RpcError { + code: -32602, + message: "key_derivation_failed", + }, + ) + } + }; + + let alg_name = alg.as_str(); + let key_id = &key_entry.key_id; + + // ── Dispatch by verb ────────────────────────────────────────────── + + match method { + enforcement::VERB_GET_PUBLIC_KEY => { + let result = json!({ + "algorithm": alg_name, + "public_key": key_entry.pubkey_hex, + "key_id": key_id, + }); + make_success_response(id, &result.to_string()) + } + + enforcement::VERB_SIGN => { + let msg_hex = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let msg_bytes = match hex::decode(msg_hex) { + Ok(b) => b, + Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + if msg_bytes.is_empty() { + return make_error_response(id, RpcError::INVALID_PARAMS); + } + + let priv_slice = key_entry.private_key.as_slice(); + let sig = sign_with_alg(alg, &priv_slice[..32].try_into().unwrap(), &msg_bytes); + match sig { + Ok(s) => { + let sig_hex = hex::encode(&s); + let result = json!({ + "algorithm": alg_name, + "key_id": key_id, + "signature": sig_hex, + }); + make_success_response(id, &result.to_string()) + } + Err(_) => make_error_response( + id, + RpcError { + code: -32602, + message: "signing_failed", + }, + ), + } + } + + enforcement::VERB_VERIFY => { + let msg_hex = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let sig_hex = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let msg_bytes = match hex::decode(msg_hex) { + Ok(b) => b, + Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let sig_bytes = match hex::decode(sig_hex) { + Ok(b) => b, + Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + + let pub_slice = key_entry.public_key.as_slice(); + let valid = verify_with_alg(alg, pub_slice, &msg_bytes, &sig_bytes); + let result = json!({ + "valid": valid, + "algorithm": alg_name, + }); + make_success_response(id, &result.to_string()) + } + + enforcement::VERB_DERIVE_SHARED => { + if alg != CryptoAlg::X25519 { + return make_error_response(id, RpcError::ALGORITHM_NOT_SUPPORTED); + } + let peer_hex = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let peer_bytes = match hex::decode(peer_hex) { + Ok(b) if b.len() == 32 => b, + _ => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + + let priv_slice = key_entry.private_key.as_slice(); + let mut priv_arr = [0u8; 32]; + priv_arr.copy_from_slice(&priv_slice[..32]); + let peer_arr: [u8; 32] = peer_bytes[..32].try_into().unwrap(); + let shared = crate::pq_crypto::x25519_ecdh(&priv_arr, &peer_arr); + let shared_hex = hex::encode(&shared); + let result = json!({ + "shared_secret": shared_hex, + "algorithm": "x25519", + }); + make_success_response(id, &result.to_string()) + } + + enforcement::VERB_DERIVE => { + // HMAC-SHA256(privkey, data) — index is required + let data_str = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + // index is required for derive (no default) + let has_index = options + .and_then(|o| o.get("index")) + .and_then(|v| v.as_i64()) + .is_some(); + if !has_index { + return make_error_response( + id, + RpcError { + code: -32602, + message: "missing_index", + }, + ); + } + + let priv_slice = key_entry.private_key.as_slice(); + let mac = nostr_core::crypto::hmac::hmac_sha256(priv_slice, data_str.as_bytes()); + let mac_hex = hex::encode(&mac); + let result = json!({ + "algorithm": alg_name, + "key_id": key_id, + "digest": mac_hex, + }); + make_success_response(id, &result.to_string()) + } + + enforcement::VERB_ENCAPSULATE => { + // ML-KEM-768 only — TODO: Phase 13 + make_error_response(id, RpcError::NOT_YET_IMPLEMENTED) + } + + enforcement::VERB_DECAPSULATE => { + // ML-KEM-768 only — TODO: Phase 13 + make_error_response(id, RpcError::NOT_YET_IMPLEMENTED) + } + + _ => make_error_response(id, RpcError::METHOD_NOT_FOUND), + } +} + +// ── Nostr Verb Handler ─────────────────────────────────────────────────────── + +fn handle_nostr_verb( + ctx: &mut DispatcherContext, + id: &str, + method: &str, + params: &[Value], + options: Option<&Value>, +) -> String { + // Parse selector from options + let mut sel = SelectorRequest::new(); + if let Some(opts) = options { + if let Some(role) = opts.get("role").and_then(|v| v.as_str()) { + sel.has_role = true; + sel.role_name = role.to_string(); + } + if let Some(path) = opts.get("role_path").and_then(|v| v.as_str()) { + sel.has_role_path = true; + sel.role_path = path.to_string(); + } + } + + // Resolve selector + let role_index = match selector_resolve(&sel, ctx.role_table) { + Ok(i) => i, + Err(e) => { + return make_error_response( + id, + match e { + crate::selector::SelectorError::Ambiguous => RpcError::AMBIGUOUS_ROLE_SELECTOR, + crate::selector::SelectorError::NotFound => RpcError::UNKNOWN_ROLE, + crate::selector::SelectorError::NoDefault => RpcError::NO_DEFAULT_ROLE, + crate::selector::SelectorError::PathMismatch => RpcError::PATH_NOT_ALLOWED, + crate::selector::SelectorError::RoleRequired => RpcError::ROLE_REQUIRED, + crate::selector::SelectorError::PathRequired => RpcError::PATH_REQUIRED, + _ => RpcError::INVALID_PARAMS, + }, + ) + } + }; + + // Enforce verb+role + let role = &ctx.role_table.entries[role_index]; + if let Err(_) = enforcement::enforce_verb_role(method, role) { + return make_error_response( + id, + RpcError { + code: 1004, + message: "purpose_mismatch", + }, + ); + } + + // Mnemonic must be loaded + if !ctx.mnemonic.is_loaded() { + return make_error_response(id, RpcError::MNEMONIC_NOT_LOADED); + } + + // Ensure key is derived + if !role.derived { + if let Err(_) = ctx + .key_store + .derive_one(ctx.role_table, ctx.mnemonic, role_index) + { + return make_error_response( + id, + RpcError { + code: -32602, + message: "key_derivation_failed", + }, + ); + } + } + + // Dispatch by verb + match method { + enforcement::VERB_NOSTR_GET_PUBLIC_KEY => { + let pub_hex = ctx.key_store.get_pubkey_hex(role_index).unwrap_or(""); + // Check for structured format option + let want_structured = options + .and_then(|o| o.get("format")) + .and_then(|v| v.as_str()) + == Some("structured"); + + if want_structured { + let key_id = if pub_hex.len() >= 16 { + &pub_hex[..16] + } else { + &pub_hex + }; + let result = json!({ + "algorithm": "secp256k1", + "public_key": pub_hex, + "key_id": key_id, + }); + make_success_response(id, &result.to_string()) + } else { + // Plain hex string + make_success_response(id, &format!("\"{}\"", pub_hex)) + } + } + + enforcement::VERB_NOSTR_SIGN_EVENT => { + let event_json = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + match ctx.key_store.sign_event(role_index, event_json) { + Ok(signed) => make_success_response(id, &format!("\"{}\"", signed)), + Err(_) => make_error_response(id, RpcError::INVALID_PARAMS), + } + } + + enforcement::VERB_NOSTR_MINE_EVENT => { + // TODO: Phase 10 — NIP-13 mining + make_error_response(id, RpcError::NOT_YET_IMPLEMENTED) + } + + enforcement::VERB_NOSTR_NIP44_ENCRYPT => { + let peer_hex = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let plaintext = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + match ctx.key_store.nip44_encrypt(role_index, peer_hex, plaintext) { + Ok(ct) => { + let ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct); + make_success_response(id, &format!("\"{}\"", ct_b64)) + } + Err(_) => make_error_response(id, RpcError::INVALID_PARAMS), + } + } + + enforcement::VERB_NOSTR_NIP44_DECRYPT => { + let peer_hex = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let ciphertext_b64 = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let ct = match base64::engine::general_purpose::STANDARD.decode(ciphertext_b64) { + Ok(b) => b, + Err(_) => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + match ctx.key_store.nip44_decrypt(role_index, peer_hex, &ct) { + Ok(pt) => { + let pt_b64 = base64::engine::general_purpose::STANDARD.encode(&pt); + make_success_response(id, &format!("\"{}\"", pt_b64)) + } + Err(_) => make_error_response(id, RpcError::INVALID_PARAMS), + } + } + + enforcement::VERB_NOSTR_NIP04_ENCRYPT => { + let peer_hex = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let plaintext = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + match ctx.key_store.nip04_encrypt(role_index, peer_hex, plaintext) { + Ok(ct) => make_success_response(id, &format!("\"{}\"", ct)), + Err(_) => make_error_response(id, RpcError::INVALID_PARAMS), + } + } + + enforcement::VERB_NOSTR_NIP04_DECRYPT => { + let peer_hex = match params.first().and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + let ciphertext = match params.get(1).and_then(|v| v.as_str()) { + Some(s) => s, + None => return make_error_response(id, RpcError::INVALID_PARAMS), + }; + match ctx.key_store.nip04_decrypt(role_index, peer_hex, ciphertext) { + Ok(pt) => make_success_response(id, &format!("\"{}\"", pt)), + Err(_) => make_error_response(id, RpcError::INVALID_PARAMS), + } + } + + _ => make_error_response(id, RpcError::METHOD_NOT_FOUND), + } +} + +// ── OTP Verb Handler ───────────────────────────────────────────────────────── + +fn handle_otp_verb(id: &str, method: &str, _params: &[Value]) -> String { + // TODO: Phase 12 — OTP pad encrypt/decrypt + let _ = method; + make_error_response( + id, + RpcError { + code: -32601, + message: "otp_pad_not_bound", + }, + ) +} + +// ── get_info ──────────────────────────────────────────────────────────────── + +fn handle_get_info(id: &str) -> String { + let info = json!({ + "name": "n_signer", + "implementation": "host", + "version": crate::VERSION, + "api": "json-rpc-2.0", + "verbs": [ + "get_info", "get_public_key", "sign", "verify", + "encapsulate", "decapsulate", "derive_shared_secret", "derive", + "encrypt", "decrypt", + "nostr_get_public_key", "nostr_sign_event", "nostr_mine_event", + "nostr_nip04_encrypt", "nostr_nip04_decrypt", + "nostr_nip44_encrypt", "nostr_nip44_decrypt", + ], + "algorithms": [ + "secp256k1", "ed25519", "x25519", + "ml-dsa-65", "slh-dsa-128s", "ml-kem-768", "otp", + ], + }); + make_success_response(id, &info.to_string()) +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +fn is_nostr_verb(verb: &str) -> bool { + matches!( + verb, + enforcement::VERB_NOSTR_GET_PUBLIC_KEY + | enforcement::VERB_NOSTR_SIGN_EVENT + | enforcement::VERB_NOSTR_MINE_EVENT + | enforcement::VERB_NOSTR_NIP44_ENCRYPT + | enforcement::VERB_NOSTR_NIP44_DECRYPT + | enforcement::VERB_NOSTR_NIP04_ENCRYPT + | enforcement::VERB_NOSTR_NIP04_DECRYPT + ) +} + +fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8; 32], msg: &[u8]) -> Result, NsignerError> { + match alg { + CryptoAlg::Secp256k1 => { + // Check for scheme option (schnorr default, ecdsa alternative) + // For now, default to schnorr + let sk = nostr_core::types::SecretKey::from_bytes(*priv_key); + let digest = nostr_core::crypto::sha256::sha256(msg); + let sig = nostr_core::crypto::keys::schnorr_sign(&sk, &digest)?; + Ok(sig.as_bytes().to_vec()) + } + CryptoAlg::Ed25519 => { + let sig = crate::pq_crypto::ed25519_sign(priv_key, msg); + Ok(sig.to_vec()) + } + CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_sign(priv_key, msg), + CryptoAlg::SlhDsa128s => crate::pq_crypto::slh_dsa_128s_sign(priv_key, msg), + _ => Err(NsignerError::CryptoFailed), + } +} + +fn verify_with_alg(alg: CryptoAlg, pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool { + match alg { + CryptoAlg::Secp256k1 => { + if pub_key.len() != 32 || sig.len() != 64 { + return false; + } + // Use schnorr verify (default) + let pub_arr: [u8; 32] = pub_key[..32].try_into().unwrap(); + let sig_arr: [u8; 64] = sig[..64].try_into().unwrap(); + let digest = nostr_core::crypto::sha256::sha256(msg); + let pk = nostr_core::types::PublicKey::from_bytes(pub_arr); + let signature = nostr_core::types::Signature::from_bytes(sig_arr); + nostr_core::crypto::keys::schnorr_verify(&pk, &digest, &signature).unwrap_or(false) + } + CryptoAlg::Ed25519 => { + if pub_key.len() != 32 || sig.len() != 64 { + return false; + } + let pub_arr: [u8; 32] = pub_key[..32].try_into().unwrap(); + let sig_arr: [u8; 64] = sig[..64].try_into().unwrap(); + crate::pq_crypto::ed25519_verify(&pub_arr, msg, &sig_arr) + } + CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_verify(pub_key, msg, sig), + CryptoAlg::SlhDsa128s => crate::pq_crypto::slh_dsa_128s_verify(pub_key, msg, sig), + _ => false, + } +} + +fn make_error_response(id: &str, err: RpcError) -> String { + format!( + r#"{{"id":"{}","error":{}}}"#, + id, + err.to_json() + ) +} + +fn make_success_response(id: &str, result: &str) -> String { + // result is already a JSON value string — wrap it as a JSON string + format!(r#"{{"id":"{}","result":{}}}"#, id, result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::alg_cache::AlgorithmKeyCache; + use crate::key_store::KeyStore; + use crate::mnemonic::MnemonicState; + use crate::role_table::*; + + fn make_ctx<'a>( + role_table: &'a mut RoleTable, + mnemonic: &'a MnemonicState, + key_store: &'a mut KeyStore, + alg_cache: &'a mut AlgorithmKeyCache, + ) -> DispatcherContext<'a> { + DispatcherContext { + role_table, + mnemonic, + key_store, + alg_key_cache: alg_cache, + } + } + + fn setup() -> ( + RoleTable, + MnemonicState, + KeyStore, + AlgorithmKeyCache, + ) { + let mut mnemonic = MnemonicState::new(); + mnemonic + .load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") + .unwrap(); + + let mut table = RoleTable::new(); + table + .register_role_path( + "main", + "m/44'/1237'/0'/0/0", + RolePurpose::Nostr, + RoleCurve::Secp256k1, + -1, -1, -1, &[], + ) + .unwrap(); + + let mut store = KeyStore::new(); + store.derive_all(&mut table, &mnemonic).unwrap(); + + (table, mnemonic, store, AlgorithmKeyCache::new()) + } + + #[test] + fn test_get_info() { + let (mut table, mnemonic, mut store, mut cache) = setup(); + let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache); + + let req = r#"{"id":"1","method":"get_info","params":[]}"#; + let resp = handle_request(&mut ctx, req); + assert!(resp.contains("\"result\"")); + assert!(resp.contains("n_signer")); + assert!(resp.contains("json-rpc-2.0")); + } + + #[test] + fn test_nostr_get_public_key() { + let (mut table, mnemonic, mut store, mut cache) = setup(); + let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache); + + let req = r#"{"id":"2","method":"nostr_get_public_key","params":[],"params":[{},{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#; + // Use proper format + let req = r#"{"id":"2","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#; + let resp = handle_request(&mut ctx, req); + assert!(resp.contains("\"result\"")); + assert!(!resp.contains("\"error\"")); + } + + #[test] + fn test_unknown_method() { + let (mut table, mnemonic, mut store, mut cache) = setup(); + let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache); + + let req = r#"{"id":"3","method":"nonexistent_method","params":[]}"#; + let resp = handle_request(&mut ctx, req); + assert!(resp.contains("\"error\"")); + assert!(resp.contains("-32601")); + } + + #[test] + fn test_parse_error() { + let (mut table, mnemonic, mut store, mut cache) = setup(); + let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache); + + let req = r#"not valid json"#; + let resp = handle_request(&mut ctx, req); + assert!(resp.contains("\"error\"")); + assert!(resp.contains("-32700")); + } + + #[test] + fn test_ed25519_get_public_key() { + let (mut table, mnemonic, mut store, mut cache) = setup(); + let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache); + + let req = r#"{"id":"4","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}"#; + let resp = handle_request(&mut ctx, req); + assert!(resp.contains("\"result\"")); + assert!(resp.contains("ed25519")); + } + + #[test] + fn test_ed25519_sign_verify() { + let (mut table, mnemonic, mut store, mut cache) = setup(); + let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache); + + let msg_hex = hex::encode(b"hello world"); + let sign_req = format!( + r#"{{"id":"5","method":"sign","params":["{}"],{{"algorithm":"ed25519","index":0}}}}"#, + msg_hex + ); + // Fix JSON format + let sign_req = format!( + r#"{{"id":"5","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#, + msg_hex + ); + let resp = handle_request(&mut ctx, &sign_req); + assert!(resp.contains("\"result\""), "sign response: {}", resp); + assert!(resp.contains("signature")); + + // Extract signature from response + let resp_json: Value = serde_json::from_str(&resp).unwrap(); + let sig_hex = resp_json["result"]["signature"].as_str().unwrap(); + + // Verify + let verify_req = format!( + r#"{{"id":"6","method":"verify","params":["{}","{}",{{"algorithm":"ed25519","index":0}}]}}"#, + msg_hex, sig_hex + ); + let resp = handle_request(&mut ctx, &verify_req); + assert!(resp.contains("\"valid\":true")); + } + + #[test] + fn test_missing_algorithm() { + let (mut table, mnemonic, mut store, mut cache) = setup(); + let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache); + + let req = r#"{"id":"7","method":"get_public_key","params":[{}]}"#; + let resp = handle_request(&mut ctx, req); + assert!(resp.contains("\"error\"")); + } +} diff --git a/src/enforcement.rs b/src/enforcement.rs new file mode 100644 index 0000000..2d3955a --- /dev/null +++ b/src/enforcement.rs @@ -0,0 +1,191 @@ +//! Enforcement — verb/role/algorithm validation. +//! +//! Port of `enforcement.c`. Checks whether a verb is allowed +//! to execute against a role (purpose/curve) or algorithm. + +use crate::role_table::{RoleCurve, RoleEntry, RolePurpose}; + +// ── Error Codes ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnforceError { + Ok = 0, + PurposeMismatch = -1, + CurveMismatch = -2, + UnknownVerb = -3, + Algorithm = -4, +} + +// ── Verb Constants ─────────────────────────────────────────────────────────── + +// Algorithm-based verbs (algorithm is a parameter, not implicit). +pub const VERB_SIGN: &str = "sign"; +pub const VERB_VERIFY: &str = "verify"; +pub const VERB_ENCAPSULATE: &str = "encapsulate"; +pub const VERB_DECAPSULATE: &str = "decapsulate"; +pub const VERB_DERIVE_SHARED: &str = "derive_shared_secret"; +pub const VERB_DERIVE: &str = "derive"; +pub const VERB_GET_PUBLIC_KEY: &str = "get_public_key"; + +// Nostr protocol verbs (secp256k1 NIP-06, role-based selector). +pub const VERB_NOSTR_GET_PUBLIC_KEY: &str = "nostr_get_public_key"; +pub const VERB_NOSTR_SIGN_EVENT: &str = "nostr_sign_event"; +pub const VERB_NOSTR_MINE_EVENT: &str = "nostr_mine_event"; +pub const VERB_NOSTR_NIP44_ENCRYPT: &str = "nostr_nip44_encrypt"; +pub const VERB_NOSTR_NIP44_DECRYPT: &str = "nostr_nip44_decrypt"; +pub const VERB_NOSTR_NIP04_ENCRYPT: &str = "nostr_nip04_encrypt"; +pub const VERB_NOSTR_NIP04_DECRYPT: &str = "nostr_nip04_decrypt"; + +// OTP verbs (one-time pad; selected via algorithm:"otp"). +pub const VERB_ENCRYPT: &str = "encrypt"; +pub const VERB_DECRYPT: &str = "decrypt"; + +// Metadata verb (no key material, no approval, no role required). +pub const VERB_GET_INFO: &str = "get_info"; + +// ── Role-based Enforcement ─────────────────────────────────────────────────── + +/// Check whether `verb` is allowed to execute against `role`. +/// +/// Rules: +/// - All nostr verbs require purpose == Nostr and curve == Secp256k1 +/// - Unknown verbs return UnknownVerb (fail-closed) +pub fn enforce_verb_role(verb: &str, role: &RoleEntry) -> Result<(), EnforceError> { + let is_nostr_verb = matches!( + verb, + VERB_NOSTR_GET_PUBLIC_KEY + | VERB_NOSTR_SIGN_EVENT + | VERB_NOSTR_MINE_EVENT + | VERB_NOSTR_NIP44_ENCRYPT + | VERB_NOSTR_NIP44_DECRYPT + | VERB_NOSTR_NIP04_ENCRYPT + | VERB_NOSTR_NIP04_DECRYPT + ); + + if is_nostr_verb { + if role.purpose != RolePurpose::Nostr { + return Err(EnforceError::PurposeMismatch); + } + if role.curve != RoleCurve::Secp256k1 { + return Err(EnforceError::CurveMismatch); + } + return Ok(()); + } + + Err(EnforceError::UnknownVerb) +} + +// ── Algorithm-based Enforcement ─────────────────────────────────────────────── + +/// Check whether a verb is valid for an algorithm (algorithm-based enforcement). +/// Does NOT check purpose — purpose is irrelevant for the new verbs. +pub fn enforce_verb_algorithm(verb: &str, alg: crate::pq_crypto::CryptoAlg) -> Result<(), EnforceError> { + use crate::pq_crypto::CryptoAlg::*; + + match verb { + VERB_SIGN | VERB_VERIFY => match alg { + Secp256k1 | Ed25519 | MlDsa65 | SlhDsa128s => Ok(()), + _ => Err(EnforceError::Algorithm), + }, + VERB_ENCAPSULATE | VERB_DECAPSULATE => match alg { + MlKem768 => Ok(()), + _ => Err(EnforceError::Algorithm), + }, + VERB_DERIVE_SHARED => match alg { + X25519 => Ok(()), + _ => Err(EnforceError::Algorithm), + }, + VERB_DERIVE => match alg { + Secp256k1 => Ok(()), + _ => Err(EnforceError::Algorithm), + }, + VERB_GET_PUBLIC_KEY => match alg { + Secp256k1 | Ed25519 | X25519 | MlDsa65 | SlhDsa128s | MlKem768 => Ok(()), + _ => Err(EnforceError::Algorithm), + }, + _ => Err(EnforceError::UnknownVerb), + } +} + +/// Check if a verb is an algorithm-based verb (bypasses role table). +pub fn is_algorithm_verb(verb: &str) -> bool { + matches!( + verb, + VERB_SIGN + | VERB_VERIFY + | VERB_ENCAPSULATE + | VERB_DECAPSULATE + | VERB_DERIVE_SHARED + | VERB_GET_PUBLIC_KEY + | VERB_DERIVE + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::role_table::*; + + fn make_nostr_role() -> RoleEntry { + let mut r = RoleEntry::default(); + r.name = "main".into(); + r.purpose = RolePurpose::Nostr; + r.curve = RoleCurve::Secp256k1; + r + } + + fn make_ssh_role() -> RoleEntry { + let mut r = RoleEntry::default(); + r.name = "ssh".into(); + r.purpose = RolePurpose::Ssh; + r.curve = RoleCurve::Ed25519; + r + } + + #[test] + fn test_nostr_verb_on_nostr_role() { + let role = make_nostr_role(); + assert!(enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &role).is_ok()); + assert!(enforce_verb_role(VERB_NOSTR_GET_PUBLIC_KEY, &role).is_ok()); + } + + #[test] + fn test_nostr_verb_on_ssh_role_fails() { + let role = make_ssh_role(); + assert_eq!( + enforce_verb_role(VERB_NOSTR_SIGN_EVENT, &role), + Err(EnforceError::PurposeMismatch) + ); + } + + #[test] + fn test_unknown_verb_fails() { + let role = make_nostr_role(); + assert_eq!( + enforce_verb_role("unknown_verb", &role), + Err(EnforceError::UnknownVerb) + ); + } + + #[test] + fn test_algorithm_sign() { + use crate::pq_crypto::CryptoAlg::*; + assert!(enforce_verb_algorithm(VERB_SIGN, Secp256k1).is_ok()); + assert!(enforce_verb_algorithm(VERB_SIGN, Ed25519).is_ok()); + assert!(enforce_verb_algorithm(VERB_SIGN, MlDsa65).is_ok()); + assert_eq!( + enforce_verb_algorithm(VERB_SIGN, X25519), + Err(EnforceError::Algorithm) + ); + } + + #[test] + fn test_algorithm_encapsulate() { + use crate::pq_crypto::CryptoAlg::*; + assert!(enforce_verb_algorithm(VERB_ENCAPSULATE, MlKem768).is_ok()); + assert_eq!( + enforce_verb_algorithm(VERB_ENCAPSULATE, Secp256k1), + Err(EnforceError::Algorithm) + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0341dae --- /dev/null +++ b/src/error.rs @@ -0,0 +1,93 @@ +//! Error types for nsigner. +//! +//! JSON-RPC error codes are preserved exactly for wire compatibility +//! with the C n_signer. + +use thiserror::Error; + +/// nsigner-specific errors (internal operations). +#[derive(Error, Debug, Clone)] +pub enum NsignerError { + #[error("invalid input")] + InvalidInput, + #[error("memory allocation failed (mlock)")] + MemoryFailed, + #[error("I/O operation failed: {0}")] + IoFailed(String), + #[error("crypto operation failed")] + CryptoFailed, + #[error("key derivation failed")] + KeyDerivationFailed, + #[error("mnemonic not loaded")] + MnemonicNotLoaded, + #[error("role not found")] + RoleNotFound, + #[error("not found")] + NotFound, + #[error("policy denied")] + PolicyDenied, + #[error("not yet implemented")] + NotYetImplemented, + #[error("internal error: {0}")] + Internal(String), +} + +impl From for NsignerError { + fn from(e: nostr_core::error::NostrError) -> Self { + // Map NostrError to NsignerError + match e { + nostr_core::error::NostrError::InvalidInput => NsignerError::InvalidInput, + nostr_core::error::NostrError::CryptoFailed => NsignerError::CryptoFailed, + _ => NsignerError::CryptoFailed, + } + } +} + +/// JSON-RPC 2.0 error code + message (wire format). +/// +/// These codes match the C n_signer exactly for client compatibility. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RpcError { + pub code: i32, + pub message: &'static str, +} + +impl RpcError { + // ── JSON-RPC standard errors ────────────────────────────────────── + pub const PARSE_ERROR: Self = RpcError { code: -32700, message: "parse_error" }; + pub const INVALID_REQUEST: Self = RpcError { code: -32600, message: "invalid_request" }; + pub const METHOD_NOT_FOUND: Self = RpcError { code: -32601, message: "method_not_found" }; + pub const INVALID_PARAMS: Self = RpcError { code: -32602, message: "invalid_params" }; + pub const INTERNAL_ERROR: Self = RpcError { code: -32603, message: "internal_error" }; + + // ── nsigner-specific errors ────────────────────────────────────── + pub const AMBIGUOUS_ROLE_SELECTOR: Self = RpcError { code: 1001, message: "ambiguous_role_selector" }; + pub const UNKNOWN_ROLE: Self = RpcError { code: 1002, message: "unknown_role" }; + pub const NO_DEFAULT_ROLE: Self = RpcError { code: 1003, message: "no_default_role" }; + pub const PURPOSE_MISMATCH: Self = RpcError { code: 1004, message: "purpose_mismatch" }; + pub const CURVE_MISMATCH: Self = RpcError { code: 1005, message: "curve_mismatch" }; + pub const MNEMONIC_NOT_LOADED: Self = RpcError { code: 1006, message: "mnemonic_not_loaded" }; + pub const NO_TERMINATION_CONDITION: Self = RpcError { code: 1007, message: "no_termination_condition" }; + pub const MINING_FAILED: Self = RpcError { code: 1008, message: "mining_failed" }; + pub const NOT_YET_IMPLEMENTED: Self = RpcError { code: 1009, message: "not_yet_implemented" }; + pub const ALGORITHM_NOT_SUPPORTED: Self = RpcError { code: 1010, message: "algorithm_not_supported_for_verb" }; + + pub const PATH_NOT_ALLOWED: Self = RpcError { code: 2003, message: "path_not_allowed" }; + pub const INDEX_OUT_OF_RANGE: Self = RpcError { code: 2005, message: "index_out_of_range" }; + pub const ROLE_REQUIRED: Self = RpcError { code: 2008, message: "role_required" }; + pub const PATH_REQUIRED: Self = RpcError { code: 2009, message: "path_required" }; + + /// Serialize to a JSON string for the "error" field of a JSON-RPC response. + pub fn to_json(&self) -> String { + format!( + r#"{{"code":{},"message":"{}"}}"#, + self.code, self.message + ) + } +} + +impl std::fmt::Display for RpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.code, self.message) + } +} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..ee0f01b --- /dev/null +++ b/src/http.rs @@ -0,0 +1,180 @@ +//! Minimal HTTP/1.1 parser for nsigner's HTTP listener mode. +//! +//! Port of `http_listener.c`. Only supports POST with a JSON body. +//! No chunked encoding, no keep-alive, one request per connection. + +use std::io::{self, Read, Write}; + +/// Read an HTTP POST request and return the JSON body. +/// +/// Returns `Ok(body)` on success, `Err` on parse error / non-POST. +pub fn recv_request(reader: &mut R) -> io::Result { + let mut line = String::new(); + let mut content_length: usize = 0; + let mut is_post = false; + + // Read header lines until empty line + loop { + line.clear(); + read_line(reader, &mut line)?; + let trimmed = line.trim_end(); + if trimmed.is_empty() { + break; // End of headers + } + + if trimmed.starts_with("POST ") { + is_post = true; + } else if let Some(cl) = trimmed.strip_prefix("Content-Length: ").or_else(|| trimmed.strip_prefix("content-length: ")) { + content_length = cl.parse().unwrap_or(0); + } + } + + if !is_post { + return Err(io::Error::new(io::ErrorKind::InvalidData, "not a POST request")); + } + + if content_length == 0 || content_length > super::transport::MAX_MSG_SIZE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid content length")); + } + + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body)?; + String::from_utf8(body).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid UTF-8")) +} + +/// Send an HTTP 200 response with a JSON body and CORS headers. +pub fn send_response(writer: &mut W, json_body: &str) -> io::Result<()> { + let response = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: POST, OPTIONS\r\n\ + Access-Control-Allow-Headers: Content-Type\r\n\ + Connection: close\r\n\ + \r\n\ + {}", + json_body.len(), + json_body + ); + writer.write_all(response.as_bytes())?; + writer.flush()?; + Ok(()) +} + +/// Send an HTTP error response. +pub fn send_error(writer: &mut W, code: u16, message: &str) -> io::Result<()> { + let response = format!( + "HTTP/1.1 {} {}\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {{\"error\":\"{}\"}}", + code, + message, + message.len() + 12, + message + ); + writer.write_all(response.as_bytes())?; + writer.flush()?; + Ok(()) +} + +/// Send a CORS preflight response (for OPTIONS requests). +pub fn send_cors_preflight(writer: &mut W) -> io::Result<()> { + let response = "HTTP/1.1 204 No Content\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: POST, OPTIONS\r\n\ + Access-Control-Allow-Headers: Content-Type\r\n\ + Access-Control-Max-Age: 86400\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\ + \r\n"; + writer.write_all(response.as_bytes())?; + writer.flush()?; + Ok(()) +} + +/// Read a line from a reader (up to \r\n). +fn read_line(reader: &mut R, buf: &mut String) -> io::Result<()> { + buf.clear(); + let mut byte = [0u8; 1]; + loop { + match reader.read(&mut byte) { + Ok(0) => break, + Ok(_) => { + if byte[0] == b'\n' { + buf.push('\n'); + break; + } + buf.push(byte[0] as char); + } + Err(e) => return Err(e), + } + if buf.len() > 8192 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "header line too long")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_recv_post_request() { + let body = r#"{"id":"1","method":"get_info","params":[]}"#; + let request = format!( + "POST / HTTP/1.1\r\n\ + Host: localhost\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + \r\n\ + {}", + body.len(), + body + ); + let mut cursor = Cursor::new(request.into_bytes()); + let received = recv_request(&mut cursor).unwrap(); + assert_eq!(received, body); + } + + #[test] + fn test_recv_non_post_rejected() { + let request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + let mut cursor = Cursor::new(request.as_bytes()); + assert!(recv_request(&mut cursor).is_err()); + } + + #[test] + fn test_send_response() { + let mut buf = Vec::new(); + let body = r#"{"result":"ok"}"#; + send_response(&mut buf, body).unwrap(); + let response = String::from_utf8(buf).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK")); + assert!(response.contains("Content-Type: application/json")); + assert!(response.contains("Access-Control-Allow-Origin: *")); + assert!(response.contains(body)); + } + + #[test] + fn test_send_error() { + let mut buf = Vec::new(); + send_error(&mut buf, 400, "Bad Request").unwrap(); + let response = String::from_utf8(buf).unwrap(); + assert!(response.starts_with("HTTP/1.1 400 Bad Request")); + } + + #[test] + fn test_send_cors_preflight() { + let mut buf = Vec::new(); + send_cors_preflight(&mut buf).unwrap(); + let response = String::from_utf8(buf).unwrap(); + assert!(response.starts_with("HTTP/1.1 204 No Content")); + assert!(response.contains("Access-Control-Allow-Origin: *")); + } +} diff --git a/src/key_store.rs b/src/key_store.rs new file mode 100644 index 0000000..d2706f9 --- /dev/null +++ b/src/key_store.rs @@ -0,0 +1,479 @@ +//! Key store — holds derived keys for all roles. +//! +//! Port of `key_store.c`. Uses `nostr_core_lib_rust` for secp256k1 +//! operations and NIP-01/04/44 protocol functions. + +use crate::mnemonic::MnemonicState; +use crate::pq_crypto::{self, CryptoAlg}; +use crate::role_table::{self, RoleCurve, RoleEntry, RolePurpose, RoleTable}; +use crate::secure_mem::SecureBuf; +use crate::NsignerError; + +/// Per-role derived key material (stored in secure memory). +pub struct DerivedKey { + pub private_key: SecureBuf, + pub public_key: SecureBuf, + pub pubkey_hex: String, + pub npub: String, // bech32 npub (secp256k1 only, empty for others) + pub alg: CryptoAlg, + pub valid: bool, +} + +/// Key store — holds derived keys for all roles. +pub struct KeyStore { + pub keys: Vec>, +} + +impl KeyStore { + pub fn new() -> Self { + KeyStore { keys: Vec::new() } + } + + /// Derive keys for all roles in the table using the loaded mnemonic. + /// Returns the number of keys derived. + pub fn derive_all( + &mut self, + table: &mut RoleTable, + mnemonic: &MnemonicState, + ) -> Result { + if !mnemonic.is_loaded() { + return Err(NsignerError::MnemonicNotLoaded); + } + + let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?; + self.keys.clear(); + self.keys.resize_with(table.entries.len(), || None); + + let mut derived_count = 0; + for (i, role) in table.entries.iter_mut().enumerate() { + role.derived = false; + role.pubkey_hex.clear(); + + // Skip OTP roles (no derivation) + if role.curve == RoleCurve::Unknown && role.purpose == RolePurpose::Nostr { + continue; + } + + // Template roles with no default index are skipped (derived on-demand) + if role.has_variable_path() && role.path_default_index < 0 { + continue; + } + + // Substitute default index into template if needed + let path = if role.has_variable_path() && role.path_default_index >= 0 { + role.role_path.replace("%d", &role.path_default_index.to_string()) + } else { + role.role_path.clone() + }; + + match derive_for_role(&path, role, phrase) { + Ok(dk) => { + role.pubkey_hex = dk.pubkey_hex.clone(); + role.derived = true; + self.keys[i] = Some(dk); + derived_count += 1; + } + Err(_) => continue, + } + } + + Ok(derived_count) + } + + /// Derive key for exactly one role index. + pub fn derive_one( + &mut self, + table: &mut RoleTable, + mnemonic: &MnemonicState, + role_index: usize, + ) -> Result<(), NsignerError> { + if !mnemonic.is_loaded() { + return Err(NsignerError::MnemonicNotLoaded); + } + + let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?; + let role = table + .entries + .get_mut(role_index) + .ok_or(NsignerError::InvalidInput)?; + + role.derived = false; + role.pubkey_hex.clear(); + + let dk = derive_for_role(&role.role_path, role, phrase)?; + role.pubkey_hex = dk.pubkey_hex.clone(); + role.derived = true; + + if self.keys.len() <= role_index { + self.keys.resize_with(role_index + 1, || None); + } + self.keys[role_index] = Some(dk); + Ok(()) + } + + /// Get the derived private key for a role (by table index). + pub fn get_private_key(&self, role_index: usize) -> Option<&[u8]> { + self.keys.get(role_index)?.as_ref().map(|dk| dk.private_key.as_slice()) + } + + /// Get the derived public key hex for a role. + pub fn get_pubkey_hex(&self, role_index: usize) -> Option<&str> { + self.keys.get(role_index)?.as_ref().map(|dk| dk.pubkey_hex.as_str()) + } + + /// Sign a Nostr event. event_json is the unsigned event JSON string. + /// Returns the signed event JSON string. + pub fn sign_event( + &self, + role_index: usize, + event_json: &str, + ) -> Result { + let priv_bytes = self + .get_private_key(role_index) + .ok_or(NsignerError::KeyDerivationFailed)?; + + // Parse the unsigned event from JSON + let event: nostr_core::types::Event = + serde_json::from_str(event_json).map_err(|_| NsignerError::InvalidInput)?; + + // Use NIP-01 to create and sign the event + let mut priv_arr = [0u8; 32]; + priv_arr.copy_from_slice(&priv_bytes[..32]); + let sk = nostr_core::types::SecretKey::from_bytes(priv_arr); + + let signed = nips::nip001::create_and_sign_event( + event.kind, + &event.content, + event.tags.clone(), + &sk, + event.created_at, + ) + .map_err(|_| NsignerError::CryptoFailed)?; + + serde_json::to_string(&signed).map_err(|_| NsignerError::InvalidInput) + } + + /// NIP-44 encrypt. + pub fn nip44_encrypt( + &self, + role_index: usize, + recipient_pubkey_hex: &str, + plaintext: &str, + ) -> Result, NsignerError> { + let priv_bytes = self + .get_private_key(role_index) + .ok_or(NsignerError::KeyDerivationFailed)?; + let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex + .parse() + .map_err(|_| NsignerError::InvalidInput)?; + + let mut priv_arr = [0u8; 32]; + priv_arr.copy_from_slice(&priv_bytes[..32]); + let sk = nostr_core::types::SecretKey::from_bytes(priv_arr); + + nostr_core::crypto::nip44::nip44_encrypt(&sk, &recipient_pk, plaintext.as_bytes()) + .map_err(|_| NsignerError::CryptoFailed) + } + + /// NIP-44 decrypt. + pub fn nip44_decrypt( + &self, + role_index: usize, + sender_pubkey_hex: &str, + ciphertext: &[u8], + ) -> Result, NsignerError> { + let priv_bytes = self + .get_private_key(role_index) + .ok_or(NsignerError::KeyDerivationFailed)?; + let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex + .parse() + .map_err(|_| NsignerError::InvalidInput)?; + + let mut priv_arr = [0u8; 32]; + priv_arr.copy_from_slice(&priv_bytes[..32]); + let sk = nostr_core::types::SecretKey::from_bytes(priv_arr); + + nostr_core::crypto::nip44::nip44_decrypt(&sk, &sender_pk, ciphertext) + .map_err(|_| NsignerError::CryptoFailed) + } + + /// NIP-04 encrypt. + pub fn nip04_encrypt( + &self, + role_index: usize, + recipient_pubkey_hex: &str, + plaintext: &str, + ) -> Result { + let priv_bytes = self + .get_private_key(role_index) + .ok_or(NsignerError::KeyDerivationFailed)?; + let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex + .parse() + .map_err(|_| NsignerError::InvalidInput)?; + + let mut priv_arr = [0u8; 32]; + priv_arr.copy_from_slice(&priv_bytes[..32]); + let sk = nostr_core::types::SecretKey::from_bytes(priv_arr); + + nips::nip004::nip04_encrypt(&sk, &recipient_pk, plaintext) + .map_err(|_| NsignerError::CryptoFailed) + } + + /// NIP-04 decrypt. + pub fn nip04_decrypt( + &self, + role_index: usize, + sender_pubkey_hex: &str, + ciphertext: &str, + ) -> Result { + let priv_bytes = self + .get_private_key(role_index) + .ok_or(NsignerError::KeyDerivationFailed)?; + let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex + .parse() + .map_err(|_| NsignerError::InvalidInput)?; + + let mut priv_arr = [0u8; 32]; + priv_arr.copy_from_slice(&priv_bytes[..32]); + let sk = nostr_core::types::SecretKey::from_bytes(priv_arr); + + nips::nip004::nip04_decrypt(&sk, &sender_pk, ciphertext) + .map_err(|_| NsignerError::CryptoFailed) + } + + /// Zeroize all derived keys. + pub fn wipe(&mut self) { + self.keys.clear(); + } +} + +// ── Derivation Helpers ─────────────────────────────────────────────────────── + +/// Derive a key for a single role into `DerivedKey`. +fn derive_for_role( + path: &str, + role: &RoleEntry, + mnemonic_phrase: &str, +) -> Result { + let alg = role_table::crypto_alg_from_role(role.curve, role.purpose); + // crypto_alg_from_role returns Unknown for OTP, but we skip OTP earlier + let alg = if alg == CryptoAlg::Unknown { + return Err(NsignerError::KeyDerivationFailed); + } else { + alg + }; + + let sizes = alg + .sizes() + .ok_or(NsignerError::KeyDerivationFailed)?; + + // Derive the 32-byte seed from the mnemonic using the path + let seed = pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?; + + match alg { + CryptoAlg::Secp256k1 => { + // BIP-32 derivation: seed → master key → derive path → private key + let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic_phrase, ""); + let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed); + let path_indices = nips::nip006::parse_bip44_path(path) + .map_err(|_| NsignerError::KeyDerivationFailed)?; + let (derived_key, _) = nips::nip006::bip32_derive_path( + &master_key, + &master_chain_code, + &path_indices, + ) + .map_err(|_| NsignerError::KeyDerivationFailed)?; + + let mut priv_arr = [0u8; 32]; + priv_arr.copy_from_slice(&derived_key); + let sk = nostr_core::types::SecretKey::from_bytes(priv_arr); + let pk = nostr_core::crypto::keys::public_key_from_secret_key(&sk) + .map_err(|_| NsignerError::CryptoFailed)?; + + let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?; + priv_buf.copy_from(&priv_arr); + + let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?; + pub_buf.copy_from(pk.as_bytes()); + + let pubkey_hex = hex::encode(pk.as_bytes()); + let npub = String::new(); // TODO: bech32 npub via nips::nip019 + + Ok(DerivedKey { + private_key: priv_buf, + public_key: pub_buf, + pubkey_hex, + npub, + alg, + valid: true, + }) + } + CryptoAlg::Ed25519 => { + let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed); + + let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?; + priv_buf.copy_from(&priv_bytes); + + let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?; + pub_buf.copy_from(&pub_bytes); + + let pubkey_hex = hex::encode(&pub_bytes); + + Ok(DerivedKey { + private_key: priv_buf, + public_key: pub_buf, + pubkey_hex, + npub: String::new(), + alg, + valid: true, + }) + } + CryptoAlg::X25519 => { + let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed); + + let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?; + priv_buf.copy_from(&priv_bytes); + + let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?; + pub_buf.copy_from(&pub_bytes); + + let pubkey_hex = hex::encode(&pub_bytes); + + Ok(DerivedKey { + private_key: priv_buf, + public_key: pub_buf, + pubkey_hex, + npub: String::new(), + alg, + valid: true, + }) + } + CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => { + // PQ algorithms — TODO: Phase 13 + Err(NsignerError::NotYetImplemented) + } + CryptoAlg::Unknown => Err(NsignerError::KeyDerivationFailed), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::role_table::*; + + fn make_mnemonic() -> MnemonicState { + let mut m = MnemonicState::new(); + m.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") + .unwrap(); + m + } + + fn make_table() -> RoleTable { + let mut t = RoleTable::new(); + t.register_role_path( + "main", + "m/44'/1237'/0'/0/0", + RolePurpose::Nostr, + RoleCurve::Secp256k1, + -1, -1, -1, &[], + ) + .unwrap(); + t.register_role_path( + "ssh", + "m/44'/102001'/0'/0'/0'", + RolePurpose::Ssh, + RoleCurve::Ed25519, + -1, -1, -1, &[], + ) + .unwrap(); + t.register_role_path( + "age", + "m/44'/102002'/0'/0'/0'", + RolePurpose::Age, + RoleCurve::X25519, + -1, -1, -1, &[], + ) + .unwrap(); + t + } + + #[test] + fn test_derive_all_secp256k1() { + let mnemonic = make_mnemonic(); + let mut table = make_table(); + let mut store = KeyStore::new(); + + let count = store.derive_all(&mut table, &mnemonic).unwrap(); + assert!(count >= 1); + + // main role should have a derived pubkey + let main_idx = table.entries.iter().position(|e| e.name == "main").unwrap(); + let pubkey_hex = store.get_pubkey_hex(main_idx).unwrap(); + assert!(!pubkey_hex.is_empty()); + assert_eq!(pubkey_hex.len(), 64); // 32 bytes hex + } + + #[test] + fn test_derive_all_ed25519() { + let mnemonic = make_mnemonic(); + let mut table = make_table(); + let mut store = KeyStore::new(); + + store.derive_all(&mut table, &mnemonic).unwrap(); + + let ssh_idx = table.entries.iter().position(|e| e.name == "ssh").unwrap(); + let pubkey_hex = store.get_pubkey_hex(ssh_idx).unwrap(); + assert_eq!(pubkey_hex.len(), 64); // 32 bytes hex + } + + #[test] + fn test_derive_all_x25519() { + let mnemonic = make_mnemonic(); + let mut table = make_table(); + let mut store = KeyStore::new(); + + store.derive_all(&mut table, &mnemonic).unwrap(); + + let age_idx = table.entries.iter().position(|e| e.name == "age").unwrap(); + let pubkey_hex = store.get_pubkey_hex(age_idx).unwrap(); + assert_eq!(pubkey_hex.len(), 64); // 32 bytes hex + } + + #[test] + fn test_derive_deterministic() { + let mnemonic = make_mnemonic(); + + // First derivation + let mut table1 = make_table(); + let mut store1 = KeyStore::new(); + store1.derive_all(&mut table1, &mnemonic).unwrap(); + let pk1 = store1.get_pubkey_hex(0).unwrap().to_string(); + + // Second derivation with same mnemonic + let mut table2 = make_table(); + let mut store2 = KeyStore::new(); + store2.derive_all(&mut table2, &mnemonic).unwrap(); + let pk2 = store2.get_pubkey_hex(0).unwrap().to_string(); + + assert_eq!(pk1, pk2); + } + + #[test] + fn test_derive_without_mnemonic_fails() { + let mnemonic = MnemonicState::new(); // not loaded + let mut table = make_table(); + let mut store = KeyStore::new(); + assert!(store.derive_all(&mut table, &mnemonic).is_err()); + } + + #[test] + fn test_wipe() { + let mnemonic = make_mnemonic(); + let mut table = make_table(); + let mut store = KeyStore::new(); + store.derive_all(&mut table, &mnemonic).unwrap(); + assert!(!store.keys.is_empty()); + store.wipe(); + assert!(store.keys.is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..58e7f55 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,36 @@ +//! # nsigner — Attended Nostr signing daemon +//! +//! Rust port of the C-based `n_signer`. Holds signing key material in +//! locked memory and signs on request via a JSON-RPC 2.0 API over +//! multiple transports (Unix socket, TCP, HTTP, stdio, qrexec). +//! +//! This is a **program, not a daemon**: no background process, no +//! runtime config files, no persistence. Closing the terminal or +//! quitting the program ends the trust session and destroys state. + +pub mod secure_mem; +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; +pub mod pq_drbg; +pub mod dispatcher; +pub mod server; +pub mod transport; +pub mod http; +pub mod auth_envelope; +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"; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d273cdf --- /dev/null +++ b/src/main.rs @@ -0,0 +1,680 @@ +//! nsigner — attended Nostr signing daemon. +//! +//! Port of `main.c`. Single binary that holds signing key material in +//! locked memory and signs on request via JSON-RPC 2.0. + +use clap::{Parser, Subcommand}; +use nsigner::{ + alg_cache::AlgorithmKeyCache, + dispatcher::DispatcherContext, + key_store::KeyStore, + mnemonic::MnemonicState, + policy::{parse_preapprove_spec, PolicyTable}, + role_table::{RoleCurve, RolePurpose, RoleTable}, + server::{AuthMode, ListenMode, ServerContext}, + NsignerError, +}; + +/// Command-line arguments. +#[derive(Parser, Debug)] +#[command(name = "nsigner", version = nsigner::VERSION, about = "Attended Nostr signing daemon")] +struct Cli { + /// Socket name (abstract namespace, without @ prefix) + #[arg(long, short = 'n', alias = "name")] + socket_name: Option, + + /// 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, + + /// Register a named path-role non-interactively (repeatable) + #[arg(long, value_name = "SPEC")] + register_role: Vec, + + /// Auth envelope policy: off, optional, required + #[arg(long, short = 'a', default_value = "off")] + auth: String, + + /// Read mnemonic from stdin (one line) at startup + #[arg(long)] + mnemonic_stdin: bool, + + /// Read mnemonic from inherited fd N (one line) at startup + #[arg(long, value_name = "N")] + mnemonic_fd: Option, + + /// 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, + + /// Accept qrexec_source preamble on unix connections (bridge mode) + #[arg(long)] + bridge_source_trusted: bool, + + /// OTP pad directory + #[arg(long, value_name = "DIR")] + otp_pad_dir: Option, + + /// OTP pad checksum or prefix + #[arg(long, value_name = "SPEC")] + otp_pad: Option, + + /// Allow pads on qvm-block (blkback) devices + #[arg(long)] + otp_allow_blkback: bool, + + /// Subcommand + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Send a JSON-RPC request to a running signer + Client { + /// JSON-RPC request string, or "-" to read from stdin + request: String, + }, + + /// Stateless qrexec → unix-socket relay + Bridge { + /// Target socket name + #[arg(long)] + to: Option, + }, + + /// List running nsigner abstract sockets + List, +} + +fn main() { + let cli = Cli::parse(); + + // Handle subcommands first (no mnemonic needed) + if let Some(cmd) = &cli.command { + match cmd { + Commands::Client { request } => { + std::process::exit(client_main(request, &cli)); + } + Commands::Bridge { to } => { + std::process::exit(bridge_main(to.as_deref(), &cli)); + } + Commands::List => { + std::process::exit(list_main()); + } + } + } + + // Server mode + match server_main(&cli) { + Ok(()) => {} + Err(e) => { + eprintln!("nsigner: {}", e); + std::process::exit(1); + } + } +} + +/// Server main: mnemonic → roles → transport → server start → TUI loop +fn server_main(cli: &Cli) -> Result<(), NsignerError> { + println!("nsigner {}", nsigner::VERSION); + + if cli.allow_unlocked_memory { + nsigner::secure_mem::allow_unlocked(); + } + + // Install SIGWINCH handler for terminal resize detection + nsigner::tui_continuous::install_resize_handler(); + + // ── Mnemonic ────────────────────────────────────────────────── + let mut mnemonic = MnemonicState::new(); + + if cli.mnemonic_stdin { + // Read one line from stdin + + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + let phrase = input.trim().to_string(); + mnemonic.load(&phrase)?; + } else if let Some(fd) = cli.mnemonic_fd { + // Read from inherited fd + use std::io::Read; + use std::os::unix::io::FromRawFd; + let mut file = unsafe { std::fs::File::from_raw_fd(fd) }; + let mut input = String::new(); + file.read_to_string(&mut input) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + 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 + 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()))?; + } 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); + } + + // ── Derive keys ────────────────────────────────────────────── + let derived_count = key_store.derive_all(&mut role_table, &mnemonic)?; + + // ── Transport selection ────────────────────────────────────── + let listen_mode = parse_listen_mode(&cli.listen); + let socket_name = cli + .socket_name + .clone() + .unwrap_or_else(|| { + // Generate random socket name for Unix mode + nsigner::socket_name::socket_name_random().unwrap_or_default() + }); + + // ── Start server ───────────────────────────────────────────── + 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 + 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; + } + } + } + } + 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 { + 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(()) +} + +/// Client subcommand: send a JSON-RPC request to a running signer. +fn client_main(request: &str, cli: &Cli) -> i32 { + use std::io::Read; + + let socket_name = cli.socket_name.as_deref().unwrap_or("nsigner"); + + // Discover single socket if not explicit + let socket_name = if cli.socket_name.is_some() { + socket_name.to_string() + } else { + nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| socket_name.to_string()) + }; + + let mut stream = match nsigner::transport::connect_abstract_unix(&socket_name) { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to connect to {}: {}", socket_name, e); + return 1; + } + }; + + // Read from stdin if "-" + let request = if request == "-" { + let mut input = String::new(); + if std::io::stdin().read_to_string(&mut input).is_err() { + eprintln!("Failed to read request from stdin"); + return 1; + } + input + } else { + request.to_string() + }; + + // Send framed request + if nsigner::transport::send_framed(&mut stream, &request).is_err() { + eprintln!("Failed to send request"); + return 1; + } + + // Receive framed response + match nsigner::transport::recv_framed(&mut stream) { + Ok(response) => { + println!("{}", response); + 0 + } + Err(e) => { + eprintln!("Failed to receive response: {}", e); + 1 + } + } +} + +/// Bridge subcommand: stateless qrexec → unix-socket relay. +fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 { + + + let target = to.unwrap_or("nsigner"); + let target = if cli.socket_name.is_some() { + target.to_string() + } else { + nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| target.to_string()) + }; + + // Read source qube from qrexec environment + let source_qube = std::env::var("QREXEC_REMOTE_DOMAIN").unwrap_or_default(); + + // Connect to persistent signer via abstract socket + let mut stream = match nsigner::transport::connect_abstract_unix(&target) { + Ok(s) => s, + Err(e) => { + eprintln!("bridge: cannot connect to {}: {}", target, e); + return 1; + } + }; + + // Send source-qube preamble + let preamble = format!(r#"{{"qrexec_source":"{}"}}"#, source_qube); + if nsigner::transport::send_framed(&mut stream, &preamble).is_err() { + eprintln!("bridge: failed to send preamble"); + return 1; + } + + // Read one framed request from stdin and forward + let mut stdin = std::io::stdin(); + let request = match nsigner::transport::recv_framed(&mut stdin) { + Ok(r) => r, + Err(e) => { + eprintln!("bridge: failed to read request from stdin: {}", e); + return 1; + } + }; + + if nsigner::transport::send_framed(&mut stream, &request).is_err() { + eprintln!("bridge: failed to forward request"); + return 1; + } + + // Relay response to stdout + match nsigner::transport::recv_framed(&mut stream) { + Ok(response) => { + + let mut stdout = std::io::stdout(); + if nsigner::transport::send_framed(&mut stdout, &response).is_err() { + eprintln!("bridge: failed to relay response"); + return 1; + } + 0 + } + Err(e) => { + eprintln!("bridge: failed to read response: {}", e); + 1 + } + } +} + +/// List subcommand: list running nsigner sockets. +fn list_main() -> i32 { + let sockets = nsigner::socket_name::list_sockets(); + if sockets.is_empty() { + println!("(none)"); + } else { + for name in &sockets { + println!("{}", name); + } + } + 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: `::` +fn register_role_from_spec( + role_table: &mut RoleTable, + spec: &str, +) -> Result<(), NsignerError> { + let parts: Vec<&str> = spec.splitn(3, ':').collect(); + if parts.len() != 3 { + return Err(NsignerError::InvalidInput); + } + + let name = parts[0]; + let curve_str = parts[1]; + let path_token = parts[2]; + + if name.is_empty() || path_token.is_empty() { + return Err(NsignerError::InvalidInput); + } + + // Resolve curve + let curve = if curve_str.is_empty() { + // Auto-detect from path + match nsigner::role_table::purpose_from_path(path_token) { + RolePurpose::Ssh => RoleCurve::Ed25519, + RolePurpose::Age => RoleCurve::X25519, + RolePurpose::PqSig => { + if path_token.starts_with("m/44'/102004'") { + RoleCurve::SlhDsa128s + } else { + RoleCurve::MlDsa65 + } + } + RolePurpose::PqKem => RoleCurve::MlKem768, + _ => RoleCurve::Secp256k1, + } + } else { + RoleCurve::from_str(curve_str) + }; + + if curve == RoleCurve::Unknown { + return Err(NsignerError::InvalidInput); + } + + let purpose = nsigner::role_table::purpose_from_path(path_token); + + // Parse path template + let (template, range_lo, range_hi, allowed_indices) = + nsigner::role_table::parse_path_template(path_token) + .map_err(|_| NsignerError::InvalidInput)?; + + role_table + .register_role_path( + name, + &template, + purpose, + curve, + range_lo, + range_hi, + -1, // no default index + &allowed_indices, + ) + .map_err(|e| NsignerError::Internal(e.to_string()))?; + + Ok(()) +} + +/// Parse listen mode from --listen string. +fn parse_listen_mode(s: &str) -> ListenMode { + if s.starts_with("tcp:") { + ListenMode::Tcp + } else if s.starts_with("http:") { + ListenMode::Http + } else { + match s { + "unix" => ListenMode::Unix, + "stdio" => ListenMode::Stdio, + "qrexec" => ListenMode::Qrexec, + _ => ListenMode::Unix, + } + } +} + +/// Parse auth mode from --auth string. +fn parse_auth_mode(s: &str) -> AuthMode { + match s { + "off" => AuthMode::Off, + "optional" => AuthMode::Optional, + "required" => AuthMode::Required, + _ => AuthMode::Off, + } +} + diff --git a/src/miner.rs b/src/miner.rs new file mode 100644 index 0000000..8fcf3cd --- /dev/null +++ b/src/miner.rs @@ -0,0 +1,265 @@ +//! NIP-13 proof-of-work mining coordinator. +//! +//! Port of `miner.c`. Multi-threaded best-effort mining that: +//! - Runs N worker threads, each trying nonces with a unique stride +//! - Tracks the best event (highest difficulty) across all threads +//! - Stops when target difficulty is reached OR timeout expires +//! - Always returns the best result found + +use nostr_core::types::{Event, SecretKey, Tag}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Mine result. +#[derive(Debug, Clone)] +pub struct MineResult { + pub best_event_json: String, + pub achieved_difficulty: i32, + pub target_difficulty: i32, + pub target_reached: bool, + pub elapsed_sec: u64, + pub total_attempts: u64, +} + +/// Shared state across worker threads. +struct MineShared { + best_difficulty: i32, + best_nonce: u64, + total_attempts: u64, + target_reached: bool, + stop: bool, +} + +/// Run the mining coordinator. +/// +/// `event_json` — the unsigned event JSON (will be parsed and modified) +/// `private_key` — 32-byte secp256k1 private key (for signing the mined event) +/// `target_difficulty` — target leading zero bits (0 = no target, mine for full timeout) +/// `thread_count` — number of mining threads (1..32) +/// `timeout_sec` — time budget in seconds +pub fn miner_run( + event_json: &str, + private_key: &[u8; 32], + target_difficulty: i32, + thread_count: i32, + timeout_sec: u64, +) -> Result { + let threads = thread_count.clamp(1, 32) as usize; + let timeout = if timeout_sec == 0 { 600 } else { timeout_sec }; + let deadline = Instant::now() + Duration::from_secs(timeout); + + // Parse the unsigned event + let mut event: Event = + serde_json::from_str(event_json).map_err(|_| crate::NsignerError::InvalidInput)?; + + // Ensure there's a nonce tag (will be updated by workers) + let has_nonce = event.tags.iter().any(|t| t.kind() == "nonce"); + if !has_nonce { + let mut tag = Tag::with_value("nonce", "0"); + tag.0.push(target_difficulty.to_string()); + event.tags.push(tag); + } + + let shared = Arc::new(Mutex::new(MineShared { + best_difficulty: 0, + best_nonce: 0, + total_attempts: 0, + target_reached: false, + stop: false, + })); + + let start = Instant::now(); + let mut handles = Vec::new(); + + for thread_id in 0..threads { + let shared = Arc::clone(&shared); + let event = event.clone(); + let target = target_difficulty; + let deadline = deadline; + + let handle = thread::spawn(move || { + mine_worker( + thread_id as u64, + threads as u64, + &shared, + &event, + target, + deadline, + ); + }); + handles.push(handle); + } + + for handle in handles { + let _ = handle.join(); + } + + let shared = Arc::try_unwrap(shared) + .map_err(|_| crate::NsignerError::Internal("mining thread still holds shared state".into()))? + .into_inner() + .map_err(|_| crate::NsignerError::Internal("mining shared state poisoned".into()))?; + + let elapsed = start.elapsed().as_secs(); + + // Reconstruct the best event with the best nonce and sign it + let best_event_json = if shared.best_difficulty > 0 { + let nonce_tag_index = event.tags.iter().position(|t| t.kind() == "nonce"); + if let Some(idx) = nonce_tag_index { + event.tags[idx] = Tag::with_value("nonce", &shared.best_nonce.to_string()); + event.tags[idx].0.push(target_difficulty.to_string()); + } + let sk = SecretKey::from_bytes(*private_key); + let signed = nips::nip001::create_and_sign_event( + event.kind, + &event.content, + event.tags.clone(), + &sk, + event.created_at, + ) + .map_err(|_| crate::NsignerError::CryptoFailed)?; + serde_json::to_string(&signed).map_err(|_| crate::NsignerError::InvalidInput)? + } else { + // No event mined — return the original unsigned event + event_json.to_string() + }; + + Ok(MineResult { + best_event_json, + achieved_difficulty: shared.best_difficulty, + target_difficulty, + target_reached: shared.target_reached, + elapsed_sec: elapsed, + total_attempts: shared.total_attempts, + }) +} + +/// Per-worker mining loop. +fn mine_worker( + thread_id: u64, + thread_count: u64, + shared: &Arc>, + event: &Event, + target_difficulty: i32, + deadline: Instant, +) { + let mut nonce: u64 = thread_id; + let stride = thread_count; + let nonce_tag_index = event.tags.iter().position(|t| t.kind() == "nonce"); + + let mut local_event = event.clone(); + let mut local_best_difficulty: i32 = 0; + let mut local_best_nonce: u64 = 0; + let mut attempts: u64 = 0; + + while Instant::now() < deadline { + // Check if we should stop (target reached by another thread) + { + let s = shared.lock().unwrap(); + if s.stop { + // Flush our attempts + drop(s); + let mut s = shared.lock().unwrap(); + s.total_attempts += attempts; + return; + } + } + + // Update nonce tag + if let Some(idx) = nonce_tag_index { + local_event.tags[idx] = Tag::with_value("nonce", &nonce.to_string()); + local_event.tags[idx].0.push(target_difficulty.to_string()); + } + + // Recompute event ID + if let Ok(new_id) = local_event.compute_id() { + let difficulty = nips::nip013::count_leading_zero_bits(&new_id) as i32; + + if difficulty > local_best_difficulty { + local_best_difficulty = difficulty; + local_best_nonce = nonce; + + // Check if target reached + if target_difficulty > 0 && difficulty >= target_difficulty { + let mut s = shared.lock().unwrap(); + if local_best_difficulty > s.best_difficulty { + s.best_difficulty = local_best_difficulty; + s.best_nonce = local_best_nonce; + } + s.total_attempts += attempts; + s.target_reached = true; + s.stop = true; + return; + } + } + } + + attempts += 1; + nonce += stride; + + // Periodically flush to shared + if attempts % 10000 == 0 { + let mut s = shared.lock().unwrap(); + s.total_attempts += 10000; + if local_best_difficulty > s.best_difficulty { + s.best_difficulty = local_best_difficulty; + s.best_nonce = local_best_nonce; + } + attempts = 0; + } + } + + // Final flush + let mut s = shared.lock().unwrap(); + s.total_attempts += attempts; + if local_best_difficulty > s.best_difficulty { + s.best_difficulty = local_best_difficulty; + s.best_nonce = local_best_nonce; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_core::crypto::keys::generate_keypair; + use nostr_core::types::Kind; + + #[test] + fn test_mine_low_difficulty() { + let (sk, pk) = generate_keypair(); + let event = Event::new(pk, 1234567890, Kind::Text, vec![], "hello mining"); + + let event_json = serde_json::to_string(&event).unwrap(); + let priv_bytes = sk.as_bytes(); + + let result = miner_run(&event_json, &priv_bytes, 4, 2, 5).unwrap(); + + assert!(result.achieved_difficulty >= 4, "achieved: {}", result.achieved_difficulty); + assert!(result.target_reached); + assert!(result.total_attempts > 0); + } + + #[test] + fn test_mine_zero_target_returns_best() { + let (sk, pk) = generate_keypair(); + let event = Event::new(pk, 1234567890, Kind::Text, vec![], "test zero target"); + + let event_json = serde_json::to_string(&event).unwrap(); + let priv_bytes = sk.as_bytes(); + + // target=0 means mine for the full timeout, return best found + let result = miner_run(&event_json, &priv_bytes, 0, 2, 1).unwrap(); + + // Should have found something with at least 0 difficulty + assert!(result.achieved_difficulty >= 0); + assert!(!result.best_event_json.is_empty()); + } + + #[test] + fn test_mine_invalid_event_json() { + let (sk, _) = generate_keypair(); + let priv_bytes = sk.as_bytes(); + + assert!(miner_run("not valid json", &priv_bytes, 4, 1, 5).is_err()); + } +} diff --git a/src/mnemonic.rs b/src/mnemonic.rs new file mode 100644 index 0000000..5b42322 --- /dev/null +++ b/src/mnemonic.rs @@ -0,0 +1,251 @@ +//! Mnemonic state — holds the loaded BIP-39 mnemonic in secure memory. +//! +//! Port of `mnemonic.c`. Uses `nips::nip006` for BIP-39 validation, +//! generation, and seed conversion. + +use crate::secure_mem::SecureBuf; +use crate::NsignerError; + +/// Maximum mnemonic length: 24 words * ~10 chars + spaces + null. +pub const MNEMONIC_MAX_LEN: usize = 256; + +/// Mnemonic state — holds the loaded mnemonic in secure memory. +/// +/// Only one mnemonic is active at a time per process. +pub struct MnemonicState { + buf: Option, + loaded: bool, + word_count: u8, +} + +impl MnemonicState { + /// Create an empty mnemonic state (no mnemonic loaded). + pub fn new() -> Self { + MnemonicState { + buf: None, + loaded: false, + word_count: 0, + } + } + + /// Load a mnemonic string into secure memory. + /// + /// Validates word count (12/15/18/21/24) and BIP-39 checksum. + /// Returns `InvalidInput` on invalid mnemonic, `MemoryFailed` on alloc error. + pub fn load(&mut self, phrase: &str) -> Result<(), NsignerError> { + let words: Vec<&str> = phrase.split_whitespace().collect(); + let count = words.len(); + + // Validate word count + if ![12, 15, 18, 21, 24].contains(&count) { + return Err(NsignerError::InvalidInput); + } + + // Validate via nostr_core_lib_rust + if !nips::nip006::mnemonic_validate(phrase) { + return Err(NsignerError::InvalidInput); + } + + // Store in secure memory + let phrase_bytes = phrase.as_bytes(); + let mut buf = SecureBuf::alloc(phrase_bytes.len() + 1)?; + buf.copy_from(phrase_bytes); + // NUL-terminate for C-compatible access if needed + buf.as_mut_slice()[phrase_bytes.len()] = 0; + + self.buf = Some(buf); + self.loaded = true; + self.word_count = count as u8; + Ok(()) + } + + /// Generate a new BIP-39 mnemonic phrase. + /// + /// `word_count` must be 12, 15, 18, 21, or 24. + pub fn generate(&mut self, word_count: u8) -> Result { + let entropy_bytes = match word_count { + 12 => 16, + 15 => 20, + 18 => 24, + 21 => 28, + 24 => 32, + _ => return Err(NsignerError::InvalidInput), + }; + + let mut entropy = vec![0u8; entropy_bytes]; + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut entropy); + + let phrase = nips::nip006::mnemonic_from_bytes(&entropy) + .map_err(|_| NsignerError::CryptoFailed)?; + + // Zeroize entropy + use zeroize::Zeroize; + entropy.zeroize(); + + // Load into state + self.load(&phrase)?; + + Ok(phrase) + } + + /// Zeroize and unload the mnemonic. Idempotent. + pub fn unload(&mut self) { + self.buf = None; // Drop runs zeroize + munlock + self.loaded = false; + self.word_count = 0; + } + + /// Check if a mnemonic is currently loaded. + pub fn is_loaded(&self) -> bool { + self.loaded + } + + /// Get the mnemonic string (only valid while loaded). + pub fn phrase(&self) -> Option<&str> { + if !self.loaded { + return None; + } + let buf = self.buf.as_ref()?; + // Exclude trailing NUL + let len = buf.size().saturating_sub(1); + let bytes = &buf.as_slice()[..len]; + std::str::from_utf8(bytes).ok() + } + + /// Word count of the loaded mnemonic (0 if not loaded). + pub fn word_count(&self) -> u8 { + self.word_count + } + + /// Convert the mnemonic to a 64-byte BIP-39 seed (PBKDF2, 2048 rounds). + /// + /// Uses an empty passphrase. For passphrase support, use [`to_seed_with_passphrase`]. + pub fn to_seed(&self) -> Option<[u8; 64]> { + let phrase = self.phrase()?; + Some(nips::nip006::mnemonic_to_seed(phrase, "")) + } + + /// Convert the mnemonic to a 64-byte BIP-39 seed with a passphrase. + /// + /// The passphrase is zeroized from local memory after use. + pub fn to_seed_with_passphrase(&self, passphrase: &str) -> Option<[u8; 64]> { + let phrase = self.phrase()?; + let seed = nips::nip006::mnemonic_to_seed(phrase, passphrase); + // Zeroize passphrase bytes in local stack copy + let mut pp_bytes = passphrase.as_bytes().to_vec(); + use zeroize::Zeroize; + pp_bytes.zeroize(); + Some(seed) + } +} + +impl Default for MnemonicState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_valid_mnemonic() { + let mut state = MnemonicState::new(); + let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + assert!(state.load(phrase).is_ok()); + assert!(state.is_loaded()); + assert_eq!(state.word_count(), 12); + } + + #[test] + fn test_load_invalid_word_count() { + let mut state = MnemonicState::new(); + assert!(state.load("abandon abandon abandon").is_err()); // 3 words + assert!(!state.is_loaded()); + } + + #[test] + fn test_unload() { + let mut state = MnemonicState::new(); + state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap(); + state.unload(); + assert!(!state.is_loaded()); + assert!(state.phrase().is_none()); + } + + #[test] + fn test_generate() { + let mut state = MnemonicState::new(); + let phrase = state.generate(12).unwrap(); + assert!(state.is_loaded()); + let words: Vec<&str> = phrase.split_whitespace().collect(); + assert_eq!(words.len(), 12); + } + + #[test] + fn test_to_seed() { + let mut state = MnemonicState::new(); + state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap(); + let seed = state.to_seed().unwrap(); + assert_eq!(seed.len(), 64); + } + + #[test] + fn test_to_seed_deterministic() { + // Same mnemonic + empty passphrase must always produce the same seed. + let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let mut state = MnemonicState::new(); + state.load(phrase).unwrap(); + let seed1 = state.to_seed().unwrap(); + let seed2 = state.to_seed().unwrap(); + assert_eq!(seed1, seed2); + assert_eq!(seed1.len(), 64); + } + + #[test] + fn test_to_seed_with_passphrase_differs() { + let mut state = MnemonicState::new(); + state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap(); + let seed_no_pp = state.to_seed().unwrap(); + let seed_with_pp = state.to_seed_with_passphrase("test").unwrap(); + assert_ne!(seed_no_pp, seed_with_pp); + } + + #[test] + fn test_load_replaces_existing() { + let mut state = MnemonicState::new(); + state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap(); + // Load a different valid mnemonic + state.load("legal winner thank year wave sausage worth useful legal winner thank yellow").unwrap(); + assert_eq!(state.word_count(), 12); + assert!(state.phrase().unwrap().starts_with("legal")); + } + + #[test] + fn test_load_invalid_word() { + let mut state = MnemonicState::new(); + // 12 words but one is not a BIP-39 word + assert!(state.load("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon notaword").is_err()); + } + + #[test] + fn test_generate_all_word_counts() { + for &wc in &[12u8, 15, 18, 21, 24] { + let mut state = MnemonicState::new(); + let phrase = state.generate(wc).unwrap(); + let words: Vec<&str> = phrase.split_whitespace().collect(); + assert_eq!(words.len(), wc as usize); + assert!(state.is_loaded()); + } + } + + #[test] + fn test_generate_invalid_word_count() { + let mut state = MnemonicState::new(); + assert!(state.generate(13).is_err()); + assert!(state.generate(0).is_err()); + assert!(state.generate(25).is_err()); + } +} diff --git a/src/otp_pad.rs b/src/otp_pad.rs new file mode 100644 index 0000000..ee92796 --- /dev/null +++ b/src/otp_pad.rs @@ -0,0 +1,332 @@ +//! OTP pad — one-time pad encryption. +//! +//! Port of `otp_pad.c` + `libotppad.c`. One pad per session, bound at +//! startup. Pad offset advances monotonically across requests. + +use crate::secure_mem::SecureBuf; +use crate::NsignerError; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; + +/// OTP pad state. +pub struct OtpPadState { + bound: bool, + pads_dir: String, + chksum: String, + pad_path: String, + pad_file: Option, + pad_size: u64, + offset: u64, + scratch: Option, +} + +impl OtpPadState { + pub fn new() -> Self { + OtpPadState { + bound: false, + pads_dir: String::new(), + chksum: String::new(), + pad_path: String::new(), + pad_file: None, + pad_size: 0, + offset: 0, + scratch: None, + } + } + + /// Check if a pad is bound. + pub fn is_bound(&self) -> bool { + self.bound + } + + /// Get the pad checksum (64 hex chars). + pub fn chksum(&self) -> Option<&str> { + if self.bound { + Some(&self.chksum) + } else { + None + } + } + + /// Get the current pad offset. + pub fn current_offset(&self) -> u64 { + self.offset + } + + /// Get the total pad size. + pub fn pad_size(&self) -> u64 { + self.pad_size + } + + /// Bind a pad at startup. + /// + /// `dir` — directory containing .pad and .state files + /// `spec` — pad checksum (64 hex) or unique prefix + /// `allow_blkback` — allow pads on qvm-block devices (not for production) + pub fn bind(&mut self, dir: &str, spec: &str, _allow_blkback: bool) -> Result<(), NsignerError> { + // Find the pad file matching the spec + let pad_filename = if spec.len() == 64 { + format!("{}/{}.pad", dir, spec) + } else { + // Prefix match — find a .pad file starting with spec + let entries = std::fs::read_dir(dir) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + + let mut found = None; + for entry in entries { + if let Ok(entry) = entry { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(spec) && name_str.ends_with(".pad") { + found = Some(entry.path()); + break; + } + } + } + found + .map(|p| p.to_string_lossy().to_string()) + .ok_or(NsignerError::InvalidInput)? + }; + + let file = File::open(&pad_filename) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + + let metadata = file + .metadata() + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + let size = metadata.len(); + + // Extract checksum from filename + let chksum = std::path::Path::new(&pad_filename) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + // Read offset from .state file + let state_path = format!("{}/{}.state", dir, chksum); + let offset = if let Ok(state_content) = std::fs::read_to_string(&state_path) { + state_content.trim().parse().unwrap_or(0) + } else { + 0 + }; + + // Allocate scratch buffer for XOR + let scratch = SecureBuf::alloc(4 * 1024 * 1024) // 4 MB max chunk + .map_err(|_| NsignerError::MemoryFailed)?; + + self.bound = true; + self.pads_dir = dir.to_string(); + self.chksum = chksum; + self.pad_path = pad_filename; + self.pad_file = Some(file); + self.pad_size = size; + self.offset = offset; + self.scratch = Some(scratch); + + Ok(()) + } + + /// Unbind the pad and zeroize scratch buffer. + pub fn unbind(&mut self) { + self.bound = false; + self.pad_file = None; + self.scratch = None; // Drop runs zeroize + self.offset = 0; + self.pad_size = 0; + } + + /// Encrypt plaintext using the OTP pad. + /// + /// Returns (ciphertext, pad_offset_before, pad_offset_after). + /// TODO: Phase 12 — full ASCII armoring + binary encoding + pub fn encrypt( + &mut self, + plaintext: &[u8], + _encoding: Option<&str>, + ) -> Result<(Vec, u64, u64), NsignerError> { + if !self.bound { + return Err(NsignerError::InvalidInput); + } + + let off_before = self.offset; + let ct = self.xor_with_pad(plaintext)?; + let off_after = self.offset; + + Ok((ct, off_before, off_after)) + } + + /// Decrypt ciphertext using the OTP pad. + /// + /// Returns plaintext. + /// TODO: Phase 12 — full ASCII armoring + binary encoding + pub fn decrypt( + &mut self, + ciphertext: &[u8], + _encoding: Option<&str>, + ) -> Result, NsignerError> { + if !self.bound { + return Err(NsignerError::InvalidInput); + } + + self.xor_with_pad(ciphertext) + } + + /// XOR data with pad bytes at the current offset, advancing the offset. + fn xor_with_pad(&mut self, data: &[u8]) -> Result, NsignerError> { + let file = self.pad_file.as_mut().ok_or(NsignerError::InvalidInput)?; + let scratch = self.scratch.as_mut().ok_or(NsignerError::InvalidInput)?; + + let data_len = data.len(); + if data_len > scratch.size() { + return Err(NsignerError::InvalidInput); + } + + // Seek to current offset + file.seek(SeekFrom::Start(self.offset)) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + + // Read pad bytes + let pad_slice = &mut scratch.as_mut_slice()[..data_len]; + file.read_exact(pad_slice) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + + // XOR + let result: Vec = data + .iter() + .zip(pad_slice.iter()) + .map(|(d, p)| d ^ p) + .collect(); + + // Zeroize the pad slice we just used + crate::secure_mem::secure_memzero(&mut scratch.as_mut_slice()[..data_len]); + + // Advance offset + self.offset += data_len as u64; + + // Persist offset to .state file + let state_path = format!("{}/{}.state", self.pads_dir, self.chksum); + let _ = std::fs::write(&state_path, self.offset.to_string()); + + Ok(result) + } +} + +impl Default for OtpPadState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn make_test_pad(dir: &std::path::Path, size: usize) -> String { + std::fs::create_dir_all(dir).unwrap(); + // Create a deterministic pad: bytes 0,1,2,...,255,0,1,... + let pad_data: Vec = (0..size).map(|i| (i % 256) as u8).collect(); + let chksum = hex::encode(&nostr_core::crypto::sha256::sha256(&pad_data)); + let pad_path = dir.join(format!("{}.pad", chksum)); + let mut file = File::create(&pad_path).unwrap(); + file.write_all(&pad_data).unwrap(); + chksum + } + + #[test] + fn test_bind_and_encrypt() { + let dir = std::env::temp_dir().join("nsigner_otp_test_1"); + let _ = std::fs::remove_dir_all(&dir); + let chksum = make_test_pad(&dir, 1024); + + let mut otp = OtpPadState::new(); + assert!(otp.bind(dir.to_str().unwrap(), &chksum, false).is_ok()); + assert!(otp.is_bound()); + assert_eq!(otp.pad_size(), 1024); + assert_eq!(otp.current_offset(), 0); + + let plaintext = b"hello world"; + let (ciphertext, off_before, off_after) = otp.encrypt(plaintext, None).unwrap(); + assert_eq!(off_before, 0); + assert_eq!(off_after, plaintext.len() as u64); + assert_eq!(ciphertext.len(), plaintext.len()); + // XOR with deterministic pad: plaintext[i] ^ (i % 256) + for i in 0..plaintext.len() { + assert_eq!(ciphertext[i], plaintext[i] ^ (i as u8)); + } + + // Cleanup + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let dir = std::env::temp_dir().join("nsigner_otp_test_2"); + let _ = std::fs::remove_dir_all(&dir); + let chksum = make_test_pad(&dir, 1024); + + let mut otp = OtpPadState::new(); + otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap(); + + let plaintext = b"secret message for OTP encryption!"; + let (ciphertext, _, _) = otp.encrypt(plaintext, None).unwrap(); + + // Decrypt: need to seek back to offset 0 + // For this test, create a new pad state at offset 0 + let mut otp2 = OtpPadState::new(); + otp2.bind(dir.to_str().unwrap(), &chksum, false).unwrap(); + // Reset offset to 0 by overwriting state file + let state_path = dir.join(format!("{}.state", chksum)); + std::fs::write(&state_path, "0").unwrap(); + otp2.offset = 0; + + let decrypted = otp2.decrypt(&ciphertext, None).unwrap(); + assert_eq!(decrypted, plaintext); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_encrypt_without_bind_fails() { + let mut otp = OtpPadState::new(); + assert!(otp.encrypt(b"test", None).is_err()); + } + + #[test] + fn test_unbind() { + let dir = std::env::temp_dir().join("nsigner_otp_test_3"); + let _ = std::fs::remove_dir_all(&dir); + let chksum = make_test_pad(&dir, 1024); + + let mut otp = OtpPadState::new(); + otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap(); + assert!(otp.is_bound()); + + otp.unbind(); + assert!(!otp.is_bound()); + assert_eq!(otp.current_offset(), 0); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_offset_advances() { + let dir = std::env::temp_dir().join("nsigner_otp_test_4"); + let _ = std::fs::remove_dir_all(&dir); + let chksum = make_test_pad(&dir, 1024); + + let mut otp = OtpPadState::new(); + otp.bind(dir.to_str().unwrap(), &chksum, false).unwrap(); + + let (_, off1_before, off1_after) = otp.encrypt(b"first", None).unwrap(); + assert_eq!(off1_before, 0); + assert_eq!(off1_after, 5); + + let (_, off2_before, off2_after) = otp.encrypt(b"second", None).unwrap(); + assert_eq!(off2_before, 5); + assert_eq!(off2_after, 11); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 0000000..bfcd5be --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,461 @@ +//! 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, + pub roles: Vec, + pub purposes: Vec, + pub algorithms: Vec, + 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, +} + +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=,role=,verb=sign,verify` +/// or: `caller=,algorithm=ed25519,index=0-4,verb=sign,verify` +/// or: `caller=,role=main,verb=nostr_sign_event,nostr_get_public_key` +pub fn parse_preapprove_spec(spec: &str) -> Result { + 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/pq_crypto.rs b/src/pq_crypto.rs new file mode 100644 index 0000000..70bfc64 --- /dev/null +++ b/src/pq_crypto.rs @@ -0,0 +1,319 @@ +//! Post-quantum crypto algorithm registry. +//! +//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum and size +//! constants for all six algorithms. Actual crypto operations +//! (ed25519, x25519, PQ) are implemented in Phase 13. + +// ── Algorithm Identifiers ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CryptoAlg { + Secp256k1, + Ed25519, + X25519, + MlDsa65, + SlhDsa128s, + MlKem768, + Unknown, +} + +impl CryptoAlg { + pub fn from_str(s: &str) -> Self { + match s { + "secp256k1" => Self::Secp256k1, + "ed25519" => Self::Ed25519, + "x25519" => Self::X25519, + "ml-dsa-65" => Self::MlDsa65, + "slh-dsa-128s" => Self::SlhDsa128s, + "ml-kem-768" => Self::MlKem768, + _ => Self::Unknown, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Secp256k1 => "secp256k1", + Self::Ed25519 => "ed25519", + Self::X25519 => "x25519", + Self::MlDsa65 => "ml-dsa-65", + Self::SlhDsa128s => "slh-dsa-128s", + Self::MlKem768 => "ml-kem-768", + Self::Unknown => "unknown", + } + } +} + +// ── Key Sizes ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy)] +pub struct CryptoAlgSizes { + pub priv_key_len: usize, + pub pub_key_len: usize, + pub sig_len: usize, // 0 for KEM + pub ciphertext_len: usize, // 0 for signatures + pub shared_secret_len: usize, // 0 for signatures +} + +impl CryptoAlg { + pub fn sizes(&self) -> Option { + match self { + Self::Secp256k1 => Some(CryptoAlgSizes { + priv_key_len: 32, pub_key_len: 32, sig_len: 64, ciphertext_len: 0, shared_secret_len: 0, + }), + Self::Ed25519 => Some(CryptoAlgSizes { + priv_key_len: 32, pub_key_len: 32, sig_len: 64, ciphertext_len: 0, shared_secret_len: 0, + }), + Self::X25519 => Some(CryptoAlgSizes { + priv_key_len: 32, pub_key_len: 32, sig_len: 0, ciphertext_len: 0, shared_secret_len: 32, + }), + Self::MlDsa65 => Some(CryptoAlgSizes { + priv_key_len: 4032, pub_key_len: 1952, sig_len: 3309, ciphertext_len: 0, shared_secret_len: 0, + }), + Self::SlhDsa128s => Some(CryptoAlgSizes { + priv_key_len: 64, pub_key_len: 32, sig_len: 7856, ciphertext_len: 0, shared_secret_len: 0, + }), + Self::MlKem768 => Some(CryptoAlgSizes { + priv_key_len: 2400, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32, + }), + Self::Unknown => None, + } + } +} + +// ── Crypto Operations (stubs — Phase 13) ───────────────────────────────────── + +/// Derive a 32-byte seed from a mnemonic using a BIP-44 path (SLIP-0010). +/// +/// For secp256k1: uses BIP-32 derivation. +/// For ed25519/x25519: uses SLIP-0010 (all-hardened). +/// For PQ: uses SLIP-0010 to get a 32-byte seed, then feeds DRBG for keygen. +pub fn derive_seed_from_mnemonic( + mnemonic: &str, + path: &str, +) -> Result<[u8; 32], crate::NsignerError> { + let seed = nips::nip006::mnemonic_to_seed(mnemonic, ""); + + // Parse the path + let path_indices = nips::nip006::parse_bip44_path(path) + .map_err(|_| crate::NsignerError::KeyDerivationFailed)?; + + // Determine if this is a secp256k1 path (BIP-32) or ed25519/x25519 path (SLIP-0010) + // by checking the purpose prefix. + if path.starts_with("m/44'/1237'") { + // BIP-32 derivation for secp256k1 + let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&seed); + let (derived_key, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &path_indices) + .map_err(|_| crate::NsignerError::KeyDerivationFailed)?; + Ok(derived_key) + } else { + // SLIP-0010 derivation for ed25519/x25519/PQ + let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&seed); + let (derived_key, _) = nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices) + .map_err(|_| crate::NsignerError::KeyDerivationFailed)?; + Ok(derived_key) + } +} + +/// ed25519: derive keypair from a 32-byte seed. +pub fn ed25519_keygen_from_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) { + use ed25519_dalek::{SigningKey, VerifyingKey}; + let signing = SigningKey::from_bytes(seed); + let public: VerifyingKey = signing.verifying_key(); + (*seed, public.to_bytes()) +} + +/// ed25519: sign a message. priv is 32-byte private key. +/// Returns 64-byte signature. +pub fn ed25519_sign(priv_key: &[u8; 32], msg: &[u8]) -> [u8; 64] { + use ed25519_dalek::{Signer, SigningKey}; + let signing = SigningKey::from_bytes(priv_key); + let sig = signing.sign(msg); + sig.to_bytes() +} + +/// ed25519: verify a signature. pub is 32-byte public key. +/// Returns true on valid, false on invalid. +pub fn ed25519_verify(pub_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> bool { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + let verifying = match VerifyingKey::from_bytes(pub_key) { + Ok(k) => k, + Err(_) => return false, + }; + let signature = Signature::from_bytes(sig); + verifying.verify(msg, &signature).is_ok() +} + +/// x25519: derive keypair from a 32-byte seed. +pub fn x25519_keygen_from_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) { + let secret = x25519_dalek::StaticSecret::from(*seed); + let public = x25519_dalek::PublicKey::from(&secret); + (*seed, public.to_bytes()) +} + +/// x25519: derive shared secret from our private key and peer's public key. +pub fn x25519_ecdh(our_priv: &[u8; 32], peer_pub: &[u8; 32]) -> [u8; 32] { + let secret = x25519_dalek::StaticSecret::from(*our_priv); + let public = x25519_dalek::PublicKey::from(*peer_pub); + secret.diffie_hellman(&public).to_bytes() +} + +// ── secp256k1 ECDSA (not just Schnorr) ────────────────────────────────────── + +/// secp256k1 ECDSA sign arbitrary bytes. +/// Hashes the message with SHA-256 before signing. +/// Returns 64-byte compact signature (r || s). +pub fn secp256k1_ecdsa_sign(priv_key: &[u8; 32], msg: &[u8]) -> Result<[u8; 64], crate::NsignerError> { + use secp256k1::{Message, Secp256k1, SecretKey}; + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(priv_key).map_err(|_| crate::NsignerError::CryptoFailed)?; + let hash = sha256(msg); + let msg = Message::from_digest_slice(&hash).map_err(|_| crate::NsignerError::CryptoFailed)?; + let sig = secp.sign_ecdsa(&msg, &sk); + Ok(sig.serialize_compact()) +} + +/// secp256k1 ECDSA verify. +/// pub is 32-byte x-only pubkey (converted internally to compressed form). +/// sig is 64-byte compact (r || s). +pub fn secp256k1_ecdsa_verify(pub_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> bool { + use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature}; + let secp = Secp256k1::new(); + // x-only pubkey → compressed pubkey (prefix 0x02 for even) + let mut compressed = [0u8; 33]; + compressed[0] = 0x02; + compressed[1..].copy_from_slice(pub_key); + let pk = match PublicKey::from_slice(&compressed) { + Ok(k) => k, + Err(_) => return false, + }; + let hash = sha256(msg); + let msg = match Message::from_digest_slice(&hash) { + Ok(m) => m, + Err(_) => return false, + }; + let signature = match Signature::from_compact(sig) { + Ok(s) => s, + Err(_) => return false, + }; + // Also need the secret key to get the full public key for verification... + // Actually, we can verify with just the public key. + secp.verify_ecdsa(&msg, &signature, &pk).is_ok() +} + +// ── PQ Crypto Stubs ────────────────────────────────────────────────────────── +// +// The pure Rust crates (ml-dsa, ml-kem, slh-dsa) are included as dependencies +// for future implementation. Their APIs use `TryCryptoRng`, `KeyExport`, and +// other traits that require careful integration with the SHAKE-256 DRBG. +// +// TODO: Wire up the crate APIs for deterministic keygen from seed, sign, verify, +// encapsulate, and decapsulate operations. + +/// ML-DSA-65: generate keypair from a 32-byte seed (deterministic). +/// TODO: Wire up ml-dsa crate API. +pub fn ml_dsa_65_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec, Vec), crate::NsignerError> { + Err(crate::NsignerError::NotYetImplemented) +} + +/// ML-DSA-65: sign a message. +/// TODO: Wire up ml-dsa crate API. +pub fn ml_dsa_65_sign(_priv: &[u8], _msg: &[u8]) -> Result, crate::NsignerError> { + Err(crate::NsignerError::NotYetImplemented) +} + +/// ML-DSA-65: verify a signature. +/// TODO: Wire up ml-dsa crate API. +pub fn ml_dsa_65_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool { + false +} + +/// SLH-DSA-128s: generate keypair from a 32-byte seed (deterministic). +/// TODO: Wire up slh-dsa crate API. +pub fn slh_dsa_128s_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec, Vec), crate::NsignerError> { + Err(crate::NsignerError::NotYetImplemented) +} + +/// SLH-DSA-128s: sign a message. +/// TODO: Wire up slh-dsa crate API. +pub fn slh_dsa_128s_sign(_priv: &[u8], _msg: &[u8]) -> Result, crate::NsignerError> { + Err(crate::NsignerError::NotYetImplemented) +} + +/// SLH-DSA-128s: verify a signature. +/// TODO: Wire up slh-dsa crate API. +pub fn slh_dsa_128s_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool { + false +} + +/// ML-KEM-768: generate keypair from a 32-byte seed (deterministic). +/// TODO: Wire up ml-kem crate API. +pub fn ml_kem_768_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec, Vec), crate::NsignerError> { + Err(crate::NsignerError::NotYetImplemented) +} + +/// ML-KEM-768: encapsulate. pub is 1184-byte public key. +/// Returns (ciphertext[1088], shared_secret[32]). +/// TODO: Wire up ml-kem crate API. +pub fn ml_kem_768_encaps(_pub: &[u8]) -> Result<(Vec, [u8; 32]), crate::NsignerError> { + Err(crate::NsignerError::NotYetImplemented) +} + +/// ML-KEM-768: decapsulate. priv is 2400-byte secret key, ct is 1088-byte ciphertext. +/// Returns shared_secret[32]. +/// TODO: Wire up ml-kem crate API. +pub fn ml_kem_768_decaps(_priv: &[u8], _ct: &[u8]) -> Result<[u8; 32], crate::NsignerError> { + Err(crate::NsignerError::NotYetImplemented) +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/// SHA-256 hash (via nostr_core_lib_rust). +fn sha256(data: &[u8]) -> [u8; 32] { + nostr_core::crypto::sha256::sha256(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_alg_from_str() { + assert_eq!(CryptoAlg::from_str("secp256k1"), CryptoAlg::Secp256k1); + assert_eq!(CryptoAlg::from_str("ed25519"), CryptoAlg::Ed25519); + assert_eq!(CryptoAlg::from_str("ml-dsa-65"), CryptoAlg::MlDsa65); + assert_eq!(CryptoAlg::from_str("unknown"), CryptoAlg::Unknown); + } + + #[test] + fn test_sizes() { + let s = CryptoAlg::Secp256k1.sizes().unwrap(); + assert_eq!(s.priv_key_len, 32); + assert_eq!(s.pub_key_len, 32); + + let s = CryptoAlg::MlKem768.sizes().unwrap(); + assert_eq!(s.priv_key_len, 2400); + assert_eq!(s.pub_key_len, 1184); + assert_eq!(s.ciphertext_len, 1088); + } + + #[test] + fn test_ed25519_sign_verify() { + let seed = [0x42u8; 32]; + let (priv_key, pub_key) = ed25519_keygen_from_seed(&seed); + let msg = b"hello world"; + let sig = ed25519_sign(&priv_key, msg); + assert!(ed25519_verify(&pub_key, msg, &sig)); + assert!(!ed25519_verify(&pub_key, b"wrong message", &sig)); + } + + #[test] + fn test_x25519_ecdh() { + let seed_a = [0x01u8; 32]; + let seed_b = [0x02u8; 32]; + let (priv_a, pub_a) = x25519_keygen_from_seed(&seed_a); + let (priv_b, pub_b) = x25519_keygen_from_seed(&seed_b); + let shared_a = x25519_ecdh(&priv_a, &pub_b); + let shared_b = x25519_ecdh(&priv_b, &pub_a); + assert_eq!(shared_a, shared_b); + } +} diff --git a/src/pq_drbg.rs b/src/pq_drbg.rs new file mode 100644 index 0000000..dfec6ee --- /dev/null +++ b/src/pq_drbg.rs @@ -0,0 +1,170 @@ +//! Deterministic PRNG for post-quantum key generation. +//! +//! Port of `pq_drbg.c`. Implements a SHAKE-256-based deterministic PRNG +//! that replaces PQClean's `randombytes()` callback. Same seed → same output. + +use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}}; + +/// DRBG state (not global — per-instance for thread safety). +pub struct Drbg { + seed: [u8; 32], + counter: u64, + buffer: [u8; 168], // SHAKE-256 squeeze buffer + buffer_pos: usize, +} + +impl Drbg { + /// Initialize the DRBG with a 32-byte seed. + pub fn new(seed: &[u8; 32]) -> Self { + Drbg { + seed: *seed, + counter: 0, + buffer: [0u8; 168], + buffer_pos: 168, // forces refill on first read + } + } + + /// Squeeze more bytes from SHAKE-256(seed || counter_le_64). + fn refill(&mut self) { + let mut hasher = Shake256::default(); + hasher.update(&self.seed); + hasher.update(&self.counter.to_le_bytes()); + let mut reader = hasher.finalize_xof(); + let mut out = [0u8; 168]; + reader.read(&mut out); + self.buffer = out; + self.buffer_pos = 0; + self.counter += 1; + } + + /// Fill `buf` with pseudo-random bytes. + pub fn randombytes(&mut self, buf: &mut [u8]) { + for byte in buf.iter_mut() { + if self.buffer_pos >= self.buffer.len() { + self.refill(); + } + *byte = self.buffer[self.buffer_pos]; + self.buffer_pos += 1; + } + } + + /// Zeroize the seed. + pub fn zeroize(&mut self) { + use zeroize::Zeroize; + self.seed.zeroize(); + self.buffer.zeroize(); + self.counter = 0; + self.buffer_pos = 0; + } +} + +impl Drop for Drbg { + fn drop(&mut self) { + self.zeroize(); + } +} + +/// RngCore wrapper for SHAKE-256 DRBG — can be consumed by PQ crypto keygen. +/// +/// Implements `rand_core::RngCore` + `CryptoRng` so it can be passed to +/// PQ crypto crates that require a deterministic RNG for seed-based keygen. +#[derive(Clone)] +pub struct ShakeDrbgRng { + seed: [u8; 32], + counter: u64, + buffer: [u8; 168], + buffer_pos: usize, +} + +impl ShakeDrbgRng { + /// Create a new SHAKE-256 DRBG RNG from a 32-byte seed. + pub fn new(seed: &[u8; 32]) -> Self { + ShakeDrbgRng { + seed: *seed, + counter: 0, + buffer: [0u8; 168], + buffer_pos: 168, // forces refill on first read + } + } + + fn refill(&mut self) { + let mut hasher = Shake256::default(); + hasher.update(&self.seed); + hasher.update(&self.counter.to_le_bytes()); + let mut reader = hasher.finalize_xof(); + let mut out = [0u8; 168]; + reader.read(&mut out); + self.buffer = out; + self.buffer_pos = 0; + self.counter += 1; + } +} + +impl rand_core::RngCore for ShakeDrbgRng { + fn next_u32(&mut self) -> u32 { + let mut buf = [0u8; 4]; + self.fill_bytes(&mut buf); + u32::from_le_bytes(buf) + } + + fn next_u64(&mut self) -> u64 { + let mut buf = [0u8; 8]; + self.fill_bytes(&mut buf); + u64::from_le_bytes(buf) + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + for byte in dest.iter_mut() { + if self.buffer_pos >= self.buffer.len() { + self.refill(); + } + *byte = self.buffer[self.buffer_pos]; + self.buffer_pos += 1; + } + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { + self.fill_bytes(dest); + Ok(()) + } +} + +impl rand_core::CryptoRng for ShakeDrbgRng {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_determinism() { + let seed = [0x42u8; 32]; + let mut drbg1 = Drbg::new(&seed); + let mut drbg2 = Drbg::new(&seed); + + let mut out1 = [0u8; 100]; + let mut out2 = [0u8; 100]; + drbg1.randombytes(&mut out1); + drbg2.randombytes(&mut out2); + assert_eq!(out1, out2); + } + + #[test] + fn test_different_seeds() { + let mut drbg1 = Drbg::new(&[0x01u8; 32]); + let mut drbg2 = Drbg::new(&[0x02u8; 32]); + + let mut out1 = [0u8; 32]; + let mut out2 = [0u8; 32]; + drbg1.randombytes(&mut out1); + drbg2.randombytes(&mut out2); + assert_ne!(out1, out2); + } + + #[test] + fn test_large_request() { + let mut drbg = Drbg::new(&[0x42u8; 32]); + let mut out = [0u8; 500]; // larger than buffer (168) + drbg.randombytes(&mut out); + // Should not panic + } +} diff --git a/src/role_table.rs b/src/role_table.rs new file mode 100644 index 0000000..dd59758 --- /dev/null +++ b/src/role_table.rs @@ -0,0 +1,643 @@ +//! Role table — binds role names to derivation path templates. +//! +//! Port of `role_table.c`. Each role entry maps a human-readable name +//! (acting as an access token) to a BIP-44 derivation path template. +//! The template may contain a `%d` placeholder for a variable index. + +use crate::NsignerError; +use std::collections::HashSet; + +// ── Limits ─────────────────────────────────────────────────────────────────── + +/// Map role_curve + role_purpose to crypto_alg. +pub fn crypto_alg_from_role(curve: RoleCurve, purpose: RolePurpose) -> crate::pq_crypto::CryptoAlg { + use crate::pq_crypto::CryptoAlg; + match (curve, purpose) { + (RoleCurve::Secp256k1, RolePurpose::Nostr) => CryptoAlg::Secp256k1, + (RoleCurve::Ed25519, RolePurpose::Ssh) => CryptoAlg::Ed25519, + (RoleCurve::X25519, RolePurpose::Age) => CryptoAlg::X25519, + (RoleCurve::MlDsa65, RolePurpose::PqSig) => CryptoAlg::MlDsa65, + (RoleCurve::SlhDsa128s, RolePurpose::PqSig) => CryptoAlg::SlhDsa128s, + (RoleCurve::MlKem768, RolePurpose::PqKem) => CryptoAlg::MlKem768, + _ => CryptoAlg::Unknown, + } +} + +pub const ROLE_NAME_MAX: usize = 64; +pub const ROLE_PATH_MAX: usize = 128; +pub const ROLE_PURPOSE_MAX: usize = 32; +pub const ROLE_CURVE_MAX: usize = 16; +pub const ROLE_TABLE_MAX_ENTRIES: usize = 256; +pub const PATH_ALLOWED_MAX: usize = 64; + +// ── Enums ──────────────────────────────────────────────────────────────────── + +/// Purpose enum for fast comparison (string form kept for display). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RolePurpose { + Nostr, + Bitcoin, + Ssh, + Age, + Fips, + PqSig, + PqKem, + Unknown, +} + +impl RolePurpose { + pub fn from_str(s: &str) -> Self { + match s { + "nostr" => Self::Nostr, + "bitcoin" => Self::Bitcoin, + "ssh" => Self::Ssh, + "age" => Self::Age, + "fips" => Self::Fips, + "pq_sig" => Self::PqSig, + "pq_kem" => Self::PqKem, + _ => Self::Unknown, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Nostr => "nostr", + Self::Bitcoin => "bitcoin", + Self::Ssh => "ssh", + Self::Age => "age", + Self::Fips => "fips", + Self::PqSig => "pq_sig", + Self::PqKem => "pq_kem", + Self::Unknown => "unknown", + } + } +} + +/// Curve enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RoleCurve { + Secp256k1, + Ed25519, + X25519, + MlDsa65, + SlhDsa128s, + MlKem768, + Unknown, +} + +impl RoleCurve { + pub fn from_str(s: &str) -> Self { + match s { + "secp256k1" => Self::Secp256k1, + "ed25519" => Self::Ed25519, + "x25519" => Self::X25519, + "ml-dsa-65" => Self::MlDsa65, + "slh-dsa-128s" => Self::SlhDsa128s, + "ml-kem-768" => Self::MlKem768, + _ => Self::Unknown, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Secp256k1 => "secp256k1", + Self::Ed25519 => "ed25519", + Self::X25519 => "x25519", + Self::MlDsa65 => "ml-dsa-65", + Self::SlhDsa128s => "slh-dsa-128s", + Self::MlKem768 => "ml-kem-768", + Self::Unknown => "unknown", + } + } +} + +/// Selector type — how this role's key is addressed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoleSelectorType { + NostrIndex, + RolePath, +} + +// ── Role Entry ─────────────────────────────────────────────────────────────── + +/// A single role entry. +#[derive(Debug, Clone)] +pub struct RoleEntry { + pub name: String, + pub purpose_str: String, + pub curve_str: String, + pub purpose: RolePurpose, + pub curve: RoleCurve, + pub selector_type: RoleSelectorType, + /// Valid if selector_type == NostrIndex. + pub nostr_index: i32, + /// Valid if selector_type == RolePath. May contain `%d` placeholder. + pub role_path: String, + /// Filled after derivation, empty until then. + pub pubkey_hex: String, + /// 1 if pubkey_hex has been populated. + pub derived: bool, + /// Inclusive lower bound for %d; -1 = fixed path (no variable). + pub path_range_lo: i32, + /// Inclusive upper bound; == path_range_lo for single. + pub path_range_hi: i32, + /// Default index when client sends {"role":...} without "index"; -1 = require explicit. + pub path_default_index: i32, + /// Explicit set of allowed indices (for sets); empty = use range. + pub path_allowed_indices: Vec, + /// 0 = role-as-password (no prompt), 1 = require interactive approval. + pub requires_approval: bool, +} + +impl Default for RoleEntry { + fn default() -> Self { + RoleEntry { + name: String::new(), + purpose_str: String::new(), + curve_str: String::new(), + purpose: RolePurpose::Unknown, + curve: RoleCurve::Unknown, + selector_type: RoleSelectorType::RolePath, + nostr_index: -1, + role_path: String::new(), + pubkey_hex: String::new(), + derived: false, + path_range_lo: -1, + path_range_hi: -1, + path_default_index: -1, + path_allowed_indices: Vec::new(), + requires_approval: false, + } + } +} + +impl RoleEntry { + /// Check if the role path contains a `%d` variable placeholder. + pub fn has_variable_path(&self) -> bool { + self.selector_type == RoleSelectorType::RolePath && self.role_path.contains("%d") + } + + /// Check if a concrete derivation path matches this role's path template. + /// + /// The template may contain a `%d` placeholder (with optional `'` hardened marker). + /// Returns the extracted index if matched, or None. + pub fn path_matches_template(&self, concrete: &str) -> Option { + role_path_matches_template(&concrete, &self.role_path) + } + + /// Check whether a concrete path matches the template AND the + /// extracted index is within the role's allowed range/set. + pub fn path_matches_with_range(&self, concrete: &str) -> bool { + // Fixed path (no %d) — direct string comparison + if !self.has_variable_path() { + return concrete == self.role_path; + } + + let index = match self.path_matches_template(concrete) { + Some(i) => i, + None => return false, + }; + + if !self.path_allowed_indices.is_empty() { + // Set form: check if index is in the allowed set + self.path_allowed_indices.contains(&index) + } else if self.path_range_lo >= 0 { + // Range form: check lo..hi + index >= self.path_range_lo && index <= self.path_range_hi + } else { + // Wildcard with no range restriction + true + } + } +} + +// ── Role Table ─────────────────────────────────────────────────────────────── + +/// The role table. +#[derive(Debug, Default)] +pub struct RoleTable { + pub entries: Vec, +} + +impl RoleTable { + pub fn new() -> Self { + RoleTable::default() + } + + /// Add a role entry. Returns error if table full or name duplicate. + pub fn add(&mut self, entry: RoleEntry) -> Result<(), NsignerError> { + if self.entries.len() >= ROLE_TABLE_MAX_ENTRIES { + return Err(NsignerError::Internal("role table full".into())); + } + if self.find_by_name(&entry.name).is_some() { + return Err(NsignerError::Internal("duplicate role name".into())); + } + self.entries.push(entry); + Ok(()) + } + + /// Find a role by name. + pub fn find_by_name(&self, name: &str) -> Option<&RoleEntry> { + self.entries.iter().find(|e| e.name == name) + } + + /// Find a role by name (mutable). + pub fn find_by_name_mut(&mut self, name: &str) -> Option<&mut RoleEntry> { + self.entries.iter_mut().find(|e| e.name == name) + } + + /// Find a role by nostr_index. + pub fn find_by_nostr_index(&self, index: i32) -> Option<&RoleEntry> { + self.entries + .iter() + .find(|e| e.selector_type == RoleSelectorType::NostrIndex && e.nostr_index == index) + } + + /// Get the default role (named "main"). + pub fn get_default(&self) -> Option<&RoleEntry> { + self.find_by_name("main") + } + + /// Number of entries. + pub fn count(&self) -> usize { + self.entries.len() + } + + /// Register a nostr-index role if missing. + pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), NsignerError> { + if self.find_by_nostr_index(nostr_index).is_some() { + return Ok(()); + } + let mut entry = RoleEntry::default(); + entry.name = if nostr_index == 0 { + "main".to_string() + } else { + format!("nostr_idx_{}", nostr_index) + }; + entry.purpose = RolePurpose::Nostr; + entry.purpose_str = "nostr".to_string(); + entry.curve = RoleCurve::Secp256k1; + entry.curve_str = "secp256k1".to_string(); + entry.selector_type = RoleSelectorType::NostrIndex; + entry.nostr_index = nostr_index; + entry.role_path = format!("m/44'/1237'/{}'/0/0", nostr_index); + entry.requires_approval = false; + self.add(entry) + } + + /// Register a RolePath role bound to an explicit derivation path template. + #[allow(clippy::too_many_arguments)] + pub fn register_role_path( + &mut self, + name: &str, + path: &str, + purpose: RolePurpose, + curve: RoleCurve, + range_lo: i32, + range_hi: i32, + default_index: i32, + allowed_indices: &[i32], + ) -> Result<(), NsignerError> { + let mut entry = RoleEntry::default(); + entry.name = name.to_string(); + entry.purpose = purpose; + entry.purpose_str = purpose.as_str().to_string(); + entry.curve = curve; + entry.curve_str = curve.as_str().to_string(); + entry.selector_type = RoleSelectorType::RolePath; + entry.role_path = path.to_string(); + entry.nostr_index = -1; + entry.path_range_lo = range_lo; + entry.path_range_hi = range_hi; + entry.path_default_index = default_index; + entry.path_allowed_indices = allowed_indices.to_vec(); + entry.requires_approval = false; + entry.derived = false; + self.add(entry) + } +} + +// ── Path Template Matching ─────────────────────────────────────────────────── + +/// Check whether a concrete derivation path matches a role's path template. +/// +/// The template may contain a `%d` placeholder (with optional `'` hardened marker). +/// Returns `Some(index)` if matched, or `None`. +pub fn role_path_matches_template(concrete: &str, template: &str) -> Option { + let template_segs: Vec<&str> = template.split('/').collect(); + let concrete_segs: Vec<&str> = concrete.split('/').collect(); + + if template_segs.len() != concrete_segs.len() { + return None; + } + + let mut extracted_index: Option = None; + + for (tseg, cseg) in template_segs.iter().zip(concrete_segs.iter()) { + if *tseg == "%d" || *tseg == "%d'" { + // Variable segment — extract the index + let (num_part, hardened) = if let Some(stripped) = cseg + .strip_suffix('\'') + .or_else(|| cseg.strip_suffix('h')) + .or_else(|| cseg.strip_suffix('H')) + { + (stripped, true) + } else { + (*cseg, false) + }; + + // Template hardened marker must match + let template_hardened = tseg.ends_with('\''); + if template_hardened != hardened { + return None; + } + + let val: i32 = num_part.parse().ok()?; + if val < 0 { + return None; + } + if extracted_index.is_some() { + // Only one %d per template + return None; + } + extracted_index = Some(val); + } else { + // Literal segment — must match exactly + if *tseg != *cseg { + return None; + } + } + } + + extracted_index +} + +/// Extract the numeric index from a concrete path matching a `%d` template. +/// Returns the index, or -1 if no `%d` or no match. +pub fn role_path_extract_index(concrete: &str, template: &str) -> i32 { + role_path_matches_template(concrete, template).unwrap_or(-1) +} + +// ── Path Template Parser ───────────────────────────────────────────────────── + +/// Parse a path template token (e.g. "m/44'/1237'/0-3/1/0" or +/// "m/44'/1237'/1+34+54/1/0") into a template with `%d` placeholder +/// and allowed indices. +/// +/// On success: +/// - `template_out` — the path with `%d` replacing the numeric/range/set segment +/// - `range_lo`/`range_hi` — min/max of the allowed indices +/// - `allowed_indices` — explicit set (if set form was used); empty for pure range/single +/// - Returns `Ok(())` on success, `Err` on parse error +#[allow(clippy::too_many_arguments)] +pub fn parse_path_template( + token: &str, +) -> Result<(String, i32, i32, Vec), NsignerError> { + let segs: Vec<&str> = token.split('/').collect(); + if segs.is_empty() { + return Err(NsignerError::InvalidInput); + } + + let mut template_out = String::new(); + let mut range_lo: i32 = 0; + let mut range_hi: i32 = 0; + let mut allowed_indices: Vec = Vec::new(); + let mut found_variable = false; + + for (i, seg) in segs.iter().enumerate() { + if i == 0 && (*seg == "m" || *seg == "M") { + template_out.push_str(seg); + template_out.push('/'); + continue; + } + + if !found_variable { + // Check for wildcard * + if *seg == "*" || *seg == "*'" || *seg == "*h" || *seg == "*H" { + let hardened = seg.contains('\'') || seg.contains('h') || seg.contains('H'); + found_variable = true; + range_lo = 0; + range_hi = i32::MAX; + template_out.push_str("%d"); + if hardened { + template_out.push('\''); + } + template_out.push('/'); + continue; + } + + // Check for range/set markers + let has_plus = seg.contains('+'); + let has_dash = seg.contains('-'); + let is_range_or_set = has_plus || has_dash; + + // Strip hardened marker for range/set forms + let (seg_clean, seg_hardened) = if is_range_or_set { + if let Some(stripped) = seg + .strip_suffix('\'') + .or_else(|| seg.strip_suffix('h')) + .or_else(|| seg.strip_suffix('H')) + { + (stripped, true) + } else { + (*seg, false) + } + } else { + (*seg, false) + }; + + if has_plus { + // Set form: "1+34+54" or "1+3-5+10" + let mut set = HashSet::new(); + for tok in seg_clean.split('+') { + if let Some(dash) = tok.find('-') { + let lo: i32 = tok[..dash].parse().map_err(|_| NsignerError::InvalidInput)?; + let hi: i32 = tok[dash + 1..].parse().map_err(|_| NsignerError::InvalidInput)?; + if lo < 0 || hi < 0 || lo > hi { + return Err(NsignerError::InvalidInput); + } + for v in lo..=hi { + set.insert(v); + } + } else { + let val: i32 = tok.parse().map_err(|_| NsignerError::InvalidInput)?; + if val < 0 { + return Err(NsignerError::InvalidInput); + } + set.insert(val); + } + } + if set.is_empty() { + // Not a valid set — treat as literal + template_out.push_str(seg); + template_out.push('/'); + } else { + found_variable = true; + allowed_indices = set.iter().copied().collect(); + allowed_indices.sort(); + range_lo = *allowed_indices.first().unwrap(); + range_hi = *allowed_indices.last().unwrap(); + template_out.push_str("%d"); + if seg_hardened { + template_out.push('\''); + } + template_out.push('/'); + } + } else if has_dash { + // Range form: "N-M" + let dash_pos = seg_clean.find('-').unwrap(); + let lo: i32 = seg_clean[..dash_pos] + .parse() + .map_err(|_| NsignerError::InvalidInput)?; + let hi: i32 = seg_clean[dash_pos + 1..] + .parse() + .map_err(|_| NsignerError::InvalidInput)?; + if lo < 0 || hi < 0 || lo > hi { + // Not a valid numeric range — treat as literal + template_out.push_str(seg); + template_out.push('/'); + } else { + found_variable = true; + range_lo = lo; + range_hi = hi; + template_out.push_str("%d"); + if seg_hardened { + template_out.push('\''); + } + template_out.push('/'); + } + } else { + // Single number or literal — always treat as a literal segment. + // The variable is only introduced via wildcard (*), range (N-M), + // or set (N+M) forms. A plain number like "0" is a fixed literal. + template_out.push_str(seg); + template_out.push('/'); + } + } else { + // Literal segment after the variable + template_out.push_str(seg); + template_out.push('/'); + } + } + + // Remove trailing '/' + if template_out.ends_with('/') { + template_out.pop(); + } + + if !found_variable { + // Fixed path — no variable segment. Treat as a single fixed key. + range_lo = -1; + range_hi = -1; + } + + Ok((template_out, range_lo, range_hi, allowed_indices)) +} + +/// Auto-detect purpose from a derivation path prefix. +/// m/44'/1237' → nostr, m/44'/102001' → ssh, etc. +pub fn purpose_from_path(path: &str) -> RolePurpose { + if path.starts_with("m/44'/1237'") { + RolePurpose::Nostr + } else if path.starts_with("m/44'/102001'") { + RolePurpose::Ssh + } else if path.starts_with("m/44'/102002'") { + RolePurpose::Age + } else if path.starts_with("m/44'/102003'") { + RolePurpose::PqSig + } else if path.starts_with("m/44'/102004'") { + RolePurpose::PqSig + } else if path.starts_with("m/44'/102005'") { + RolePurpose::PqKem + } else if path.starts_with("m/84'") || path.starts_with("m/86'") { + RolePurpose::Bitcoin + } else { + RolePurpose::Nostr // default + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_and_find() { + let mut table = RoleTable::new(); + let mut entry = RoleEntry::default(); + entry.name = "main".to_string(); + entry.purpose = RolePurpose::Nostr; + entry.curve = RoleCurve::Secp256k1; + entry.selector_type = RoleSelectorType::RolePath; + entry.role_path = "m/44'/1237'/0'/0/0".to_string(); + table.add(entry).unwrap(); + + assert!(table.find_by_name("main").is_some()); + assert!(table.find_by_name("nonexistent").is_none()); + } + + #[test] + fn test_duplicate_rejected() { + let mut table = RoleTable::new(); + let mut e1 = RoleEntry::default(); + e1.name = "main".to_string(); + table.add(e1).unwrap(); + + let mut e2 = RoleEntry::default(); + e2.name = "main".to_string(); + assert!(table.add(e2).is_err()); + } + + #[test] + fn test_path_template_matching() { + let template = "m/44'/1237'/%d'/0/0"; + assert_eq!(role_path_matches_template("m/44'/1237'/5'/0/0", template), Some(5)); + assert_eq!(role_path_matches_template("m/44'/1237'/0'/0/0", template), Some(0)); + assert_eq!(role_path_matches_template("m/44'/1237'/5/1/0", template), None); // wrong segment + } + + #[test] + fn test_parse_path_template_range() { + let (tmpl, lo, hi, allowed) = parse_path_template("m/44'/1237'/0-3/0/0").unwrap(); + assert_eq!(tmpl, "m/44'/1237'/%d/0/0"); + assert_eq!(lo, 0); + assert_eq!(hi, 3); + assert!(allowed.is_empty()); + } + + #[test] + fn test_parse_path_template_wildcard() { + let (tmpl, lo, hi, _) = parse_path_template("m/44'/1237'/*'/0/0").unwrap(); + assert_eq!(tmpl, "m/44'/1237'/%d'/0/0"); + assert_eq!(lo, 0); + assert_eq!(hi, i32::MAX); + } + + #[test] + fn test_parse_path_template_set() { + let (tmpl, lo, hi, allowed) = parse_path_template("m/44'/1237'/1+34+54/0/0").unwrap(); + assert_eq!(tmpl, "m/44'/1237'/%d/0/0"); + assert_eq!(lo, 1); + assert_eq!(hi, 54); + assert_eq!(allowed.len(), 3); + assert!(allowed.contains(&1)); + assert!(allowed.contains(&34)); + assert!(allowed.contains(&54)); + } + + #[test] + fn test_parse_path_template_fixed() { + let (tmpl, lo, hi, _) = parse_path_template("m/44'/1237'/0'/0/0").unwrap(); + assert_eq!(tmpl, "m/44'/1237'/0'/0/0"); + assert_eq!(lo, -1); // fixed path + assert_eq!(hi, -1); + } + + #[test] + fn test_purpose_from_path() { + assert_eq!(purpose_from_path("m/44'/1237'/0'/0/0"), RolePurpose::Nostr); + assert_eq!(purpose_from_path("m/44'/102001'/0'/0'/0'"), RolePurpose::Ssh); + assert_eq!(purpose_from_path("m/44'/102002'/0'/0'/0'"), RolePurpose::Age); + assert_eq!(purpose_from_path("m/44'/102003'/0'/0'/0'"), RolePurpose::PqSig); + assert_eq!(purpose_from_path("m/44'/102005'/0'/0'/0'"), RolePurpose::PqKem); + } +} diff --git a/src/secure_mem.rs b/src/secure_mem.rs new file mode 100644 index 0000000..6ff67b6 --- /dev/null +++ b/src/secure_mem.rs @@ -0,0 +1,290 @@ +//! Secure memory buffer — mlock'd, zeroized on free. +//! +//! Port of `secure_mem.c`. Sensitive buffers (mnemonic, private keys) +//! live in mlock'd RAM and are zeroized on drop. + +use std::alloc::{alloc, dealloc, Layout}; +use std::sync::atomic::{AtomicBool, Ordering}; +use zeroize::Zeroize; + +/// Global flag permitting unlocked operation (when mlock fails). +static ALLOW_UNLOCKED: AtomicBool = AtomicBool::new(false); + +/// Set the global flag permitting unlocked operation. +/// +/// Call at startup when running in containers or environments with +/// limited `RLIMIT_MEMLOCK`. When set, `mlock` failures produce a +/// warning but do not abort allocation. +pub fn allow_unlocked() { + ALLOW_UNLOCKED.store(true, Ordering::SeqCst); +} + +/// Whether unlocked operation is currently permitted. +pub fn is_unlocked_allowed() -> bool { + ALLOW_UNLOCKED.load(Ordering::SeqCst) +} + +/// Secure memory buffer — mlock'd, zeroized on drop. +/// +/// Holds sensitive material (mnemonic phrases, private keys). The +/// memory is locked with `mlock(2)` to prevent swap-out, and +/// zeroized with `explicit_bzero` semantics on free. +pub struct SecureBuf { + ptr: *mut u8, + size: usize, + locked: bool, +} + +unsafe impl Send for SecureBuf {} +unsafe impl Sync for SecureBuf {} + +impl SecureBuf { + /// Allocate a secure buffer of `size` bytes. + /// + /// Returns `MemoryFailed` if allocation or mlock fails (unless + /// `allow_unlocked()` was called). + pub fn alloc(size: usize) -> Result { + if size == 0 { + return Err(crate::NsignerError::InvalidInput); + } + + let layout = Layout::from_size_align(size, 1) + .map_err(|_| crate::NsignerError::MemoryFailed)?; + + let ptr = unsafe { alloc(layout) }; + if ptr.is_null() { + return Err(crate::NsignerError::MemoryFailed); + } + + // Zero-initialize + unsafe { std::ptr::write_bytes(ptr, 0, size) }; + + // Attempt mlock + let locked = unsafe { libc::mlock(ptr as *const libc::c_void, size) } == 0; + if !locked && !is_unlocked_allowed() { + // mlock failed and unlocked mode not permitted — fail hard + unsafe { dealloc(ptr, layout) }; + return Err(crate::NsignerError::MemoryFailed); + } + + Ok(SecureBuf { ptr, size, locked }) + } + + /// Usable size in bytes. + pub fn size(&self) -> usize { + self.size + } + + /// Whether mlock succeeded. + pub fn is_locked(&self) -> bool { + self.locked + } + + /// Read access to the buffer contents. + pub fn as_slice(&self) -> &[u8] { + unsafe { std::slice::from_raw_parts(self.ptr, self.size) } + } + + /// Write access to the buffer contents. + pub fn as_mut_slice(&mut self) -> &mut [u8] { + unsafe { std::slice::from_raw_parts_mut(self.ptr, self.size) } + } + + /// Copy data into the buffer (truncates to buffer size). + pub fn copy_from(&mut self, src: &[u8]) { + let len = src.len().min(self.size); + self.as_mut_slice()[..len].copy_from_slice(&src[..len]); + } + + /// Copy data into the buffer from a slice (alias for compatibility). + pub fn copy_from_slice(&mut self, src: &[u8]) { + self.copy_from(src); + } + + /// Zeroize the buffer contents in place. + pub fn clear(&mut self) { + self.as_mut_slice().zeroize(); + } + + /// Resize the buffer to `new_size`, preserving the prefix that fits. + /// + /// If `new_size` is 0, returns `InvalidInput`. If allocation of the + /// new buffer fails, the original buffer is left intact and an error + /// is returned. + pub fn resize(&mut self, new_size: usize) -> Result<(), crate::NsignerError> { + if new_size == 0 { + return Err(crate::NsignerError::InvalidInput); + } + if new_size == self.size { + return Ok(()); + } + + let mut new_buf = SecureBuf::alloc(new_size)?; + let copy_len = self.size.min(new_size); + new_buf.as_mut_slice()[..copy_len].copy_from_slice(&self.as_slice()[..copy_len]); + + // Swap internals + let old_ptr = self.ptr; + let old_size = self.size; + let old_locked = self.locked; + + self.ptr = new_buf.ptr; + self.size = new_buf.size; + self.locked = new_buf.locked; + + // Prevent new_buf's Drop from running on the moved-out pointer + new_buf.ptr = std::ptr::null_mut(); + new_buf.size = 0; + new_buf.locked = false; + + // Free old buffer + if !old_ptr.is_null() && old_size > 0 { + unsafe { std::ptr::write_bytes(old_ptr, 0, old_size) }; + if old_locked { + unsafe { libc::munlock(old_ptr as *const libc::c_void, old_size) }; + } + let layout = Layout::from_size_align(old_size, 1).unwrap(); + unsafe { dealloc(old_ptr, layout) }; + } + + Ok(()) + } +} + +impl Drop for SecureBuf { + fn drop(&mut self) { + if !self.ptr.is_null() && self.size > 0 { + // Zeroize + unsafe { std::ptr::write_bytes(self.ptr, 0, self.size) }; + + // munlock if locked + if self.locked { + unsafe { libc::munlock(self.ptr as *const libc::c_void, self.size) }; + } + + // Dealloc + let layout = Layout::from_size_align(self.size, 1).unwrap(); + unsafe { dealloc(self.ptr, layout) }; + } + self.ptr = std::ptr::null_mut(); + self.size = 0; + } +} + +/// Zeroize `len` bytes at `ptr` in a way the compiler cannot optimize away. +pub fn secure_memzero(buf: &mut [u8]) { + buf.zeroize(); +} + +/// Constant-time comparison of two byte slices. +/// +/// Returns `true` if the slices are equal. The comparison runs in time +/// proportional to the shorter slice's length (callers should ensure +/// equal lengths for full constant-time properties). +pub fn secure_compare(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_alloc_free() { + let buf = SecureBuf::alloc(32).unwrap(); + assert_eq!(buf.size(), 32); + // Drop runs zeroize + munlock + dealloc + } + + #[test] + fn test_copy_from() { + let mut buf = SecureBuf::alloc(8).unwrap(); + let data = [1u8, 2, 3, 4, 5, 6, 7, 8]; + buf.copy_from(&data); + assert_eq!(buf.as_slice(), &data[..]); + } + + #[test] + fn test_copy_from_truncates() { + let mut buf = SecureBuf::alloc(4).unwrap(); + let data = [1u8, 2, 3, 4, 5, 6, 7, 8]; + buf.copy_from(&data); + assert_eq!(buf.as_slice(), &[1, 2, 3, 4]); + } + + #[test] + fn test_zero_size_rejected() { + assert!(SecureBuf::alloc(0).is_err()); + } + + #[test] + fn test_clear_zeroizes() { + let mut buf = SecureBuf::alloc(16).unwrap(); + buf.copy_from(&[0xFF; 16]); + buf.clear(); + assert!(buf.as_slice().iter().all(|&b| b == 0)); + } + + #[test] + fn test_resize_grows_preserving_prefix() { + let mut buf = SecureBuf::alloc(4).unwrap(); + buf.copy_from(&[10, 20, 30, 40]); + buf.resize(8).unwrap(); + assert_eq!(buf.size(), 8); + assert_eq!(&buf.as_slice()[..4], &[10, 20, 30, 40]); + assert_eq!(&buf.as_slice()[4..], &[0, 0, 0, 0]); + } + + #[test] + fn test_resize_shrinks_preserving_prefix() { + let mut buf = SecureBuf::alloc(8).unwrap(); + buf.copy_from(&[10, 20, 30, 40, 50, 60, 70, 80]); + buf.resize(3).unwrap(); + assert_eq!(buf.size(), 3); + assert_eq!(buf.as_slice(), &[10, 20, 30]); + } + + #[test] + fn test_resize_zero_rejected() { + let mut buf = SecureBuf::alloc(4).unwrap(); + assert!(buf.resize(0).is_err()); + } + + #[test] + fn test_secure_memzero() { + let mut data = [0xABu8; 32]; + secure_memzero(&mut data); + assert!(data.iter().all(|&b| b == 0)); + } + + #[test] + fn test_secure_compare_equal() { + assert!(secure_compare(&[1, 2, 3], &[1, 2, 3])); + } + + #[test] + fn test_secure_compare_unequal() { + assert!(!secure_compare(&[1, 2, 3], &[1, 2, 4])); + } + + #[test] + fn test_secure_compare_different_lengths() { + assert!(!secure_compare(&[1, 2, 3], &[1, 2])); + } + + #[test] + fn test_allow_unlocked_flag() { + let prev = is_unlocked_allowed(); + allow_unlocked(); + assert!(is_unlocked_allowed()); + // Restore for other tests + ALLOW_UNLOCKED.store(prev, Ordering::SeqCst); + } +} diff --git a/src/selector.rs b/src/selector.rs new file mode 100644 index 0000000..478e11c --- /dev/null +++ b/src/selector.rs @@ -0,0 +1,223 @@ +//! Selector resolution — matches a request's role selector against the role table. +//! +//! Port of `selector.c`. + +use crate::role_table::RoleTable; + +// ── Error Codes ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectorError { + Ok = 0, + Ambiguous = -1, + NotFound = -2, + NoDefault = -3, + PathMismatch = -4, + NostrIndexDeprecated = -5, + IndexDeprecated = -6, + RoleRequired = -7, + PathRequired = -8, +} + +impl SelectorError { + pub fn as_str(&self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Ambiguous => "ambiguous_role_selector", + Self::NotFound => "unknown_role", + Self::NoDefault => "no_default_role", + Self::PathMismatch => "path_not_allowed", + Self::NostrIndexDeprecated => "nostr_index_deprecated", + Self::IndexDeprecated => "index_deprecated", + Self::RoleRequired => "role_required", + Self::PathRequired => "path_required", + } + } +} + +// ── Selector Request ───────────────────────────────────────────────────────── + +/// Parsed selector from a request's options object. +#[derive(Debug, Clone, Default)] +pub struct SelectorRequest { + pub has_role: bool, + pub role_name: String, + + pub has_nostr_index: bool, + pub nostr_index: i32, + + pub has_role_path: bool, + pub role_path: String, + + pub has_index: bool, + pub index: i32, +} + +impl SelectorRequest { + pub fn new() -> Self { + Self::default() + } +} + +// ── Resolution ─────────────────────────────────────────────────────────────── + +/// Resolve a selector request against the role table. +/// +/// On success returns `Ok(role_index)` — the index into the table. +/// On failure returns the appropriate `SelectorError`. +pub fn selector_resolve( + req: &SelectorRequest, + table: &RoleTable, +) -> Result { + // Both role and role_path are required together (combined selector). + // No resolution order and no default role. + if !req.has_role && !req.has_nostr_index && !req.has_role_path { + // No selector given — check for default "main" role + if table.get_default().is_some() { + // Find the index of "main" + return Ok(table + .entries + .iter() + .position(|e| e.name == "main") + .ok_or(SelectorError::NoDefault)?); + } + return Err(SelectorError::NoDefault); + } + + if req.has_role { + if !req.has_role_path { + // role without role_path — check if it's a fixed-path role + let entry = table.find_by_name(&req.role_name).ok_or(SelectorError::NotFound)?; + if entry.has_variable_path() { + // Variable path role needs role_path + return Err(SelectorError::PathRequired); + } + // Fixed path role — OK, return index + return Ok(table + .entries + .iter() + .position(|e| e.name == req.role_name) + .ok_or(SelectorError::NotFound)?); + } + + // role + role_path: verify path matches the role's template + let entry = table.find_by_name(&req.role_name).ok_or(SelectorError::NotFound)?; + if !entry.path_matches_with_range(&req.role_path) { + return Err(SelectorError::PathMismatch); + } + return Ok(table + .entries + .iter() + .position(|e| e.name == req.role_name) + .ok_or(SelectorError::NotFound)?); + } + + if req.has_nostr_index { + // nostr_index is deprecated — must use role + role_path + return Err(SelectorError::NostrIndexDeprecated); + } + + if req.has_role_path && !req.has_role { + return Err(SelectorError::RoleRequired); + } + + Err(SelectorError::NotFound) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::role_table::*; + + fn make_test_table() -> RoleTable { + let mut table = RoleTable::new(); + // main: fixed path + table.register_role_path( + "main", + "m/44'/1237'/0'/0/0", + RolePurpose::Nostr, + RoleCurve::Secp256k1, + -1, -1, -1, &[], + ).unwrap(); + // nostr_range: variable path with range 0-3 + table.register_role_path( + "nostr_range", + "m/44'/1237'/%d'/0/0", + RolePurpose::Nostr, + RoleCurve::Secp256k1, + 0, 3, -1, &[], + ).unwrap(); + table + } + + #[test] + fn test_role_fixed_path() { + let table = make_test_table(); + let mut req = SelectorRequest::new(); + req.has_role = true; + req.role_name = "main".to_string(); + req.has_role_path = true; + req.role_path = "m/44'/1237'/0'/0/0".to_string(); + assert!(selector_resolve(&req, &table).is_ok()); + } + + #[test] + fn test_role_variable_path_in_range() { + let table = make_test_table(); + let mut req = SelectorRequest::new(); + req.has_role = true; + req.role_name = "nostr_range".to_string(); + req.has_role_path = true; + req.role_path = "m/44'/1237'/2'/0/0".to_string(); + assert!(selector_resolve(&req, &table).is_ok()); + } + + #[test] + fn test_role_variable_path_out_of_range() { + let table = make_test_table(); + let mut req = SelectorRequest::new(); + req.has_role = true; + req.role_name = "nostr_range".to_string(); + req.has_role_path = true; + req.role_path = "m/44'/1237'/5'/0/0".to_string(); + assert_eq!(selector_resolve(&req, &table), Err(SelectorError::PathMismatch)); + } + + #[test] + fn test_role_without_path() { + let table = make_test_table(); + let mut req = SelectorRequest::new(); + req.has_role = true; + req.role_name = "nostr_range".to_string(); + // Variable path role without role_path → PathRequired + assert_eq!(selector_resolve(&req, &table), Err(SelectorError::PathRequired)); + } + + #[test] + fn test_path_without_role() { + let table = make_test_table(); + let mut req = SelectorRequest::new(); + req.has_role_path = true; + req.role_path = "m/44'/1237'/0'/0/0".to_string(); + assert_eq!(selector_resolve(&req, &table), Err(SelectorError::RoleRequired)); + } + + #[test] + fn test_unknown_role() { + let table = make_test_table(); + let mut req = SelectorRequest::new(); + req.has_role = true; + req.role_name = "nonexistent".to_string(); + req.has_role_path = true; + req.role_path = "m/44'/1237'/0'/0/0".to_string(); + assert_eq!(selector_resolve(&req, &table), Err(SelectorError::NotFound)); + } + + #[test] + fn test_no_selector_with_default() { + let table = make_test_table(); + let req = SelectorRequest::new(); + // No selector — should find "main" as default + assert!(selector_resolve(&req, &table).is_ok()); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..dded04d --- /dev/null +++ b/src/server.rs @@ -0,0 +1,582 @@ +//! Server — multi-transport server with poll loop. +//! +//! Port of `server.c`. Supports Unix abstract socket, TCP, HTTP, +//! 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. + +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; +use std::os::unix::net::UnixListener; + +pub const SERVER_SOCKET_NAME_MAX: usize = 108; + +/// Listen mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListenMode { + Unix, + Stdio, + Qrexec, + Tcp, + Http, +} + +/// Auth mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMode { + Off, + Optional, + Required, +} + +/// Caller identity. +#[derive(Debug, Clone)] +pub struct CallerIdentity { + pub uid: u32, + pub gid: u32, + pub pid: u32, + pub kind: ListenMode, + pub caller_id: String, // "uid:", "qubes:", "tcp:[ip]:port", "pubkey:" + pub source_qube: String, + pub auth_present: bool, + pub auth_pubkey_hex: String, + pub auth_label: String, +} + +impl CallerIdentity { + fn new(kind: ListenMode) -> Self { + CallerIdentity { + uid: 0, + gid: 0, + pid: 0, + kind, + caller_id: String::new(), + source_qube: String::new(), + auth_present: false, + auth_pubkey_hex: String::new(), + auth_label: String::new(), + } + } +} + +/// Server context. +pub struct ServerContext { + pub socket_name: String, + pub listen_mode: ListenMode, + pub auth_mode: AuthMode, + pub auth_skew_seconds: i32, + pub bridge_source_trusted: bool, + pub listener: Option, + pub tcp_listener: Option, + pub running: bool, + pub auth_cache: AuthNonceCache, +} + +impl ServerContext { + pub fn new(socket_name: &str, listen_mode: ListenMode, auth_mode: AuthMode) -> Self { + ServerContext { + socket_name: socket_name.to_string(), + listen_mode, + auth_mode, + auth_skew_seconds: 300, + bridge_source_trusted: false, + listener: None, + tcp_listener: None, + running: false, + auth_cache: AuthNonceCache::new(), + } + } + + /// Start listening. Returns error on bind failure. + pub fn start(&mut self) -> Result<(), NsignerError> { + match self.listen_mode { + ListenMode::Unix => { + // Abstract namespace: bind via libc (sun_path[0] = '\0') + let listener = bind_abstract_unix(&self.socket_name)?; + listener + .set_nonblocking(true) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + self.listener = Some(listener); + self.running = true; + Ok(()) + } + ListenMode::Tcp | ListenMode::Http => { + // Parse "tcp:host:port" or "http:host:port" + let addr = self + .socket_name + .strip_prefix("tcp:") + .or_else(|| self.socket_name.strip_prefix("http:")) + .unwrap_or(&self.socket_name); + let listener = TcpListener::bind(addr) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + listener + .set_nonblocking(true) + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + self.tcp_listener = Some(listener); + self.running = true; + Ok(()) + } + ListenMode::Stdio | ListenMode::Qrexec => { + // One request over stdin/stdout + self.running = true; + Ok(()) + } + } + } + + /// Stop the server. + pub fn stop(&mut self) { + self.listener = None; + self.tcp_listener = None; + self.running = false; + } + + /// Handle one pending connection (non-blocking). + /// Returns Ok(true) if handled, Ok(false) if nothing pending. + pub fn handle_one( + &mut self, + dispatcher: &mut DispatcherContext, + policy: &mut PolicyTable, + ) -> Result { + if let Some(ref listener) = self.listener { + match listener.accept() { + Ok((stream, _)) => { + + let mut reader = stream + .try_clone() + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + let mut writer = stream; + + // Read framed request + let request = match crate::transport::recv_framed(&mut reader) { + Ok(r) => r, + Err(_) => return Ok(true), + }; + + // Identify caller via SO_PEERCRED + let caller = identify_unix_caller(&reader); + + // Process with policy enforcement + let response = self.process_request(dispatcher, policy, &request, &caller); + + // Send framed response + if let Err(_) = crate::transport::send_framed(&mut writer, &response) { + // Client disconnected — ignore + } + return Ok(true); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + return Ok(false); // Nothing pending + } + Err(e) => return Err(NsignerError::IoFailed(e.to_string())), + } + } + + if let Some(ref listener) = self.tcp_listener { + match listener.accept() { + Ok((stream, _)) => { + + + // Identify caller via peer address before moving stream + let caller = identify_tcp_caller(&stream); + + let mut reader = stream + .try_clone() + .map_err(|e| NsignerError::IoFailed(e.to_string()))?; + let mut writer = stream; + + let request = if self.listen_mode == ListenMode::Http { + match crate::http::recv_request(&mut reader) { + Ok(r) => r, + Err(_) => return Ok(true), + } + } else { + match crate::transport::recv_framed(&mut reader) { + Ok(r) => r, + Err(_) => return Ok(true), + } + }; + + // Process with policy enforcement + let response = self.process_request(dispatcher, policy, &request, &caller); + + if self.listen_mode == ListenMode::Http { + let _ = crate::http::send_response(&mut writer, &response); + } else { + let _ = crate::transport::send_framed(&mut writer, &response); + } + return Ok(true); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + return Ok(false); + } + Err(e) => return Err(NsignerError::IoFailed(e.to_string())), + } + } + + Ok(false) + } + + /// Process a request through the full security pipeline: + /// auth envelope → selector resolution → policy check → approval → dispatch. + fn process_request( + &mut self, + dispatcher: &mut DispatcherContext, + policy: &mut PolicyTable, + request: &str, + caller: &CallerIdentity, + ) -> String { + // ── Auth envelope verification ───────────────────────────── + let mut caller = caller.clone(); + if self.auth_mode != AuthMode::Off { + match crate::auth_envelope::verify_request( + request, + &mut self.auth_cache, + self.auth_skew_seconds, + ) { + Ok((pubkey, label)) => { + caller.auth_present = true; + caller.auth_pubkey_hex = pubkey.clone(); + caller.auth_label = label; + caller.caller_id = format!("pubkey:{}", pubkey); + } + Err((code, msg)) => { + if self.auth_mode == AuthMode::Required { + return make_auth_error(&request, code, msg); + } + // Optional: continue without auth + } + } + } + + // ── Extract method and selector ──────────────────────────── + let (method, selector_req) = match extract_method_and_selector(request) { + Some(v) => v, + None => { + // Malformed request — let the dispatcher produce the error + return crate::dispatcher::handle_request(dispatcher, request); + } + }; + + // get_info is metadata — no key material, no policy check + if method == crate::enforcement::VERB_GET_INFO { + return crate::dispatcher::handle_request(dispatcher, request); + } + + // Algorithm-based verbs (bypass role table) — check algorithm policy + if crate::enforcement::is_algorithm_verb(&method) { + return self.process_algorithm_verb(dispatcher, policy, request, &caller, &method, &selector_req); + } + + // OTP verbs + if method == crate::enforcement::VERB_ENCRYPT || method == crate::enforcement::VERB_DECRYPT { + return crate::dispatcher::handle_request(dispatcher, request); + } + + // ── Resolve role selector ────────────────────────────────── + 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. + 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) + } +} + +/// Bind a Unix socket in the abstract namespace via libc. +/// +/// Rust's safe `UnixListener::bind` rejects paths containing null bytes, +/// so abstract sockets (sun_path[0] = '\0') must be bound via libc. +fn bind_abstract_unix(name: &str) -> Result { + use std::os::unix::io::FromRawFd; + + if name.len() >= 107 { + return Err(NsignerError::InvalidInput); + } + + let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if fd < 0 { + return Err(NsignerError::IoFailed("socket() failed".into())); + } + + // Build sockaddr_un with abstract namespace (sun_path[0] = '\0') + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + addr.sun_family = libc::AF_UNIX as libc::sa_family_t; + // sun_path[0] = '\0' (already zeroed), then copy name bytes + let name_bytes = name.as_bytes(); + for (i, &b) in name_bytes.iter().enumerate() { + addr.sun_path[i + 1] = b as libc::c_char; + } + + let addrlen = (std::mem::size_of::() + 1 + name_bytes.len()) as libc::socklen_t; + + let rc = unsafe { + libc::bind( + fd, + &addr as *const libc::sockaddr_un as *const libc::sockaddr, + addrlen, + ) + }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(NsignerError::IoFailed(format!("bind: {}", err))); + } + + let rc = unsafe { libc::listen(fd, 16) }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(NsignerError::IoFailed(format!("listen: {}", err))); + } + + // Wrap the raw fd in a UnixListener + let listener = unsafe { UnixListener::from_raw_fd(fd) }; + Ok(listener) +} + +/// Identify the caller on a Unix socket via SO_PEERCRED. +fn identify_unix_caller(stream: &std::os::unix::net::UnixStream) -> CallerIdentity { + use std::os::unix::io::AsRawFd; + let mut caller = CallerIdentity::new(ListenMode::Unix); + + let fd = stream.as_raw_fd(); + let mut cred: libc::ucred = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut _ as *mut libc::c_void, + &mut len, + ) + }; + + if rc == 0 { + caller.uid = cred.uid; + caller.gid = cred.gid; + caller.pid = cred.pid as u32; + caller.caller_id = format!("uid:{}", cred.uid); + } else { + // Fallback: current uid + caller.uid = unsafe { libc::getuid() }; + caller.caller_id = format!("uid:{}", caller.uid); + } + + caller +} + +/// Identify the caller on a TCP connection via peer address. +fn identify_tcp_caller(stream: &std::net::TcpStream) -> CallerIdentity { + let mut caller = CallerIdentity::new(ListenMode::Tcp); + + match stream.peer_addr() { + Ok(addr) => { + caller.caller_id = format!("tcp:{}", addr); + } + Err(_) => { + caller.caller_id = "tcp:unknown".to_string(); + } + } + + caller +} + +/// Extract method and selector from a JSON-RPC request. +fn extract_method_and_selector(request: &str) -> Option<(String, SelectorRequest)> { + let root: serde_json::Value = serde_json::from_str(request).ok()?; + let method = root.get("method")?.as_str()?.to_string(); + + let mut sel = SelectorRequest::new(); + 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(role) = options.get("role").and_then(|v| v.as_str()) { + sel.has_role = true; + sel.role_name = role.to_string(); + } + if let Some(path) = options.get("role_path").and_then(|v| v.as_str()) { + sel.has_role_path = true; + sel.role_path = path.to_string(); + } + if let Some(idx) = options.get("index").and_then(|v| v.as_i64()) { + sel.has_index = true; + sel.index = idx as i32; + } + if let Some(nidx) = options.get("nostr_index").and_then(|v| v.as_i64()) { + sel.has_nostr_index = true; + sel.nostr_index = nidx as i32; + } + } + } + + 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); + format!( + r#"{{"id":"{}","error":{{"code":{},"message":"{}"}}}}"#, + id, code, message + ) +} + +/// Build a selector error response. +fn make_selector_error(request: &str, err: crate::selector::SelectorError) -> String { + use crate::selector::SelectorError; + let id = extract_id(request); + let (code, message) = match err { + SelectorError::Ambiguous => (1001, "ambiguous_role_selector"), + SelectorError::NotFound => (1002, "unknown_role"), + SelectorError::NoDefault => (1003, "no_default_role"), + SelectorError::PathMismatch => (2003, "path_not_allowed"), + SelectorError::RoleRequired => (2006, "role_required"), + SelectorError::PathRequired => (2007, "path_required"), + _ => (1002, "unknown_role"), + }; + format!( + r#"{{"id":"{}","error":{{"code":{},"message":"{}"}}}}"#, + id, code, message + ) +} + +/// 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 { + let root: serde_json::Value = serde_json::from_str(request).unwrap_or(serde_json::Value::Null); + root.get("id") + .map(|v| { + if let Some(s) = v.as_str() { + s.to_string() + } else { + v.to_string() + } + }) + .unwrap_or_else(|| "null".to_string()) +} diff --git a/src/socket_name.rs b/src/socket_name.rs new file mode 100644 index 0000000..d13e486 --- /dev/null +++ b/src/socket_name.rs @@ -0,0 +1,71 @@ +//! Socket naming — random abstract socket name generation. +//! +//! Port of `socket_name.c`. Generates random names in the format +//! `nsigner__` using the BIP-39 English wordlist. + +use rand::seq::SliceRandom; +use rand::thread_rng; + +/// Generate a random socket name: `nsigner__`. +/// +/// Uses two random words from the BIP-39 English wordlist. +pub fn socket_name_random() -> Result { + let wordlist = nips::nip006::bip39_wordlist(); + let mut rng = thread_rng(); + + let word1 = wordlist + .choose(&mut rng) + .ok_or(crate::NsignerError::CryptoFailed)?; + let word2 = wordlist + .choose(&mut rng) + .ok_or(crate::NsignerError::CryptoFailed)?; + + Ok(format!("nsigner_{}_{}", word1, word2)) +} + +/// List running nsigner abstract sockets by reading /proc/net/unix. +pub fn list_sockets() -> Vec { + let mut found = Vec::new(); + + if let Ok(content) = std::fs::read_to_string("/proc/net/unix") { + for line in content.lines() { + // Look for @nsigner prefix in the path column + if let Some(pos) = line.find("@nsigner") { + let rest = &line[pos + 1..]; // skip @ + // Extract the name (up to whitespace or end of line) + let name: String = rest + .chars() + .take_while(|c| !c.is_whitespace()) + .collect(); + if name.starts_with("nsigner_") { + found.push(name); + } + } + } + } + + found +} + +/// Discover a single running nsigner socket. +/// Returns Ok(name) if exactly one is found, Err if zero or multiple. +pub fn discover_single_socket() -> Result { + let sockets = list_sockets(); + if sockets.len() == 1 { + Ok(sockets[0].clone()) + } else { + Err(crate::NsignerError::NotFound) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_socket_name_random() { + let name = socket_name_random().unwrap(); + assert!(name.starts_with("nsigner_")); + assert!(name.len() > 10); // nsigner_ + two words + } +} diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..c78c21b --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,101 @@ +//! Transport framing — length-prefixed JSON over sockets. +//! +//! Port of `transport_frame.c`. 4-byte big-endian length prefix + payload. + +use std::io::{self, Read, Write}; +use std::os::unix::net::UnixStream; + +pub const MAX_MSG_SIZE: usize = 16 * 1024 * 1024; // 16 MB + +/// Send a framed JSON message: 4-byte BE length + payload. +pub fn send_framed(writer: &mut W, payload: &str) -> io::Result<()> { + let len = payload.len() as u32; + writer.write_all(&len.to_be_bytes())?; + writer.write_all(payload.as_bytes())?; + writer.flush()?; + Ok(()) +} + +/// Receive a framed JSON message: read 4-byte BE length, then payload. +pub fn recv_framed(reader: &mut R) -> io::Result { + let mut header = [0u8; 4]; + reader.read_exact(&mut header)?; + let len = u32::from_be_bytes(header) as usize; + if len == 0 || len > MAX_MSG_SIZE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid frame length")); + } + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; + String::from_utf8(buf).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid UTF-8")) +} + +/// Connect to a Unix socket in the abstract namespace via libc. +/// +/// Rust's safe `UnixStream::connect` rejects paths containing null bytes, +/// so abstract sockets (sun_path[0] = '\0') must be connected via libc. +pub fn connect_abstract_unix(name: &str) -> io::Result { + use std::os::unix::io::FromRawFd; + + if name.len() >= 107 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "socket name too long")); + } + + let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + addr.sun_family = libc::AF_UNIX as libc::sa_family_t; + let name_bytes = name.as_bytes(); + for (i, &b) in name_bytes.iter().enumerate() { + addr.sun_path[i + 1] = b as libc::c_char; + } + + let addrlen = (std::mem::size_of::() + 1 + name_bytes.len()) as libc::socklen_t; + + let rc = unsafe { + libc::connect( + fd, + &addr as *const libc::sockaddr_un as *const libc::sockaddr, + addrlen, + ) + }; + if rc != 0 { + let err = io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(err); + } + + let stream = unsafe { UnixStream::from_raw_fd(fd) }; + Ok(stream) +} + +/// Send a framed message over a Unix socket. +pub fn send_framed_unix(stream: &UnixStream, payload: &str) -> io::Result<()> { + let mut writer = stream; + send_framed(&mut writer, payload) +} + +/// Receive a framed message from a Unix socket. +pub fn recv_framed_unix(stream: &UnixStream) -> io::Result { + let mut reader = stream; + recv_framed(&mut reader) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_framing_roundtrip() { + let mut buf = Vec::new(); + let msg = r#"{"id":"1","method":"get_info","params":[]}"#; + send_framed(&mut buf, msg).unwrap(); + + let mut cursor = Cursor::new(buf); + let received = recv_framed(&mut cursor).unwrap(); + assert_eq!(received, msg); + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..10fef62 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,788 @@ +//! Terminal UI — interactive status display, role wizard, approval prompts. +//! +//! 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). + +use crate::policy::PolicyResult; +use crate::role_table::RoleTable; +use crate::tui_continuous::{ + self, TuiColumn, TuiFrame, TuiMenu, TuiMenuItem, TuiTable, +}; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; + +// ──────────────────────────────────────────────────────────────────────────── +// Activity log +// ──────────────────────────────────────────────────────────────────────────── + +/// Maximum number of activity log entries kept (matches C ACTIVITY_LOG_CAP). +pub const ACTIVITY_LOG_CAP: usize = 16; + +/// Activity log — a ring buffer of timestamped messages. +pub struct ActivityLog { + lines: [String; ACTIVITY_LOG_CAP], + count: usize, +} + +impl ActivityLog { + /// Create an empty activity log. + pub fn new() -> Self { + Self { + lines: std::array::from_fn(|_| String::new()), + count: 0, + } + } + + /// Add a message to the log with a timestamp. + pub fn add(&mut self, message: &str) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let ts = format_timestamp(now); + let idx = self.count % ACTIVITY_LOG_CAP; + self.lines[idx] = format!("{} {}", ts, message); + self.count += 1; + } + + /// Return entries newest-first (up to ACTIVITY_LOG_CAP). + pub fn entries(&self) -> Vec<&str> { + if self.count == 0 { + return Vec::new(); + } + let total = self.count.min(ACTIVITY_LOG_CAP); + let mut result = Vec::with_capacity(total); + for i in (0..total).rev() { + let idx = (self.count - 1 - i) % ACTIVITY_LOG_CAP; + result.push(self.lines[idx].as_str()); + } + result + } + + /// Clear the log. + pub fn clear(&mut self) { + for line in &mut self.lines { + line.clear(); + } + self.count = 0; + } +} + +impl Default for ActivityLog { + fn default() -> Self { + Self::new() + } +} + +/// Format a Unix timestamp as `YYYY-MM-DD HH:MM:SS` (local time). +fn format_timestamp(epoch: u64) -> String { + let t = epoch as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + unsafe { + libc::localtime_r(&t, &mut tm); + } + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + tm.tm_year + 1900, + tm.tm_mon + 1, + tm.tm_mday, + tm.tm_hour, + tm.tm_min, + tm.tm_sec + ) +} + +// ──────────────────────────────────────────────────────────────────────────── +// Global flags +// ──────────────────────────────────────────────────────────────────────────── + +/// 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TuiKey { + /// 'l' — lock/reunlock + Lock, + /// 'r' — refresh + Refresh, + /// 'd' — show connection details + Connections, + /// 'q' or 'x' — quit + Quit, + /// Any other key (ignored) + Other, + /// No key pressed within the poll timeout + None, +} + +/// Poll for a single keypress with a timeout (non-blocking). +/// +/// 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}; + + if !poll(std::time::Duration::from_millis(timeout_ms)).unwrap_or(false) { + return TuiKey::None; + } + + // Check for resize + if tui_continuous::resize_pending() { + return TuiKey::Other; // Will be handled by resize check in main loop + } + + 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, + } +} + +/// 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 { + 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); + } + line.push(c); + let _ = write!(stdout, "{}", c); + let _ = stdout.flush(); + } + Ok(Event::Key(KeyEvent { + code: KeyCode::Backspace, + .. + })) => { + if line.pop().is_some() { + let _ = write!(stdout, "\x08 \x08"); + let _ = stdout.flush(); + } + } + _ => {} + } + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// 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, + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Screen rendering (matches C render_status / render_connections exactly) +// ──────────────────────────────────────────────────────────────────────────── + +/// 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(), + } + }; + + 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 + )); + } + 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; + } + + let choice = input.trim(); + + // Done / empty → finish + if choice == "0" || choice.is_empty() { + break; + } + + // 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 + _ => { + crate::tui_continuous::print("Invalid selection, try again."); + continue; + } + }; + + 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; + } + let name = name.trim().to_string(); + let name = if name.is_empty() { default_name.to_string() } else { name }; + + // Check for duplicate + if role_table.find_by_name(&name).is_some() { + crate::tui_continuous::print(&format!(" Role '{}' already exists, skipping.", name)); + continue; + } + + // 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( + &name, + &template, + purpose, + curve, + range_lo, + 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)); + } + } + } + } + + // 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 + )); + } + + 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); + } + + 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); + } + + 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, + }; + + role_table.register_role_path( + &name, + &path, + purpose, + curve, + -1, -1, -1, &[], + )?; + + 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 + } + + match input.trim() { + "2" => 0x04, // TCP + "3" => 0x08, // HTTP + "4" => 0x09, // Unix + HTTP + _ => 0x01, // Unix (default) + } +} + +/// Prompt for mnemonic phrase input (interactive mode). +pub fn prompt_mnemonic() -> Result { + 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 new file mode 100644 index 0000000..de5f3a8 --- /dev/null +++ b/src/tui_continuous.rs @@ -0,0 +1,915 @@ +//! # 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: &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, "{: 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, "{: Vec { + 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 { + 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 new file mode 100644 index 0000000..2c50ab2 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,341 @@ +//! Integration tests — end-to-end server + client over Unix socket. +//! +//! These tests verify the full security pipeline: +//! caller identification → policy check → approval → dispatch. + +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}, +}; +use std::os::unix::net::UnixListener; +use std::time::Duration; + +const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +/// Set up a server context with a test mnemonic and role table. +fn setup_server(socket_name: &str) -> ( + ServerContext, + RoleTable, + MnemonicState, + KeyStore, + AlgorithmKeyCache, +) { + let mut mnemonic = MnemonicState::new(); + mnemonic.load(TEST_MNEMONIC).unwrap(); + + let mut role_table = RoleTable::new(); + role_table + .register_role_path( + "main", + "m/44'/1237'/0'/0/0", + RolePurpose::Nostr, + RoleCurve::Secp256k1, + -1, -1, -1, &[], + ) + .unwrap(); + + let mut key_store = KeyStore::new(); + key_store.derive_all(&mut role_table, &mnemonic).unwrap(); + + let mut server = ServerContext::new(socket_name, ListenMode::Unix, AuthMode::Off); + server.start().unwrap(); + + (server, role_table, mnemonic, key_store, AlgorithmKeyCache::new()) +} + +/// Run the server poll loop in a background thread. +/// +/// Returns a handle and a stop flag. Set the stop flag to `true` to +/// terminate the loop (the thread will exit on the next iteration). +fn spawn_server_loop( + mut server: ServerContext, + mut role_table: RoleTable, + mnemonic: MnemonicState, + mut key_store: KeyStore, + mut alg_cache: AlgorithmKeyCache, + mut policy: PolicyTable, +) -> (std::thread::JoinHandle<()>, std::sync::Arc) { + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop_clone = stop.clone(); + + let handle = std::thread::spawn(move || { + while server.running && !stop_clone.load(std::sync::atomic::Ordering::SeqCst) { + let mut dispatcher = DispatcherContext { + role_table: &mut role_table, + mnemonic: &mnemonic, + key_store: &mut key_store, + alg_key_cache: &mut alg_cache, + }; + match server.handle_one(&mut dispatcher, &mut policy) { + Ok(true) => {} + Ok(false) => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(_) => break, + } + } + }); + + (handle, stop) +} + +/// Send a framed request to a Unix socket and return the response. +fn send_request(socket_name: &str, request: &str) -> String { + let mut stream = nsigner::transport::connect_abstract_unix(socket_name).unwrap(); + nsigner::transport::send_framed(&mut stream, request).unwrap(); + nsigner::transport::recv_framed(&mut stream).unwrap() +} + +/// Wait for the server socket to be ready. +fn wait_for_server(socket_name: &str, attempts: u32) { + for _ in 0..attempts { + if nsigner::transport::connect_abstract_unix(socket_name).is_ok() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("server socket {} not ready", socket_name); +} + +#[test] +fn test_get_info_no_policy_needed() { + 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); + wait_for_server(&socket_name, 100); + + // get_info is metadata — should work without policy + 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()); + 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); + 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. + let resp = send_request( + &socket_name, + r#"{"id":"2","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#, + ); + assert!(resp.contains("\"result\""), "role-as-password should allow, got: {}", resp); + + let _ = nsigner::transport::connect_abstract_unix(&socket_name); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + handle.join().ok(); +} + +#[test] +fn test_nostr_get_public_key_allowed_with_preapprove() { + let socket_name = format!("nsigner_test_allow_{}", std::process::id()); + let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name); + + let mut policy = PolicyTable::new(); + policy.init_default(unsafe { libc::getuid() }); + + // Preapprove the caller for nostr_get_public_key on main + let entry = parse_preapprove_spec( + &format!( + "caller=uid:{},role=main,verb=nostr_get_public_key", + unsafe { libc::getuid() } + ), + ) + .unwrap(); + policy.insert_before_last(entry).unwrap(); + + let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache, policy); + wait_for_server(&socket_name, 100); + + let resp = send_request( + &socket_name, + r#"{"id":"3","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#, + ); + assert!(resp.contains("\"result\""), "expected success, got: {}", resp); + // Result is a plain hex pubkey string (64 hex chars) + assert!(resp.contains("e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f"), + "expected pubkey in result, got: {}", resp); + + let _ = nsigner::transport::connect_abstract_unix(&socket_name); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + handle.join().ok(); +} + +#[test] +fn test_unknown_role_returns_selector_error() { + let socket_name = format!("nsigner_test_unknown_{}", std::process::id()); + let (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); + wait_for_server(&socket_name, 100); + + // Unknown role should return unknown_role error before policy check + 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"}]}"#, + ); + assert!(resp.contains("unknown_role"), "expected unknown_role, 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_denied_without_approval() { + let socket_name = format!("nsigner_test_alg_deny_{}", 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); + wait_for_server(&socket_name, 100); + + 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}}]}}"#, + msg_hex + ); + let resp = send_request(&socket_name, &req); + assert!(resp.contains("\"result\""), "expected success, got: {}", resp); + assert!(resp.contains("signature"), "expected signature in result: {}", resp); + + let _ = nsigner::transport::connect_abstract_unix(&socket_name); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + handle.join().ok(); +} + +#[test] +fn test_session_grant_flow() { + let socket_name = format!("nsigner_test_session_{}", std::process::id()); + 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); + 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); + + let _ = nsigner::transport::connect_abstract_unix(&socket_name); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + handle.join().ok(); +} + +// Keep UnixListener import used (for potential future filesystem socket tests) +#[allow(dead_code)] +fn _unused(_l: UnixListener) {} \ No newline at end of file