Skip to main content

mongreldb_core/
encryption.rs

1//! Native page-level encryption (optional, behind the `encryption` feature).
2//!
3//! Realizes §7 of the design spec (Phase 10.1): a per-table Key-Encryption Key
4//! (KEK) derived from a passphrase via **Argon2id** (slow memory-hard KDF,
5//! resists offline brute force) followed by **HKDF-SHA256** expand (domain
6//! separation). Each sorted run gets a fresh random Data-Encryption Key (DEK);
7//! the DEK is wrapped (AES-256-GCM) by the KEK and stored, alongside a per-run
8//! nonce prefix, in the run's Encryption Descriptor. Per-page nonces are
9//! deterministic — `nonce_prefix[0..8] (random) || column_id (2) || page_seq
10//! (2)` — so no per-page nonce material is persisted. Decrypting a page
11//! requires unwrapping its run's DEK with the table KEK.
12
13use crate::error::{MongrelError, Result};
14use zeroize::Zeroizing;
15
16/// DEK length (AES-256 = 32 bytes). Always available.
17pub const DEK_LEN: usize = 32;
18
19/// Fill a buffer with OS CSPRNG bytes. Always available.
20pub fn fill_random(buf: &mut [u8]) -> Result<()> {
21    getrandom::getrandom(buf).map_err(|error| MongrelError::EntropyUnavailable(error.to_string()))
22}
23
24/// Symmetric page cipher.
25pub trait Cipher: Send + Sync {
26    /// Encrypt a page payload. `nonce` is the deterministic per-page nonce.
27    fn encrypt_page(&self, nonce: &[u8; 12], plaintext: &[u8]) -> Result<Vec<u8>>;
28
29    /// Decrypt a page payload.
30    fn decrypt_page(&self, nonce: &[u8; 12], ciphertext: &[u8]) -> Result<Vec<u8>>;
31
32    fn encrypt_page_with_aad(
33        &self,
34        nonce: &[u8; 12],
35        plaintext: &[u8],
36        aad: &[u8],
37    ) -> Result<Vec<u8>> {
38        if aad.is_empty() {
39            self.encrypt_page(nonce, plaintext)
40        } else {
41            Err(MongrelError::Encryption(
42                "cipher does not support associated data".into(),
43            ))
44        }
45    }
46
47    fn decrypt_page_with_aad(
48        &self,
49        nonce: &[u8; 12],
50        ciphertext: &[u8],
51        aad: &[u8],
52    ) -> Result<Vec<u8>> {
53        if aad.is_empty() {
54            self.decrypt_page(nonce, ciphertext)
55        } else {
56            Err(MongrelError::Decryption(
57                "cipher does not support associated data".into(),
58            ))
59        }
60    }
61}
62
63/// No-op cipher for unencrypted tables. Used by default.
64#[derive(Debug, Default, Clone, Copy)]
65pub struct PlaintextCipher;
66
67impl Cipher for PlaintextCipher {
68    fn encrypt_page(&self, _nonce: &[u8; 12], plaintext: &[u8]) -> Result<Vec<u8>> {
69        Ok(plaintext.to_vec())
70    }
71
72    fn decrypt_page(&self, _nonce: &[u8; 12], ciphertext: &[u8]) -> Result<Vec<u8>> {
73        Ok(ciphertext.to_vec())
74    }
75}
76
77mod aes {
78    use super::{Cipher, MongrelError, Result};
79    use aes_gcm::aead::{Aead, Payload};
80    use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
81
82    /// AES-256-GCM page cipher over an (unwrapped) per-run DEK. Per-page nonces
83    /// are derived outside and passed in.
84    pub struct AesCipher {
85        cipher: Aes256Gcm,
86    }
87
88    impl AesCipher {
89        /// `key` must be exactly 32 bytes (the unwrapped DEK).
90        pub fn new(key: &[u8]) -> Result<Self> {
91            if key.len() != 32 {
92                return Err(MongrelError::InvalidArgument(format!(
93                    "aes-256 key must be 32 bytes, got {}",
94                    key.len()
95                )));
96            }
97            Ok(Self {
98                cipher: Aes256Gcm::new_from_slice(key)
99                    .map_err(|e| MongrelError::Encryption(format!("aes key init: {e}")))?,
100            })
101        }
102    }
103
104    impl Cipher for AesCipher {
105        fn encrypt_page(&self, nonce: &[u8; 12], plaintext: &[u8]) -> Result<Vec<u8>> {
106            let nonce = Nonce::from_slice(nonce);
107            self.cipher
108                .encrypt(nonce, plaintext)
109                .map_err(|e| MongrelError::Encryption(format!("aes encrypt: {e}")))
110        }
111
112        fn decrypt_page(&self, nonce: &[u8; 12], ciphertext: &[u8]) -> Result<Vec<u8>> {
113            let nonce = Nonce::from_slice(nonce);
114            self.cipher
115                .decrypt(nonce, ciphertext)
116                .map_err(|e| MongrelError::Decryption(format!("aes decrypt: {e}")))
117        }
118
119        fn encrypt_page_with_aad(
120            &self,
121            nonce: &[u8; 12],
122            plaintext: &[u8],
123            aad: &[u8],
124        ) -> Result<Vec<u8>> {
125            self.cipher
126                .encrypt(
127                    Nonce::from_slice(nonce),
128                    Payload {
129                        msg: plaintext,
130                        aad,
131                    },
132                )
133                .map_err(|e| MongrelError::Encryption(format!("aes encrypt: {e}")))
134        }
135
136        fn decrypt_page_with_aad(
137            &self,
138            nonce: &[u8; 12],
139            ciphertext: &[u8],
140            aad: &[u8],
141        ) -> Result<Vec<u8>> {
142            self.cipher
143                .decrypt(
144                    Nonce::from_slice(nonce),
145                    Payload {
146                        msg: ciphertext,
147                        aad,
148                    },
149                )
150                .map_err(|e| MongrelError::Decryption(format!("aes decrypt: {e}")))
151        }
152    }
153}
154
155pub use aes::AesCipher;
156
157mod key {
158    use super::{fill_random, Cipher, MongrelError, Result, DEK_LEN};
159    use aes_gcm::aead::{Aead, KeyInit};
160    use aes_gcm::{Aes256Gcm, Nonce};
161    use serde::{Deserialize, Serialize};
162    use zeroize::Zeroizing;
163
164    /// Argon2id salt length (bytes).
165    pub const SALT_LEN: usize = 16;
166    /// Algorithm tag stored in the Encryption Descriptor: AES-256-GCM.
167    pub const ALGO_AES_GCM: u8 = 1;
168    /// HKDF-SHA256 info label for KEK domain separation.
169    const KEK_INFO: &[u8] = b"mongreldb/kek/v1";
170    /// HKDF-SHA256 info label for raw-key KEK domain separation.
171    const KEK_RAW_INFO: &[u8] = b"mongreldb/kek-raw/v1";
172    /// Argon2id memory cost (KiB) — OWASP-recommended minimum (≈19 MiB).
173    const ARGON2_M_COST: u32 = 19_456;
174    /// Argon2id time cost (iterations).
175    const ARGON2_T_COST: u32 = 2;
176    /// Argon2id parallelism.
177    const ARGON2_P_COST: u32 = 1;
178
179    /// Table-level Key-Encryption Key. Derived from a passphrase + salt via
180    /// Argon2id (the extract step, memory-hard) followed by HKDF-SHA256 expand
181    /// (domain separation). Never persisted; reconstructable only from the
182    /// passphrase plus the stored salt.
183    pub struct Kek(Zeroizing<[u8; DEK_LEN]>);
184
185    impl Kek {
186        /// Derive a 256-bit KEK from `passphrase` and `salt` via Argon2id +
187        /// HKDF-SHA256.
188        pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Result<Self> {
189            let params =
190                argon2::Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(DEK_LEN))
191                    .map_err(|e| MongrelError::Encryption(format!("argon2 params: {e}")))?;
192            let argon =
193                argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
194            // Argon2id output is the extracted pseudo-random key (PRK).
195            let mut prk = Zeroizing::new([0u8; DEK_LEN]);
196            argon
197                .hash_password_into(passphrase.as_bytes(), salt, prk.as_mut())
198                .map_err(|e| MongrelError::Encryption(format!("argon2 derive: {e}")))?;
199            // HKDF-Expand gives a domain-separated KEK from the PRK.
200            let hk = hkdf::Hkdf::<sha2::Sha256>::from_prk(prk.as_ref())
201                .map_err(|e| MongrelError::Encryption(format!("hkdf from_prk: {e}")))?;
202            let mut kek = Zeroizing::new([0u8; DEK_LEN]);
203            hk.expand(KEK_INFO, kek.as_mut())
204                .map_err(|e| MongrelError::Encryption(format!("hkdf expand: {e}")))?;
205            Ok(Kek(kek))
206        }
207
208        /// Derive a 256-bit KEK from a raw key (e.g. a key file's contents)
209        /// via HKDF-SHA256 only — no Argon2id. The raw key must be >= 32
210        /// bytes and already high-entropy (machine-generated). ~0.1ms vs
211        /// ~50ms for the passphrase path.
212        pub fn from_raw_key(raw: &[u8], salt: &[u8; SALT_LEN]) -> Result<Self> {
213            if raw.len() < DEK_LEN {
214                return Err(MongrelError::InvalidArgument(format!(
215                    "raw key must be >= {DEK_LEN} bytes, got {}",
216                    raw.len()
217                )));
218            }
219            // HKDF-Extract (uses salt for domain separation), then HKDF-Expand.
220            let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(salt), raw);
221            let mut kek = Zeroizing::new([0u8; DEK_LEN]);
222            hk.expand(KEK_RAW_INFO, kek.as_mut())
223                .map_err(|e| MongrelError::Encryption(format!("hkdf expand: {e}")))?;
224            Ok(Kek(kek))
225        }
226
227        /// Derive a WAL DEK from this KEK for frame-level AEAD.
228        pub fn derive_wal_key(&self) -> Zeroizing<[u8; DEK_LEN]> {
229            self.derive_subkey(b"mongreldb/wal/v1")
230        }
231
232        /// Derive the shared (multi-table) WAL DEK — domain-separated from the
233        /// per-table WAL so the two logs never share key+nonce space under the
234        /// same KEK (review fix from P2 peer review).
235        pub fn derive_shared_wal_key(&self) -> Zeroizing<[u8; DEK_LEN]> {
236            self.derive_subkey(b"mongreldb/swal/v1")
237        }
238
239        /// Derive a per-table WAL DEK unique to `table_id`, so no two tables
240        /// share the same DEK even under the same KEK.
241        pub fn derive_table_wal_key(&self, table_id: u64) -> Zeroizing<[u8; DEK_LEN]> {
242            let mut info = b"mongreldb/twal/".to_vec();
243            info.extend_from_slice(&table_id.to_be_bytes());
244            info.extend_from_slice(b"/v1");
245            self.derive_subkey(&info)
246        }
247
248        /// Derive a result-cache DEK from this KEK for cache file AEAD.
249        pub fn derive_cache_key(&self) -> Zeroizing<[u8; DEK_LEN]> {
250            self.derive_subkey(b"mongreldb/rcache/v1")
251        }
252
253        /// Derive the index-checkpoint DEK from this KEK (`_idx/global.idx`).
254        pub fn derive_idx_key(&self) -> Zeroizing<[u8; DEK_LEN]> {
255            self.derive_subkey(b"mongreldb/idx/v1")
256        }
257
258        /// Derive the run-metadata MAC key from this KEK (see [`run_metadata_mac`]).
259        pub fn derive_run_mac_key(&self) -> Zeroizing<[u8; DEK_LEN]> {
260            self.derive_subkey(b"mongreldb/run-mac/v1")
261        }
262
263        /// Derive the meta DEK used to encrypt + authenticate DB-wide metadata
264        /// (catalog, manifest checkpoints). Mirrors [`Self::derive_idx_key`].
265        pub fn derive_meta_key(&self) -> Zeroizing<[u8; DEK_LEN]> {
266            self.derive_subkey(b"mongreldb/meta/v1")
267        }
268
269        /// Wrap a 32-byte DEK with the KEK using AES-256-GCM. `wrap_nonce` must
270        /// be unique per use under this KEK (a run's random `nonce_prefix`
271        /// satisfies this).
272        pub fn wrap_dek(&self, dek: &[u8; DEK_LEN], wrap_nonce: &[u8; 12]) -> Result<Vec<u8>> {
273            let cipher = Aes256Gcm::new_from_slice(&self.0[..])
274                .map_err(|e| MongrelError::Encryption(format!("kek aes init: {e}")))?;
275            cipher
276                .encrypt(Nonce::from_slice(wrap_nonce), dek.as_slice())
277                .map_err(|e| MongrelError::Encryption(format!("dek wrap: {e}")))
278        }
279
280        /// Unwrap a DEK previously produced by [`Self::wrap_dek`].
281        pub fn unwrap_dek(
282            &self,
283            wrapped: &[u8],
284            wrap_nonce: &[u8; 12],
285        ) -> Result<Zeroizing<[u8; DEK_LEN]>> {
286            let cipher = Aes256Gcm::new_from_slice(&self.0[..])
287                .map_err(|e| MongrelError::Encryption(format!("kek aes init: {e}")))?;
288            let pt = Zeroizing::new(
289                cipher
290                    .decrypt(Nonce::from_slice(wrap_nonce), wrapped)
291                    .map_err(|e| MongrelError::Decryption(format!("dek unwrap: {e}")))?,
292            );
293            if pt.len() != DEK_LEN {
294                return Err(MongrelError::Decryption(format!(
295                    "unwrapped dek is {} bytes, expected {DEK_LEN}",
296                    pt.len()
297                )));
298            }
299            let mut dek = Zeroizing::new([0u8; DEK_LEN]);
300            dek.copy_from_slice(&pt[..]);
301            Ok(dek)
302        }
303    }
304
305    impl std::fmt::Debug for Kek {
306        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307            f.write_str("Kek(**redacted**)")
308        }
309    }
310
311    /// Scheme tag for an indexable-encrypted column (§7).
312    pub const SCHEME_HMAC_EQ: u8 = 1;
313    pub const SCHEME_OPE_RANGE: u8 = 2;
314
315    impl Kek {
316        /// Derive a 256-bit sub-key from this KEK via HKDF-Expand, domain-
317        /// separated by `info`. Used for per-column indexable-encryption keys:
318        /// deriving deterministically from the (stable) KEK makes a column's
319        /// tokens identical across runs, so cross-run indexes (bitmap / range)
320        /// unify them.
321        pub fn derive_subkey(&self, info: &[u8]) -> Zeroizing<[u8; DEK_LEN]> {
322            let hk = hkdf::Hkdf::<sha2::Sha256>::from_prk(&self.0[..])
323                .expect("KEK is 32 bytes >= HashLen");
324            let mut k = Zeroizing::new([0u8; DEK_LEN]);
325            hk.expand(info, k.as_mut())
326                .expect("32-byte output <= 255*HashLen");
327            k
328        }
329
330        /// Derive the per-column indexable-encryption key.
331        pub fn derive_column_key(&self, column_id: u16) -> Zeroizing<[u8; DEK_LEN]> {
332            let mut info = b"mongreldb/colkey/".to_vec();
333            info.extend_from_slice(&column_id.to_be_bytes());
334            self.derive_subkey(&info)
335        }
336
337        /// Wrap a column key (for the §7 descriptor). Reuses the run nonce prefix.
338        pub fn wrap_column_key(
339            &self,
340            col_key: &[u8; DEK_LEN],
341            wrap_nonce: &[u8; 12],
342        ) -> Result<Vec<u8>> {
343            self.wrap_dek(col_key, wrap_nonce)
344        }
345    }
346
347    /// Deterministic equality token: HMAC-SHA256 over the value's bytes. Equal
348    /// plaintexts collide; unequal plaintexts (cryptographically) do not. Used
349    /// for equality indexes (bitmap / PK) over ENCRYPTED_INDEXABLE columns.
350    pub fn hmac_token(col_key: &[u8; DEK_LEN], msg: &[u8]) -> [u8; 32] {
351        use hmac::Mac;
352        let mut mac = <hmac::Hmac<sha2::Sha256> as Mac>::new_from_slice(col_key)
353            .expect("HMAC accepts any key size");
354        mac.update(msg);
355        mac.finalize().into_bytes().into()
356    }
357
358    /// Order-preserving token for an `i64`: the sign-flipped u64 representation
359    /// run through [`monotone_ope`]. The result is a 16-byte big-endian token
360    /// whose byte order equals the value's numeric order, so a range index over
361    /// the token serves range queries without decrypting.
362    ///
363    /// **Still an OPE — leaks order; not ideal ORE.** Unlike the previous affine
364    /// map, this is *non-linear*: each byte-block's contribution is an
365    /// independent, key-derived random monotone function of that block, keyed by
366    /// all higher-order bytes (the prefix). There is no global `a·m + b`
367    /// structure, so the two-known-plaintext inversion and the GCD-of-gaps
368    /// spacing recovery that broke the affine scheme no longer apply. What it
369    /// still reveals: order (inherent to any OPE) and, to a known/chosen-
370    /// plaintext attacker, the monotone mapping of a block *within a shared
371    /// prefix*. For values that must stay fully hidden, prefer equality tokens
372    /// ([`hmac_token`]) or no index.
373    pub fn ope_token_i64(col_key: &[u8; DEK_LEN], x: i64) -> [u8; 16] {
374        let m = (x as u64) ^ (1u64 << 63); // order-preserving i64 -> u64
375        monotone_ope(col_key, m)
376    }
377
378    /// Order-preserving token for an `f64`, via the IEEE-754 total-order → u64
379    /// bijection, then the same [`monotone_ope`] as [`ope_token_i64`].
380    pub fn ope_token_f64(col_key: &[u8; DEK_LEN], x: f64) -> [u8; 16] {
381        let bits = x.to_bits();
382        let m = if bits & (1u64 << 63) != 0 {
383            !bits
384        } else {
385            bits ^ (1u64 << 63)
386        };
387        monotone_ope(col_key, m)
388    }
389
390    /// HKDF-Expand info label for the per-block OPE gap streams.
391    const OPE_INFO: &[u8] = b"mongreldb/ope-blk/v2";
392
393    /// Non-linear, prefix-keyed order-preserving encoding of a u64 onto a
394    /// 16-byte big-endian token.
395    ///
396    /// `m`'s 8 big-endian bytes are mapped block-by-block (MSB first). Block `i`
397    /// (value `v ∈ [0,256)`) becomes a 2-byte chunk
398    /// `o(v) = v + Σ_{t<v} gap[t]`, a strictly-increasing step function whose
399    /// gaps come from `HKDF(col_key, info ‖ i ‖ m[..i])` — i.e. keyed by the
400    /// block index AND every higher-order byte. With each `gap[t] ∈ [0,256)` the
401    /// chunk stays ≤ 65 280 < 2^16, so it fits two bytes and never overflows.
402    ///
403    /// Order preservation: for `m < m'` let `j` be the first differing byte
404    /// (`m[j] < m'[j]`, equal above). Equal prefixes ⇒ identical gap streams and
405    /// identical chunks for blocks `< j`; at `j` the step function is strictly
406    /// increasing so `o(m[j]) < o(m'[j])`; that smaller fixed-width chunk wins
407    /// the lexicographic comparison regardless of the lower bytes. Hence the
408    /// token byte-order equals numeric order, and the map is deterministic and
409    /// column-stable (same key ⇒ same token across runs), as the range index
410    /// requires.
411    fn monotone_ope(col_key: &[u8; DEK_LEN], m: u64) -> [u8; 16] {
412        let mb = m.to_be_bytes();
413        let mut out = [0u8; 16];
414        for i in 0..8 {
415            let v = mb[i] as usize;
416            let mut o: u32 = v as u32;
417            if v > 0 {
418                // Derive gap[0..v] (HKDF-Expand is a prefix-stable stream, so
419                // expanding `v` bytes matches the first `v` bytes for any v).
420                let mut info = Vec::with_capacity(OPE_INFO.len() + 1 + i);
421                info.extend_from_slice(OPE_INFO);
422                info.push(i as u8);
423                info.extend_from_slice(&mb[..i]);
424                let hk = hkdf::Hkdf::<sha2::Sha256>::from_prk(&col_key[..])
425                    .expect("col_key is 32 bytes >= HashLen");
426                let mut gaps = [0u8; 255];
427                hk.expand(&info, &mut gaps[..v])
428                    .expect("v <= 255 <= 255*HashLen");
429                for &g in &gaps[..v] {
430                    o += g as u32;
431                }
432            }
433            let chunk = o as u16; // <= 65_280, no truncation
434            out[2 * i..2 * i + 2].copy_from_slice(&chunk.to_be_bytes());
435        }
436        out
437    }
438
439    /// Encrypt an arbitrary blob with a DEK: `[nonce: 12B][AES-256-GCM ct+tag]`,
440    /// fresh random 96-bit nonce. Used for the at-rest index checkpoint and any
441    /// other low-write-volume blob (a random nonce is safe well past the handful
442    /// of writes such files see per key).
443    pub fn encrypt_blob(dek: &[u8; DEK_LEN], plaintext: &[u8]) -> Result<Vec<u8>> {
444        let cipher = crate::encryption::AesCipher::new(&dek[..])?;
445        let mut nonce = [0u8; 12];
446        fill_random(&mut nonce)?;
447        let ct = cipher.encrypt_page(&nonce, plaintext)?;
448        let mut out = Vec::with_capacity(12 + ct.len());
449        out.extend_from_slice(&nonce);
450        out.extend_from_slice(&ct);
451        Ok(out)
452    }
453
454    /// Inverse of [`encrypt_blob`]. The GCM tag authenticates; a wrong key or
455    /// tampered blob fails here.
456    pub fn decrypt_blob(dek: &[u8; DEK_LEN], bytes: &[u8]) -> Result<Vec<u8>> {
457        if bytes.len() < 12 + 16 {
458            return Err(MongrelError::Decryption("blob too short".into()));
459        }
460        let cipher = crate::encryption::AesCipher::new(&dek[..])?;
461        let nonce: [u8; 12] = bytes[..12].try_into().unwrap();
462        cipher.decrypt_page(&nonce, &bytes[12..])
463    }
464
465    /// HMAC-SHA256 over a run's metadata (`header ‖ dir ‖ descriptor`, each
466    /// length-prefixed) under a KEK-derived key. Binds the *cleartext* run
467    /// header, column directory, and encryption descriptor to the key, so an
468    /// attacker with write access cannot tamper offsets / page stats / structure
469    /// (which would otherwise silently corrupt query results) without detection.
470    /// Page payloads are already AEAD-authenticated per page, so they are not
471    /// re-covered here (avoids a full-file read on open).
472    pub fn run_metadata_mac(
473        mac_key: &[u8; DEK_LEN],
474        header: &[u8],
475        dir: &[u8],
476        descriptor: &[u8],
477    ) -> [u8; 32] {
478        use hmac::Mac;
479        let mut mac = <hmac::Hmac<sha2::Sha256> as Mac>::new_from_slice(mac_key)
480            .expect("HMAC accepts any key size");
481        mac.update(b"mongreldb/run-meta-mac/v1");
482        for part in [header, dir, descriptor] {
483            mac.update(&(part.len() as u64).to_le_bytes());
484            mac.update(part);
485        }
486        mac.finalize().into_bytes().into()
487    }
488
489    /// Build a distinct AEAD nonce for a KEK wrap (DEK or a column key) from the
490    /// run's `nonce_prefix`. The high 8 bytes stay random-per-run (cross-run
491    /// uniqueness under the KEK); byte `[8]` distinguishes the wrap kind (0 =
492    /// DEK, 1 = column key) and bytes `[9..11]` carry the column id, so the DEK
493    /// and every column key get a UNIQUE nonce within one run — mandatory for
494    /// AES-GCM (nonce reuse under a key is catastrophic).
495    pub(super) fn wrap_nonce(nonce_prefix: [u8; 12], kind: u8, column_id: u16) -> [u8; 12] {
496        let mut n = nonce_prefix;
497        n[8] = kind;
498        n[9..11].copy_from_slice(&column_id.to_le_bytes());
499        n[11] = 0;
500        n
501    }
502
503    /// Wrap-nonce kind tag for the DEK (also used to unwrap on read).
504    pub(super) const WRAP_KIND_DEK: u8 = 0;
505    /// Wrap-nonce kind tag for a column key.
506    pub(super) const WRAP_KIND_COLUMN: u8 = 1;
507
508    /// Per-column indexable-encryption descriptor (Phase 10.2 — HMAC-eq /
509    /// OPE-range). Populated for every ENCRYPTED_INDEXABLE column so the
510    /// descriptor is self-describing per §7.
511    #[derive(Clone, Serialize, Deserialize)]
512    pub struct ColumnKeyDescriptor {
513        pub column_id: u16,
514        /// 1 = HMAC-eq, 2 = OPE-range.
515        pub scheme: u8,
516        pub wrapped_column_key: Vec<u8>,
517    }
518
519    /// Encryption Descriptor, serialized into each encrypted run at
520    /// `header.encryption_descriptor_offset` (4-byte little-endian length prefix
521    /// + bincode body).
522    #[derive(Clone, Serialize, Deserialize)]
523    pub struct EncryptionDescriptor {
524        /// 1 = AES-256-GCM.
525        pub algo: u8,
526        /// 12-byte per-run nonce prefix. Bytes `[0..8]` are random per run;
527        /// bytes `[8..12]` are zero and overlaid with `column_id` + `page_seq`
528        /// at the page level (see [`build_page_nonce`]).
529        pub nonce_prefix: [u8; 12],
530        /// DEK wrapped by the table KEK (AES-256-GCM; 32 + 16-byte tag = 48).
531        pub wrapped_dek: Vec<u8>,
532        /// Per-column indexable-encryption descriptors (Phase 10.2).
533        pub column_descriptors: Vec<ColumnKeyDescriptor>,
534    }
535
536    /// Generate a fresh random 32-byte DEK from the OS CSPRNG.
537    pub fn generate_dek() -> Result<Zeroizing<[u8; DEK_LEN]>> {
538        let mut k = Zeroizing::new([0u8; DEK_LEN]);
539        fill_random(k.as_mut())?;
540        Ok(k)
541    }
542
543    /// Generate a fresh random 16-byte Argon2id salt from the OS CSPRNG.
544    pub fn random_salt() -> Result<[u8; SALT_LEN]> {
545        let mut s = [0u8; SALT_LEN];
546        fill_random(&mut s)?;
547        Ok(s)
548    }
549
550    /// Generate a per-run nonce prefix: 8 random bytes + 4 zero bytes (the low
551    /// 4 bytes are overlaid per page by [`build_page_nonce`]).
552    pub fn random_nonce_prefix() -> Result<[u8; 12]> {
553        let mut n = [0u8; 12];
554        fill_random(&mut n[..8])?;
555        Ok(n)
556    }
557
558    /// Construct the deterministic 12-byte nonce for a page:
559    /// `nonce_prefix[0..8] (random, per run) || column_id (2) || page_seq (2)`.
560    /// Within a run the `(column_id, page_seq)` pair is unique per page and the
561    /// DEK is per-run, so nonces never repeat under a key.
562    pub fn build_page_nonce(nonce_prefix: [u8; 12], column_id: u16, page_seq: u32) -> [u8; 12] {
563        let mut n = nonce_prefix;
564        n[8..10].copy_from_slice(&column_id.to_le_bytes());
565        // page_seq occupies the low 2 bytes — a column/run cannot exceed
566        // 65 535 pages (enforced at encode), so this never truncates.
567        n[10..12].copy_from_slice(&(page_seq as u16).to_le_bytes());
568        n
569    }
570
571    /// Assemble per-run encryption material from a KEK: generate a DEK, build
572    /// the page cipher, wrap the DEK into a descriptor, and derive+wrap a per-
573    /// column key for each ENCRYPTED_INDEXABLE column (`(column_id, scheme)`).
574    /// Each KEK wrap uses a distinct nonce (DEK vs column keys) so AES-GCM never
575    /// sees nonce reuse under the KEK.
576    pub fn setup_run_encryption(
577        kek: &Kek,
578        indexable_columns: &[(u16, u8)],
579    ) -> Result<crate::encryption::RunEncryption> {
580        let dek = generate_dek()?;
581        let nonce_prefix = random_nonce_prefix()?;
582        let cipher: Box<dyn Cipher> = Box::new(crate::encryption::AesCipher::new(&dek[..])?);
583        let dek_nonce = wrap_nonce(nonce_prefix, WRAP_KIND_DEK, 0);
584        let wrapped = kek.wrap_dek(&dek, &dek_nonce)?;
585        let mut column_descriptors = Vec::with_capacity(indexable_columns.len());
586        for &(column_id, scheme) in indexable_columns {
587            let col_key = kek.derive_column_key(column_id);
588            let col_nonce = wrap_nonce(nonce_prefix, WRAP_KIND_COLUMN, column_id);
589            let wrapped_col = kek.wrap_column_key(&col_key, &col_nonce)?;
590            column_descriptors.push(ColumnKeyDescriptor {
591                column_id,
592                scheme,
593                wrapped_column_key: wrapped_col,
594            });
595        }
596        let desc = EncryptionDescriptor {
597            algo: ALGO_AES_GCM,
598            nonce_prefix,
599            wrapped_dek: wrapped,
600            column_descriptors,
601        };
602        let descriptor_bytes = bincode::serialize(&desc)?;
603        Ok(crate::encryption::RunEncryption {
604            cipher,
605            nonce_prefix,
606            descriptor_bytes,
607            mac_key: Some(*kek.derive_run_mac_key()),
608        })
609    }
610
611    /// Read a run's Encryption Descriptor (serialized body, not the length
612    /// prefix) and unwrap its DEK, returning the page cipher + nonce prefix.
613    pub fn build_run_cipher(
614        kek: &Kek,
615        descriptor_bytes: &[u8],
616    ) -> Result<crate::encryption::RunEncryption> {
617        let desc: EncryptionDescriptor = bincode::deserialize(descriptor_bytes)
618            .map_err(|e| MongrelError::Decryption(format!("bad encryption descriptor: {e}")))?;
619        if desc.algo != ALGO_AES_GCM {
620            return Err(MongrelError::Decryption(format!(
621                "unsupported encryption algo {}",
622                desc.algo
623            )));
624        }
625        let dek_nonce = wrap_nonce(desc.nonce_prefix, WRAP_KIND_DEK, 0);
626        let dek = kek.unwrap_dek(&desc.wrapped_dek, &dek_nonce)?;
627        let cipher: Box<dyn Cipher> = Box::new(crate::encryption::AesCipher::new(&dek[..])?);
628        Ok(crate::encryption::RunEncryption {
629            cipher,
630            nonce_prefix: desc.nonce_prefix,
631            descriptor_bytes: Vec::new(),
632            mac_key: None,
633        })
634    }
635}
636
637pub use key::{
638    build_page_nonce, build_run_cipher, decrypt_blob, encrypt_blob, generate_dek, hmac_token,
639    ope_token_f64, ope_token_i64, random_nonce_prefix, random_salt, run_metadata_mac,
640    setup_run_encryption, ColumnKeyDescriptor, EncryptionDescriptor, Kek, ALGO_AES_GCM, SALT_LEN,
641    SCHEME_HMAC_EQ, SCHEME_OPE_RANGE,
642};
643
644/// Per-run encryption material assembled at write/read time: the page cipher
645/// (over the unwrapped DEK), the nonce prefix, and (write path only) the
646/// serialized descriptor to embed in the run.
647pub struct RunEncryption {
648    pub cipher: Box<dyn Cipher>,
649    pub nonce_prefix: [u8; 12],
650    pub descriptor_bytes: Vec<u8>,
651    /// Run-metadata MAC key (write path only; `None` on the read path, where the
652    /// reader re-derives it from the KEK). See [`run_metadata_mac`].
653    pub mac_key: Option<[u8; 32]>,
654}
655
656/// Derive the DB-wide meta DEK from an optional KEK. `None` when the KEK is
657/// absent (plaintext DB).
658pub fn meta_dek_for(kek: Option<&Kek>) -> Option<[u8; DEK_LEN]> {
659    kek.map(|k| *k.derive_meta_key())
660}
661
662/// Derive the shared-WAL frame DEK from an optional KEK. `None` when the KEK is
663/// absent (plaintext DB).
664pub fn wal_dek_for(kek: Option<&Kek>) -> Option<Zeroizing<[u8; DEK_LEN]>> {
665    kek.map(|k| k.derive_shared_wal_key())
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn plaintext_is_identity() {
674        let c = PlaintextCipher;
675        let ct = c.encrypt_page(&[0; 12], b"hello").unwrap();
676        assert_eq!(ct, b"hello");
677        let pt = c.decrypt_page(&[0; 12], &ct).unwrap();
678        assert_eq!(pt, b"hello");
679    }
680
681    #[test]
682    fn aes_round_trip() {
683        let c = AesCipher::new(&[7u8; 32]).unwrap();
684        let nonce = [1u8; 12];
685        let ct = c.encrypt_page(&nonce, b"secret page").unwrap();
686        assert_ne!(ct, b"secret page");
687        let pt = c.decrypt_page(&nonce, &ct).unwrap();
688        assert_eq!(pt, b"secret page");
689    }
690
691    #[test]
692    fn kek_derive_is_deterministic_for_same_passphrase_and_salt() {
693        let salt = random_salt().unwrap();
694        let k1 = Kek::derive("correct horse battery staple", &salt).unwrap();
695        let k2 = Kek::derive("correct horse battery staple", &salt).unwrap();
696        let dek = generate_dek().unwrap();
697        let np = random_nonce_prefix().unwrap();
698        let w1 = k1.wrap_dek(&dek, &np).unwrap();
699        let w2 = k2.wrap_dek(&dek, &np).unwrap();
700        assert_eq!(w1, w2, "same passphrase+salt must yield same KEK");
701    }
702
703    #[test]
704    fn kek_differs_for_different_salt() {
705        let s1 = random_salt().unwrap();
706        let s2 = random_salt().unwrap();
707        let k1 = Kek::derive("passphrase", &s1).unwrap();
708        let k2 = Kek::derive("passphrase", &s2).unwrap();
709        let dek = generate_dek().unwrap();
710        let np = random_nonce_prefix().unwrap();
711        let w1 = k1.wrap_dek(&dek, &np).unwrap();
712        let w2 = k2.wrap_dek(&dek, &np).unwrap();
713        assert_ne!(w1, w2, "different salts must yield different KEKs");
714    }
715
716    #[test]
717    fn dek_wrap_unwrap_round_trip() {
718        let salt = random_salt().unwrap();
719        let kek = Kek::derive("hunter2", &salt).unwrap();
720        let dek = generate_dek().unwrap();
721        let np = random_nonce_prefix().unwrap();
722        let wrapped = kek.wrap_dek(&dek, &np).unwrap();
723        assert_eq!(wrapped.len(), DEK_LEN + 16);
724        let unwrapped = kek.unwrap_dek(&wrapped, &np).unwrap();
725        assert_eq!(unwrapped.as_ref(), dek.as_ref());
726    }
727
728    #[test]
729    fn unwrap_rejects_wrong_passphrase() {
730        let salt = random_salt().unwrap();
731        let enc_kek = Kek::derive("right-pass", &salt).unwrap();
732        let dec_kek = Kek::derive("wrong-pass", &salt).unwrap();
733        let dek = generate_dek().unwrap();
734        let np = random_nonce_prefix().unwrap();
735        let wrapped = enc_kek.wrap_dek(&dek, &np).unwrap();
736        assert!(dec_kek.unwrap_dek(&wrapped, &np).is_err());
737    }
738
739    #[test]
740    fn page_nonce_overlays_column_and_page() {
741        let np = random_nonce_prefix().unwrap();
742        let n = build_page_nonce(np, 0x0304, 0x0506);
743        // high 8 bytes preserved (random), low 4 = column_id (LE) + page_seq (LE).
744        assert_eq!(&n[..8], &np[..8]);
745        assert_eq!(n[8..10], [0x04, 0x03]);
746        assert_eq!(n[10..12], [0x06, 0x05]);
747    }
748
749    #[test]
750    fn page_nonce_unique_per_column_and_page() {
751        let np = random_nonce_prefix().unwrap();
752        let a = build_page_nonce(np, 1, 0);
753        let b = build_page_nonce(np, 1, 1);
754        let c = build_page_nonce(np, 2, 0);
755        assert_ne!(a, b);
756        assert_ne!(a, c);
757        assert_ne!(b, c);
758    }
759
760    #[test]
761    fn column_key_is_deterministic_from_kek() {
762        let salt = random_salt().unwrap();
763        let k1 = Kek::derive("pass", &salt).unwrap();
764        let k2 = Kek::derive("pass", &salt).unwrap();
765        let c1 = k1.derive_column_key(7);
766        let c2 = k2.derive_column_key(7);
767        assert_eq!(c1.as_ref(), c2.as_ref(), "same KEK + column => same key");
768        // Different columns get different keys.
769        let c3 = k1.derive_column_key(8);
770        assert_ne!(c1.as_ref(), c3.as_ref());
771    }
772
773    #[test]
774    fn hmac_token_collides_only_for_equal_values() {
775        let salt = random_salt().unwrap();
776        let k = Kek::derive("pass", &salt).unwrap();
777        let ck = k.derive_column_key(1);
778        let a = hmac_token(&ck, b"hello");
779        let b = hmac_token(&ck, b"hello");
780        let c = hmac_token(&ck, b"world");
781        assert_eq!(a, b, "equal plaintexts => equal tokens");
782        assert_ne!(a, c, "unequal plaintexts => distinct tokens");
783        // A different column key yields a different token for the same value.
784        let ck2 = k.derive_column_key(2);
785        assert_ne!(a, hmac_token(&ck2, b"hello"));
786    }
787
788    #[test]
789    fn ope_token_i64_preserves_order() {
790        let salt = random_salt().unwrap();
791        let k = Kek::derive("pass", &salt).unwrap();
792        let ck = k.derive_column_key(3);
793        let vals = [i64::MIN, -1_000_000, -1, 0, 1, 42, 1_000_000, i64::MAX];
794        let tokens: Vec<_> = vals.iter().map(|&x| ope_token_i64(&ck, x)).collect();
795        // Strictly increasing (big-endian u128 byte order == numeric order).
796        for w in tokens.windows(2) {
797            assert!(w[0] < w[1], "OPE must preserve order");
798        }
799        // Equal values map to equal tokens (deterministic).
800        assert_eq!(ope_token_i64(&ck, 0), ope_token_i64(&ck, 0));
801    }
802
803    /// Regression (peer review): the OPE must be NON-linear. The old affine map
804    /// (`a*m + b`) gave equally-spaced tokens for equally-spaced inputs, which a
805    /// two-known-plaintext / GCD attacker inverts trivially. The prefix-keyed
806    /// construction must not have constant token gaps.
807    #[test]
808    fn ope_token_is_non_linear() {
809        let salt = random_salt().unwrap();
810        let k = Kek::derive("pass", &salt).unwrap();
811        let ck = k.derive_column_key(9);
812        let t = |x: i64| u128::from_be_bytes(ope_token_i64(&ck, x));
813        // Equally-spaced inputs → gaps must differ (an affine map would tie them).
814        let g1 = t(101).wrapping_sub(t(100));
815        let g2 = t(102).wrapping_sub(t(101));
816        let g3 = t(103).wrapping_sub(t(102));
817        assert!(
818            !(g1 == g2 && g2 == g3),
819            "constant token gaps => OPE is still affine/linear"
820        );
821        // …while remaining strictly order-preserving and deterministic.
822        assert!(t(100) < t(101) && t(101) < t(102) && t(102) < t(103));
823        assert_eq!(ope_token_i64(&ck, 100), ope_token_i64(&ck, 100));
824    }
825
826    #[test]
827    fn ope_token_f64_preserves_order() {
828        let salt = random_salt().unwrap();
829        let k = Kek::derive("pass", &salt).unwrap();
830        let ck = k.derive_column_key(4);
831        let vals = [
832            f64::NEG_INFINITY,
833            -1.5,
834            0.0,
835            std::f64::consts::PI,
836            1e9,
837            f64::INFINITY,
838        ];
839        let tokens: Vec<_> = vals.iter().map(|&x| ope_token_f64(&ck, x)).collect();
840        for w in tokens.windows(2) {
841            assert!(w[0] < w[1], "OPE over f64 must preserve total order");
842        }
843        // Negative < positive (verifies the sign handling).
844        assert!(ope_token_f64(&ck, -1.0) < ope_token_f64(&ck, 1.0));
845    }
846
847    /// Regression for the Phase 10 review CRITICAL: within one run the DEK and
848    /// every column key must be wrapped under DISTINCT nonces, or AES-GCM nonce
849    /// reuse under the KEK is catastrophic.
850    #[test]
851    fn wrap_nonces_are_distinct_within_a_run() {
852        use super::key::{wrap_nonce, WRAP_KIND_COLUMN, WRAP_KIND_DEK};
853        let salt = random_salt().unwrap();
854        let kek = Kek::derive("pass", &salt).unwrap();
855        let np = random_nonce_prefix().unwrap();
856
857        // Distinct nonces for the DEK and each column.
858        let dek_n = wrap_nonce(np, WRAP_KIND_DEK, 0);
859        let col1 = wrap_nonce(np, WRAP_KIND_COLUMN, 1);
860        let col2 = wrap_nonce(np, WRAP_KIND_COLUMN, 2);
861        assert_ne!(dek_n, col1);
862        assert_ne!(dek_n, col2);
863        assert_ne!(col1, col2);
864
865        // Wrapping the SAME key material under the DEK vs a column nonce yields
866        // distinct ciphertext — the (KEK, nonce) pair is never reused.
867        let k = generate_dek().unwrap();
868        let w_dek = kek.wrap_dek(&k, &dek_n).unwrap();
869        let w_col = kek.wrap_column_key(&k, &col1).unwrap();
870        assert_ne!(
871            w_dek, w_col,
872            "DEK and column-key wraps must not share a nonce"
873        );
874
875        // End-to-end: setup + build_run_cipher unwrap the DEK consistently.
876        let enc =
877            setup_run_encryption(&kek, &[(1, SCHEME_HMAC_EQ), (2, SCHEME_OPE_RANGE)]).unwrap();
878        let built = build_run_cipher(&kek, &enc.descriptor_bytes).unwrap();
879        assert_eq!(built.nonce_prefix, enc.nonce_prefix);
880    }
881
882    /// The shared WAL, per-table WAL, and per-table-2 WAL must all derive
883    /// DISTINCT DEKs under the same KEK — otherwise two WAL files sharing the
884    /// same key + the same `segment_no=0` nonce space is catastrophic AES-GCM
885    /// nonce reuse (review fix from P2 peer review).
886    #[test]
887    fn wal_deks_are_domain_separated() {
888        let salt = random_salt().unwrap();
889        let k = Kek::derive("pass", &salt).unwrap();
890        let shared = k.derive_shared_wal_key();
891        let tbl1 = k.derive_table_wal_key(1);
892        let tbl2 = k.derive_table_wal_key(2);
893        let legacy = k.derive_wal_key();
894        assert_ne!(shared.as_ref(), tbl1.as_ref(), "shared != table1");
895        assert_ne!(shared.as_ref(), tbl2.as_ref(), "shared != table2");
896        assert_ne!(shared.as_ref(), legacy.as_ref(), "shared != legacy");
897        assert_ne!(tbl1.as_ref(), tbl2.as_ref(), "table1 != table2");
898        assert_ne!(tbl1.as_ref(), legacy.as_ref(), "table1 != legacy");
899    }
900}