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
332#[derive(Debug, Clone, Copy)]
333pub struct AeadObjectContext<'a> {
334    pub algo: AeadAlgo,
335    pub key: &'a [u8; SUBKEY_LEN],
336    pub nonce_seed: &'a [u8; SUBKEY_LEN],
337    pub domain: &'a [u8],
338    pub archive_uuid: &'a [u8; 16],
339    pub session_id: &'a [u8; 16],
340    pub counter: u64,
341}
342
343pub fn encrypt_padded_aead_object(
344    context: AeadObjectContext<'_>,
345    block_size: usize,
346    payload: &[u8],
347) -> Result<Vec<u8>, FormatError> {
348    let nonce = derive_nonce(
349        context.nonce_seed,
350        context.domain,
351        context.archive_uuid,
352        context.session_id,
353        context.counter,
354        context.algo.nonce_len(),
355    )?;
356    let aad = build_aad(
357        context.domain,
358        context.archive_uuid,
359        context.session_id,
360        context.counter,
361    )?;
362    let padded = suffix_pad_for_aead(payload, context.algo.tag_len(), block_size)?;
363    aead_encrypt(context.algo, context.key, &nonce, &aad, &padded)
364}
365
366pub fn decrypt_padded_aead_object(
367    context: AeadObjectContext<'_>,
368    ciphertext_and_tag: &[u8],
369) -> Result<Vec<u8>, FormatError> {
370    let nonce = derive_nonce(
371        context.nonce_seed,
372        context.domain,
373        context.archive_uuid,
374        context.session_id,
375        context.counter,
376        context.algo.nonce_len(),
377    )?;
378    let aad = build_aad(
379        context.domain,
380        context.archive_uuid,
381        context.session_id,
382        context.counter,
383    )?;
384    let padded = aead_decrypt(context.algo, context.key, &nonce, &aad, ciphertext_and_tag)?;
385    Ok(depad_suffix_padding(&padded)?.to_vec())
386}
387
388fn parse_raw_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
389    if bytes.len() < RAW_KDF_PARAMS_LEN {
390        return Err(FormatError::TruncatedKdfParams);
391    }
392    let algo_tag = read_u16(bytes, 0)?;
393    if algo_tag != KdfAlgo::Raw as u16 {
394        return Err(FormatError::KdfAlgoTagMismatch {
395            expected: KdfAlgo::Raw as u16,
396            actual: algo_tag,
397        });
398    }
399    Ok((KdfParams::Raw, RAW_KDF_PARAMS_LEN))
400}
401
402fn parse_argon2id_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
403    if bytes.len() < ARGON2ID_FIXED_PARAMS_LEN {
404        return Err(FormatError::TruncatedKdfParams);
405    }
406    let algo_tag = read_u16(bytes, 0)?;
407    if algo_tag != KdfAlgo::Argon2id as u16 {
408        return Err(FormatError::KdfAlgoTagMismatch {
409            expected: KdfAlgo::Argon2id as u16,
410            actual: algo_tag,
411        });
412    }
413    let t_cost = read_u32(bytes, 2)?;
414    let m_cost_kib = read_u32(bytes, 6)?;
415    let parallelism = read_u32(bytes, 10)?;
416    let salt_length = read_u16(bytes, 14)?;
417    if !(ARGON2ID_MIN_SALT_LEN..=ARGON2ID_MAX_SALT_LEN).contains(&salt_length) {
418        return Err(FormatError::InvalidKdfParams(
419            "argon2id salt length must be 8..64 bytes",
420        ));
421    }
422    if t_cost == 0 {
423        return Err(FormatError::InvalidKdfParams(
424            "argon2id t_cost must be non-zero",
425        ));
426    }
427    if parallelism == 0 {
428        return Err(FormatError::InvalidKdfParams(
429            "argon2id parallelism must be non-zero",
430        ));
431    }
432    validate_argon2id_bounds(t_cost, m_cost_kib, parallelism, salt_length)?;
433
434    let total_len = ARGON2ID_FIXED_PARAMS_LEN + salt_length as usize;
435    if bytes.len() < total_len {
436        return Err(FormatError::TruncatedKdfParams);
437    }
438    Ok((
439        KdfParams::Argon2id {
440            t_cost,
441            m_cost_kib,
442            parallelism,
443            salt: bytes[ARGON2ID_FIXED_PARAMS_LEN..total_len].to_vec(),
444        },
445        total_len,
446    ))
447}
448
449fn validate_argon2id_bounds(
450    t_cost: u32,
451    m_cost_kib: u32,
452    parallelism: u32,
453    salt_length: u16,
454) -> Result<(), FormatError> {
455    if !(ARGON2ID_MIN_SALT_LEN..=ARGON2ID_MAX_SALT_LEN).contains(&salt_length) {
456        return Err(FormatError::InvalidKdfParams(
457            "argon2id salt length must be 8..64 bytes",
458        ));
459    }
460    if t_cost == 0 {
461        return Err(FormatError::InvalidKdfParams(
462            "argon2id t_cost must be non-zero",
463        ));
464    }
465    if t_cost > READER_MAX_ARGON2ID_T_COST {
466        return Err(FormatError::ReaderResourceLimitExceeded {
467            field: "argon2id t_cost",
468            cap: READER_MAX_ARGON2ID_T_COST as u64,
469            actual: t_cost as u64,
470        });
471    }
472    if parallelism == 0 {
473        return Err(FormatError::InvalidKdfParams(
474            "argon2id parallelism must be non-zero",
475        ));
476    }
477    if parallelism > READER_MAX_ARGON2ID_PARALLELISM {
478        return Err(FormatError::ReaderResourceLimitExceeded {
479            field: "argon2id parallelism",
480            cap: READER_MAX_ARGON2ID_PARALLELISM as u64,
481            actual: parallelism as u64,
482        });
483    }
484    if m_cost_kib > READER_MAX_ARGON2ID_M_COST_KIB {
485        return Err(FormatError::ReaderResourceLimitExceeded {
486            field: "argon2id m_cost_kib",
487            cap: READER_MAX_ARGON2ID_M_COST_KIB as u64,
488            actual: m_cost_kib as u64,
489        });
490    }
491    let min_memory = parallelism
492        .checked_mul(8)
493        .ok_or(FormatError::InvalidKdfParams(
494            "argon2id memory requirement overflow",
495        ))?;
496    if m_cost_kib < min_memory {
497        return Err(FormatError::InvalidKdfParams(
498            "argon2id memory must be at least 8 KiB per lane",
499        ));
500    }
501    Ok(())
502}
503
504fn expand_subkey(hk: &Hkdf<Sha256>, info: &[u8]) -> Result<[u8; SUBKEY_LEN], FormatError> {
505    let mut output = [0u8; SUBKEY_LEN];
506    hk.expand(info, &mut output)
507        .map_err(|_| FormatError::HkdfExpandFailure)?;
508    Ok(output)
509}
510
511fn nonce_or_aad_info(
512    prefix: &[u8],
513    domain: &[u8],
514    archive_uuid: &[u8; 16],
515    session_id: &[u8; 16],
516    counter: u64,
517) -> Result<Vec<u8>, FormatError> {
518    let domain_len = u16::try_from(domain.len()).map_err(|_| FormatError::DomainTooLong)?;
519    let mut info = Vec::with_capacity(prefix.len() + 2 + domain.len() + 16 + 16 + 8);
520    info.extend_from_slice(prefix);
521    info.extend_from_slice(&domain_len.to_le_bytes());
522    info.extend_from_slice(domain);
523    info.extend_from_slice(archive_uuid);
524    info.extend_from_slice(session_id);
525    info.extend_from_slice(&counter.to_le_bytes());
526    Ok(info)
527}
528
529fn validate_nonce_len(algo: AeadAlgo, nonce: &[u8]) -> Result<(), FormatError> {
530    let expected = algo.nonce_len();
531    if nonce.len() != expected {
532        return Err(FormatError::InvalidNonceLength {
533            algo,
534            expected,
535            actual: nonce.len(),
536        });
537    }
538    Ok(())
539}
540
541fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, FormatError> {
542    let array: [u8; 2] = bytes
543        .get(offset..offset + 2)
544        .ok_or(FormatError::InvalidLength {
545            structure: "u16",
546            expected: offset + 2,
547            actual: bytes.len(),
548        })?
549        .try_into()
550        .expect("slice length checked");
551    Ok(u16::from_le_bytes(array))
552}
553
554fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, FormatError> {
555    let array: [u8; 4] = bytes
556        .get(offset..offset + 4)
557        .ok_or(FormatError::InvalidLength {
558            structure: "u32",
559            expected: offset + 4,
560            actual: bytes.len(),
561        })?
562        .try_into()
563        .expect("slice length checked");
564    Ok(u32::from_le_bytes(array))
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    fn uuid() -> [u8; 16] {
572        [0x11; 16]
573    }
574
575    fn session() -> [u8; 16] {
576        [0x22; 16]
577    }
578
579    fn legacy_nonce_info(
580        domain: &[u8],
581        archive_uuid: &[u8; 16],
582        session_id: &[u8; 16],
583        counter: u64,
584    ) -> Vec<u8> {
585        let mut info = Vec::with_capacity(b"tzap-v1-nonce".len() + domain.len() + 16 + 16 + 8);
586        info.extend_from_slice(b"tzap-v1-nonce");
587        info.extend_from_slice(domain);
588        info.extend_from_slice(archive_uuid);
589        info.extend_from_slice(session_id);
590        info.extend_from_slice(&counter.to_le_bytes());
591        info
592    }
593
594    #[test]
595    fn parses_raw_kdf_params() {
596        let (params, consumed) = KdfParams::parse(KdfAlgo::Raw, &0u16.to_le_bytes()).unwrap();
597        assert_eq!(params, KdfParams::Raw);
598        assert_eq!(consumed, 2);
599    }
600
601    #[test]
602    fn parses_argon2id_kdf_params() {
603        let mut bytes = Vec::new();
604        bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
605        bytes.extend_from_slice(&1u32.to_le_bytes());
606        bytes.extend_from_slice(&8u32.to_le_bytes());
607        bytes.extend_from_slice(&1u32.to_le_bytes());
608        bytes.extend_from_slice(&8u16.to_le_bytes());
609        bytes.extend_from_slice(b"12345678");
610
611        let (params, consumed) = KdfParams::parse(KdfAlgo::Argon2id, &bytes).unwrap();
612        assert_eq!(consumed, 24);
613        assert_eq!(
614            params,
615            KdfParams::Argon2id {
616                t_cost: 1,
617                m_cost_kib: 8,
618                parallelism: 1,
619                salt: b"12345678".to_vec()
620            }
621        );
622    }
623
624    #[test]
625    fn rejects_argon2id_params_above_reader_caps() {
626        let mut bytes = Vec::new();
627        bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
628        bytes.extend_from_slice(&(READER_MAX_ARGON2ID_T_COST + 1).to_le_bytes());
629        bytes.extend_from_slice(&8u32.to_le_bytes());
630        bytes.extend_from_slice(&1u32.to_le_bytes());
631        bytes.extend_from_slice(&8u16.to_le_bytes());
632        bytes.extend_from_slice(b"12345678");
633
634        assert_eq!(
635            KdfParams::parse(KdfAlgo::Argon2id, &bytes).unwrap_err(),
636            FormatError::ReaderResourceLimitExceeded {
637                field: "argon2id t_cost",
638                cap: READER_MAX_ARGON2ID_T_COST as u64,
639                actual: (READER_MAX_ARGON2ID_T_COST + 1) as u64,
640            }
641        );
642
643        let err = MasterKey::derive_from_passphrase(
644            &KdfParams::Argon2id {
645                t_cost: 1,
646                m_cost_kib: READER_MAX_ARGON2ID_M_COST_KIB + 1,
647                parallelism: 1,
648                salt: b"12345678".to_vec(),
649            },
650            "passphrase",
651        )
652        .unwrap_err();
653        assert_eq!(
654            err,
655            FormatError::ReaderResourceLimitExceeded {
656                field: "argon2id m_cost_kib",
657                cap: READER_MAX_ARGON2ID_M_COST_KIB as u64,
658                actual: (READER_MAX_ARGON2ID_M_COST_KIB + 1) as u64,
659            }
660        );
661    }
662
663    #[test]
664    fn rejects_argon2id_salt_bounds_and_raw_kdf_truncation() {
665        fn argon_bytes(salt_len: u16, actual_salt: &[u8]) -> Vec<u8> {
666            let mut bytes = Vec::new();
667            bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
668            bytes.extend_from_slice(&1u32.to_le_bytes());
669            bytes.extend_from_slice(&8u32.to_le_bytes());
670            bytes.extend_from_slice(&1u32.to_le_bytes());
671            bytes.extend_from_slice(&salt_len.to_le_bytes());
672            bytes.extend_from_slice(actual_salt);
673            bytes
674        }
675
676        assert_eq!(
677            KdfParams::parse(KdfAlgo::Raw, &[]).unwrap_err(),
678            FormatError::TruncatedKdfParams
679        );
680        assert_eq!(
681            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(7, b"1234567")).unwrap_err(),
682            FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
683        );
684        assert!(matches!(
685            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(8, b"12345678")).unwrap(),
686            (KdfParams::Argon2id { .. }, 24)
687        ));
688        assert!(matches!(
689            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(64, &[0x5a; 64])).unwrap(),
690            (KdfParams::Argon2id { .. }, 80)
691        ));
692        assert_eq!(
693            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(65, &[0x5a; 65])).unwrap_err(),
694            FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
695        );
696        assert_eq!(
697            KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(64, &[0x5a; 63])).unwrap_err(),
698            FormatError::TruncatedKdfParams
699        );
700    }
701
702    #[test]
703    fn rejects_kdf_algo_tag_mismatch() {
704        assert_eq!(
705            KdfParams::parse(KdfAlgo::Raw, &(KdfAlgo::Argon2id as u16).to_le_bytes()).unwrap_err(),
706            FormatError::KdfAlgoTagMismatch {
707                expected: 0,
708                actual: 1
709            }
710        );
711    }
712
713    #[test]
714    fn passphrase_normalization_preserves_archive_semantics() {
715        assert_eq!(normalize_passphrase_nfc("e\u{301}\n\0"), "é\n\0".as_bytes());
716    }
717
718    #[test]
719    fn argon2id_passphrase_edge_vectors_are_literal() {
720        let params = KdfParams::Argon2id {
721            t_cost: 1,
722            m_cost_kib: 8,
723            parallelism: 1,
724            salt: b"12345678".to_vec(),
725        };
726        let cases = [
727            (
728                "trailing newline",
729                "pass\n",
730                "f63027356e6da90a4f6c81af70b9e6f1b1967ab684ecda8257cb7d21de760623",
731            ),
732            (
733                "embedded nul",
734                "pass\0word",
735                "23db596ddbaa8f3f36d653f456dd9819e342aad4e30224008a22f1fb7648780e",
736            ),
737            (
738                "leading bom",
739                "\u{feff}pass",
740                "d493645da269dce9b0ab6d39367d94c1896b0f4a2c3ca486c775d7275b8558da",
741            ),
742        ];
743
744        for (name, passphrase, expected_hex) in cases {
745            let master = MasterKey::derive_from_passphrase(&params, passphrase).unwrap();
746            assert_eq!(hex::encode(master.0), expected_hex, "{name}");
747        }
748
749        let without_newline = MasterKey::derive_from_passphrase(&params, "pass").unwrap();
750        let with_newline = MasterKey::derive_from_passphrase(&params, "pass\n").unwrap();
751        assert_ne!(without_newline, with_newline);
752    }
753
754    #[test]
755    fn argon2id_profile_rejects_alternate_version_vector() {
756        let params = KdfParams::Argon2id {
757            t_cost: 1,
758            m_cost_kib: 8,
759            parallelism: 1,
760            salt: b"12345678".to_vec(),
761        };
762        let current = MasterKey::derive_from_passphrase(&params, "e\u{301}").unwrap();
763
764        let argon_params = Params::new(8, 1, 1, Some(MASTER_KEY_LEN)).unwrap();
765        let old_argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x10, argon_params);
766        let mut old_output = [0u8; MASTER_KEY_LEN];
767        let passphrase = normalize_passphrase_nfc("e\u{301}");
768        old_argon2
769            .hash_password_into(&passphrase, b"12345678", &mut old_output)
770            .unwrap();
771
772        assert_eq!(
773            hex::encode(current.0),
774            "24709642204c04bf88fb36550c478769eb10a0400c0493c9695d30fbf7082241"
775        );
776        assert_ne!(old_output, current.0);
777    }
778
779    #[test]
780    fn derives_argon2id_master_key_from_nfc_passphrase() {
781        let params = KdfParams::Argon2id {
782            t_cost: 1,
783            m_cost_kib: 8,
784            parallelism: 1,
785            salt: b"12345678".to_vec(),
786        };
787        let one = MasterKey::derive_from_passphrase(&params, "e\u{301}").unwrap();
788        let two = MasterKey::derive_from_passphrase(&params, "é").unwrap();
789        assert_eq!(one.0, two.0);
790        assert_ne!(one.0, [0u8; MASTER_KEY_LEN]);
791    }
792
793    #[test]
794    fn derives_stable_distinct_subkeys() {
795        let master = MasterKey::from_raw_key(&[0x33; MASTER_KEY_LEN]).unwrap();
796        let subkeys = Subkeys::derive(&master, &uuid(), &session()).unwrap();
797        assert_ne!(subkeys.enc_key, subkeys.mac_key);
798        assert_ne!(subkeys.index_root_key, subkeys.index_shard_key);
799
800        let repeat = Subkeys::derive(&master, &uuid(), &session()).unwrap();
801        assert_eq!(subkeys, repeat);
802    }
803
804    #[test]
805    fn hkdf_passphrase_and_identity_vectors_are_literal() {
806        let params = KdfParams::Argon2id {
807            t_cost: 1,
808            m_cost_kib: 8,
809            parallelism: 1,
810            salt: b"saltsalt".to_vec(),
811        };
812        let archive_uuid = core::array::from_fn::<_, 16, _>(|idx| 0x30 + idx as u8);
813        let session_id = core::array::from_fn::<_, 16, _>(|idx| 0xc0 + idx as u8);
814        let master = MasterKey::derive_from_passphrase(&params, "correct horse\n").unwrap();
815        let subkeys = Subkeys::derive(&master, &archive_uuid, &session_id).unwrap();
816
817        assert_eq!(
818            hex::encode(master.0),
819            "c58d65c836c8a590c0d34fcc0907d876e969d72c51a267cad2518cfee8eb2a21"
820        );
821        assert_eq!(
822            hex::encode(subkeys.enc_key),
823            "786001f513f99062c7c7ef72c978847a7c2daa452f363177839ce2ed3ecfd5df"
824        );
825        assert_eq!(
826            hex::encode(subkeys.mac_key),
827            "024f2737f6db8aa03d3ce241d25c26fcc18bbcf4af242614c3d703224cd82b74"
828        );
829        assert_eq!(
830            hex::encode(subkeys.index_nonce_seed),
831            "5d51a19bf7f6d77ce7945517ce95837a089f8d1cd20aea43cbcb8d745c0668ee"
832        );
833
834        let different_session = Subkeys::derive(&master, &archive_uuid, &[0xc1; 16]).unwrap();
835        let different_archive = Subkeys::derive(&master, &[0x31; 16], &session_id).unwrap();
836        assert_ne!(subkeys.enc_key, different_session.enc_key);
837        assert_ne!(subkeys.enc_key, different_archive.enc_key);
838    }
839
840    #[test]
841    fn computes_and_verifies_hmac_domains() {
842        let key = [0x44; SUBKEY_LEN];
843        let covered = b"covered bytes";
844        let tag = compute_hmac(HmacDomain::CryptoHeader, &key, &uuid(), &session(), covered);
845        verify_hmac(
846            HmacDomain::CryptoHeader,
847            &key,
848            &uuid(),
849            &session(),
850            covered,
851            &tag,
852        )
853        .unwrap();
854
855        assert_eq!(
856            verify_hmac(
857                HmacDomain::ManifestFooter,
858                &key,
859                &uuid(),
860                &session(),
861                covered,
862                &tag,
863            )
864            .unwrap_err(),
865            FormatError::HmacMismatch {
866                structure: "ManifestFooter"
867            }
868        );
869    }
870
871    #[test]
872    fn hmac_sidecar_domain_vector_and_boundary_bytes_are_literal() {
873        let key = [0x44; SUBKEY_LEN];
874        let covered = b"covered bytes";
875        let tag = compute_hmac(
876            HmacDomain::BootstrapSidecar,
877            &key,
878            &uuid(),
879            &session(),
880            covered,
881        );
882        assert_eq!(
883            hex::encode(tag),
884            "1ecc9e0c5c9079b6824e16c4468ac9df22ca50fa2a924d21a91aab33c3721d51"
885        );
886        verify_hmac(
887            HmacDomain::BootstrapSidecar,
888            &key,
889            &uuid(),
890            &session(),
891            covered,
892            &tag,
893        )
894        .unwrap();
895
896        for mutate_index in [0, covered.len() - 1] {
897            let mut mutated = covered.to_vec();
898            mutated[mutate_index] ^= 0x01;
899            assert_eq!(
900                verify_hmac(
901                    HmacDomain::BootstrapSidecar,
902                    &key,
903                    &uuid(),
904                    &session(),
905                    &mutated,
906                    &tag,
907                )
908                .unwrap_err(),
909                FormatError::HmacMismatch {
910                    structure: "BootstrapSidecarHeader"
911                }
912            );
913        }
914
915        for mutate_index in [0, tag.len() - 1] {
916            let mut mutated_tag = tag;
917            mutated_tag[mutate_index] ^= 0x01;
918            assert_eq!(
919                verify_hmac(
920                    HmacDomain::BootstrapSidecar,
921                    &key,
922                    &uuid(),
923                    &session(),
924                    covered,
925                    &mutated_tag,
926                )
927                .unwrap_err(),
928                FormatError::HmacMismatch {
929                    structure: "BootstrapSidecarHeader"
930                }
931            );
932        }
933    }
934
935    #[test]
936    fn derives_nonce_and_aad_with_domain_separation() {
937        let seed = [0x55; SUBKEY_LEN];
938        let nonce = derive_nonce(&seed, b"envelope", &uuid(), &session(), 7, 12).unwrap();
939        let other = derive_nonce(&seed, b"idxroot", &uuid(), &session(), 7, 12).unwrap();
940        assert_eq!(nonce.len(), 12);
941        assert_ne!(nonce, other);
942
943        let aad = build_aad(b"envelope", &uuid(), &session(), 7).unwrap();
944        assert!(aad.starts_with(b"tzap-v1-aad"));
945        assert_ne!(aad, nonce);
946    }
947
948    #[test]
949    fn rejects_old_nonce_info_without_domain_length() {
950        let key = [0x66; SUBKEY_LEN];
951        let nonce_seed = [0x77; SUBKEY_LEN];
952        let uuid = uuid();
953        let session = session();
954        let counter = 7u64;
955        let domain = b"idxroot";
956
957        let ciphertext = encrypt_padded_aead_object(
958            AeadObjectContext {
959                algo: AeadAlgo::AesGcmSiv256,
960                key: &key,
961                nonce_seed: &nonce_seed,
962                domain,
963                archive_uuid: &uuid,
964                session_id: &session,
965                counter,
966            },
967            4096,
968            b"index-root",
969        )
970        .unwrap();
971        let mut legacy_nonce = vec![0u8; AeadAlgo::AesGcmSiv256.nonce_len()];
972        Hkdf::<Sha256>::from_prk(&nonce_seed)
973            .unwrap()
974            .expand(
975                &legacy_nonce_info(domain, &uuid, &session, counter),
976                &mut legacy_nonce,
977            )
978            .unwrap();
979        let aad = build_aad(domain, &uuid, &session, counter).unwrap();
980
981        assert_ne!(
982            legacy_nonce,
983            derive_nonce(
984                &nonce_seed,
985                domain,
986                &uuid,
987                &session,
988                counter,
989                AeadAlgo::AesGcmSiv256.nonce_len()
990            )
991            .unwrap(),
992            "legacy nonce info encoding must differ from current encoding"
993        );
994
995        assert_eq!(
996            aead_decrypt(
997                AeadAlgo::AesGcmSiv256,
998                &key,
999                &legacy_nonce,
1000                &aad,
1001                &ciphertext,
1002            )
1003            .unwrap_err(),
1004            FormatError::AeadFailure
1005        );
1006    }
1007
1008    #[test]
1009    fn aead_round_trips_all_registered_algorithms() {
1010        for algo in [
1011            AeadAlgo::AesGcmSiv256,
1012            AeadAlgo::XChaCha20Poly1305,
1013            AeadAlgo::AesGcm256,
1014        ] {
1015            let key = [0x66; SUBKEY_LEN];
1016            let nonce = derive_nonce(
1017                &[0x77; SUBKEY_LEN],
1018                b"envelope",
1019                &uuid(),
1020                &session(),
1021                0,
1022                algo.nonce_len(),
1023            )
1024            .unwrap();
1025            let aad = build_aad(b"envelope", &uuid(), &session(), 0).unwrap();
1026            let ciphertext = aead_encrypt(algo, &key, &nonce, &aad, b"plaintext").unwrap();
1027            assert_ne!(ciphertext, b"plaintext");
1028            let plaintext = aead_decrypt(algo, &key, &nonce, &aad, &ciphertext).unwrap();
1029            assert_eq!(plaintext, b"plaintext");
1030
1031            let mut tampered = ciphertext;
1032            tampered[0] ^= 1;
1033            assert_eq!(
1034                aead_decrypt(algo, &key, &nonce, &aad, &tampered).unwrap_err(),
1035                FormatError::AeadFailure
1036            );
1037        }
1038    }
1039
1040    #[test]
1041    fn aead_rejects_wrong_nonce_length() {
1042        assert_eq!(
1043            aead_encrypt(AeadAlgo::AesGcmSiv256, &[0; SUBKEY_LEN], &[0; 11], b"", b"").unwrap_err(),
1044            FormatError::InvalidNonceLength {
1045                algo: AeadAlgo::AesGcmSiv256,
1046                expected: 12,
1047                actual: 11
1048            }
1049        );
1050    }
1051
1052    #[test]
1053    fn padded_aead_object_round_trips_with_derived_nonce_and_aad() {
1054        let key = [0x66; SUBKEY_LEN];
1055        let nonce_seed = [0x77; SUBKEY_LEN];
1056        let uuid = uuid();
1057        let session = session();
1058        let context = AeadObjectContext {
1059            algo: AeadAlgo::AesGcmSiv256,
1060            key: &key,
1061            nonce_seed: &nonce_seed,
1062            domain: b"envelope",
1063            archive_uuid: &uuid,
1064            session_id: &session,
1065            counter: 3,
1066        };
1067        let ciphertext = encrypt_padded_aead_object(context, 4096, b"packed frames").unwrap();
1068        assert_eq!(ciphertext.len() % 4096, 0);
1069
1070        let plaintext = decrypt_padded_aead_object(context, &ciphertext).unwrap();
1071        assert_eq!(plaintext, b"packed frames");
1072
1073        assert_eq!(
1074            decrypt_padded_aead_object(
1075                AeadObjectContext {
1076                    domain: b"idxroot",
1077                    ..context
1078                },
1079                &ciphertext,
1080            )
1081            .unwrap_err(),
1082            FormatError::AeadFailure
1083        );
1084    }
1085
1086    #[test]
1087    fn rejects_index_root_aad_counter_mismatch() {
1088        let key = [0x99; SUBKEY_LEN];
1089        let nonce_seed = [0x88; SUBKEY_LEN];
1090        let uuid = uuid();
1091        let session = session();
1092
1093        let ciphertext = encrypt_padded_aead_object(
1094            AeadObjectContext {
1095                algo: AeadAlgo::AesGcmSiv256,
1096                key: &key,
1097                nonce_seed: &nonce_seed,
1098                domain: b"idxroot",
1099                archive_uuid: &uuid,
1100                session_id: &session,
1101                counter: 0,
1102            },
1103            4096,
1104            b"index-root-meta",
1105        )
1106        .unwrap();
1107
1108        let nonce = derive_nonce(
1109            &nonce_seed,
1110            b"idxroot",
1111            &uuid,
1112            &session,
1113            0,
1114            AeadAlgo::AesGcmSiv256.nonce_len(),
1115        )
1116        .unwrap();
1117        let mismatched_aad = build_aad(b"idxroot", &uuid, &session, 1).unwrap();
1118
1119        assert_eq!(
1120            aead_decrypt(
1121                AeadAlgo::AesGcmSiv256,
1122                &key,
1123                &nonce,
1124                &mismatched_aad,
1125                &ciphertext,
1126            )
1127            .unwrap_err(),
1128            FormatError::AeadFailure
1129        );
1130    }
1131}