2026-09-10 01:43:02 -04:00
|
|
|
//! M1 store: identity on `self`, friends keyed by pubkey, onion is locator.
|
|
|
|
|
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::sync::{Mutex, MutexGuard};
|
|
|
|
|
|
|
|
|
|
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());
|
2026-09-10 17:19:50 -04:00
|
|
|
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test");
|
2026-09-10 01:43:02 -04:00
|
|
|
}
|
|
|
|
|
Self { dir, _guard: guard }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn path(&self) -> &Path {
|
|
|
|
|
self.dir.path()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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:43:02 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn mode(path: &Path) -> u32 {
|
|
|
|
|
fs::metadata(path).unwrap().permissions().mode() & 0o777
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn pk(tag: u8) -> [u8; 32] {
|
|
|
|
|
let mut k = [0u8; 32];
|
|
|
|
|
k[0] = tag;
|
|
|
|
|
k
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn first_run_creates_0700_dirs_and_self_row() {
|
|
|
|
|
let home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
|
|
|
|
|
let arti = home.path().join("arti");
|
2026-09-10 20:30:18 -04:00
|
|
|
let cache = home.path().join("cache");
|
2026-09-10 01:43:02 -04:00
|
|
|
let db = home.path().join("onionwire.db");
|
|
|
|
|
assert!(arti.is_dir(), "arti dir");
|
2026-09-10 20:30:18 -04:00
|
|
|
assert!(cache.is_dir(), "cache dir");
|
2026-09-10 01:43:02 -04:00
|
|
|
assert!(db.is_file(), "onionwire.db");
|
|
|
|
|
assert_eq!(mode(home.path()), 0o700);
|
|
|
|
|
assert_eq!(mode(&arti), 0o700);
|
2026-09-10 20:30:18 -04:00
|
|
|
assert_eq!(mode(&cache), 0o700);
|
2026-09-10 01:43:02 -04:00
|
|
|
|
|
|
|
|
let me = store.self_identity().expect("self");
|
|
|
|
|
assert_eq!(me.identity_pk.len(), 32);
|
|
|
|
|
assert_eq!(me.identity_sk.len(), 32);
|
|
|
|
|
assert_ne!(me.identity_pk, me.identity_sk);
|
|
|
|
|
assert_eq!(me.onion, "");
|
|
|
|
|
assert_eq!(store.friend_count().unwrap(), 0);
|
|
|
|
|
|
|
|
|
|
drop(store);
|
|
|
|
|
let store2 = Store::open().expect("reopen");
|
|
|
|
|
let me2 = store2.self_identity().expect("self again");
|
|
|
|
|
assert_eq!(me.identity_pk, me2.identity_pk);
|
|
|
|
|
assert_eq!(me.identity_sk, me2.identity_sk);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn insert_friend_by_pubkey() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
store
|
|
|
|
|
.upsert_friend(&pk(1), "aaa.onion", Some("alice"))
|
|
|
|
|
.expect("insert");
|
|
|
|
|
let f = store.get_friend(&pk(1)).expect("row").expect("found");
|
|
|
|
|
assert_eq!(f.onion, "aaa.onion");
|
|
|
|
|
assert_eq!(f.petname.as_deref(), Some("alice"));
|
|
|
|
|
assert_eq!(f.pubkey, pk(1));
|
|
|
|
|
assert!(!f.fingerprint.is_empty());
|
|
|
|
|
assert_eq!(store.friend_count().unwrap(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn same_pubkey_different_onion_updates_locator() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
store
|
|
|
|
|
.upsert_friend(&pk(1), "old.onion", Some("alice"))
|
|
|
|
|
.expect("insert");
|
|
|
|
|
store
|
|
|
|
|
.upsert_friend(&pk(1), "new.onion", None)
|
|
|
|
|
.expect("update");
|
|
|
|
|
assert_eq!(store.friend_count().unwrap(), 1);
|
|
|
|
|
let f = store.get_friend(&pk(1)).expect("row").expect("found");
|
|
|
|
|
assert_eq!(f.onion, "new.onion");
|
|
|
|
|
assert_eq!(f.petname.as_deref(), Some("alice"), "petname is local");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unknown_pubkey_inserts_new_row() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
store.upsert_friend(&pk(1), "a.onion", None).expect("first");
|
|
|
|
|
store
|
|
|
|
|
.upsert_friend(&pk(2), "b.onion", None)
|
|
|
|
|
.expect("second");
|
|
|
|
|
assert_eq!(store.friend_count().unwrap(), 2);
|
|
|
|
|
assert_eq!(store.get_friend(&pk(1)).unwrap().unwrap().onion, "a.onion");
|
|
|
|
|
assert_eq!(store.get_friend(&pk(2)).unwrap().unwrap().onion, "b.onion");
|
|
|
|
|
}
|
2026-09-10 02:07:40 -04:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn open_at_two_homes_are_independent() {
|
|
|
|
|
let a = tempfile::tempdir().expect("a");
|
|
|
|
|
let b = tempfile::tempdir().expect("b");
|
2026-09-10 17:19:50 -04:00
|
|
|
let sa = onionwire::Store::open_at_with_passphrase(a.path(), "a-pass").expect("open a");
|
|
|
|
|
let sb = onionwire::Store::open_at_with_passphrase(b.path(), "b-pass").expect("open b");
|
2026-09-10 02:07:40 -04:00
|
|
|
let ia = sa.self_identity().unwrap();
|
|
|
|
|
let ib = sb.self_identity().unwrap();
|
|
|
|
|
assert_ne!(ia.identity_pk, ib.identity_pk);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn incoming_message_persists_plaintext() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
|
|
|
|
|
store.append_message(&pk(1), "in", b"hello wire").unwrap();
|
|
|
|
|
let msgs = store.list_messages(&pk(1)).unwrap();
|
|
|
|
|
assert_eq!(msgs.len(), 1);
|
|
|
|
|
assert_eq!(msgs[0].dir, "in");
|
|
|
|
|
assert_eq!(msgs[0].plaintext, b"hello wire");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn self_has_x25519_prekey_and_set_onion() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
let me = store.self_identity().unwrap();
|
|
|
|
|
assert_eq!(me.prekey_pk.len(), 32);
|
|
|
|
|
assert_eq!(me.prekey_sk.len(), 32);
|
|
|
|
|
assert_ne!(me.prekey_pk, me.prekey_sk);
|
|
|
|
|
store.set_onion("abc.onion").unwrap();
|
|
|
|
|
assert_eq!(store.self_identity().unwrap().onion, "abc.onion");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn friend_prekey_lookup() {
|
|
|
|
|
let _home = TempHome::new();
|
|
|
|
|
let store = Store::open().expect("open");
|
|
|
|
|
let spk = [9u8; 32];
|
|
|
|
|
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
|
|
|
|
|
store.set_friend_prekey(&pk(1), &spk).unwrap();
|
|
|
|
|
let f = store.get_friend_by_prekey(&spk).unwrap().expect("found");
|
|
|
|
|
assert_eq!(f.pubkey, pk(1));
|
|
|
|
|
assert_eq!(f.prekey, spk);
|
|
|
|
|
}
|
2026-09-10 14:50:01 -04:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn open_uses_wal_journal() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
2026-09-10 17:19:50 -04:00
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
|
2026-09-10 14:50:01 -04:00
|
|
|
assert_eq!(store.journal_mode().unwrap().to_ascii_lowercase(), "wal");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn corrupt_db_fails_closed() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
std::fs::write(dir.path().join("onionwire.db"), b"not a sqlite database").unwrap();
|
2026-09-10 17:19:50 -04:00
|
|
|
assert!(Store::open_at_with_passphrase(dir.path(), "onionwire-test").is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn home_contains_bytes(home: &Path, needle: &[u8]) -> bool {
|
|
|
|
|
let Ok(rd) = fs::read_dir(home) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
for ent in rd.flatten() {
|
|
|
|
|
let name = ent.file_name();
|
|
|
|
|
let name = name.to_string_lossy();
|
|
|
|
|
if !name.starts_with("onionwire.db") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Ok(bytes) = fs::read(ent.path()) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if bytes.windows(needle.len()).any(|w| w == needle) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn empty_passphrase_is_rejected() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let err = match Store::open_at_with_passphrase(dir.path(), "") {
|
|
|
|
|
Ok(_) => panic!("empty passphrase should fail"),
|
|
|
|
|
Err(e) => e,
|
|
|
|
|
};
|
|
|
|
|
assert!(
|
|
|
|
|
err.to_string().to_ascii_lowercase().contains("empty"),
|
|
|
|
|
"got {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn append_message_db_file_does_not_contain_plaintext() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "correct-horse").expect("open");
|
|
|
|
|
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
|
|
|
|
|
let needle = b"needle-plaintext-xyzzy-at-rest";
|
|
|
|
|
store.append_message(&pk(1), "out", needle).unwrap();
|
|
|
|
|
let msgs = store.list_messages(&pk(1)).unwrap();
|
|
|
|
|
assert_eq!(msgs.len(), 1);
|
|
|
|
|
assert_eq!(msgs[0].plaintext, needle);
|
|
|
|
|
drop(store);
|
|
|
|
|
assert!(
|
|
|
|
|
!home_contains_bytes(dir.path(), needle),
|
|
|
|
|
"sqlite files must not contain chat plaintext"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn wrong_passphrase_cannot_open_or_list() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "right-pass").expect("open");
|
|
|
|
|
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
|
|
|
|
|
store
|
|
|
|
|
.append_message(&pk(1), "in", b"secret-chat-body")
|
|
|
|
|
.unwrap();
|
|
|
|
|
drop(store);
|
|
|
|
|
let err = match Store::open_at_with_passphrase(dir.path(), "wrong-pass") {
|
|
|
|
|
Ok(_) => panic!("wrong passphrase should fail"),
|
|
|
|
|
Err(e) => e,
|
|
|
|
|
};
|
|
|
|
|
let msg = err.to_string().to_ascii_lowercase();
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("passphrase") || msg.contains("decrypt") || msg.contains("wrong"),
|
|
|
|
|
"got {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reopen_with_same_passphrase_decrypts() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "same-pass").expect("open");
|
|
|
|
|
store.upsert_friend(&pk(1), "b.onion", None).unwrap();
|
|
|
|
|
store.append_message(&pk(1), "out", b"hello again").unwrap();
|
|
|
|
|
drop(store);
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "same-pass").expect("reopen");
|
|
|
|
|
let msgs = store.list_messages(&pk(1)).unwrap();
|
|
|
|
|
assert_eq!(msgs[0].plaintext, b"hello again");
|
2026-09-10 14:50:01 -04:00
|
|
|
}
|
2026-09-10 21:10:50 -04:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn env_passphrase_skips_prompt() {
|
|
|
|
|
let _g = ENV_LOCK.lock().expect("env lock");
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "from-env");
|
|
|
|
|
}
|
|
|
|
|
let mut prompted = false;
|
|
|
|
|
let got = onionwire::resolve_store_passphrase(|_| {
|
|
|
|
|
prompted = true;
|
|
|
|
|
Ok("from-tty".into())
|
|
|
|
|
});
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
|
|
|
|
}
|
|
|
|
|
assert_eq!(got.unwrap(), "from-env");
|
|
|
|
|
assert!(!prompted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn empty_env_passphrase_is_rejected_without_prompt() {
|
|
|
|
|
let _g = ENV_LOCK.lock().expect("env lock");
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "");
|
|
|
|
|
}
|
|
|
|
|
let mut prompted = false;
|
|
|
|
|
let err = onionwire::resolve_store_passphrase(|_| {
|
|
|
|
|
prompted = true;
|
|
|
|
|
Ok("from-tty".into())
|
|
|
|
|
})
|
|
|
|
|
.unwrap_err();
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
|
|
|
|
}
|
|
|
|
|
assert!(err.to_string().contains("empty"), "got {err}");
|
|
|
|
|
assert!(!prompted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn missing_env_reads_secret_and_strips_newline() {
|
|
|
|
|
let _g = ENV_LOCK.lock().expect("env lock");
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
|
|
|
|
}
|
|
|
|
|
let got = onionwire::resolve_store_passphrase(|prompt| {
|
|
|
|
|
assert!(prompt.contains("store passphrase"), "prompt {prompt}");
|
|
|
|
|
Ok("secret-from-tty\n".into())
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(got, "secret-from-tty");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn missing_env_empty_secret_is_rejected() {
|
|
|
|
|
let _g = ENV_LOCK.lock().expect("env lock");
|
|
|
|
|
unsafe {
|
|
|
|
|
std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE");
|
|
|
|
|
}
|
|
|
|
|
let err = onionwire::resolve_store_passphrase(|_| Ok(String::new())).unwrap_err();
|
|
|
|
|
assert!(err.to_string().contains("empty"), "got {err}");
|
|
|
|
|
}
|