1use chacha20poly1305::aead::AeadInPlace;
14use chacha20poly1305::{Key as AeadKey, KeyInit, XChaCha20Poly1305, XNonce};
15use zeroize::Zeroizing;
16
17pub const ENC_PAGE_SIZE: usize = 8232;
20pub const NONCE_LEN: usize = 24;
23pub const TAG_LEN: usize = 16;
25pub const DEK_LEN: usize = 32;
27pub const SALT_LEN: usize = 16;
29
30#[derive(Clone)]
35pub enum Key {
36 Raw(Zeroizing<Vec<u8>>),
37 Passphrase(Zeroizing<String>),
38}
39
40impl std::fmt::Debug for Key {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Key::Raw(_) => f.write_str("Key::Raw(<redacted>)"),
46 Key::Passphrase(_) => f.write_str("Key::Passphrase(<redacted>)"),
47 }
48 }
49}
50
51pub struct Dek(Zeroizing<[u8; DEK_LEN]>);
55
56impl Dek {
57 pub fn from_bytes(bytes: [u8; DEK_LEN]) -> Self {
60 Dek(Zeroizing::new(bytes))
61 }
62 pub fn as_bytes(&self) -> &[u8; DEK_LEN] {
65 &self.0
66 }
67}
68
69impl Clone for Dek {
70 fn clone(&self) -> Self {
71 Dek(Zeroizing::new(*self.0))
72 }
73}
74
75pub struct Kek(Zeroizing<[u8; 32]>);
81
82impl Kek {
83 #[cfg(test)]
84 pub fn from_bytes(bytes: [u8; 32]) -> Self {
85 Kek(Zeroizing::new(bytes))
86 }
87 pub fn as_bytes(&self) -> &[u8; 32] {
88 &self.0
89 }
90}
91
92#[derive(Clone, Copy, PartialEq, Debug)]
95pub enum KdfId {
96 Hkdf = 1,
97 Argon2id = 2,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub struct Argon2Params {
104 pub m_cost: u32, pub t_cost: u32, pub p_cost: u32, }
108
109impl Default for Argon2Params {
110 fn default() -> Self {
112 Argon2Params {
113 m_cost: 19456,
114 t_cost: 2,
115 p_cost: 1,
116 }
117 }
118}
119
120#[derive(Debug, PartialEq)]
124pub enum CryptoError {
125 Auth,
127 Kdf,
129 BadKeyLength,
131}
132
133use argon2::{Algorithm, Argon2, Params, Version};
134use hkdf::Hkdf;
135use sha2::Sha256;
136
137const KEK_INFO: &[u8] = b"chisel-kek-v1";
141
142pub fn derive_kek(
158 key: &Key,
159 kdf: KdfId,
160 salt: &[u8; SALT_LEN],
161 params: &Argon2Params,
162) -> Result<Kek, CryptoError> {
163 let ikm: &[u8] = match key {
164 Key::Raw(bytes) => bytes.as_slice(),
165 Key::Passphrase(s) => s.as_bytes(),
166 };
167 if ikm.is_empty() {
172 return Err(CryptoError::BadKeyLength);
173 }
174 let mut okm = Zeroizing::new([0u8; 32]);
178 match kdf {
179 KdfId::Hkdf => {
180 let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
181 hk.expand(KEK_INFO, okm.as_mut())
182 .map_err(|_| CryptoError::Kdf)?;
183 }
184 KdfId::Argon2id => {
185 let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32))
186 .map_err(|_| CryptoError::Kdf)?;
187 let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, p);
193 a2.hash_password_into(ikm, salt, okm.as_mut())
194 .map_err(|_| CryptoError::Kdf)?;
195 }
196 }
197 Ok(Kek(okm))
198}
199
200fn seal_detached(
205 key: &[u8; 32],
206 nonce: &[u8; NONCE_LEN],
207 aad: &[u8],
208 plaintext: &[u8],
209) -> (Vec<u8>, [u8; TAG_LEN]) {
210 let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key));
211 let mut buf = plaintext.to_vec();
212 let tag = cipher
213 .encrypt_in_place_detached(XNonce::from_slice(nonce), aad, &mut buf)
214 .expect("XChaCha20-Poly1305 encrypt cannot fail for in-range lengths");
215 let mut tag_arr = [0u8; TAG_LEN];
216 tag_arr.copy_from_slice(&tag);
217 (buf, tag_arr)
218}
219
220fn open_detached(
228 key: &[u8; 32],
229 nonce: &[u8; NONCE_LEN],
230 aad: &[u8],
231 ciphertext: &[u8],
232 tag: &[u8; TAG_LEN],
233) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
234 let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key));
235 let mut buf = Zeroizing::new(ciphertext.to_vec());
236 cipher
237 .decrypt_in_place_detached(
238 XNonce::from_slice(nonce),
239 aad,
240 &mut buf,
241 tag.as_slice().into(),
242 )
243 .map_err(|_| CryptoError::Auth)?;
244 Ok(buf)
245}
246
247pub fn wrap_dek(
252 kek: &Kek,
253 dek: &Dek,
254 wrap_nonce: &[u8; NONCE_LEN],
255 aad: &[u8],
256) -> ([u8; DEK_LEN], [u8; TAG_LEN]) {
257 let (ct, tag) = seal_detached(kek.as_bytes(), wrap_nonce, aad, dek.as_bytes());
258 let mut wrapped = [0u8; DEK_LEN];
259 wrapped.copy_from_slice(&ct);
260 (wrapped, tag)
261}
262
263pub fn unwrap_dek(
272 kek: &Kek,
273 wrapped: &[u8; DEK_LEN],
274 tag: &[u8; TAG_LEN],
275 wrap_nonce: &[u8; NONCE_LEN],
276 aad: &[u8],
277) -> Result<Dek, CryptoError> {
278 let pt = open_detached(kek.as_bytes(), wrap_nonce, aad, wrapped, tag)?;
279 let mut dek_bytes = Zeroizing::new([0u8; DEK_LEN]);
280 dek_bytes.copy_from_slice(&pt);
281 Ok(Dek::from_bytes(*dek_bytes))
282}
283
284pub fn random_array<const N: usize>() -> [u8; N] {
288 let mut b = [0u8; N];
289 getrandom::getrandom(&mut b).expect("OS RNG unavailable");
290 b
291}
292
293pub fn random_dek() -> Dek {
295 Dek::from_bytes(random_array::<DEK_LEN>())
296}
297
298#[derive(Clone)]
308pub struct PageCipher {
309 dek: Dek,
310}
311
312impl PageCipher {
313 pub fn new(dek: Dek) -> Self {
314 PageCipher { dek }
315 }
316
317 pub fn seal(&self, page_id: u64, plaintext: &[u8; 8192]) -> [u8; ENC_PAGE_SIZE] {
322 let nonce = random_array::<NONCE_LEN>();
323 let aad = page_id.to_le_bytes();
324 let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, &aad, plaintext);
325 let mut out = [0u8; ENC_PAGE_SIZE];
326 out[0..8192].copy_from_slice(&ct);
327 out[8192..8208].copy_from_slice(&tag);
328 out[8208..8232].copy_from_slice(&nonce);
329 out
330 }
331
332 pub fn open(
340 &self,
341 page_id: u64,
342 ondisk: &[u8; ENC_PAGE_SIZE],
343 ) -> Result<[u8; 8192], CryptoError> {
344 let ct = &ondisk[0..8192];
345 let mut tag = [0u8; TAG_LEN];
346 tag.copy_from_slice(&ondisk[8192..8208]);
347 let mut nonce = [0u8; NONCE_LEN];
348 nonce.copy_from_slice(&ondisk[8208..8232]);
349 let aad = page_id.to_le_bytes();
350 let pt = open_detached(self.dek.as_bytes(), &nonce, &aad, ct, &tag)?;
351 let mut page = [0u8; 8192];
352 page.copy_from_slice(&pt);
353 Ok(page)
354 }
355
356 pub fn seal_body(
360 &self,
361 aad: &[u8],
362 plaintext: &[u8],
363 ) -> ([u8; NONCE_LEN], [u8; TAG_LEN], Vec<u8>) {
364 let nonce = random_array::<NONCE_LEN>();
365 let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, aad, plaintext);
366 (nonce, tag, ct)
367 }
368
369 pub fn open_body(
376 &self,
377 aad: &[u8],
378 nonce: &[u8; NONCE_LEN],
379 tag: &[u8; TAG_LEN],
380 ct: &[u8],
381 ) -> Result<Vec<u8>, CryptoError> {
382 open_detached(self.dek.as_bytes(), nonce, aad, ct, tag).map(|z| z.to_vec())
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn constants_match_spec() {
392 assert_eq!(ENC_PAGE_SIZE, 8232);
394 assert_eq!(NONCE_LEN, 24);
395 assert_eq!(TAG_LEN, 16);
396 assert_eq!(DEK_LEN, 32);
397 assert_eq!(SALT_LEN, 16);
398 assert_eq!(ENC_PAGE_SIZE, 8192 + TAG_LEN + NONCE_LEN);
399 }
400
401 #[test]
402 fn argon2_params_default_is_owasp() {
403 let p = Argon2Params::default();
404 assert_eq!(p.m_cost, 19456); assert_eq!(p.t_cost, 2);
406 assert_eq!(p.p_cost, 1);
407 }
408
409 #[test]
410 fn kdf_id_discriminants_are_wire_stable() {
411 assert_eq!(KdfId::Hkdf as u8, 1);
413 assert_eq!(KdfId::Argon2id as u8, 2);
414 assert_ne!(KdfId::Hkdf, KdfId::Argon2id);
415 }
416
417 #[test]
418 fn random_array_is_os_filled_and_distinct() {
419 let a: [u8; 32] = random_array();
420 let b: [u8; 32] = random_array();
421 assert_ne!(a, b);
423 assert_ne!(a, [0u8; 32]);
424 }
425
426 #[test]
427 fn random_dek_differs_each_call() {
428 let d1 = random_dek();
429 let d2 = random_dek();
430 assert_ne!(d1.as_bytes(), d2.as_bytes());
431 }
432
433 #[test]
434 fn crypto_error_is_comparable() {
435 assert_eq!(CryptoError::Auth, CryptoError::Auth);
436 assert_ne!(CryptoError::Auth, CryptoError::Kdf);
437 }
438
439 #[test]
440 fn derive_kek_hkdf_matches_reference_construction() {
441 let ikm = [0x0bu8; 22];
443 let salt: [u8; SALT_LEN] = [
444 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
445 0x0e, 0x0f,
446 ];
447 let key = Key::Raw(zeroize::Zeroizing::new(ikm.to_vec()));
448 let kek = derive_kek(&key, KdfId::Hkdf, &salt, &Argon2Params::default()).unwrap();
449
450 use hkdf::Hkdf;
452 use sha2::Sha256;
453 let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
454 let mut expect = [0u8; 32];
455 hk.expand(b"chisel-kek-v1", &mut expect).unwrap();
456 assert_eq!(kek.as_bytes(), &expect);
457 }
458
459 #[test]
460 fn derive_kek_hkdf_is_deterministic_and_salt_sensitive() {
461 let key = Key::Raw(zeroize::Zeroizing::new(vec![7u8; 32]));
462 let salt_a = [1u8; SALT_LEN];
463 let salt_b = [2u8; SALT_LEN];
464 let p = Argon2Params::default();
465 let k1 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap();
466 let k2 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap();
467 let k3 = derive_kek(&key, KdfId::Hkdf, &salt_b, &p).unwrap();
468 assert_eq!(
469 k1.as_bytes(),
470 k2.as_bytes(),
471 "same input must be deterministic"
472 );
473 assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge");
474 }
475
476 #[test]
477 fn derive_kek_argon2_roundtrips_and_is_salt_sensitive() {
478 let fast = Argon2Params {
480 m_cost: 256,
481 t_cost: 1,
482 p_cost: 1,
483 };
484 let key = Key::Passphrase(zeroize::Zeroizing::new("correct horse".to_string()));
485 let salt_a = [9u8; SALT_LEN];
486 let salt_b = [8u8; SALT_LEN];
487 let k1 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap();
488 let k2 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap();
489 let k3 = derive_kek(&key, KdfId::Argon2id, &salt_b, &fast).unwrap();
490 assert_eq!(
491 k1.as_bytes(),
492 k2.as_bytes(),
493 "Argon2id must be deterministic"
494 );
495 assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge");
496 assert_ne!(k1.as_bytes(), &[0u8; 32]);
497 }
498
499 #[test]
500 fn derive_kek_argon2_rejects_zero_memory() {
501 let bad = Argon2Params {
502 m_cost: 0,
503 t_cost: 1,
504 p_cost: 1,
505 };
506 let key = Key::Passphrase(zeroize::Zeroizing::new("x".to_string()));
507 assert!(matches!(
510 derive_kek(&key, KdfId::Argon2id, &[0u8; SALT_LEN], &bad),
511 Err(CryptoError::Kdf)
512 ));
513 }
514
515 #[test]
516 fn wrap_unwrap_roundtrip() {
517 let kek = Kek::from_bytes([3u8; 32]);
518 let dek = Dek::from_bytes([42u8; DEK_LEN]);
519 let nonce = [5u8; NONCE_LEN];
520 let aad = b"slot-meta";
521 let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad);
522 assert_ne!(
523 &wrapped,
524 dek.as_bytes(),
525 "wrapped DEK must not equal plaintext DEK"
526 );
527 let out = unwrap_dek(&kek, &wrapped, &tag, &nonce, aad).unwrap();
528 assert_eq!(out.as_bytes(), dek.as_bytes());
529 }
530
531 #[test]
532 fn unwrap_wrong_kek_is_auth() {
533 let dek = Dek::from_bytes([42u8; DEK_LEN]);
534 let nonce = [5u8; NONCE_LEN];
535 let aad = b"slot-meta";
536 let (wrapped, tag) = wrap_dek(&Kek::from_bytes([3u8; 32]), &dek, &nonce, aad);
537 assert!(matches!(
540 unwrap_dek(&Kek::from_bytes([4u8; 32]), &wrapped, &tag, &nonce, aad),
541 Err(CryptoError::Auth)
542 ));
543 }
544
545 #[test]
546 fn unwrap_tampered_tag_is_auth() {
547 let kek = Kek::from_bytes([3u8; 32]);
548 let dek = Dek::from_bytes([42u8; DEK_LEN]);
549 let nonce = [5u8; NONCE_LEN];
550 let aad = b"slot-meta";
551 let (wrapped, mut tag) = wrap_dek(&kek, &dek, &nonce, aad);
552 tag[0] ^= 0x01;
553 assert!(matches!(
554 unwrap_dek(&kek, &wrapped, &tag, &nonce, aad),
555 Err(CryptoError::Auth)
556 ));
557 }
558
559 #[test]
560 fn unwrap_tampered_ciphertext_is_auth() {
561 let kek = Kek::from_bytes([3u8; 32]);
562 let dek = Dek::from_bytes([42u8; DEK_LEN]);
563 let nonce = [5u8; NONCE_LEN];
564 let aad = b"slot-meta";
565 let (mut wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad);
566 wrapped[0] ^= 0x01;
567 assert!(matches!(
568 unwrap_dek(&kek, &wrapped, &tag, &nonce, aad),
569 Err(CryptoError::Auth)
570 ));
571 }
572
573 #[test]
574 fn unwrap_wrong_aad_is_auth() {
575 let kek = Kek::from_bytes([3u8; 32]);
576 let dek = Dek::from_bytes([42u8; DEK_LEN]);
577 let nonce = [5u8; NONCE_LEN];
578 let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, b"slot-meta-A");
579 assert!(matches!(
580 unwrap_dek(&kek, &wrapped, &tag, &nonce, b"slot-meta-B"),
581 Err(CryptoError::Auth)
582 ));
583 }
584
585 #[test]
586 fn page_seal_open_roundtrip() {
587 let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
588 let mut page = [0u8; 8192];
589 for (i, b) in page.iter_mut().enumerate() {
590 *b = (i % 251) as u8;
591 }
592 let blob = pc.seal(7, &page);
593 assert_eq!(blob.len(), ENC_PAGE_SIZE);
594 let out = pc.open(7, &blob).unwrap();
595 assert_eq!(out, page);
596 }
597
598 #[test]
599 fn page_seal_layout_is_ct_tag_nonce() {
600 let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
601 let page = [0xABu8; 8192];
602 let blob = pc.seal(0, &page);
603 assert_ne!(
605 &blob[0..8192],
606 &page[..],
607 "ciphertext must differ from plaintext"
608 );
609 }
610
611 #[test]
612 fn page_open_wrong_page_id_is_auth() {
613 let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
616 let page = [9u8; 8192];
617 let blob = pc.seal(7, &page);
618 assert_eq!(pc.open(8, &blob).unwrap_err(), CryptoError::Auth);
619 }
620
621 #[test]
622 fn page_open_byte_flip_is_auth() {
623 let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
624 let page = [9u8; 8192];
625 let mut blob = pc.seal(7, &page);
626 blob[100] ^= 0x01; assert_eq!(pc.open(7, &blob).unwrap_err(), CryptoError::Auth);
628 }
629
630 #[test]
631 fn page_two_seals_use_different_nonces() {
632 let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
635 let page = [9u8; 8192];
636 let a = pc.seal(7, &page);
637 let b = pc.seal(7, &page);
638 assert_ne!(&a[..], &b[..], "nonce reuse: identical blobs for same page");
639 assert_eq!(pc.open(7, &a).unwrap(), page);
641 assert_eq!(pc.open(7, &b).unwrap(), page);
642 }
643
644 #[test]
645 fn body_seal_open_roundtrip() {
646 let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN]));
647 let body = b"root pointers + named_roots".to_vec();
648 let aad = b"sb-identity";
649 let (nonce, tag, ct) = pc.seal_body(aad, &body);
650 assert_eq!(ct.len(), body.len(), "body cipher is length-preserving");
651 let out = pc.open_body(aad, &nonce, &tag, &ct).unwrap();
652 assert_eq!(out, body);
653 }
654
655 #[test]
656 fn body_open_wrong_aad_is_auth() {
657 let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN]));
658 let body = b"secret".to_vec();
659 let (nonce, tag, ct) = pc.seal_body(b"sb-A", &body);
660 assert_eq!(
661 pc.open_body(b"sb-B", &nonce, &tag, &ct).unwrap_err(),
662 CryptoError::Auth
663 );
664 }
665
666 #[test]
672 fn dek_clone_is_independent_zeroizing_copy() {
673 let d = Dek::from_bytes([7u8; DEK_LEN]);
674 let c = d.clone();
675 assert_eq!(d.as_bytes(), c.as_bytes());
676 drop(c);
678 assert_eq!(d.as_bytes(), &[7u8; DEK_LEN]);
679 }
680
681 #[test]
682 fn key_variants_construct_from_zeroizing() {
683 let raw = Key::Raw(zeroize::Zeroizing::new(vec![1u8, 2, 3]));
685 let pass = Key::Passphrase(zeroize::Zeroizing::new("pw".to_string()));
686 let _r2 = raw.clone();
688 let _p2 = pass.clone();
689 }
690
691 #[test]
692 fn argon2id_known_answer_test() {
693 let key = Key::Passphrase(zeroize::Zeroizing::new("password".to_string()));
706 let salt = *b"somesalt12345678";
707 let params = Argon2Params {
708 m_cost: 8,
709 t_cost: 1,
710 p_cost: 1,
711 };
712 let kek = derive_kek(&key, KdfId::Argon2id, &salt, ¶ms).unwrap();
713 let expected: [u8; 32] = [
714 0xd8, 0x38, 0x04, 0x14, 0x00, 0x12, 0xc3, 0xe6, 0xd3, 0x50, 0x2a, 0x3e, 0xb5, 0x9f,
715 0xc2, 0x4a, 0x89, 0xa9, 0xec, 0x08, 0xb6, 0xac, 0x97, 0xbe, 0x1f, 0xec, 0xa1, 0x70,
716 0x0a, 0xbe, 0x0a, 0xfb,
717 ];
718 assert_eq!(
719 kek.as_bytes(),
720 &expected,
721 "Argon2id output changed — KDF config or format break; update golden and bump FORMAT_VERSION"
722 );
723 }
724}