User: chat history (messages + mesh/Tor contacts) must persist and be secure/encrypted per best practice. Root cause of the .198 loss was the B17 mount race writing empty stores over real data (B17 already fixes the trigger); this hardens storage so it can never silently lose or expose data: - storage_crypto: shared at-rest envelope mirroring credentials::store — key = SHA-256(domain ‖ node identity key) (seed-derived, per-store domain separation), ChaCha20-Poly1305 AEAD with a random 96-bit nonce, tamper-evident. Transparent migration of legacy plaintext files. Unit-tested (round-trip, wrong-key/tamper rejection, plaintext detection). - messages.json: encrypted at rest + ATOMIC write (temp+rename) so a crash/ reboot mid-write cannot corrupt history; decrypt-with-migration on load; a failed decrypt never overwrites the on-disk data. - mesh contacts (alias/notes/pinned/blocked): were ONLY in memory and lost on every restart — now persisted to mesh-contacts.json (encrypted, atomic), loaded on MeshState startup, saved after contacts-save/contacts-block. Explicit clear (mesh.clear-all) still wipes everything, as intended. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
4.2 KiB
Rust
109 lines
4.2 KiB
Rust
//! At-rest encryption for local state stores (chat messages, mesh contacts).
|
|
//!
|
|
//! Best-practice envelope, matching `credentials::store`:
|
|
//! - **Key**: SHA-256(domain-separator ‖ node identity key). The node key is
|
|
//! seed-derived and never leaves the device, so each store is bound to this
|
|
//! node's identity — a stolen disk image is unreadable without it, and the
|
|
//! per-domain separator means one store's key can't open another.
|
|
//! - **Cipher**: ChaCha20-Poly1305 AEAD with a fresh random 96-bit nonce per
|
|
//! write (`nonce ‖ ciphertext` on disk). The Poly1305 tag makes it
|
|
//! tamper-evident — any on-disk modification fails to open.
|
|
//! - **Migration**: legacy plaintext JSON is detected and read transparently,
|
|
//! then re-written encrypted on the next save. No data is stranded.
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::path::Path;
|
|
|
|
/// Domain separators — one per store so keys never overlap.
|
|
pub const DOMAIN_MESSAGES: &[u8] = b"archipelago-message-store-v1";
|
|
pub const DOMAIN_MESH_CONTACTS: &[u8] = b"archipelago-mesh-contacts-v1";
|
|
|
|
/// Derive a 32-byte key bound to this node's identity for a given store domain.
|
|
pub async fn derive_key(data_dir: &Path, domain: &[u8]) -> Result<[u8; 32]> {
|
|
let node_key_path = data_dir.join("identity").join("node_key");
|
|
let key_bytes = tokio::fs::read(&node_key_path)
|
|
.await
|
|
.context("reading node key for at-rest encryption")?;
|
|
use sha2::{Digest, Sha256};
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(domain);
|
|
hasher.update(&key_bytes);
|
|
let mut key = [0u8; 32];
|
|
key.copy_from_slice(&hasher.finalize());
|
|
Ok(key)
|
|
}
|
|
|
|
/// Encrypt `plaintext`, returning `nonce ‖ ciphertext`.
|
|
pub fn seal(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
let nonce_bytes: [u8; 12] = rand::random();
|
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
|
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
|
|
let ct = cipher
|
|
.encrypt(
|
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
|
plaintext,
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
|
|
let mut out = Vec::with_capacity(12 + ct.len());
|
|
out.extend_from_slice(&nonce_bytes);
|
|
out.extend_from_slice(&ct);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Decrypt `nonce ‖ ciphertext`.
|
|
pub fn open(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
if data.len() < 12 {
|
|
anyhow::bail!("ciphertext too short");
|
|
}
|
|
let (nonce, ct) = data.split_at(12);
|
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
|
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
|
|
cipher
|
|
.decrypt(
|
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
|
ct,
|
|
)
|
|
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
|
|
}
|
|
|
|
/// Heuristic: does this look like legacy plaintext JSON (starts with `{`/`[`)?
|
|
/// Encrypted blobs start with a random nonce byte, so a `{`/`[` first byte is a
|
|
/// reliable migration signal.
|
|
pub fn is_plaintext_json(raw: &[u8]) -> bool {
|
|
matches!(raw.first(), Some(b'{') | Some(b'['))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn seal_open_round_trips() {
|
|
let key = [7u8; 32];
|
|
let msg = br#"{"messages":[{"m":"hi"}]}"#;
|
|
let sealed = seal(msg, &key).unwrap();
|
|
// Encrypted output must NOT be readable plaintext.
|
|
assert!(!is_plaintext_json(&sealed));
|
|
assert_ne!(&sealed[12..], &msg[..]);
|
|
assert_eq!(open(&sealed, &key).unwrap(), msg);
|
|
}
|
|
|
|
#[test]
|
|
fn open_fails_on_wrong_key_or_tamper() {
|
|
let sealed = seal(b"secret", &[1u8; 32]).unwrap();
|
|
assert!(open(&sealed, &[2u8; 32]).is_err());
|
|
let mut tampered = sealed.clone();
|
|
*tampered.last_mut().unwrap() ^= 0x01;
|
|
assert!(open(&tampered, &[1u8; 32]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn detects_plaintext_vs_ciphertext() {
|
|
assert!(is_plaintext_json(b"{\"a\":1}"));
|
|
assert!(is_plaintext_json(b"[]"));
|
|
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
|
|
}
|
|
}
|