[verified] Add QR codec and ratatui roster.

M2: onionwire:v1 signed payload, Unicode QR, same-k locator update,
unknown-k approval in TUI. F4 is a no-op stub. No Tor in the bin.
This commit is contained in:
Sirius DevOps 2026-09-10 01:54:19 -04:00
parent 0a0dd3a8d8
commit 4fb4648552
No known key found for this signature in database
8 changed files with 959 additions and 32 deletions

362
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -1,3 +1,5 @@
pub mod qr;
mod store;
pub mod tui;
pub use store::{Friend, SelfIdentity, Store};

View file

@ -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);

138
src/qr.rs Normal file
View file

@ -0,0 +1,138 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use qrcode::render::unicode::Dense1x2;
use qrcode::QrCode;
pub type Result<T> = std::result::Result<T, Error>;
#[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<u8>,
pub onion: String,
pub signed_prekey: Vec<u8>,
}
/// `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<String> {
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<QrPayload> {
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<String> {
let code = QrCode::new(payload.as_bytes()).map_err(|e| Error(e.to_string()))?;
Ok(code.render::<Dense1x2>().build())
}
fn sign_msg(k: &str, onion: &str, spk: &str) -> Vec<u8> {
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<Vec<u8>> {
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()
}

View file

@ -47,6 +47,7 @@ pub struct Friend {
pub fingerprint: String,
pub petname: Option<String>,
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<Option<Friend>> {
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<Vec<Friend>> {
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<PathBuf> {

300
src/tui.rs Normal file
View file

@ -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<Friend>,
selected: usize,
me_fp: String,
me_onion: String,
identity_sk: Vec<u8>,
}
enum Screen {
Main,
Share { art: String, payload: String },
Paste { buf: String, err: Option<String> },
Approve { payload: QrPayload },
Rotate,
}
impl App {
fn new(store: Store) -> Result<Self, String> {
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<ListItem> = 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()
}

132
tests/qr.rs Normal file
View file

@ -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<char> = 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);
}