feat(storage): encrypt chat history + mesh contacts at rest, atomic writes, persist contacts (#12)
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>
This commit is contained in:
parent
774ca28847
commit
edd03e542d
@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@ -64,6 +64,7 @@ mod server;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod storage_crypto;
|
||||
mod streaming;
|
||||
mod totp;
|
||||
mod transport;
|
||||
|
||||
@ -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<String, listener::ContactEntry> {
|
||||
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<String, listener::ContactEntry>,
|
||||
) -> 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<String> {
|
||||
@ -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,
|
||||
|
||||
@ -43,18 +43,68 @@ fn data_path() -> &'static Mutex<Option<PathBuf>> {
|
||||
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<Option<[u8; 32]>> {
|
||||
static KEY: OnceLock<Mutex<Option<[u8; 32]>>> = 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::<MessageStore>(&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::<MessageStore>(&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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
108
core/archipelago/src/storage_crypto.rs
Normal file
108
core/archipelago/src/storage_crypto.rs
Normal file
@ -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<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()));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user