Skip to main content

neleus_db/
encryption.rs

1//! Authenticated, at-rest encryption for content-addressed blobs and objects.
2//!
3//! Design overview
4//! ---------------
5//!
6//! 1. A 32-byte **master key** is derived once at `Database::open` from the
7//!    user's password and a long-lived random `master_salt` (persisted in
8//!    `meta/config.json`) via PBKDF2-HMAC-SHA256 at the configured iteration
9//!    count (default 600k, OWASP-2024).
10//!
11//! 2. For each encryption operation we generate a fresh random per-blob
12//!    `salt` and `nonce`, then derive a **per-blob key** via
13//!    HKDF-SHA256(master_key, salt, info=algorithm). The per-blob key is
14//!    used once with an AEAD (AES-256-GCM or ChaCha20-Poly1305) and zeroized
15//!    immediately.
16//!
17//! 3. The on-disk envelope (`EncryptedData v3`) is canonical DAG-CBOR with
18//!    `salt`, `nonce`, and `ciphertext` only — no per-blob KDF parameters,
19//!    since the master KDF config lives at the database level and is fixed
20//!    for a given DB. v3 supersedes the v2 JSON envelope; the encoder/decoder
21//!    runs ~10–15× faster and the bytes are ~40% smaller.
22//!
23//! Why master + HKDF
24//! -----------------
25//!
26//! Earlier versions ran PBKDF2 with 210k iterations *per blob operation*,
27//! which made encryption unusable at any real throughput (~100 ms per read
28//! or write). With master+HKDF, the PBKDF2 cost is paid once at open; each
29//! subsequent operation pays nanosecond-scale HKDF.
30//!
31//! Why zeroize
32//! -----------
33//!
34//! Passwords and derived keys are wrapped in `Zeroizing<...>` so they are
35//! wiped from memory on drop. Decrypted plaintext flows through callers and
36//! escapes our control, so it's not zeroized here — document and move on.
37
38use std::sync::Arc;
39
40use aes_gcm::aead::{Aead, KeyInit};
41use aes_gcm::{Aes256Gcm, Nonce as AesNonce};
42use anyhow::{Result, anyhow};
43use chacha20poly1305::{ChaCha20Poly1305, Nonce as ChaChaNonce};
44use hkdf::Hkdf;
45use pbkdf2::pbkdf2_hmac;
46use serde::{Deserialize, Serialize};
47use sha2::Sha256;
48use zeroize::Zeroizing;
49
50/// On-disk envelope format version emitted by current writes.
51const ENVELOPE_VERSION: u32 = 3;
52
53/// PBKDF2 iteration count below which the config is rejected. Matches OWASP
54/// 2024 guidance for PBKDF2-HMAC-SHA256.
55pub const MIN_KDF_ITERATIONS: u32 = 600_000;
56
57/// AEAD nonce length in bytes (AES-256-GCM and ChaCha20-Poly1305 both use 12).
58const AEAD_NONCE_LEN: usize = 12;
59/// AEAD key length in bytes (both algorithms use 32).
60const AEAD_KEY_LEN: usize = 32;
61/// HKDF salt length in bytes (per-blob, random).
62const PER_BLOB_SALT_LEN: usize = 16;
63/// Master salt length in bytes (long-lived, persisted in config).
64pub const MASTER_SALT_LEN: usize = 16;
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct EncryptionConfig {
68    pub enabled: bool,
69    pub algorithm: String,
70    pub kdf_iterations: u32,
71    /// Long-lived random salt for master-key derivation. Persisted in
72    /// `meta/config.json`. Hex-encoded so the config remains human-readable.
73    /// Set on first open of an encryption-enabled database; never rotated.
74    /// Rotating the *password* (via `Database::rotate_encryption_key`)
75    /// rewrites every ciphertext but leaves `master_salt` unchanged.
76    #[serde(default, skip_serializing_if = "String::is_empty")]
77    pub master_salt: String,
78}
79
80impl Default for EncryptionConfig {
81    fn default() -> Self {
82        Self {
83            enabled: false,
84            algorithm: "aes-256-gcm".to_string(),
85            kdf_iterations: MIN_KDF_ITERATIONS,
86            master_salt: String::new(),
87        }
88    }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct EncryptedData {
93    pub version: u32,
94    pub algorithm: String,
95    pub salt: Vec<u8>,
96    pub nonce: Vec<u8>,
97    pub ciphertext: Vec<u8>,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101enum Algorithm {
102    Aes256Gcm,
103    ChaCha20Poly1305,
104}
105
106impl Algorithm {
107    fn parse(name: &str) -> Result<Self> {
108        match name.to_ascii_lowercase().as_str() {
109            "aes-256-gcm" => Ok(Self::Aes256Gcm),
110            "chacha20-poly1305" => Ok(Self::ChaCha20Poly1305),
111            other => Err(anyhow!(
112                "unsupported encryption algorithm '{}'; expected aes-256-gcm or chacha20-poly1305",
113                other
114            )),
115        }
116    }
117
118    fn name(self) -> &'static str {
119        match self {
120            Self::Aes256Gcm => "aes-256-gcm",
121            Self::ChaCha20Poly1305 => "chacha20-poly1305",
122        }
123    }
124}
125
126/// Runtime encryption handle. Owns the master key and zeroizes it on drop.
127///
128/// Constructed once per `Database::open` via `from_config`. Cheap to clone
129/// (the wrapped state lives behind `Arc`).
130#[derive(Clone)]
131pub struct EncryptionRuntime {
132    inner: Arc<RuntimeInner>,
133}
134
135struct RuntimeInner {
136    algorithm: Algorithm,
137    master_key: Zeroizing<Vec<u8>>,
138}
139
140impl std::fmt::Debug for EncryptionRuntime {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        f.debug_struct("EncryptionRuntime")
143            .field("algorithm", &self.inner.algorithm.name())
144            .field("master_key", &"<redacted>")
145            .finish()
146    }
147}
148
149impl EncryptionRuntime {
150    /// Derive the master key and build a runtime.
151    ///
152    /// `password` is consumed and zeroized before this function returns.
153    /// Errors if encryption is disabled in `config`, the password is empty,
154    /// or the config fails validation.
155    pub fn from_config(config: EncryptionConfig, password: String) -> Result<Self> {
156        if !config.enabled {
157            return Err(anyhow!(
158                "encryption runtime requires enabled encryption config"
159            ));
160        }
161        validate_config(&config)?;
162        let password = Zeroizing::new(password);
163        if password.is_empty() {
164            return Err(anyhow!("encryption password cannot be empty"));
165        }
166        let master_salt = hex::decode(&config.master_salt)
167            .map_err(|_| anyhow!("master_salt is not valid hex"))?;
168        if master_salt.len() != MASTER_SALT_LEN {
169            return Err(anyhow!(
170                "master_salt must be {} bytes (got {})",
171                MASTER_SALT_LEN,
172                master_salt.len()
173            ));
174        }
175        let algorithm = Algorithm::parse(&config.algorithm)?;
176
177        let mut master_key = Zeroizing::new(vec![0u8; AEAD_KEY_LEN]);
178        pbkdf2_hmac::<Sha256>(
179            password.as_bytes(),
180            &master_salt,
181            config.kdf_iterations,
182            &mut master_key,
183        );
184
185        Ok(Self {
186            inner: Arc::new(RuntimeInner {
187                algorithm,
188                master_key,
189            }),
190        })
191    }
192
193    /// Encrypt `plaintext`, returning the serialized envelope.
194    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
195        let salt = utils::random_bytes(PER_BLOB_SALT_LEN)?;
196        let nonce = utils::random_bytes(AEAD_NONCE_LEN)?;
197        let per_blob_key = derive_per_blob_key(
198            &self.inner.master_key,
199            &salt,
200            self.inner.algorithm.name(),
201        )?;
202        let ciphertext =
203            aead_encrypt(self.inner.algorithm, per_blob_key.as_slice(), &nonce, plaintext)?;
204
205        let envelope = EncryptedData {
206            version: ENVELOPE_VERSION,
207            algorithm: self.inner.algorithm.name().to_string(),
208            salt,
209            nonce,
210            ciphertext,
211        };
212        crate::canonical::to_cbor(&envelope)
213    }
214
215    /// Decrypt a serialized envelope.
216    pub fn decrypt(&self, bytes: &[u8]) -> Result<Vec<u8>> {
217        let envelope: EncryptedData = crate::canonical::from_cbor(bytes)
218            .map_err(|e| anyhow!("invalid encryption envelope: {e}"))?;
219        if envelope.version != ENVELOPE_VERSION {
220            return Err(anyhow!(
221                "unsupported envelope version {}; this build only reads v{}",
222                envelope.version,
223                ENVELOPE_VERSION
224            ));
225        }
226        let algorithm = Algorithm::parse(&envelope.algorithm)?;
227        if algorithm != self.inner.algorithm {
228            return Err(anyhow!(
229                "algorithm mismatch: runtime={} envelope={}",
230                self.inner.algorithm.name(),
231                envelope.algorithm
232            ));
233        }
234        if envelope.nonce.len() != AEAD_NONCE_LEN {
235            return Err(anyhow!(
236                "invalid nonce size: expected {}, got {}",
237                AEAD_NONCE_LEN,
238                envelope.nonce.len()
239            ));
240        }
241        let per_blob_key = derive_per_blob_key(
242            &self.inner.master_key,
243            &envelope.salt,
244            self.inner.algorithm.name(),
245        )?;
246        aead_decrypt(
247            self.inner.algorithm,
248            per_blob_key.as_slice(),
249            &envelope.nonce,
250            &envelope.ciphertext,
251        )
252    }
253
254    pub fn algorithm(&self) -> &'static str {
255        self.inner.algorithm.name()
256    }
257
258    pub fn is_enabled(&self) -> bool {
259        true
260    }
261}
262
263/// Validate an `EncryptionConfig` before constructing a runtime.
264pub fn validate_config(config: &EncryptionConfig) -> Result<()> {
265    if !config.enabled {
266        return Err(anyhow!("encryption is disabled in config"));
267    }
268    Algorithm::parse(&config.algorithm)?;
269    if config.kdf_iterations < MIN_KDF_ITERATIONS {
270        return Err(anyhow!(
271            "kdf_iterations must be at least {} (OWASP 2024 minimum for PBKDF2-HMAC-SHA256)",
272            MIN_KDF_ITERATIONS
273        ));
274    }
275    if config.master_salt.is_empty() {
276        return Err(anyhow!(
277            "master_salt is missing from config; call ensure_master_salt before runtime construction"
278        ));
279    }
280    Ok(())
281}
282
283/// Ensure the config has a `master_salt`. If missing, generate one and set it.
284/// Returns `true` if a new salt was generated and the config should be persisted.
285pub fn ensure_master_salt(config: &mut EncryptionConfig) -> Result<bool> {
286    if !config.master_salt.is_empty() {
287        return Ok(false);
288    }
289    let salt = utils::random_bytes(MASTER_SALT_LEN)?;
290    config.master_salt = hex::encode(&salt);
291    Ok(true)
292}
293
294fn derive_per_blob_key(
295    master_key: &[u8],
296    salt: &[u8],
297    algorithm_name: &str,
298) -> Result<Zeroizing<[u8; AEAD_KEY_LEN]>> {
299    let hk = Hkdf::<Sha256>::new(Some(salt), master_key);
300    let mut key = Zeroizing::new([0u8; AEAD_KEY_LEN]);
301    hk.expand(algorithm_name.as_bytes(), key.as_mut())
302        .map_err(|e| anyhow!("HKDF expand failed: {e}"))?;
303    Ok(key)
304}
305
306fn aead_encrypt(
307    algorithm: Algorithm,
308    key: &[u8],
309    nonce: &[u8],
310    plaintext: &[u8],
311) -> Result<Vec<u8>> {
312    match algorithm {
313        Algorithm::Aes256Gcm => {
314            let cipher = Aes256Gcm::new_from_slice(key)
315                .map_err(|_| anyhow!("invalid AES-256-GCM key size"))?;
316            cipher
317                .encrypt(AesNonce::from_slice(nonce), plaintext)
318                .map_err(|e| anyhow!("AES-256-GCM encryption failed: {e}"))
319        }
320        Algorithm::ChaCha20Poly1305 => {
321            let cipher = ChaCha20Poly1305::new_from_slice(key)
322                .map_err(|_| anyhow!("invalid ChaCha20-Poly1305 key size"))?;
323            cipher
324                .encrypt(ChaChaNonce::from_slice(nonce), plaintext)
325                .map_err(|e| anyhow!("ChaCha20-Poly1305 encryption failed: {e}"))
326        }
327    }
328}
329
330fn aead_decrypt(
331    algorithm: Algorithm,
332    key: &[u8],
333    nonce: &[u8],
334    ciphertext: &[u8],
335) -> Result<Vec<u8>> {
336    match algorithm {
337        Algorithm::Aes256Gcm => {
338            let cipher = Aes256Gcm::new_from_slice(key)
339                .map_err(|_| anyhow!("invalid AES-256-GCM key size"))?;
340            cipher
341                .decrypt(AesNonce::from_slice(nonce), ciphertext)
342                .map_err(|_| {
343                    anyhow!("AES-256-GCM authentication failed (wrong password or tampered data)")
344                })
345        }
346        Algorithm::ChaCha20Poly1305 => {
347            let cipher = ChaCha20Poly1305::new_from_slice(key)
348                .map_err(|_| anyhow!("invalid ChaCha20-Poly1305 key size"))?;
349            cipher
350                .decrypt(ChaChaNonce::from_slice(nonce), ciphertext)
351                .map_err(|_| {
352                    anyhow!(
353                        "ChaCha20-Poly1305 authentication failed (wrong password or tampered data)"
354                    )
355                })
356        }
357    }
358}
359
360pub mod utils {
361    use super::*;
362
363    pub fn random_bytes(len: usize) -> Result<Vec<u8>> {
364        if len == 0 {
365            return Ok(Vec::new());
366        }
367        let mut output = vec![0u8; len];
368        getrandom::getrandom(&mut output)
369            .map_err(|e| anyhow!("secure random generation failed: {e}"))?;
370        Ok(output)
371    }
372}
373
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn enabled_aes_config() -> EncryptionConfig {
380        let mut c = EncryptionConfig {
381            enabled: true,
382            algorithm: "aes-256-gcm".into(),
383            ..EncryptionConfig::default()
384        };
385        ensure_master_salt(&mut c).unwrap();
386        c
387    }
388
389    fn enabled_chacha_config() -> EncryptionConfig {
390        let mut c = EncryptionConfig {
391            enabled: true,
392            algorithm: "chacha20-poly1305".into(),
393            ..EncryptionConfig::default()
394        };
395        ensure_master_salt(&mut c).unwrap();
396        c
397    }
398
399    #[test]
400    fn defaults_are_safe() {
401        let c = EncryptionConfig::default();
402        assert!(!c.enabled);
403        assert_eq!(c.algorithm, "aes-256-gcm");
404        assert_eq!(c.kdf_iterations, MIN_KDF_ITERATIONS);
405    }
406
407    #[test]
408    fn ensure_master_salt_only_generates_once() {
409        let mut c = EncryptionConfig {
410            enabled: true,
411            algorithm: "aes-256-gcm".into(),
412            ..EncryptionConfig::default()
413        };
414        assert!(ensure_master_salt(&mut c).unwrap());
415        let salt = c.master_salt.clone();
416        assert!(!ensure_master_salt(&mut c).unwrap());
417        assert_eq!(c.master_salt, salt);
418    }
419
420    #[test]
421    fn config_below_min_iterations_rejected() {
422        let mut c = enabled_aes_config();
423        c.kdf_iterations = 100_000;
424        let err = validate_config(&c).unwrap_err();
425        assert!(err.to_string().contains("kdf_iterations"));
426    }
427
428    #[test]
429    fn config_without_master_salt_rejected() {
430        let c = EncryptionConfig {
431            enabled: true,
432            algorithm: "aes-256-gcm".into(),
433            ..EncryptionConfig::default()
434        };
435        let err = validate_config(&c).unwrap_err();
436        assert!(err.to_string().contains("master_salt"));
437    }
438
439    #[test]
440    fn aes_runtime_roundtrip() {
441        let runtime =
442            EncryptionRuntime::from_config(enabled_aes_config(), "strong-password".into())
443                .unwrap();
444        let plaintext = b"runtime payload";
445        let envelope = runtime.encrypt(plaintext).unwrap();
446        let decrypted = runtime.decrypt(&envelope).unwrap();
447        assert_eq!(plaintext, &decrypted[..]);
448        assert_eq!(runtime.algorithm(), "aes-256-gcm");
449    }
450
451    #[test]
452    fn chacha_runtime_roundtrip() {
453        let runtime =
454            EncryptionRuntime::from_config(enabled_chacha_config(), "strong-password".into())
455                .unwrap();
456        let plaintext = b"runtime payload";
457        let envelope = runtime.encrypt(plaintext).unwrap();
458        let decrypted = runtime.decrypt(&envelope).unwrap();
459        assert_eq!(plaintext, &decrypted[..]);
460        assert_eq!(runtime.algorithm(), "chacha20-poly1305");
461    }
462
463    #[test]
464    fn wrong_password_fails() {
465        let cfg = enabled_aes_config();
466        let runtime = EncryptionRuntime::from_config(cfg.clone(), "right".into()).unwrap();
467        let envelope = runtime.encrypt(b"x").unwrap();
468
469        let other = EncryptionRuntime::from_config(cfg, "wrong".into()).unwrap();
470        assert!(other.decrypt(&envelope).is_err());
471    }
472
473    /// Same plaintext encrypted twice must yield distinct envelopes
474    /// (different per-blob salt and nonce). This is what makes the encryption
475    /// IND-CPA across writes.
476    #[test]
477    fn same_plaintext_different_ciphertext() {
478        let runtime =
479            EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
480        let a = runtime.encrypt(b"same").unwrap();
481        let b = runtime.encrypt(b"same").unwrap();
482        assert_ne!(a, b);
483    }
484
485    /// Throughput sanity check: 100 encrypt ops must complete in well under
486    /// a second. Pre-refactor PBKDF2-per-blob would take ~10 seconds for
487    /// this loop on a modern laptop.
488    #[test]
489    fn per_op_cost_is_fast() {
490        let runtime =
491            EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
492        let start = std::time::Instant::now();
493        for i in 0..100 {
494            let payload = format!("payload-{i}");
495            let _ = runtime.encrypt(payload.as_bytes()).unwrap();
496        }
497        let elapsed = start.elapsed();
498        assert!(
499            elapsed < std::time::Duration::from_secs(1),
500            "100 encrypts took {:?}; per-op derivation is likely regressed",
501            elapsed
502        );
503    }
504
505    #[test]
506    fn older_envelope_versions_are_rejected() {
507        // A CBOR envelope with an older version field must be refused, even
508        // if every other field is well-formed.
509        let runtime =
510            EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
511        let older = EncryptedData {
512            version: ENVELOPE_VERSION - 1,
513            algorithm: "aes-256-gcm".into(),
514            salt: vec![0u8; PER_BLOB_SALT_LEN],
515            nonce: vec![0u8; AEAD_NONCE_LEN],
516            ciphertext: vec![0xde, 0xad, 0xbe, 0xef],
517        };
518        let bytes = crate::canonical::to_cbor(&older).unwrap();
519        let err = runtime.decrypt(&bytes).unwrap_err();
520        assert!(err.to_string().contains("unsupported envelope version"));
521    }
522
523    #[test]
524    fn legacy_json_envelope_is_rejected() {
525        // Pre-v3 envelopes were JSON; CBOR decode rejects them as malformed.
526        let json = br#"{"version":2,"algorithm":"aes-256-gcm","salt":"","nonce":"","ciphertext":""}"#;
527        let runtime =
528            EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
529        let err = runtime.decrypt(json).unwrap_err();
530        assert!(err.to_string().contains("invalid encryption envelope"));
531    }
532
533    #[test]
534    fn algorithm_mismatch_between_runtime_and_envelope_fails() {
535        let aes = EncryptionRuntime::from_config(enabled_aes_config(), "pw".into()).unwrap();
536        let envelope = aes.encrypt(b"x").unwrap();
537        let chacha =
538            EncryptionRuntime::from_config(enabled_chacha_config(), "pw".into()).unwrap();
539        let err = chacha.decrypt(&envelope).unwrap_err();
540        assert!(err.to_string().contains("algorithm mismatch"));
541    }
542
543    #[test]
544    fn empty_password_rejected() {
545        let err = EncryptionRuntime::from_config(enabled_aes_config(), "".into()).unwrap_err();
546        assert!(err.to_string().contains("password"));
547    }
548}