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