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(¶ms, passphrase).unwrap();
746 assert_eq!(hex::encode(master.0), expected_hex, "{name}");
747 }
748
749 let without_newline = MasterKey::derive_from_passphrase(¶ms, "pass").unwrap();
750 let with_newline = MasterKey::derive_from_passphrase(¶ms, "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(¶ms, "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(¶ms, "e\u{301}").unwrap();
788 let two = MasterKey::derive_from_passphrase(¶ms, "é").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(¶ms, "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}