Skip to main content

chisel/crypto/
mod.rs

1// src/crypto/mod.rs — Crypto core (layer 1, no engine coupling).
2//
3// Standalone at-rest encryption primitives for Chisel: the XChaCha20-Poly1305
4// PageCipher (whole-page + variable-length body seal/open), the envelope KDF
5// (HKDF-SHA256 for raw keys, Argon2id for passphrases), DEK wrap/unwrap, and
6// the zeroizing key types. Nothing here touches page_io, the cache, or the
7// superblock — those layers consume this module in later phases. See
8// docs/specs/2026-06-29-on-disk-encryption-design.md §3.
9//
10// All randomness is OS-sourced (getrandom). Rolling our own crypto is
11// forbidden; only the vetted RustCrypto primitives are used.
12
13use chacha20poly1305::aead::AeadInPlace;
14use chacha20poly1305::{Key as AeadKey, KeyInit, XChaCha20Poly1305, XNonce};
15use zeroize::Zeroizing;
16
17/// On-disk stride of one encrypted page: 8192 ciphertext + 16 tag + 24 nonce.
18/// The logical page stays 8192 (spec §4.1); only the I/O unit grows.
19pub const ENC_PAGE_SIZE: usize = 8232;
20/// XChaCha20 nonce length (192 bits). The extended nonce is what makes random
21/// per-write nonces safe under shadow-paging page reuse (spec §2.1).
22pub const NONCE_LEN: usize = 24;
23/// Poly1305 authentication tag length.
24pub const TAG_LEN: usize = 16;
25/// Data Encryption Key length (256-bit).
26pub const DEK_LEN: usize = 32;
27/// Per-key-slot KDF salt length.
28pub const SALT_LEN: usize = 16;
29
30/// Client-supplied encryption credential. `Raw` is high-entropy key bytes
31/// (derived via HKDF); `Passphrase` is a human secret (derived via Argon2id).
32/// Both are zeroized on drop. `Clone` is needed because `Options` is consumed
33/// by `open` while rotation APIs may also hold a key.
34#[derive(Clone)]
35pub enum Key {
36    Raw(Zeroizing<Vec<u8>>),
37    Passphrase(Zeroizing<String>),
38}
39
40// Debug intentionally omits the key bytes — key material must not appear in
41// logs, panic messages, or error chains. The variant name is enough for diagnostics.
42impl std::fmt::Debug for Key {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Key::Raw(_) => f.write_str("Key::Raw(<redacted>)"),
46            Key::Passphrase(_) => f.write_str("Key::Passphrase(<redacted>)"),
47        }
48    }
49}
50
51/// The Data Encryption Key: seals every page and the superblock body. Generated
52/// once at create time, held for the open session only, wiped on drop. Never
53/// written to disk except KEK-wrapped in a key-slot.
54pub struct Dek(Zeroizing<[u8; DEK_LEN]>);
55
56impl Dek {
57    /// Construct from raw bytes (used by unwrap_dek). Kept crate-internal-ish via
58    /// module visibility; later phases hold a Dek but do not fabricate one.
59    pub fn from_bytes(bytes: [u8; DEK_LEN]) -> Self {
60        Dek(Zeroizing::new(bytes))
61    }
62    /// Borrow the raw key bytes. Callers must not copy these into a non-zeroizing
63    /// buffer that outlives the operation.
64    pub fn as_bytes(&self) -> &[u8; DEK_LEN] {
65        &self.0
66    }
67}
68
69impl Clone for Dek {
70    fn clone(&self) -> Self {
71        Dek(Zeroizing::new(*self.0))
72    }
73}
74
75/// The Key Encryption Key: derived per-open from the client key + a slot's
76/// salt/params. Only ever wraps/unwraps the DEK; transient, wiped on drop.
77///
78/// No Debug/Display: `Zeroizing`'s derived Debug delegates to the inner array
79/// (it does not redact), so printing a Kek would leak the raw key bytes.
80pub struct Kek(Zeroizing<[u8; 32]>);
81
82impl Kek {
83    #[cfg(test)]
84    pub fn from_bytes(bytes: [u8; 32]) -> Self {
85        Kek(Zeroizing::new(bytes))
86    }
87    pub fn as_bytes(&self) -> &[u8; 32] {
88        &self.0
89    }
90}
91
92/// KDF selector recorded per key-slot. The integer discriminants are part of
93/// the on-disk format (written into the slot's `kdf_id` byte) — do not renumber.
94#[derive(Clone, Copy, PartialEq, Debug)]
95pub enum KdfId {
96    Hkdf = 1,
97    Argon2id = 2,
98}
99
100/// Argon2id cost parameters. Stored per-slot so a slot can be re-derived
101/// regardless of the binary's current defaults.
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub struct Argon2Params {
104    pub m_cost: u32, // KiB of memory
105    pub t_cost: u32, // iterations
106    pub p_cost: u32, // lanes
107}
108
109impl Default for Argon2Params {
110    /// OWASP-recommended Argon2id baseline (19 MiB, 2 iterations, 1 lane).
111    fn default() -> Self {
112        Argon2Params {
113            m_cost: 19456,
114            t_cost: 2,
115            p_cost: 1,
116        }
117    }
118}
119
120/// Failures internal to the crypto layer. The engine layer maps these onto
121/// ChiselError (Auth → InvalidEncryptionKey/DecryptionFailed depending on site;
122/// Kdf/BadKeyLength → operational key errors). PartialEq for ergonomic tests.
123#[derive(Debug, PartialEq)]
124pub enum CryptoError {
125    /// AEAD tag verification failed (wrong key, tampered ciphertext, wrong AAD).
126    Auth,
127    /// A key-derivation primitive rejected its parameters.
128    Kdf,
129    /// A raw key was not the length the KDF requires.
130    BadKeyLength,
131}
132
133use argon2::{Algorithm, Argon2, Params, Version};
134use hkdf::Hkdf;
135use sha2::Sha256;
136
137/// HKDF info string binding derived KEKs to this construction/version. Changing
138/// it is a format break (existing slots would stop unwrapping); versioned so a
139/// future KDF revision can coexist.
140const KEK_INFO: &[u8] = b"chisel-kek-v1";
141
142/// Derive a 256-bit KEK from the client key and a slot's salt/params.
143///
144/// Dispatch is on `kdf`, NOT on the `Key` variant: the slot records which KDF
145/// produced it, and that is the authority. A `Raw` key is the IKM for HKDF; a
146/// `Passphrase` is the password for Argon2id. (A mismatched pairing — e.g. a
147/// passphrase with KdfId::Hkdf — still derives a deterministic KEK; it simply
148/// won't match the slot that was written with the other KDF, surfacing as an
149/// unwrap Auth failure one layer up. The slot's kdf_id is the single source of
150/// truth, so we never guess from the variant.)
151///
152/// # Errors
153/// Returns `CryptoError::Kdf` if the KDF primitive rejects its parameters
154/// (e.g. Argon2id with zero memory cost). Returns `CryptoError::BadKeyLength`
155/// if the supplied key material is empty (an empty `Raw` key or empty
156/// `Passphrase`), regardless of `kdf`.
157pub fn derive_kek(
158    key: &Key,
159    kdf: KdfId,
160    salt: &[u8; SALT_LEN],
161    params: &Argon2Params,
162) -> Result<Kek, CryptoError> {
163    let ikm: &[u8] = match key {
164        Key::Raw(bytes) => bytes.as_slice(),
165        Key::Passphrase(s) => s.as_bytes(),
166    };
167    // Reject empty key material: HKDF and Argon2id both accept a zero-length
168    // ikm and would silently derive a KEK from nothing, so a caller-supplied
169    // empty key must be refused at this trust boundary rather than producing a
170    // usable wrap. Documented in this function's # Errors as BadKeyLength.
171    if ikm.is_empty() {
172        return Err(CryptoError::BadKeyLength);
173    }
174    // Zeroizing so the derived key never lingers un-wiped on the stack: on the
175    // success path it is MOVED into Kek (no copy left behind), and on any error
176    // path partial KDF output is wiped on drop.
177    let mut okm = Zeroizing::new([0u8; 32]);
178    match kdf {
179        KdfId::Hkdf => {
180            let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
181            hk.expand(KEK_INFO, okm.as_mut())
182                .map_err(|_| CryptoError::Kdf)?;
183        }
184        KdfId::Argon2id => {
185            let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32))
186                .map_err(|_| CryptoError::Kdf)?;
187            // Version::V0x13 and the 32-byte output length (`Some(32)` above) are
188            // pinned to the on-disk format, exactly like KEK_INFO on the HKDF path:
189            // changing either re-derives a different KEK, so every existing Argon2id
190            // slot would stop unwrapping (silent data loss). Bump the format version
191            // deliberately if this ever changes; the argon2id KAT test pins it.
192            let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, p);
193            a2.hash_password_into(ikm, salt, okm.as_mut())
194                .map_err(|_| CryptoError::Kdf)?;
195        }
196    }
197    Ok(Kek(okm))
198}
199
200/// Detached AEAD seal: ciphertext is the same length as plaintext, the 16-byte
201/// Poly1305 tag is returned separately. Detached suits our fixed page layout
202/// (ciphertext occupies a known 32-byte DEK slot, tag a known 16-byte slot;
203/// for pages the same pattern applies with 8192-byte slots).
204fn seal_detached(
205    key: &[u8; 32],
206    nonce: &[u8; NONCE_LEN],
207    aad: &[u8],
208    plaintext: &[u8],
209) -> (Vec<u8>, [u8; TAG_LEN]) {
210    let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key));
211    let mut buf = plaintext.to_vec();
212    let tag = cipher
213        .encrypt_in_place_detached(XNonce::from_slice(nonce), aad, &mut buf)
214        .expect("XChaCha20-Poly1305 encrypt cannot fail for in-range lengths");
215    let mut tag_arr = [0u8; TAG_LEN];
216    tag_arr.copy_from_slice(&tag);
217    (buf, tag_arr)
218}
219
220/// Detached AEAD open. Any tag mismatch (wrong key, tampered ct/tag, wrong AAD,
221/// wrong nonce) maps to CryptoError::Auth. XChaCha20-Poly1305 is verify-then-
222/// decrypt: `decrypt_in_place_detached` checks the Poly1305 tag BEFORE applying
223/// the keystream, so on auth failure the buffer still holds the original
224/// ciphertext and no plaintext is ever written. The returned buffer is
225/// Zeroizing so the decrypted plaintext (key material) is wiped on drop rather
226/// than handed back to the allocator un-scrubbed.
227fn open_detached(
228    key: &[u8; 32],
229    nonce: &[u8; NONCE_LEN],
230    aad: &[u8],
231    ciphertext: &[u8],
232    tag: &[u8; TAG_LEN],
233) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
234    let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key));
235    let mut buf = Zeroizing::new(ciphertext.to_vec());
236    cipher
237        .decrypt_in_place_detached(
238            XNonce::from_slice(nonce),
239            aad,
240            &mut buf,
241            tag.as_slice().into(),
242        )
243        .map_err(|_| CryptoError::Auth)?;
244    Ok(buf)
245}
246
247/// Wrap (encrypt) the DEK under a KEK using detached XChaCha20-Poly1305.
248/// `aad` binds the slot's metadata (kdf_id, salt, Argon2 params) so an
249/// attacker cannot tamper a slot's parameters to force a mis-derivation.
250/// Returns (wrapped_dek, wrap_tag); both are written to the key-slot on disk.
251pub fn wrap_dek(
252    kek: &Kek,
253    dek: &Dek,
254    wrap_nonce: &[u8; NONCE_LEN],
255    aad: &[u8],
256) -> ([u8; DEK_LEN], [u8; TAG_LEN]) {
257    let (ct, tag) = seal_detached(kek.as_bytes(), wrap_nonce, aad, dek.as_bytes());
258    let mut wrapped = [0u8; DEK_LEN];
259    wrapped.copy_from_slice(&ct);
260    (wrapped, tag)
261}
262
263/// Unwrap (decrypt + authenticate) the DEK. A successful unwrap IS the proof
264/// that the client key (hence KEK) is correct — there is no separate verifier.
265/// Any failure (wrong passphrase, wrong KEK, tampered ciphertext or tag, wrong
266/// AAD) returns CryptoError::Auth without revealing partial plaintext.
267///
268/// # Errors
269/// Returns `CryptoError::Auth` if AEAD authentication fails (wrong key,
270/// tampered ciphertext, wrong AAD, or wrong nonce).
271pub fn unwrap_dek(
272    kek: &Kek,
273    wrapped: &[u8; DEK_LEN],
274    tag: &[u8; TAG_LEN],
275    wrap_nonce: &[u8; NONCE_LEN],
276    aad: &[u8],
277) -> Result<Dek, CryptoError> {
278    let pt = open_detached(kek.as_bytes(), wrap_nonce, aad, wrapped, tag)?;
279    let mut dek_bytes = Zeroizing::new([0u8; DEK_LEN]);
280    dek_bytes.copy_from_slice(&pt);
281    Ok(Dek::from_bytes(*dek_bytes))
282}
283
284/// Fill an N-byte array from the OS CSPRNG. Panics if the OS RNG is unavailable,
285/// which on a supported platform indicates a broken system — there is no safe
286/// fallback for key material, so failing loud is correct.
287pub fn random_array<const N: usize>() -> [u8; N] {
288    let mut b = [0u8; N];
289    getrandom::getrandom(&mut b).expect("OS RNG unavailable");
290    b
291}
292
293/// Generate a fresh random DEK from the OS CSPRNG.
294pub fn random_dek() -> Dek {
295    Dek::from_bytes(random_array::<DEK_LEN>())
296}
297
298/// Holds the DEK and performs the two seal/open transforms the engine needs:
299/// whole-page (fixed 8192→8232) and variable-length body (superblock sub-blob).
300/// Lives in the page-cache layer in later phases; here it is fully standalone.
301/// The AEAD cipher is derived from the DEK on each `seal`/`open` call; this is
302/// cheap for XChaCha20-Poly1305 and avoids holding any additional per-call state.
303///
304/// `Clone` produces an independent copy with its own `Zeroizing` DEK (both
305/// copies wipe on drop independently). Used when the cache and the session
306/// manager each need their own cipher instance from the same DEK.
307#[derive(Clone)]
308pub struct PageCipher {
309    dek: Dek,
310}
311
312impl PageCipher {
313    pub fn new(dek: Dek) -> Self {
314        PageCipher { dek }
315    }
316
317    /// Seal a full 8192-byte plaintext page image into the 8232-byte on-disk
318    /// blob: `ciphertext(8192) ‖ tag(16) ‖ nonce(24)`. AAD = page_id LE bytes
319    /// (anti-relocation). A fresh random 192-bit nonce per call (spec §2.1) —
320    /// safe under shadow-paging page reuse, and stored in the clear.
321    pub fn seal(&self, page_id: u64, plaintext: &[u8; 8192]) -> [u8; ENC_PAGE_SIZE] {
322        let nonce = random_array::<NONCE_LEN>();
323        let aad = page_id.to_le_bytes();
324        let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, &aad, plaintext);
325        let mut out = [0u8; ENC_PAGE_SIZE];
326        out[0..8192].copy_from_slice(&ct);
327        out[8192..8208].copy_from_slice(&tag);
328        out[8208..8232].copy_from_slice(&nonce);
329        out
330    }
331
332    /// Open an 8232-byte on-disk blob back to the 8192-byte plaintext page.
333    /// AAD = page_id LE. Any authentication failure → CryptoError::Auth (the
334    /// engine maps this to DecryptionFailed at the page-read site).
335    ///
336    /// # Errors
337    /// Returns `CryptoError::Auth` if the AEAD tag does not verify (wrong DEK,
338    /// tampered ciphertext, or mismatched page_id).
339    pub fn open(
340        &self,
341        page_id: u64,
342        ondisk: &[u8; ENC_PAGE_SIZE],
343    ) -> Result<[u8; 8192], CryptoError> {
344        let ct = &ondisk[0..8192];
345        let mut tag = [0u8; TAG_LEN];
346        tag.copy_from_slice(&ondisk[8192..8208]);
347        let mut nonce = [0u8; NONCE_LEN];
348        nonce.copy_from_slice(&ondisk[8208..8232]);
349        let aad = page_id.to_le_bytes();
350        let pt = open_detached(self.dek.as_bytes(), &nonce, &aad, ct, &tag)?;
351        let mut page = [0u8; 8192];
352        page.copy_from_slice(&pt);
353        Ok(page)
354    }
355
356    /// Seal a variable-length body (the superblock sensitive sub-blob). Returns
357    /// (nonce, tag, ciphertext); the caller lays these out in the reserved
358    /// region. AAD binds the body to the superblock's identity (anti-splicing).
359    pub fn seal_body(
360        &self,
361        aad: &[u8],
362        plaintext: &[u8],
363    ) -> ([u8; NONCE_LEN], [u8; TAG_LEN], Vec<u8>) {
364        let nonce = random_array::<NONCE_LEN>();
365        let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, aad, plaintext);
366        (nonce, tag, ct)
367    }
368
369    /// Open a variable-length body sealed by `seal_body`. AAD must match the
370    /// superblock identity used at seal time, else CryptoError::Auth.
371    ///
372    /// # Errors
373    /// Returns `CryptoError::Auth` if the AEAD tag does not verify (wrong DEK
374    /// or AAD, or tampered ciphertext).
375    pub fn open_body(
376        &self,
377        aad: &[u8],
378        nonce: &[u8; NONCE_LEN],
379        tag: &[u8; TAG_LEN],
380        ct: &[u8],
381    ) -> Result<Vec<u8>, CryptoError> {
382        open_detached(self.dek.as_bytes(), nonce, aad, ct, tag).map(|z| z.to_vec())
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn constants_match_spec() {
392        // On-disk encrypted stride = 8192 ciphertext + 16 tag + 24 nonce.
393        assert_eq!(ENC_PAGE_SIZE, 8232);
394        assert_eq!(NONCE_LEN, 24);
395        assert_eq!(TAG_LEN, 16);
396        assert_eq!(DEK_LEN, 32);
397        assert_eq!(SALT_LEN, 16);
398        assert_eq!(ENC_PAGE_SIZE, 8192 + TAG_LEN + NONCE_LEN);
399    }
400
401    #[test]
402    fn argon2_params_default_is_owasp() {
403        let p = Argon2Params::default();
404        assert_eq!(p.m_cost, 19456); // 19 MiB
405        assert_eq!(p.t_cost, 2);
406        assert_eq!(p.p_cost, 1);
407    }
408
409    #[test]
410    fn kdf_id_discriminants_are_wire_stable() {
411        // These ints are written into key-slots on disk; pin them.
412        assert_eq!(KdfId::Hkdf as u8, 1);
413        assert_eq!(KdfId::Argon2id as u8, 2);
414        assert_ne!(KdfId::Hkdf, KdfId::Argon2id);
415    }
416
417    #[test]
418    fn random_array_is_os_filled_and_distinct() {
419        let a: [u8; 32] = random_array();
420        let b: [u8; 32] = random_array();
421        // Astronomically unlikely to collide; all-zero would mean RNG silent-failed.
422        assert_ne!(a, b);
423        assert_ne!(a, [0u8; 32]);
424    }
425
426    #[test]
427    fn random_dek_differs_each_call() {
428        let d1 = random_dek();
429        let d2 = random_dek();
430        assert_ne!(d1.as_bytes(), d2.as_bytes());
431    }
432
433    #[test]
434    fn crypto_error_is_comparable() {
435        assert_eq!(CryptoError::Auth, CryptoError::Auth);
436        assert_ne!(CryptoError::Auth, CryptoError::Kdf);
437    }
438
439    #[test]
440    fn derive_kek_hkdf_matches_reference_construction() {
441        // RFC 5869 Test Case 1 inputs (IKM/salt), our pinned info string.
442        let ikm = [0x0bu8; 22];
443        let salt: [u8; SALT_LEN] = [
444            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
445            0x0e, 0x0f,
446        ];
447        let key = Key::Raw(zeroize::Zeroizing::new(ikm.to_vec()));
448        let kek = derive_kek(&key, KdfId::Hkdf, &salt, &Argon2Params::default()).unwrap();
449
450        // Independent reference: run hkdf directly with our exact salt+info.
451        use hkdf::Hkdf;
452        use sha2::Sha256;
453        let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
454        let mut expect = [0u8; 32];
455        hk.expand(b"chisel-kek-v1", &mut expect).unwrap();
456        assert_eq!(kek.as_bytes(), &expect);
457    }
458
459    #[test]
460    fn derive_kek_hkdf_is_deterministic_and_salt_sensitive() {
461        let key = Key::Raw(zeroize::Zeroizing::new(vec![7u8; 32]));
462        let salt_a = [1u8; SALT_LEN];
463        let salt_b = [2u8; SALT_LEN];
464        let p = Argon2Params::default();
465        let k1 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap();
466        let k2 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap();
467        let k3 = derive_kek(&key, KdfId::Hkdf, &salt_b, &p).unwrap();
468        assert_eq!(
469            k1.as_bytes(),
470            k2.as_bytes(),
471            "same input must be deterministic"
472        );
473        assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge");
474    }
475
476    #[test]
477    fn derive_kek_argon2_roundtrips_and_is_salt_sensitive() {
478        // Cheap params so the test is fast (real defaults are 19 MiB).
479        let fast = Argon2Params {
480            m_cost: 256,
481            t_cost: 1,
482            p_cost: 1,
483        };
484        let key = Key::Passphrase(zeroize::Zeroizing::new("correct horse".to_string()));
485        let salt_a = [9u8; SALT_LEN];
486        let salt_b = [8u8; SALT_LEN];
487        let k1 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap();
488        let k2 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap();
489        let k3 = derive_kek(&key, KdfId::Argon2id, &salt_b, &fast).unwrap();
490        assert_eq!(
491            k1.as_bytes(),
492            k2.as_bytes(),
493            "Argon2id must be deterministic"
494        );
495        assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge");
496        assert_ne!(k1.as_bytes(), &[0u8; 32]);
497    }
498
499    #[test]
500    fn derive_kek_argon2_rejects_zero_memory() {
501        let bad = Argon2Params {
502            m_cost: 0,
503            t_cost: 1,
504            p_cost: 1,
505        };
506        let key = Key::Passphrase(zeroize::Zeroizing::new("x".to_string()));
507        // matches! rather than unwrap_err: Kek deliberately has no Debug (it
508        // wraps key bytes), so Result::unwrap_err can't be used here.
509        assert!(matches!(
510            derive_kek(&key, KdfId::Argon2id, &[0u8; SALT_LEN], &bad),
511            Err(CryptoError::Kdf)
512        ));
513    }
514
515    #[test]
516    fn wrap_unwrap_roundtrip() {
517        let kek = Kek::from_bytes([3u8; 32]);
518        let dek = Dek::from_bytes([42u8; DEK_LEN]);
519        let nonce = [5u8; NONCE_LEN];
520        let aad = b"slot-meta";
521        let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad);
522        assert_ne!(
523            &wrapped,
524            dek.as_bytes(),
525            "wrapped DEK must not equal plaintext DEK"
526        );
527        let out = unwrap_dek(&kek, &wrapped, &tag, &nonce, aad).unwrap();
528        assert_eq!(out.as_bytes(), dek.as_bytes());
529    }
530
531    #[test]
532    fn unwrap_wrong_kek_is_auth() {
533        let dek = Dek::from_bytes([42u8; DEK_LEN]);
534        let nonce = [5u8; NONCE_LEN];
535        let aad = b"slot-meta";
536        let (wrapped, tag) = wrap_dek(&Kek::from_bytes([3u8; 32]), &dek, &nonce, aad);
537        // unwrap_err() requires Dek: Debug, which we deliberately omit (it wraps key
538        // bytes). Use matches! to test the error variant without printing anything.
539        assert!(matches!(
540            unwrap_dek(&Kek::from_bytes([4u8; 32]), &wrapped, &tag, &nonce, aad),
541            Err(CryptoError::Auth)
542        ));
543    }
544
545    #[test]
546    fn unwrap_tampered_tag_is_auth() {
547        let kek = Kek::from_bytes([3u8; 32]);
548        let dek = Dek::from_bytes([42u8; DEK_LEN]);
549        let nonce = [5u8; NONCE_LEN];
550        let aad = b"slot-meta";
551        let (wrapped, mut tag) = wrap_dek(&kek, &dek, &nonce, aad);
552        tag[0] ^= 0x01;
553        assert!(matches!(
554            unwrap_dek(&kek, &wrapped, &tag, &nonce, aad),
555            Err(CryptoError::Auth)
556        ));
557    }
558
559    #[test]
560    fn unwrap_tampered_ciphertext_is_auth() {
561        let kek = Kek::from_bytes([3u8; 32]);
562        let dek = Dek::from_bytes([42u8; DEK_LEN]);
563        let nonce = [5u8; NONCE_LEN];
564        let aad = b"slot-meta";
565        let (mut wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad);
566        wrapped[0] ^= 0x01;
567        assert!(matches!(
568            unwrap_dek(&kek, &wrapped, &tag, &nonce, aad),
569            Err(CryptoError::Auth)
570        ));
571    }
572
573    #[test]
574    fn unwrap_wrong_aad_is_auth() {
575        let kek = Kek::from_bytes([3u8; 32]);
576        let dek = Dek::from_bytes([42u8; DEK_LEN]);
577        let nonce = [5u8; NONCE_LEN];
578        let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, b"slot-meta-A");
579        assert!(matches!(
580            unwrap_dek(&kek, &wrapped, &tag, &nonce, b"slot-meta-B"),
581            Err(CryptoError::Auth)
582        ));
583    }
584
585    #[test]
586    fn page_seal_open_roundtrip() {
587        let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
588        let mut page = [0u8; 8192];
589        for (i, b) in page.iter_mut().enumerate() {
590            *b = (i % 251) as u8;
591        }
592        let blob = pc.seal(7, &page);
593        assert_eq!(blob.len(), ENC_PAGE_SIZE);
594        let out = pc.open(7, &blob).unwrap();
595        assert_eq!(out, page);
596    }
597
598    #[test]
599    fn page_seal_layout_is_ct_tag_nonce() {
600        let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
601        let page = [0xABu8; 8192];
602        let blob = pc.seal(0, &page);
603        // ciphertext occupies 0..8192, tag 8192..8208, nonce 8208..8232.
604        assert_ne!(
605            &blob[0..8192],
606            &page[..],
607            "ciphertext must differ from plaintext"
608        );
609    }
610
611    #[test]
612    fn page_open_wrong_page_id_is_auth() {
613        // AAD = page_id gives anti-relocation: a page sealed at id 7 must not
614        // authenticate at id 8.
615        let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
616        let page = [9u8; 8192];
617        let blob = pc.seal(7, &page);
618        assert_eq!(pc.open(8, &blob).unwrap_err(), CryptoError::Auth);
619    }
620
621    #[test]
622    fn page_open_byte_flip_is_auth() {
623        let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
624        let page = [9u8; 8192];
625        let mut blob = pc.seal(7, &page);
626        blob[100] ^= 0x01; // flip a ciphertext byte
627        assert_eq!(pc.open(7, &blob).unwrap_err(), CryptoError::Auth);
628    }
629
630    #[test]
631    fn page_two_seals_use_different_nonces() {
632        // Random per-write nonce (spec §2.1): two seals of the same page must
633        // produce different on-disk blobs (different nonce ⇒ different ct+tag).
634        let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
635        let page = [9u8; 8192];
636        let a = pc.seal(7, &page);
637        let b = pc.seal(7, &page);
638        assert_ne!(&a[..], &b[..], "nonce reuse: identical blobs for same page");
639        // Both still open correctly.
640        assert_eq!(pc.open(7, &a).unwrap(), page);
641        assert_eq!(pc.open(7, &b).unwrap(), page);
642    }
643
644    #[test]
645    fn body_seal_open_roundtrip() {
646        let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN]));
647        let body = b"root pointers + named_roots".to_vec();
648        let aad = b"sb-identity";
649        let (nonce, tag, ct) = pc.seal_body(aad, &body);
650        assert_eq!(ct.len(), body.len(), "body cipher is length-preserving");
651        let out = pc.open_body(aad, &nonce, &tag, &ct).unwrap();
652        assert_eq!(out, body);
653    }
654
655    #[test]
656    fn body_open_wrong_aad_is_auth() {
657        let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN]));
658        let body = b"secret".to_vec();
659        let (nonce, tag, ct) = pc.seal_body(b"sb-A", &body);
660        assert_eq!(
661            pc.open_body(b"sb-B", &nonce, &tag, &ct).unwrap_err(),
662            CryptoError::Auth
663        );
664    }
665
666    // Zeroization guards: verify that Dek/Key wrap Zeroizing buffers and that
667    // Clone produces independent copies (so dropping one does not corrupt the
668    // other).  We cannot observe freed memory in safe Rust, so the honest check
669    // is independence of cloned buffers — if the inner Zeroizing zeroes-on-drop
670    // the original's view is unaffected because they own separate allocations.
671    #[test]
672    fn dek_clone_is_independent_zeroizing_copy() {
673        let d = Dek::from_bytes([7u8; DEK_LEN]);
674        let c = d.clone();
675        assert_eq!(d.as_bytes(), c.as_bytes());
676        // Dropping the clone must not affect the original (independent buffers).
677        drop(c);
678        assert_eq!(d.as_bytes(), &[7u8; DEK_LEN]);
679    }
680
681    #[test]
682    fn key_variants_construct_from_zeroizing() {
683        // Compile + construct proof that Key wraps Zeroizing for both variants.
684        let raw = Key::Raw(zeroize::Zeroizing::new(vec![1u8, 2, 3]));
685        let pass = Key::Passphrase(zeroize::Zeroizing::new("pw".to_string()));
686        // Clone works (needed by Options/rotation).
687        let _r2 = raw.clone();
688        let _p2 = pass.clone();
689    }
690
691    #[test]
692    fn argon2id_known_answer_test() {
693        // Golden regression pin for our Argon2id KDF path. Input: password=b"password",
694        // salt=b"somesalt12345678" (16 bytes), m_cost=8, t_cost=1, p_cost=1, tag=32,
695        // algorithm=Argon2id, version=0x13. These are deliberately cheap params so the
696        // test is instant; they differ from production defaults (OWASP: m=19456).
697        //
698        // This is a golden (regression) pin computed from the argon2 crate, not an
699        // independently-published first-principles vector — the RFC 9106 reference
700        // vectors use secret+AD parameters our derive_kek API does not expose.
701        // The pin's value: if this test fails, the KDF configuration silently changed
702        // (algorithm, version, output length, info string) and existing key slots would
703        // fail to unwrap. That is a data-loss event; the test must be updated
704        // deliberately and the format version bumped.
705        let key = Key::Passphrase(zeroize::Zeroizing::new("password".to_string()));
706        let salt = *b"somesalt12345678";
707        let params = Argon2Params {
708            m_cost: 8,
709            t_cost: 1,
710            p_cost: 1,
711        };
712        let kek = derive_kek(&key, KdfId::Argon2id, &salt, &params).unwrap();
713        let expected: [u8; 32] = [
714            0xd8, 0x38, 0x04, 0x14, 0x00, 0x12, 0xc3, 0xe6, 0xd3, 0x50, 0x2a, 0x3e, 0xb5, 0x9f,
715            0xc2, 0x4a, 0x89, 0xa9, 0xec, 0x08, 0xb6, 0xac, 0x97, 0xbe, 0x1f, 0xec, 0xa1, 0x70,
716            0x0a, 0xbe, 0x0a, 0xfb,
717        ];
718        assert_eq!(
719            kek.as_bytes(),
720            &expected,
721            "Argon2id output changed — KDF config or format break; update golden and bump FORMAT_VERSION"
722        );
723    }
724}