diff --git a/README.md b/README.md index 54559e1..e80dab3 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ If you set `ONIONWIRE_HOME`, delete that directory instead. ## Seized laptop -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. +Chat bodies in sqlite are ChaCha20-Poly1305 (`nonce || ciphertext` in `messages.plaintext`) with AAD bound to `friend_id`, `dir`, and row id, wrapped by a passphrase-derived Argon2id key. A disk grep of `onionwire.db` must not yield the message log. Empty-AAD v0.2 blobs are rewrapped once on unlock. Still plaintext on disk (unless you add OS/FDE): diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index e2442c1..8e86980 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -16,7 +16,7 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friend’s locator **only ## Message bodies are encrypted at rest; keys are not -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. +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. diff --git a/src/backup.rs b/src/backup.rs index 92f6713..b946476 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,7 +1,7 @@ //! Encrypted identity backup. Onion (locator) is not included. use argon2::{Algorithm, Argon2, Params, Version}; -use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use rand::RngCore; @@ -95,11 +95,21 @@ pub fn open(passphrase: &str, blob: &[u8]) -> Result { BackupKeys::from_bytes(&pt) } -pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result, String> { +pub(crate) fn aead_encrypt( + key: &[u8; 32], + plaintext: &[u8], + aad: &[u8], +) -> Result, String> { let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); let ct = cipher - .encrypt(&nonce, plaintext) + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad, + }, + ) .map_err(|_| "encrypt failed".to_string())?; let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); out.extend_from_slice(&nonce); @@ -107,13 +117,19 @@ pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result, Ok(out) } -pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8]) -> Result, String> { +pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8], aad: &[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..]) + .decrypt( + nonce, + Payload { + msg: &blob[NONCE_LEN..], + aad, + }, + ) .map_err(|_| "wrong passphrase or corrupt".to_string()) } diff --git a/src/store.rs b/src/store.rs index 891d5cb..adc1228 100644 --- a/src/store.rs +++ b/src/store.rs @@ -270,12 +270,13 @@ impl Store { match row { Some((salt, wrapped)) => { let wrap_key = crate::backup::kdf(passphrase, &salt)?; - let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped) + let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped, b"") .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); + self.rewrap_empty_aad_messages()?; Ok(()) } None => { @@ -284,7 +285,7 @@ impl Store { 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)?; + let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key, b"")?; self.conn.execute( "INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)", params![salt.as_slice(), wrapped], @@ -297,17 +298,59 @@ impl Store { } 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)?)))? + let mut stmt = self + .conn + .prepare("SELECT id, friend_id, dir, plaintext FROM messages")?; + let rows: Vec<(i64, i64, String, Vec)> = stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? .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)?; + for (id, friend_id, dir, plain) in rows { + let aad = message_aad(friend_id, &dir, id); + let blob = crate::backup::aead_encrypt(&self.msg_key, &plain, &aad)?; + 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(()) + } + + /// One-shot: empty-AAD v0.2 blobs → row-bound AAD. list_messages never falls back. + fn rewrap_empty_aad_messages(&self) -> Result<()> { + let mut stmt = self + .conn + .prepare("SELECT id, friend_id, dir, plaintext FROM messages")?; + let rows: Vec<(i64, i64, String, Vec)> = stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::>()?; + drop(stmt); + let mut updates = Vec::new(); + for (id, friend_id, dir, blob) in rows { + let aad = message_aad(friend_id, &dir, id); + if crate::backup::aead_decrypt(&self.msg_key, &blob, &aad).is_ok() { + continue; + } + if let Ok(pt) = crate::backup::aead_decrypt(&self.msg_key, &blob, b"") { + let new_blob = crate::backup::aead_encrypt(&self.msg_key, &pt, &aad)?; + updates.push((id, new_blob)); + } + } + if updates.is_empty() { + return Ok(()); + } + let tx = self.conn.unchecked_transaction()?; + for (id, blob) in updates { tx.execute( "UPDATE messages SET plaintext = ?1 WHERE id = ?2", params![blob, id], @@ -663,28 +706,42 @@ 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, blob, unix_now()], + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, x'', ?3)", + params![friend_id, dir, unix_now()], )?; + let row_id = tx.last_insert_rowid(); + let aad = message_aad(friend_id, dir, row_id); + let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext, &aad)?; + tx.execute( + "UPDATE messages SET plaintext = ?1 WHERE id = ?2", + params![blob, row_id], + )?; + tx.commit()?; Ok(()) } pub fn list_messages(&self, friend_pk: &[u8]) -> Result> { let mut stmt = self.conn.prepare( - "SELECT m.dir, m.plaintext FROM messages m + "SELECT m.id, m.friend_id, m.dir, m.plaintext FROM messages m JOIN friends f ON f.id = m.friend_id WHERE f.pubkey = ?1 ORDER BY m.id", )?; let rows = stmt.query_map(params![friend_pk], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)) + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Vec>(3)?, + )) })?; let mut out = Vec::new(); for row in rows { - let (dir, blob) = row?; - let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob) + let (id, friend_id, dir, blob) = row?; + let aad = message_aad(friend_id, &dir, id); + let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob, &aad) .map_err(|_| Error("message decrypt failed".into()))?; out.push(Message { dir, plaintext }); } @@ -775,6 +832,17 @@ fn mkdir_700(path: &Path) -> Result<()> { Ok(()) } +/// AAD: `owmsg1` || friend_id_le64 || dir || 0x00 || row_id_le64 +fn message_aad(friend_id: i64, dir: &str, row_id: i64) -> Vec { + let mut aad = Vec::with_capacity(6 + 8 + dir.len() + 1 + 8); + aad.extend_from_slice(b"owmsg1"); + aad.extend_from_slice(&friend_id.to_le_bytes()); + aad.extend_from_slice(dir.as_bytes()); + aad.push(0); + aad.extend_from_slice(&row_id.to_le_bytes()); + aad +} + fn unix_now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -790,6 +858,148 @@ fn fingerprint(pubkey: &[u8]) -> String { mod at_rest { use super::*; + #[test] + fn swapped_ciphertext_does_not_show_other_friends_body() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open_at_with_passphrase(dir.path(), "aad-pass").unwrap(); + let alice = [1u8; 32]; + let bob = [2u8; 32]; + store.upsert_friend(&alice, "a.onion", None).unwrap(); + store.upsert_friend(&bob, "b.onion", None).unwrap(); + store + .append_message(&alice, "out", b"secret-for-alice") + .unwrap(); + store + .append_message(&bob, "out", b"secret-for-bob") + .unwrap(); + + let blobs: Vec<(i64, Vec)> = { + let mut stmt = store + .conn + .prepare("SELECT id, plaintext FROM messages ORDER BY id") + .unwrap(); + stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>>() + .unwrap() + }; + assert_eq!(blobs.len(), 2); + store + .conn + .execute( + "UPDATE messages SET plaintext = ?1 WHERE id = ?2", + params![&blobs[1].1, blobs[0].0], + ) + .unwrap(); + store + .conn + .execute( + "UPDATE messages SET plaintext = ?1 WHERE id = ?2", + params![&blobs[0].1, blobs[1].0], + ) + .unwrap(); + + if let Ok(msgs) = store.list_messages(&alice) { + assert!( + msgs.iter() + .all(|m| m.plaintext.as_slice() != b"secret-for-bob"), + "alice saw bob's body after ciphertext swap" + ); + } + if let Ok(msgs) = store.list_messages(&bob) { + assert!( + msgs.iter() + .all(|m| m.plaintext.as_slice() != b"secret-for-alice"), + "bob saw alice's body after ciphertext swap" + ); + } + } + + #[test] + fn empty_aad_rows_rewrap_on_unlock_then_swap_fails() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap(); + let alice = [1u8; 32]; + let bob = [2u8; 32]; + store.upsert_friend(&alice, "a.onion", None).unwrap(); + store.upsert_friend(&bob, "b.onion", None).unwrap(); + store + .append_message(&alice, "out", b"secret-for-alice") + .unwrap(); + store + .append_message(&bob, "out", b"secret-for-bob") + .unwrap(); + let ids: Vec = { + let mut stmt = store + .conn + .prepare("SELECT id FROM messages ORDER BY id") + .unwrap(); + stmt.query_map([], |r| r.get(0)) + .unwrap() + .collect::>>() + .unwrap() + }; + let a_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-alice", b"").unwrap(); + let b_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-bob", b"").unwrap(); + store + .conn + .execute( + "UPDATE messages SET plaintext = ?1 WHERE id = ?2", + params![a_blob, ids[0]], + ) + .unwrap(); + store + .conn + .execute( + "UPDATE messages SET plaintext = ?1 WHERE id = ?2", + params![b_blob, ids[1]], + ) + .unwrap(); + drop(store); + + let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap(); + assert_eq!( + store.list_messages(&alice).unwrap()[0].plaintext, + b"secret-for-alice" + ); + assert_eq!( + store.list_messages(&bob).unwrap()[0].plaintext, + b"secret-for-bob" + ); + + let blobs: Vec<(i64, Vec)> = { + let mut stmt = store + .conn + .prepare("SELECT id, plaintext FROM messages ORDER BY id") + .unwrap(); + stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>>() + .unwrap() + }; + store + .conn + .execute( + "UPDATE messages SET plaintext = ?1 WHERE id = ?2", + params![&blobs[1].1, blobs[0].0], + ) + .unwrap(); + store + .conn + .execute( + "UPDATE messages SET plaintext = ?1 WHERE id = ?2", + params![&blobs[0].1, blobs[1].0], + ) + .unwrap(); + if let Ok(msgs) = store.list_messages(&alice) { + assert!( + msgs.iter() + .all(|m| m.plaintext.as_slice() != b"secret-for-bob"), + "alice saw bob's body after post-rewrap swap" + ); + } + } + #[test] fn first_unlock_reencrypts_legacy_plaintext_rows() { let dir = tempfile::tempdir().unwrap();