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::{Digest, 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, READER_MAX_KEY_WRAP_TABLE_LEN,
16    READER_MAX_KEY_WRAP_TABLE_RECIPIENT_RECORDS, SUBKEY_LEN,
17};
18use crate::padding::{depad_suffix_padding, suffix_pad_for_aead};
19
20type HmacSha256 = Hmac<Sha256>;
21
22const HKDF_SALT_DOMAIN: &[u8] = b"tzap-v1-subkeys";
23const CRYPTO_HEADER_HMAC_DOMAIN: &[u8] = b"tzap-v1-crypto-header";
24const MANIFEST_FOOTER_HMAC_DOMAIN: &[u8] = b"tzap-v1-manifest-footer";
25const VOLUME_TRAILER_HMAC_DOMAIN: &[u8] = b"tzap-v1-volume-trailer";
26const BOOTSTRAP_SIDECAR_HMAC_DOMAIN: &[u8] = b"tzap-v1-sidecar";
27const CRYPTO_HEADER_DIGEST_DOMAIN_V45: &[u8] = b"tzap-header-v45";
28const MANIFEST_FOOTER_DIGEST_DOMAIN_V45: &[u8] = b"tzap-manifest-v45";
29const VOLUME_TRAILER_DIGEST_DOMAIN_V45: &[u8] = b"tzap-trailer-v45";
30const BOOTSTRAP_SIDECAR_DIGEST_DOMAIN_V45: &[u8] = b"tzap-sidecar-v45";
31
32const RAW_KDF_PARAMS_LEN: usize = 2;
33const NONE_KDF_PARAMS_LEN: usize = 2;
34const ARGON2ID_FIXED_PARAMS_LEN: usize = 16;
35const RECIPIENT_WRAP_KDF_PARAMS_LEN: usize = 46;
36const ARGON2ID_MIN_SALT_LEN: u16 = 8;
37const ARGON2ID_MAX_SALT_LEN: u16 = 64;
38const RECIPIENT_WRAP_TABLE_VERSION: u16 = 1;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum KdfParams {
42    None,
43    Raw,
44    Argon2id {
45        t_cost: u32,
46        m_cost_kib: u32,
47        parallelism: u32,
48        salt: Vec<u8>,
49    },
50    RecipientWrap {
51        key_wrap_table_length: u32,
52        key_wrap_table_record_count: u32,
53        key_wrap_table_version: u16,
54        key_wrap_table_digest: [u8; 32],
55    },
56}
57
58impl KdfParams {
59    pub fn parse(algo: KdfAlgo, bytes: &[u8]) -> Result<(Self, usize), FormatError> {
60        match algo {
61            KdfAlgo::Raw => parse_raw_kdf_params(bytes),
62            KdfAlgo::Argon2id => parse_argon2id_kdf_params(bytes),
63            KdfAlgo::None => parse_none_kdf_params(bytes),
64            KdfAlgo::RecipientWrap => parse_recipient_wrap_kdf_params(bytes),
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
70pub struct MasterKey(pub [u8; MASTER_KEY_LEN]);
71
72impl MasterKey {
73    pub fn from_raw_key(raw_key: &[u8]) -> Result<Self, FormatError> {
74        if raw_key.len() != MASTER_KEY_LEN {
75            return Err(FormatError::InvalidRawMasterKeyLength);
76        }
77        let mut key = [0u8; MASTER_KEY_LEN];
78        key.copy_from_slice(raw_key);
79        Ok(Self(key))
80    }
81
82    pub fn derive_from_passphrase(
83        params: &KdfParams,
84        passphrase: &str,
85    ) -> Result<Self, FormatError> {
86        let KdfParams::Argon2id {
87            t_cost,
88            m_cost_kib,
89            parallelism,
90            salt,
91        } = params
92        else {
93            return Err(FormatError::KeyMaterialMismatch);
94        };
95
96        let salt_length = u16::try_from(salt.len()).map_err(|_| {
97            FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
98        })?;
99        validate_argon2id_bounds(*t_cost, *m_cost_kib, *parallelism, salt_length)?;
100        let params = Params::new(*m_cost_kib, *t_cost, *parallelism, Some(MASTER_KEY_LEN))
101            .map_err(|_| FormatError::InvalidKdfParams("argon2 params rejected"))?;
102        let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
103        let mut output = [0u8; MASTER_KEY_LEN];
104        let mut passphrase_bytes = normalize_passphrase_nfc(passphrase);
105        let result = argon2.hash_password_into(&passphrase_bytes, salt, &mut output);
106        passphrase_bytes.zeroize();
107        result.map_err(|_| FormatError::Argon2idFailure)?;
108        Ok(Self(output))
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
113pub struct Subkeys {
114    pub enc_key: [u8; SUBKEY_LEN],
115    pub mac_key: [u8; SUBKEY_LEN],
116    pub nonce_seed: [u8; SUBKEY_LEN],
117    pub index_root_key: [u8; SUBKEY_LEN],
118    pub index_shard_key: [u8; SUBKEY_LEN],
119    pub dictionary_key: [u8; SUBKEY_LEN],
120    pub dir_hint_key: [u8; SUBKEY_LEN],
121    pub index_nonce_seed: [u8; SUBKEY_LEN],
122}
123
124impl Subkeys {
125    pub(crate) fn unencrypted_placeholder() -> Self {
126        Self {
127            enc_key: [0; SUBKEY_LEN],
128            mac_key: [0; SUBKEY_LEN],
129            nonce_seed: [0; SUBKEY_LEN],
130            index_root_key: [0; SUBKEY_LEN],
131            index_shard_key: [0; SUBKEY_LEN],
132            dictionary_key: [0; SUBKEY_LEN],
133            dir_hint_key: [0; SUBKEY_LEN],
134            index_nonce_seed: [0; SUBKEY_LEN],
135        }
136    }
137
138    pub fn derive(
139        master_key: &MasterKey,
140        archive_uuid: &[u8; 16],
141        session_id: &[u8; 16],
142    ) -> Result<Self, FormatError> {
143        let mut salt = Vec::with_capacity(HKDF_SALT_DOMAIN.len() + 32);
144        salt.extend_from_slice(HKDF_SALT_DOMAIN);
145        salt.extend_from_slice(archive_uuid);
146        salt.extend_from_slice(session_id);
147        let hk = Hkdf::<Sha256>::new(Some(&salt), &master_key.0);
148        salt.zeroize();
149
150        Ok(Self {
151            enc_key: expand_subkey(&hk, b"tzap-v1-enc")?,
152            mac_key: expand_subkey(&hk, b"tzap-v1-mac")?,
153            nonce_seed: expand_subkey(&hk, b"tzap-v1-nonce")?,
154            index_root_key: expand_subkey(&hk, b"tzap-v1-idxroot")?,
155            index_shard_key: expand_subkey(&hk, b"tzap-v1-idxshard")?,
156            dictionary_key: expand_subkey(&hk, b"tzap-v1-dict")?,
157            dir_hint_key: expand_subkey(&hk, b"tzap-v1-dirhint")?,
158            index_nonce_seed: expand_subkey(&hk, b"tzap-v1-idxnonce")?,
159        })
160    }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum HmacDomain {
165    CryptoHeader,
166    ManifestFooter,
167    VolumeTrailer,
168    BootstrapSidecar,
169}
170
171impl HmacDomain {
172    pub fn structure_name(self) -> &'static str {
173        match self {
174            Self::CryptoHeader => "CryptoHeader",
175            Self::ManifestFooter => "ManifestFooter",
176            Self::VolumeTrailer => "VolumeTrailer",
177            Self::BootstrapSidecar => "BootstrapSidecarHeader",
178        }
179    }
180
181    fn domain_bytes(self) -> &'static [u8] {
182        match self {
183            Self::CryptoHeader => CRYPTO_HEADER_HMAC_DOMAIN,
184            Self::ManifestFooter => MANIFEST_FOOTER_HMAC_DOMAIN,
185            Self::VolumeTrailer => VOLUME_TRAILER_HMAC_DOMAIN,
186            Self::BootstrapSidecar => BOOTSTRAP_SIDECAR_HMAC_DOMAIN,
187        }
188    }
189
190    fn digest_domain_bytes(self) -> &'static [u8] {
191        match self {
192            Self::CryptoHeader => CRYPTO_HEADER_DIGEST_DOMAIN_V45,
193            Self::ManifestFooter => MANIFEST_FOOTER_DIGEST_DOMAIN_V45,
194            Self::VolumeTrailer => VOLUME_TRAILER_DIGEST_DOMAIN_V45,
195            Self::BootstrapSidecar => BOOTSTRAP_SIDECAR_DIGEST_DOMAIN_V45,
196        }
197    }
198}
199
200pub fn compute_hmac(
201    domain: HmacDomain,
202    mac_key: &[u8; SUBKEY_LEN],
203    archive_uuid: &[u8; 16],
204    session_id: &[u8; 16],
205    covered_bytes: &[u8],
206) -> [u8; SUBKEY_LEN] {
207    let mut mac =
208        <HmacSha256 as Mac>::new_from_slice(mac_key).expect("HMAC accepts any key length");
209    mac.update(domain.domain_bytes());
210    mac.update(archive_uuid);
211    mac.update(session_id);
212    mac.update(covered_bytes);
213    let digest = mac.finalize().into_bytes();
214    let mut output = [0u8; SUBKEY_LEN];
215    output.copy_from_slice(&digest);
216    output
217}
218
219pub fn verify_hmac(
220    domain: HmacDomain,
221    mac_key: &[u8; SUBKEY_LEN],
222    archive_uuid: &[u8; 16],
223    session_id: &[u8; 16],
224    covered_bytes: &[u8],
225    expected_hmac: &[u8],
226) -> Result<(), FormatError> {
227    let mut mac =
228        <HmacSha256 as Mac>::new_from_slice(mac_key).expect("HMAC accepts any key length");
229    mac.update(domain.domain_bytes());
230    mac.update(archive_uuid);
231    mac.update(session_id);
232    mac.update(covered_bytes);
233    mac.verify_slice(expected_hmac)
234        .map_err(|_| FormatError::HmacMismatch {
235            structure: domain.structure_name(),
236        })
237}
238
239pub fn compute_integrity_tag(
240    domain: HmacDomain,
241    aead_algo: AeadAlgo,
242    volume_format_rev: u16,
243    mac_key: Option<&[u8; SUBKEY_LEN]>,
244    archive_uuid: &[u8; 16],
245    session_id: &[u8; 16],
246    covered_bytes: &[u8],
247) -> Result<[u8; SUBKEY_LEN], FormatError> {
248    if aead_algo.is_encrypted() {
249        return Ok(compute_hmac(
250            domain,
251            mac_key.ok_or(FormatError::KeyMaterialMismatch)?,
252            archive_uuid,
253            session_id,
254            covered_bytes,
255        ));
256    }
257
258    let mut hasher = Sha256::new();
259    let _ = volume_format_rev;
260    hasher.update(domain.digest_domain_bytes());
261    hasher.update(archive_uuid);
262    hasher.update(session_id);
263    hasher.update(covered_bytes);
264    let digest = hasher.finalize();
265    let mut output = [0u8; SUBKEY_LEN];
266    output.copy_from_slice(&digest);
267    Ok(output)
268}
269
270#[allow(clippy::too_many_arguments)]
271pub fn verify_integrity_tag(
272    domain: HmacDomain,
273    aead_algo: AeadAlgo,
274    volume_format_rev: u16,
275    mac_key: Option<&[u8; SUBKEY_LEN]>,
276    archive_uuid: &[u8; 16],
277    session_id: &[u8; 16],
278    covered_bytes: &[u8],
279    expected_tag: &[u8],
280) -> Result<(), FormatError> {
281    if aead_algo.is_encrypted() {
282        return verify_hmac(
283            domain,
284            mac_key.ok_or(FormatError::KeyMaterialMismatch)?,
285            archive_uuid,
286            session_id,
287            covered_bytes,
288            expected_tag,
289        );
290    }
291
292    let actual = compute_integrity_tag(
293        domain,
294        aead_algo,
295        volume_format_rev,
296        None,
297        archive_uuid,
298        session_id,
299        covered_bytes,
300    )?;
301    if expected_tag == actual {
302        Ok(())
303    } else {
304        Err(FormatError::IntegrityDigestMismatch {
305            structure: domain.structure_name(),
306        })
307    }
308}
309
310pub fn normalize_passphrase_nfc(passphrase: &str) -> Vec<u8> {
311    passphrase.nfc().collect::<String>().into_bytes()
312}
313
314pub fn derive_nonce(
315    seed: &[u8; SUBKEY_LEN],
316    domain: &[u8],
317    archive_uuid: &[u8; 16],
318    session_id: &[u8; 16],
319    counter: u64,
320    len: usize,
321) -> Result<Vec<u8>, FormatError> {
322    let info = nonce_or_aad_info(b"tzap-v1-nonce", domain, archive_uuid, session_id, counter)?;
323    let hk = Hkdf::<Sha256>::from_prk(seed)
324        .map_err(|_| FormatError::InvalidKdfParams("bad nonce seed"))?;
325    let mut nonce = vec![0u8; len];
326    hk.expand(&info, &mut nonce)
327        .map_err(|_| FormatError::HkdfExpandFailure)?;
328    Ok(nonce)
329}
330
331pub fn build_aad(
332    domain: &[u8],
333    archive_uuid: &[u8; 16],
334    session_id: &[u8; 16],
335    counter: u64,
336) -> Result<Vec<u8>, FormatError> {
337    nonce_or_aad_info(b"tzap-v1-aad", domain, archive_uuid, session_id, counter)
338}
339
340pub fn aead_encrypt(
341    algo: AeadAlgo,
342    key: &[u8; SUBKEY_LEN],
343    nonce: &[u8],
344    aad: &[u8],
345    plaintext: &[u8],
346) -> Result<Vec<u8>, FormatError> {
347    validate_nonce_len(algo, nonce)?;
348    match algo {
349        AeadAlgo::None => Ok(plaintext.to_vec()),
350        AeadAlgo::AesGcmSiv256 => {
351            let cipher =
352                Aes256GcmSiv::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
353            cipher
354                .encrypt(
355                    aes_gcm_siv::Nonce::from_slice(nonce),
356                    Payload {
357                        msg: plaintext,
358                        aad,
359                    },
360                )
361                .map_err(|_| FormatError::AeadFailure)
362        }
363        AeadAlgo::XChaCha20Poly1305 => {
364            let cipher = XChaCha20Poly1305::new_from_slice(key)
365                .map_err(|_| FormatError::InvalidAeadKeyLength)?;
366            cipher
367                .encrypt(
368                    chacha20poly1305::XNonce::from_slice(nonce),
369                    Payload {
370                        msg: plaintext,
371                        aad,
372                    },
373                )
374                .map_err(|_| FormatError::AeadFailure)
375        }
376        AeadAlgo::AesGcm256 => {
377            let cipher =
378                Aes256Gcm::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
379            cipher
380                .encrypt(
381                    aes_gcm::Nonce::from_slice(nonce),
382                    Payload {
383                        msg: plaintext,
384                        aad,
385                    },
386                )
387                .map_err(|_| FormatError::AeadFailure)
388        }
389    }
390}
391
392pub fn aead_decrypt(
393    algo: AeadAlgo,
394    key: &[u8; SUBKEY_LEN],
395    nonce: &[u8],
396    aad: &[u8],
397    ciphertext_and_tag: &[u8],
398) -> Result<Vec<u8>, FormatError> {
399    validate_nonce_len(algo, nonce)?;
400    match algo {
401        AeadAlgo::None => Ok(ciphertext_and_tag.to_vec()),
402        AeadAlgo::AesGcmSiv256 => {
403            let cipher =
404                Aes256GcmSiv::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
405            cipher
406                .decrypt(
407                    aes_gcm_siv::Nonce::from_slice(nonce),
408                    Payload {
409                        msg: ciphertext_and_tag,
410                        aad,
411                    },
412                )
413                .map_err(|_| FormatError::AeadFailure)
414        }
415        AeadAlgo::XChaCha20Poly1305 => {
416            let cipher = XChaCha20Poly1305::new_from_slice(key)
417                .map_err(|_| FormatError::InvalidAeadKeyLength)?;
418            cipher
419                .decrypt(
420                    chacha20poly1305::XNonce::from_slice(nonce),
421                    Payload {
422                        msg: ciphertext_and_tag,
423                        aad,
424                    },
425                )
426                .map_err(|_| FormatError::AeadFailure)
427        }
428        AeadAlgo::AesGcm256 => {
429            let cipher =
430                Aes256Gcm::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
431            cipher
432                .decrypt(
433                    aes_gcm::Nonce::from_slice(nonce),
434                    Payload {
435                        msg: ciphertext_and_tag,
436                        aad,
437                    },
438                )
439                .map_err(|_| FormatError::AeadFailure)
440        }
441    }
442}
443
444#[derive(Debug, Clone, Copy)]
445pub struct AeadObjectContext<'a> {
446    pub algo: AeadAlgo,
447    pub key: &'a [u8; SUBKEY_LEN],
448    pub nonce_seed: &'a [u8; SUBKEY_LEN],
449    pub domain: &'a [u8],
450    pub archive_uuid: &'a [u8; 16],
451    pub session_id: &'a [u8; 16],
452    pub counter: u64,
453}
454
455pub fn encrypt_padded_aead_object(
456    context: AeadObjectContext<'_>,
457    block_size: usize,
458    payload: &[u8],
459) -> Result<Vec<u8>, FormatError> {
460    let nonce = derive_nonce(
461        context.nonce_seed,
462        context.domain,
463        context.archive_uuid,
464        context.session_id,
465        context.counter,
466        context.algo.nonce_len(),
467    )?;
468    let aad = build_aad(
469        context.domain,
470        context.archive_uuid,
471        context.session_id,
472        context.counter,
473    )?;
474    let padded = suffix_pad_for_aead(payload, context.algo.tag_len(), block_size)?;
475    aead_encrypt(context.algo, context.key, &nonce, &aad, &padded)
476}
477
478pub fn decrypt_padded_aead_object(
479    context: AeadObjectContext<'_>,
480    ciphertext_and_tag: &[u8],
481) -> Result<Vec<u8>, FormatError> {
482    let nonce = derive_nonce(
483        context.nonce_seed,
484        context.domain,
485        context.archive_uuid,
486        context.session_id,
487        context.counter,
488        context.algo.nonce_len(),
489    )?;
490    let aad = build_aad(
491        context.domain,
492        context.archive_uuid,
493        context.session_id,
494        context.counter,
495    )?;
496    let padded = aead_decrypt(context.algo, context.key, &nonce, &aad, ciphertext_and_tag)?;
497    Ok(depad_suffix_padding(&padded)?.to_vec())
498}
499
500fn parse_raw_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
501    if bytes.len() < RAW_KDF_PARAMS_LEN {
502        return Err(FormatError::TruncatedKdfParams);
503    }
504    let algo_tag = read_u16(bytes, 0)?;
505    if algo_tag != KdfAlgo::Raw as u16 {
506        return Err(FormatError::KdfAlgoTagMismatch {
507            expected: KdfAlgo::Raw as u16,
508            actual: algo_tag,
509        });
510    }
511    Ok((KdfParams::Raw, RAW_KDF_PARAMS_LEN))
512}
513
514fn parse_none_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
515    if bytes.len() < NONE_KDF_PARAMS_LEN {
516        return Err(FormatError::TruncatedKdfParams);
517    }
518    let algo_tag = read_u16(bytes, 0)?;
519    if algo_tag != KdfAlgo::None as u16 {
520        return Err(FormatError::KdfAlgoTagMismatch {
521            expected: KdfAlgo::None as u16,
522            actual: algo_tag,
523        });
524    }
525    Ok((KdfParams::None, NONE_KDF_PARAMS_LEN))
526}
527
528fn parse_argon2id_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
529    if bytes.len() < ARGON2ID_FIXED_PARAMS_LEN {
530        return Err(FormatError::TruncatedKdfParams);
531    }
532    let algo_tag = read_u16(bytes, 0)?;
533    if algo_tag != KdfAlgo::Argon2id as u16 {
534        return Err(FormatError::KdfAlgoTagMismatch {
535            expected: KdfAlgo::Argon2id as u16,
536            actual: algo_tag,
537        });
538    }
539    let t_cost = read_u32(bytes, 2)?;
540    let m_cost_kib = read_u32(bytes, 6)?;
541    let parallelism = read_u32(bytes, 10)?;
542    let salt_length = read_u16(bytes, 14)?;
543    if !(ARGON2ID_MIN_SALT_LEN..=ARGON2ID_MAX_SALT_LEN).contains(&salt_length) {
544        return Err(FormatError::InvalidKdfParams(
545            "argon2id salt length must be 8..64 bytes",
546        ));
547    }
548    if t_cost == 0 {
549        return Err(FormatError::InvalidKdfParams(
550            "argon2id t_cost must be non-zero",
551        ));
552    }
553    if parallelism == 0 {
554        return Err(FormatError::InvalidKdfParams(
555            "argon2id parallelism must be non-zero",
556        ));
557    }
558    validate_argon2id_bounds(t_cost, m_cost_kib, parallelism, salt_length)?;
559
560    let total_len = ARGON2ID_FIXED_PARAMS_LEN + salt_length as usize;
561    if bytes.len() < total_len {
562        return Err(FormatError::TruncatedKdfParams);
563    }
564    Ok((
565        KdfParams::Argon2id {
566            t_cost,
567            m_cost_kib,
568            parallelism,
569            salt: bytes[ARGON2ID_FIXED_PARAMS_LEN..total_len].to_vec(),
570        },
571        total_len,
572    ))
573}
574
575fn parse_recipient_wrap_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
576    if bytes.len() < RECIPIENT_WRAP_KDF_PARAMS_LEN {
577        return Err(FormatError::TruncatedKdfParams);
578    }
579    let algo_tag = read_u16(bytes, 0)?;
580    if algo_tag != KdfAlgo::RecipientWrap as u16 {
581        return Err(FormatError::KdfAlgoTagMismatch {
582            expected: KdfAlgo::RecipientWrap as u16,
583            actual: algo_tag,
584        });
585    }
586    let key_wrap_table_length = read_u32(bytes, 2)?;
587    let key_wrap_table_record_count = read_u32(bytes, 6)?;
588    let table_version = read_u16(bytes, 10)?;
589    if key_wrap_table_length == 0 {
590        return Err(FormatError::InvalidKdfParams(
591            "recipient-wrap key_wrap_table_length must be non-zero",
592        ));
593    }
594    if key_wrap_table_length > READER_MAX_KEY_WRAP_TABLE_LEN {
595        return Err(FormatError::ReaderResourceLimitExceeded {
596            field: "KeyWrapTableV1 length",
597            cap: READER_MAX_KEY_WRAP_TABLE_LEN as u64,
598            actual: key_wrap_table_length as u64,
599        });
600    }
601    if key_wrap_table_record_count > READER_MAX_KEY_WRAP_TABLE_RECIPIENT_RECORDS {
602        return Err(FormatError::ReaderResourceLimitExceeded {
603            field: "KeyWrapTableV1 recipient_record_count",
604            cap: READER_MAX_KEY_WRAP_TABLE_RECIPIENT_RECORDS as u64,
605            actual: key_wrap_table_record_count as u64,
606        });
607    }
608    if table_version != RECIPIENT_WRAP_TABLE_VERSION {
609        return Err(FormatError::InvalidKdfParams(
610            "recipient-wrap table version must be 1",
611        ));
612    }
613    let reserved = read_u16(bytes, 12)?;
614    if reserved != 0 {
615        return Err(FormatError::InvalidKdfParams(
616            "recipient-wrap reserved bytes must be zero",
617        ));
618    }
619    let mut key_wrap_table_digest = [0u8; 32];
620    key_wrap_table_digest.copy_from_slice(&bytes[14..RECIPIENT_WRAP_KDF_PARAMS_LEN]);
621    Ok((
622        KdfParams::RecipientWrap {
623            key_wrap_table_length,
624            key_wrap_table_record_count,
625            key_wrap_table_version: table_version,
626            key_wrap_table_digest,
627        },
628        RECIPIENT_WRAP_KDF_PARAMS_LEN,
629    ))
630}
631
632fn validate_argon2id_bounds(
633    t_cost: u32,
634    m_cost_kib: u32,
635    parallelism: u32,
636    salt_length: u16,
637) -> Result<(), FormatError> {
638    if !(ARGON2ID_MIN_SALT_LEN..=ARGON2ID_MAX_SALT_LEN).contains(&salt_length) {
639        return Err(FormatError::InvalidKdfParams(
640            "argon2id salt length must be 8..64 bytes",
641        ));
642    }
643    if t_cost == 0 {
644        return Err(FormatError::InvalidKdfParams(
645            "argon2id t_cost must be non-zero",
646        ));
647    }
648    if t_cost > READER_MAX_ARGON2ID_T_COST {
649        return Err(FormatError::ReaderResourceLimitExceeded {
650            field: "argon2id t_cost",
651            cap: READER_MAX_ARGON2ID_T_COST as u64,
652            actual: t_cost as u64,
653        });
654    }
655    if parallelism == 0 {
656        return Err(FormatError::InvalidKdfParams(
657            "argon2id parallelism must be non-zero",
658        ));
659    }
660    if parallelism > READER_MAX_ARGON2ID_PARALLELISM {
661        return Err(FormatError::ReaderResourceLimitExceeded {
662            field: "argon2id parallelism",
663            cap: READER_MAX_ARGON2ID_PARALLELISM as u64,
664            actual: parallelism as u64,
665        });
666    }
667    if m_cost_kib > READER_MAX_ARGON2ID_M_COST_KIB {
668        return Err(FormatError::ReaderResourceLimitExceeded {
669            field: "argon2id m_cost_kib",
670            cap: READER_MAX_ARGON2ID_M_COST_KIB as u64,
671            actual: m_cost_kib as u64,
672        });
673    }
674    let min_memory = parallelism
675        .checked_mul(8)
676        .ok_or(FormatError::InvalidKdfParams(
677            "argon2id memory requirement overflow",
678        ))?;
679    if m_cost_kib < min_memory {
680        return Err(FormatError::InvalidKdfParams(
681            "argon2id memory must be at least 8 KiB per lane",
682        ));
683    }
684    Ok(())
685}
686
687fn expand_subkey(hk: &Hkdf<Sha256>, info: &[u8]) -> Result<[u8; SUBKEY_LEN], FormatError> {
688    let mut output = [0u8; SUBKEY_LEN];
689    hk.expand(info, &mut output)
690        .map_err(|_| FormatError::HkdfExpandFailure)?;
691    Ok(output)
692}
693
694fn nonce_or_aad_info(
695    prefix: &[u8],
696    domain: &[u8],
697    archive_uuid: &[u8; 16],
698    session_id: &[u8; 16],
699    counter: u64,
700) -> Result<Vec<u8>, FormatError> {
701    let domain_len = u16::try_from(domain.len()).map_err(|_| FormatError::DomainTooLong)?;
702    let mut info = Vec::with_capacity(prefix.len() + 2 + domain.len() + 16 + 16 + 8);
703    info.extend_from_slice(prefix);
704    info.extend_from_slice(&domain_len.to_le_bytes());
705    info.extend_from_slice(domain);
706    info.extend_from_slice(archive_uuid);
707    info.extend_from_slice(session_id);
708    info.extend_from_slice(&counter.to_le_bytes());
709    Ok(info)
710}
711
712fn validate_nonce_len(algo: AeadAlgo, nonce: &[u8]) -> Result<(), FormatError> {
713    let expected = algo.nonce_len();
714    if nonce.len() != expected {
715        return Err(FormatError::InvalidNonceLength {
716            algo,
717            expected,
718            actual: nonce.len(),
719        });
720    }
721    Ok(())
722}
723
724fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, FormatError> {
725    let array: [u8; 2] = bytes
726        .get(offset..offset + 2)
727        .ok_or(FormatError::InvalidLength {
728            structure: "u16",
729            expected: offset + 2,
730            actual: bytes.len(),
731        })?
732        .try_into()
733        .expect("slice length checked");
734    Ok(u16::from_le_bytes(array))
735}
736
737fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, FormatError> {
738    let array: [u8; 4] = bytes
739        .get(offset..offset + 4)
740        .ok_or(FormatError::InvalidLength {
741            structure: "u32",
742            expected: offset + 4,
743            actual: bytes.len(),
744        })?
745        .try_into()
746        .expect("slice length checked");
747    Ok(u32::from_le_bytes(array))
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use crate::format::VOLUME_FORMAT_REV_45;
754
755    fn uuid() -> [u8; 16] {
756        [0x11; 16]
757    }
758
759    fn session() -> [u8; 16] {
760        [0x22; 16]
761    }
762
763    fn legacy_nonce_info(
764        domain: &[u8],
765        archive_uuid: &[u8; 16],
766        session_id: &[u8; 16],
767        counter: u64,
768    ) -> Vec<u8> {
769        let mut info = Vec::with_capacity(b"tzap-v1-nonce".len() + domain.len() + 16 + 16 + 8);
770        info.extend_from_slice(b"tzap-v1-nonce");
771        info.extend_from_slice(domain);
772        info.extend_from_slice(archive_uuid);
773        info.extend_from_slice(session_id);
774        info.extend_from_slice(&counter.to_le_bytes());
775        info
776    }
777
778    #[test]
779    fn parses_raw_kdf_params() {
780        let (params, consumed) = KdfParams::parse(KdfAlgo::Raw, &0u16.to_le_bytes()).unwrap();
781        assert_eq!(params, KdfParams::Raw);
782        assert_eq!(consumed, 2);
783    }
784
785    #[test]
786    fn parses_none_kdf_params() {
787        let (params, consumed) =
788            KdfParams::parse(KdfAlgo::None, &(KdfAlgo::None as u16).to_le_bytes()).unwrap();
789        assert_eq!(params, KdfParams::None);
790        assert_eq!(consumed, 2);
791
792        assert_eq!(
793            KdfParams::parse(KdfAlgo::None, &(KdfAlgo::Raw as u16).to_le_bytes()).unwrap_err(),
794            FormatError::KdfAlgoTagMismatch {
795                expected: KdfAlgo::None as u16,
796                actual: KdfAlgo::Raw as u16,
797            }
798        );
799    }
800
801    #[test]
802    fn parses_argon2id_kdf_params() {
803        let mut bytes = Vec::new();
804        bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
805        bytes.extend_from_slice(&1u32.to_le_bytes());
806        bytes.extend_from_slice(&8u32.to_le_bytes());
807        bytes.extend_from_slice(&1u32.to_le_bytes());
808        bytes.extend_from_slice(&8u16.to_le_bytes());
809        bytes.extend_from_slice(b"12345678");
810
811        let (params, consumed) = KdfParams::parse(KdfAlgo::Argon2id, &bytes).unwrap();
812        assert_eq!(consumed, 24);
813        assert_eq!(
814            params,
815            KdfParams::Argon2id {
816                t_cost: 1,
817                m_cost_kib: 8,
818                parallelism: 1,
819                salt: b"12345678".to_vec()
820            }
821        );
822    }
823
824    #[test]
825    fn parses_recipient_wrap_kdf_params() {
826        let mut bytes = Vec::new();
827        bytes.extend_from_slice(&(KdfAlgo::RecipientWrap as u16).to_le_bytes());
828        bytes.extend_from_slice(&16u32.to_le_bytes());
829        bytes.extend_from_slice(&4u32.to_le_bytes());
830        bytes.extend_from_slice(&1u16.to_le_bytes());
831        bytes.extend_from_slice(&0u16.to_le_bytes());
832        bytes.extend_from_slice(&[0xaau8; 32]);
833
834        let (params, consumed) = KdfParams::parse(KdfAlgo::RecipientWrap, &bytes).unwrap();
835        assert_eq!(consumed, 46);
836        assert_eq!(
837            params,
838            KdfParams::RecipientWrap {
839                key_wrap_table_length: 16,
840                key_wrap_table_record_count: 4,
841                key_wrap_table_version: 1,
842                key_wrap_table_digest: [0xaau8; 32]
843            }
844        );
845    }
846
847    #[test]
848    fn rejects_invalid_recipient_wrap_kdf_params_fields() {
849        let mut bytes = Vec::new();
850        bytes.extend_from_slice(&(KdfAlgo::RecipientWrap as u16).to_le_bytes());
851        bytes.extend_from_slice(&16u32.to_le_bytes());
852        bytes.extend_from_slice(&4u32.to_le_bytes());
853        bytes.extend_from_slice(&2u16.to_le_bytes());
854        bytes.extend_from_slice(&0u16.to_le_bytes());
855        bytes.extend_from_slice(&[0xaau8; 32]);
856        assert_eq!(
857            KdfParams::parse(KdfAlgo::RecipientWrap, &bytes).unwrap_err(),
858            FormatError::InvalidKdfParams("recipient-wrap table version must be 1")
859        );
860
861        let mut bytes = Vec::new();
862        bytes.extend_from_slice(&(KdfAlgo::RecipientWrap as u16).to_le_bytes());
863        bytes.extend_from_slice(&0u32.to_le_bytes());
864        bytes.extend_from_slice(&1u32.to_le_bytes());
865        bytes.extend_from_slice(&1u16.to_le_bytes());
866        bytes.extend_from_slice(&0u16.to_le_bytes());
867        bytes.extend_from_slice(&[0u8; 32]);
868        assert_eq!(
869            KdfParams::parse(KdfAlgo::RecipientWrap, &bytes).unwrap_err(),
870            FormatError::InvalidKdfParams("recipient-wrap key_wrap_table_length must be non-zero"),
871        );
872
873        let mut bytes = Vec::new();
874        bytes.extend_from_slice(&(KdfAlgo::RecipientWrap as u16).to_le_bytes());
875        bytes.extend_from_slice(&(READER_MAX_KEY_WRAP_TABLE_LEN + 1).to_le_bytes());
876        bytes.extend_from_slice(&0u32.to_le_bytes());
877        bytes.extend_from_slice(&1u16.to_le_bytes());
878        bytes.extend_from_slice(&0u16.to_le_bytes());
879        bytes.extend_from_slice(&[0u8; 32]);
880        assert_eq!(
881            KdfParams::parse(KdfAlgo::RecipientWrap, &bytes).unwrap_err(),
882            FormatError::ReaderResourceLimitExceeded {
883                field: "KeyWrapTableV1 length",
884                cap: READER_MAX_KEY_WRAP_TABLE_LEN as u64,
885                actual: (READER_MAX_KEY_WRAP_TABLE_LEN + 1) as u64,
886            },
887        );
888
889        let mut bytes = Vec::new();
890        bytes.extend_from_slice(&(KdfAlgo::RecipientWrap as u16).to_le_bytes());
891        bytes.extend_from_slice(&16u32.to_le_bytes());
892        bytes.extend_from_slice(&(READER_MAX_KEY_WRAP_TABLE_RECIPIENT_RECORDS + 1).to_le_bytes());
893        bytes.extend_from_slice(&1u16.to_le_bytes());
894        bytes.extend_from_slice(&0u16.to_le_bytes());
895        bytes.extend_from_slice(&[0u8; 32]);
896        assert_eq!(
897            KdfParams::parse(KdfAlgo::RecipientWrap, &bytes).unwrap_err(),
898            FormatError::ReaderResourceLimitExceeded {
899                field: "KeyWrapTableV1 recipient_record_count",
900                cap: READER_MAX_KEY_WRAP_TABLE_RECIPIENT_RECORDS as u64,
901                actual: (READER_MAX_KEY_WRAP_TABLE_RECIPIENT_RECORDS + 1) as u64,
902            },
903        );
904    }
905
906    #[test]
907    fn rejects_argon2id_params_above_reader_caps() {
908        let mut bytes = Vec::new();
909        bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
910        bytes.extend_from_slice(&(READER_MAX_ARGON2ID_T_COST + 1).to_le_bytes());
911        bytes.extend_from_slice(&8u32.to_le_bytes());
912        bytes.extend_from_slice(&1u32.to_le_bytes());
913        bytes.extend_from_slice(&8u16.to_le_bytes());
914        bytes.extend_from_slice(b"12345678");
915
916        assert_eq!(
917            KdfParams::parse(KdfAlgo::Argon2id, &bytes).unwrap_err(),
918            FormatError::ReaderResourceLimitExceeded {
919                field: "argon2id t_cost",
920                cap: READER_MAX_ARGON2ID_T_COST as u64,
921                actual: (READER_MAX_ARGON2ID_T_COST + 1) as u64,
922            }
923        );
924
925        let err = MasterKey::derive_from_passphrase(
926            &KdfParams::Argon2id {
927                t_cost: 1,
928                m_cost_kib: READER_MAX_ARGON2ID_M_COST_KIB + 1,
929                parallelism: 1,
930                salt: b"12345678".to_vec(),
931            },
932            "passphrase",
933        )
934        .unwrap_err();
935        assert_eq!(
936            err,
937            FormatError::ReaderResourceLimitExceeded {
938                field: "argon2id m_cost_kib",
939                cap: READER_MAX_ARGON2ID_M_COST_KIB as u64,
940                actual: (READER_MAX_ARGON2ID_M_COST_KIB + 1) as u64,
941            }
942        );
943    }
944
945    #[test]
946    fn rejects_argon2id_salt_bounds_and_raw_kdf_truncation() {
947        fn argon_bytes(salt_len: u16, actual_salt: &[u8]) -> Vec<u8> {
948            let mut bytes = Vec::new();
949            bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
950            bytes.extend_from_slice(&1u32.to_le_bytes());
951            bytes.extend_from_slice(&8u32.to_le_bytes());
952            bytes.extend_from_slice(&1u32.to_le_bytes());
953            bytes.extend_from_slice(&salt_len.to_le_bytes());
954            bytes.extend_from_slice(actual_salt);
955            bytes
956        }
957
958        assert_eq!(
959            KdfParams::parse(KdfAlgo::Raw, &[]).unwrap_err(),
960            FormatError::TruncatedKdfParams
961        );
962        assert_eq!(
963            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(7, b"1234567")).unwrap_err(),
964            FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
965        );
966        assert!(matches!(
967            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(8, b"12345678")).unwrap(),
968            (KdfParams::Argon2id { .. }, 24)
969        ));
970        assert!(matches!(
971            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(64, &[0x5a; 64])).unwrap(),
972            (KdfParams::Argon2id { .. }, 80)
973        ));
974        assert_eq!(
975            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(65, &[0x5a; 65])).unwrap_err(),
976            FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
977        );
978        assert_eq!(
979            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(64, &[0x5a; 63])).unwrap_err(),
980            FormatError::TruncatedKdfParams
981        );
982    }
983
984    #[test]
985    fn rejects_kdf_algo_tag_mismatch() {
986        assert_eq!(
987            KdfParams::parse(KdfAlgo::Raw, &(KdfAlgo::Argon2id as u16).to_le_bytes()).unwrap_err(),
988            FormatError::KdfAlgoTagMismatch {
989                expected: 0,
990                actual: 1
991            }
992        );
993    }
994
995    #[test]
996    fn passphrase_normalization_preserves_archive_semantics() {
997        assert_eq!(normalize_passphrase_nfc("e\u{301}\n\0"), "é\n\0".as_bytes());
998    }
999
1000    #[test]
1001    fn argon2id_passphrase_edge_vectors_are_literal() {
1002        let params = KdfParams::Argon2id {
1003            t_cost: 1,
1004            m_cost_kib: 8,
1005            parallelism: 1,
1006            salt: b"12345678".to_vec(),
1007        };
1008        let cases = [
1009            (
1010                "trailing newline",
1011                "pass\n",
1012                "f63027356e6da90a4f6c81af70b9e6f1b1967ab684ecda8257cb7d21de760623",
1013            ),
1014            (
1015                "embedded nul",
1016                "pass\0word",
1017                "23db596ddbaa8f3f36d653f456dd9819e342aad4e30224008a22f1fb7648780e",
1018            ),
1019            (
1020                "leading bom",
1021                "\u{feff}pass",
1022                "d493645da269dce9b0ab6d39367d94c1896b0f4a2c3ca486c775d7275b8558da",
1023            ),
1024        ];
1025
1026        for (name, passphrase, expected_hex) in cases {
1027            let master = MasterKey::derive_from_passphrase(&params, passphrase).unwrap();
1028            assert_eq!(hex::encode(master.0), expected_hex, "{name}");
1029        }
1030
1031        let without_newline = MasterKey::derive_from_passphrase(&params, "pass").unwrap();
1032        let with_newline = MasterKey::derive_from_passphrase(&params, "pass\n").unwrap();
1033        assert_ne!(without_newline, with_newline);
1034    }
1035
1036    #[test]
1037    fn argon2id_profile_rejects_alternate_version_vector() {
1038        let params = KdfParams::Argon2id {
1039            t_cost: 1,
1040            m_cost_kib: 8,
1041            parallelism: 1,
1042            salt: b"12345678".to_vec(),
1043        };
1044        let current = MasterKey::derive_from_passphrase(&params, "e\u{301}").unwrap();
1045
1046        let argon_params = Params::new(8, 1, 1, Some(MASTER_KEY_LEN)).unwrap();
1047        let old_argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x10, argon_params);
1048        let mut old_output = [0u8; MASTER_KEY_LEN];
1049        let passphrase = normalize_passphrase_nfc("e\u{301}");
1050        old_argon2
1051            .hash_password_into(&passphrase, b"12345678", &mut old_output)
1052            .unwrap();
1053
1054        assert_eq!(
1055            hex::encode(current.0),
1056            "24709642204c04bf88fb36550c478769eb10a0400c0493c9695d30fbf7082241"
1057        );
1058        assert_ne!(old_output, current.0);
1059    }
1060
1061    #[test]
1062    fn derives_argon2id_master_key_from_nfc_passphrase() {
1063        let params = KdfParams::Argon2id {
1064            t_cost: 1,
1065            m_cost_kib: 8,
1066            parallelism: 1,
1067            salt: b"12345678".to_vec(),
1068        };
1069        let one = MasterKey::derive_from_passphrase(&params, "e\u{301}").unwrap();
1070        let two = MasterKey::derive_from_passphrase(&params, "é").unwrap();
1071        assert_eq!(one.0, two.0);
1072        assert_ne!(one.0, [0u8; MASTER_KEY_LEN]);
1073    }
1074
1075    #[test]
1076    fn derives_stable_distinct_subkeys() {
1077        let master = MasterKey::from_raw_key(&[0x33; MASTER_KEY_LEN]).unwrap();
1078        let subkeys = Subkeys::derive(&master, &uuid(), &session()).unwrap();
1079        assert_ne!(subkeys.enc_key, subkeys.mac_key);
1080        assert_ne!(subkeys.index_root_key, subkeys.index_shard_key);
1081
1082        let repeat = Subkeys::derive(&master, &uuid(), &session()).unwrap();
1083        assert_eq!(subkeys, repeat);
1084    }
1085
1086    #[test]
1087    fn hkdf_passphrase_and_identity_vectors_are_literal() {
1088        let params = KdfParams::Argon2id {
1089            t_cost: 1,
1090            m_cost_kib: 8,
1091            parallelism: 1,
1092            salt: b"saltsalt".to_vec(),
1093        };
1094        let archive_uuid = core::array::from_fn::<_, 16, _>(|idx| 0x30 + idx as u8);
1095        let session_id = core::array::from_fn::<_, 16, _>(|idx| 0xc0 + idx as u8);
1096        let master = MasterKey::derive_from_passphrase(&params, "correct horse\n").unwrap();
1097        let subkeys = Subkeys::derive(&master, &archive_uuid, &session_id).unwrap();
1098
1099        assert_eq!(
1100            hex::encode(master.0),
1101            "c58d65c836c8a590c0d34fcc0907d876e969d72c51a267cad2518cfee8eb2a21"
1102        );
1103        assert_eq!(
1104            hex::encode(subkeys.enc_key),
1105            "786001f513f99062c7c7ef72c978847a7c2daa452f363177839ce2ed3ecfd5df"
1106        );
1107        assert_eq!(
1108            hex::encode(subkeys.mac_key),
1109            "024f2737f6db8aa03d3ce241d25c26fcc18bbcf4af242614c3d703224cd82b74"
1110        );
1111        assert_eq!(
1112            hex::encode(subkeys.index_nonce_seed),
1113            "5d51a19bf7f6d77ce7945517ce95837a089f8d1cd20aea43cbcb8d745c0668ee"
1114        );
1115
1116        let different_session = Subkeys::derive(&master, &archive_uuid, &[0xc1; 16]).unwrap();
1117        let different_archive = Subkeys::derive(&master, &[0x31; 16], &session_id).unwrap();
1118        assert_ne!(subkeys.enc_key, different_session.enc_key);
1119        assert_ne!(subkeys.enc_key, different_archive.enc_key);
1120    }
1121
1122    #[test]
1123    fn computes_and_verifies_hmac_domains() {
1124        let key = [0x44; SUBKEY_LEN];
1125        let covered = b"covered bytes";
1126        let tag = compute_hmac(HmacDomain::CryptoHeader, &key, &uuid(), &session(), covered);
1127        verify_hmac(
1128            HmacDomain::CryptoHeader,
1129            &key,
1130            &uuid(),
1131            &session(),
1132            covered,
1133            &tag,
1134        )
1135        .unwrap();
1136
1137        assert_eq!(
1138            verify_hmac(
1139                HmacDomain::ManifestFooter,
1140                &key,
1141                &uuid(),
1142                &session(),
1143                covered,
1144                &tag,
1145            )
1146            .unwrap_err(),
1147            FormatError::HmacMismatch {
1148                structure: "ManifestFooter"
1149            }
1150        );
1151    }
1152
1153    #[test]
1154    fn computes_and_verifies_unkeyed_integrity_domains() {
1155        let covered = b"covered bytes";
1156        let tag_v45 = compute_integrity_tag(
1157            HmacDomain::CryptoHeader,
1158            AeadAlgo::None,
1159            VOLUME_FORMAT_REV_45,
1160            None,
1161            &uuid(),
1162            &session(),
1163            covered,
1164        )
1165        .unwrap();
1166
1167        verify_integrity_tag(
1168            HmacDomain::CryptoHeader,
1169            AeadAlgo::None,
1170            VOLUME_FORMAT_REV_45,
1171            None,
1172            &uuid(),
1173            &session(),
1174            covered,
1175            &tag_v45,
1176        )
1177        .unwrap();
1178
1179        assert_eq!(
1180            verify_integrity_tag(
1181                HmacDomain::ManifestFooter,
1182                AeadAlgo::None,
1183                VOLUME_FORMAT_REV_45,
1184                None,
1185                &uuid(),
1186                &session(),
1187                covered,
1188                &tag_v45,
1189            )
1190            .unwrap_err(),
1191            FormatError::IntegrityDigestMismatch {
1192                structure: "ManifestFooter"
1193            }
1194        );
1195
1196        let manifest_tag_v45 = compute_integrity_tag(
1197            HmacDomain::ManifestFooter,
1198            AeadAlgo::None,
1199            VOLUME_FORMAT_REV_45,
1200            None,
1201            &uuid(),
1202            &session(),
1203            covered,
1204        )
1205        .unwrap();
1206        assert_eq!(
1207            verify_integrity_tag(
1208                HmacDomain::ManifestFooter,
1209                AeadAlgo::None,
1210                VOLUME_FORMAT_REV_45,
1211                None,
1212                &uuid(),
1213                &session(),
1214                covered,
1215                &manifest_tag_v45,
1216            )
1217            .unwrap(),
1218            ()
1219        );
1220        assert_ne!(
1221            tag_v45,
1222            compute_integrity_tag(
1223                HmacDomain::ManifestFooter,
1224                AeadAlgo::None,
1225                VOLUME_FORMAT_REV_45,
1226                None,
1227                &uuid(),
1228                &session(),
1229                covered,
1230            )
1231            .unwrap()
1232        );
1233    }
1234
1235    #[test]
1236    fn hmac_sidecar_domain_vector_and_boundary_bytes_are_literal() {
1237        let key = [0x44; SUBKEY_LEN];
1238        let covered = b"covered bytes";
1239        let tag = compute_hmac(
1240            HmacDomain::BootstrapSidecar,
1241            &key,
1242            &uuid(),
1243            &session(),
1244            covered,
1245        );
1246        assert_eq!(
1247            hex::encode(tag),
1248            "1ecc9e0c5c9079b6824e16c4468ac9df22ca50fa2a924d21a91aab33c3721d51"
1249        );
1250        verify_hmac(
1251            HmacDomain::BootstrapSidecar,
1252            &key,
1253            &uuid(),
1254            &session(),
1255            covered,
1256            &tag,
1257        )
1258        .unwrap();
1259
1260        for mutate_index in [0, covered.len() - 1] {
1261            let mut mutated = covered.to_vec();
1262            mutated[mutate_index] ^= 0x01;
1263            assert_eq!(
1264                verify_hmac(
1265                    HmacDomain::BootstrapSidecar,
1266                    &key,
1267                    &uuid(),
1268                    &session(),
1269                    &mutated,
1270                    &tag,
1271                )
1272                .unwrap_err(),
1273                FormatError::HmacMismatch {
1274                    structure: "BootstrapSidecarHeader"
1275                }
1276            );
1277        }
1278
1279        for mutate_index in [0, tag.len() - 1] {
1280            let mut mutated_tag = tag;
1281            mutated_tag[mutate_index] ^= 0x01;
1282            assert_eq!(
1283                verify_hmac(
1284                    HmacDomain::BootstrapSidecar,
1285                    &key,
1286                    &uuid(),
1287                    &session(),
1288                    covered,
1289                    &mutated_tag,
1290                )
1291                .unwrap_err(),
1292                FormatError::HmacMismatch {
1293                    structure: "BootstrapSidecarHeader"
1294                }
1295            );
1296        }
1297    }
1298
1299    #[test]
1300    fn derives_nonce_and_aad_with_domain_separation() {
1301        let seed = [0x55; SUBKEY_LEN];
1302        let nonce = derive_nonce(&seed, b"envelope", &uuid(), &session(), 7, 12).unwrap();
1303        let other = derive_nonce(&seed, b"idxroot", &uuid(), &session(), 7, 12).unwrap();
1304        assert_eq!(nonce.len(), 12);
1305        assert_ne!(nonce, other);
1306
1307        let aad = build_aad(b"envelope", &uuid(), &session(), 7).unwrap();
1308        assert!(aad.starts_with(b"tzap-v1-aad"));
1309        assert_ne!(aad, nonce);
1310    }
1311
1312    #[test]
1313    fn rejects_old_nonce_info_without_domain_length() {
1314        let key = [0x66; SUBKEY_LEN];
1315        let nonce_seed = [0x77; SUBKEY_LEN];
1316        let uuid = uuid();
1317        let session = session();
1318        let counter = 7u64;
1319        let domain = b"idxroot";
1320
1321        let ciphertext = encrypt_padded_aead_object(
1322            AeadObjectContext {
1323                algo: AeadAlgo::AesGcmSiv256,
1324                key: &key,
1325                nonce_seed: &nonce_seed,
1326                domain,
1327                archive_uuid: &uuid,
1328                session_id: &session,
1329                counter,
1330            },
1331            4096,
1332            b"index-root",
1333        )
1334        .unwrap();
1335        let mut legacy_nonce = vec![0u8; AeadAlgo::AesGcmSiv256.nonce_len()];
1336        Hkdf::<Sha256>::from_prk(&nonce_seed)
1337            .unwrap()
1338            .expand(
1339                &legacy_nonce_info(domain, &uuid, &session, counter),
1340                &mut legacy_nonce,
1341            )
1342            .unwrap();
1343        let aad = build_aad(domain, &uuid, &session, counter).unwrap();
1344
1345        assert_ne!(
1346            legacy_nonce,
1347            derive_nonce(
1348                &nonce_seed,
1349                domain,
1350                &uuid,
1351                &session,
1352                counter,
1353                AeadAlgo::AesGcmSiv256.nonce_len()
1354            )
1355            .unwrap(),
1356            "legacy nonce info encoding must differ from current encoding"
1357        );
1358
1359        assert_eq!(
1360            aead_decrypt(
1361                AeadAlgo::AesGcmSiv256,
1362                &key,
1363                &legacy_nonce,
1364                &aad,
1365                &ciphertext,
1366            )
1367            .unwrap_err(),
1368            FormatError::AeadFailure
1369        );
1370    }
1371
1372    #[test]
1373    fn aead_round_trips_all_registered_algorithms() {
1374        for algo in [
1375            AeadAlgo::AesGcmSiv256,
1376            AeadAlgo::XChaCha20Poly1305,
1377            AeadAlgo::AesGcm256,
1378        ] {
1379            let key = [0x66; SUBKEY_LEN];
1380            let nonce = derive_nonce(
1381                &[0x77; SUBKEY_LEN],
1382                b"envelope",
1383                &uuid(),
1384                &session(),
1385                0,
1386                algo.nonce_len(),
1387            )
1388            .unwrap();
1389            let aad = build_aad(b"envelope", &uuid(), &session(), 0).unwrap();
1390            let ciphertext = aead_encrypt(algo, &key, &nonce, &aad, b"plaintext").unwrap();
1391            assert_ne!(ciphertext, b"plaintext");
1392            let plaintext = aead_decrypt(algo, &key, &nonce, &aad, &ciphertext).unwrap();
1393            assert_eq!(plaintext, b"plaintext");
1394
1395            let mut tampered = ciphertext;
1396            tampered[0] ^= 1;
1397            assert_eq!(
1398                aead_decrypt(algo, &key, &nonce, &aad, &tampered).unwrap_err(),
1399                FormatError::AeadFailure
1400            );
1401        }
1402    }
1403
1404    #[test]
1405    fn aead_none_passes_plaintext_through() {
1406        let ciphertext =
1407            aead_encrypt(AeadAlgo::None, &[0; SUBKEY_LEN], &[], b"aad", b"plaintext").unwrap();
1408        assert_eq!(ciphertext, b"plaintext");
1409        assert_eq!(
1410            aead_decrypt(AeadAlgo::None, &[0; SUBKEY_LEN], &[], b"aad", &ciphertext).unwrap(),
1411            b"plaintext"
1412        );
1413        assert_eq!(AeadAlgo::None.nonce_len(), 0);
1414        assert_eq!(AeadAlgo::None.tag_len(), 0);
1415    }
1416
1417    #[test]
1418    fn aead_rejects_wrong_nonce_length() {
1419        assert_eq!(
1420            aead_encrypt(AeadAlgo::AesGcmSiv256, &[0; SUBKEY_LEN], &[0; 11], b"", b"").unwrap_err(),
1421            FormatError::InvalidNonceLength {
1422                algo: AeadAlgo::AesGcmSiv256,
1423                expected: 12,
1424                actual: 11
1425            }
1426        );
1427    }
1428
1429    #[test]
1430    fn padded_aead_object_round_trips_with_derived_nonce_and_aad() {
1431        let key = [0x66; SUBKEY_LEN];
1432        let nonce_seed = [0x77; SUBKEY_LEN];
1433        let uuid = uuid();
1434        let session = session();
1435        let context = AeadObjectContext {
1436            algo: AeadAlgo::AesGcmSiv256,
1437            key: &key,
1438            nonce_seed: &nonce_seed,
1439            domain: b"envelope",
1440            archive_uuid: &uuid,
1441            session_id: &session,
1442            counter: 3,
1443        };
1444        let ciphertext = encrypt_padded_aead_object(context, 4096, b"packed frames").unwrap();
1445        assert_eq!(ciphertext.len() % 4096, 0);
1446
1447        let plaintext = decrypt_padded_aead_object(context, &ciphertext).unwrap();
1448        assert_eq!(plaintext, b"packed frames");
1449
1450        assert_eq!(
1451            decrypt_padded_aead_object(
1452                AeadObjectContext {
1453                    domain: b"idxroot",
1454                    ..context
1455                },
1456                &ciphertext,
1457            )
1458            .unwrap_err(),
1459            FormatError::AeadFailure
1460        );
1461    }
1462
1463    #[test]
1464    fn rejects_index_root_aad_counter_mismatch() {
1465        let key = [0x99; SUBKEY_LEN];
1466        let nonce_seed = [0x88; SUBKEY_LEN];
1467        let uuid = uuid();
1468        let session = session();
1469
1470        let ciphertext = encrypt_padded_aead_object(
1471            AeadObjectContext {
1472                algo: AeadAlgo::AesGcmSiv256,
1473                key: &key,
1474                nonce_seed: &nonce_seed,
1475                domain: b"idxroot",
1476                archive_uuid: &uuid,
1477                session_id: &session,
1478                counter: 0,
1479            },
1480            4096,
1481            b"index-root-meta",
1482        )
1483        .unwrap();
1484
1485        let nonce = derive_nonce(
1486            &nonce_seed,
1487            b"idxroot",
1488            &uuid,
1489            &session,
1490            0,
1491            AeadAlgo::AesGcmSiv256.nonce_len(),
1492        )
1493        .unwrap();
1494        let mismatched_aad = build_aad(b"idxroot", &uuid, &session, 1).unwrap();
1495
1496        assert_eq!(
1497            aead_decrypt(
1498                AeadAlgo::AesGcmSiv256,
1499                &key,
1500                &nonce,
1501                &mismatched_aad,
1502                &ciphertext,
1503            )
1504            .unwrap_err(),
1505            FormatError::AeadFailure
1506        );
1507    }
1508}