Skip to main content

qualia_core_db/crypto/
sanctuary_crypto.rs

1//! Sanctuary lane cryptography.
2//!
3//! This module derives 48 bytes of key material from a user PIN, splits it into
4//! a 32-byte AEAD key plus a 16-byte volume tweak, and performs zero-heap
5//! encryption and decryption using deterministic, domain-separated nonces.
6
7use core::fmt;
8
9use aes_gcm::aead::{AeadInOut, KeyInit};
10use pbkdf2::pbkdf2_hmac;
11use sha2::Sha256;
12use zeroize::{Zeroize, ZeroizeOnDrop};
13
14pub const SANCTUARY_CIPHER_KEY_BYTES: usize = 32;
15pub const SANCTUARY_TWEAK_BYTES: usize = 16;
16pub const SANCTUARY_KEY_MATERIAL_BYTES: usize = SANCTUARY_CIPHER_KEY_BYTES + SANCTUARY_TWEAK_BYTES;
17pub const SANCTUARY_TAG_BYTES: usize = 16;
18pub const SANCTUARY_GCM_NONCE_BYTES: usize = 12;
19pub const SANCTUARY_XCHACHA_NONCE_BYTES: usize = 24;
20pub const DEFAULT_PBKDF2_ITERATIONS: u32 = 310_000;
21
22/// Argon2id defaults (memory-hard KDF; ADR D1). 64 MiB, 3 passes, 1 lane. Memory-hardness is what
23/// PBKDF2 lacks — it blunts GPU/ASIC offline brute-force of a weak PIN, the vault's real weak link.
24pub const ARGON2_M_COST_KIB: u32 = 65_536;
25pub const ARGON2_T_COST: u32 = 3;
26pub const ARGON2_P_COST: u32 = 1;
27
28const AES_GCM_DOMAIN: [u8; 4] = *b"QGCM";
29const CHACHA20_DOMAIN: [u8; 4] = *b"QCHA";
30const XCHACHA20_HEAD_DOMAIN: [u8; 8] = *b"Q42XCH1!";
31const XCHACHA20_TAIL_DOMAIN: [u8; 8] = *b"Q42XCH2!";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SanctuaryAeadAlgorithm {
35    Aes256Gcm,
36    ChaCha20Poly1305,
37    XChaCha20Poly1305,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SanctuaryCryptoError {
42    OutputBufferTooSmall,
43    EncryptionFailed,
44    DecryptionFailed,
45}
46
47/// Derived sanctuary key material.
48///
49/// The debug representation is intentionally redacted to avoid leaking secrets
50/// into logs or test output.
51#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
52pub struct SanctuaryKeyMaterial {
53    /// 32-byte cipher key for AEAD encryption.
54    pub cipher_key: [u8; SANCTUARY_CIPHER_KEY_BYTES],
55    /// 16-byte volume root tweak for nonce derivation.
56    pub volume_tweak: [u8; SANCTUARY_TWEAK_BYTES],
57}
58
59impl fmt::Debug for SanctuaryKeyMaterial {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(
62            "SanctuaryKeyMaterial { cipher_key: [REDACTED; 32], volume_tweak: [REDACTED; 16] }",
63        )
64    }
65}
66
67/// Derive 48 bytes of sanctuary key material from a PIN and salt.
68///
69/// Layout:
70/// `bytes[0..32]`   -> AEAD cipher key
71/// `bytes[32..48]`  -> volume tweak used for deterministic nonce derivation
72pub fn derive_sanctuary_key_material(
73    pin: &[u8],
74    salt: &[u8],
75    iterations: u32,
76) -> SanctuaryKeyMaterial {
77    let mut key_material = [0u8; SANCTUARY_KEY_MATERIAL_BYTES];
78    pbkdf2_hmac::<Sha256>(pin, salt, iterations, &mut key_material);
79
80    let mut cipher_key = [0u8; SANCTUARY_CIPHER_KEY_BYTES];
81    let mut volume_tweak = [0u8; SANCTUARY_TWEAK_BYTES];
82    cipher_key.copy_from_slice(&key_material[..SANCTUARY_CIPHER_KEY_BYTES]);
83    volume_tweak.copy_from_slice(&key_material[SANCTUARY_CIPHER_KEY_BYTES..]);
84    key_material.zeroize();
85
86    SanctuaryKeyMaterial {
87        cipher_key,
88        volume_tweak,
89    }
90}
91
92/// Derive the 48-byte sanctuary key material with **Argon2id** (memory-hard), same output layout as
93/// [`derive_sanctuary_key_material`]: `[0..32]` cipher key, `[32..48]` volume tweak.
94pub fn derive_sanctuary_key_material_argon2(
95    secret: &[u8],
96    salt: &[u8],
97    m_cost_kib: u32,
98    t_cost: u32,
99    p_cost: u32,
100) -> Result<SanctuaryKeyMaterial, String> {
101    use argon2::{Algorithm, Argon2, Params, Version};
102
103    let params = Params::new(
104        m_cost_kib,
105        t_cost,
106        p_cost,
107        Some(SANCTUARY_KEY_MATERIAL_BYTES),
108    )
109    .map_err(|e| format!("argon2 params: {e}"))?;
110    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
111
112    let mut key_material = [0u8; SANCTUARY_KEY_MATERIAL_BYTES];
113    argon
114        .hash_password_into(secret, salt, &mut key_material)
115        .map_err(|e| format!("argon2 derive: {e}"))?;
116
117    let mut cipher_key = [0u8; SANCTUARY_CIPHER_KEY_BYTES];
118    let mut volume_tweak = [0u8; SANCTUARY_TWEAK_BYTES];
119    cipher_key.copy_from_slice(&key_material[..SANCTUARY_CIPHER_KEY_BYTES]);
120    volume_tweak.copy_from_slice(&key_material[SANCTUARY_CIPHER_KEY_BYTES..]);
121    key_material.zeroize();
122
123    Ok(SanctuaryKeyMaterial {
124        cipher_key,
125        volume_tweak,
126    })
127}
128
129/// Convenience wrapper for call sites that only need the 32-byte cipher key.
130pub fn derive_lane_cipher_key(pin: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] {
131    let key_material = derive_sanctuary_key_material(pin, salt, iterations);
132    key_material.cipher_key
133}
134
135/// Derive a 96-bit nonce for AES-256-GCM.
136pub fn derive_chunk_nonce(volume_tweak: &[u8; 16], chunk_index_or_offset: u64) -> [u8; 12] {
137    derive_compact_nonce(volume_tweak, chunk_index_or_offset, AES_GCM_DOMAIN)
138}
139
140/// Derive a 96-bit nonce for ChaCha20-Poly1305.
141pub fn derive_chacha_nonce(volume_tweak: &[u8; 16], chunk_index_or_offset: u64) -> [u8; 12] {
142    derive_compact_nonce(volume_tweak, chunk_index_or_offset, CHACHA20_DOMAIN)
143}
144
145/// Derive a 192-bit nonce for XChaCha20-Poly1305.
146pub fn derive_xchacha_nonce(volume_tweak: &[u8; 16], chunk_index_or_offset: u64) -> [u8; 24] {
147    let index_bytes = chunk_index_or_offset.to_le_bytes();
148    let mut nonce = [0u8; SANCTUARY_XCHACHA_NONCE_BYTES];
149
150    for i in 0..8 {
151        nonce[i] = volume_tweak[i] ^ index_bytes[i] ^ XCHACHA20_HEAD_DOMAIN[i];
152        nonce[8 + i] = volume_tweak[8 + i] ^ XCHACHA20_TAIL_DOMAIN[i];
153        nonce[16 + i] = volume_tweak[15 - i] ^ index_bytes[i] ^ XCHACHA20_TAIL_DOMAIN[i];
154    }
155
156    nonce
157}
158
159/// Encrypt the caller-owned buffer in place without heap allocation.
160pub fn encrypt_sanctuary_chunk_in_place(
161    algorithm: SanctuaryAeadAlgorithm,
162    key_material: &SanctuaryKeyMaterial,
163    chunk_index: u64,
164    buffer: &mut [u8],
165    additional_data: &[u8],
166    tag_out: &mut [u8; SANCTUARY_TAG_BYTES],
167) -> Result<usize, SanctuaryCryptoError> {
168    match algorithm {
169        SanctuaryAeadAlgorithm::Aes256Gcm => {
170            let nonce = derive_chunk_nonce(&key_material.volume_tweak, chunk_index);
171            let key = <&aes_gcm::Key<aes_gcm::Aes256Gcm>>::try_from(&key_material.cipher_key[..])
172                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
173            let cipher = aes_gcm::Aes256Gcm::new(key);
174            let nonce = <&aes_gcm::aead::Nonce<aes_gcm::Aes256Gcm>>::try_from(&nonce[..])
175                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
176            let tag = cipher
177                .encrypt_inout_detached(nonce, additional_data, buffer.into())
178                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
179            tag_out.copy_from_slice(tag.as_slice());
180        }
181        SanctuaryAeadAlgorithm::ChaCha20Poly1305 => {
182            let nonce = derive_chacha_nonce(&key_material.volume_tweak, chunk_index);
183            let key = <&chacha20poly1305::Key>::try_from(&key_material.cipher_key[..])
184                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
185            let cipher = chacha20poly1305::ChaCha20Poly1305::new(key);
186            let nonce = <&chacha20poly1305::Nonce>::try_from(&nonce[..])
187                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
188            let tag = cipher
189                .encrypt_inout_detached(nonce, additional_data, buffer.into())
190                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
191            tag_out.copy_from_slice(tag.as_slice());
192        }
193        SanctuaryAeadAlgorithm::XChaCha20Poly1305 => {
194            let nonce = derive_xchacha_nonce(&key_material.volume_tweak, chunk_index);
195            let key = <&chacha20poly1305::Key>::try_from(&key_material.cipher_key[..])
196                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
197            let cipher = chacha20poly1305::XChaCha20Poly1305::new(key);
198            let nonce = <&chacha20poly1305::XNonce>::try_from(&nonce[..])
199                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
200            let tag = cipher
201                .encrypt_inout_detached(nonce, additional_data, buffer.into())
202                .map_err(|_| SanctuaryCryptoError::EncryptionFailed)?;
203            tag_out.copy_from_slice(tag.as_slice());
204        }
205    }
206
207    Ok(buffer.len())
208}
209
210/// Decrypt the caller-owned buffer in place without heap allocation.
211pub fn decrypt_sanctuary_chunk_in_place(
212    algorithm: SanctuaryAeadAlgorithm,
213    key_material: &SanctuaryKeyMaterial,
214    chunk_index: u64,
215    buffer: &mut [u8],
216    tag: &[u8; SANCTUARY_TAG_BYTES],
217    additional_data: &[u8],
218) -> Result<usize, SanctuaryCryptoError> {
219    match algorithm {
220        SanctuaryAeadAlgorithm::Aes256Gcm => {
221            let nonce = derive_chunk_nonce(&key_material.volume_tweak, chunk_index);
222            let key = <&aes_gcm::Key<aes_gcm::Aes256Gcm>>::try_from(&key_material.cipher_key[..])
223                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
224            let cipher = aes_gcm::Aes256Gcm::new(key);
225            let nonce = <&aes_gcm::aead::Nonce<aes_gcm::Aes256Gcm>>::try_from(&nonce[..])
226                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
227            let tag = <&aes_gcm::aead::Tag<aes_gcm::Aes256Gcm>>::try_from(&tag[..])
228                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
229            cipher
230                .decrypt_inout_detached(nonce, additional_data, buffer.into(), tag)
231                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
232        }
233        SanctuaryAeadAlgorithm::ChaCha20Poly1305 => {
234            let nonce = derive_chacha_nonce(&key_material.volume_tweak, chunk_index);
235            let key = <&chacha20poly1305::Key>::try_from(&key_material.cipher_key[..])
236                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
237            let cipher = chacha20poly1305::ChaCha20Poly1305::new(key);
238            let nonce = <&chacha20poly1305::Nonce>::try_from(&nonce[..])
239                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
240            let tag = <&chacha20poly1305::Tag>::try_from(&tag[..])
241                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
242            cipher
243                .decrypt_inout_detached(nonce, additional_data, buffer.into(), tag)
244                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
245        }
246        SanctuaryAeadAlgorithm::XChaCha20Poly1305 => {
247            let nonce = derive_xchacha_nonce(&key_material.volume_tweak, chunk_index);
248            let key = <&chacha20poly1305::Key>::try_from(&key_material.cipher_key[..])
249                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
250            let cipher = chacha20poly1305::XChaCha20Poly1305::new(key);
251            let nonce = <&chacha20poly1305::XNonce>::try_from(&nonce[..])
252                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
253            let tag = <&chacha20poly1305::Tag>::try_from(&tag[..])
254                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
255            cipher
256                .decrypt_inout_detached(nonce, additional_data, buffer.into(), tag)
257                .map_err(|_| SanctuaryCryptoError::DecryptionFailed)?;
258        }
259    }
260
261    Ok(buffer.len())
262}
263
264/// Copy plaintext into a caller-supplied output buffer, then encrypt in place.
265pub fn encrypt_sanctuary_chunk(
266    algorithm: SanctuaryAeadAlgorithm,
267    key_material: &SanctuaryKeyMaterial,
268    chunk_index: u64,
269    plaintext: &[u8],
270    ciphertext_out: &mut [u8],
271    tag_out: &mut [u8; SANCTUARY_TAG_BYTES],
272    additional_data: &[u8],
273) -> Result<usize, SanctuaryCryptoError> {
274    if ciphertext_out.len() < plaintext.len() {
275        return Err(SanctuaryCryptoError::OutputBufferTooSmall);
276    }
277
278    let ciphertext = &mut ciphertext_out[..plaintext.len()];
279    ciphertext.copy_from_slice(plaintext);
280    encrypt_sanctuary_chunk_in_place(
281        algorithm,
282        key_material,
283        chunk_index,
284        ciphertext,
285        additional_data,
286        tag_out,
287    )
288}
289
290/// Copy ciphertext into a caller-supplied output buffer, then decrypt in place.
291pub fn decrypt_sanctuary_chunk(
292    algorithm: SanctuaryAeadAlgorithm,
293    key_material: &SanctuaryKeyMaterial,
294    chunk_index: u64,
295    ciphertext: &[u8],
296    tag: &[u8; SANCTUARY_TAG_BYTES],
297    plaintext_out: &mut [u8],
298    additional_data: &[u8],
299) -> Result<usize, SanctuaryCryptoError> {
300    if plaintext_out.len() < ciphertext.len() {
301        return Err(SanctuaryCryptoError::OutputBufferTooSmall);
302    }
303
304    let plaintext = &mut plaintext_out[..ciphertext.len()];
305    plaintext.copy_from_slice(ciphertext);
306    decrypt_sanctuary_chunk_in_place(
307        algorithm,
308        key_material,
309        chunk_index,
310        plaintext,
311        tag,
312        additional_data,
313    )
314}
315
316fn derive_compact_nonce(
317    volume_tweak: &[u8; 16],
318    chunk_index_or_offset: u64,
319    domain: [u8; 4],
320) -> [u8; 12] {
321    let index_bytes = chunk_index_or_offset.to_le_bytes();
322    let mut nonce = [0u8; SANCTUARY_GCM_NONCE_BYTES];
323
324    for i in 0..4 {
325        nonce[i] = volume_tweak[i] ^ volume_tweak[12 + i] ^ domain[i];
326    }
327    for i in 0..8 {
328        nonce[4 + i] = volume_tweak[4 + i] ^ index_bytes[i];
329    }
330
331    nonce
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    const TEST_ITERATIONS: u32 = 1_000;
339
340    #[test]
341    fn test_sanctuary_key_derivation() {
342        let result =
343            derive_sanctuary_key_material(b"test_pin_123", b"test_salt_456", TEST_ITERATIONS);
344        let result2 =
345            derive_sanctuary_key_material(b"test_pin_123", b"test_salt_456", TEST_ITERATIONS);
346
347        assert_eq!(result.cipher_key.len(), SANCTUARY_CIPHER_KEY_BYTES);
348        assert_eq!(result.volume_tweak.len(), SANCTUARY_TWEAK_BYTES);
349        assert_ne!(result.cipher_key[..16], result.volume_tweak);
350        assert_eq!(result, result2);
351    }
352
353    #[test]
354    fn test_aes_nonce_derivation_uses_full_chunk_index() {
355        let tweak = [1u8; SANCTUARY_TWEAK_BYTES];
356        let low = derive_chunk_nonce(&tweak, 0);
357        let high = derive_chunk_nonce(&tweak, 1u64 << 40);
358
359        assert_ne!(low, high);
360    }
361
362    #[test]
363    fn test_nonce_domains_are_distinct() {
364        let tweak = [7u8; SANCTUARY_TWEAK_BYTES];
365        let chunk_index = 42u64;
366
367        let aes_nonce = derive_chunk_nonce(&tweak, chunk_index);
368        let chacha_nonce = derive_chacha_nonce(&tweak, chunk_index);
369        let xchacha_nonce = derive_xchacha_nonce(&tweak, chunk_index);
370
371        assert_ne!(aes_nonce, chacha_nonce);
372        assert_ne!(aes_nonce[..], xchacha_nonce[..SANCTUARY_GCM_NONCE_BYTES]);
373        assert_eq!(xchacha_nonce.len(), SANCTUARY_XCHACHA_NONCE_BYTES);
374    }
375
376    #[test]
377    fn test_nonce_uniqueness_across_volume() {
378        let tweak = [3u8; SANCTUARY_TWEAK_BYTES];
379        let mut nonces = std::collections::HashSet::new();
380
381        for index in 0..1_000u64 {
382            let nonce = derive_chunk_nonce(&tweak, index);
383            assert!(nonces.insert(nonce), "duplicate nonce at chunk {index}");
384        }
385    }
386
387    #[test]
388    fn test_zero_heap_encrypt_decrypt_aes_gcm() {
389        let key_material =
390            derive_sanctuary_key_material(b"test_pin", b"test_salt", TEST_ITERATIONS);
391        let plaintext = b"Hello, zero-heap world!";
392        let mut ciphertext = [0u8; 128];
393        let mut tag = [0u8; SANCTUARY_TAG_BYTES];
394
395        let written = encrypt_sanctuary_chunk(
396            SanctuaryAeadAlgorithm::Aes256Gcm,
397            &key_material,
398            0,
399            plaintext,
400            &mut ciphertext,
401            &mut tag,
402            b"",
403        )
404        .unwrap();
405
406        let mut decrypted = [0u8; 128];
407        let read = decrypt_sanctuary_chunk(
408            SanctuaryAeadAlgorithm::Aes256Gcm,
409            &key_material,
410            0,
411            &ciphertext[..written],
412            &tag,
413            &mut decrypted,
414            b"",
415        )
416        .unwrap();
417
418        assert_eq!(&decrypted[..read], plaintext);
419    }
420
421    #[test]
422    fn test_zero_heap_encrypt_decrypt_xchacha() {
423        let key_material = derive_sanctuary_key_material(b"pin", b"salt", TEST_ITERATIONS);
424        let mut buffer = *b"Secret message in place";
425        let original = buffer;
426        let mut tag = [0u8; SANCTUARY_TAG_BYTES];
427
428        encrypt_sanctuary_chunk_in_place(
429            SanctuaryAeadAlgorithm::XChaCha20Poly1305,
430            &key_material,
431            9,
432            &mut buffer,
433            b"context",
434            &mut tag,
435        )
436        .unwrap();
437
438        assert_ne!(buffer, original);
439
440        decrypt_sanctuary_chunk_in_place(
441            SanctuaryAeadAlgorithm::XChaCha20Poly1305,
442            &key_material,
443            9,
444            &mut buffer,
445            &tag,
446            b"context",
447        )
448        .unwrap();
449
450        assert_eq!(buffer, original);
451    }
452
453    #[test]
454    fn test_decrypt_rejects_wrong_aad() {
455        let key_material = derive_sanctuary_key_material(b"pin", b"salt", TEST_ITERATIONS);
456        let plaintext = b"Bound to aad";
457        let mut ciphertext = [0u8; 64];
458        let mut tag = [0u8; SANCTUARY_TAG_BYTES];
459
460        let written = encrypt_sanctuary_chunk(
461            SanctuaryAeadAlgorithm::ChaCha20Poly1305,
462            &key_material,
463            3,
464            plaintext,
465            &mut ciphertext,
466            &mut tag,
467            b"correct",
468        )
469        .unwrap();
470
471        let mut decrypted = [0u8; 64];
472        let result = decrypt_sanctuary_chunk(
473            SanctuaryAeadAlgorithm::ChaCha20Poly1305,
474            &key_material,
475            3,
476            &ciphertext[..written],
477            &tag,
478            &mut decrypted,
479            b"wrong",
480        );
481
482        assert_eq!(result, Err(SanctuaryCryptoError::DecryptionFailed));
483    }
484}