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