Skip to main content

tzap_core/
crypto.rs

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