1use 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
22pub 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#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
52pub struct SanctuaryKeyMaterial {
53 pub cipher_key: [u8; SANCTUARY_CIPHER_KEY_BYTES],
55 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
67pub 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
92pub 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
129pub 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
135pub 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
140pub 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
145pub 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
159pub 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
210pub 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
264pub 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
290pub 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}