Skip to main content

sochdb_storage/
encryption.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4
5//! # Data-at-Rest Encryption (Enterprise Security)
6//!
7//! Transparent AES-256-GCM-SIV encryption for data blocks, WAL entries,
8//! and checkpoint files. Uses nonce-misuse-resistant authenticated encryption
9//! to prevent catastrophic failures from nonce reuse.
10//!
11//! ## Design Choices
12//!
13//! - **AES-256-GCM-SIV**: Nonce-misuse resistant — safe even if nonces are
14//!   accidentally repeated (unlike plain AES-GCM which is catastrophic).
15//! - **Per-block random nonces**: 12-byte random nonce per encrypt operation.
16//! - **Zero-copy where possible**: Encrypt in-place for WAL append path.
17//! - **Key wrapping**: Data Encryption Key (DEK) is wrapped by a Key Encryption
18//!   Key (KEK) loaded from Kubernetes Secrets or env vars.
19//!
20//! ## Wire Format
21//!
22//! ```text
23//! [1 byte: version] [12 bytes: nonce] [N bytes: ciphertext+tag]
24//! ```
25//!
26//! Version 1: AES-256-GCM-SIV with 12-byte nonce, 16-byte auth tag appended
27//! to ciphertext by the AEAD.
28//!
29//! ## Performance Notes
30//!
31//! On x86_64 with AES-NI: ~4 GB/s encryption throughput (hardware-accelerated).
32//! The overhead is negligible compared to disk I/O.
33
34use aes_gcm_siv::{
35    Aes256GcmSiv, Nonce,
36    aead::{Aead, KeyInit, OsRng, Payload},
37};
38use hkdf::Hkdf;
39use rand::RngCore;
40use sha2::Sha256;
41use sochdb_core::SochDBError;
42use zeroize::Zeroize;
43
44/// Current encryption format version.
45const ENCRYPTION_VERSION: u8 = 1;
46/// Nonce size for AES-256-GCM-SIV.
47const NONCE_SIZE: usize = 12;
48/// Header size: 1 (version) + 12 (nonce).
49const HEADER_SIZE: usize = 1 + NONCE_SIZE;
50
51/// Data-at-rest encryption engine.
52///
53/// Wraps AES-256-GCM-SIV with random nonces. Thread-safe (the cipher
54/// is `Send + Sync` and nonce generation uses OS randomness).
55pub struct EncryptionEngine {
56    cipher: Aes256GcmSiv,
57    /// Whether encryption is active (false = passthrough)
58    enabled: bool,
59}
60
61impl EncryptionEngine {
62    /// Create an encryption engine with the given 256-bit key.
63    ///
64    /// The key must be exactly 32 bytes. Typically loaded from
65    /// Kubernetes Secrets or the `SOCHDB_ENCRYPTION_KEY` env var.
66    pub fn new(key: &[u8; 32]) -> Self {
67        let cipher =
68            Aes256GcmSiv::new_from_slice(key).expect("AES-256-GCM-SIV key must be 32 bytes");
69        Self {
70            cipher,
71            enabled: true,
72        }
73    }
74
75    /// Create an encryption engine from a zeroize-on-drop [`EncryptionKey`].
76    ///
77    /// Preferred over [`Self::new`] on the live path: the caller holds the key
78    /// material in a wiping container rather than a bare `[u8; 32]`.
79    pub fn from_key(key: &EncryptionKey) -> Self {
80        Self::new(key.as_bytes())
81    }
82
83    /// Create a disabled (passthrough) encryption engine.
84    ///
85    /// `encrypt()` and `decrypt()` are identity operations when disabled.
86    pub fn disabled() -> Self {
87        // Use a dummy key — cipher is never called when disabled
88        let key = [0u8; 32];
89        let cipher =
90            Aes256GcmSiv::new_from_slice(&key).expect("AES-256-GCM-SIV key must be 32 bytes");
91        Self {
92            cipher,
93            enabled: false,
94        }
95    }
96
97    /// Whether encryption is active.
98    pub fn is_enabled(&self) -> bool {
99        self.enabled
100    }
101
102    /// Encrypt a plaintext block.
103    ///
104    /// Returns `[version(1) | nonce(12) | ciphertext+tag(N+16)]`.
105    ///
106    /// # Performance
107    ///
108    /// ~4 GB/s on x86_64 with AES-NI. The overhead is the 13-byte header
109    /// plus 16-byte auth tag per block.
110    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, EncryptionError> {
111        self.encrypt_with_aad(plaintext, &[])
112    }
113
114    /// Encrypt a plaintext block, binding `aad` as additional authenticated data.
115    ///
116    /// Returns `[version(1) | nonce(12) | ciphertext+tag(N+16)]`. The `aad` is
117    /// NOT stored in the output — it is authenticated only, and the reader MUST
118    /// reconstruct the identical `aad` (e.g. `{format_version, db_uuid,
119    /// dek_epoch, record_LSN}`) from its own trusted state. Binding framing/
120    /// position as AAD is what prevents an attacker (or a corrupt/misdirected
121    /// write) from reordering, duplicating, splicing, or downgrading WAL records
122    /// — GCM-SIV alone authenticates only the isolated record body.
123    ///
124    /// AAD scope is part of the on-disk format and cannot be widened later
125    /// without a format break, so callers must commit to the full AAD tuple from
126    /// the first encrypted byte written.
127    pub fn encrypt_with_aad(
128        &self,
129        plaintext: &[u8],
130        aad: &[u8],
131    ) -> Result<Vec<u8>, EncryptionError> {
132        if !self.enabled {
133            // Passthrough MUST stay byte-identical to the legacy plaintext frame
134            // so an un-keyed DB is wire-compatible with pre-encryption binaries.
135            return Ok(plaintext.to_vec());
136        }
137
138        // Generate random nonce
139        let mut nonce_bytes = [0u8; NONCE_SIZE];
140        OsRng.fill_bytes(&mut nonce_bytes);
141        let nonce = Nonce::from_slice(&nonce_bytes);
142
143        let ciphertext = self
144            .cipher
145            .encrypt(
146                nonce,
147                Payload {
148                    msg: plaintext,
149                    aad,
150                },
151            )
152            .map_err(|_| EncryptionError::EncryptFailed)?;
153
154        // Build output: version + nonce + ciphertext
155        let mut output = Vec::with_capacity(HEADER_SIZE + ciphertext.len());
156        output.push(ENCRYPTION_VERSION);
157        output.extend_from_slice(&nonce_bytes);
158        output.extend_from_slice(&ciphertext);
159
160        Ok(output)
161    }
162
163    /// Decrypt an encrypted block produced by `encrypt()`.
164    ///
165    /// Validates the version byte and authentication tag.
166    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, EncryptionError> {
167        self.decrypt_with_aad(encrypted, &[])
168    }
169
170    /// Decrypt an encrypted block, verifying it against the same `aad` the writer
171    /// bound via [`Self::encrypt_with_aad`]. A mismatched `aad` (e.g. a record
172    /// that was moved to a different LSN/position) fails authentication exactly
173    /// like a wrong key or tampered ciphertext — surfaced as
174    /// [`EncryptionError::DecryptFailed`], which callers MUST treat as a hard
175    /// error, never as a clean end-of-stream.
176    pub fn decrypt_with_aad(
177        &self,
178        encrypted: &[u8],
179        aad: &[u8],
180    ) -> Result<Vec<u8>, EncryptionError> {
181        if !self.enabled {
182            return Ok(encrypted.to_vec());
183        }
184
185        if encrypted.len() < HEADER_SIZE + 16 {
186            return Err(EncryptionError::InvalidFormat(
187                "Data too short for encrypted block".into(),
188            ));
189        }
190
191        let version = encrypted[0];
192        if version != ENCRYPTION_VERSION {
193            return Err(EncryptionError::UnsupportedVersion(version));
194        }
195
196        let nonce = Nonce::from_slice(&encrypted[1..HEADER_SIZE]);
197        let ciphertext = &encrypted[HEADER_SIZE..];
198
199        self.cipher
200            .decrypt(
201                nonce,
202                Payload {
203                    msg: ciphertext,
204                    aad,
205                },
206            )
207            .map_err(|_| EncryptionError::DecryptFailed)
208    }
209
210    /// Encrypt in-place for zero-copy WAL append.
211    ///
212    /// Prepends the header to the buffer and encrypts the payload region.
213    /// The buffer is resized to accommodate the header + auth tag.
214    pub fn encrypt_in_place(&self, buffer: &mut Vec<u8>) -> Result<(), EncryptionError> {
215        if !self.enabled {
216            return Ok(());
217        }
218
219        let encrypted = self.encrypt(buffer)?;
220        *buffer = encrypted;
221        Ok(())
222    }
223}
224
225/// HKDF-SHA256 expand: derive a 32-byte subkey from input key material.
226///
227/// Used so the operator-supplied secret (the KEK) is never used verbatim as a
228/// cipher key: the per-DB DEK and the keyring wrapping-key are both derived via
229/// this with a per-DB random salt and a distinct `info` label. The same env
230/// secret across two databases therefore yields independent keys.
231pub fn derive_subkey(ikm: &[u8], salt: &[u8], info: &[u8]) -> EncryptionKey {
232    let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
233    let mut okm = [0u8; 32];
234    hk.expand(info, &mut okm)
235        .expect("HKDF expand of 32 bytes never fails");
236    let key = EncryptionKey::new(okm);
237    okm.zeroize();
238    key
239}
240
241/// Encryption error types.
242#[derive(Debug)]
243pub enum EncryptionError {
244    /// Encryption operation failed
245    EncryptFailed,
246    /// Decryption failed (wrong key or tampered data)
247    DecryptFailed,
248    /// Invalid encrypted data format
249    InvalidFormat(String),
250    /// Unsupported encryption version
251    UnsupportedVersion(u8),
252}
253
254impl std::fmt::Display for EncryptionError {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        match self {
257            EncryptionError::EncryptFailed => write!(f, "Encryption failed"),
258            EncryptionError::DecryptFailed => {
259                write!(f, "Decryption failed (wrong key or tampered data)")
260            }
261            EncryptionError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
262            EncryptionError::UnsupportedVersion(v) => {
263                write!(f, "Unsupported encryption version: {}", v)
264            }
265        }
266    }
267}
268
269impl EncryptionError {
270    /// Whether this error means the bytes failed integrity/authentication and
271    /// therefore CANNOT be a clean torn-tail. A torn write truncates the file —
272    /// it never produces a full-length frame that fails the AEAD tag, an
273    /// unsupported version, or a malformed envelope. WAL replay must treat these
274    /// as hard, recovery-aborting errors (wrong/missing key, key-epoch mismatch,
275    /// bit-rot, or tampering), never as end-of-WAL.
276    pub fn is_integrity_failure(&self) -> bool {
277        matches!(
278            self,
279            EncryptionError::DecryptFailed
280                | EncryptionError::InvalidFormat(_)
281                | EncryptionError::UnsupportedVersion(_)
282        )
283    }
284}
285
286impl std::error::Error for EncryptionError {}
287
288impl From<EncryptionError> for SochDBError {
289    fn from(e: EncryptionError) -> Self {
290        // Map to the dedicated `Encryption` variant (NOT `Io`) so replay loops
291        // never mistake an authentication failure for a torn-tail EOF.
292        SochDBError::Encryption(e.to_string())
293    }
294}
295
296/// Generate a new random 256-bit encryption key.
297///
298/// Use this to generate a key for `SOCHDB_ENCRYPTION_KEY`.
299/// The returned key should be base64-encoded and stored in
300/// Kubernetes Secrets.
301pub fn generate_key() -> [u8; 32] {
302    let mut key = [0u8; 32];
303    OsRng.fill_bytes(&mut key);
304    key
305}
306
307/// A wrapper that zeroizes the key material on drop.
308#[derive(Zeroize)]
309#[zeroize(drop)]
310pub struct EncryptionKey {
311    bytes: [u8; 32],
312}
313
314impl EncryptionKey {
315    pub fn new(bytes: [u8; 32]) -> Self {
316        Self { bytes }
317    }
318
319    pub fn as_bytes(&self) -> &[u8; 32] {
320        &self.bytes
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_encrypt_decrypt_roundtrip() {
330        let key = generate_key();
331        let engine = EncryptionEngine::new(&key);
332
333        let plaintext = b"Hello, SochDB enterprise encryption!";
334        let encrypted = engine.encrypt(plaintext).unwrap();
335
336        // Encrypted should be larger (header + auth tag)
337        assert!(encrypted.len() > plaintext.len());
338        assert_eq!(encrypted[0], ENCRYPTION_VERSION);
339
340        let decrypted = engine.decrypt(&encrypted).unwrap();
341        assert_eq!(decrypted, plaintext);
342    }
343
344    #[test]
345    fn test_encrypt_empty() {
346        let key = generate_key();
347        let engine = EncryptionEngine::new(&key);
348
349        let encrypted = engine.encrypt(b"").unwrap();
350        let decrypted = engine.decrypt(&encrypted).unwrap();
351        assert!(decrypted.is_empty());
352    }
353
354    #[test]
355    fn test_encrypt_large_block() {
356        let key = generate_key();
357        let engine = EncryptionEngine::new(&key);
358
359        // 1 MB block
360        let plaintext: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
361        let encrypted = engine.encrypt(&plaintext).unwrap();
362        let decrypted = engine.decrypt(&encrypted).unwrap();
363        assert_eq!(decrypted, plaintext);
364    }
365
366    #[test]
367    fn test_wrong_key_fails() {
368        let key1 = generate_key();
369        let key2 = generate_key();
370        let engine1 = EncryptionEngine::new(&key1);
371        let engine2 = EncryptionEngine::new(&key2);
372
373        let encrypted = engine1.encrypt(b"secret data").unwrap();
374        let result = engine2.decrypt(&encrypted);
375        assert!(result.is_err());
376    }
377
378    #[test]
379    fn test_tampered_data_fails() {
380        let key = generate_key();
381        let engine = EncryptionEngine::new(&key);
382
383        let mut encrypted = engine.encrypt(b"important data").unwrap();
384        // Flip a byte in the ciphertext
385        let last = encrypted.len() - 1;
386        encrypted[last] ^= 0xFF;
387
388        let result = engine.decrypt(&encrypted);
389        assert!(result.is_err());
390    }
391
392    #[test]
393    fn test_disabled_passthrough() {
394        let engine = EncryptionEngine::disabled();
395
396        let plaintext = b"no encryption here";
397        let encrypted = engine.encrypt(plaintext).unwrap();
398        assert_eq!(encrypted, plaintext);
399
400        let decrypted = engine.decrypt(&encrypted).unwrap();
401        assert_eq!(decrypted, plaintext);
402    }
403
404    #[test]
405    fn test_unique_nonces() {
406        let key = generate_key();
407        let engine = EncryptionEngine::new(&key);
408
409        let enc1 = engine.encrypt(b"same plaintext").unwrap();
410        let enc2 = engine.encrypt(b"same plaintext").unwrap();
411
412        // Nonces should differ even for same plaintext
413        assert_ne!(enc1[1..13], enc2[1..13]);
414        // Ciphertexts should differ
415        assert_ne!(enc1, enc2);
416    }
417
418    #[test]
419    fn test_invalid_format() {
420        let key = generate_key();
421        let engine = EncryptionEngine::new(&key);
422
423        // Too short
424        assert!(engine.decrypt(&[1, 2, 3]).is_err());
425        // Wrong version
426        let fake = vec![99u8; 50];
427        assert!(engine.decrypt(&fake).is_err());
428    }
429
430    #[test]
431    fn test_key_zeroize() {
432        let key = EncryptionKey::new(generate_key());
433        assert_ne!(key.as_bytes(), &[0u8; 32]);
434        drop(key);
435        // After drop, memory should be zeroed (we can't read it, but the Zeroize
436        // derive guarantees it)
437    }
438
439    #[test]
440    fn test_aad_roundtrip() {
441        let key = generate_key();
442        let engine = EncryptionEngine::new(&key);
443        let aad = b"v1|db-uuid|epoch=0|lsn=42";
444
445        let ct = engine.encrypt_with_aad(b"payload", aad).unwrap();
446        let pt = engine.decrypt_with_aad(&ct, aad).unwrap();
447        assert_eq!(pt, b"payload");
448    }
449
450    #[test]
451    fn test_aad_mismatch_fails_like_wrong_key() {
452        let key = generate_key();
453        let engine = EncryptionEngine::new(&key);
454
455        // A record encrypted at lsn=42 must NOT authenticate when the reader
456        // reconstructs aad for a different position (splice/reorder defense).
457        let ct = engine
458            .encrypt_with_aad(b"committed record", b"...|lsn=42")
459            .unwrap();
460        let err = engine.decrypt_with_aad(&ct, b"...|lsn=43").unwrap_err();
461        assert!(matches!(err, EncryptionError::DecryptFailed));
462        assert!(err.is_integrity_failure());
463    }
464
465    #[test]
466    fn test_no_aad_is_not_same_as_some_aad() {
467        let key = generate_key();
468        let engine = EncryptionEngine::new(&key);
469        let ct = engine.encrypt_with_aad(b"x", b"bound").unwrap();
470        // Decrypting the same ciphertext with empty aad must fail.
471        assert!(engine.decrypt(&ct).is_err());
472        // And the plain encrypt()/decrypt() pair (empty aad) round-trips.
473        let ct2 = engine.encrypt(b"x").unwrap();
474        assert_eq!(engine.decrypt(&ct2).unwrap(), b"x");
475    }
476
477    #[test]
478    fn test_integrity_failure_classification() {
479        assert!(EncryptionError::DecryptFailed.is_integrity_failure());
480        assert!(EncryptionError::UnsupportedVersion(9).is_integrity_failure());
481        assert!(EncryptionError::InvalidFormat("x".into()).is_integrity_failure());
482        // EncryptFailed is a write-side fault, not a replay terminator concern.
483        assert!(!EncryptionError::EncryptFailed.is_integrity_failure());
484    }
485
486    #[test]
487    fn test_hkdf_deterministic_and_salt_separated() {
488        let kek = b"operator-supplied-kek-material";
489        let salt_a = [1u8; 16];
490        let salt_b = [2u8; 16];
491
492        // Same inputs -> same key (deterministic unwrap on reopen).
493        let k1 = derive_subkey(kek, &salt_a, b"sochdb/dek/v1");
494        let k2 = derive_subkey(kek, &salt_a, b"sochdb/dek/v1");
495        assert_eq!(k1.as_bytes(), k2.as_bytes());
496
497        // Different salt (per-DB) -> different key, so the same env secret across
498        // two databases yields independent DEKs.
499        let k3 = derive_subkey(kek, &salt_b, b"sochdb/dek/v1");
500        assert_ne!(k1.as_bytes(), k3.as_bytes());
501
502        // Different info label -> different key (DEK vs wrapping-key separation).
503        let k4 = derive_subkey(kek, &salt_a, b"sochdb/wrap/v1");
504        assert_ne!(k1.as_bytes(), k4.as_bytes());
505
506        // Derived key is usable.
507        let engine = EncryptionEngine::from_key(&k1);
508        let ct = engine.encrypt(b"hi").unwrap();
509        assert_eq!(engine.decrypt(&ct).unwrap(), b"hi");
510    }
511
512    #[test]
513    fn test_encrypt_in_place() {
514        let key = generate_key();
515        let engine = EncryptionEngine::new(&key);
516
517        let original = b"WAL entry payload".to_vec();
518        let mut buffer = original.clone();
519        engine.encrypt_in_place(&mut buffer).unwrap();
520
521        assert_ne!(buffer, original);
522        let decrypted = engine.decrypt(&buffer).unwrap();
523        assert_eq!(decrypted, original);
524    }
525}