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