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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a05956c4ce
commit
09a1f7621c
@@ -97,7 +97,9 @@ pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Resul
|
||||
let mut output = Vec::with_capacity(ENCRYPTED_MAGIC.len() + encrypted.len());
|
||||
output.extend_from_slice(ENCRYPTED_MAGIC);
|
||||
output.extend_from_slice(&encrypted);
|
||||
fs::write(&path, output).await.context("Writing credentials")
|
||||
fs::write(&path, output)
|
||||
.await
|
||||
.context("Writing credentials")
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
|
||||
@@ -117,7 +119,14 @@ async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
|
||||
}
|
||||
|
||||
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
// KEY-05: the nonce names `OsRng` and is inspected before use. Nonce reuse
|
||||
// under ChaCha20-Poly1305 recovers the keystream and forges the Poly1305 tag,
|
||||
// so this draw is guarded even though it is exactly at `MIN_GUARDED_LEN`.
|
||||
// The deterministic-nonce seam below is untouched — only the *source* of the
|
||||
// random nonce changed.
|
||||
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 encrypt with degenerate nonce entropy: {}", e))?;
|
||||
encrypt_credentials_with_nonce(data, key, nonce_bytes)
|
||||
}
|
||||
|
||||
@@ -166,8 +175,8 @@ fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::types::{CredentialProof, CredentialSubject, VerifiableCredential};
|
||||
use super::*;
|
||||
|
||||
/// Tempdir with a deterministic `identity/node_key` so the encryption key
|
||||
/// can be derived. Never a real key.
|
||||
@@ -233,7 +242,10 @@ mod tests {
|
||||
nonce[0] = first_byte;
|
||||
let plaintext = serde_json::to_vec(&sample_store(label)).unwrap();
|
||||
let blob = encrypt_credentials_with_nonce(&plaintext, &key, nonce).unwrap();
|
||||
assert_eq!(blob[0], first_byte, "test must actually trigger the collision");
|
||||
assert_eq!(
|
||||
blob[0], first_byte,
|
||||
"test must actually trigger the collision"
|
||||
);
|
||||
|
||||
// Written WITHOUT magic: this is the legacy on-disk population.
|
||||
std::fs::write(store_path(dir.path()), &blob).unwrap();
|
||||
@@ -312,7 +324,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let on_disk = std::fs::read(store_path(dir.path())).unwrap();
|
||||
assert!(on_disk.starts_with(ENCRYPTED_MAGIC), "save must mark the file");
|
||||
assert!(
|
||||
on_disk.starts_with(ENCRYPTED_MAGIC),
|
||||
"save must mark the file"
|
||||
);
|
||||
// Still genuinely encrypted, not plaintext.
|
||||
assert!(!on_disk.windows(4).any(|w| w == b"did:"));
|
||||
|
||||
@@ -350,6 +365,41 @@ mod tests {
|
||||
assert!(load_credentials(dir.path()).await.is_err());
|
||||
}
|
||||
|
||||
/// KEY-05 regression: a credential blob written before the entropy migration
|
||||
/// must still open after it.
|
||||
///
|
||||
/// **Hardcoded on purpose.** Every other test in this module seals and opens
|
||||
/// in the same process, which passes even if the envelope layout changed,
|
||||
/// because both halves changed together. This one pins the on-disk format
|
||||
/// `MAGIC ‖ nonce ‖ ciphertext ‖ tag` against bytes this crate did not
|
||||
/// produce: they come from an independent RFC 8439 ChaCha20-Poly1305
|
||||
/// implementation, validated first against the RFC's own §2.8.2 vector.
|
||||
///
|
||||
/// Key derivation pinned too: `SHA-256("archipelago-credential-store-v1" ‖
|
||||
/// [0xAB; 32])`, i.e. the key `test_dir_with_node_key` produces. A change to
|
||||
/// the domain separator or the derivation would fail this test, which is the
|
||||
/// point — that would strand every credential store in the fleet.
|
||||
#[tokio::test]
|
||||
async fn opens_pre_migration_ciphertext_vector() {
|
||||
const VECTOR: [u8; 56] = [
|
||||
0x41, 0x52, 0x43, 0x48, 0x59, 0x43, 0x52, 0x45, 0x44, 0x31, 0x07, 0x07, 0x07, 0x07,
|
||||
0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x75, 0x51, 0xf7, 0x32, 0x46, 0x8c,
|
||||
0xeb, 0x45, 0x1d, 0xe4, 0x20, 0x8f, 0x02, 0xaf, 0x56, 0xfe, 0x70, 0x8d, 0xc8, 0xf8,
|
||||
0x7e, 0xf2, 0xdb, 0xa2, 0x53, 0x23, 0xdb, 0x20, 0xfe, 0x15, 0x5f, 0x8e, 0x48, 0x95,
|
||||
];
|
||||
let dir = test_dir_with_node_key();
|
||||
std::fs::write(store_path(dir.path()), VECTOR).unwrap();
|
||||
|
||||
let loaded = load_credentials(dir.path())
|
||||
.await
|
||||
.expect("pre-migration credential blob must still decrypt");
|
||||
assert!(loaded.credentials.is_empty());
|
||||
|
||||
// And the vector really is the marked format, not something that fell
|
||||
// through to the plaintext path.
|
||||
assert!(VECTOR.starts_with(ENCRYPTED_MAGIC));
|
||||
}
|
||||
|
||||
/// A magic-prefixed file that fails authentication (tampered / wrong key)
|
||||
/// must error rather than fall through to another format.
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user