From 5412e020bb6960f30aa13fca606f01daf76807a7 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 02:49:42 -0400 Subject: [PATCH] Add /wipe, /wipe-all, and threat model docs. /wipe overwrites the message log and VACUUMs; identity and friends stay. /wipe-all deletes the data dir after confirm so the next open is a new person. Neither runs without a typed confirm screen. --- README.md | 63 +++++++++++++-- docs/THREAT_MODEL.md | 27 +++++++ src/main.rs | 13 ++- src/node.rs | 8 ++ src/store.rs | 19 +++++ src/tui.rs | 186 +++++++++++++++++++++++++++++++++++++++++-- tests/wipe.rs | 117 +++++++++++++++++++++++++++ 7 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 docs/THREAT_MODEL.md create mode 100644 tests/wipe.rs diff --git a/README.md b/README.md index 57b358a..18dc155 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,63 @@ # OnionWire -Lean Tor messenger: one Rust process (ratatui + E2EE + sqlite + in-process Arti). -Two people scan a QR, then chat. No hosted server. +Lean Tor messenger: one Rust process (ratatui + Noise IK + sqlite + in-process Arti). +Two people scan a QR, then chat. No hosted server. Identity is a public key; the onion is only a locator. -## Locked model +## First run -- **Identity = ed25519 public key.** Friends are `UNIQUE(pubkey)`. -- **Onion = locator only.** It can change. Never treat `.onion` as the roster key. -- **F4 rotates the onion** (confirm by typing `ROTATE`). Same identity key. Offline friends need a QR rescan. -- **No XMPP, no Prosody, no s2s, no C-tor binary, no DHT, no outbox in v1.** +``` +cargo run --release +``` + +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. + +Arti bootstraps Tor and publishes a v3 onion service. Onion services in Arti are still **experimental**. OnionWire fails closed if the HS cannot come up — it does not fall back to a `tor` binary or C-tor. + +## Friends are keys + +A friend is an ed25519 pubkey (`UNIQUE(pubkey)`). The `.onion` on that row is only where they are reachable right now. Re-scanning the same `k` updates the locator; it never creates a second person. + +## F2 QR + +`F2` shows a terminal QR and the payload: + +`onionwire:v1:k=…:o=…:spk=…:sig=…` + +`F3` pastes a payload. Unknown `k` asks for approval. Same `k` already in the roster updates `onion` only. + +## F4 rotate onion + +`F4` is a locator change, not a new identity. Confirm by typing `ROTATE` (Enter alone does nothing). + +- Your identity fingerprint stays the same. +- A new onion is published; the old one is hard-cut (no dual-host grace). +- Online friends get a signed `loc` frame. +- Offline friends cannot find you until they rescan the new QR. There is no directory. + +## Fail closed + +If a peer’s onion is down, send fails. v1 has no outbox, no retry queue, no DHT, no name server. + +## Wipe + +Composer (bottom of the roster screen): + +- `/wipe` — confirm by typing `WIPE`. Overwrites the message log and `VACUUM`s. Identity key and friends stay. +- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Esc cancels. Nothing is wiped without confirm. + +## Seized laptop + +v1 stores **plaintext** on disk: + +- 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. + +## Not in v1 + +Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher. Plan of record: `/home/lancelot/.hermes/plans/2026-09-10_004409-onionwire-lean.md` diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..26285f8 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,27 @@ +# OnionWire threat model (v1) + +OnionWire is a two-party chat over Tor onion services. There is no chat server. + +## Tor relays are not a chat server + +Each install hosts its own v3 onion and dials friends’ onions through in-process Arti. Relays move cells; they never see plaintext, never store messages, and never hold a roster. Compromising a relay is not compromising OnionWire’s message store. The store is the sqlite file on the endpoint. + +## Location updates are not a missing-person finder + +A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s locator **only if** that friend is already in the roster, the signature verifies against the pinned pubkey, and `ts` is newer. There is no directory, DHT, or introduction server. If you rotated while they were offline, they cannot find you until they rescan your QR. OnionWire will not hunt for a missing person. + +## Rotating the onion does not revoke a friend + +`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 QR/rescan 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 + +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. + +## Fail closed + +Peer onion down → send fails. Fingerprint mismatch vs the pinned key → hard fail, no send. Arti HS experimental: if it cannot publish, OnionWire stops; it does not fall back to C-tor. + +## Out of v1 + +Prosody, XMPP, s2s, MAM, carbons, outbox, multi-device, DHT / name server, sqlcipher. diff --git a/src/main.rs b/src/main.rs index 3469113..5bb4f7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use onionwire::tui::AppExit; + #[tokio::main] async fn main() { if let Err(e) = boot().await { @@ -9,9 +11,14 @@ async fn main() { async fn boot() -> Result<(), String> { let home = onionwire::Store::home_dir().map_err(|e| e.to_string())?; eprintln!("onionwire: bootstrapping Arti…"); - let node = onionwire::node::Node::start(home).await?; + let node = onionwire::node::Node::start(home.clone()).await?; let handle = tokio::runtime::Handle::current(); - tokio::task::spawn_blocking(move || onionwire::tui::run(node, handle)) + let exit = tokio::task::spawn_blocking(move || onionwire::tui::run(node, handle)) .await - .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())??; + if exit == AppExit::WipeAll { + onionwire::Store::wipe_all(&home).map_err(|e| e.to_string())?; + eprintln!("onionwire: all local data wiped"); + } + Ok(()) } diff --git a/src/node.rs b/src/node.rs index 2d24bf1..5f2b29f 100644 --- a/src/node.rs +++ b/src/node.rs @@ -141,6 +141,14 @@ impl Node { .map_err(|e| e.to_string()) } + pub fn wipe_messages(&self) -> Result<(), String> { + self.store + .lock() + .map_err(|e| e.to_string())? + .wipe_messages() + .map_err(|e| e.to_string()) + } + pub async fn connect_onion(&self, onion: &str) -> Result<(), String> { tokio::time::timeout( Duration::from_secs(20), diff --git a/src/store.rs b/src/store.rs index 75e13cb..09dc832 100644 --- a/src/store.rs +++ b/src/store.rs @@ -364,6 +364,25 @@ impl Store { } Ok(out) } + + /// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay. + pub fn wipe_messages(&self) -> Result<()> { + self.conn.execute( + "UPDATE messages SET plaintext = zeroblob(length(plaintext))", + [], + )?; + self.conn.execute("DELETE FROM messages", [])?; + self.conn.execute_batch("VACUUM")?; + Ok(()) + } + + /// Delete the data dir. Next `open_at` is a new identity. Caller must drop Store first. + pub fn wipe_all(home: &Path) -> Result<()> { + if home.exists() { + fs::remove_dir_all(home)?; + } + Ok(()) + } } fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { diff --git a/src/tui.rs b/src/tui.rs index 3b2026b..b9ab8fd 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -23,6 +23,97 @@ Offline friends CANNOT find you until they rescan your QR.\n\ Type ROTATE to confirm Esc to cancel" } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WipeKind { + Messages, + All, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WipeDecision { + Pending, + Confirm, + Cancel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppExit { + Quit, + WipeAll, +} + +pub fn parse_slash(raw: &str) -> Option { + match raw.trim() { + "/wipe" => Some(WipeKind::Messages), + "/wipe-all" => Some(WipeKind::All), + _ => None, + } +} + +pub fn wipe_screen_text(kind: WipeKind) -> &'static str { + match kind { + WipeKind::Messages => { + "Wipe message log?\n\ +Identity key and friends stay.\n\ +Type WIPE to confirm Esc to cancel" + } + WipeKind::All => { + "Wipe ALL local data?\n\ +This deletes your identity key. You become a new person.\n\ +Type WIPEALL to confirm Esc to cancel" + } + } +} + +#[derive(Debug)] +pub struct WipePrompt { + expected: &'static str, + buf: String, +} + +impl WipePrompt { + pub fn messages() -> Self { + Self { + expected: "WIPE", + buf: String::new(), + } + } + + pub fn all() -> Self { + Self { + expected: "WIPEALL", + buf: String::new(), + } + } + + pub fn on_esc(&self) -> WipeDecision { + WipeDecision::Cancel + } + + pub fn on_char(&mut self, c: char) -> WipeDecision { + if c == '\n' { + return WipeDecision::Pending; + } + if c == '\u{8}' { + self.buf.pop(); + return WipeDecision::Pending; + } + if !c.is_ascii_alphabetic() { + return WipeDecision::Pending; + } + self.buf.push(c); + if self.buf == self.expected { + WipeDecision::Confirm + } else { + WipeDecision::Pending + } + } + + pub fn typed(&self) -> &str { + &self.buf + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RotateDecision { Pending, @@ -68,7 +159,7 @@ impl RotatePrompt { } } -pub fn run(node: Arc, rt: tokio::runtime::Handle) -> Result<(), String> { +pub fn run(node: Arc, rt: tokio::runtime::Handle) -> Result { let mut app = App::new(node, rt)?; let mut terminal = ratatui::init(); let result = app.run(&mut terminal); @@ -86,6 +177,7 @@ struct App { me_onion: String, alert: Option, status_note: Option, + composer: String, } enum Screen { @@ -94,6 +186,7 @@ enum Screen { Paste { buf: String, err: Option }, Approve { payload: QrPayload }, Rotate { prompt: RotatePrompt }, + Wipe { kind: WipeKind, prompt: WipePrompt }, } impl App { @@ -109,10 +202,11 @@ impl App { selected: 0, alert: None, status_note: None, + composer: String::new(), }) } - fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<(), String> { + fn run(&mut self, terminal: &mut DefaultTerminal) -> Result { loop { terminal.draw(|f| self.draw(f)).map_err(|e| e.to_string())?; let Event::Key(key) = event::read().map_err(io_err)? else { @@ -122,13 +216,16 @@ impl App { continue; } if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) { - return Ok(()); + return Ok(AppExit::Quit); + } + if let Some(exit) = self.handle_key(key)? { + return Ok(exit); } - self.handle_key(key)?; } } - fn handle_key(&mut self, key: KeyEvent) -> Result<(), String> { + fn handle_key(&mut self, key: KeyEvent) -> Result, String> { + let mut wipe_confirm = None; match &mut self.screen { Screen::Main => match key.code { KeyCode::Up => { @@ -151,6 +248,23 @@ impl App { prompt: RotatePrompt::new(), }; } + KeyCode::Esc => self.composer.clear(), + KeyCode::Backspace => { + self.composer.pop(); + } + KeyCode::Enter => { + if let Some(kind) = parse_slash(&self.composer) { + self.composer.clear(); + let prompt = match kind { + WipeKind::Messages => WipePrompt::messages(), + WipeKind::All => WipePrompt::all(), + }; + self.screen = Screen::Wipe { kind, prompt }; + } + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.composer.push(c); + } _ => {} }, Screen::Share { .. } => { @@ -194,8 +308,38 @@ impl App { } _ => {} }, + Screen::Wipe { kind, prompt } => { + let kind = *kind; + match key.code { + KeyCode::Esc => self.screen = Screen::Main, + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if prompt.on_char(c) == WipeDecision::Confirm { + wipe_confirm = Some(kind); + } + } + KeyCode::Backspace => { + prompt.on_char('\u{8}'); + } + _ => {} + } + } + } + if let Some(kind) = wipe_confirm { + return self.confirm_wipe(kind); + } + Ok(None) + } + + fn confirm_wipe(&mut self, kind: WipeKind) -> Result, String> { + match kind { + WipeKind::Messages => { + self.node.wipe_messages()?; + self.status_note = Some("wiped messages".into()); + self.screen = Screen::Main; + Ok(None) + } + WipeKind::All => Ok(Some(AppExit::WipeAll)), } - Ok(()) } fn confirm_rotate(&mut self) -> Result<(), String> { @@ -268,12 +412,18 @@ impl App { Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()), Screen::Approve { payload } => draw_approve(f, payload), Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()), + Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()), } } fn draw_main(&self, f: &mut Frame) { let area = f.area(); - let cols = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area); + let cols = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); let body = if self.alert.is_some() { Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(cols[0]) } else { @@ -329,7 +479,13 @@ impl App { truncate_fp(&self.me_fp), onion ); - f.render_widget(Paragraph::new(status), cols[1]); + let cmd = if self.composer.is_empty() { + "/wipe /wipe-all".to_string() + } else { + self.composer.clone() + }; + f.render_widget(Paragraph::new(cmd), cols[1]); + f.render_widget(Paragraph::new(status), cols[2]); } } @@ -377,6 +533,20 @@ fn draw_rotate(f: &mut Frame, typed: &str) { ); } +fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) { + let title = match kind { + WipeKind::Messages => "/wipe", + WipeKind::All => "/wipe-all", + }; + let body = format!("{}\n\n{typed}", wipe_screen_text(kind)); + f.render_widget( + Paragraph::new(body) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title(title)), + f.area(), + ); +} + fn truncate_fp(fp: &str) -> String { let s: String = fp.chars().take(8).collect(); format!("{s}…") diff --git a/tests/wipe.rs b/tests/wipe.rs new file mode 100644 index 0000000..2360deb --- /dev/null +++ b/tests/wipe.rs @@ -0,0 +1,117 @@ +//! 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}; + +fn pk(tag: u8) -> [u8; 32] { + let mut k = [0u8; 32]; + k[0] = tag; + k +} + +fn db_contains(home: &std::path::Path, needle: &[u8]) -> bool { + let Ok(bytes) = std::fs::read(home.join("onionwire.db")) else { + return false; + }; + bytes.windows(needle.len()).any(|w| w == needle) +} + +#[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 me = store.self_identity().unwrap(); + store + .upsert_friend(&pk(1), "a.onion", Some("alice")) + .unwrap(); + store + .append_message(&pk(1), "out", b"secret-log-line-xyz") + .unwrap(); + store.append_message(&pk(1), "in", b"reply").unwrap(); + assert_eq!(store.list_messages(&pk(1)).unwrap().len(), 2); + + store.wipe_messages().expect("wipe"); + + assert!(store.list_messages(&pk(1)).unwrap().is_empty()); + assert_eq!(store.friend_count().unwrap(), 1); + let f = store.get_friend(&pk(1)).unwrap().expect("friend"); + assert_eq!(f.petname.as_deref(), Some("alice")); + assert_eq!(f.onion, "a.onion"); + let me2 = store.self_identity().unwrap(); + assert_eq!(me.identity_pk, me2.identity_pk); + assert_eq!(me.identity_sk, me2.identity_sk); + drop(store); + assert!( + !db_contains(dir.path(), b"secret-log-line-xyz"), + "wipe must overwrite plaintext before VACUUM" + ); +} + +#[test] +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 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(); + drop(store); + assert!(home.join("onionwire.db").is_file()); + + Store::wipe_all(&home).expect("wipe-all"); + assert!(!home.exists(), "data dir gone"); + + let store2 = Store::open_at(&home).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); +} + +#[test] +fn slash_wipe_commands() { + assert_eq!(parse_slash("/wipe"), Some(WipeKind::Messages)); + assert_eq!(parse_slash(" /wipe "), Some(WipeKind::Messages)); + assert_eq!(parse_slash("/wipe-all"), Some(WipeKind::All)); + assert_eq!(parse_slash("/wipe-all\n"), Some(WipeKind::All)); + assert_eq!(parse_slash("wipe"), None); + assert_eq!(parse_slash("/rotate"), None); +} + +#[test] +fn wipe_requires_typing_not_enter() { + let mut p = WipePrompt::messages(); + assert_eq!(p.on_esc(), WipeDecision::Cancel); + assert_eq!( + p.on_char('\n'), + WipeDecision::Pending, + "Enter must not wipe" + ); + for c in "WIP".chars() { + assert_eq!(p.on_char(c), WipeDecision::Pending); + } + assert_eq!(p.on_char('E'), WipeDecision::Confirm); +} + +#[test] +fn wipe_all_requires_typing_wipeall() { + let mut p = WipePrompt::all(); + assert_eq!(p.on_char('\n'), WipeDecision::Pending); + for c in "WIPEAL".chars() { + assert_eq!(p.on_char(c), WipeDecision::Pending); + } + assert_eq!(p.on_char('L'), WipeDecision::Confirm); +} + +#[test] +fn wipe_prompt_text_matches_spec() { + let m = wipe_screen_text(WipeKind::Messages); + assert!(m.contains("Wipe message log?")); + assert!(m.contains("Identity key and friends stay.")); + assert!(m.contains("Type WIPE to confirm")); + assert!(m.contains("Esc to cancel")); + let a = wipe_screen_text(WipeKind::All); + assert!(a.contains("Wipe ALL local data?")); + assert!(a.contains("This deletes your identity key. You become a new person.")); + assert!(a.contains("Type WIPEALL to confirm")); + assert!(a.contains("Esc to cancel")); +}