fix(qr): bind invite sig fields; cap decode #6

Merged
sirius merged 2 commits from wt/t_8b24e067 into main 2026-09-10 23:52:13 +00:00
4 changed files with 248 additions and 22 deletions
Showing only changes of commit deee357772 - Show all commits

View file

@ -208,7 +208,7 @@ If you set `ONIONWIRE_HOME`, delete that directory instead.
## Seized laptop ## 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): 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 ## 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. 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. //! Encrypted identity backup. Onion (locator) is not included.
use argon2::{Algorithm, Argon2, Params, Version}; 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 chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore; use rand::RngCore;
@ -95,11 +95,21 @@ pub fn open(passphrase: &str, blob: &[u8]) -> Result<BackupKeys, String> {
BackupKeys::from_bytes(&pt) 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 cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ct = cipher let ct = cipher
.encrypt(&nonce, plaintext) .encrypt(
&nonce,
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| "encrypt failed".to_string())?; .map_err(|_| "encrypt failed".to_string())?;
let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
out.extend_from_slice(&nonce); out.extend_from_slice(&nonce);
@ -107,13 +117,19 @@ pub(crate) fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>,
Ok(out) 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 { if blob.len() < NONCE_LEN + 16 {
return Err("ciphertext length".into()); return Err("ciphertext length".into());
} }
let nonce = Nonce::from_slice(&blob[..NONCE_LEN]); let nonce = Nonce::from_slice(&blob[..NONCE_LEN]);
let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher cipher
.decrypt(nonce, &blob[NONCE_LEN..]) .decrypt(
nonce,
Payload {
msg: &blob[NONCE_LEN..],
aad,
},
)
.map_err(|_| "wrong passphrase or corrupt".to_string()) .map_err(|_| "wrong passphrase or corrupt".to_string())
} }

View file

@ -270,12 +270,13 @@ impl Store {
match row { match row {
Some((salt, wrapped)) => { Some((salt, wrapped)) => {
let wrap_key = crate::backup::kdf(passphrase, &salt)?; 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()))?; .map_err(|_| Error("wrong passphrase".into()))?;
if raw.len() != 32 { if raw.len() != 32 {
return Err(Error("wrapped message key length".into())); return Err(Error("wrapped message key length".into()));
} }
self.msg_key.copy_from_slice(&raw); self.msg_key.copy_from_slice(&raw);
self.rewrap_empty_aad_messages()?;
Ok(()) Ok(())
} }
None => { None => {
@ -284,7 +285,7 @@ impl Store {
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt); OsRng.fill_bytes(&mut salt);
let wrap_key = crate::backup::kdf(passphrase, &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( self.conn.execute(
"INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)", "INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)",
params![salt.as_slice(), wrapped], params![salt.as_slice(), wrapped],
@ -297,17 +298,59 @@ impl Store {
} }
fn reencrypt_legacy_messages(&self) -> Result<()> { fn reencrypt_legacy_messages(&self) -> Result<()> {
let mut stmt = self.conn.prepare("SELECT id, plaintext FROM messages")?; let mut stmt = self
let rows: Vec<(i64, Vec<u8>)> = stmt .conn
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .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<_, _>>()?; .collect::<std::result::Result<_, _>>()?;
drop(stmt); drop(stmt);
if rows.is_empty() { if rows.is_empty() {
return Ok(()); return Ok(());
} }
let tx = self.conn.unchecked_transaction()?; let tx = self.conn.unchecked_transaction()?;
for (id, plain) in rows { for (id, friend_id, dir, plain) in rows {
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain)?; 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( tx.execute(
"UPDATE messages SET plaintext = ?1 WHERE id = ?2", "UPDATE messages SET plaintext = ?1 WHERE id = ?2",
params![blob, id], params![blob, id],
@ -663,28 +706,42 @@ impl Store {
|row| row.get(0), |row| row.get(0),
) )
.map_err(|_| Error("friend not found".into()))?; .map_err(|_| Error("friend not found".into()))?;
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext)?; let tx = self.conn.unchecked_transaction()?;
self.conn.execute( tx.execute(
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, x'', ?3)",
params![friend_id, dir, blob, unix_now()], 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(()) Ok(())
} }
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> { pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> {
let mut stmt = self.conn.prepare( 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 JOIN friends f ON f.id = m.friend_id
WHERE f.pubkey = ?1 WHERE f.pubkey = ?1
ORDER BY m.id", ORDER BY m.id",
)?; )?;
let rows = stmt.query_map(params![friend_pk], |row| { 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(); let mut out = Vec::new();
for row in rows { for row in rows {
let (dir, blob) = row?; let (id, friend_id, dir, blob) = row?;
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob) 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()))?; .map_err(|_| Error("message decrypt failed".into()))?;
out.push(Message { dir, plaintext }); out.push(Message { dir, plaintext });
} }
@ -775,6 +832,17 @@ fn mkdir_700(path: &Path) -> Result<()> {
Ok(()) 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 { fn unix_now() -> i64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -790,6 +858,148 @@ fn fingerprint(pubkey: &[u8]) -> String {
mod at_rest { mod at_rest {
use super::*; 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] #[test]
fn first_unlock_reencrypts_legacy_plaintext_rows() { fn first_unlock_reencrypts_legacy_plaintext_rows() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();