diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0eff673..e4690a9 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -8,12 +8,18 @@ on: pull_request: workflow_dispatch: +# One cargo job on the Pi at a time. Overlapping PR+main runs shared the +# container name `onionwire-ci` (docker Conflict) and OOM-killed with 137. +concurrency: + group: onionwire-ci-pi + cancel-in-progress: false + env: CARGO_TERM_COLOR: never RUST_IMAGE: rust:1.91-bookworm CARGO_REGISTRY_VOLUME: onionwire-cargo-registry CARGO_TARGET_VOLUME: onionwire-target-ci - BUILD_CONTAINER: onionwire-ci + BUILD_CONTAINER: onionwire-ci-${{ github.run_id }} jobs: test: diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index ac4d184..881c867 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -22,7 +22,7 @@ env: RUST_IMAGE: rust:1.91-bookworm CARGO_REGISTRY_VOLUME: onionwire-cargo-registry CARGO_TARGET_VOLUME: onionwire-target-aarch64 - BUILD_CONTAINER: onionwire-release-build + BUILD_CONTAINER: onionwire-release-build-${{ github.run_id }} jobs: aarch64: diff --git a/README.md b/README.md index e80dab3..f9097d9 100644 --- a/README.md +++ b/README.md @@ -190,8 +190,8 @@ Treat the backup file like the sqlite db. 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. +- `/wipe` — confirm by typing `WIPE`. Chat and payments history gone (overwrite message bodies, drop `payments`, `VACUUM`, WAL checkpoint). Identity key and friends stay. Not a forensic erase (SSD wear-leveling). `/wipe-all` is the identity burn. +- `/wipe-all` — confirm by typing `WIPEALL`. Deletes the data dir. Next start is a **new person** (new identity key). Same disk caveat. Esc cancels. Nothing is wiped without confirm. ## Uninstall @@ -215,7 +215,7 @@ Still plaintext on disk (unless you add OS/FDE): - your identity secret key (`self.identity_sk`) - friend public keys and current locators -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. +The message key is **not** wrapped with `identity_sk` (that key is in the same file). sqlcipher is out of v1. `/wipe` deletes chat and payments history; it is not a forensic erase. Roster and identity stay. `/wipe-all` deletes the data dir (new identity). 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 8e86980..c960187 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -18,7 +18,7 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s locator **only Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in the `messages.plaintext` column) with AAD `owmsg1 || friend_id_le64 || dir || 0x00 || row_id_le64`. Swapping ciphertext between rows fails closed. 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. Empty-AAD v0.2 blobs are rewrapped once on unlock; `list_messages` never falls back to empty AAD. -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. +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` deletes chat and payments history (overwrite message bodies, `VACUUM`, WAL checkpoint); roster and identity stay. It is not a forensic erase — SSD wear-leveling can keep copies. `/wipe-all` deletes the data dir (new identity); same disk caveat. ## Fail closed diff --git a/src/store.rs b/src/store.rs index adc1228..d443d89 100644 --- a/src/store.rs +++ b/src/store.rs @@ -4,9 +4,9 @@ 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 rand::RngCore; +use rusqlite::{params, Connection, OptionalExtension}; use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; pub type Result = std::result::Result; @@ -123,6 +123,9 @@ impl Store { if !journal.eq_ignore_ascii_case("wal") { return Err(Error(format!("journal_mode WAL failed: {journal}"))); } + // Overwrite freed pages on DELETE. Flash wear-leveling can still keep copies; + // this is not a forensic / SSD crypto-shred. + conn.pragma_update(None, "secure_delete", "ON")?; conn.execute_batch( " PRAGMA foreign_keys = ON; @@ -748,14 +751,17 @@ impl Store { Ok(out) } - /// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay. + /// Drop chat + payments history. Identity + friends stay. + /// Not a forensic erase: SSD wear-leveling can keep copies. 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("DELETE FROM payments", [])?; self.conn.execute_batch("VACUUM")?; + self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?; Ok(()) } diff --git a/src/tui.rs b/src/tui.rs index 8efd685..7d5c00f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -318,8 +318,9 @@ pub fn parse_slash(raw: &str) -> Option { pub fn wipe_screen_text(kind: WipeKind) -> &'static str { match kind { WipeKind::Messages => { - "Wipe message log?\n\ + "Wipe chat and payments history?\n\ Identity key and friends stay.\n\ +Not a forensic erase.\n\ Type WIPE to confirm Esc to cancel" } WipeKind::All => { diff --git a/tests/wallet.rs b/tests/wallet.rs index 2747e0f..9fa0df6 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -17,7 +17,9 @@ fn row(txid: &str, amount: &str, address: &str) -> TransferRow { } fn xmr_addr() -> String { - format!("8{}", "B".repeat(94)) + // Same documented mainnet standard as tests/pay.rs — F4 checksums this. + "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge" + .to_string() } fn json_rpc_ok(result: &str) -> String { diff --git a/tests/wipe.rs b/tests/wipe.rs index 2cc25ab..61245cf 100644 --- a/tests/wipe.rs +++ b/tests/wipe.rs @@ -1,10 +1,13 @@ //! M5: wipe messages (keep identity + friends); wipe-all is a new person. -use onionwire::Store; use onionwire::tui::{ - QuitDecision, QuitPrompt, WipeDecision, WipeKind, WipePrompt, parse_slash, quit_screen_text, - wipe_screen_text, + parse_slash, quit_screen_text, wipe_screen_text, QuitDecision, QuitPrompt, WipeDecision, + WipeKind, WipePrompt, }; +use onionwire::{PaymentWrite, Store}; + +// Official mainnet standard from Monero docs (same fixture as tests/pay.rs). +const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"; fn pk(tag: u8) -> [u8; 32] { let mut k = [0u8; 32]; @@ -50,6 +53,53 @@ fn wipe_clears_messages_keeps_self_and_friends() { ); } +#[test] +fn wipe_clears_payments_keeps_self_and_friends() { + let dir = tempfile::tempdir().expect("tempdir"); + 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")) + .unwrap(); + store + .append_message(&pk(1), "out", b"secret-log-line-xyz") + .unwrap(); + store + .insert_payment( + &pk(1), + PaymentWrite { + dir: "out", + kind: "receipt", + amount_atomic: "1000000000000", + address: MAINNET_STD, + memo: "counterparty-memo-xyz", + txid: Some("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"), + verified: true, + }, + ) + .unwrap(); + assert_eq!(store.list_messages(&pk(1)).unwrap().len(), 1); + assert_eq!(store.list_payments(&pk(1)).unwrap().len(), 1); + + store.wipe_messages().expect("wipe"); + + assert!(store.list_messages(&pk(1)).unwrap().is_empty()); + assert!( + store.list_payments(&pk(1)).unwrap().is_empty(), + "wipe must drop payments, not only chat" + ); + 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")); + let me2 = store.self_identity().unwrap(); + assert_eq!(me.identity_pk, me2.identity_pk); + drop(store); + assert!( + !db_contains(dir.path(), b"counterparty-memo-xyz"), + "wipe must not leave payment memo in the db file" + ); +} + #[test] fn wipe_all_removes_dir_so_next_open_is_new_identity() { let dir = tempfile::tempdir().expect("tempdir"); @@ -108,8 +158,9 @@ fn wipe_all_requires_typing_wipeall() { #[test] fn wipe_prompt_text_matches_spec() { let m = wipe_screen_text(WipeKind::Messages); - assert!(m.contains("Wipe message log?")); + assert!(m.contains("Wipe chat and payments history?")); assert!(m.contains("Identity key and friends stay.")); + assert!(m.contains("Not a forensic erase.")); assert!(m.contains("Type WIPE to confirm")); assert!(m.contains("Esc to cancel")); let a = wipe_screen_text(WipeKind::All);