diff --git a/Cargo.lock b/Cargo.lock index 4eb14ac..2c85766 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "amplify" version = "4.9.0" @@ -84,6 +90,15 @@ dependencies = [ "libc", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -416,6 +431,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.4.5" @@ -496,6 +520,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.41" @@ -609,6 +647,33 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "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-bigint" version = "0.5.5" @@ -877,7 +942,7 @@ checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck", "indexmap 2.14.2", - "itertools", + "itertools 0.15.0", "proc-macro-crate", "proc-macro2", "quote", @@ -1005,6 +1070,15 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "2.0.2" @@ -1269,6 +1343,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1519,7 +1599,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1527,12 +1607,22 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1794,6 +1884,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.11.5" @@ -1832,6 +1931,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "inventory" version = "0.3.24" @@ -1856,6 +1968,15 @@ dependencies = [ "ipnet", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.15.0" @@ -1955,6 +2076,17 @@ dependencies = [ "sha3 0.10.9", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + [[package]] name = "keccak" version = "0.1.6" @@ -2055,6 +2187,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2067,6 +2208,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2082,6 +2229,15 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2306,6 +2462,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2347,7 +2512,9 @@ dependencies = [ "arti-client", "ed25519-dalek", "futures", + "qrcode", "rand 0.8.8", + "ratatui", "rusqlite", "safelog", "tempfile", @@ -2463,6 +2630,39 @@ dependencies = [ "sha2", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking" version = "2.2.1" @@ -2724,6 +2924,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quote" version = "1.0.47" @@ -2825,6 +3031,72 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rdrand" version = "0.9.0" @@ -3006,6 +3278,12 @@ 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 = "safelog" version = "0.9.1" @@ -3318,6 +3596,27 @@ 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", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3649,7 +3948,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -3869,7 +4170,7 @@ dependencies = [ "extend", "getrandom 0.4.3", "hex", - "itertools", + "itertools 0.15.0", "libc", "paste", "rand 0.10.2", @@ -3914,7 +4215,7 @@ dependencies = [ "derive-deftly", "derive_more", "educe", - "itertools", + "itertools 0.15.0", "paste", "rand 0.10.2", "smallvec", @@ -4001,7 +4302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" dependencies = [ "humantime", - "itertools", + "itertools 0.15.0", "signature", "thiserror 2.0.20", "tor-llcrypto", @@ -4025,7 +4326,7 @@ dependencies = [ "educe", "futures", "humantime-serde", - "itertools", + "itertools 0.15.0", "once_cell", "oneshot-fused-workaround", "pin-project", @@ -4075,7 +4376,7 @@ dependencies = [ "fs-mistrust", "futures", "humantime-serde", - "itertools", + "itertools 0.15.0", "notify", "paste", "postage", @@ -4138,7 +4439,7 @@ dependencies = [ "http", "httparse", "httpdate", - "itertools", + "itertools 0.15.0", "memchr", "thiserror 2.0.20", "tor-circmgr", @@ -4193,7 +4494,7 @@ dependencies = [ "hex", "humantime", "humantime-serde", - "itertools", + "itertools 0.15.0", "memmap2", "oneshot-fused-workaround", "paste", @@ -4276,7 +4577,7 @@ dependencies = [ "futures", "humantime", "humantime-serde", - "itertools", + "itertools 0.15.0", "num_enum", "oneshot-fused-workaround", "pin-project", @@ -4317,7 +4618,7 @@ dependencies = [ "educe", "either", "futures", - "itertools", + "itertools 0.15.0", "oneshot-fused-workaround", "postage", "rand 0.10.2", @@ -4363,7 +4664,7 @@ dependencies = [ "digest 0.10.7", "hex", "humantime", - "itertools", + "itertools 0.15.0", "paste", "rand 0.10.2", "safelog", @@ -4403,7 +4704,7 @@ dependencies = [ "growable-bloom-filter", "hex", "humantime", - "itertools", + "itertools 0.15.0", "k12", "once_cell", "oneshot-fused-workaround", @@ -4482,7 +4783,7 @@ dependencies = [ "glob-match", "humantime", "inventory", - "itertools", + "itertools 0.15.0", "rand 0.10.2", "safelog", "serde", @@ -4518,7 +4819,7 @@ dependencies = [ "derive_builder_fork_arti", "derive_more", "hex", - "itertools", + "itertools 0.15.0", "safelog", "serde", "serde_with", @@ -4604,7 +4905,7 @@ dependencies = [ "educe", "extend", "futures", - "itertools", + "itertools 0.15.0", "paste", "pin-project", "serde", @@ -4630,7 +4931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" dependencies = [ "derive-deftly", - "itertools", + "itertools 0.15.0", "paste", "void", ] @@ -4648,7 +4949,7 @@ dependencies = [ "futures", "hex", "humantime", - "itertools", + "itertools 0.15.0", "num_enum", "rand 0.10.2", "serde", @@ -4689,7 +4990,7 @@ dependencies = [ "humantime", "ipnet", "iprange", - "itertools", + "itertools 0.15.0", "memchr", "paste", "phf", @@ -4735,7 +5036,7 @@ dependencies = [ "fs-mistrust", "fslock-guard", "futures", - "itertools", + "itertools 0.15.0", "oneshot-fused-workaround", "paste", "sanitize-filename", @@ -4778,7 +5079,7 @@ dependencies = [ "futures-util", "hkdf", "hmac", - "itertools", + "itertools 0.15.0", "nonany", "oneshot-fused-workaround", "pin-project", @@ -4898,7 +5199,7 @@ dependencies = [ "educe", "futures", "humantime", - "itertools", + "itertools 0.15.0", "oneshot-fused-workaround", "pin-project", "priority-queue", @@ -5063,6 +5364,23 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index ee21c0a..41db803 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "ti tor-cell = "0.46" tor-hsservice = "0.46" tor-rtcompat = { version = "0.46", features = ["tokio"] } +qrcode = { version = "0.14.1", default-features = false } +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } [dev-dependencies] tempfile = "3" diff --git a/src/lib.rs b/src/lib.rs index 8290257..70d5bed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +pub mod qr; mod store; +pub mod tui; pub use store::{Friend, SelfIdentity, Store}; diff --git a/src/main.rs b/src/main.rs index ebcc7ee..301b066 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,11 @@ fn main() { match onionwire::Store::open() { - Ok(store) => match store.self_identity() { - Ok(me) => { - let fp: String = me.identity_pk.iter().map(|b| format!("{b:02x}")).collect(); - println!("onionwire ready"); - println!("fingerprint {fp}"); - } - Err(e) => { + Ok(store) => { + if let Err(e) = onionwire::tui::run(store) { eprintln!("onionwire: {e}"); std::process::exit(1); } - }, + } Err(e) => { eprintln!("onionwire: {e}"); std::process::exit(1); diff --git a/src/qr.rs b/src/qr.rs new file mode 100644 index 0000000..b6f5d93 --- /dev/null +++ b/src/qr.rs @@ -0,0 +1,138 @@ +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use qrcode::render::unicode::Dense1x2; +use qrcode::QrCode; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for Error {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QrPayload { + pub pubkey: Vec, + pub onion: String, + pub signed_prekey: Vec, +} + +/// `onionwire:v1:k={pubkey_hex}:o={v3onion}:spk={signed_prekey}:sig={sign(k||o||spk)}` +pub fn encode(identity_sk: &[u8], onion: &str, signed_prekey: &[u8]) -> Result { + let sk_bytes: [u8; 32] = identity_sk + .try_into() + .map_err(|_| Error("identity secret key must be 32 bytes".into()))?; + let sk = SigningKey::from_bytes(&sk_bytes); + let k = to_hex(&sk.verifying_key().to_bytes()); + let spk = to_hex(signed_prekey); + let msg = sign_msg(&k, onion, &spk); + let sig = to_hex(&sk.sign(&msg).to_bytes()); + Ok(format!("onionwire:v1:k={k}:o={onion}:spk={spk}:sig={sig}")) +} + +pub fn decode(raw: &str) -> Result { + let (k, onion, spk, sig) = parse_fields(raw)?; + let pubkey = from_hex(&k)?; + let signed_prekey = from_hex(&spk)?; + let sig_bytes = from_hex(&sig)?; + if pubkey.len() != 32 { + return Err(Error("k must be 32 bytes".into())); + } + if sig_bytes.len() != 64 { + return Err(Error("sig must be 64 bytes".into())); + } + let pk_arr: [u8; 32] = pubkey + .as_slice() + .try_into() + .map_err(|_| Error("k must be 32 bytes".into()))?; + let sig_arr: [u8; 64] = sig_bytes + .as_slice() + .try_into() + .map_err(|_| Error("sig must be 64 bytes".into()))?; + let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| Error(e.to_string()))?; + let signature = Signature::from_bytes(&sig_arr); + let msg = sign_msg(&k, &onion, &spk); + vk.verify(&msg, &signature) + .map_err(|_| Error("bad signature".into()))?; + Ok(QrPayload { + pubkey, + onion, + signed_prekey, + }) +} + +pub fn render_unicode(payload: &str) -> Result { + let code = QrCode::new(payload.as_bytes()).map_err(|e| Error(e.to_string()))?; + Ok(code.render::().build()) +} + +fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec { + let mut msg = Vec::with_capacity(k.len() + onion.len() + spk.len()); + msg.extend_from_slice(k.as_bytes()); + msg.extend_from_slice(onion.as_bytes()); + msg.extend_from_slice(spk.as_bytes()); + msg +} + +fn parse_fields(raw: &str) -> Result<(String, String, String, String)> { + let rest = raw + .strip_prefix("onionwire:v1:") + .ok_or_else(|| Error("not onionwire:v1".into()))?; + let mut k = None; + let mut o = None; + let mut spk = None; + let mut sig = None; + for part in rest.split(':') { + if let Some(v) = part.strip_prefix("k=") { + if k.is_some() { + return Err(Error("duplicate k".into())); + } + k = Some(v.to_string()); + } else if let Some(v) = part.strip_prefix("o=") { + if o.is_some() { + return Err(Error("duplicate o".into())); + } + o = Some(v.to_string()); + } else if let Some(v) = part.strip_prefix("spk=") { + if spk.is_some() { + return Err(Error("duplicate spk".into())); + } + spk = Some(v.to_string()); + } else if let Some(v) = part.strip_prefix("sig=") { + if sig.is_some() { + return Err(Error("duplicate sig".into())); + } + sig = Some(v.to_string()); + } else { + return Err(Error("unknown field".into())); + } + } + Ok(( + k.ok_or_else(|| Error("missing k".into()))?, + o.ok_or_else(|| Error("missing o".into()))?, + spk.ok_or_else(|| Error("missing spk".into()))?, + sig.ok_or_else(|| Error("missing sig".into()))?, + )) +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn from_hex(s: &str) -> Result> { + if s.is_empty() || !s.len().is_multiple_of(2) { + return Err(Error("invalid hex".into())); + } + if !s.bytes().all(|c| c.is_ascii_hexdigit()) { + return Err(Error("invalid hex".into())); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| Error("invalid hex".into()))) + .collect() +} diff --git a/src/store.rs b/src/store.rs index 956ea16..ddb5e9f 100644 --- a/src/store.rs +++ b/src/store.rs @@ -47,6 +47,7 @@ pub struct Friend { pub fingerprint: String, pub petname: Option, pub onion: String, + pub last_connect_ok: bool, } impl Store { @@ -69,7 +70,8 @@ impl Store { petname TEXT, onion TEXT NOT NULL, onion_updated_at INTEGER NOT NULL, - added_at INTEGER NOT NULL + added_at INTEGER NOT NULL, + last_connect_ok INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY, @@ -88,10 +90,26 @@ impl Store { ", )?; let store = Self { conn }; + store.migrate()?; store.ensure_self()?; Ok(store) } + fn migrate(&self) -> Result<()> { + let has: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('friends') WHERE name = 'last_connect_ok'", + [], + |row| row.get(0), + )?; + if has == 0 { + self.conn.execute( + "ALTER TABLE friends ADD COLUMN last_connect_ok INTEGER NOT NULL DEFAULT 1", + [], + )?; + } + Ok(()) + } + fn ensure_self(&self) -> Result<()> { let exists: i64 = self.conn @@ -151,7 +169,7 @@ impl Store { pub fn get_friend(&self, pubkey: &[u8]) -> Result> { self.conn .query_row( - "SELECT pubkey, fingerprint, petname, onion FROM friends WHERE pubkey = ?1", + "SELECT pubkey, fingerprint, petname, onion, last_connect_ok FROM friends WHERE pubkey = ?1", params![pubkey], |row| { Ok(Friend { @@ -159,12 +177,34 @@ impl Store { fingerprint: row.get(1)?, petname: row.get(2)?, onion: row.get(3)?, + last_connect_ok: row.get::<_, i64>(4)? != 0, }) }, ) .optional() .map_err(Into::into) } + + pub fn list_friends(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT pubkey, fingerprint, petname, onion, last_connect_ok FROM friends + ORDER BY COALESCE(petname, fingerprint) COLLATE NOCASE", + )?; + let rows = stmt.query_map([], |row| { + Ok(Friend { + pubkey: row.get(0)?, + fingerprint: row.get(1)?, + petname: row.get(2)?, + onion: row.get(3)?, + last_connect_ok: row.get::<_, i64>(4)? != 0, + }) + })?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + } } fn home_dir() -> Result { diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..2c1bf10 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,300 @@ +use std::io; + +use rand::RngCore; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Constraint, Layout}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; +use ratatui::{DefaultTerminal, Frame}; + +use crate::qr::{self, QrPayload}; +use crate::store::{Friend, Store}; + +pub fn run(store: Store) -> Result<(), String> { + let mut app = App::new(store)?; + let mut terminal = ratatui::init(); + let result = app.run(&mut terminal); + ratatui::restore(); + result +} + +struct App { + store: Store, + screen: Screen, + friends: Vec, + selected: usize, + me_fp: String, + me_onion: String, + identity_sk: Vec, +} + +enum Screen { + Main, + Share { art: String, payload: String }, + Paste { buf: String, err: Option }, + Approve { payload: QrPayload }, + Rotate, +} + +impl App { + fn new(store: Store) -> Result { + let me = store.self_identity().map_err(|e| e.to_string())?; + let friends = store.list_friends().map_err(|e| e.to_string())?; + Ok(Self { + store, + screen: Screen::Main, + friends, + selected: 0, + me_fp: to_hex(&me.identity_pk), + me_onion: me.onion, + identity_sk: me.identity_sk, + }) + } + + fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<(), String> { + loop { + terminal.draw(|f| self.draw(f)).map_err(|e| e.to_string())?; + let Event::Key(key) = event::read().map_err(io_err)? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(()); + } + self.handle_key(key)?; + } + } + + fn handle_key(&mut self, key: KeyEvent) -> Result<(), String> { + match &mut self.screen { + Screen::Main => match key.code { + KeyCode::Up => { + self.selected = self.selected.saturating_sub(1); + } + KeyCode::Down => { + if !self.friends.is_empty() { + self.selected = (self.selected + 1).min(self.friends.len() - 1); + } + } + KeyCode::F(2) => self.open_share()?, + KeyCode::F(3) => { + self.screen = Screen::Paste { + buf: String::new(), + err: None, + }; + } + KeyCode::F(4) => self.screen = Screen::Rotate, + _ => {} + }, + Screen::Share { .. } => { + if key.code == KeyCode::Esc { + self.screen = Screen::Main; + } + } + Screen::Paste { buf, err } => match key.code { + KeyCode::Esc => self.screen = Screen::Main, + KeyCode::Enter => { + let raw = buf.clone(); + self.submit_paste(&raw)?; + } + KeyCode::Backspace => { + buf.pop(); + *err = None; + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + buf.push(c); + *err = None; + } + _ => {} + }, + Screen::Approve { payload } => match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + let p = payload.clone(); + self.accept_friend(&p)?; + } + KeyCode::Char('n') | KeyCode::Esc => self.screen = Screen::Main, + _ => {} + }, + Screen::Rotate => { + if matches!(key.code, KeyCode::Esc | KeyCode::Enter) { + self.screen = Screen::Main; + } + } + } + Ok(()) + } + + fn open_share(&mut self) -> Result<(), String> { + let mut spk = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut spk); + let onion = if self.me_onion.is_empty() { + "unset" + } else { + self.me_onion.as_str() + }; + let payload = qr::encode(&self.identity_sk, onion, &spk).map_err(|e| e.to_string())?; + let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?; + self.screen = Screen::Share { art, payload }; + Ok(()) + } + + fn submit_paste(&mut self, raw: &str) -> Result<(), String> { + match qr::decode(raw.trim()) { + Err(e) => { + self.screen = Screen::Paste { + buf: raw.to_string(), + err: Some(e.to_string()), + }; + } + Ok(payload) => { + let known = self + .store + .get_friend(&payload.pubkey) + .map_err(|e| e.to_string())? + .is_some(); + if known { + self.accept_friend(&payload)?; + } else { + self.screen = Screen::Approve { payload }; + } + } + } + Ok(()) + } + + fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> { + self.store + .upsert_friend(&payload.pubkey, &payload.onion, None) + .map_err(|e| e.to_string())?; + self.reload_friends()?; + self.screen = Screen::Main; + Ok(()) + } + + fn reload_friends(&mut self) -> Result<(), String> { + self.friends = self.store.list_friends().map_err(|e| e.to_string())?; + if self.friends.is_empty() { + self.selected = 0; + } else { + self.selected = self.selected.min(self.friends.len() - 1); + } + Ok(()) + } + + fn draw(&self, f: &mut Frame) { + match &self.screen { + Screen::Main => self.draw_main(f), + Screen::Share { art, payload } => draw_share(f, art, payload), + Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()), + Screen::Approve { payload } => draw_approve(f, payload), + Screen::Rotate => draw_rotate(f), + } + } + + fn draw_main(&self, f: &mut Frame) { + let area = f.area(); + let cols = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area); + let panes = + Layout::horizontal([Constraint::Percentage(36), Constraint::Min(10)]).split(cols[0]); + + let items: Vec = self + .friends + .iter() + .enumerate() + .map(|(i, friend)| { + let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint); + let fp = truncate_fp(&friend.fingerprint); + let stale = if friend.last_connect_ok { "" } else { " stale" }; + let mark = if i == self.selected { ">" } else { " " }; + ListItem::new(format!("{mark} {name} {fp}{stale}")) + }) + .collect(); + let roster = List::new(items).block(Block::default().borders(Borders::ALL).title("roster")); + f.render_widget(roster, panes[0]); + + let chat_text = if let Some(friend) = self.friends.get(self.selected) { + let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint); + format!("{name}\n\nchat arrives in M3") + } else { + "no friends yet\nF3 paste a QR to add one".into() + }; + let chat = Paragraph::new(chat_text) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title("chat")); + f.render_widget(chat, panes[1]); + + let onion = if self.me_onion.is_empty() { + "unset" + } else { + self.me_onion.as_str() + }; + let status = format!( + "TOR: offline | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit", + truncate_fp(&self.me_fp), + onion + ); + f.render_widget(Paragraph::new(status), cols[1]); + } +} + +fn draw_share(f: &mut Frame, art: &str, payload: &str) { + let body = format!("{art}\n{payload}\n\nEsc back"); + f.render_widget( + Paragraph::new(body) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title("F2 share")), + f.area(), + ); +} + +fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) { + let hint = err.unwrap_or("paste onionwire:v1 payload, Enter to submit"); + let body = format!("{hint}\n\n{buf}"); + f.render_widget( + Paragraph::new(body) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title("F3 paste QR")), + f.area(), + ); +} + +fn draw_approve(f: &mut Frame, payload: &QrPayload) { + let fp = to_hex(&payload.pubkey); + let body = format!( + "Unknown key. Add this friend?\n\nfp {}\nonion {}\n\ny accept n / Esc reject", + truncate_fp(&fp), + payload.onion + ); + f.render_widget( + Paragraph::new(body).block(Block::default().borders(Borders::ALL).title("approve")), + f.area(), + ); +} + +fn draw_rotate(f: &mut Frame) { + let body = "Rotate onion address?\n\ +Your identity key stays the same.\n\ +Online friends get a signed location update.\n\ +Offline friends CANNOT find you until they rescan your QR.\n\n\ +M4 will rotate the HS. Esc / Enter to go back (no change)."; + f.render_widget( + Paragraph::new(body) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title("F4 rotate")), + f.area(), + ); +} + +fn truncate_fp(fp: &str) -> String { + let s: String = fp.chars().take(8).collect(); + format!("{s}…") +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn io_err(e: io::Error) -> String { + e.to_string() +} diff --git a/tests/qr.rs b/tests/qr.rs new file mode 100644 index 0000000..351390c --- /dev/null +++ b/tests/qr.rs @@ -0,0 +1,132 @@ +//! M2 QR: roundtrip, garbage → Err, same-k rescan does not duplicate. + +use std::sync::{Mutex, MutexGuard}; + +use onionwire::qr; +use onionwire::Store; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct TempHome { + _dir: tempfile::TempDir, + _guard: MutexGuard<'static, ()>, +} + +impl TempHome { + fn new() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + let guard = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::set_var("ONIONWIRE_HOME", dir.path()); + } + Self { + _dir: dir, + _guard: guard, + } + } +} + +impl Drop for TempHome { + fn drop(&mut self) { + unsafe { + std::env::remove_var("ONIONWIRE_HOME"); + } + } +} + +fn v3onion(tag: &str) -> String { + format!("{tag}.onion") +} + +#[test] +fn encode_decode_roundtrip() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + let me = store.self_identity().expect("self"); + let spk = [7u8; 32]; + let onion = v3onion("alice-locator"); + let raw = qr::encode(&me.identity_sk, &onion, &spk).expect("encode"); + assert!(raw.starts_with("onionwire:v1:k="), "payload prefix: {raw}"); + assert!(raw.contains(":o=")); + assert!(raw.contains(":spk=")); + assert!(raw.contains(":sig=")); + + let p = qr::decode(&raw).expect("decode"); + assert_eq!(p.pubkey, me.identity_pk); + assert_eq!(p.onion, onion); + assert_eq!(p.signed_prekey, spk); +} + +#[test] +fn garbage_is_err() { + assert!(qr::decode("not a qr").is_err()); + assert!(qr::decode("").is_err()); + assert!(qr::decode("onionwire:v1:k=aa").is_err()); + assert!(qr::decode("🔥").is_err()); +} + +#[test] +fn missing_sig_is_err() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + let me = store.self_identity().expect("self"); + let raw = qr::encode(&me.identity_sk, &v3onion("x"), &[1u8; 32]).expect("encode"); + let stripped = raw.rsplit_once(":sig=").expect("sig field").0; + assert!(qr::decode(stripped).is_err()); + assert!(qr::decode(&format!("{stripped}:sig=")).is_err()); +} + +#[test] +fn bad_sig_is_err() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + let me = store.self_identity().expect("self"); + let raw = qr::encode(&me.identity_sk, &v3onion("x"), &[1u8; 32]).expect("encode"); + let (head, sig) = raw.rsplit_once(":sig=").expect("sig field"); + let mut chars: Vec = sig.chars().collect(); + let last = chars.last_mut().expect("sig chars"); + *last = if *last == '0' { '1' } else { '0' }; + let flipped: String = chars.into_iter().collect(); + assert!(qr::decode(&format!("{head}:sig={flipped}")).is_err()); +} + +#[test] +fn unicode_qr_renders() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + let me = store.self_identity().expect("self"); + let raw = qr::encode(&me.identity_sk, &v3onion("x"), &[1u8; 32]).expect("encode"); + let art = qr::render_unicode(&raw).expect("render"); + assert!( + art.contains('█') || art.contains('▀') || art.contains('▄') || art.contains('▌'), + "expected unicode blocks, got {art:?}" + ); +} + +#[test] +fn same_k_rescan_updates_onion_not_duplicate() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + let me = store.self_identity().expect("self"); + let spk = [9u8; 32]; + let p1 = qr::decode(&qr::encode(&me.identity_sk, &v3onion("old"), &spk).unwrap()).unwrap(); + store + .upsert_friend(&p1.pubkey, &p1.onion, Some("alice")) + .unwrap(); + let p2 = qr::decode(&qr::encode(&me.identity_sk, &v3onion("new"), &spk).unwrap()).unwrap(); + store.upsert_friend(&p2.pubkey, &p2.onion, None).unwrap(); + assert_eq!(store.friend_count().unwrap(), 1); + let f = store.get_friend(&p2.pubkey).unwrap().unwrap(); + assert_eq!(f.onion, v3onion("new")); + assert_eq!(f.petname.as_deref(), Some("alice")); +} + +#[test] +fn unknown_k_decode_does_not_insert() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + let me = store.self_identity().expect("self"); + let raw = qr::encode(&me.identity_sk, &v3onion("x"), &[1u8; 32]).unwrap(); + let _p = qr::decode(&raw).unwrap(); + assert_eq!(store.friend_count().unwrap(), 0); +}