1#[cfg(feature = "fips")]
10use aes_gcm::{Aes256Gcm, Nonce};
11use argon2::{Algorithm, Argon2, Params, Version};
12use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
13use chacha20poly1305::{
14 aead::{Aead, KeyInit},
15 XChaCha20Poly1305, XNonce,
16};
17use hkdf::Hkdf;
18use rand::RngCore;
19use sha2::Sha256;
20use zeroize::{Zeroize, ZeroizeOnDrop};
21
22use tracing::instrument;
23
24use crate::errors::{SafeError, SafeResult};
25
26pub const VAULT_KDF_M_COST: u32 = 65536; pub const VAULT_KDF_T_COST: u32 = 3;
29pub const VAULT_KDF_P_COST: u32 = 4;
30pub const SALT_LEN: usize = 32;
31pub const KEY_LEN: usize = 32;
32pub const XCHACHA20POLY1305_NONCE_LEN: usize = 24;
33pub const NONCE_LEN: usize = XCHACHA20POLY1305_NONCE_LEN; #[cfg(feature = "fips")]
35pub const AES256GCM_NONCE_LEN: usize = 12;
36
37#[derive(Zeroize, ZeroizeOnDrop)]
39pub struct VaultKey([u8; KEY_LEN]);
40
41impl VaultKey {
42 pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
43 &self.0
44 }
45
46 pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
48 Self(bytes)
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum KeySchedule {
57 LegacyDirect,
58 HkdfSha256V1,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum CipherKind {
64 XChaCha20Poly1305,
65 #[cfg(feature = "fips")]
66 Aes256Gcm,
67}
68
69impl CipherKind {
70 pub fn as_str(self) -> &'static str {
71 match self {
72 Self::XChaCha20Poly1305 => "xchacha20poly1305",
73 #[cfg(feature = "fips")]
74 Self::Aes256Gcm => "aes256gcm",
75 }
76 }
77
78 pub fn nonce_len(self) -> usize {
79 match self {
80 Self::XChaCha20Poly1305 => XCHACHA20POLY1305_NONCE_LEN,
81 #[cfg(feature = "fips")]
82 Self::Aes256Gcm => AES256GCM_NONCE_LEN,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum KeyPurpose {
91 SecretData,
92 VaultChallenge,
93 AuditLog,
94 Snapshot,
95}
96
97impl KeyPurpose {
98 fn label(self) -> &'static str {
99 match self {
100 Self::SecretData => "tsafe/vault/secret-data/v1",
101 Self::VaultChallenge => "tsafe/vault/challenge/v1",
102 Self::AuditLog => "tsafe/vault/audit-log/v1",
103 Self::Snapshot => "tsafe/vault/snapshot/v1",
104 }
105 }
106}
107
108pub fn default_vault_cipher() -> CipherKind {
109 #[cfg(feature = "fips")]
110 {
111 CipherKind::Aes256Gcm
112 }
113 #[cfg(not(feature = "fips"))]
114 {
115 CipherKind::XChaCha20Poly1305
116 }
117}
118
119pub fn parse_cipher_kind(label: &str) -> SafeResult<CipherKind> {
120 match label {
121 "xchacha20poly1305" => Ok(CipherKind::XChaCha20Poly1305),
122 #[cfg(feature = "fips")]
123 "aes256gcm" => Ok(CipherKind::Aes256Gcm),
124 #[cfg(not(feature = "fips"))]
125 "aes256gcm" => Err(SafeError::InvalidVault {
126 reason: "cipher 'aes256gcm' requires a build with the 'fips' feature enabled".into(),
127 }),
128 other => Err(SafeError::InvalidVault {
129 reason: format!("unsupported cipher: '{other}'"),
130 }),
131 }
132}
133
134pub fn random_salt() -> [u8; SALT_LEN] {
135 let mut buf = [0u8; SALT_LEN];
136 rand::rngs::OsRng.fill_bytes(&mut buf);
137 buf
138}
139
140pub fn random_nonce() -> [u8; NONCE_LEN] {
141 let mut buf = [0u8; NONCE_LEN];
142 rand::rngs::OsRng.fill_bytes(&mut buf);
143 buf
144}
145
146#[cfg(feature = "fips")]
147fn random_aes_nonce() -> [u8; AES256GCM_NONCE_LEN] {
148 let mut buf = [0u8; AES256GCM_NONCE_LEN];
149 rand::rngs::OsRng.fill_bytes(&mut buf);
150 buf
151}
152
153#[instrument(skip(password, salt), fields(m_cost, t_cost, p_cost))]
155pub fn derive_key(
156 password: &[u8],
157 salt: &[u8],
158 m_cost: u32,
159 t_cost: u32,
160 p_cost: u32,
161) -> SafeResult<VaultKey> {
162 let params =
163 Params::new(m_cost, t_cost, p_cost, Some(KEY_LEN)).map_err(|e| SafeError::Crypto {
164 context: e.to_string(),
165 })?;
166 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
167 let mut key_bytes = [0u8; KEY_LEN];
168 argon2
169 .hash_password_into(password, salt, &mut key_bytes)
170 .map_err(|e| SafeError::Crypto {
171 context: e.to_string(),
172 })?;
173 Ok(VaultKey(key_bytes))
174}
175
176pub fn derive_subkey(root_key: &VaultKey, purpose: KeyPurpose) -> SafeResult<VaultKey> {
178 derive_labeled_subkey(root_key, purpose.label())
179}
180
181pub fn derive_labeled_subkey(root_key: &VaultKey, label: &str) -> SafeResult<VaultKey> {
183 let hkdf = Hkdf::<Sha256>::new(None, root_key.as_bytes());
184 let mut subkey = [0u8; KEY_LEN];
185 hkdf.expand(label.as_bytes(), &mut subkey)
186 .map_err(|e| SafeError::Crypto {
187 context: format!("hkdf expand for {label}: {e}"),
188 })?;
189 Ok(VaultKey(subkey))
190}
191
192pub fn encrypt_with_key_schedule(
193 root_key: &VaultKey,
194 schedule: KeySchedule,
195 purpose: KeyPurpose,
196 cipher: CipherKind,
197 plaintext: &[u8],
198) -> SafeResult<(Vec<u8>, Vec<u8>)> {
199 match schedule {
200 KeySchedule::LegacyDirect => encrypt_for_cipher(cipher, root_key, plaintext),
201 KeySchedule::HkdfSha256V1 => {
202 let subkey = derive_subkey(root_key, purpose)?;
203 encrypt_for_cipher(cipher, &subkey, plaintext)
204 }
205 }
206}
207
208pub fn decrypt_with_key_schedule(
209 root_key: &VaultKey,
210 schedule: KeySchedule,
211 purpose: KeyPurpose,
212 cipher: CipherKind,
213 nonce_bytes: &[u8],
214 ciphertext: &[u8],
215) -> SafeResult<Vec<u8>> {
216 match schedule {
217 KeySchedule::LegacyDirect => decrypt_for_cipher(cipher, root_key, nonce_bytes, ciphertext),
218 KeySchedule::HkdfSha256V1 => {
219 let subkey = derive_subkey(root_key, purpose)?;
220 decrypt_for_cipher(cipher, &subkey, nonce_bytes, ciphertext)
221 }
222 }
223}
224
225pub fn detect_key_schedule(
227 root_key: &VaultKey,
228 purpose: KeyPurpose,
229 cipher: CipherKind,
230 nonce_bytes: &[u8],
231 ciphertext: &[u8],
232 expected_plaintext: &[u8],
233) -> SafeResult<KeySchedule> {
234 for schedule in [KeySchedule::HkdfSha256V1, KeySchedule::LegacyDirect] {
235 match decrypt_with_key_schedule(
236 root_key,
237 schedule,
238 purpose,
239 cipher,
240 nonce_bytes,
241 ciphertext,
242 ) {
243 Ok(plaintext) if plaintext.as_slice() == expected_plaintext => return Ok(schedule),
244 Ok(_) | Err(SafeError::DecryptionFailed) => continue,
245 Err(err) => return Err(err),
246 }
247 }
248 Err(SafeError::DecryptionFailed)
249}
250
251pub fn encrypt_for_cipher(
252 cipher: CipherKind,
253 key: &VaultKey,
254 plaintext: &[u8],
255) -> SafeResult<(Vec<u8>, Vec<u8>)> {
256 match cipher {
257 CipherKind::XChaCha20Poly1305 => encrypt(key, plaintext),
258 #[cfg(feature = "fips")]
259 CipherKind::Aes256Gcm => encrypt_aes_gcm(key, plaintext),
260 }
261}
262
263pub fn decrypt_for_cipher(
264 cipher: CipherKind,
265 key: &VaultKey,
266 nonce_bytes: &[u8],
267 ciphertext: &[u8],
268) -> SafeResult<Vec<u8>> {
269 match cipher {
270 CipherKind::XChaCha20Poly1305 => decrypt(key, nonce_bytes, ciphertext),
271 #[cfg(feature = "fips")]
272 CipherKind::Aes256Gcm => decrypt_aes_gcm(key, nonce_bytes, ciphertext),
273 }
274}
275
276#[instrument(skip_all, fields(plaintext_len = plaintext.len()))]
278pub fn encrypt(key: &VaultKey, plaintext: &[u8]) -> SafeResult<(Vec<u8>, Vec<u8>)> {
279 let nonce_bytes = random_nonce();
280 let nonce = XNonce::from_slice(&nonce_bytes);
281 let cipher =
282 XChaCha20Poly1305::new_from_slice(key.as_bytes()).map_err(|_| SafeError::Crypto {
283 context: "invalid key length".into(),
284 })?;
285 let ciphertext = cipher
286 .encrypt(nonce, plaintext)
287 .map_err(|_| SafeError::Crypto {
288 context: "encryption failed".into(),
289 })?;
290 Ok((nonce_bytes.to_vec(), ciphertext))
291}
292
293#[instrument(skip_all, fields(ciphertext_len = ciphertext.len()))]
295pub fn decrypt(key: &VaultKey, nonce_bytes: &[u8], ciphertext: &[u8]) -> SafeResult<Vec<u8>> {
296 if nonce_bytes.len() != NONCE_LEN {
297 return Err(SafeError::InvalidVault {
298 reason: format!(
299 "invalid nonce length: expected {NONCE_LEN} bytes, got {}",
300 nonce_bytes.len()
301 ),
302 });
303 }
304 let nonce = XNonce::from_slice(nonce_bytes);
305 let cipher =
306 XChaCha20Poly1305::new_from_slice(key.as_bytes()).map_err(|_| SafeError::Crypto {
307 context: "invalid key length".into(),
308 })?;
309 cipher
310 .decrypt(nonce, ciphertext)
311 .map_err(|_| SafeError::DecryptionFailed)
312}
313
314#[cfg(feature = "fips")]
315fn encrypt_aes_gcm(key: &VaultKey, plaintext: &[u8]) -> SafeResult<(Vec<u8>, Vec<u8>)> {
316 let nonce_bytes = random_aes_nonce();
317 let nonce = Nonce::from_slice(&nonce_bytes);
318 let cipher = Aes256Gcm::new_from_slice(key.as_bytes()).map_err(|_| SafeError::Crypto {
319 context: "invalid AES-256-GCM key length".into(),
320 })?;
321 let ciphertext = cipher
322 .encrypt(nonce, plaintext)
323 .map_err(|_| SafeError::Crypto {
324 context: "AES-256-GCM encryption failed".into(),
325 })?;
326 Ok((nonce_bytes.to_vec(), ciphertext))
327}
328
329#[cfg(feature = "fips")]
330fn decrypt_aes_gcm(key: &VaultKey, nonce_bytes: &[u8], ciphertext: &[u8]) -> SafeResult<Vec<u8>> {
331 if nonce_bytes.len() != AES256GCM_NONCE_LEN {
332 return Err(SafeError::InvalidVault {
333 reason: format!(
334 "invalid AES-256-GCM nonce length: expected {AES256GCM_NONCE_LEN} bytes, got {}",
335 nonce_bytes.len()
336 ),
337 });
338 }
339 let nonce = Nonce::from_slice(nonce_bytes);
340 let cipher = Aes256Gcm::new_from_slice(key.as_bytes()).map_err(|_| SafeError::Crypto {
341 context: "invalid AES-256-GCM key length".into(),
342 })?;
343 cipher
344 .decrypt(nonce, ciphertext)
345 .map_err(|_| SafeError::DecryptionFailed)
346}
347
348pub fn encode_b64(data: &[u8]) -> String {
349 URL_SAFE_NO_PAD.encode(data)
350}
351
352pub fn decode_b64(s: &str) -> SafeResult<Vec<u8>> {
353 URL_SAFE_NO_PAD
354 .decode(s)
355 .map_err(|e| SafeError::InvalidVault {
356 reason: format!("base64 decode: {e}"),
357 })
358}
359
360pub fn ct_eq_str(a: &str, b: &str) -> bool {
368 use subtle::ConstantTimeEq;
369 let a = a.as_bytes();
370 let b = b.as_bytes();
371 if a.len() != b.len() {
372 return false;
373 }
374 a.ct_eq(b).into()
375}
376
377pub fn snap_encrypt(plaintext: &str) -> SafeResult<(String, String)> {
387 let mut key_bytes = [0u8; KEY_LEN];
388 rand::rngs::OsRng.fill_bytes(&mut key_bytes);
389 let snap_key = VaultKey(key_bytes);
390 let (nonce, ciphertext) = encrypt(&snap_key, plaintext.as_bytes())?;
391 let mut blob = nonce;
392 blob.extend_from_slice(&ciphertext);
393 let blob_b64 = encode_b64(&blob);
394 let key_b64 = encode_b64(snap_key.as_bytes());
395 Ok((blob_b64, key_b64))
396}
397
398pub fn snap_decrypt(blob_b64: &str, key_b64: &str) -> SafeResult<String> {
403 let blob = decode_b64(blob_b64).map_err(|_| SafeError::InvalidVault {
404 reason: "snap blob is not valid base64url".into(),
405 })?;
406 let key_bytes = decode_b64(key_b64).map_err(|_| SafeError::InvalidVault {
407 reason: "snap key is not valid base64url".into(),
408 })?;
409 if key_bytes.len() != KEY_LEN {
410 return Err(SafeError::InvalidVault {
411 reason: format!("snap key must be {KEY_LEN} bytes, got {}", key_bytes.len()),
412 });
413 }
414 if blob.len() < NONCE_LEN {
415 return Err(SafeError::InvalidVault {
416 reason: "snap blob too short — nonce is missing".into(),
417 });
418 }
419 let key_arr: [u8; KEY_LEN] = key_bytes.try_into().unwrap();
420 let snap_key = VaultKey(key_arr);
421 let nonce = &blob[..NONCE_LEN];
422 let ciphertext = &blob[NONCE_LEN..];
423 let plaintext_bytes = decrypt(&snap_key, nonce, ciphertext)?;
424 String::from_utf8(plaintext_bytes).map_err(|_| SafeError::InvalidVault {
425 reason: "decrypted snap is not valid UTF-8".into(),
426 })
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn roundtrip_encrypt_decrypt() {
435 let salt = random_salt();
436 let key = derive_key(
437 b"test-password",
438 &salt,
439 VAULT_KDF_M_COST,
440 VAULT_KDF_T_COST,
441 VAULT_KDF_P_COST,
442 )
443 .unwrap();
444 let plaintext = b"super-secret-value";
445 let (nonce, ct) = encrypt(&key, plaintext).unwrap();
446 let pt = decrypt(&key, &nonce, &ct).unwrap();
447 assert_eq!(pt, plaintext);
448 }
449
450 #[test]
451 fn wrong_password_returns_decryption_failed() {
452 let salt = random_salt();
453 let k1 = derive_key(
454 b"correct",
455 &salt,
456 VAULT_KDF_M_COST,
457 VAULT_KDF_T_COST,
458 VAULT_KDF_P_COST,
459 )
460 .unwrap();
461 let k2 = derive_key(
462 b"wrong",
463 &salt,
464 VAULT_KDF_M_COST,
465 VAULT_KDF_T_COST,
466 VAULT_KDF_P_COST,
467 )
468 .unwrap();
469 let (nonce, ct) = encrypt(&k1, b"data").unwrap();
470 let result = decrypt(&k2, &nonce, &ct);
471 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
472 }
473
474 #[test]
475 fn nonces_are_unique() {
476 let n1 = random_nonce();
477 let n2 = random_nonce();
478 assert_ne!(n1, n2);
479 }
480
481 #[test]
482 fn b64_roundtrip() {
483 let data = b"hello world \x00\xff";
484 assert_eq!(decode_b64(&encode_b64(data)).unwrap(), data);
485 }
486
487 #[test]
488 fn hkdf_subkeys_are_domain_separated_and_deterministic() {
489 let salt = random_salt();
490 let root = derive_key(
491 b"test-password",
492 &salt,
493 VAULT_KDF_M_COST,
494 VAULT_KDF_T_COST,
495 VAULT_KDF_P_COST,
496 )
497 .unwrap();
498 let enc_1 = derive_subkey(&root, KeyPurpose::SecretData).unwrap();
499 let enc_2 = derive_subkey(&root, KeyPurpose::SecretData).unwrap();
500 let challenge = derive_subkey(&root, KeyPurpose::VaultChallenge).unwrap();
501 let audit = derive_subkey(&root, KeyPurpose::AuditLog).unwrap();
502
503 assert_eq!(enc_1.as_bytes(), enc_2.as_bytes());
504 assert_ne!(enc_1.as_bytes(), challenge.as_bytes());
505 assert_ne!(enc_1.as_bytes(), audit.as_bytes());
506 assert_ne!(challenge.as_bytes(), audit.as_bytes());
507 }
508
509 #[test]
510 fn detect_key_schedule_recognizes_hkdf_and_legacy_ciphertexts() {
511 let salt = random_salt();
512 let root = derive_key(
513 b"test-password",
514 &salt,
515 VAULT_KDF_M_COST,
516 VAULT_KDF_T_COST,
517 VAULT_KDF_P_COST,
518 )
519 .unwrap();
520 let expected = b"known-plaintext";
521
522 let (hkdf_nonce, hkdf_ct) = encrypt_with_key_schedule(
523 &root,
524 KeySchedule::HkdfSha256V1,
525 KeyPurpose::VaultChallenge,
526 CipherKind::XChaCha20Poly1305,
527 expected,
528 )
529 .unwrap();
530 assert_eq!(
531 detect_key_schedule(
532 &root,
533 KeyPurpose::VaultChallenge,
534 CipherKind::XChaCha20Poly1305,
535 &hkdf_nonce,
536 &hkdf_ct,
537 expected
538 )
539 .unwrap(),
540 KeySchedule::HkdfSha256V1
541 );
542
543 let (legacy_nonce, legacy_ct) = encrypt_with_key_schedule(
544 &root,
545 KeySchedule::LegacyDirect,
546 KeyPurpose::VaultChallenge,
547 CipherKind::XChaCha20Poly1305,
548 expected,
549 )
550 .unwrap();
551 assert_eq!(
552 detect_key_schedule(
553 &root,
554 KeyPurpose::VaultChallenge,
555 CipherKind::XChaCha20Poly1305,
556 &legacy_nonce,
557 &legacy_ct,
558 expected
559 )
560 .unwrap(),
561 KeySchedule::LegacyDirect
562 );
563 }
564
565 #[test]
566 fn parse_cipher_kind_accepts_legacy_cipher() {
567 assert_eq!(
568 parse_cipher_kind("xchacha20poly1305").unwrap(),
569 CipherKind::XChaCha20Poly1305
570 );
571 }
572
573 #[cfg(feature = "fips")]
574 #[test]
575 fn default_cipher_is_aes_gcm_in_fips_builds() {
576 assert_eq!(default_vault_cipher(), CipherKind::Aes256Gcm);
577 assert_eq!(CipherKind::Aes256Gcm.nonce_len(), AES256GCM_NONCE_LEN);
578 }
579
580 #[cfg(not(feature = "fips"))]
581 #[test]
582 fn default_cipher_is_xchacha_without_fips() {
583 assert_eq!(default_vault_cipher(), CipherKind::XChaCha20Poly1305);
584 }
585
586 #[cfg(feature = "fips")]
587 #[test]
588 fn aes_cipher_roundtrip_works_when_fips_enabled() {
589 let salt = random_salt();
590 let key = derive_key(
591 b"test-password",
592 &salt,
593 VAULT_KDF_M_COST,
594 VAULT_KDF_T_COST,
595 VAULT_KDF_P_COST,
596 )
597 .unwrap();
598 let plaintext = b"fips-mode-value";
599 let (nonce, ct) = encrypt_for_cipher(CipherKind::Aes256Gcm, &key, plaintext).unwrap();
600 let pt = decrypt_for_cipher(CipherKind::Aes256Gcm, &key, &nonce, &ct).unwrap();
601 assert_eq!(pt, plaintext);
602 }
603
604 fn test_key() -> VaultKey {
607 let salt = random_salt();
608 derive_key(b"test-pw", &salt, 8192, 1, 1).unwrap()
609 }
610
611 #[test]
613 fn tampered_ciphertext_tag_returns_decryption_failed() {
614 let key = test_key();
615 let (nonce, mut ct) = encrypt(&key, b"sensitive").unwrap();
616 let last = ct.len() - 1;
617 ct[last] ^= 0xff;
618 let result = decrypt(&key, &nonce, &ct);
619 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
620 }
621
622 #[test]
624 fn tampered_ciphertext_body_returns_decryption_failed() {
625 let key = test_key();
626 let (nonce, mut ct) = encrypt(&key, b"another-secret-value").unwrap();
627 ct[0] ^= 0x01;
628 let result = decrypt(&key, &nonce, &ct);
629 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
630 }
631
632 #[test]
634 fn empty_ciphertext_returns_decryption_failed() {
635 let key = test_key();
636 let nonce = random_nonce();
637 let result = decrypt(&key, &nonce, &[]);
638 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
639 }
640
641 #[test]
643 fn wrong_nonce_length_returns_invalid_vault() {
644 let key = test_key();
645 let (_, ct) = encrypt(&key, b"data").unwrap();
646 let short_nonce = [0u8; 12]; let result = decrypt(&key, &short_nonce, &ct);
648 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
649 }
650
651 #[test]
653 fn correct_key_wrong_nonce_returns_decryption_failed() {
654 let key = test_key();
655 let (_, ct) = encrypt(&key, b"data").unwrap();
656 let wrong_nonce = random_nonce();
657 let result = decrypt(&key, &wrong_nonce, &ct);
658 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
659 }
660
661 #[test]
664 fn keypurpose_segregation_prevents_cross_purpose_decryption() {
665 let root = test_key();
666 let plaintext = b"cross-purpose-test";
667
668 let (nonce, ct) = encrypt_with_key_schedule(
669 &root,
670 KeySchedule::HkdfSha256V1,
671 KeyPurpose::SecretData,
672 CipherKind::XChaCha20Poly1305,
673 plaintext,
674 )
675 .unwrap();
676
677 let result = decrypt_with_key_schedule(
678 &root,
679 KeySchedule::HkdfSha256V1,
680 KeyPurpose::VaultChallenge,
681 CipherKind::XChaCha20Poly1305,
682 &nonce,
683 &ct,
684 );
685 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
686 }
687
688 #[test]
690 fn hkdf_schedule_ciphertext_rejected_by_legacy_direct() {
691 let root = test_key();
692 let plaintext = b"schedule-isolation";
693
694 let (nonce, ct) = encrypt_with_key_schedule(
695 &root,
696 KeySchedule::HkdfSha256V1,
697 KeyPurpose::SecretData,
698 CipherKind::XChaCha20Poly1305,
699 plaintext,
700 )
701 .unwrap();
702
703 let result = decrypt_with_key_schedule(
704 &root,
705 KeySchedule::LegacyDirect,
706 KeyPurpose::SecretData,
707 CipherKind::XChaCha20Poly1305,
708 &nonce,
709 &ct,
710 );
711 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
712 }
713
714 #[test]
716 fn detect_key_schedule_with_wrong_key_returns_decryption_failed() {
717 let enc_key = test_key();
718 let dec_key = test_key(); let plaintext = b"detection-test";
720
721 let (nonce, ct) = encrypt_with_key_schedule(
722 &enc_key,
723 KeySchedule::HkdfSha256V1,
724 KeyPurpose::SecretData,
725 CipherKind::XChaCha20Poly1305,
726 plaintext,
727 )
728 .unwrap();
729
730 let result = detect_key_schedule(
731 &dec_key,
732 KeyPurpose::SecretData,
733 CipherKind::XChaCha20Poly1305,
734 &nonce,
735 &ct,
736 plaintext,
737 );
738 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
739 }
740
741 #[test]
743 fn all_keypurpose_subkeys_are_distinct() {
744 let root = test_key();
745 let sd = derive_subkey(&root, KeyPurpose::SecretData).unwrap();
746 let vc = derive_subkey(&root, KeyPurpose::VaultChallenge).unwrap();
747 let al = derive_subkey(&root, KeyPurpose::AuditLog).unwrap();
748 let sn = derive_subkey(&root, KeyPurpose::Snapshot).unwrap();
749
750 let keys = [sd.as_bytes(), vc.as_bytes(), al.as_bytes(), sn.as_bytes()];
751 for i in 0..keys.len() {
752 for j in (i + 1)..keys.len() {
753 assert_ne!(keys[i], keys[j], "subkeys[{i}] and subkeys[{j}] collided");
754 }
755 }
756 }
757
758 #[test]
761 fn snap_roundtrip() {
762 let plaintext = "my one-time secret";
763 let (blob, key) = snap_encrypt(plaintext).unwrap();
764 let recovered = snap_decrypt(&blob, &key).unwrap();
765 assert_eq!(recovered, plaintext);
766 }
767
768 #[test]
769 fn snap_decrypt_tampered_blob_returns_decryption_failed() {
770 let (mut blob_b64, key_b64) = snap_encrypt("value").unwrap();
771 let mut blob = decode_b64(&blob_b64).unwrap();
772 let last = blob.len() - 1;
773 blob[last] ^= 0xff;
774 blob_b64 = encode_b64(&blob);
775 let result = snap_decrypt(&blob_b64, &key_b64);
776 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
777 }
778
779 #[test]
780 fn snap_decrypt_wrong_key_returns_decryption_failed() {
781 let (blob_b64, _) = snap_encrypt("value").unwrap();
782 let (_, wrong_key_b64) = snap_encrypt("other").unwrap();
783 let result = snap_decrypt(&blob_b64, &wrong_key_b64);
784 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
785 }
786
787 #[test]
788 fn snap_decrypt_truncated_blob_returns_invalid_vault() {
789 let short_blob = encode_b64(&[0u8; 4]); let (_, key_b64) = snap_encrypt("value").unwrap();
791 let result = snap_decrypt(&short_blob, &key_b64);
792 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
793 }
794
795 #[test]
796 fn snap_decrypt_invalid_base64_blob_returns_invalid_vault() {
797 let (_, key_b64) = snap_encrypt("value").unwrap();
798 let result = snap_decrypt("!!!not-base64!!!", &key_b64);
799 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
800 }
801
802 #[test]
803 fn snap_decrypt_invalid_base64_key_returns_invalid_vault() {
804 let (blob_b64, _) = snap_encrypt("value").unwrap();
805 let result = snap_decrypt(&blob_b64, "!!!not-base64!!!");
806 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
807 }
808
809 #[test]
810 fn snap_decrypt_short_key_returns_invalid_vault() {
811 let (blob_b64, _) = snap_encrypt("value").unwrap();
812 let short_key = encode_b64(&[0u8; 16]); let result = snap_decrypt(&blob_b64, &short_key);
814 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
815 }
816
817 #[test]
820 fn hkdf_schedule_roundtrip_for_all_purposes() {
821 let root = test_key();
822 let plaintext = b"purpose-roundtrip";
823 for purpose in [
824 KeyPurpose::SecretData,
825 KeyPurpose::VaultChallenge,
826 KeyPurpose::AuditLog,
827 KeyPurpose::Snapshot,
828 ] {
829 let (nonce, ct) = encrypt_with_key_schedule(
830 &root,
831 KeySchedule::HkdfSha256V1,
832 purpose,
833 CipherKind::XChaCha20Poly1305,
834 plaintext,
835 )
836 .unwrap();
837 let pt = decrypt_with_key_schedule(
838 &root,
839 KeySchedule::HkdfSha256V1,
840 purpose,
841 CipherKind::XChaCha20Poly1305,
842 &nonce,
843 &ct,
844 )
845 .unwrap();
846 assert_eq!(pt, plaintext, "roundtrip failed for {purpose:?}");
847 }
848 }
849
850 #[test]
851 fn legacy_direct_schedule_roundtrip() {
852 let root = test_key();
853 let plaintext = b"legacy-data";
854 let (nonce, ct) = encrypt_with_key_schedule(
855 &root,
856 KeySchedule::LegacyDirect,
857 KeyPurpose::SecretData,
858 CipherKind::XChaCha20Poly1305,
859 plaintext,
860 )
861 .unwrap();
862 let pt = decrypt_with_key_schedule(
863 &root,
864 KeySchedule::LegacyDirect,
865 KeyPurpose::SecretData,
866 CipherKind::XChaCha20Poly1305,
867 &nonce,
868 &ct,
869 )
870 .unwrap();
871 assert_eq!(pt, plaintext);
872 }
873
874 #[test]
876 fn parse_cipher_kind_rejects_unknown_label() {
877 let result = parse_cipher_kind("chacha8");
878 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
879 }
880
881 #[test]
883 fn parse_cipher_kind_rejects_empty_string() {
884 let result = parse_cipher_kind("");
885 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
886 }
887}