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:
archipelago
2026-06-16 08:54:37 -04:00
co-authored by Claude Opus 4.8
parent 774ca28847
commit edd03e542d
5 changed files with 262 additions and 4 deletions
@@ -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 }))
}