Merge branch 'main' into wt/t_8b24e067
Some checks failed
ci / test (pull_request) Failing after 24s
Some checks failed
ci / test (pull_request) Failing after 24s
This commit is contained in:
commit
deee357772
4 changed files with 248 additions and 22 deletions
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
240
src/store.rs
240
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<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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue