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:
co-authored by
Claude Opus 4.8
parent
774ca28847
commit
edd03e542d
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user