Files
archy/core/archipelago/src/storage_crypto.rs
T
archipelagoandClaude Opus 5 09a1f7621c feat(10-06): name every entropy source and guard key draws (KEY-05 a/d)
Closes F-10a. Nothing here fixes a present defect: on the pinned rand
0.8.5, rand::random() and thread_rng() both resolve to a ChaCha12 CSPRNG
seeded from getrandom(2). What they lack is a STATED backend — it is
fixed by dependency and build configuration rather than by the calling
code, with no compile error if that changes. That is the structural
shape behind the 2026-07-30 COLDCARD entropy defect, and here the blast
radius includes Cashu blinded-key-exchange values, X3DH prekey material,
session bearer tokens and a ChaCha20-Poly1305 nonce.

Layer (a) — every production key, nonce and token draw now names
rand::rngs::OsRng at its own call site. The mnemonic seam is bound to
entropy::KeyGenRng, a SEALED allowlist whose supertrait lives in a
private module, so the set of RNGs that can drive the master key
hierarchy is exactly what one file says it is. This retires the false
promise at seed.rs:656: rand::CryptoRng is a marker with no
compiler-checked content, and the crate now contains zero impls of it.

Layer (d) — key material and AEAD nonces of >=12 bytes run a
degenerate-entropy predicate that refuses all-zero, all-identical and
wrapping +/-1 counter draws. Nothing heuristic: no entropy estimator, no
chi-squared. Each of the three shapes has a false-positive probability
computable in closed form (3 * 2^-88 at 12 bytes, 3 * 2^-248 at 32), and
a predicate whose false-positive rate cannot be computed cannot be
argued safe on a key-generation path. There is deliberately no retry — a
retry would paper over the broken RNG this exists to surface.

Layer (e) — the kernel-CSPRNG readiness verdict at master-seed
generation is now durable (backlog R-09). It was previously computed,
logged and thrown away, so a node could never answer after the fact
whether its keys were born from a seeded pool. The record holds a schema
version, timestamp, verdict and event name — no entropy, no key bytes.

Formats and wire shapes are proven unchanged rather than asserted:
storage_crypto and the credential store each open a HARDCODED
pre-migration ciphertext vector (a same-process round trip would pass
even if the envelope had changed), the vector was produced by an
independent RFC 8439 implementation so it pins the documented
nonce||ciphertext format rather than this implementation's output, and
the x3dh prekey bundle and bdhke values keep their field set and order.

totp.rs migrates its SOURCE only: the % charset.len() reduction and the
32-char charset are untouched. The bias there is presently zero (32
divides 256) and fixing the latent bias is R-12, which stays deferred.

Verified: cargo build clean; cargo test -p archipelago 1068 passed,
2 failed. Both failures are container::boot_reconciler timing tests
(second_pass_fires_after_interval, shutdown_terminates_loop) in a file
this change does not touch — pre-existing, not caused here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:27:34 -04:00

171 lines
7.3 KiB
Rust

//! 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};
// KEY-05: the nonce names `OsRng` and is inspected before use. Nonce reuse
// under ChaCha20-Poly1305 is a keystream recovery *and* a Poly1305 forgery,
// so this is the highest-consequence twelve bytes in the module. Only the
// *source* changed — the envelope below is byte-identical to what every blob
// already on a fleet node was written with.
let mut nonce_bytes = [0u8; 12];
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes)
.map_err(|e| anyhow::anyhow!("refusing to seal with degenerate nonce entropy: {e}"))?;
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()));
}
/// KEY-05 regression: a blob written by the pre-migration `seal` must still
/// open after the migration.
///
/// The vector is **hardcoded**, deliberately. A seal-then-open round trip in
/// the same process passes even if the envelope layout changed, because both
/// halves changed together — it proves self-consistency, not compatibility.
/// Every at-rest message store and mesh-contact store on every fleet node was
/// written by the old code, and CLAUDE.md's "migrations never destroy data"
/// invariant means this must decrypt.
///
/// The bytes were produced by an **independent** RFC 8439 ChaCha20-Poly1305
/// implementation (validated first against the RFC's own §2.8.2 test vector),
/// not captured from this crate — so it pins the documented envelope
/// `nonce ‖ ciphertext` as a *format*, rather than pinning whatever this
/// implementation happened to emit.
///
/// key = `[0x42; 32]`, nonce = `[0x07; 12]`, no AAD.
#[test]
fn opens_pre_migration_ciphertext_vector() {
const VECTOR: [u8; 81] = [
0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0xb1, 0x19,
0x96, 0xcc, 0xc8, 0x41, 0x2b, 0xf4, 0x25, 0x2a, 0x0f, 0x8b, 0xdc, 0xcb, 0xbb, 0x4d,
0x90, 0xe9, 0x99, 0xc5, 0xeb, 0x67, 0xd6, 0x5e, 0x1d, 0xcb, 0x61, 0x2c, 0xd1, 0xe4,
0x91, 0xe1, 0x44, 0x29, 0xaa, 0x0b, 0x1d, 0xfd, 0xf0, 0x88, 0xe4, 0x5a, 0x65, 0x5b,
0x29, 0x53, 0xc9, 0xf7, 0x85, 0xd1, 0xa1, 0xec, 0xb7, 0xd7, 0xd6, 0x12, 0xf2, 0x88,
0x77, 0xed, 0x2d, 0x72, 0x90, 0xff, 0x9a, 0x5b, 0x98, 0xea, 0xec,
];
let key = [0x42u8; 32];
assert_eq!(
open(&VECTOR, &key).expect("pre-migration blob must still decrypt"),
b"archipelago storage_crypto pre-KEY-05 envelope vector".to_vec()
);
}
/// The envelope shape itself, pinned so a later refactor cannot reshape it:
/// 12-byte nonce prefix, 16-byte Poly1305 tag suffix, and a *fresh* nonce per
/// seal.
#[test]
fn seal_envelope_layout_unchanged() {
let key = [9u8; 32];
let plaintext = b"twelve plus n plus sixteen";
let a = seal(plaintext, &key).unwrap();
let b = seal(plaintext, &key).unwrap();
assert_eq!(a.len(), 12 + plaintext.len() + 16);
assert_eq!(b.len(), 12 + plaintext.len() + 16);
assert_ne!(
a[..12],
b[..12],
"two seals of the same plaintext must not share a nonce"
);
assert_eq!(open(&a, &key).unwrap(), plaintext);
assert_eq!(open(&b, &key).unwrap(), plaintext);
}
}