//! 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> { 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> { 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())); } }