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/// Constant-time equality for secret tokens (session tokens, cell tokens, etc.).
361///
362/// Uses [`subtle::ConstantTimeEq`] so the comparison does not short-circuit on
363/// the first differing byte, preventing a same-host local process from mounting
364/// a timing oracle to recover a token byte-by-byte. Lengths are compared first
365/// (length is not itself secret for these fixed-width hex tokens); only when the
366/// lengths match is the constant-time byte comparison performed.
367pub 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
377// ── Zero-knowledge snap crypto ────────────────────────────────────────────────
378// The snap server stores only ciphertext. The decryption key lives in the URL
379// fragment (#key) and is never transmitted to the server.
380
381/// Encrypt a plaintext string for one-time snap sharing.
382///
383/// Returns `(blob_b64url, key_b64url)`.
384/// - `blob_b64url` — base64url-encoded `nonce(24) || ciphertext` — safe to send to the snap server.
385/// - `key_b64url`  — base64url-encoded 32-byte random key — embed in the URL fragment only, **never POST this**.
386pub 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
398/// Decrypt a snap blob received from the snap server.
399///
400/// - `blob_b64`  — the `ciphertext` field from the snap server response.
401/// - `key_b64`   — the URL fragment value (after `#`).
402pub 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    // ── Adversarial / negative-path tests ─────────────────────────────────────
605
606    fn test_key() -> VaultKey {
607        let salt = random_salt();
608        derive_key(b"test-pw", &salt, 8192, 1, 1).unwrap()
609    }
610
611    /// Flipping any byte in the auth tag must cause authentication failure.
612    #[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    /// Flipping a byte in the ciphertext body (not the tag) also fails.
623    #[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    /// Empty ciphertext (no auth tag) is rejected cleanly.
633    #[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    /// A nonce of the wrong length is rejected with InvalidVault (not a panic).
642    #[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]; // 12 bytes instead of the required 24
647        let result = decrypt(&key, &short_nonce, &ct);
648        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
649    }
650
651    /// Correct key but a mismatched nonce must fail AEAD authentication.
652    #[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    /// KeyPurpose segregation: SecretData ciphertext cannot be decrypted with
662    /// the VaultChallenge subkey derived from the same root key.
663    #[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    /// HkdfSha256V1 ciphertext is rejected when fed to LegacyDirect even with the same root.
689    #[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    /// detect_key_schedule returns DecryptionFailed when neither schedule matches.
715    #[test]
716    fn detect_key_schedule_with_wrong_key_returns_decryption_failed() {
717        let enc_key = test_key();
718        let dec_key = test_key(); // entirely different key
719        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    /// All four KeyPurpose subkeys derived from the same root are mutually distinct.
742    #[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    // ── snap_encrypt / snap_decrypt adversarial paths ─────────────────────────
759
760    #[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]); // shorter than NONCE_LEN (24)
790        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]); // 16 bytes, expected 32
813        let result = snap_decrypt(&blob_b64, &short_key);
814        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
815    }
816
817    // ── Key schedule full roundtrip for every purpose ─────────────────────────
818
819    #[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    /// parse_cipher_kind rejects unknown labels.
875    #[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    /// parse_cipher_kind rejects empty string.
882    #[test]
883    fn parse_cipher_kind_rejects_empty_string() {
884        let result = parse_cipher_kind("");
885        assert!(matches!(result, Err(SafeError::InvalidVault { .. })));
886    }
887}