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.
This commit is contained in:
Sirius DevOps 2026-09-10 02:49:42 -04:00
parent 07aa97711c
commit 5412e020bb
No known key found for this signature in database
7 changed files with 415 additions and 18 deletions

View file

@ -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 peers 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`

27
docs/THREAT_MODEL.md Normal file
View file

@ -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 OnionWires 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 friends 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.

View file

@ -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(())
}

View file

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

View file

@ -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<Friend> {

View file

@ -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<WipeKind> {
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<Node>, rt: tokio::runtime::Handle) -> Result<(), String> {
pub fn run(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<AppExit, String> {
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<String>,
status_note: Option<String>,
composer: String,
}
enum Screen {
@ -94,6 +186,7 @@ enum Screen {
Paste { buf: String, err: Option<String> },
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<AppExit, 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 {
@ -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<Option<AppExit>, 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<Option<AppExit>, 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}")

117
tests/wipe.rs Normal file
View file

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