Skip to main content

tsafe_core/
crypto.rs

1//! Low-level cryptography primitives for tsafe.
2//!
3//! Key derivation: Argon2id with tunable cost parameters.  Encryption:
4//! XChaCha20-Poly1305 by default, with optional AES-256-GCM support behind the
5//! `fips` feature flag.  All secret material is handled via
6//! [`Zeroizing`](zeroize::Zeroizing) wrappers so keys are wiped from heap
7//! memory on drop.
8
9#[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
26// KDF defaults — validated against OWASP recommendations for Argon2id.
27pub const VAULT_KDF_M_COST: u32 = 65536; // 64 MiB
28pub 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; // snap + legacy helpers
34#[cfg(feature = "fips")]
35pub const AES256GCM_NONCE_LEN: usize = 12;
36
37/// A 256-bit key that is zeroed from memory on drop.
38#[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    /// Construct a VaultKey from raw bytes (e.g. a randomly generated DEK).
47    pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
48        Self(bytes)
49    }
50}
51
52/// The on-disk vault format historically used the root key directly. Newer
53/// vaults keep the same file format but scope encryption through HKDF-derived
54/// purpose keys so challenge/data material do not share a key.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum KeySchedule {
57    LegacyDirect,
58    HkdfSha256V1,
59}
60
61/// Cipher selection for vault/team ciphertext on disk.
62#[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/// Purpose labels for HKDF expansion. Adding new purposes must use a distinct
88/// label so old ciphertext domains remain isolated.
89#[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/// Derive a 256-bit key from a password + salt using Argon2id.
154#[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
176/// Derive a purpose-scoped 256-bit subkey from a root key using HKDF-SHA256.
177pub fn derive_subkey(root_key: &VaultKey, purpose: KeyPurpose) -> SafeResult<VaultKey> {
178    derive_labeled_subkey(root_key, purpose.label())
179}
180
181/// Derive a 256-bit subkey from a root key using an explicit HKDF label.
182pub 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
225/// Detect which key schedule was used for a known-plaintext record.
226pub 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/// Encrypt plaintext with XChaCha20-Poly1305. Returns (nonce, ciphertext).
277#[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/// Decrypt with XChaCha20-Poly1305. Authentication failure returns `DecryptionFailed`.
294#[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
360// ── Zero-knowledge snap crypto ────────────────────────────────────────────────
361// The snap server stores only ciphertext. The decryption key lives in the URL
362// fragment (#key) and is never transmitted to the server.
363
364/// Encrypt a plaintext string for one-time snap sharing.
365///
366/// Returns `(blob_b64url, key_b64url)`.
367/// - `blob_b64url` — base64url-encoded `nonce(24) || ciphertext` — safe to send to the snap server.
368/// - `key_b64url`  — base64url-encoded 32-byte random key — embed in the URL fragment only, **never POST this**.
369pub 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
381/// Decrypt a snap blob received from the snap server.
382///
383/// - `blob_b64`  — the `ciphertext` field from the snap server response.
384/// - `key_b64`   — the URL fragment value (after `#`).
385pub 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    // ── Adversarial / negative-path tests ─────────────────────────────────────
588
589    fn test_key() -> VaultKey {
590        let salt = random_salt();
591        derive_key(b"test-pw", &salt, 8192, 1, 1).unwrap()
592    }
593
594    /// Flipping any byte in the auth tag must cause authentication failure.
595    #[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    /// Flipping a byte in the ciphertext body (not the tag) also fails.
606    #[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    /// Empty ciphertext (no auth tag) is rejected cleanly.
616    #[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    /// A nonce of the wrong length is rejected with InvalidVault (not a panic).
625    #[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]; // 12 bytes instead of the required 24
630        let result = decrypt(&key, &short_nonce, &ct);
631        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
632    }
633
634    /// Correct key but a mismatched nonce must fail AEAD authentication.
635    #[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    /// KeyPurpose segregation: SecretData ciphertext cannot be decrypted with
645    /// the VaultChallenge subkey derived from the same root key.
646    #[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    /// HkdfSha256V1 ciphertext is rejected when fed to LegacyDirect even with the same root.
672    #[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    /// detect_key_schedule returns DecryptionFailed when neither schedule matches.
698    #[test]
699    fn detect_key_schedule_with_wrong_key_returns_decryption_failed() {
700        let enc_key = test_key();
701        let dec_key = test_key(); // entirely different key
702        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    /// All four KeyPurpose subkeys derived from the same root are mutually distinct.
725    #[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    // ── snap_encrypt / snap_decrypt adversarial paths ─────────────────────────
742
743    #[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]); // shorter than NONCE_LEN (24)
773        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]); // 16 bytes, expected 32
796        let result = snap_decrypt(&blob_b64, &short_key);
797        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
798    }
799
800    // ── Key schedule full roundtrip for every purpose ─────────────────────────
801
802    #[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    /// parse_cipher_kind rejects unknown labels.
858    #[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    /// parse_cipher_kind rejects empty string.
865    #[test]
866    fn parse_cipher_kind_rejects_empty_string() {
867        let result = parse_cipher_kind("");
868        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
869    }
870}