diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 0803195a..57b06f0d 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -1184,6 +1184,12 @@ impl RpcHandler { entry.pinned = p; } let saved = entry.clone(); + let snapshot = contacts.clone(); + drop(contacts); + // Persist (encrypted, atomic) so the customisation survives restarts. + if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await { + tracing::warn!("failed to persist mesh contacts: {e}"); + } Ok(serde_json::json!({ "saved": true, "pubkey": pubkey, @@ -1215,6 +1221,11 @@ impl RpcHandler { let mut contacts = state.contacts.write().await; let entry = contacts.entry(pubkey.clone()).or_default(); entry.blocked = blocked; + let snapshot = contacts.clone(); + drop(contacts); + if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await { + tracing::warn!("failed to persist mesh contacts: {e}"); + } Ok(serde_json::json!({ "pubkey": pubkey, "blocked": blocked })) } diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index a18db1cb..17c16ecc 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -64,6 +64,7 @@ mod server; mod session; mod settings; mod state; +mod storage_crypto; mod streaming; mod totp; mod transport; diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 70cb2c43..7adb301a 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -37,6 +37,7 @@ use tracing::{error, info, warn}; const MESH_CONFIG_FILE: &str = "mesh-config.json"; const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json"; +const MESH_CONTACTS_FILE: &str = "mesh-contacts.json"; /// Derive a stable synthetic `contact_id` for a federation peer from its /// archipelago ed25519 pubkey. Mesh LoRa contacts use meshcore firmware's @@ -210,6 +211,66 @@ pub async fn save_ignored_radio_contacts(data_dir: &Path, pubkeys: &[String]) -> Ok(()) } +/// Load persisted mesh contact customisations (alias / notes / pinned / blocked), +/// decrypting at rest with the node key and migrating any legacy plaintext file. +/// Returns an empty map on any error so a read failure never loses live state. +pub async fn load_mesh_contacts( + data_dir: &Path, +) -> std::collections::HashMap { + let path = data_dir.join(MESH_CONTACTS_FILE); + let Ok(raw) = fs::read(&path).await else { + return std::collections::HashMap::new(); + }; + let bytes = if crate::storage_crypto::is_plaintext_json(&raw) { + raw + } else { + match crate::storage_crypto::derive_key( + data_dir, + crate::storage_crypto::DOMAIN_MESH_CONTACTS, + ) + .await + { + Ok(k) => match crate::storage_crypto::open(&raw, &k) { + Ok(p) => p, + Err(e) => { + warn!("mesh contacts: decrypt failed ({e}); keeping in-memory state"); + return std::collections::HashMap::new(); + } + }, + Err(_) => return std::collections::HashMap::new(), + } + }; + serde_json::from_slice(&bytes).unwrap_or_default() +} + +/// Persist mesh contact customisations, encrypted at rest with the node key and +/// written atomically (temp + rename) so a crash mid-write can't corrupt them. +pub async fn save_mesh_contacts( + data_dir: &Path, + contacts: &std::collections::HashMap, +) -> Result<()> { + fs::create_dir_all(data_dir).await.ok(); + let content = serde_json::to_vec(contacts).context("Failed to serialize mesh contacts")?; + let bytes = match crate::storage_crypto::derive_key( + data_dir, + crate::storage_crypto::DOMAIN_MESH_CONTACTS, + ) + .await + { + Ok(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content), + Err(_) => content, // no key yet (pre-onboarding) → plaintext rather than no-write + }; + let path = data_dir.join(MESH_CONTACTS_FILE); + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, &bytes) + .await + .context("Failed to write mesh contacts tmp")?; + fs::rename(&tmp, &path) + .await + .context("Failed to rename mesh contacts")?; + Ok(()) +} + /// Detect serial devices that could be mesh radios. /// Checks both Meshcore (via probe) and legacy Meshtastic paths. pub async fn detect_devices() -> Vec { @@ -304,6 +365,18 @@ impl MeshService { } } + // Restore persisted contact customisations (alias/notes/pinned/blocked), + // decrypted with the node key, so they survive restarts. + { + let saved = load_mesh_contacts(data_dir).await; + if !saved.is_empty() { + let mut contacts = state.contacts.write().await; + for (pk, entry) in saved { + contacts.insert(pk, entry); + } + } + } + Ok(Self { state, config, diff --git a/core/archipelago/src/node_message.rs b/core/archipelago/src/node_message.rs index 316b90b0..660b3640 100644 --- a/core/archipelago/src/node_message.rs +++ b/core/archipelago/src/node_message.rs @@ -43,18 +43,68 @@ fn data_path() -> &'static Mutex> { PATH.get_or_init(|| Mutex::new(None)) } +/// At-rest encryption key for messages.json, derived from the node identity in +/// `init()`. `None` only if the node key is unreadable (pre-onboarding) — in +/// which case we persist plaintext rather than lose messages. +fn enc_key() -> &'static Mutex> { + static KEY: OnceLock>> = OnceLock::new(); + KEY.get_or_init(|| Mutex::new(None)) +} + /// Initialize message store — load from disk. Call once at startup. pub async fn init(data_dir: &Path) { let path = data_dir.join("messages.json"); *data_path().lock().unwrap_or_else(|e| e.into_inner()) = Some(path.clone()); - if let Ok(content) = tokio::fs::read_to_string(&path).await { - if let Ok(loaded) = serde_json::from_str::(&content) { + // Derive + cache the at-rest encryption key (bound to this node's identity). + match crate::storage_crypto::derive_key(data_dir, crate::storage_crypto::DOMAIN_MESSAGES).await + { + Ok(k) => *enc_key().lock().unwrap_or_else(|e| e.into_inner()) = Some(k), + Err(e) => tracing::warn!( + "message store: encryption key unavailable ({e}); will persist plaintext" + ), + } + + let Ok(raw) = tokio::fs::read(&path).await else { + return; // no file yet (new node) + }; + // Decrypt the on-disk blob, transparently migrating a legacy plaintext file. + let mut was_plaintext = false; + let bytes = if crate::storage_crypto::is_plaintext_json(&raw) { + was_plaintext = true; + Some(raw) + } else { + let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner()); + match key { + Some(k) => match crate::storage_crypto::open(&raw, &k) { + Ok(p) => Some(p), + Err(e) => { + tracing::error!( + "message store: decrypt failed ({e}); NOT overwriting on-disk data" + ); + None + } + }, + None => None, + } + }; + if let Some(bytes) = bytes { + if let Ok(loaded) = serde_json::from_slice::(&bytes) { let mut guard = store().lock().unwrap_or_else(|e| e.into_inner()); *guard = loaded; tracing::info!("Loaded {} messages from disk", guard.messages.len()); } } + // Eagerly re-write a legacy plaintext file as encrypted on first boot. + if was_plaintext + && enc_key() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() + { + persist(); + tracing::info!("message store: migrated plaintext messages.json to encrypted at rest"); + } } /// Persist current messages to disk. @@ -63,13 +113,28 @@ pub async fn init(data_dir: &Path) { fn persist() { let guard = store().lock().unwrap_or_else(|e| e.into_inner()); let path_guard = data_path().lock().unwrap_or_else(|e| e.into_inner()); + let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner()); if let Some(ref path) = *path_guard { - if let Ok(content) = serde_json::to_string(&*guard) { + if let Ok(content) = serde_json::to_vec(&*guard) { let path = path.clone(); drop(path_guard); drop(guard); tokio::task::spawn(async move { - let _ = tokio::fs::write(&path, content).await; + // Encrypt at rest when the node key is available; fall back to + // plaintext rather than drop the write if it somehow isn't. + let bytes = match key { + Some(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content), + None => content, + }; + // Atomic write: stage to a temp file then rename, so a crash or + // reboot mid-write can never truncate/corrupt the real history + // (rename is atomic on the same filesystem). + let tmp = path.with_extension("json.tmp"); + if tokio::fs::write(&tmp, &bytes).await.is_ok() { + let _ = tokio::fs::rename(&tmp, &path).await; + } else { + let _ = tokio::fs::remove_file(&tmp).await; + } }); } } diff --git a/core/archipelago/src/storage_crypto.rs b/core/archipelago/src/storage_crypto.rs new file mode 100644 index 00000000..6ecfa3b7 --- /dev/null +++ b/core/archipelago/src/storage_crypto.rs @@ -0,0 +1,108 @@ +//! 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())); + } +}