fix(store): drop payments on /wipe; document non-forensic
All checks were successful
ci / test (pull_request) Successful in 3m1s
All checks were successful
ci / test (pull_request) Successful in 3m1s
/wipe now deletes payments as well as chat, checkpoints WAL, and enables sqlite secure_delete (flash still not crypto-shred).
This commit is contained in:
parent
bfd28fce6b
commit
45c105de8a
5 changed files with 70 additions and 12 deletions
|
|
@ -190,8 +190,8 @@ Treat the backup file like the sqlite db.
|
||||||
|
|
||||||
Composer (bottom of the roster screen):
|
Composer (bottom of the roster screen):
|
||||||
|
|
||||||
- `/wipe` — confirm by typing `WIPE`. Overwrites the message log and `VACUUM`s. Identity key and friends stay.
|
- `/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). Esc cancels. Nothing is wiped without confirm.
|
- `/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
|
## Uninstall
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ Still plaintext on disk (unless you add OS/FDE):
|
||||||
- your identity secret key (`self.identity_sk`)
|
- your identity secret key (`self.identity_sk`)
|
||||||
- friend public keys and current locators
|
- 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).
|
Threat model: [`docs/THREAT_MODEL.md`](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.
|
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
|
## Fail closed
|
||||||
|
|
||||||
|
|
|
||||||
12
src/store.rs
12
src/store.rs
|
|
@ -4,9 +4,9 @@ use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rand::RngCore;
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
|
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
@ -123,6 +123,9 @@ impl Store {
|
||||||
if !journal.eq_ignore_ascii_case("wal") {
|
if !journal.eq_ignore_ascii_case("wal") {
|
||||||
return Err(Error(format!("journal_mode WAL failed: {journal}")));
|
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(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
@ -748,14 +751,17 @@ impl Store {
|
||||||
Ok(out)
|
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<()> {
|
pub fn wipe_messages(&self) -> Result<()> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE messages SET plaintext = zeroblob(length(plaintext))",
|
"UPDATE messages SET plaintext = zeroblob(length(plaintext))",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
self.conn.execute("DELETE FROM messages", [])?;
|
self.conn.execute("DELETE FROM messages", [])?;
|
||||||
|
self.conn.execute("DELETE FROM payments", [])?;
|
||||||
self.conn.execute_batch("VACUUM")?;
|
self.conn.execute_batch("VACUUM")?;
|
||||||
|
self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -318,8 +318,9 @@ pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
||||||
pub fn wipe_screen_text(kind: WipeKind) -> &'static str {
|
pub fn wipe_screen_text(kind: WipeKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
WipeKind::Messages => {
|
WipeKind::Messages => {
|
||||||
"Wipe message log?\n\
|
"Wipe chat and payments history?\n\
|
||||||
Identity key and friends stay.\n\
|
Identity key and friends stay.\n\
|
||||||
|
Not a forensic erase.\n\
|
||||||
Type WIPE to confirm Esc to cancel"
|
Type WIPE to confirm Esc to cancel"
|
||||||
}
|
}
|
||||||
WipeKind::All => {
|
WipeKind::All => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
//! M5: wipe messages (keep identity + friends); wipe-all is a new person.
|
//! M5: wipe messages (keep identity + friends); wipe-all is a new person.
|
||||||
|
|
||||||
use onionwire::Store;
|
|
||||||
use onionwire::tui::{
|
use onionwire::tui::{
|
||||||
QuitDecision, QuitPrompt, WipeDecision, WipeKind, WipePrompt, parse_slash, quit_screen_text,
|
parse_slash, quit_screen_text, wipe_screen_text, QuitDecision, QuitPrompt, WipeDecision,
|
||||||
wipe_screen_text,
|
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] {
|
fn pk(tag: u8) -> [u8; 32] {
|
||||||
let mut k = [0u8; 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]
|
#[test]
|
||||||
fn wipe_all_removes_dir_so_next_open_is_new_identity() {
|
fn wipe_all_removes_dir_so_next_open_is_new_identity() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
@ -108,8 +158,9 @@ fn wipe_all_requires_typing_wipeall() {
|
||||||
#[test]
|
#[test]
|
||||||
fn wipe_prompt_text_matches_spec() {
|
fn wipe_prompt_text_matches_spec() {
|
||||||
let m = wipe_screen_text(WipeKind::Messages);
|
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("Identity key and friends stay."));
|
||||||
|
assert!(m.contains("Not a forensic erase."));
|
||||||
assert!(m.contains("Type WIPE to confirm"));
|
assert!(m.contains("Type WIPE to confirm"));
|
||||||
assert!(m.contains("Esc to cancel"));
|
assert!(m.contains("Esc to cancel"));
|
||||||
let a = wipe_screen_text(WipeKind::All);
|
let a = wipe_screen_text(WipeKind::All);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue