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