2026-09-10 01:54:19 -04:00
|
|
|
//! M2 QR: roundtrip, garbage → Err, same-k rescan does not duplicate.
|
|
|
|
|
|
|
|
|
|
use std::sync::{Mutex, MutexGuard};
|
|
|
|
|
|
2026-09-10 17:19:50 -04:00
|
|
|
use onionwire::qr;
|
2026-09-10 19:52:44 -04:00
|
|
|
use onionwire::Store;
|
2026-09-10 01:54:19 -04:00
|
|
|
|
|
|
|
|
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");
|
2026-09-10 19:52:44 -04:00
|
|
|
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
2026-09-10 01:54:19 -04:00
|
|
|
unsafe {
|
|
|
|
|
std::env::set_var("ONIONWIRE_HOME", dir.path());
|
2026-09-10 17:19:50 -04:00
|
|
|
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
Self {
|
|
|
|
|
_dir: dir,
|
|
|
|
|
_guard: guard,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for TempHome {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var("ONIONWIRE_HOME");
|
2026-09-10 17:19:50 -04:00
|
|
|
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn v3onion(tag: &str) -> String {
|
2026-09-10 19:52:44 -04:00
|
|
|
let mut addr = vec![b'a'; 56];
|
|
|
|
|
let bytes: Vec<u8> = tag
|
|
|
|
|
.bytes()
|
|
|
|
|
.map(|b| match b {
|
|
|
|
|
b'a'..=b'z' | b'2'..=b'7' => b,
|
|
|
|
|
_ => b'a',
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let n = bytes.len().min(56);
|
|
|
|
|
addr[..n].copy_from_slice(&bytes[..n]);
|
|
|
|
|
format!("{}.onion", String::from_utf8(addr).expect("base32"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn invite_fields(raw: &str) -> (String, String, String, String) {
|
|
|
|
|
let rest = raw.strip_prefix("onionwire:v1:").expect("prefix");
|
|
|
|
|
let mut k = String::new();
|
|
|
|
|
let mut o = String::new();
|
|
|
|
|
let mut spk = String::new();
|
|
|
|
|
let mut sig = String::new();
|
|
|
|
|
for part in rest.split(':') {
|
|
|
|
|
if let Some(v) = part.strip_prefix("k=") {
|
|
|
|
|
k = v.to_string();
|
|
|
|
|
} else if let Some(v) = part.strip_prefix("o=") {
|
|
|
|
|
o = v.to_string();
|
|
|
|
|
} else if let Some(v) = part.strip_prefix("spk=") {
|
|
|
|
|
spk = v.to_string();
|
|
|
|
|
} else if let Some(v) = part.strip_prefix("sig=") {
|
|
|
|
|
sig = v.to_string();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(k, o, spk, sig)
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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 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);
|
|
|
|
|
}
|
2026-09-10 19:52:44 -04:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn shifted_o_spk_is_err() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
let me = store.self_identity().expect("self");
|
|
|
|
|
let onion = v3onion("honest");
|
|
|
|
|
let spk = [0xab; 32];
|
|
|
|
|
let raw = qr::encode(&me.identity_sk, &onion, &spk).expect("encode");
|
|
|
|
|
let (k, o, spk_hex, sig) = invite_fields(&raw);
|
|
|
|
|
assert_eq!(spk_hex.len(), 64);
|
|
|
|
|
let mutant = format!(
|
|
|
|
|
"onionwire:v1:k={k}:o={o}{}:spk={}:sig={sig}",
|
|
|
|
|
&spk_hex[..8],
|
|
|
|
|
&spk_hex[8..]
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
qr::decode(&mutant).is_err(),
|
|
|
|
|
"shifted o/spk must not verify"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn encode_rejects_spk_not_32() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
let me = store.self_identity().expect("self");
|
|
|
|
|
let onion = v3onion("spk");
|
|
|
|
|
assert!(qr::encode(&me.identity_sk, &onion, &[1u8; 31]).is_err());
|
|
|
|
|
assert!(qr::encode(&me.identity_sk, &onion, &[1u8; 33]).is_err());
|
|
|
|
|
assert!(qr::encode(&me.identity_sk, &onion, &[]).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn encode_rejects_garbage_onion() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
let me = store.self_identity().expect("self");
|
|
|
|
|
let spk = [2u8; 32];
|
|
|
|
|
assert!(qr::encode(&me.identity_sk, "not-an-onion", &spk).is_err());
|
|
|
|
|
assert!(qr::encode(&me.identity_sk, "alice.onion", &spk).is_err());
|
|
|
|
|
assert!(qr::encode(&me.identity_sk, &format!("{}.onion", "A".repeat(56)), &spk).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn decode_rejects_spk_len_and_garbage_onion() {
|
|
|
|
|
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("ok"), &[3u8; 32]).expect("encode");
|
|
|
|
|
let (k, o, spk, sig) = invite_fields(&raw);
|
|
|
|
|
let short_spk = format!("onionwire:v1:k={k}:o={o}:spk={}:sig={sig}", &spk[..62]);
|
|
|
|
|
assert!(qr::decode(&short_spk).is_err());
|
|
|
|
|
let garbage_o = format!("onionwire:v1:k={k}:o=nope.onion:spk={spk}:sig={sig}");
|
|
|
|
|
assert!(qr::decode(&garbage_o).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn huge_hex_is_err_quickly() {
|
|
|
|
|
let huge = format!(
|
|
|
|
|
"onionwire:v1:k={}:o=x:spk=yy:sig=zz",
|
|
|
|
|
"aa".repeat(1024 * 1024)
|
|
|
|
|
);
|
|
|
|
|
let t = std::time::Instant::now();
|
|
|
|
|
assert!(qr::decode(&huge).is_err());
|
|
|
|
|
assert!(
|
|
|
|
|
t.elapsed() < std::time::Duration::from_millis(250),
|
|
|
|
|
"decode must fail closed before a multi-MB alloc"
|
|
|
|
|
);
|
|
|
|
|
}
|