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