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;
|
entry.pinned = p;
|
||||||
}
|
}
|
||||||
let saved = entry.clone();
|
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!({
|
Ok(serde_json::json!({
|
||||||
"saved": true,
|
"saved": true,
|
||||||
"pubkey": pubkey,
|
"pubkey": pubkey,
|
||||||
@ -1215,6 +1221,11 @@ impl RpcHandler {
|
|||||||
let mut contacts = state.contacts.write().await;
|
let mut contacts = state.contacts.write().await;
|
||||||
let entry = contacts.entry(pubkey.clone()).or_default();
|
let entry = contacts.entry(pubkey.clone()).or_default();
|
||||||
entry.blocked = blocked;
|
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 }))
|
Ok(serde_json::json!({ "pubkey": pubkey, "blocked": blocked }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,7 @@ mod server;
|
|||||||
mod session;
|
mod session;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod storage_crypto;
|
||||||
mod streaming;
|
mod streaming;
|
||||||
mod totp;
|
mod totp;
|
||||||
mod transport;
|
mod transport;
|
||||||
|
|||||||
@ -37,6 +37,7 @@ use tracing::{error, info, warn};
|
|||||||
|
|
||||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||||
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.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
|
/// Derive a stable synthetic `contact_id` for a federation peer from its
|
||||||
/// archipelago ed25519 pubkey. Mesh LoRa contacts use meshcore firmware's
|
/// 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(())
|
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.
|
/// Detect serial devices that could be mesh radios.
|
||||||
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
|
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
|
||||||
pub async fn detect_devices() -> Vec<String> {
|
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 {
|
Ok(Self {
|
||||||
state,
|
state,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@ -43,18 +43,68 @@ fn data_path() -> &'static Mutex<Option<PathBuf>> {
|
|||||||
PATH.get_or_init(|| Mutex::new(None))
|
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.
|
/// Initialize message store — load from disk. Call once at startup.
|
||||||
pub async fn init(data_dir: &Path) {
|
pub async fn init(data_dir: &Path) {
|
||||||
let path = data_dir.join("messages.json");
|
let path = data_dir.join("messages.json");
|
||||||
*data_path().lock().unwrap_or_else(|e| e.into_inner()) = Some(path.clone());
|
*data_path().lock().unwrap_or_else(|e| e.into_inner()) = Some(path.clone());
|
||||||
|
|
||||||
if let Ok(content) = tokio::fs::read_to_string(&path).await {
|
// Derive + cache the at-rest encryption key (bound to this node's identity).
|
||||||
if let Ok(loaded) = serde_json::from_str::<MessageStore>(&content) {
|
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());
|
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
*guard = loaded;
|
*guard = loaded;
|
||||||
tracing::info!("Loaded {} messages from disk", guard.messages.len());
|
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.
|
/// Persist current messages to disk.
|
||||||
@ -63,13 +113,28 @@ pub async fn init(data_dir: &Path) {
|
|||||||
fn persist() {
|
fn persist() {
|
||||||
let guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
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 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 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();
|
let path = path.clone();
|
||||||
drop(path_guard);
|
drop(path_guard);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
tokio::task::spawn(async move {
|
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