From 6323619d80dcfb7f8e9cb01a864c244715e0c25b Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 17:19:50 -0400 Subject: [PATCH] feat(store): encrypt chat bodies at rest; Ctrl-Q CLEAR/QUIT Wrap a random 32-byte message key with Argon2id+ChaCha20-Poly1305. sqlite messages.plaintext is nonce||ciphertext; identity keys stay plaintext. Empty/wrong passphrase fails closed. Ctrl-Q types CLEAR or QUIT then Enter (Esc cancels). --- README.md | 13 ++-- docs/THREAT_MODEL.md | 6 +- src/backup.rs | 25 +++++++- src/main.rs | 23 ++++++- src/node.rs | 14 +++- src/store.rs | 149 +++++++++++++++++++++++++++++++++++++++++-- src/tui.rs | 107 ++++++++++++++++++++++++++++++- tests/backup.rs | 2 +- tests/pay.rs | 2 +- tests/profile.rs | 2 +- tests/qr.rs | 4 +- tests/rotate.rs | 2 +- tests/rotate_hs.rs | 5 +- tests/store.rs | 92 ++++++++++++++++++++++++-- tests/two_node.rs | 3 + tests/wipe.rs | 47 ++++++++++++-- 16 files changed, 461 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 6d6740a..1e7ba0e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Lean Tor messenger: one Rust process (ratatui + Noise IK + sqlite + in-process Arti). No hosted server. Identity is a public key; the onion is only a locator. -**v0.2** — Linux TUI. Two people add each other with an `onionwire:v1:…` invite string (`F2` share / `F3` paste). There is no QR graphic. Chat is Noise IK over your own v3 onion. Optional Monero invoices if you point at a local `monero-wallet-rpc`. Fail closed: peer down → send fails. Arti onion services are still experimental. The message log is plaintext on disk. +**v0.2** — Linux TUI. Two people add each other with an `onionwire:v1:…` invite string (`F2` share / `F3` paste). There is no QR graphic. Chat is Noise IK over your own v3 onion. Optional Monero invoices if you point at a local `monero-wallet-rpc`. Fail closed: peer down → send fails. Arti onion services are still experimental. Chat bodies are encrypted at rest; identity keys in sqlite are still plaintext. You do **not** install a `tor` daemon, `torrc`, Prosody, or XMPP. OnionWire embeds Arti and publishes its own v3 onion. @@ -108,7 +108,7 @@ onionwire or `cargo run --release`. -On start you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you. +On start you are prompted for a store passphrase (or set `ONIONWIRE_STORE_PASSPHRASE`). Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you. Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy. @@ -133,7 +133,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j | `F5` | Selected friend’s profile (`/who`) | | Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer | | `Esc` | Close overlay / back to Main / clear composer | -| `Ctrl-Q` | Quit | +| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay | ## F2 / F3 invite @@ -208,13 +208,14 @@ If you set `ONIONWIRE_HOME`, delete that directory instead. ## Seized laptop -v1 stores **plaintext** on disk: +Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`), wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log. + +Still plaintext on disk (unless you add OS/FDE): -- message log (sqlite `messages.plaintext`) - your identity secret key (`self.identity_sk`) - friend public keys and current locators -Full-disk encryption plus `/wipe` / `/wipe-all` is the mitigation. There is no sqlcipher in v1. +The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. Ctrl-Q can clear history on the way out (`CLEAR`) without becoming a new person. Threat model: [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md). diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 43d739f..aa74de7 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -14,9 +14,11 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s locator **only `F4` changes the locator only. The identity key is unchanged. Anyone who already has your pubkey can still prove they are talking to you, and a later invite paste with the same `k` updates their row. To become a new person, `/wipe-all` (new identity key). There is no in-band unfriend/revoke in v1. -## v1 stores plaintext locally +## Message bodies are encrypted at rest; keys are not -The message log, identity secret key, and friend public keys sit on disk unencrypted (aside from whatever the OS/FDE provides). A seized laptop yields the chat history and who you talk to. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. sqlcipher is out of v1. +Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column). A random 32-byte data key is wrapped with Argon2id (same params as identity backup) from a non-empty passphrase. Salt + wrapped key live in `store_meta`. Unlock is fail-closed: wrong or empty passphrase does not open chat. + +Identity secret key, friend public keys, and locators remain plaintext in the same db. The message key is not wrapped with `identity_sk` (that key is already on disk). A seized laptop still yields who you talk to and your identity unless you add OS/FDE. sqlcipher is out of v1. `/wipe` overwrites message bodies and vacuums; `/wipe-all` deletes the data dir. ## Fail closed diff --git a/src/backup.rs b/src/backup.rs index 6270b5f..92f6713 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -42,7 +42,7 @@ impl BackupKeys { } } -fn kdf(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> { +pub(crate) fn kdf(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> { if passphrase.is_empty() { return Err("empty passphrase".into()); } @@ -94,3 +94,26 @@ pub fn open(passphrase: &str, blob: &[u8]) -> Result { .map_err(|_| "wrong passphrase or corrupt backup".to_string())?; BackupKeys::from_bytes(&pt) } + +pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result, String> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let ct = cipher + .encrypt(&nonce, plaintext) + .map_err(|_| "encrypt failed".to_string())?; + let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct); + Ok(out) +} + +pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8]) -> Result, String> { + if blob.len() < NONCE_LEN + 16 { + return Err("ciphertext length".into()); + } + let nonce = Nonce::from_slice(&blob[..NONCE_LEN]); + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + cipher + .decrypt(nonce, &blob[NONCE_LEN..]) + .map_err(|_| "wrong passphrase or corrupt".to_string()) +} diff --git a/src/main.rs b/src/main.rs index 3aff8c5..c37cf2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use onionwire::tui::AppExit; +use std::io::{self, Write}; fn print_help() { let v = env!("CARGO_PKG_VERSION"); @@ -34,8 +35,9 @@ async fn main() { async fn boot() -> Result<(), String> { let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?; + let pass = store_passphrase()?; eprintln!("onionwire: bootstrapping Arti…"); - let node = onionwire::node::Node::start(home.clone()).await?; + let node = onionwire::node::Node::start_with_passphrase(home.clone(), &pass).await?; let handle = tokio::runtime::Handle::current(); let exit = tokio::task::spawn_blocking(move || onionwire::tui::run(node, handle)) .await @@ -46,3 +48,22 @@ async fn boot() -> Result<(), String> { } Ok(()) } + +fn store_passphrase() -> Result { + match std::env::var("ONIONWIRE_STORE_PASSPHRASE") { + Ok(p) if p.is_empty() => Err("empty passphrase".into()), + Ok(p) => Ok(p), + Err(_) => { + eprint!("onionwire: store passphrase: "); + let _ = io::stderr().flush(); + let mut s = String::new(); + io::stdin().read_line(&mut s).map_err(|e| e.to_string())?; + let s = s.trim_end_matches(['\n', '\r']).to_string(); + if s.is_empty() { + Err("empty passphrase".into()) + } else { + Ok(s) + } + } + } +} diff --git a/src/node.rs b/src/node.rs index 3024a43..c79aff2 100644 --- a/src/node.rs +++ b/src/node.rs @@ -50,7 +50,19 @@ pub struct Node { impl Node { pub async fn start(home: PathBuf) -> Result, String> { - let store = Store::open_at(&home).map_err(|e| e.to_string())?; + let pass = std::env::var("ONIONWIRE_STORE_PASSPHRASE") + .map_err(|_| "ONIONWIRE_STORE_PASSPHRASE required".to_string())?; + if pass.is_empty() { + return Err("empty passphrase".into()); + } + Self::start_with_passphrase(home, &pass).await + } + + pub async fn start_with_passphrase( + home: PathBuf, + passphrase: &str, + ) -> Result, String> { + let store = Store::open_at_with_passphrase(&home, passphrase).map_err(|e| e.to_string())?; let me = store.self_identity().map_err(|e| e.to_string())?; let keys = Keys::from_self(&me).map_err(|e| e.to_string())?; let nickname = store.hs_nickname().map_err(|e| e.to_string())?; diff --git a/src/store.rs b/src/store.rs index 8838785..7eb65ce 100644 --- a/src/store.rs +++ b/src/store.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use ed25519_dalek::SigningKey; +use rand::RngCore; use rand::rngs::OsRng; use rusqlite::{Connection, OptionalExtension, params}; use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; @@ -33,8 +34,15 @@ impl From for Error { } } +impl From for Error { + fn from(e: String) -> Self { + Self(e) + } +} + pub struct Store { conn: Connection, + msg_key: [u8; 32], } pub struct SelfIdentity { @@ -100,6 +108,13 @@ impl Store { } pub fn open_at(home: &Path) -> Result { + Self::open_at_with_passphrase(home, &passphrase_from_env()?) + } + + pub fn open_at_with_passphrase(home: &Path, passphrase: &str) -> Result { + if passphrase.is_empty() { + return Err(Error("empty passphrase".into())); + } mkdir_700(home)?; mkdir_700(&home.join("arti"))?; let db_path = home.join("onionwire.db"); @@ -153,6 +168,11 @@ impl Store { xmr_addr TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS store_meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + kdf_salt BLOB NOT NULL, + wrapped_key BLOB NOT NULL + ); CREATE TABLE IF NOT EXISTS payments ( id INTEGER PRIMARY KEY, friend_id INTEGER NOT NULL REFERENCES friends(id), @@ -172,9 +192,13 @@ impl Store { if check != "ok" { return Err(Error(format!("integrity_check: {check}"))); } - let store = Self { conn }; + let mut store = Self { + conn, + msg_key: [0u8; 32], + }; store.migrate()?; store.ensure_self()?; + store.unlock_messages(passphrase)?; Ok(store) } @@ -234,6 +258,66 @@ impl Store { Ok(()) } + fn unlock_messages(&mut self, passphrase: &str) -> Result<()> { + let row: Option<(Vec, Vec)> = self + .conn + .query_row( + "SELECT kdf_salt, wrapped_key FROM store_meta WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + match row { + Some((salt, wrapped)) => { + let wrap_key = crate::backup::kdf(passphrase, &salt)?; + let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped) + .map_err(|_| Error("wrong passphrase".into()))?; + if raw.len() != 32 { + return Err(Error("wrapped message key length".into())); + } + self.msg_key.copy_from_slice(&raw); + Ok(()) + } + None => { + let mut data_key = [0u8; 32]; + OsRng.fill_bytes(&mut data_key); + let mut salt = [0u8; 16]; + OsRng.fill_bytes(&mut salt); + let wrap_key = crate::backup::kdf(passphrase, &salt)?; + let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key)?; + self.conn.execute( + "INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)", + params![salt.as_slice(), wrapped], + )?; + self.msg_key = data_key; + self.reencrypt_legacy_messages()?; + Ok(()) + } + } + } + + fn reencrypt_legacy_messages(&self) -> Result<()> { + let mut stmt = self.conn.prepare("SELECT id, plaintext FROM messages")?; + let rows: Vec<(i64, Vec)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + drop(stmt); + if rows.is_empty() { + return Ok(()); + } + let tx = self.conn.unchecked_transaction()?; + for (id, plain) in rows { + let blob = crate::backup::aead_encrypt(&self.msg_key, &plain)?; + tx.execute( + "UPDATE messages SET plaintext = ?1 WHERE id = ?2", + params![blob, id], + )?; + } + tx.commit()?; + let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)"); + Ok(()) + } + fn add_column_if_missing(&self, table: &str, column: &str, ddl: &str) -> Result<()> { let sql = format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = '{column}'"); @@ -562,9 +646,10 @@ impl Store { |row| row.get(0), ) .map_err(|_| Error("friend not found".into()))?; + let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext)?; self.conn.execute( "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)", - params![friend_id, dir, plaintext, unix_now()], + params![friend_id, dir, blob, unix_now()], )?; Ok(()) } @@ -577,14 +662,14 @@ impl Store { ORDER BY m.id", )?; let rows = stmt.query_map(params![friend_pk], |row| { - Ok(Message { - dir: row.get(0)?, - plaintext: row.get(1)?, - }) + Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)) })?; let mut out = Vec::new(); for row in rows { - out.push(row?); + let (dir, blob) = row?; + let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob) + .map_err(|_| Error("message decrypt failed".into()))?; + out.push(Message { dir, plaintext }); } Ok(out) } @@ -657,6 +742,14 @@ fn home_dir() -> Result { Ok(PathBuf::from(home).join(".local/share/onionwire")) } +fn passphrase_from_env() -> Result { + match std::env::var("ONIONWIRE_STORE_PASSPHRASE") { + Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())), + Ok(p) => Ok(p), + Err(_) => Err(Error("ONIONWIRE_STORE_PASSPHRASE required".into())), + } +} + fn mkdir_700(path: &Path) -> Result<()> { fs::create_dir_all(path)?; let mut perms = fs::metadata(path)?.permissions(); @@ -675,3 +768,45 @@ fn unix_now() -> i64 { fn fingerprint(pubkey: &[u8]) -> String { pubkey.iter().map(|b| format!("{b:02x}")).collect() } + +#[cfg(test)] +mod at_rest { + use super::*; + + #[test] + fn first_unlock_reencrypts_legacy_plaintext_rows() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap(); + let pk = [1u8; 32]; + store.upsert_friend(&pk, "a.onion", None).unwrap(); + store + .append_message(&pk, "out", b"legacy-plain-xyz") + .unwrap(); + store.conn.execute("DELETE FROM store_meta", []).unwrap(); + store + .conn + .execute( + "UPDATE messages SET plaintext = ?1", + params![b"legacy-plain-xyz".as_slice()], + ) + .unwrap(); + let _ = store.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)"); + drop(store); + + let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap(); + let msgs = store.list_messages(&pk).unwrap(); + assert_eq!(msgs[0].plaintext, b"legacy-plain-xyz"); + drop(store); + let needle = b"legacy-plain-xyz"; + for name in ["onionwire.db", "onionwire.db-wal", "onionwire.db-shm"] { + let path = dir.path().join(name); + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + assert!( + !bytes.windows(needle.len()).any(|w| w == needle), + "{name} still contains legacy plaintext" + ); + } + } +} diff --git a/src/tui.rs b/src/tui.rs index 61a3625..8efd685 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -31,6 +31,15 @@ Offline friends CANNOT find you until they F3-paste your new invite.\n\ Type ROTATE to confirm Esc to cancel" } +pub fn quit_screen_text() -> &'static str { + "Quit OnionWire?\n\ +Message history can be cleared; identity and friends stay.\n\ +This is not /wipe-all.\n\ +Type CLEAR then Enter to clear history and quit.\n\ +Type QUIT then Enter to quit without clearing.\n\ +Esc to cancel" +} + /// Full Main-screen wordmark. Every line is <= 80 ASCII characters. pub fn wordmark_banner() -> &'static str { "\ @@ -71,7 +80,7 @@ OnionWire keys\n\ /backup /path encrypted identity export\n\ /restore /path overwrite self keys\n\ ? this help\n\ - Ctrl-Q quit\n\ + Ctrl-Q quit; type CLEAR or QUIT\n\ \n\ Esc closes this overlay" } @@ -415,6 +424,52 @@ impl RotatePrompt { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuitDecision { + Pending, + ClearAndQuit, + Quit, + Cancel, +} + +#[derive(Debug, Default)] +pub struct QuitPrompt { + buf: String, +} + +impl QuitPrompt { + pub fn new() -> Self { + Self::default() + } + + pub fn on_esc(&self) -> QuitDecision { + QuitDecision::Cancel + } + + pub fn on_char(&mut self, c: char) -> QuitDecision { + if c == '\n' { + return match self.buf.as_str() { + "CLEAR" => QuitDecision::ClearAndQuit, + "QUIT" => QuitDecision::Quit, + _ => QuitDecision::Pending, + }; + } + if c == '\u{8}' { + self.buf.pop(); + return QuitDecision::Pending; + } + if !c.is_ascii_alphabetic() { + return QuitDecision::Pending; + } + self.buf.push(c); + QuitDecision::Pending + } + + pub fn typed(&self) -> &str { + &self.buf + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BackupKind { Backup, @@ -558,6 +613,9 @@ enum Screen { first: Option, buf: String, }, + Quit { + prompt: QuitPrompt, + }, } impl App { @@ -591,7 +649,12 @@ impl App { continue; } if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) { - return Ok(AppExit::Quit); + if !matches!(self.screen, Screen::Quit { .. }) { + self.screen = Screen::Quit { + prompt: QuitPrompt::new(), + }; + } + continue; } if let Some(exit) = self.handle_key(key)? { return Ok(exit); @@ -624,6 +687,7 @@ impl App { } let mut wipe_confirm = None; + let mut quit_clear = None; let mut profile_save = None; let mut open_profile = false; let mut pay_cmd = None; @@ -810,10 +874,31 @@ impl App { } _ => {} }, + Screen::Quit { prompt } => match key.code { + KeyCode::Esc => self.screen = Screen::Main, + KeyCode::Enter => match prompt.on_char('\n') { + QuitDecision::ClearAndQuit => quit_clear = Some(true), + QuitDecision::Quit => quit_clear = Some(false), + _ => {} + }, + KeyCode::Backspace => { + prompt.on_char('\u{8}'); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + prompt.on_char(c); + } + _ => {} + }, } if let Some(kind) = wipe_confirm { return self.confirm_wipe(kind); } + if let Some(clear) = quit_clear { + if clear { + self.node.wipe_messages()?; + } + return Ok(Some(AppExit::Quit)); + } if open_profile { self.open_profile()?; } @@ -1098,6 +1183,7 @@ impl App { Screen::Passphrase { kind, first, buf, .. } => draw_passphrase(f, *kind, first.is_some(), buf), + Screen::Quit { prompt } => draw_quit(f, prompt.typed()), } if self.help_open { draw_text_overlay(f, "? help", help_overlay_text()); @@ -1242,7 +1328,10 @@ impl App { fn chat_lines(&self) -> Vec { let Some(friend) = self.friends.get(self.selected) else { - return vec!["no friends yet".into(), "F3 paste an invite to add one".into()]; + return vec![ + "no friends yet".into(), + "F3 paste an invite to add one".into(), + ]; }; match self.node.list_messages(&friend.pubkey) { Ok(msgs) if msgs.is_empty() => vec!["no messages".into()], @@ -1342,6 +1431,7 @@ fn footer_hints(screen: &Screen) -> &'static str { BackupKind::Restore => "type RESTORE Esc cancel ? help", }, Screen::Passphrase { .. } => "Enter submit Esc cancel ? help", + Screen::Quit { .. } => "CLEAR or QUIT then Enter Esc cancel ? help", } } @@ -1428,6 +1518,17 @@ fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) { ); } +fn draw_quit(f: &mut Frame, typed: &str) { + let body = format!("{}\n\n{typed}", quit_screen_text()); + f.render_widget( + Paragraph::new(body) + .style(Style::default().fg(C_TEXT)) + .wrap(Wrap { trim: false }) + .block(themed_block("(o) quit")), + f.area(), + ); +} + fn draw_profile(f: &mut Frame, editor: &ProfileEditor) { let labels = ["name", "bio", "xmr"]; let values = [editor.display_name(), editor.bio(), editor.xmr_addr()]; diff --git a/tests/backup.rs b/tests/backup.rs index 3b83cc3..37590b6 100644 --- a/tests/backup.rs +++ b/tests/backup.rs @@ -57,7 +57,7 @@ fn garbage_file_fails() { #[test] fn restore_overwrites_self_keys_friends_stay() { let dir = tempfile::tempdir().expect("tempdir"); - let store = Store::open_at(dir.path()).expect("open"); + let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); store .upsert_friend(&[9u8; 32], "alice.onion", Some("alice")) .unwrap(); diff --git a/tests/pay.rs b/tests/pay.rs index 24fd6a4..1cc7ffa 100644 --- a/tests/pay.rs +++ b/tests/pay.rs @@ -8,7 +8,7 @@ use rand::rngs::OsRng; fn store() -> (tempfile::TempDir, Store) { let dir = tempfile::tempdir().expect("tempdir"); - let store = Store::open_at(dir.path()).expect("open"); + let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); (dir, store) } diff --git a/tests/profile.rs b/tests/profile.rs index efee510..e73c4a3 100644 --- a/tests/profile.rs +++ b/tests/profile.rs @@ -8,7 +8,7 @@ use rand::rngs::OsRng; fn store() -> (tempfile::TempDir, Store) { let dir = tempfile::tempdir().expect("tempdir"); - let store = Store::open_at(dir.path()).expect("open"); + let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); (dir, store) } diff --git a/tests/qr.rs b/tests/qr.rs index ec856ff..f026f0e 100644 --- a/tests/qr.rs +++ b/tests/qr.rs @@ -2,8 +2,8 @@ use std::sync::{Mutex, MutexGuard}; -use onionwire::qr; use onionwire::Store; +use onionwire::qr; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -18,6 +18,7 @@ impl TempHome { let guard = ENV_LOCK.lock().expect("env lock"); unsafe { std::env::set_var("ONIONWIRE_HOME", dir.path()); + std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test"); } Self { _dir: dir, @@ -30,6 +31,7 @@ impl Drop for TempHome { fn drop(&mut self) { unsafe { std::env::remove_var("ONIONWIRE_HOME"); + std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); } } } diff --git a/tests/rotate.rs b/tests/rotate.rs index 0e09f23..2794c3c 100644 --- a/tests/rotate.rs +++ b/tests/rotate.rs @@ -14,7 +14,7 @@ fn pk(tag: u8) -> [u8; 32] { fn store() -> (tempfile::TempDir, Store) { let dir = tempfile::tempdir().expect("tempdir"); - let store = Store::open_at(dir.path()).expect("open"); + let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); (dir, store) } diff --git a/tests/rotate_hs.rs b/tests/rotate_hs.rs index cbb7b8d..4039d7e 100644 --- a/tests/rotate_hs.rs +++ b/tests/rotate_hs.rs @@ -23,6 +23,9 @@ async fn rotate_changes_onion_not_identity_and_notifies_online_peer() { let carol_home = root.path().join("carol"); eprintln!("starting alice + bob…"); + unsafe { + std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test"); + } let (alice, bob) = tokio::join!( Node::start(alice_home.clone()), Node::start(bob_home.clone()) @@ -35,7 +38,7 @@ async fn rotate_changes_onion_not_identity_and_notifies_online_peer() { alice.add_friend_from_qr(&b_qr).expect("alice adds bob"); bob.add_friend_from_qr(&a_qr).expect("bob adds alice"); - let carol = Store::open_at(&carol_home).expect("carol store"); + let carol = Store::open_at_with_passphrase(&carol_home, "onionwire-test").expect("carol store"); let a_pk = alice.identity_pk(); carol .upsert_friend(&a_pk, &alice.onion(), Some("alice")) diff --git a/tests/store.rs b/tests/store.rs index 0ba924f..33f4a89 100644 --- a/tests/store.rs +++ b/tests/store.rs @@ -20,6 +20,7 @@ impl TempHome { let guard = ENV_LOCK.lock().expect("env lock"); unsafe { std::env::set_var("ONIONWIRE_HOME", dir.path()); + std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test"); } Self { dir, _guard: guard } } @@ -33,6 +34,7 @@ impl Drop for TempHome { fn drop(&mut self) { unsafe { std::env::remove_var("ONIONWIRE_HOME"); + std::env::remove_var("ONIONWIRE_STORE_PASSPHRASE"); } } } @@ -121,8 +123,8 @@ fn unknown_pubkey_inserts_new_row() { fn open_at_two_homes_are_independent() { let a = tempfile::tempdir().expect("a"); let b = tempfile::tempdir().expect("b"); - let sa = onionwire::Store::open_at(a.path()).expect("open a"); - let sb = onionwire::Store::open_at(b.path()).expect("open b"); + 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"); let ia = sa.self_identity().unwrap(); let ib = sb.self_identity().unwrap(); assert_ne!(ia.identity_pk, ib.identity_pk); @@ -167,7 +169,7 @@ fn friend_prekey_lookup() { #[test] fn open_uses_wal_journal() { let dir = tempfile::tempdir().expect("tempdir"); - let store = Store::open_at(dir.path()).expect("open"); + let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); assert_eq!(store.journal_mode().unwrap().to_ascii_lowercase(), "wal"); } @@ -175,5 +177,87 @@ fn open_uses_wal_journal() { 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(); - assert!(Store::open_at(dir.path()).is_err()); + 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"); } diff --git a/tests/two_node.rs b/tests/two_node.rs index 70c496b..2e883a8 100644 --- a/tests/two_node.rs +++ b/tests/two_node.rs @@ -21,6 +21,9 @@ async fn alice_sends_hello_wire_bob_sqlite_has_plaintext() { let bob_home = root.path().join("bob"); eprintln!("starting alice + bob nodes…"); + unsafe { + std::env::set_var("ONIONWIRE_STORE_PASSPHRASE", "onionwire-test"); + } let (alice, bob) = tokio::join!( Node::start(alice_home.clone()), Node::start(bob_home.clone()) diff --git a/tests/wipe.rs b/tests/wipe.rs index 2360deb..2cc25ab 100644 --- a/tests/wipe.rs +++ b/tests/wipe.rs @@ -1,7 +1,10 @@ //! M5: wipe messages (keep identity + friends); wipe-all is a new person. use onionwire::Store; -use onionwire::tui::{WipeDecision, WipeKind, WipePrompt, parse_slash, wipe_screen_text}; +use onionwire::tui::{ + QuitDecision, QuitPrompt, WipeDecision, WipeKind, WipePrompt, parse_slash, quit_screen_text, + wipe_screen_text, +}; fn pk(tag: u8) -> [u8; 32] { let mut k = [0u8; 32]; @@ -19,7 +22,7 @@ fn db_contains(home: &std::path::Path, needle: &[u8]) -> bool { #[test] fn wipe_clears_messages_keeps_self_and_friends() { let dir = tempfile::tempdir().expect("tempdir"); - let store = Store::open_at(dir.path()).expect("open"); + let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); let me = store.self_identity().unwrap(); store .upsert_friend(&pk(1), "a.onion", Some("alice")) @@ -51,7 +54,7 @@ fn wipe_clears_messages_keeps_self_and_friends() { fn wipe_all_removes_dir_so_next_open_is_new_identity() { let dir = tempfile::tempdir().expect("tempdir"); let home = dir.path().join("ow"); - let store = Store::open_at(&home).expect("open"); + let store = Store::open_at_with_passphrase(&home, "onionwire-test").expect("open"); let old_pk = store.self_identity().unwrap().identity_pk; store.upsert_friend(&pk(1), "a.onion", None).unwrap(); store.append_message(&pk(1), "out", b"gone").unwrap(); @@ -61,7 +64,7 @@ fn wipe_all_removes_dir_so_next_open_is_new_identity() { Store::wipe_all(&home).expect("wipe-all"); assert!(!home.exists(), "data dir gone"); - let store2 = Store::open_at(&home).expect("reopen"); + let store2 = Store::open_at_with_passphrase(&home, "onionwire-test").expect("reopen"); let new_pk = store2.self_identity().unwrap().identity_pk; assert_ne!(old_pk, new_pk, "new identity key = new person"); assert_eq!(store2.friend_count().unwrap(), 0); @@ -115,3 +118,39 @@ fn wipe_prompt_text_matches_spec() { assert!(a.contains("Type WIPEALL to confirm")); assert!(a.contains("Esc to cancel")); } + +#[test] +fn quit_requires_clear_or_quit_then_enter() { + let mut p = QuitPrompt::new(); + assert_eq!(p.on_esc(), QuitDecision::Cancel); + assert_eq!( + p.on_char('\n'), + QuitDecision::Pending, + "Enter alone must not quit" + ); + for c in "CLEA".chars() { + assert_eq!(p.on_char(c), QuitDecision::Pending); + } + assert_eq!(p.on_char('R'), QuitDecision::Pending, "CLEAR without Enter"); + assert_eq!(p.on_char('\n'), QuitDecision::ClearAndQuit); + + let mut q = QuitPrompt::new(); + for c in "QUIT".chars() { + assert_eq!(q.on_char(c), QuitDecision::Pending); + } + assert_eq!(q.on_char('\n'), QuitDecision::Quit); +} + +#[test] +fn quit_prompt_text_matches_spec() { + let t = quit_screen_text(); + assert!( + t.contains("message history can be cleared") + || t.contains("Message history can be cleared") + ); + assert!(t.contains("identity") && t.contains("friends")); + assert!(t.contains("not") && t.contains("/wipe-all")); + assert!(t.contains("CLEAR")); + assert!(t.contains("QUIT")); + assert!(t.contains("Esc")); +}