use aes_gcm::Aes256Gcm;
use aes_gcm_siv::Aes256GcmSiv;
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::XChaCha20Poly1305;
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use unicode_normalization::UnicodeNormalization;
use zeroize::{Zeroize, ZeroizeOnDrop};
use aes_gcm_siv::aead::{Aead, KeyInit as AeadKeyInit, Payload};
use crate::format::{
AeadAlgo, FormatError, KdfAlgo, MASTER_KEY_LEN, READER_MAX_ARGON2ID_M_COST_KIB,
READER_MAX_ARGON2ID_PARALLELISM, READER_MAX_ARGON2ID_T_COST, SUBKEY_LEN,
};
use crate::padding::{depad_suffix_padding, suffix_pad_for_aead};
type HmacSha256 = Hmac<Sha256>;
const HKDF_SALT_DOMAIN: &[u8] = b"tzap-v1-subkeys";
const CRYPTO_HEADER_HMAC_DOMAIN: &[u8] = b"tzap-v1-crypto-header";
const MANIFEST_FOOTER_HMAC_DOMAIN: &[u8] = b"tzap-v1-manifest-footer";
const VOLUME_TRAILER_HMAC_DOMAIN: &[u8] = b"tzap-v1-volume-trailer";
const BOOTSTRAP_SIDECAR_HMAC_DOMAIN: &[u8] = b"tzap-v1-sidecar";
const CRYPTO_HEADER_DIGEST_DOMAIN: &[u8] = b"tzap-header-v43";
const MANIFEST_FOOTER_DIGEST_DOMAIN: &[u8] = b"tzap-manifest-v43";
const VOLUME_TRAILER_DIGEST_DOMAIN: &[u8] = b"tzap-trailer-v43";
const BOOTSTRAP_SIDECAR_DIGEST_DOMAIN: &[u8] = b"tzap-sidecar-v43";
const RAW_KDF_PARAMS_LEN: usize = 2;
const NONE_KDF_PARAMS_LEN: usize = 2;
const ARGON2ID_FIXED_PARAMS_LEN: usize = 16;
const ARGON2ID_MIN_SALT_LEN: u16 = 8;
const ARGON2ID_MAX_SALT_LEN: u16 = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KdfParams {
None,
Raw,
Argon2id {
t_cost: u32,
m_cost_kib: u32,
parallelism: u32,
salt: Vec<u8>,
},
}
impl KdfParams {
pub fn parse(algo: KdfAlgo, bytes: &[u8]) -> Result<(Self, usize), FormatError> {
match algo {
KdfAlgo::Raw => parse_raw_kdf_params(bytes),
KdfAlgo::Argon2id => parse_argon2id_kdf_params(bytes),
KdfAlgo::None => parse_none_kdf_params(bytes),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub struct MasterKey(pub [u8; MASTER_KEY_LEN]);
impl MasterKey {
pub fn from_raw_key(raw_key: &[u8]) -> Result<Self, FormatError> {
if raw_key.len() != MASTER_KEY_LEN {
return Err(FormatError::InvalidRawMasterKeyLength);
}
let mut key = [0u8; MASTER_KEY_LEN];
key.copy_from_slice(raw_key);
Ok(Self(key))
}
pub fn derive_from_passphrase(
params: &KdfParams,
passphrase: &str,
) -> Result<Self, FormatError> {
let KdfParams::Argon2id {
t_cost,
m_cost_kib,
parallelism,
salt,
} = params
else {
return Err(FormatError::KeyMaterialMismatch);
};
let salt_length = u16::try_from(salt.len()).map_err(|_| {
FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
})?;
validate_argon2id_bounds(*t_cost, *m_cost_kib, *parallelism, salt_length)?;
let params = Params::new(*m_cost_kib, *t_cost, *parallelism, Some(MASTER_KEY_LEN))
.map_err(|_| FormatError::InvalidKdfParams("argon2 params rejected"))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut output = [0u8; MASTER_KEY_LEN];
let mut passphrase_bytes = normalize_passphrase_nfc(passphrase);
let result = argon2.hash_password_into(&passphrase_bytes, salt, &mut output);
passphrase_bytes.zeroize();
result.map_err(|_| FormatError::Argon2idFailure)?;
Ok(Self(output))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub struct Subkeys {
pub enc_key: [u8; SUBKEY_LEN],
pub mac_key: [u8; SUBKEY_LEN],
pub nonce_seed: [u8; SUBKEY_LEN],
pub index_root_key: [u8; SUBKEY_LEN],
pub index_shard_key: [u8; SUBKEY_LEN],
pub dictionary_key: [u8; SUBKEY_LEN],
pub dir_hint_key: [u8; SUBKEY_LEN],
pub index_nonce_seed: [u8; SUBKEY_LEN],
}
impl Subkeys {
pub(crate) fn unencrypted_placeholder() -> Self {
Self {
enc_key: [0; SUBKEY_LEN],
mac_key: [0; SUBKEY_LEN],
nonce_seed: [0; SUBKEY_LEN],
index_root_key: [0; SUBKEY_LEN],
index_shard_key: [0; SUBKEY_LEN],
dictionary_key: [0; SUBKEY_LEN],
dir_hint_key: [0; SUBKEY_LEN],
index_nonce_seed: [0; SUBKEY_LEN],
}
}
pub fn derive(
master_key: &MasterKey,
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
) -> Result<Self, FormatError> {
let mut salt = Vec::with_capacity(HKDF_SALT_DOMAIN.len() + 32);
salt.extend_from_slice(HKDF_SALT_DOMAIN);
salt.extend_from_slice(archive_uuid);
salt.extend_from_slice(session_id);
let hk = Hkdf::<Sha256>::new(Some(&salt), &master_key.0);
salt.zeroize();
Ok(Self {
enc_key: expand_subkey(&hk, b"tzap-v1-enc")?,
mac_key: expand_subkey(&hk, b"tzap-v1-mac")?,
nonce_seed: expand_subkey(&hk, b"tzap-v1-nonce")?,
index_root_key: expand_subkey(&hk, b"tzap-v1-idxroot")?,
index_shard_key: expand_subkey(&hk, b"tzap-v1-idxshard")?,
dictionary_key: expand_subkey(&hk, b"tzap-v1-dict")?,
dir_hint_key: expand_subkey(&hk, b"tzap-v1-dirhint")?,
index_nonce_seed: expand_subkey(&hk, b"tzap-v1-idxnonce")?,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HmacDomain {
CryptoHeader,
ManifestFooter,
VolumeTrailer,
BootstrapSidecar,
}
impl HmacDomain {
pub fn structure_name(self) -> &'static str {
match self {
Self::CryptoHeader => "CryptoHeader",
Self::ManifestFooter => "ManifestFooter",
Self::VolumeTrailer => "VolumeTrailer",
Self::BootstrapSidecar => "BootstrapSidecarHeader",
}
}
fn domain_bytes(self) -> &'static [u8] {
match self {
Self::CryptoHeader => CRYPTO_HEADER_HMAC_DOMAIN,
Self::ManifestFooter => MANIFEST_FOOTER_HMAC_DOMAIN,
Self::VolumeTrailer => VOLUME_TRAILER_HMAC_DOMAIN,
Self::BootstrapSidecar => BOOTSTRAP_SIDECAR_HMAC_DOMAIN,
}
}
fn digest_domain_bytes(self) -> &'static [u8] {
match self {
Self::CryptoHeader => CRYPTO_HEADER_DIGEST_DOMAIN,
Self::ManifestFooter => MANIFEST_FOOTER_DIGEST_DOMAIN,
Self::VolumeTrailer => VOLUME_TRAILER_DIGEST_DOMAIN,
Self::BootstrapSidecar => BOOTSTRAP_SIDECAR_DIGEST_DOMAIN,
}
}
}
pub fn compute_hmac(
domain: HmacDomain,
mac_key: &[u8; SUBKEY_LEN],
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
covered_bytes: &[u8],
) -> [u8; SUBKEY_LEN] {
let mut mac =
<HmacSha256 as Mac>::new_from_slice(mac_key).expect("HMAC accepts any key length");
mac.update(domain.domain_bytes());
mac.update(archive_uuid);
mac.update(session_id);
mac.update(covered_bytes);
let digest = mac.finalize().into_bytes();
let mut output = [0u8; SUBKEY_LEN];
output.copy_from_slice(&digest);
output
}
pub fn verify_hmac(
domain: HmacDomain,
mac_key: &[u8; SUBKEY_LEN],
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
covered_bytes: &[u8],
expected_hmac: &[u8],
) -> Result<(), FormatError> {
let mut mac =
<HmacSha256 as Mac>::new_from_slice(mac_key).expect("HMAC accepts any key length");
mac.update(domain.domain_bytes());
mac.update(archive_uuid);
mac.update(session_id);
mac.update(covered_bytes);
mac.verify_slice(expected_hmac)
.map_err(|_| FormatError::HmacMismatch {
structure: domain.structure_name(),
})
}
pub fn compute_integrity_tag(
domain: HmacDomain,
aead_algo: AeadAlgo,
mac_key: Option<&[u8; SUBKEY_LEN]>,
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
covered_bytes: &[u8],
) -> Result<[u8; SUBKEY_LEN], FormatError> {
if aead_algo.is_encrypted() {
return Ok(compute_hmac(
domain,
mac_key.ok_or(FormatError::KeyMaterialMismatch)?,
archive_uuid,
session_id,
covered_bytes,
));
}
let mut hasher = Sha256::new();
hasher.update(domain.digest_domain_bytes());
hasher.update(archive_uuid);
hasher.update(session_id);
hasher.update(covered_bytes);
let digest = hasher.finalize();
let mut output = [0u8; SUBKEY_LEN];
output.copy_from_slice(&digest);
Ok(output)
}
pub fn verify_integrity_tag(
domain: HmacDomain,
aead_algo: AeadAlgo,
mac_key: Option<&[u8; SUBKEY_LEN]>,
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
covered_bytes: &[u8],
expected_tag: &[u8],
) -> Result<(), FormatError> {
if aead_algo.is_encrypted() {
return verify_hmac(
domain,
mac_key.ok_or(FormatError::KeyMaterialMismatch)?,
archive_uuid,
session_id,
covered_bytes,
expected_tag,
);
}
let actual = compute_integrity_tag(
domain,
aead_algo,
None,
archive_uuid,
session_id,
covered_bytes,
)?;
if expected_tag == actual {
Ok(())
} else {
Err(FormatError::IntegrityDigestMismatch {
structure: domain.structure_name(),
})
}
}
pub fn normalize_passphrase_nfc(passphrase: &str) -> Vec<u8> {
passphrase.nfc().collect::<String>().into_bytes()
}
pub fn derive_nonce(
seed: &[u8; SUBKEY_LEN],
domain: &[u8],
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
counter: u64,
len: usize,
) -> Result<Vec<u8>, FormatError> {
let info = nonce_or_aad_info(b"tzap-v1-nonce", domain, archive_uuid, session_id, counter)?;
let hk = Hkdf::<Sha256>::from_prk(seed)
.map_err(|_| FormatError::InvalidKdfParams("bad nonce seed"))?;
let mut nonce = vec![0u8; len];
hk.expand(&info, &mut nonce)
.map_err(|_| FormatError::HkdfExpandFailure)?;
Ok(nonce)
}
pub fn build_aad(
domain: &[u8],
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
counter: u64,
) -> Result<Vec<u8>, FormatError> {
nonce_or_aad_info(b"tzap-v1-aad", domain, archive_uuid, session_id, counter)
}
pub fn aead_encrypt(
algo: AeadAlgo,
key: &[u8; SUBKEY_LEN],
nonce: &[u8],
aad: &[u8],
plaintext: &[u8],
) -> Result<Vec<u8>, FormatError> {
validate_nonce_len(algo, nonce)?;
match algo {
AeadAlgo::None => Ok(plaintext.to_vec()),
AeadAlgo::AesGcmSiv256 => {
let cipher =
Aes256GcmSiv::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
cipher
.encrypt(
aes_gcm_siv::Nonce::from_slice(nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| FormatError::AeadFailure)
}
AeadAlgo::XChaCha20Poly1305 => {
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|_| FormatError::InvalidAeadKeyLength)?;
cipher
.encrypt(
chacha20poly1305::XNonce::from_slice(nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| FormatError::AeadFailure)
}
AeadAlgo::AesGcm256 => {
let cipher =
Aes256Gcm::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
cipher
.encrypt(
aes_gcm::Nonce::from_slice(nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| FormatError::AeadFailure)
}
}
}
pub fn aead_decrypt(
algo: AeadAlgo,
key: &[u8; SUBKEY_LEN],
nonce: &[u8],
aad: &[u8],
ciphertext_and_tag: &[u8],
) -> Result<Vec<u8>, FormatError> {
validate_nonce_len(algo, nonce)?;
match algo {
AeadAlgo::None => Ok(ciphertext_and_tag.to_vec()),
AeadAlgo::AesGcmSiv256 => {
let cipher =
Aes256GcmSiv::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
cipher
.decrypt(
aes_gcm_siv::Nonce::from_slice(nonce),
Payload {
msg: ciphertext_and_tag,
aad,
},
)
.map_err(|_| FormatError::AeadFailure)
}
AeadAlgo::XChaCha20Poly1305 => {
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|_| FormatError::InvalidAeadKeyLength)?;
cipher
.decrypt(
chacha20poly1305::XNonce::from_slice(nonce),
Payload {
msg: ciphertext_and_tag,
aad,
},
)
.map_err(|_| FormatError::AeadFailure)
}
AeadAlgo::AesGcm256 => {
let cipher =
Aes256Gcm::new_from_slice(key).map_err(|_| FormatError::InvalidAeadKeyLength)?;
cipher
.decrypt(
aes_gcm::Nonce::from_slice(nonce),
Payload {
msg: ciphertext_and_tag,
aad,
},
)
.map_err(|_| FormatError::AeadFailure)
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct AeadObjectContext<'a> {
pub algo: AeadAlgo,
pub key: &'a [u8; SUBKEY_LEN],
pub nonce_seed: &'a [u8; SUBKEY_LEN],
pub domain: &'a [u8],
pub archive_uuid: &'a [u8; 16],
pub session_id: &'a [u8; 16],
pub counter: u64,
}
pub fn encrypt_padded_aead_object(
context: AeadObjectContext<'_>,
block_size: usize,
payload: &[u8],
) -> Result<Vec<u8>, FormatError> {
let nonce = derive_nonce(
context.nonce_seed,
context.domain,
context.archive_uuid,
context.session_id,
context.counter,
context.algo.nonce_len(),
)?;
let aad = build_aad(
context.domain,
context.archive_uuid,
context.session_id,
context.counter,
)?;
let padded = suffix_pad_for_aead(payload, context.algo.tag_len(), block_size)?;
aead_encrypt(context.algo, context.key, &nonce, &aad, &padded)
}
pub fn decrypt_padded_aead_object(
context: AeadObjectContext<'_>,
ciphertext_and_tag: &[u8],
) -> Result<Vec<u8>, FormatError> {
let nonce = derive_nonce(
context.nonce_seed,
context.domain,
context.archive_uuid,
context.session_id,
context.counter,
context.algo.nonce_len(),
)?;
let aad = build_aad(
context.domain,
context.archive_uuid,
context.session_id,
context.counter,
)?;
let padded = aead_decrypt(context.algo, context.key, &nonce, &aad, ciphertext_and_tag)?;
Ok(depad_suffix_padding(&padded)?.to_vec())
}
fn parse_raw_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
if bytes.len() < RAW_KDF_PARAMS_LEN {
return Err(FormatError::TruncatedKdfParams);
}
let algo_tag = read_u16(bytes, 0)?;
if algo_tag != KdfAlgo::Raw as u16 {
return Err(FormatError::KdfAlgoTagMismatch {
expected: KdfAlgo::Raw as u16,
actual: algo_tag,
});
}
Ok((KdfParams::Raw, RAW_KDF_PARAMS_LEN))
}
fn parse_none_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
if bytes.len() < NONE_KDF_PARAMS_LEN {
return Err(FormatError::TruncatedKdfParams);
}
let algo_tag = read_u16(bytes, 0)?;
if algo_tag != KdfAlgo::None as u16 {
return Err(FormatError::KdfAlgoTagMismatch {
expected: KdfAlgo::None as u16,
actual: algo_tag,
});
}
Ok((KdfParams::None, NONE_KDF_PARAMS_LEN))
}
fn parse_argon2id_kdf_params(bytes: &[u8]) -> Result<(KdfParams, usize), FormatError> {
if bytes.len() < ARGON2ID_FIXED_PARAMS_LEN {
return Err(FormatError::TruncatedKdfParams);
}
let algo_tag = read_u16(bytes, 0)?;
if algo_tag != KdfAlgo::Argon2id as u16 {
return Err(FormatError::KdfAlgoTagMismatch {
expected: KdfAlgo::Argon2id as u16,
actual: algo_tag,
});
}
let t_cost = read_u32(bytes, 2)?;
let m_cost_kib = read_u32(bytes, 6)?;
let parallelism = read_u32(bytes, 10)?;
let salt_length = read_u16(bytes, 14)?;
if !(ARGON2ID_MIN_SALT_LEN..=ARGON2ID_MAX_SALT_LEN).contains(&salt_length) {
return Err(FormatError::InvalidKdfParams(
"argon2id salt length must be 8..64 bytes",
));
}
if t_cost == 0 {
return Err(FormatError::InvalidKdfParams(
"argon2id t_cost must be non-zero",
));
}
if parallelism == 0 {
return Err(FormatError::InvalidKdfParams(
"argon2id parallelism must be non-zero",
));
}
validate_argon2id_bounds(t_cost, m_cost_kib, parallelism, salt_length)?;
let total_len = ARGON2ID_FIXED_PARAMS_LEN + salt_length as usize;
if bytes.len() < total_len {
return Err(FormatError::TruncatedKdfParams);
}
Ok((
KdfParams::Argon2id {
t_cost,
m_cost_kib,
parallelism,
salt: bytes[ARGON2ID_FIXED_PARAMS_LEN..total_len].to_vec(),
},
total_len,
))
}
fn validate_argon2id_bounds(
t_cost: u32,
m_cost_kib: u32,
parallelism: u32,
salt_length: u16,
) -> Result<(), FormatError> {
if !(ARGON2ID_MIN_SALT_LEN..=ARGON2ID_MAX_SALT_LEN).contains(&salt_length) {
return Err(FormatError::InvalidKdfParams(
"argon2id salt length must be 8..64 bytes",
));
}
if t_cost == 0 {
return Err(FormatError::InvalidKdfParams(
"argon2id t_cost must be non-zero",
));
}
if t_cost > READER_MAX_ARGON2ID_T_COST {
return Err(FormatError::ReaderResourceLimitExceeded {
field: "argon2id t_cost",
cap: READER_MAX_ARGON2ID_T_COST as u64,
actual: t_cost as u64,
});
}
if parallelism == 0 {
return Err(FormatError::InvalidKdfParams(
"argon2id parallelism must be non-zero",
));
}
if parallelism > READER_MAX_ARGON2ID_PARALLELISM {
return Err(FormatError::ReaderResourceLimitExceeded {
field: "argon2id parallelism",
cap: READER_MAX_ARGON2ID_PARALLELISM as u64,
actual: parallelism as u64,
});
}
if m_cost_kib > READER_MAX_ARGON2ID_M_COST_KIB {
return Err(FormatError::ReaderResourceLimitExceeded {
field: "argon2id m_cost_kib",
cap: READER_MAX_ARGON2ID_M_COST_KIB as u64,
actual: m_cost_kib as u64,
});
}
let min_memory = parallelism
.checked_mul(8)
.ok_or(FormatError::InvalidKdfParams(
"argon2id memory requirement overflow",
))?;
if m_cost_kib < min_memory {
return Err(FormatError::InvalidKdfParams(
"argon2id memory must be at least 8 KiB per lane",
));
}
Ok(())
}
fn expand_subkey(hk: &Hkdf<Sha256>, info: &[u8]) -> Result<[u8; SUBKEY_LEN], FormatError> {
let mut output = [0u8; SUBKEY_LEN];
hk.expand(info, &mut output)
.map_err(|_| FormatError::HkdfExpandFailure)?;
Ok(output)
}
fn nonce_or_aad_info(
prefix: &[u8],
domain: &[u8],
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
counter: u64,
) -> Result<Vec<u8>, FormatError> {
let domain_len = u16::try_from(domain.len()).map_err(|_| FormatError::DomainTooLong)?;
let mut info = Vec::with_capacity(prefix.len() + 2 + domain.len() + 16 + 16 + 8);
info.extend_from_slice(prefix);
info.extend_from_slice(&domain_len.to_le_bytes());
info.extend_from_slice(domain);
info.extend_from_slice(archive_uuid);
info.extend_from_slice(session_id);
info.extend_from_slice(&counter.to_le_bytes());
Ok(info)
}
fn validate_nonce_len(algo: AeadAlgo, nonce: &[u8]) -> Result<(), FormatError> {
let expected = algo.nonce_len();
if nonce.len() != expected {
return Err(FormatError::InvalidNonceLength {
algo,
expected,
actual: nonce.len(),
});
}
Ok(())
}
fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, FormatError> {
let array: [u8; 2] = bytes
.get(offset..offset + 2)
.ok_or(FormatError::InvalidLength {
structure: "u16",
expected: offset + 2,
actual: bytes.len(),
})?
.try_into()
.expect("slice length checked");
Ok(u16::from_le_bytes(array))
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, FormatError> {
let array: [u8; 4] = bytes
.get(offset..offset + 4)
.ok_or(FormatError::InvalidLength {
structure: "u32",
expected: offset + 4,
actual: bytes.len(),
})?
.try_into()
.expect("slice length checked");
Ok(u32::from_le_bytes(array))
}
#[cfg(test)]
mod tests {
use super::*;
fn uuid() -> [u8; 16] {
[0x11; 16]
}
fn session() -> [u8; 16] {
[0x22; 16]
}
fn legacy_nonce_info(
domain: &[u8],
archive_uuid: &[u8; 16],
session_id: &[u8; 16],
counter: u64,
) -> Vec<u8> {
let mut info = Vec::with_capacity(b"tzap-v1-nonce".len() + domain.len() + 16 + 16 + 8);
info.extend_from_slice(b"tzap-v1-nonce");
info.extend_from_slice(domain);
info.extend_from_slice(archive_uuid);
info.extend_from_slice(session_id);
info.extend_from_slice(&counter.to_le_bytes());
info
}
#[test]
fn parses_raw_kdf_params() {
let (params, consumed) = KdfParams::parse(KdfAlgo::Raw, &0u16.to_le_bytes()).unwrap();
assert_eq!(params, KdfParams::Raw);
assert_eq!(consumed, 2);
}
#[test]
fn parses_none_kdf_params() {
let (params, consumed) =
KdfParams::parse(KdfAlgo::None, &(KdfAlgo::None as u16).to_le_bytes()).unwrap();
assert_eq!(params, KdfParams::None);
assert_eq!(consumed, 2);
assert_eq!(
KdfParams::parse(KdfAlgo::None, &(KdfAlgo::Raw as u16).to_le_bytes()).unwrap_err(),
FormatError::KdfAlgoTagMismatch {
expected: KdfAlgo::None as u16,
actual: KdfAlgo::Raw as u16,
}
);
}
#[test]
fn parses_argon2id_kdf_params() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&8u32.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&8u16.to_le_bytes());
bytes.extend_from_slice(b"12345678");
let (params, consumed) = KdfParams::parse(KdfAlgo::Argon2id, &bytes).unwrap();
assert_eq!(consumed, 24);
assert_eq!(
params,
KdfParams::Argon2id {
t_cost: 1,
m_cost_kib: 8,
parallelism: 1,
salt: b"12345678".to_vec()
}
);
}
#[test]
fn rejects_argon2id_params_above_reader_caps() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
bytes.extend_from_slice(&(READER_MAX_ARGON2ID_T_COST + 1).to_le_bytes());
bytes.extend_from_slice(&8u32.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&8u16.to_le_bytes());
bytes.extend_from_slice(b"12345678");
assert_eq!(
KdfParams::parse(KdfAlgo::Argon2id, &bytes).unwrap_err(),
FormatError::ReaderResourceLimitExceeded {
field: "argon2id t_cost",
cap: READER_MAX_ARGON2ID_T_COST as u64,
actual: (READER_MAX_ARGON2ID_T_COST + 1) as u64,
}
);
let err = MasterKey::derive_from_passphrase(
&KdfParams::Argon2id {
t_cost: 1,
m_cost_kib: READER_MAX_ARGON2ID_M_COST_KIB + 1,
parallelism: 1,
salt: b"12345678".to_vec(),
},
"passphrase",
)
.unwrap_err();
assert_eq!(
err,
FormatError::ReaderResourceLimitExceeded {
field: "argon2id m_cost_kib",
cap: READER_MAX_ARGON2ID_M_COST_KIB as u64,
actual: (READER_MAX_ARGON2ID_M_COST_KIB + 1) as u64,
}
);
}
#[test]
fn rejects_argon2id_salt_bounds_and_raw_kdf_truncation() {
fn argon_bytes(salt_len: u16, actual_salt: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(KdfAlgo::Argon2id as u16).to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&8u32.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&salt_len.to_le_bytes());
bytes.extend_from_slice(actual_salt);
bytes
}
assert_eq!(
KdfParams::parse(KdfAlgo::Raw, &[]).unwrap_err(),
FormatError::TruncatedKdfParams
);
assert_eq!(
KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(7, b"1234567")).unwrap_err(),
FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
);
assert!(matches!(
KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(8, b"12345678")).unwrap(),
(KdfParams::Argon2id { .. }, 24)
));
assert!(matches!(
KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(64, &[0x5a; 64])).unwrap(),
(KdfParams::Argon2id { .. }, 80)
));
assert_eq!(
KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(65, &[0x5a; 65])).unwrap_err(),
FormatError::InvalidKdfParams("argon2id salt length must be 8..64 bytes")
);
assert_eq!(
KdfParams::parse(KdfAlgo::Argon2id, &argon_bytes(64, &[0x5a; 63])).unwrap_err(),
FormatError::TruncatedKdfParams
);
}
#[test]
fn rejects_kdf_algo_tag_mismatch() {
assert_eq!(
KdfParams::parse(KdfAlgo::Raw, &(KdfAlgo::Argon2id as u16).to_le_bytes()).unwrap_err(),
FormatError::KdfAlgoTagMismatch {
expected: 0,
actual: 1
}
);
}
#[test]
fn passphrase_normalization_preserves_archive_semantics() {
assert_eq!(normalize_passphrase_nfc("e\u{301}\n\0"), "é\n\0".as_bytes());
}
#[test]
fn argon2id_passphrase_edge_vectors_are_literal() {
let params = KdfParams::Argon2id {
t_cost: 1,
m_cost_kib: 8,
parallelism: 1,
salt: b"12345678".to_vec(),
};
let cases = [
(
"trailing newline",
"pass\n",
"f63027356e6da90a4f6c81af70b9e6f1b1967ab684ecda8257cb7d21de760623",
),
(
"embedded nul",
"pass\0word",
"23db596ddbaa8f3f36d653f456dd9819e342aad4e30224008a22f1fb7648780e",
),
(
"leading bom",
"\u{feff}pass",
"d493645da269dce9b0ab6d39367d94c1896b0f4a2c3ca486c775d7275b8558da",
),
];
for (name, passphrase, expected_hex) in cases {
let master = MasterKey::derive_from_passphrase(¶ms, passphrase).unwrap();
assert_eq!(hex::encode(master.0), expected_hex, "{name}");
}
let without_newline = MasterKey::derive_from_passphrase(¶ms, "pass").unwrap();
let with_newline = MasterKey::derive_from_passphrase(¶ms, "pass\n").unwrap();
assert_ne!(without_newline, with_newline);
}
#[test]
fn argon2id_profile_rejects_alternate_version_vector() {
let params = KdfParams::Argon2id {
t_cost: 1,
m_cost_kib: 8,
parallelism: 1,
salt: b"12345678".to_vec(),
};
let current = MasterKey::derive_from_passphrase(¶ms, "e\u{301}").unwrap();
let argon_params = Params::new(8, 1, 1, Some(MASTER_KEY_LEN)).unwrap();
let old_argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x10, argon_params);
let mut old_output = [0u8; MASTER_KEY_LEN];
let passphrase = normalize_passphrase_nfc("e\u{301}");
old_argon2
.hash_password_into(&passphrase, b"12345678", &mut old_output)
.unwrap();
assert_eq!(
hex::encode(current.0),
"24709642204c04bf88fb36550c478769eb10a0400c0493c9695d30fbf7082241"
);
assert_ne!(old_output, current.0);
}
#[test]
fn derives_argon2id_master_key_from_nfc_passphrase() {
let params = KdfParams::Argon2id {
t_cost: 1,
m_cost_kib: 8,
parallelism: 1,
salt: b"12345678".to_vec(),
};
let one = MasterKey::derive_from_passphrase(¶ms, "e\u{301}").unwrap();
let two = MasterKey::derive_from_passphrase(¶ms, "é").unwrap();
assert_eq!(one.0, two.0);
assert_ne!(one.0, [0u8; MASTER_KEY_LEN]);
}
#[test]
fn derives_stable_distinct_subkeys() {
let master = MasterKey::from_raw_key(&[0x33; MASTER_KEY_LEN]).unwrap();
let subkeys = Subkeys::derive(&master, &uuid(), &session()).unwrap();
assert_ne!(subkeys.enc_key, subkeys.mac_key);
assert_ne!(subkeys.index_root_key, subkeys.index_shard_key);
let repeat = Subkeys::derive(&master, &uuid(), &session()).unwrap();
assert_eq!(subkeys, repeat);
}
#[test]
fn hkdf_passphrase_and_identity_vectors_are_literal() {
let params = KdfParams::Argon2id {
t_cost: 1,
m_cost_kib: 8,
parallelism: 1,
salt: b"saltsalt".to_vec(),
};
let archive_uuid = core::array::from_fn::<_, 16, _>(|idx| 0x30 + idx as u8);
let session_id = core::array::from_fn::<_, 16, _>(|idx| 0xc0 + idx as u8);
let master = MasterKey::derive_from_passphrase(¶ms, "correct horse\n").unwrap();
let subkeys = Subkeys::derive(&master, &archive_uuid, &session_id).unwrap();
assert_eq!(
hex::encode(master.0),
"c58d65c836c8a590c0d34fcc0907d876e969d72c51a267cad2518cfee8eb2a21"
);
assert_eq!(
hex::encode(subkeys.enc_key),
"786001f513f99062c7c7ef72c978847a7c2daa452f363177839ce2ed3ecfd5df"
);
assert_eq!(
hex::encode(subkeys.mac_key),
"024f2737f6db8aa03d3ce241d25c26fcc18bbcf4af242614c3d703224cd82b74"
);
assert_eq!(
hex::encode(subkeys.index_nonce_seed),
"5d51a19bf7f6d77ce7945517ce95837a089f8d1cd20aea43cbcb8d745c0668ee"
);
let different_session = Subkeys::derive(&master, &archive_uuid, &[0xc1; 16]).unwrap();
let different_archive = Subkeys::derive(&master, &[0x31; 16], &session_id).unwrap();
assert_ne!(subkeys.enc_key, different_session.enc_key);
assert_ne!(subkeys.enc_key, different_archive.enc_key);
}
#[test]
fn computes_and_verifies_hmac_domains() {
let key = [0x44; SUBKEY_LEN];
let covered = b"covered bytes";
let tag = compute_hmac(HmacDomain::CryptoHeader, &key, &uuid(), &session(), covered);
verify_hmac(
HmacDomain::CryptoHeader,
&key,
&uuid(),
&session(),
covered,
&tag,
)
.unwrap();
assert_eq!(
verify_hmac(
HmacDomain::ManifestFooter,
&key,
&uuid(),
&session(),
covered,
&tag,
)
.unwrap_err(),
FormatError::HmacMismatch {
structure: "ManifestFooter"
}
);
}
#[test]
fn computes_and_verifies_unkeyed_integrity_domains() {
let covered = b"covered bytes";
let tag = compute_integrity_tag(
HmacDomain::CryptoHeader,
AeadAlgo::None,
None,
&uuid(),
&session(),
covered,
)
.unwrap();
verify_integrity_tag(
HmacDomain::CryptoHeader,
AeadAlgo::None,
None,
&uuid(),
&session(),
covered,
&tag,
)
.unwrap();
assert_eq!(
verify_integrity_tag(
HmacDomain::ManifestFooter,
AeadAlgo::None,
None,
&uuid(),
&session(),
covered,
&tag,
)
.unwrap_err(),
FormatError::IntegrityDigestMismatch {
structure: "ManifestFooter"
}
);
assert_ne!(
tag,
compute_integrity_tag(
HmacDomain::ManifestFooter,
AeadAlgo::None,
None,
&uuid(),
&session(),
covered,
)
.unwrap()
);
}
#[test]
fn hmac_sidecar_domain_vector_and_boundary_bytes_are_literal() {
let key = [0x44; SUBKEY_LEN];
let covered = b"covered bytes";
let tag = compute_hmac(
HmacDomain::BootstrapSidecar,
&key,
&uuid(),
&session(),
covered,
);
assert_eq!(
hex::encode(tag),
"1ecc9e0c5c9079b6824e16c4468ac9df22ca50fa2a924d21a91aab33c3721d51"
);
verify_hmac(
HmacDomain::BootstrapSidecar,
&key,
&uuid(),
&session(),
covered,
&tag,
)
.unwrap();
for mutate_index in [0, covered.len() - 1] {
let mut mutated = covered.to_vec();
mutated[mutate_index] ^= 0x01;
assert_eq!(
verify_hmac(
HmacDomain::BootstrapSidecar,
&key,
&uuid(),
&session(),
&mutated,
&tag,
)
.unwrap_err(),
FormatError::HmacMismatch {
structure: "BootstrapSidecarHeader"
}
);
}
for mutate_index in [0, tag.len() - 1] {
let mut mutated_tag = tag;
mutated_tag[mutate_index] ^= 0x01;
assert_eq!(
verify_hmac(
HmacDomain::BootstrapSidecar,
&key,
&uuid(),
&session(),
covered,
&mutated_tag,
)
.unwrap_err(),
FormatError::HmacMismatch {
structure: "BootstrapSidecarHeader"
}
);
}
}
#[test]
fn derives_nonce_and_aad_with_domain_separation() {
let seed = [0x55; SUBKEY_LEN];
let nonce = derive_nonce(&seed, b"envelope", &uuid(), &session(), 7, 12).unwrap();
let other = derive_nonce(&seed, b"idxroot", &uuid(), &session(), 7, 12).unwrap();
assert_eq!(nonce.len(), 12);
assert_ne!(nonce, other);
let aad = build_aad(b"envelope", &uuid(), &session(), 7).unwrap();
assert!(aad.starts_with(b"tzap-v1-aad"));
assert_ne!(aad, nonce);
}
#[test]
fn rejects_old_nonce_info_without_domain_length() {
let key = [0x66; SUBKEY_LEN];
let nonce_seed = [0x77; SUBKEY_LEN];
let uuid = uuid();
let session = session();
let counter = 7u64;
let domain = b"idxroot";
let ciphertext = encrypt_padded_aead_object(
AeadObjectContext {
algo: AeadAlgo::AesGcmSiv256,
key: &key,
nonce_seed: &nonce_seed,
domain,
archive_uuid: &uuid,
session_id: &session,
counter,
},
4096,
b"index-root",
)
.unwrap();
let mut legacy_nonce = vec![0u8; AeadAlgo::AesGcmSiv256.nonce_len()];
Hkdf::<Sha256>::from_prk(&nonce_seed)
.unwrap()
.expand(
&legacy_nonce_info(domain, &uuid, &session, counter),
&mut legacy_nonce,
)
.unwrap();
let aad = build_aad(domain, &uuid, &session, counter).unwrap();
assert_ne!(
legacy_nonce,
derive_nonce(
&nonce_seed,
domain,
&uuid,
&session,
counter,
AeadAlgo::AesGcmSiv256.nonce_len()
)
.unwrap(),
"legacy nonce info encoding must differ from current encoding"
);
assert_eq!(
aead_decrypt(
AeadAlgo::AesGcmSiv256,
&key,
&legacy_nonce,
&aad,
&ciphertext,
)
.unwrap_err(),
FormatError::AeadFailure
);
}
#[test]
fn aead_round_trips_all_registered_algorithms() {
for algo in [
AeadAlgo::AesGcmSiv256,
AeadAlgo::XChaCha20Poly1305,
AeadAlgo::AesGcm256,
] {
let key = [0x66; SUBKEY_LEN];
let nonce = derive_nonce(
&[0x77; SUBKEY_LEN],
b"envelope",
&uuid(),
&session(),
0,
algo.nonce_len(),
)
.unwrap();
let aad = build_aad(b"envelope", &uuid(), &session(), 0).unwrap();
let ciphertext = aead_encrypt(algo, &key, &nonce, &aad, b"plaintext").unwrap();
assert_ne!(ciphertext, b"plaintext");
let plaintext = aead_decrypt(algo, &key, &nonce, &aad, &ciphertext).unwrap();
assert_eq!(plaintext, b"plaintext");
let mut tampered = ciphertext;
tampered[0] ^= 1;
assert_eq!(
aead_decrypt(algo, &key, &nonce, &aad, &tampered).unwrap_err(),
FormatError::AeadFailure
);
}
}
#[test]
fn aead_none_passes_plaintext_through() {
let ciphertext =
aead_encrypt(AeadAlgo::None, &[0; SUBKEY_LEN], &[], b"aad", b"plaintext").unwrap();
assert_eq!(ciphertext, b"plaintext");
assert_eq!(
aead_decrypt(AeadAlgo::None, &[0; SUBKEY_LEN], &[], b"aad", &ciphertext).unwrap(),
b"plaintext"
);
assert_eq!(AeadAlgo::None.nonce_len(), 0);
assert_eq!(AeadAlgo::None.tag_len(), 0);
}
#[test]
fn aead_rejects_wrong_nonce_length() {
assert_eq!(
aead_encrypt(AeadAlgo::AesGcmSiv256, &[0; SUBKEY_LEN], &[0; 11], b"", b"").unwrap_err(),
FormatError::InvalidNonceLength {
algo: AeadAlgo::AesGcmSiv256,
expected: 12,
actual: 11
}
);
}
#[test]
fn padded_aead_object_round_trips_with_derived_nonce_and_aad() {
let key = [0x66; SUBKEY_LEN];
let nonce_seed = [0x77; SUBKEY_LEN];
let uuid = uuid();
let session = session();
let context = AeadObjectContext {
algo: AeadAlgo::AesGcmSiv256,
key: &key,
nonce_seed: &nonce_seed,
domain: b"envelope",
archive_uuid: &uuid,
session_id: &session,
counter: 3,
};
let ciphertext = encrypt_padded_aead_object(context, 4096, b"packed frames").unwrap();
assert_eq!(ciphertext.len() % 4096, 0);
let plaintext = decrypt_padded_aead_object(context, &ciphertext).unwrap();
assert_eq!(plaintext, b"packed frames");
assert_eq!(
decrypt_padded_aead_object(
AeadObjectContext {
domain: b"idxroot",
..context
},
&ciphertext,
)
.unwrap_err(),
FormatError::AeadFailure
);
}
#[test]
fn rejects_index_root_aad_counter_mismatch() {
let key = [0x99; SUBKEY_LEN];
let nonce_seed = [0x88; SUBKEY_LEN];
let uuid = uuid();
let session = session();
let ciphertext = encrypt_padded_aead_object(
AeadObjectContext {
algo: AeadAlgo::AesGcmSiv256,
key: &key,
nonce_seed: &nonce_seed,
domain: b"idxroot",
archive_uuid: &uuid,
session_id: &session,
counter: 0,
},
4096,
b"index-root-meta",
)
.unwrap();
let nonce = derive_nonce(
&nonce_seed,
b"idxroot",
&uuid,
&session,
0,
AeadAlgo::AesGcmSiv256.nonce_len(),
)
.unwrap();
let mismatched_aad = build_aad(b"idxroot", &uuid, &session, 1).unwrap();
assert_eq!(
aead_decrypt(
AeadAlgo::AesGcmSiv256,
&key,
&nonce,
&mismatched_aad,
&ciphertext,
)
.unwrap_err(),
FormatError::AeadFailure
);
}
}