[verified] fix(store): bind chat AEAD to row AAD
Some checks failed
ci / test (pull_request) Failing after 2m59s

ChaCha20-Poly1305 at rest now MACs owmsg1||friend_id_le64||dir||0x00||row_id_le64.
Swapped ciphertext fails closed. Empty-AAD v0.2 blobs rewrap once on unlock;
list_messages never falls back to empty AAD.
This commit is contained in:
Sirius DevOps 2026-09-10 19:41:00 -04:00
parent bdea9578e4
commit f75c434db7
No known key found for this signature in database
4 changed files with 248 additions and 22 deletions

View file

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

View file

@ -16,7 +16,7 @@ A signed `loc` frame (`onion`, `ts`, `sig`) rewrites a friends 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.

View file

@ -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, String> {
BackupKeys::from_bytes(&pt)
}
pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>, String> {
pub(crate) fn aead_encrypt(
key: &[u8; 32],
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, 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<Vec<u8>,
Ok(out)
}
pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8]) -> Result<Vec<u8>, String> {
pub(crate) fn aead_decrypt(key: &[u8; 32], blob: &[u8], aad: &[u8]) -> Result<Vec<u8>, 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())
}

View file

@ -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<u8>)> = 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<u8>)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<std::result::Result<_, _>>()?;
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<u8>)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})?
.collect::<std::result::Result<_, _>>()?;
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<Vec<Message>> {
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<u8>>(1)?))
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Vec<u8>>(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<u8> {
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<u8>)> = {
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::<rusqlite::Result<Vec<_>>>()
.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<i64> = {
let mut stmt = store
.conn
.prepare("SELECT id FROM messages ORDER BY id")
.unwrap();
stmt.query_map([], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.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<u8>)> = {
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::<rusqlite::Result<Vec<_>>>()
.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();