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 snap_encrypt(plaintext: &str) -> SafeResult<(String, String)> {
370 let mut key_bytes = [0u8; KEY_LEN];
371 rand::rngs::OsRng.fill_bytes(&mut key_bytes);
372 let snap_key = VaultKey(key_bytes);
373 let (nonce, ciphertext) = encrypt(&snap_key, plaintext.as_bytes())?;
374 let mut blob = nonce;
375 blob.extend_from_slice(&ciphertext);
376 let blob_b64 = encode_b64(&blob);
377 let key_b64 = encode_b64(snap_key.as_bytes());
378 Ok((blob_b64, key_b64))
379}
380
381pub fn snap_decrypt(blob_b64: &str, key_b64: &str) -> SafeResult<String> {
386 let blob = decode_b64(blob_b64).map_err(|_| SafeError::InvalidVault {
387 reason: "snap blob is not valid base64url".into(),
388 })?;
389 let key_bytes = decode_b64(key_b64).map_err(|_| SafeError::InvalidVault {
390 reason: "snap key is not valid base64url".into(),
391 })?;
392 if key_bytes.len() != KEY_LEN {
393 return Err(SafeError::InvalidVault {
394 reason: format!("snap key must be {KEY_LEN} bytes, got {}", key_bytes.len()),
395 });
396 }
397 if blob.len() < NONCE_LEN {
398 return Err(SafeError::InvalidVault {
399 reason: "snap blob too short — nonce is missing".into(),
400 });
401 }
402 let key_arr: [u8; KEY_LEN] = key_bytes.try_into().unwrap();
403 let snap_key = VaultKey(key_arr);
404 let nonce = &blob[..NONCE_LEN];
405 let ciphertext = &blob[NONCE_LEN..];
406 let plaintext_bytes = decrypt(&snap_key, nonce, ciphertext)?;
407 String::from_utf8(plaintext_bytes).map_err(|_| SafeError::InvalidVault {
408 reason: "decrypted snap is not valid UTF-8".into(),
409 })
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn roundtrip_encrypt_decrypt() {
418 let salt = random_salt();
419 let key = derive_key(
420 b"test-password",
421 &salt,
422 VAULT_KDF_M_COST,
423 VAULT_KDF_T_COST,
424 VAULT_KDF_P_COST,
425 )
426 .unwrap();
427 let plaintext = b"super-secret-value";
428 let (nonce, ct) = encrypt(&key, plaintext).unwrap();
429 let pt = decrypt(&key, &nonce, &ct).unwrap();
430 assert_eq!(pt, plaintext);
431 }
432
433 #[test]
434 fn wrong_password_returns_decryption_failed() {
435 let salt = random_salt();
436 let k1 = derive_key(
437 b"correct",
438 &salt,
439 VAULT_KDF_M_COST,
440 VAULT_KDF_T_COST,
441 VAULT_KDF_P_COST,
442 )
443 .unwrap();
444 let k2 = derive_key(
445 b"wrong",
446 &salt,
447 VAULT_KDF_M_COST,
448 VAULT_KDF_T_COST,
449 VAULT_KDF_P_COST,
450 )
451 .unwrap();
452 let (nonce, ct) = encrypt(&k1, b"data").unwrap();
453 let result = decrypt(&k2, &nonce, &ct);
454 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
455 }
456
457 #[test]
458 fn nonces_are_unique() {
459 let n1 = random_nonce();
460 let n2 = random_nonce();
461 assert_ne!(n1, n2);
462 }
463
464 #[test]
465 fn b64_roundtrip() {
466 let data = b"hello world \x00\xff";
467 assert_eq!(decode_b64(&encode_b64(data)).unwrap(), data);
468 }
469
470 #[test]
471 fn hkdf_subkeys_are_domain_separated_and_deterministic() {
472 let salt = random_salt();
473 let root = derive_key(
474 b"test-password",
475 &salt,
476 VAULT_KDF_M_COST,
477 VAULT_KDF_T_COST,
478 VAULT_KDF_P_COST,
479 )
480 .unwrap();
481 let enc_1 = derive_subkey(&root, KeyPurpose::SecretData).unwrap();
482 let enc_2 = derive_subkey(&root, KeyPurpose::SecretData).unwrap();
483 let challenge = derive_subkey(&root, KeyPurpose::VaultChallenge).unwrap();
484 let audit = derive_subkey(&root, KeyPurpose::AuditLog).unwrap();
485
486 assert_eq!(enc_1.as_bytes(), enc_2.as_bytes());
487 assert_ne!(enc_1.as_bytes(), challenge.as_bytes());
488 assert_ne!(enc_1.as_bytes(), audit.as_bytes());
489 assert_ne!(challenge.as_bytes(), audit.as_bytes());
490 }
491
492 #[test]
493 fn detect_key_schedule_recognizes_hkdf_and_legacy_ciphertexts() {
494 let salt = random_salt();
495 let root = derive_key(
496 b"test-password",
497 &salt,
498 VAULT_KDF_M_COST,
499 VAULT_KDF_T_COST,
500 VAULT_KDF_P_COST,
501 )
502 .unwrap();
503 let expected = b"known-plaintext";
504
505 let (hkdf_nonce, hkdf_ct) = encrypt_with_key_schedule(
506 &root,
507 KeySchedule::HkdfSha256V1,
508 KeyPurpose::VaultChallenge,
509 CipherKind::XChaCha20Poly1305,
510 expected,
511 )
512 .unwrap();
513 assert_eq!(
514 detect_key_schedule(
515 &root,
516 KeyPurpose::VaultChallenge,
517 CipherKind::XChaCha20Poly1305,
518 &hkdf_nonce,
519 &hkdf_ct,
520 expected
521 )
522 .unwrap(),
523 KeySchedule::HkdfSha256V1
524 );
525
526 let (legacy_nonce, legacy_ct) = encrypt_with_key_schedule(
527 &root,
528 KeySchedule::LegacyDirect,
529 KeyPurpose::VaultChallenge,
530 CipherKind::XChaCha20Poly1305,
531 expected,
532 )
533 .unwrap();
534 assert_eq!(
535 detect_key_schedule(
536 &root,
537 KeyPurpose::VaultChallenge,
538 CipherKind::XChaCha20Poly1305,
539 &legacy_nonce,
540 &legacy_ct,
541 expected
542 )
543 .unwrap(),
544 KeySchedule::LegacyDirect
545 );
546 }
547
548 #[test]
549 fn parse_cipher_kind_accepts_legacy_cipher() {
550 assert_eq!(
551 parse_cipher_kind("xchacha20poly1305").unwrap(),
552 CipherKind::XChaCha20Poly1305
553 );
554 }
555
556 #[cfg(feature = "fips")]
557 #[test]
558 fn default_cipher_is_aes_gcm_in_fips_builds() {
559 assert_eq!(default_vault_cipher(), CipherKind::Aes256Gcm);
560 assert_eq!(CipherKind::Aes256Gcm.nonce_len(), AES256GCM_NONCE_LEN);
561 }
562
563 #[cfg(not(feature = "fips"))]
564 #[test]
565 fn default_cipher_is_xchacha_without_fips() {
566 assert_eq!(default_vault_cipher(), CipherKind::XChaCha20Poly1305);
567 }
568
569 #[cfg(feature = "fips")]
570 #[test]
571 fn aes_cipher_roundtrip_works_when_fips_enabled() {
572 let salt = random_salt();
573 let key = derive_key(
574 b"test-password",
575 &salt,
576 VAULT_KDF_M_COST,
577 VAULT_KDF_T_COST,
578 VAULT_KDF_P_COST,
579 )
580 .unwrap();
581 let plaintext = b"fips-mode-value";
582 let (nonce, ct) = encrypt_for_cipher(CipherKind::Aes256Gcm, &key, plaintext).unwrap();
583 let pt = decrypt_for_cipher(CipherKind::Aes256Gcm, &key, &nonce, &ct).unwrap();
584 assert_eq!(pt, plaintext);
585 }
586
587 fn test_key() -> VaultKey {
590 let salt = random_salt();
591 derive_key(b"test-pw", &salt, 8192, 1, 1).unwrap()
592 }
593
594 #[test]
596 fn tampered_ciphertext_tag_returns_decryption_failed() {
597 let key = test_key();
598 let (nonce, mut ct) = encrypt(&key, b"sensitive").unwrap();
599 let last = ct.len() - 1;
600 ct[last] ^= 0xff;
601 let result = decrypt(&key, &nonce, &ct);
602 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
603 }
604
605 #[test]
607 fn tampered_ciphertext_body_returns_decryption_failed() {
608 let key = test_key();
609 let (nonce, mut ct) = encrypt(&key, b"another-secret-value").unwrap();
610 ct[0] ^= 0x01;
611 let result = decrypt(&key, &nonce, &ct);
612 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
613 }
614
615 #[test]
617 fn empty_ciphertext_returns_decryption_failed() {
618 let key = test_key();
619 let nonce = random_nonce();
620 let result = decrypt(&key, &nonce, &[]);
621 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
622 }
623
624 #[test]
626 fn wrong_nonce_length_returns_invalid_vault() {
627 let key = test_key();
628 let (_, ct) = encrypt(&key, b"data").unwrap();
629 let short_nonce = [0u8; 12]; let result = decrypt(&key, &short_nonce, &ct);
631 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
632 }
633
634 #[test]
636 fn correct_key_wrong_nonce_returns_decryption_failed() {
637 let key = test_key();
638 let (_, ct) = encrypt(&key, b"data").unwrap();
639 let wrong_nonce = random_nonce();
640 let result = decrypt(&key, &wrong_nonce, &ct);
641 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
642 }
643
644 #[test]
647 fn keypurpose_segregation_prevents_cross_purpose_decryption() {
648 let root = test_key();
649 let plaintext = b"cross-purpose-test";
650
651 let (nonce, ct) = encrypt_with_key_schedule(
652 &root,
653 KeySchedule::HkdfSha256V1,
654 KeyPurpose::SecretData,
655 CipherKind::XChaCha20Poly1305,
656 plaintext,
657 )
658 .unwrap();
659
660 let result = decrypt_with_key_schedule(
661 &root,
662 KeySchedule::HkdfSha256V1,
663 KeyPurpose::VaultChallenge,
664 CipherKind::XChaCha20Poly1305,
665 &nonce,
666 &ct,
667 );
668 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
669 }
670
671 #[test]
673 fn hkdf_schedule_ciphertext_rejected_by_legacy_direct() {
674 let root = test_key();
675 let plaintext = b"schedule-isolation";
676
677 let (nonce, ct) = encrypt_with_key_schedule(
678 &root,
679 KeySchedule::HkdfSha256V1,
680 KeyPurpose::SecretData,
681 CipherKind::XChaCha20Poly1305,
682 plaintext,
683 )
684 .unwrap();
685
686 let result = decrypt_with_key_schedule(
687 &root,
688 KeySchedule::LegacyDirect,
689 KeyPurpose::SecretData,
690 CipherKind::XChaCha20Poly1305,
691 &nonce,
692 &ct,
693 );
694 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
695 }
696
697 #[test]
699 fn detect_key_schedule_with_wrong_key_returns_decryption_failed() {
700 let enc_key = test_key();
701 let dec_key = test_key(); let plaintext = b"detection-test";
703
704 let (nonce, ct) = encrypt_with_key_schedule(
705 &enc_key,
706 KeySchedule::HkdfSha256V1,
707 KeyPurpose::SecretData,
708 CipherKind::XChaCha20Poly1305,
709 plaintext,
710 )
711 .unwrap();
712
713 let result = detect_key_schedule(
714 &dec_key,
715 KeyPurpose::SecretData,
716 CipherKind::XChaCha20Poly1305,
717 &nonce,
718 &ct,
719 plaintext,
720 );
721 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
722 }
723
724 #[test]
726 fn all_keypurpose_subkeys_are_distinct() {
727 let root = test_key();
728 let sd = derive_subkey(&root, KeyPurpose::SecretData).unwrap();
729 let vc = derive_subkey(&root, KeyPurpose::VaultChallenge).unwrap();
730 let al = derive_subkey(&root, KeyPurpose::AuditLog).unwrap();
731 let sn = derive_subkey(&root, KeyPurpose::Snapshot).unwrap();
732
733 let keys = [sd.as_bytes(), vc.as_bytes(), al.as_bytes(), sn.as_bytes()];
734 for i in 0..keys.len() {
735 for j in (i + 1)..keys.len() {
736 assert_ne!(keys[i], keys[j], "subkeys[{i}] and subkeys[{j}] collided");
737 }
738 }
739 }
740
741 #[test]
744 fn snap_roundtrip() {
745 let plaintext = "my one-time secret";
746 let (blob, key) = snap_encrypt(plaintext).unwrap();
747 let recovered = snap_decrypt(&blob, &key).unwrap();
748 assert_eq!(recovered, plaintext);
749 }
750
751 #[test]
752 fn snap_decrypt_tampered_blob_returns_decryption_failed() {
753 let (mut blob_b64, key_b64) = snap_encrypt("value").unwrap();
754 let mut blob = decode_b64(&blob_b64).unwrap();
755 let last = blob.len() - 1;
756 blob[last] ^= 0xff;
757 blob_b64 = encode_b64(&blob);
758 let result = snap_decrypt(&blob_b64, &key_b64);
759 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
760 }
761
762 #[test]
763 fn snap_decrypt_wrong_key_returns_decryption_failed() {
764 let (blob_b64, _) = snap_encrypt("value").unwrap();
765 let (_, wrong_key_b64) = snap_encrypt("other").unwrap();
766 let result = snap_decrypt(&blob_b64, &wrong_key_b64);
767 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
768 }
769
770 #[test]
771 fn snap_decrypt_truncated_blob_returns_invalid_vault() {
772 let short_blob = encode_b64(&[0u8; 4]); let (_, key_b64) = snap_encrypt("value").unwrap();
774 let result = snap_decrypt(&short_blob, &key_b64);
775 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
776 }
777
778 #[test]
779 fn snap_decrypt_invalid_base64_blob_returns_invalid_vault() {
780 let (_, key_b64) = snap_encrypt("value").unwrap();
781 let result = snap_decrypt("!!!not-base64!!!", &key_b64);
782 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
783 }
784
785 #[test]
786 fn snap_decrypt_invalid_base64_key_returns_invalid_vault() {
787 let (blob_b64, _) = snap_encrypt("value").unwrap();
788 let result = snap_decrypt(&blob_b64, "!!!not-base64!!!");
789 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
790 }
791
792 #[test]
793 fn snap_decrypt_short_key_returns_invalid_vault() {
794 let (blob_b64, _) = snap_encrypt("value").unwrap();
795 let short_key = encode_b64(&[0u8; 16]); let result = snap_decrypt(&blob_b64, &short_key);
797 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
798 }
799
800 #[test]
803 fn hkdf_schedule_roundtrip_for_all_purposes() {
804 let root = test_key();
805 let plaintext = b"purpose-roundtrip";
806 for purpose in [
807 KeyPurpose::SecretData,
808 KeyPurpose::VaultChallenge,
809 KeyPurpose::AuditLog,
810 KeyPurpose::Snapshot,
811 ] {
812 let (nonce, ct) = encrypt_with_key_schedule(
813 &root,
814 KeySchedule::HkdfSha256V1,
815 purpose,
816 CipherKind::XChaCha20Poly1305,
817 plaintext,
818 )
819 .unwrap();
820 let pt = decrypt_with_key_schedule(
821 &root,
822 KeySchedule::HkdfSha256V1,
823 purpose,
824 CipherKind::XChaCha20Poly1305,
825 &nonce,
826 &ct,
827 )
828 .unwrap();
829 assert_eq!(pt, plaintext, "roundtrip failed for {purpose:?}");
830 }
831 }
832
833 #[test]
834 fn legacy_direct_schedule_roundtrip() {
835 let root = test_key();
836 let plaintext = b"legacy-data";
837 let (nonce, ct) = encrypt_with_key_schedule(
838 &root,
839 KeySchedule::LegacyDirect,
840 KeyPurpose::SecretData,
841 CipherKind::XChaCha20Poly1305,
842 plaintext,
843 )
844 .unwrap();
845 let pt = decrypt_with_key_schedule(
846 &root,
847 KeySchedule::LegacyDirect,
848 KeyPurpose::SecretData,
849 CipherKind::XChaCha20Poly1305,
850 &nonce,
851 &ct,
852 )
853 .unwrap();
854 assert_eq!(pt, plaintext);
855 }
856
857 #[test]
859 fn parse_cipher_kind_rejects_unknown_label() {
860 let result = parse_cipher_kind("chacha8");
861 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
862 }
863
864 #[test]
866 fn parse_cipher_kind_rejects_empty_string() {
867 let result = parse_cipher_kind("");
868 assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
869 }
870}