Skip to main content

tzap_core/
crypto.rs

1use aes_gcm::Aes256Gcm;
2use aes_gcm_siv::Aes256GcmSiv;
3use argon2::{Algorithm, Argon2, Params, Version};
4use chacha20poly1305::XChaCha20Poly1305;
5use hkdf::Hkdf;
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8use unicode_normalization::UnicodeNormalization;
9use zeroize::{Zeroize, ZeroizeOnDrop};
10
11use aes_gcm_siv::aead::{Aead, KeyInit as AeadKeyInit, Payload};
12
13use crate::format::{
14    AeadAlgo, FormatError, KdfAlgo, MASTER_KEY_LEN, READER_MAX_ARGON2ID_M_COST_KIB,
15    READER_MAX_ARGON2ID_PARALLELISM, READER_MAX_ARGON2ID_T_COST, SUBKEY_LEN,
16};
17use crate::padding::{depad_suffix_padding, suffix_pad_for_aead};
18
19type HmacSha256 = Hmac<Sha256>;
20
21const HKDF_SALT_DOMAIN: &[u8] = b"tzap-v1-subkeys";
22const CRYPTO_HEADER_HMAC_DOMAIN: &[u8] = b"tzap-v1-crypto-header";
23const MANIFEST_FOOTER_HMAC_DOMAIN: &[u8] = b"tzap-v1-manifest-footer";
24const VOLUME_TRAILER_HMAC_DOMAIN: &[u8] = b"tzap-v1-volume-trailer";
25const BOOTSTRAP_SIDECAR_HMAC_DOMAIN: &[u8] = b"tzap-v1-sidecar";
26
27const RAW_KDF_PARAMS_LEN: usize = 2;
28const ARGON2ID_FIXED_PARAMS_LEN: usize = 16;
29const ARGON2ID_MIN_SALT_LEN: u16 = 8;
30const ARGON2ID_MAX_SALT_LEN: u16 = 64;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum KdfParams {
34    Raw,
35    Argon2id {
36        t_cost: u32,
37        m_cost_kib: u32,
38        parallelism: u32,
39        salt: Vec<u8>,
40    },
41}
42
43impl KdfParams {
44    pub fn parse(algo: KdfAlgo, bytes: &[u8]) -> Result<(Self, usize), FormatError> {
45        match algo {
46            KdfAlgo::Raw => parse_raw_kdf_params(bytes),
47            KdfAlgo::Argon2id => parse_argon2id_kdf_params(bytes),
48        }
49    }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
53pub struct MasterKey(pub [u8; MASTER_KEY_LEN]);
54
55impl MasterKey {
56    pub fn from_raw_key(raw_key: &[u8]) -> Result<Self, FormatError> {
57        if raw_key.len() != MASTER_KEY_LEN {
58            return Err(FormatError::InvalidRawMasterKeyLength);
59        }
60        let mut key = [0u8; MASTER_KEY_LEN];
61        key.copy_from_slice(raw_key);
62        Ok(Self(key))
63    }
64
65    pub fn derive_from_passphrase(
66        params: &KdfParams,
67        passphrase: &str,
68    ) -> Result<Self, FormatError> {
69        let KdfParams::Argon2id {
70            t_cost,
71            m_cost_kib,
72            parallelism,
73            salt,
74        } = params
75        else {
76            return Err(FormatError::KeyMaterialMismatch);
77        };
78
79        let salt_length = u16::try_from(salt.len()).map_err(|_| {
80            FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
81        })?;
82        validate_argon2id_bounds(*t_cost, *m_cost_kib, *parallelism, salt_length)?;
83        let params = Params::new(*m_cost_kib, *t_cost, *parallelism, Some(MASTER_KEY_LEN))
84            .map_err(|_| FormatError::InvalidKdfParams("argon2 params rejected"))?;
85        let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
86        let mut output = [0u8; MASTER_KEY_LEN];
87        let mut passphrase_bytes = normalize_passphrase_nfc(passphrase);
88        let result = argon2.hash_password_into(&passphrase_bytes, salt, &mut output);
89        passphrase_bytes.zeroize();
90        result.map_err(|_| FormatError::Argon2idFailure)?;
91        Ok(Self(output))
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
96pub struct Subkeys {
97    pub enc_key: [u8; SUBKEY_LEN],
98    pub mac_key: [u8; SUBKEY_LEN],
99    pub nonce_seed: [u8; SUBKEY_LEN],
100    pub index_root_key: [u8; SUBKEY_LEN],
101    pub index_shard_key: [u8; SUBKEY_LEN],
102    pub dictionary_key: [u8; SUBKEY_LEN],
103    pub dir_hint_key: [u8; SUBKEY_LEN],
104    pub index_nonce_seed: [u8; SUBKEY_LEN],
105}
106
107impl Subkeys {
108    pub fn derive(
109        master_key: &MasterKey,
110        archive_uuid: &[u8; 16],
111        session_id: &[u8; 16],
112    ) -> Result<Self, FormatError> {
113        let mut salt = Vec::with_capacity(HKDF_SALT_DOMAIN.len() + 32);
114        salt.extend_from_slice(HKDF_SALT_DOMAIN);
115        salt.extend_from_slice(archive_uuid);
116        salt.extend_from_slice(session_id);
117        let hk = Hkdf::<Sha256>::new(Some(&salt), &master_key.0);
118        salt.zeroize();
119
120        Ok(Self {
121            enc_key: expand_subkey(&hk, b"tzap-v1-enc")?,
122            mac_key: expand_subkey(&hk, b"tzap-v1-mac")?,
123            nonce_seed: expand_subkey(&hk, b"tzap-v1-nonce")?,
124            index_root_key: expand_subkey(&hk, b"tzap-v1-idxroot")?,
125            index_shard_key: expand_subkey(&hk, b"tzap-v1-idxshard")?,
126            dictionary_key: expand_subkey(&hk, b"tzap-v1-dict")?,
127            dir_hint_key: expand_subkey(&hk, b"tzap-v1-dirhint")?,
128            index_nonce_seed: expand_subkey(&hk, b"tzap-v1-idxnonce")?,
129        })
130    }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum HmacDomain {
135    CryptoHeader,
136    ManifestFooter,
137    VolumeTrailer,
138    BootstrapSidecar,
139}
140
141impl HmacDomain {
142    pub fn structure_name(self) -> &'static str {
143        match self {
144            Self::CryptoHeader => "CryptoHeader",
145            Self::ManifestFooter => "ManifestFooter",
146            Self::VolumeTrailer => "VolumeTrailer",
147            Self::BootstrapSidecar => "BootstrapSidecarHeader",
148        }
149    }
150
151    fn domain_bytes(self) -> &'static [u8] {
152        match self {
153            Self::CryptoHeader => CRYPTO_HEADER_HMAC_DOMAIN,
154            Self::ManifestFooter => MANIFEST_FOOTER_HMAC_DOMAIN,
155            Self::VolumeTrailer => VOLUME_TRAILER_HMAC_DOMAIN,
156            Self::BootstrapSidecar => BOOTSTRAP_SIDECAR_HMAC_DOMAIN,
157        }
158    }
159}
160
161pub fn compute_hmac(
162    domain: HmacDomain,
163    mac_key: &[u8; SUBKEY_LEN],
164    archive_uuid: &[u8; 16],
165    session_id: &[u8; 16],
166    covered_bytes: &[u8],
167) -> [u8; SUBKEY_LEN] {
168    let mut mac =
169        <HmacSha256 as Mac>::new_from_slice(mac_key).expect("HMAC accepts any key length");
170    mac.update(domain.domain_bytes());
171    mac.update(archive_uuid);
172    mac.update(session_id);
173    mac.update(covered_bytes);
174    let digest = mac.finalize().into_bytes();
175    let mut output = [0u8; SUBKEY_LEN];
176    output.copy_from_slice(&digest);
177    output
178}
179
180pub fn verify_hmac(
181    domain: HmacDomain,
182    mac_key: &[u8; SUBKEY_LEN],
183    archive_uuid: &[u8; 16],
184    session_id: &[u8; 16],
185    covered_bytes: &[u8],
186    expected_hmac: &[u8],
187) -> Result<(), FormatError> {
188    let mut mac =
189        <HmacSha256 as Mac>::new_from_slice(mac_key).expect("HMAC accepts any key length");
190    mac.update(domain.domain_bytes());
191    mac.update(archive_uuid);
192    mac.update(session_id);
193    mac.update(covered_bytes);
194    mac.verify_slice(expected_hmac)
195        .map_err(|_| FormatError::HmacMismatch {
196            structure: domain.structure_name(),
197        })
198}
199
200pub fn normalize_passphrase_nfc(passphrase: &str) -> Vec<u8> {
201    passphrase.nfc().collect::<String>().into_bytes()
202}
203
204pub fn derive_nonce(
205    seed: &[u8; SUBKEY_LEN],
206    domain: &[u8],
207    archive_uuid: &[u8; 16],
208    session_id: &[u8; 16],
209    counter: u64,
210    len: usize,
211) -> Result<Vec<u8>, FormatError> {
212    let info = nonce_or_aad_info(b"tzap-v1-nonce", domain, archive_uuid, session_id, counter)?;
213    let hk = Hkdf::<Sha256>::from_prk(seed)
214        .map_err(|_| FormatError::InvalidKdfParams("bad nonce seed"))?;
215    let mut nonce = vec![0u8; len];
216    hk.expand(&info, &mut nonce)
217        .map_err(|_| FormatError::HkdfExpandFailure)?;
218    Ok(nonce)
219}
220
221pub fn build_aad(
222    domain: &[u8],
223    archive_uuid: &[u8; 16],
224    session_id: &[u8; 16],
225    counter: u64,
226) -> Result<Vec<u8>, FormatError> {
227    nonce_or_aad_info(b"tzap-v1-aad", domain, archive_uuid, session_id, counter)
228}
229
230pub fn aead_encrypt(
231    algo: AeadAlgo,
232    key: &[u8; SUBKEY_LEN],
233    nonce: &[u8],
234    aad: &[u8],
235    plaintext: &[u8],
236) -> Result<Vec<u8>, FormatError> {
237    validate_nonce_len(algo, nonce)?;
238    match algo {
239        AeadAlgo::AesGcmSiv256 => {
240            let cipher =
241                Aes256GcmSiv::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
242            cipher
243                .encrypt(
244                    aes_gcm_siv::Nonce::from_slice(nonce),
245                    Payload {
246                        msg: plaintext,
247                        aad,
248                    },
249                )
250                .map_err(|_| FormatError::AeadFailure)
251        }
252        AeadAlgo::XChaCha20Poly1305 => {
253            let cipher = XChaCha20Poly1305::new_from_slice(key)
254                .map_err(|_| FormatError::InvalidAeadKeyLength)?;
255            cipher
256                .encrypt(
257                    chacha20poly1305::XNonce::from_slice(nonce),
258                    Payload {
259                        msg: plaintext,
260                        aad,
261                    },
262                )
263                .map_err(|_| FormatError::AeadFailure)
264        }
265        AeadAlgo::AesGcm256 => {
266            let cipher =
267                Aes256Gcm::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
268            cipher
269                .encrypt(
270                    aes_gcm::Nonce::from_slice(nonce),
271                    Payload {
272                        msg: plaintext,
273                        aad,
274                    },
275                )
276                .map_err(|_| FormatError::AeadFailure)
277        }
278    }
279}
280
281pub fn aead_decrypt(
282    algo: AeadAlgo,
283    key: &[u8; SUBKEY_LEN],
284    nonce: &[u8],
285    aad: &[u8],
286    ciphertext_and_tag: &[u8],
287) -> Result<Vec<u8>, FormatError> {
288    validate_nonce_len(algo, nonce)?;
289    match algo {
290        AeadAlgo::AesGcmSiv256 => {
291            let cipher =
292                Aes256GcmSiv::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
293            cipher
294                .decrypt(
295                    aes_gcm_siv::Nonce::from_slice(nonce),
296                    Payload {
297                        msg: ciphertext_and_tag,
298                        aad,
299                    },
300                )
301                .map_err(|_| FormatError::AeadFailure)
302        }
303        AeadAlgo::XChaCha20Poly1305 => {
304            let cipher = XChaCha20Poly1305::new_from_slice(key)
305                .map_err(|_| FormatError::InvalidAeadKeyLength)?;
306            cipher
307                .decrypt(
308                    chacha20poly1305::XNonce::from_slice(nonce),
309                    Payload {
310                        msg: ciphertext_and_tag,
311                        aad,
312                    },
313                )
314                .map_err(|_| FormatError::AeadFailure)
315        }
316        AeadAlgo::AesGcm256 => {
317            let cipher =
318                Aes256Gcm::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
319            cipher
320                .decrypt(
321                    aes_gcm::Nonce::from_slice(nonce),
322                    Payload {
323                        msg: ciphertext_and_tag,
324                        aad,
325                    },
326                )
327                .map_err(|_| FormatError::AeadFailure)
328        }
329    }
330}
331
332pub fn encrypt_padded_aead_object(
333    algo: AeadAlgo,
334    key: &[u8; SUBKEY_LEN],
335    nonce_seed: &[u8; SUBKEY_LEN],
336    domain: &[u8],
337    archive_uuid: &[u8; 16],
338    session_id: &[u8; 16],
339    counter: u64,
340    block_size: usize,
341    payload: &[u8],
342) -> Result<Vec<u8>, FormatError> {
343    let nonce = derive_nonce(
344        nonce_seed,
345        domain,
346        archive_uuid,
347        session_id,
348        counter,
349        algo.nonce_len(),
350    )?;
351    let aad = build_aad(domain, archive_uuid, session_id, counter)?;
352    let padded = suffix_pad_for_aead(payload, algo.tag_len(), block_size)?;
353    aead_encrypt(algo, key, &nonce, &aad, &padded)
354}
355
356pub fn decrypt_padded_aead_object(
357    algo: AeadAlgo,
358    key: &[u8; SUBKEY_LEN],
359    nonce_seed: &[u8; SUBKEY_LEN],
360    domain: &[u8],
361    archive_uuid: &[u8; 16],
362    session_id: &[u8; 16],
363    counter: u64,
364    ciphertext_and_tag: &[u8],
365) -> Result<Vec<u8>, FormatError> {
366    let nonce = derive_nonce(
367        nonce_seed,
368        domain,
369        archive_uuid,
370        session_id,
371        counter,
372        algo.nonce_len(),
373    )?;
374    let aad = build_aad(domain, archive_uuid, session_id, counter)?;
375    let padded = aead_decrypt(algo, key, &nonce, &aad, ciphertext_and_tag)?;
376    Ok(depad_suffix_padding(&padded)?.to_vec())
377}
378
379fn parse_raw_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
380    if bytes.len() < RAW_KDF_PARAMS_LEN {
381        return Err(FormatError::TruncatedKdfParams);
382    }
383    let algo_tag = read_u16(bytes, 0)?;
384    if algo_tag != KdfAlgo::Raw as u16 {
385        return Err(FormatError::KdfAlgoTagMismatch {
386            expected: KdfAlgo::Raw as u16,
387            actual: algo_tag,
388        });
389    }
390    Ok((KdfParams::Raw, RAW_KDF_PARAMS_LEN))
391}
392
393fn parse_argon2id_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
394    if bytes.len() < ARGON2ID_FIXED_PARAMS_LEN {
395        return Err(FormatError::TruncatedKdfParams);
396    }
397    let algo_tag = read_u16(bytes, 0)?;
398    if algo_tag != KdfAlgo::Argon2id as u16 {
399        return Err(FormatError::KdfAlgoTagMismatch {
400            expected: KdfAlgo::Argon2id as u16,
401            actual: algo_tag,
402        });
403    }
404    let t_cost = read_u32(bytes, 2)?;
405    let m_cost_kib = read_u32(bytes, 6)?;
406    let parallelism = read_u32(bytes, 10)?;
407    let salt_length = read_u16(bytes, 14)?;
408    if salt_length < ARGON2ID_MIN_SALT_LEN || salt_length > ARGON2ID_MAX_SALT_LEN {
409        return Err(FormatError::InvalidKdfParams(
410            "argon2id salt length must be 8..64 bytes",
411        ));
412    }
413    if t_cost == 0 {
414        return Err(FormatError::InvalidKdfParams(
415            "argon2id t_cost must be non-zero",
416        ));
417    }
418    if parallelism == 0 {
419        return Err(FormatError::InvalidKdfParams(
420            "argon2id parallelism must be non-zero",
421        ));
422    }
423    validate_argon2id_bounds(t_cost, m_cost_kib, parallelism, salt_length)?;
424
425    let total_len = ARGON2ID_FIXED_PARAMS_LEN + salt_length as usize;
426    if bytes.len() < total_len {
427        return Err(FormatError::TruncatedKdfParams);
428    }
429    Ok((
430        KdfParams::Argon2id {
431            t_cost,
432            m_cost_kib,
433            parallelism,
434            salt: bytes[ARGON2ID_FIXED_PARAMS_LEN..total_len].to_vec(),
435        },
436        total_len,
437    ))
438}
439
440fn validate_argon2id_bounds(
441    t_cost: u32,
442    m_cost_kib: u32,
443    parallelism: u32,
444    salt_length: u16,
445) -> Result<(), FormatError> {
446    if salt_length < ARGON2ID_MIN_SALT_LEN || salt_length > ARGON2ID_MAX_SALT_LEN {
447        return Err(FormatError::InvalidKdfParams(
448            "argon2id salt length must be 8..64 bytes",
449        ));
450    }
451    if t_cost == 0 {
452        return Err(FormatError::InvalidKdfParams(
453            "argon2id t_cost must be non-zero",
454        ));
455    }
456    if t_cost > READER_MAX_ARGON2ID_T_COST {
457        return Err(FormatError::ReaderResourceLimitExceeded {
458            field: "argon2id t_cost",
459            cap: READER_MAX_ARGON2ID_T_COST as u64,
460            actual: t_cost as u64,
461        });
462    }
463    if parallelism == 0 {
464        return Err(FormatError::InvalidKdfParams(
465            "argon2id parallelism must be non-zero",
466        ));
467    }
468    if parallelism > READER_MAX_ARGON2ID_PARALLELISM {
469        return Err(FormatError::ReaderResourceLimitExceeded {
470            field: "argon2id parallelism",
471            cap: READER_MAX_ARGON2ID_PARALLELISM as u64,
472            actual: parallelism as u64,
473        });
474    }
475    if m_cost_kib > READER_MAX_ARGON2ID_M_COST_KIB {
476        return Err(FormatError::ReaderResourceLimitExceeded {
477            field: "argon2id m_cost_kib",
478            cap: READER_MAX_ARGON2ID_M_COST_KIB as u64,
479            actual: m_cost_kib as u64,
480        });
481    }
482    let min_memory = parallelism
483        .checked_mul(8)
484        .ok_or(FormatError::InvalidKdfParams(
485            "argon2id memory requirement overflow",
486        ))?;
487    if m_cost_kib < min_memory {
488        return Err(FormatError::InvalidKdfParams(
489            "argon2id memory must be at least 8 KiB per lane",
490        ));
491    }
492    Ok(())
493}
494
495fn expand_subkey(hk: &Hkdf<Sha256>, info: &[u8]) -> Result<[u8; SUBKEY_LEN], FormatError> {
496    let mut output = [0u8; SUBKEY_LEN];
497    hk.expand(info, &mut output)
498        .map_err(|_| FormatError::HkdfExpandFailure)?;
499    Ok(output)
500}
501
502fn nonce_or_aad_info(
503    prefix: &[u8],
504    domain: &[u8],
505    archive_uuid: &[u8; 16],
506    session_id: &[u8; 16],
507    counter: u64,
508) -> Result<Vec<u8>, FormatError> {
509    let domain_len = u16::try_from(domain.len()).map_err(|_| FormatError::DomainTooLong)?;
510    let mut info = Vec::with_capacity(prefix.len() + 2 + domain.len() + 16 + 16 + 8);
511    info.extend_from_slice(prefix);
512    info.extend_from_slice(&domain_len.to_le_bytes());
513    info.extend_from_slice(domain);
514    info.extend_from_slice(archive_uuid);
515    info.extend_from_slice(session_id);
516    info.extend_from_slice(&counter.to_le_bytes());
517    Ok(info)
518}
519
520fn validate_nonce_len(algo: AeadAlgo, nonce: &[u8]) -> Result<(), FormatError> {
521    let expected = algo.nonce_len();
522    if nonce.len() != expected {
523        return Err(FormatError::InvalidNonceLength {
524            algo,
525            expected,
526            actual: nonce.len(),
527        });
528    }
529    Ok(())
530}
531
532fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, FormatError> {
533    let array: [u8; 2] = bytes
534        .get(offset..offset + 2)
535        .ok_or(FormatError::InvalidLength {
536            structure: "u16",
537            expected: offset + 2,
538            actual: bytes.len(),
539        })?
540        .try_into()
541        .expect("slice length checked");
542    Ok(u16::from_le_bytes(array))
543}
544
545fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, FormatError> {
546    let array: [u8; 4] = bytes
547        .get(offset..offset + 4)
548        .ok_or(FormatError::InvalidLength {
549            structure: "u32",
550            expected: offset + 4,
551            actual: bytes.len(),
552        })?
553        .try_into()
554        .expect("slice length checked");
555    Ok(u32::from_le_bytes(array))
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn uuid() -> [u8; 16] {
563        [0x11; 16]
564    }
565
566    fn session() -> [u8; 16] {
567        [0x22; 16]
568    }
569
570    #[test]
571    fn parses_raw_kdf_params() {
572        let (params, consumed) = KdfParams::parse(KdfAlgo::Raw, &0u16.to_le_bytes()).unwrap();
573        assert_eq!(params, KdfParams::Raw);
574        assert_eq!(consumed, 2);
575    }
576
577    #[test]
578    fn parses_argon2id_kdf_params() {
579        let mut bytes = Vec::new();
580        bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
581        bytes.extend_from_slice(&1u32.to_le_bytes());
582        bytes.extend_from_slice(&8u32.to_le_bytes());
583        bytes.extend_from_slice(&1u32.to_le_bytes());
584        bytes.extend_from_slice(&8u16.to_le_bytes());
585        bytes.extend_from_slice(b"12345678");
586
587        let (params, consumed) = KdfParams::parse(KdfAlgo::Argon2id, &bytes).unwrap();
588        assert_eq!(consumed, 24);
589        assert_eq!(
590            params,
591            KdfParams::Argon2id {
592                t_cost: 1,
593                m_cost_kib: 8,
594                parallelism: 1,
595                salt: b"12345678".to_vec()
596            }
597        );
598    }
599
600    #[test]
601    fn rejects_argon2id_params_above_reader_caps() {
602        let mut bytes = Vec::new();
603        bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
604        bytes.extend_from_slice(&(READER_MAX_ARGON2ID_T_COST + 1).to_le_bytes());
605        bytes.extend_from_slice(&8u32.to_le_bytes());
606        bytes.extend_from_slice(&1u32.to_le_bytes());
607        bytes.extend_from_slice(&8u16.to_le_bytes());
608        bytes.extend_from_slice(b"12345678");
609
610        assert_eq!(
611            KdfParams::parse(KdfAlgo::Argon2id, &bytes).unwrap_err(),
612            FormatError::ReaderResourceLimitExceeded {
613                field: "argon2id t_cost",
614                cap: READER_MAX_ARGON2ID_T_COST as u64,
615                actual: (READER_MAX_ARGON2ID_T_COST + 1) as u64,
616            }
617        );
618
619        let err = MasterKey::derive_from_passphrase(
620            &KdfParams::Argon2id {
621                t_cost: 1,
622                m_cost_kib: READER_MAX_ARGON2ID_M_COST_KIB + 1,
623                parallelism: 1,
624                salt: b"12345678".to_vec(),
625            },
626            "passphrase",
627        )
628        .unwrap_err();
629        assert_eq!(
630            err,
631            FormatError::ReaderResourceLimitExceeded {
632                field: "argon2id m_cost_kib",
633                cap: READER_MAX_ARGON2ID_M_COST_KIB as u64,
634                actual: (READER_MAX_ARGON2ID_M_COST_KIB + 1) as u64,
635            }
636        );
637    }
638
639    #[test]
640    fn rejects_kdf_algo_tag_mismatch() {
641        assert_eq!(
642            KdfParams::parse(KdfAlgo::Raw, &(KdfAlgo::Argon2id as u16).to_le_bytes()).unwrap_err(),
643            FormatError::KdfAlgoTagMismatch {
644                expected: 0,
645                actual: 1
646            }
647        );
648    }
649
650    #[test]
651    fn passphrase_normalization_preserves_archive_semantics() {
652        assert_eq!(normalize_passphrase_nfc("e\u{301}\n\0"), "é\n\0".as_bytes());
653    }
654
655    #[test]
656    fn derives_argon2id_master_key_from_nfc_passphrase() {
657        let params = KdfParams::Argon2id {
658            t_cost: 1,
659            m_cost_kib: 8,
660            parallelism: 1,
661            salt: b"12345678".to_vec(),
662        };
663        let one = MasterKey::derive_from_passphrase(&params, "e\u{301}").unwrap();
664        let two = MasterKey::derive_from_passphrase(&params, "é").unwrap();
665        assert_eq!(one.0, two.0);
666        assert_ne!(one.0, [0u8; MASTER_KEY_LEN]);
667    }
668
669    #[test]
670    fn derives_stable_distinct_subkeys() {
671        let master = MasterKey::from_raw_key(&[0x33; MASTER_KEY_LEN]).unwrap();
672        let subkeys = Subkeys::derive(&master, &uuid(), &session()).unwrap();
673        assert_ne!(subkeys.enc_key, subkeys.mac_key);
674        assert_ne!(subkeys.index_root_key, subkeys.index_shard_key);
675
676        let repeat = Subkeys::derive(&master, &uuid(), &session()).unwrap();
677        assert_eq!(subkeys, repeat);
678    }
679
680    #[test]
681    fn computes_and_verifies_hmac_domains() {
682        let key = [0x44; SUBKEY_LEN];
683        let covered = b"covered bytes";
684        let tag = compute_hmac(HmacDomain::CryptoHeader, &key, &uuid(), &session(), covered);
685        verify_hmac(
686            HmacDomain::CryptoHeader,
687            &key,
688            &uuid(),
689            &session(),
690            covered,
691            &tag,
692        )
693        .unwrap();
694
695        assert_eq!(
696            verify_hmac(
697                HmacDomain::ManifestFooter,
698                &key,
699                &uuid(),
700                &session(),
701                covered,
702                &tag,
703            )
704            .unwrap_err(),
705            FormatError::HmacMismatch {
706                structure: "ManifestFooter"
707            }
708        );
709    }
710
711    #[test]
712    fn derives_nonce_and_aad_with_domain_separation() {
713        let seed = [0x55; SUBKEY_LEN];
714        let nonce = derive_nonce(&seed, b"envelope", &uuid(), &session(), 7, 12).unwrap();
715        let other = derive_nonce(&seed, b"idxroot", &uuid(), &session(), 7, 12).unwrap();
716        assert_eq!(nonce.len(), 12);
717        assert_ne!(nonce, other);
718
719        let aad = build_aad(b"envelope", &uuid(), &session(), 7).unwrap();
720        assert!(aad.starts_with(b"tzap-v1-aad"));
721        assert_ne!(aad, nonce);
722    }
723
724    #[test]
725    fn aead_round_trips_all_registered_algorithms() {
726        for algo in [
727            AeadAlgo::AesGcmSiv256,
728            AeadAlgo::XChaCha20Poly1305,
729            AeadAlgo::AesGcm256,
730        ] {
731            let key = [0x66; SUBKEY_LEN];
732            let nonce = derive_nonce(
733                &[0x77; SUBKEY_LEN],
734                b"envelope",
735                &uuid(),
736                &session(),
737                0,
738                algo.nonce_len(),
739            )
740            .unwrap();
741            let aad = build_aad(b"envelope", &uuid(), &session(), 0).unwrap();
742            let ciphertext = aead_encrypt(algo, &key, &nonce, &aad, b"plaintext").unwrap();
743            assert_ne!(ciphertext, b"plaintext");
744            let plaintext = aead_decrypt(algo, &key, &nonce, &aad, &ciphertext).unwrap();
745            assert_eq!(plaintext, b"plaintext");
746
747            let mut tampered = ciphertext;
748            tampered[0] ^= 1;
749            assert_eq!(
750                aead_decrypt(algo, &key, &nonce, &aad, &tampered).unwrap_err(),
751                FormatError::AeadFailure
752            );
753        }
754    }
755
756    #[test]
757    fn aead_rejects_wrong_nonce_length() {
758        assert_eq!(
759            aead_encrypt(AeadAlgo::AesGcmSiv256, &[0; SUBKEY_LEN], &[0; 11], b"", b"").unwrap_err(),
760            FormatError::InvalidNonceLength {
761                algo: AeadAlgo::AesGcmSiv256,
762                expected: 12,
763                actual: 11
764            }
765        );
766    }
767
768    #[test]
769    fn padded_aead_object_round_trips_with_derived_nonce_and_aad() {
770        let key = [0x66; SUBKEY_LEN];
771        let nonce_seed = [0x77; SUBKEY_LEN];
772        let ciphertext = encrypt_padded_aead_object(
773            AeadAlgo::AesGcmSiv256,
774            &key,
775            &nonce_seed,
776            b"envelope",
777            &uuid(),
778            &session(),
779            3,
780            4096,
781            b"packed frames",
782        )
783        .unwrap();
784        assert_eq!(ciphertext.len() % 4096, 0);
785
786        let plaintext = decrypt_padded_aead_object(
787            AeadAlgo::AesGcmSiv256,
788            &key,
789            &nonce_seed,
790            b"envelope",
791            &uuid(),
792            &session(),
793            3,
794            &ciphertext,
795        )
796        .unwrap();
797        assert_eq!(plaintext, b"packed frames");
798
799        assert_eq!(
800            decrypt_padded_aead_object(
801                AeadAlgo::AesGcmSiv256,
802                &key,
803                &nonce_seed,
804                b"idxroot",
805                &uuid(),
806                &session(),
807                3,
808                &ciphertext,
809            )
810            .unwrap_err(),
811            FormatError::AeadFailure
812        );
813    }
814}