use rayon::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use std::io;
use std::io::{Cursor, Read, Write};
use chacha20poly1305::{
aead::{Aead, AeadInOut, KeyInit, Payload, Tag},
ChaCha20Poly1305, Key, Nonce,
};
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret};
use zeroize::Zeroizing;
use crate::error::PqfileError;
use crate::format::{
chunk_nonce, commitment_for_stream, commitment_for_v10, ct_len_for_variant, fill_chunk,
hybrid_hkdf, make_chunk_aad, version_layout, PqfHeader, PqfHeaderV4, PqfHeaderV7, PqfHeaderV8,
RecipientEntryV8, BASE_NONCE_LEN, CHUNK_SIZE, COMPRESSION_NONE, COMPRESSION_ZSTD,
HYBRID_CT_LEN_768, HYBRID_SEED_LEN_768, KEM_CT_LEN_1024, KEM_CT_LEN_512, KEM_CT_LEN_768,
KEM_VARIANT_1024, KEM_VARIANT_512, KEM_VARIANT_768, KEM_VARIANT_HYBRID_768, NONCE_LEN, VERSION,
VERSION_AUTH_BIT, VERSION_V10, VERSION_V3, VERSION_V4, VERSION_V5, VERSION_V6, VERSION_V7,
VERSION_V8, VERSION_V9, WRAPPED_KEY_LEN,
};
use crate::hardware;
use crate::kem_backend::{ActiveKemBackend, KemBackend, KemSize};
use crate::keygen::{
PRIV_ENC_TAG, PRIV_ENC_TAG_1024, PRIV_ENC_TAG_512, PRIV_ENC_TAG_HYBRID_768, PRIV_TAG,
PRIV_TAG_1024, PRIV_TAG_512, PRIV_TAG_HYBRID_768,
};
use crate::passphrase;
use crate::secret::LockedSecret;
enum DkVariant {
Kem512(LockedSecret<64>),
Kem768(LockedSecret<64>),
Kem1024(LockedSecret<64>),
HybridKem768 {
x25519_sk: X25519StaticSecret,
ml_seed: LockedSecret<64>,
},
}
impl DkVariant {
fn kem_variant(&self) -> u16 {
match self {
DkVariant::Kem512(_) => KEM_VARIANT_512,
DkVariant::Kem768(_) => KEM_VARIANT_768,
DkVariant::Kem1024(_) => KEM_VARIANT_1024,
DkVariant::HybridKem768 { .. } => KEM_VARIANT_HYBRID_768,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) struct LimitedWriter<'a> {
inner: &'a mut dyn Write,
remaining: u64,
}
#[cfg(not(target_arch = "wasm32"))]
impl<'a> LimitedWriter<'a> {
fn new(inner: &'a mut dyn Write, limit: u64) -> Self {
Self {
inner,
remaining: limit,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl<'a> Write for LimitedWriter<'a> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
if (buf.len() as u64) > self.remaining {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"decompressed data exceeds declared original_size limit",
));
}
let n = self.inner.write(buf)?;
self.remaining -= n as u64;
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
pub(crate) struct StreamDecryptState {
pub version: u8,
pub kem_variant: u16,
pub original_size: u64,
pub chunk_size: usize,
pub cipher: ChaCha20Poly1305,
pub nonce: [u8; NONCE_LEN],
pub v2_plaintext: Option<Zeroizing<Vec<u8>>>,
pub key_commitment: [u8; 32],
}
#[must_use = "decryption result must be used"]
pub fn decrypt_bytes(
privkey_pem: &str,
pqf_data: &[u8],
passphrase: Option<&str>,
) -> Result<Vec<u8>, PqfileError> {
let dk = derive_dk(privkey_pem, passphrase)?;
let mut cursor = Cursor::new(pqf_data);
let header = PqfHeader::read(&mut cursor)?;
if header.layout() == VERSION_V3 || header.layout() == VERSION_V5 {
return Err(PqfileError::UnsupportedVersion(header.version));
}
check_kem_variant_match(dk.kem_variant(), header.kem_variant)?;
let ss_bytes = decapsulate_shared_secret(&dk, &header.kem_ciphertext)?;
let header_len = header.header_len();
let header_bytes = &pqf_data[..header_len];
let payload = &pqf_data[header_len..];
if payload.len() < 16 {
return Err(PqfileError::DecryptionFailure);
}
let key: &Key = ss_bytes.as_ref().try_into().expect("32-byte key");
let nonce: &Nonce = header.nonce.as_slice().try_into().expect("12-byte nonce");
let cipher = ChaCha20Poly1305::new(key);
cipher
.decrypt(
nonce,
Payload {
msg: payload,
aad: header_bytes,
},
)
.map_err(|_| PqfileError::DecryptionFailure)
}
fn init_stream_decrypt(
privkey_pem: &str,
passphrase: Option<&str>,
reader: &mut dyn Read,
) -> Result<(DkVariant, u8), PqfileError> {
let dk = derive_dk(privkey_pem, passphrase)?;
let version = PqfHeader::read_magic_version(reader)?;
if version_layout(version) == VERSION && version != VERSION {
return Err(PqfileError::UnsupportedVersion(version));
}
Ok((dk, version))
}
fn read_single_recipient_crypto(
reader: &mut dyn Read,
dk: &DkVariant,
version: u8,
) -> Result<(PqfHeader, ChaCha20Poly1305, [u8; 32]), PqfileError> {
let header = PqfHeader::read_body(reader, version)?;
check_kem_variant_match(dk.kem_variant(), header.kem_variant)?;
let ss_bytes = decapsulate_shared_secret(dk, &header.kem_ciphertext)?;
let key: &Key = ss_bytes.as_ref().try_into().expect("32-byte key");
let cipher = ChaCha20Poly1305::new(key);
let key_commitment = commitment_for_stream(
ss_bytes.as_ref(),
version,
&header.nonce,
header.original_size,
header.chunk_size,
header.compression_algo,
);
Ok((header, cipher, key_commitment))
}
struct MultiRecipientSession {
session_key: LockedSecret<32>,
nonce: [u8; NONCE_LEN],
original_size: u64,
}
fn read_multi_recipient_session(
reader: &mut dyn Read,
dk: &DkVariant,
version: u8,
) -> Result<MultiRecipientSession, PqfileError> {
let (session_key, nonce, original_size) = match version_layout(version) {
VERSION_V4 => {
let header = PqfHeaderV4::read_body(reader)?;
let entries: Vec<(u16, &[u8], &[u8; WRAPPED_KEY_LEN])> = header
.recipients
.iter()
.map(|e| (e.kem_variant, e.kem_ciphertext.as_slice(), &e.wrapped_key))
.collect();
let session_key = find_session_key(dk, &entries)?;
(session_key, header.nonce, header.original_size)
}
VERSION_V7 => {
let header = PqfHeaderV7::read_body(reader)?;
let entries: Vec<(u16, &[u8], &[u8; WRAPPED_KEY_LEN])> = header
.recipients
.iter()
.map(|e| (e.kem_variant, e.kem_ciphertext.as_slice(), &e.wrapped_key))
.collect();
let session_key = find_session_key(dk, &entries)?;
(session_key, header.nonce, header.original_size)
}
VERSION_V8 | VERSION_V9 => {
let header = PqfHeaderV8::read_body(reader)?;
let session_key = find_session_key_v8(dk, &header.recipients)?;
(session_key, header.nonce, header.original_size)
}
_ => return Err(PqfileError::UnsupportedVersion(version)),
};
Ok(MultiRecipientSession {
session_key,
nonce,
original_size,
})
}
fn session_stream_crypto(
session_key: &[u8; 32],
version: u8,
nonce: &[u8; NONCE_LEN],
original_size: u64,
) -> (ChaCha20Poly1305, [u8; 32]) {
let key_commitment = commitment_for_stream(
session_key,
version,
nonce,
original_size,
CHUNK_SIZE as u32,
COMPRESSION_NONE,
);
let key: &Key = session_key.as_slice().try_into().expect("32-byte key");
(ChaCha20Poly1305::new(key), key_commitment)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream(
privkey_pem: &str,
reader: &mut dyn Read,
writer: &mut dyn Write,
passphrase: Option<&str>,
) -> Result<(), PqfileError> {
let (dk, version) = init_stream_decrypt(privkey_pem, passphrase, reader)?;
match version_layout(version) {
VERSION | VERSION_V3 | VERSION_V5 => {
let (header, cipher, key_commitment) =
read_single_recipient_crypto(reader, &dk, version)?;
match version {
VERSION => {
let mut header_bytes = Vec::with_capacity(header.header_len());
header
.write(&mut header_bytes)
.expect("write to Vec<u8> is infallible");
decrypt_v2_payload(&cipher, &header.nonce, &header_bytes, reader, writer)
}
_ => decrypt_v3_chunks(
&cipher,
&header.nonce,
header.chunk_size as usize,
&key_commitment,
reader,
writer,
),
}
}
VERSION_V4 | VERSION_V7 | VERSION_V8 | VERSION_V9 => {
let session = read_multi_recipient_session(reader, &dk, version)?;
let (cipher, key_commitment) = session_stream_crypto(
&session.session_key,
version,
&session.nonce,
session.original_size,
);
decrypt_v3_chunks(
&cipher,
&session.nonce,
CHUNK_SIZE,
&key_commitment,
reader,
writer,
)
}
VERSION_V6 => {
let (header, cipher, key_commitment) =
read_single_recipient_crypto(reader, &dk, version)?;
match header.compression_algo {
COMPRESSION_NONE => {
decrypt_v3_chunks(
&cipher,
&header.nonce,
header.chunk_size as usize,
&key_commitment,
reader,
writer,
)
}
COMPRESSION_ZSTD => {
#[cfg(not(target_arch = "wasm32"))]
{
let limit = if header.original_size > 0 {
header.original_size
} else {
crate::format::MAX_ORIGINAL_SIZE
};
let mut limited = LimitedWriter::new(writer, limit);
let mut decoder = zstd::stream::write::Decoder::new(&mut limited)
.map_err(PqfileError::Io)?;
decrypt_v3_chunks(
&cipher,
&header.nonce,
header.chunk_size as usize,
&key_commitment,
reader,
&mut decoder,
)?;
decoder.flush().map_err(PqfileError::Io)
}
#[cfg(target_arch = "wasm32")]
{
let _ = (reader, writer);
Err(PqfileError::CompressionNotSupported)
}
}
_ => Err(PqfileError::CompressionNotSupported),
}
}
VERSION_V10 => {
let _ = (privkey_pem, reader, writer, passphrase);
Err(PqfileError::UnsupportedVersion(version))
}
_ => Err(PqfileError::UnsupportedVersion(version)),
}
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_stealth(
privkey_pem: &str,
reader: &mut dyn Read,
writer: &mut dyn Write,
passphrase: Option<&str>,
) -> Result<(), PqfileError> {
let dk = derive_dk(privkey_pem, passphrase)?;
let ct_len = ct_len_for_variant(dk.kem_variant())?;
let mut kem_ct = vec![0u8; ct_len];
reader.read_exact(&mut kem_ct)?;
let mut base_nonce = [0u8; BASE_NONCE_LEN];
reader.read_exact(&mut base_nonce)?;
let mut size_bytes = [0u8; 8];
reader.read_exact(&mut size_bytes)?;
let original_size = u64::from_le_bytes(size_bytes);
if original_size > crate::format::MAX_ORIGINAL_SIZE {
return Err(PqfileError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"original_size {original_size} exceeds maximum ({})",
crate::format::MAX_ORIGINAL_SIZE
),
)));
}
let ss_bytes = decapsulate_shared_secret(&dk, &kem_ct)?;
let mut nonce = [0u8; NONCE_LEN];
nonce[..BASE_NONCE_LEN].copy_from_slice(&base_nonce);
let key_commitment = commitment_for_stream(
ss_bytes.as_ref(),
VERSION_V3 | VERSION_AUTH_BIT,
&nonce,
original_size,
CHUNK_SIZE as u32,
COMPRESSION_NONE,
);
let key: &Key = ss_bytes.as_ref().try_into().expect("32-byte key");
let cipher = ChaCha20Poly1305::new(key);
let mut truncated = crate::padding::TruncatingWriter::new(writer, original_size);
decrypt_v3_chunks(
&cipher,
&nonce,
CHUNK_SIZE,
&key_commitment,
reader,
&mut truncated,
)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_passphrase(
passphrase: &str,
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
decrypt_stream_passphrase_with_limits(
passphrase,
crate::passphrase::ARGON2_M_COST,
crate::passphrase::ARGON2_T_COST,
reader,
writer,
)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_passphrase_with_limits(
passphrase: &str,
max_m_kib: u32,
max_t: u32,
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
decrypt_stream_passphrase_inner(
passphrase,
SecondFactor::None,
max_m_kib,
max_t,
reader,
writer,
)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_passphrase_keyfile(
passphrase: &str,
keyfile: &[u8],
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
decrypt_stream_passphrase_keyfile_with_limits(
passphrase,
keyfile,
crate::passphrase::ARGON2_M_COST,
crate::passphrase::ARGON2_T_COST,
reader,
writer,
)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_passphrase_keyfile_with_limits(
passphrase: &str,
keyfile: &[u8],
max_m_kib: u32,
max_t: u32,
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
decrypt_stream_passphrase_inner(
passphrase,
SecondFactor::Keyfile(keyfile),
max_m_kib,
max_t,
reader,
writer,
)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_passphrase_fido2(
passphrase: &str,
hmac_secret: &[u8; 32],
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
decrypt_stream_passphrase_fido2_with_limits(
passphrase,
hmac_secret,
crate::passphrase::ARGON2_M_COST,
crate::passphrase::ARGON2_T_COST,
reader,
writer,
)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_passphrase_fido2_with_limits(
passphrase: &str,
hmac_secret: &[u8; 32],
max_m_kib: u32,
max_t: u32,
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
decrypt_stream_passphrase_inner(
passphrase,
SecondFactor::Fido2(hmac_secret),
max_m_kib,
max_t,
reader,
writer,
)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_passphrase_webauthn_prf(
passphrase: &str,
prf_output: &[u8; 32],
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
decrypt_stream_passphrase_webauthn_prf_with_limits(
passphrase,
prf_output,
crate::passphrase::ARGON2_M_COST,
crate::passphrase::ARGON2_T_COST,
reader,
writer,
)
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_passphrase_webauthn_prf_with_limits(
passphrase: &str,
prf_output: &[u8; 32],
max_m_kib: u32,
max_t: u32,
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
decrypt_stream_passphrase_inner(
passphrase,
SecondFactor::WebauthnPrf(prf_output),
max_m_kib,
max_t,
reader,
writer,
)
}
enum SecondFactor<'a> {
None,
Keyfile(&'a [u8]),
Fido2(&'a [u8; 32]),
WebauthnPrf(&'a [u8; 32]),
}
enum RequiredFactor {
None,
Keyfile,
Fido2,
WebauthnPrf,
}
fn required_factor(flags: u8) -> RequiredFactor {
use crate::format::{V10_FLAG_FIDO2, V10_FLAG_KEYFILE, V10_FLAG_WEBAUTHN_PRF};
if flags & V10_FLAG_KEYFILE != 0 {
RequiredFactor::Keyfile
} else if flags & V10_FLAG_FIDO2 != 0 {
RequiredFactor::Fido2
} else if flags & V10_FLAG_WEBAUTHN_PRF != 0 {
RequiredFactor::WebauthnPrf
} else {
RequiredFactor::None
}
}
fn decrypt_stream_passphrase_inner(
passphrase: &str,
second_factor: SecondFactor,
max_m_kib: u32,
max_t: u32,
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
use crate::format::PqfHeaderV10;
use crate::passphrase::{
derive_key_with_params_and_secret, fido2_secret, keyfile_secret, webauthn_prf_secret,
};
let version = PqfHeader::read_magic_version(reader)?;
if version_layout(version) != VERSION_V10 {
return Err(PqfileError::UnsupportedVersion(version));
}
let header = PqfHeaderV10::read_body(reader)?;
let secret = match (required_factor(header.flags), second_factor) {
(RequiredFactor::Keyfile, SecondFactor::Keyfile(kf)) => Some(keyfile_secret(kf)),
(RequiredFactor::Keyfile, _) => return Err(PqfileError::KeyfileRequired),
(RequiredFactor::Fido2, SecondFactor::Fido2(hs)) => Some(fido2_secret(hs)),
(RequiredFactor::Fido2, _) => return Err(PqfileError::Fido2Required),
(RequiredFactor::WebauthnPrf, SecondFactor::WebauthnPrf(p)) => Some(webauthn_prf_secret(p)),
(RequiredFactor::WebauthnPrf, _) => return Err(PqfileError::WebauthnPrfRequired),
(RequiredFactor::None, SecondFactor::None) => None,
(RequiredFactor::None, SecondFactor::Keyfile(_)) => {
return Err(PqfileError::KeyfileNotRequired)
}
(RequiredFactor::None, SecondFactor::Fido2(_)) => {
return Err(PqfileError::Fido2NotRequired)
}
(RequiredFactor::None, SecondFactor::WebauthnPrf(_)) => {
return Err(PqfileError::WebauthnPrfNotRequired)
}
};
if header.m_kib > max_m_kib || header.t_cost > max_t {
return Err(PqfileError::KdfLimitExceeded {
m_kib: header.m_kib,
t: header.t_cost,
max_m_kib,
max_t,
});
}
let session_key = derive_key_with_params_and_secret(
passphrase,
secret.as_deref().map(|s| s.as_slice()),
&header.salt,
header.m_kib,
header.t_cost,
header.p_cost,
)?;
let key_commitment = commitment_for_v10(session_key.as_ref(), version, &header);
let key: &Key = session_key.as_ref().try_into().expect("32-byte key");
let cipher = ChaCha20Poly1305::new(key);
decrypt_v3_chunks(
&cipher,
&header.nonce,
CHUNK_SIZE,
&key_commitment,
reader,
writer,
)
}
pub fn decrypt_stream_with_progress(
privkey_pem: &str,
reader: &mut dyn Read,
writer: &mut dyn Write,
passphrase: Option<&str>,
total_hint: u64,
progress: &dyn Fn(u64, u64),
) -> Result<(), PqfileError> {
let mut tracked = crate::progress::ProgressWriter::new(writer, total_hint, progress);
decrypt_stream(privkey_pem, reader, &mut tracked, passphrase)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn decrypt_stream_parallel_with_progress(
privkey_pem: &str,
reader: &mut dyn Read,
writer: &mut dyn Write,
passphrase: Option<&str>,
batch_size: usize,
total_hint: u64,
progress: &dyn Fn(u64, u64),
) -> Result<(), PqfileError> {
let mut tracked = crate::progress::ProgressWriter::new(writer, total_hint, progress);
decrypt_stream_parallel(privkey_pem, reader, &mut tracked, passphrase, batch_size)
}
fn find_session_key(
dk: &DkVariant,
entries: &[(u16, &[u8], &[u8; WRAPPED_KEY_LEN])],
) -> Result<LockedSecret<32>, PqfileError> {
let dk_variant = dk.kem_variant();
let mut found: Option<LockedSecret<32>> = None;
let mut slots_tried: usize = 0;
for (kem_variant, kem_ciphertext, wrapped_key) in entries {
if *kem_variant != dk_variant {
continue;
}
slots_tried += 1;
let ss = decapsulate_shared_secret(dk, kem_ciphertext)?;
if let Ok(k) = unwrap_session_key(wrapped_key, &ss) {
if found.is_none() {
found = Some(k);
}
}
}
found.ok_or(PqfileError::NoMatchingRecipient { slots_tried })
}
fn find_session_key_v8(
dk: &DkVariant,
entries: &[RecipientEntryV8],
) -> Result<LockedSecret<32>, PqfileError> {
let ct_len = ct_len_for_variant(dk.kem_variant())?;
let mut found: Option<LockedSecret<32>> = None;
for entry in entries {
let kem_ct = &entry.padded_ct[..ct_len];
let ss = decapsulate_shared_secret(dk, kem_ct)?;
if let Ok(k) = unwrap_session_key(&entry.wrapped_key, &ss) {
if found.is_none() {
found = Some(k);
}
}
}
found.ok_or(PqfileError::NoMatchingRecipient {
slots_tried: entries.len(),
})
}
fn unwrap_session_key(
wrapped: &[u8; WRAPPED_KEY_LEN],
ss: &[u8; 32],
) -> Result<LockedSecret<32>, PqfileError> {
use aes_gcm::aead::{AeadInOut, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce as AesNonce};
let cipher = Aes256Gcm::new(ss.as_slice().try_into().expect("32-byte key"));
let nonce = AesNonce::from([0u8; 12]);
let (ct, tag_bytes) = wrapped.split_at(WRAPPED_KEY_LEN - 16);
let tag: Tag<Aes256Gcm> = tag_bytes.try_into().expect("16-byte tag");
let mut key = LockedSecret::<32>::zeroed();
key.copy_from_slice(ct);
cipher
.decrypt_inout_detached(&nonce, &[], (&mut key[..]).into(), &tag)
.map_err(|_| PqfileError::DecryptionFailure)?;
Ok(key)
}
pub(crate) fn decapsulate_stream_init(
reader: &mut dyn Read,
privkey_pem: &str,
passphrase: Option<&str>,
) -> Result<StreamDecryptState, PqfileError> {
let (dk, version) = init_stream_decrypt(privkey_pem, passphrase, reader)?;
match version_layout(version) {
VERSION | VERSION_V3 | VERSION_V5 => {
let (header, cipher, key_commitment) =
read_single_recipient_crypto(reader, &dk, version)?;
if version == VERSION {
let mut header_bytes = Vec::with_capacity(header.header_len());
header
.write(&mut header_bytes)
.expect("write to Vec<u8> is infallible");
let mut payload = Vec::new();
reader
.take(crate::format::MAX_ORIGINAL_SIZE + 16)
.read_to_end(&mut payload)?;
if payload.len() < 16 {
return Err(PqfileError::DecryptionFailure);
}
let nonce: &Nonce = header.nonce.as_slice().try_into().expect("12-byte nonce");
let plaintext = Zeroizing::new(
cipher
.decrypt(
nonce,
Payload {
msg: &payload,
aad: &header_bytes,
},
)
.map_err(|_| PqfileError::DecryptionFailure)?,
);
Ok(StreamDecryptState {
version,
kem_variant: header.kem_variant,
original_size: header.original_size,
chunk_size: header.chunk_size as usize,
cipher,
nonce: header.nonce,
v2_plaintext: Some(plaintext),
key_commitment: [0u8; 32],
})
} else {
Ok(StreamDecryptState {
version,
kem_variant: header.kem_variant,
original_size: header.original_size,
chunk_size: header.chunk_size as usize,
cipher,
nonce: header.nonce,
v2_plaintext: None,
key_commitment,
})
}
}
VERSION_V4 | VERSION_V7 | VERSION_V8 | VERSION_V9 => {
let session = read_multi_recipient_session(reader, &dk, version)?;
let (cipher, key_commitment) = session_stream_crypto(
&session.session_key,
version,
&session.nonce,
session.original_size,
);
Ok(StreamDecryptState {
version,
kem_variant: dk.kem_variant(),
original_size: session.original_size,
chunk_size: CHUNK_SIZE,
cipher,
nonce: session.nonce,
v2_plaintext: None,
key_commitment,
})
}
VERSION_V6 => Err(PqfileError::CompressionNotSupported),
_ => Err(PqfileError::UnsupportedVersion(version)),
}
}
pub(crate) fn decapsulate_for_rekey(
privkey_pem: &str,
passphrase: Option<&str>,
header: &PqfHeader,
) -> Result<LockedSecret<32>, PqfileError> {
let dk = derive_dk(privkey_pem, passphrase)?;
check_kem_variant_match(dk.kem_variant(), header.kem_variant)?;
decapsulate_shared_secret(&dk, &header.kem_ciphertext)
}
pub(crate) fn recover_session_key_multi(
privkey_pem: &str,
passphrase: Option<&str>,
entries: &[(u16, &[u8], &[u8; WRAPPED_KEY_LEN])],
) -> Result<LockedSecret<32>, PqfileError> {
let dk = derive_dk(privkey_pem, passphrase)?;
find_session_key(&dk, entries)
}
pub(crate) fn recover_session_key_v8(
privkey_pem: &str,
passphrase: Option<&str>,
entries: &[RecipientEntryV8],
) -> Result<LockedSecret<32>, PqfileError> {
let dk = derive_dk(privkey_pem, passphrase)?;
find_session_key_v8(&dk, entries)
}
fn check_kem_variant_match(key_variant: u16, file_variant: u16) -> Result<(), PqfileError> {
if key_variant != file_variant {
return Err(PqfileError::KemVariantMismatch {
key: key_variant,
file: file_variant,
});
}
Ok(())
}
fn decrypt_v2_payload(
cipher: &ChaCha20Poly1305,
nonce_bytes: &[u8; NONCE_LEN],
header_bytes: &[u8],
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
let mut payload = Vec::new();
reader
.take(crate::format::MAX_ORIGINAL_SIZE + 16)
.read_to_end(&mut payload)?;
if payload.len() < 16 {
return Err(PqfileError::DecryptionFailure);
}
let nonce: &Nonce = nonce_bytes.as_slice().try_into().expect("12-byte nonce");
let plaintext = Zeroizing::new(
cipher
.decrypt(
nonce,
Payload {
msg: &payload,
aad: header_bytes,
},
)
.map_err(|_| PqfileError::DecryptionFailure)?,
);
writer.write_all(&plaintext)?;
Ok(())
}
pub(crate) fn decrypt_v3_chunks(
cipher: &ChaCha20Poly1305,
header_nonce: &[u8; NONCE_LEN],
chunk_size: usize,
key_commitment: &[u8; 32],
reader: &mut dyn Read,
writer: &mut dyn Write,
) -> Result<(), PqfileError> {
let base_nonce: &[u8; BASE_NONCE_LEN] = header_nonce[..BASE_NONCE_LEN]
.try_into()
.expect("BASE_NONCE_LEN <= NONCE_LEN; parameter type guarantees 12 bytes");
let max_chunk = chunk_size + 16;
let mut current = vec![0u8; max_chunk];
let mut current_len = fill_chunk(reader, &mut current)?;
if current_len == 0 {
return Err(PqfileError::DecryptionFailure);
}
let mut next = vec![0u8; max_chunk];
let mut counter: u32 = 0;
loop {
let next_len = fill_chunk(reader, &mut next)?;
let is_last = next_len == 0;
let cn = chunk_nonce(base_nonce, counter);
let (aad_buf, aad_len) = make_chunk_aad(counter, is_last, key_commitment);
if current_len < 16 {
return Err(PqfileError::DecryptionFailure);
}
let ct_len = current_len - 16;
let tag: Tag<ChaCha20Poly1305> = current[ct_len..current_len]
.try_into()
.expect("16-byte tag");
match cipher.decrypt_inout_detached(
cn.as_slice().try_into().expect("12-byte nonce"),
&aad_buf[..aad_len],
(&mut current[..ct_len]).into(),
&tag,
) {
Ok(_) => {}
Err(_) => {
return Err(if is_last && counter > 0 {
PqfileError::Truncated
} else {
PqfileError::DecryptionFailure
});
}
}
writer.write_all(¤t[..ct_len])?;
if is_last {
break;
}
counter = counter
.checked_add(1)
.ok_or(PqfileError::DecryptionFailure)?;
std::mem::swap(&mut current, &mut next);
current_len = next_len;
}
Ok(())
}
fn locked_seed(seed: &[u8]) -> Result<LockedSecret<64>, PqfileError> {
if seed.len() != 64 {
return Err(PqfileError::InvalidKeyLength {
expected: 64,
got: seed.len(),
});
}
let mut out = LockedSecret::<64>::zeroed();
out.copy_from_slice(seed);
Ok(out)
}
fn derive_dk(privkey_pem: &str, passphrase: Option<&str>) -> Result<DkVariant, PqfileError> {
let pem = pem::parse(privkey_pem).map_err(|e| PqfileError::InvalidPem(e.to_string()))?;
let raw = pem.contents();
match pem.tag() {
t if t == hardware::HW_TAG_512 => {
let seed = hardware::load_seed(raw)?;
Ok(DkVariant::Kem512(locked_seed(seed.as_slice())?))
}
t if t == hardware::HW_TAG_768 => {
let seed = hardware::load_seed(raw)?;
Ok(DkVariant::Kem768(locked_seed(seed.as_slice())?))
}
t if t == hardware::HW_TAG_1024 => {
let seed = hardware::load_seed(raw)?;
Ok(DkVariant::Kem1024(locked_seed(seed.as_slice())?))
}
t if t == hardware::HW_TAG_HYBRID_768 => {
let seed = hardware::load_seed(raw)?;
if seed.len() != HYBRID_SEED_LEN_768 {
return Err(PqfileError::InvalidKeyLength {
expected: HYBRID_SEED_LEN_768,
got: seed.len(),
});
}
let mut seed_arr = Zeroizing::new([0u8; HYBRID_SEED_LEN_768]);
seed_arr.copy_from_slice(&seed);
derive_hybrid_dk_from_seed(&seed_arr)
}
t if t == PRIV_ENC_TAG_512 => {
let pp = passphrase.ok_or(PqfileError::PassphraseRequired)?;
let seed = passphrase::decrypt_seed(raw, pp)?;
Ok(DkVariant::Kem512(locked_seed(seed.as_slice())?))
}
t if t == PRIV_ENC_TAG => {
let pp = passphrase.ok_or(PqfileError::PassphraseRequired)?;
let seed = passphrase::decrypt_seed(raw, pp)?;
Ok(DkVariant::Kem768(locked_seed(seed.as_slice())?))
}
t if t == PRIV_ENC_TAG_1024 => {
let pp = passphrase.ok_or(PqfileError::PassphraseRequired)?;
let seed = passphrase::decrypt_seed(raw, pp)?;
Ok(DkVariant::Kem1024(locked_seed(seed.as_slice())?))
}
t if t == PRIV_TAG_512 => Ok(DkVariant::Kem512(locked_seed(raw)?)),
t if t == PRIV_TAG => Ok(DkVariant::Kem768(locked_seed(raw)?)),
t if t == PRIV_TAG_1024 => Ok(DkVariant::Kem1024(locked_seed(raw)?)),
t if t == PRIV_ENC_TAG_HYBRID_768 => {
let pp = passphrase.ok_or(PqfileError::PassphraseRequired)?;
let seed = passphrase::decrypt_hybrid_seed(raw, pp)?;
derive_hybrid_dk_from_seed(&seed)
}
t if t == PRIV_TAG_HYBRID_768 => {
if raw.len() != HYBRID_SEED_LEN_768 {
return Err(PqfileError::InvalidKeyLength {
expected: HYBRID_SEED_LEN_768,
got: raw.len(),
});
}
let mut seed = Zeroizing::new([0u8; HYBRID_SEED_LEN_768]);
seed.copy_from_slice(raw);
derive_hybrid_dk_from_seed(&seed)
}
_ => Err(PqfileError::InvalidPem(
"unrecognised private key tag".to_owned(),
)),
}
}
fn derive_hybrid_dk_from_seed(seed: &[u8; HYBRID_SEED_LEN_768]) -> Result<DkVariant, PqfileError> {
let x25519_scalar: [u8; 32] = seed[..32]
.try_into()
.expect("HYBRID_SEED_LEN_768 = X25519_SCALAR_LEN + 64 > 32; parameter type guarantees sufficient length");
let x25519_sk = X25519StaticSecret::from(x25519_scalar);
let ml_seed = locked_seed(&seed[32..])?;
Ok(DkVariant::HybridKem768 { x25519_sk, ml_seed })
}
fn decapsulate_shared_secret(
dk: &DkVariant,
kem_ct_bytes: &[u8],
) -> Result<LockedSecret<32>, PqfileError> {
match dk {
DkVariant::Kem512(seed) => {
if kem_ct_bytes.len() != KEM_CT_LEN_512 {
return Err(PqfileError::InvalidKeyLength {
expected: KEM_CT_LEN_512,
got: kem_ct_bytes.len(),
});
}
let ss = ActiveKemBackend::decapsulate(KemSize::Kem512, seed, kem_ct_bytes);
let mut ss_bytes = LockedSecret::<32>::zeroed();
ss_bytes.copy_from_slice(ss.as_slice());
Ok(ss_bytes)
}
DkVariant::Kem768(seed) => {
if kem_ct_bytes.len() != KEM_CT_LEN_768 {
return Err(PqfileError::InvalidKeyLength {
expected: KEM_CT_LEN_768,
got: kem_ct_bytes.len(),
});
}
let ss = ActiveKemBackend::decapsulate(KemSize::Kem768, seed, kem_ct_bytes);
let mut ss_bytes = LockedSecret::<32>::zeroed();
ss_bytes.copy_from_slice(ss.as_slice());
Ok(ss_bytes)
}
DkVariant::Kem1024(seed) => {
if kem_ct_bytes.len() != KEM_CT_LEN_1024 {
return Err(PqfileError::InvalidKeyLength {
expected: KEM_CT_LEN_1024,
got: kem_ct_bytes.len(),
});
}
let ss = ActiveKemBackend::decapsulate(KemSize::Kem1024, seed, kem_ct_bytes);
let mut ss_bytes = LockedSecret::<32>::zeroed();
ss_bytes.copy_from_slice(ss.as_slice());
Ok(ss_bytes)
}
DkVariant::HybridKem768 { x25519_sk, ml_seed } => {
if kem_ct_bytes.len() != HYBRID_CT_LEN_768 {
return Err(PqfileError::InvalidKeyLength {
expected: HYBRID_CT_LEN_768,
got: kem_ct_bytes.len(),
});
}
let eph_pk_bytes: [u8; 32] = kem_ct_bytes[..32]
.try_into()
.expect("HYBRID_CT_LEN_768 > 32; length checked above");
let eph_pk = X25519PublicKey::from(eph_pk_bytes);
let x25519_ss = x25519_sk.diffie_hellman(&eph_pk);
let ml_ct_bytes = &kem_ct_bytes[32..];
if ml_ct_bytes.len() != KEM_CT_LEN_768 {
return Err(PqfileError::InvalidKeyLength {
expected: KEM_CT_LEN_768,
got: ml_ct_bytes.len(),
});
}
let ml_ss = ActiveKemBackend::decapsulate(KemSize::Kem768, ml_seed, ml_ct_bytes);
hybrid_hkdf(x25519_ss.as_bytes(), ml_ss.as_slice())
}
}
}
#[must_use = "decryption result must be used"]
pub fn decrypt_stream_parallel(
privkey_pem: &str,
reader: &mut dyn Read,
writer: &mut dyn Write,
passphrase: Option<&str>,
batch_size: usize,
) -> Result<(), PqfileError> {
if batch_size <= 1 {
return decrypt_stream(privkey_pem, reader, writer, passphrase);
}
let mut preamble = [0u8; 5];
reader.read_exact(&mut preamble).map_err(PqfileError::Io)?;
if &preamble[..4] != crate::format::MAGIC.as_ref() {
return Err(PqfileError::InvalidMagic);
}
let version = preamble[4];
if version_layout(version) != VERSION_V3 && version_layout(version) != VERSION_V5 {
let prefix = Cursor::new(preamble.to_vec());
let mut chained = prefix.chain(&mut *reader);
return decrypt_stream(privkey_pem, &mut chained, writer, passphrase);
}
let dk = derive_dk(privkey_pem, passphrase)?;
let header = PqfHeader::read_body(reader, version)?;
check_kem_variant_match(dk.kem_variant(), header.kem_variant)?;
let ss_bytes = decapsulate_shared_secret(&dk, &header.kem_ciphertext)?;
let key_bytes = Zeroizing::new(*ss_bytes);
let chunk_size = header.chunk_size as usize;
let max_chunk = chunk_size + 16;
let base_nonce: [u8; BASE_NONCE_LEN] = header.nonce[..BASE_NONCE_LEN]
.try_into()
.expect("BASE_NONCE_LEN <= NONCE_LEN; field type guarantees 12 bytes");
let key_commitment = commitment_for_stream(
key_bytes.as_ref(),
version,
&header.nonce,
header.original_size,
header.chunk_size,
header.compression_algo,
);
let mut first = vec![0u8; max_chunk];
let first_len = fill_chunk(reader, &mut first)?;
if first_len == 0 {
return Err(PqfileError::DecryptionFailure);
}
let mut carry: Option<(Vec<u8>, usize)> = Some((first, first_len));
let mut counter: u32 = 0;
loop {
let mut batch: Vec<(Vec<u8>, usize)> = Vec::with_capacity(batch_size);
if let Some(c) = carry.take() {
batch.push(c);
}
while batch.len() < batch_size {
let mut buf = vec![0u8; max_chunk];
let n = fill_chunk(reader, &mut buf)?;
if n == 0 {
break;
}
batch.push((buf, n));
}
if batch.is_empty() {
break;
}
let mut peek = vec![0u8; max_chunk];
let peek_len = fill_chunk(reader, &mut peek)?;
let batch_is_final = peek_len == 0;
if !batch_is_final {
carry = Some((peek, peek_len));
}
let batch_len = batch.len();
let batch_start = counter;
let results: Vec<Result<Zeroizing<Vec<u8>>, PqfileError>> = batch
.into_par_iter()
.enumerate()
.map(|(i, (mut ct_buf, ct_len))| {
let c = batch_start
.checked_add(i as u32)
.ok_or(PqfileError::DecryptionFailure)?;
let is_last = batch_is_final && i == batch_len - 1;
let cn = chunk_nonce(&base_nonce, c);
let (aad_buf, aad_len) = make_chunk_aad(c, is_last, &key_commitment);
if ct_len < 16 {
return Err(PqfileError::DecryptionFailure);
}
let pt_len = ct_len - 16;
let tag: Tag<ChaCha20Poly1305> =
ct_buf[pt_len..ct_len].try_into().expect("16-byte tag");
let cipher =
ChaCha20Poly1305::new(key_bytes.as_ref().try_into().expect("32-byte key"));
cipher
.decrypt_inout_detached(
cn.as_slice().try_into().expect("12-byte nonce"),
&aad_buf[..aad_len],
(&mut ct_buf[..pt_len]).into(),
&tag,
)
.map_err(|_| {
if is_last && c > 0 {
PqfileError::Truncated
} else {
PqfileError::DecryptionFailure
}
})?;
ct_buf.truncate(pt_len);
Ok(Zeroizing::new(ct_buf))
})
.collect();
for r in results {
writer.write_all(&r?)?;
}
counter = batch_start
.checked_add(batch_len as u32)
.ok_or(PqfileError::DecryptionFailure)?;
if batch_is_final {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::encrypt::encrypt_stream_compressed;
use crate::encrypt::{encrypt_bytes, encrypt_stream};
use crate::format::{KEM_CT_LEN_768, KEM_VARIANT_768, MAGIC, NONCE_LEN, VERSION};
use crate::keygen::keygen_bytes;
#[test]
fn decrypt_rejects_wrong_length_private_key() {
let bad_key = pem::encode(&pem::Pem::new("ML-KEM-768 PRIVATE KEY", vec![0u8; 10]));
let result = decrypt_bytes(&bad_key, b"irrelevant", None);
assert!(matches!(
result,
Err(PqfileError::InvalidKeyLength { expected: 64, .. })
));
}
#[test]
fn decapsulate_shared_secret_rejects_wrong_length_ciphertext_512() {
let (_, priv_pem) = keygen_bytes(512, None).unwrap();
let dk = derive_dk(&priv_pem, None).unwrap();
let result = decapsulate_shared_secret(&dk, &[0u8; 10]);
assert!(matches!(result, Err(PqfileError::InvalidKeyLength { .. })));
}
#[test]
fn decapsulate_shared_secret_rejects_wrong_length_ciphertext_768() {
let (_, priv_pem) = keygen_bytes(768, None).unwrap();
let dk = derive_dk(&priv_pem, None).unwrap();
let result = decapsulate_shared_secret(&dk, &[0u8; 10]);
assert!(matches!(result, Err(PqfileError::InvalidKeyLength { .. })));
}
#[test]
fn decapsulate_shared_secret_rejects_wrong_length_ciphertext_1024() {
let (_, priv_pem) = keygen_bytes(1024, None).unwrap();
let dk = derive_dk(&priv_pem, None).unwrap();
let result = decapsulate_shared_secret(&dk, &[0u8; 10]);
assert!(matches!(result, Err(PqfileError::InvalidKeyLength { .. })));
}
#[test]
fn decrypt_rejects_truncated_payload() {
let (_, priv_pem) = keygen_bytes(768, None).unwrap();
let mut data = Vec::new();
data.extend_from_slice(MAGIC);
data.push(VERSION);
data.extend_from_slice(&KEM_VARIANT_768.to_le_bytes());
data.extend_from_slice(&[0u8; KEM_CT_LEN_768]);
data.extend_from_slice(&[0u8; NONCE_LEN]);
data.extend_from_slice(&0u64.to_le_bytes());
data.extend_from_slice(&[0u8; 8]);
let result = decrypt_bytes(&priv_pem, &data, None);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn decrypt_rejects_oversized_original_size_field() {
use crate::format::{KEM_CT_LEN_768, MAGIC, VERSION_V3};
let (_, priv_pem) = keygen_bytes(768, None).unwrap();
let mut data = Vec::new();
data.extend_from_slice(MAGIC);
data.push(VERSION_V3);
data.extend_from_slice(&KEM_VARIANT_768.to_le_bytes());
data.extend_from_slice(&[0u8; KEM_CT_LEN_768]);
data.extend_from_slice(&[0u8; NONCE_LEN]);
let oversized: u64 = (1u64 << 40) + 1;
data.extend_from_slice(&oversized.to_le_bytes());
let result = decrypt_stream(&priv_pem, &mut data.as_slice(), &mut Vec::new(), None);
assert!(
matches!(result, Err(PqfileError::Io(_))),
"expected Io error for oversized original_size, got {result:?}"
);
}
#[test]
fn decrypt_bytes_with_encrypted_key_and_correct_passphrase() {
let (pub_pem, priv_pem) = keygen_bytes(768, Some("correct horse")).unwrap();
let plaintext = b"passphrase-protected roundtrip";
let pqf = encrypt_bytes(&pub_pem, plaintext).unwrap();
let result = decrypt_bytes(&priv_pem, &pqf, Some("correct horse")).unwrap();
assert_eq!(result, plaintext);
}
#[test]
fn decrypt_bytes_with_encrypted_key_wrong_passphrase() {
let (pub_pem, priv_pem) = keygen_bytes(768, Some("correct")).unwrap();
let plaintext = b"passphrase-protected roundtrip";
let pqf = encrypt_bytes(&pub_pem, plaintext).unwrap();
let result = decrypt_bytes(&priv_pem, &pqf, Some("wrong"));
assert!(matches!(result, Err(PqfileError::WrongPassphrase)));
}
#[test]
fn decrypt_bytes_encrypted_key_without_passphrase_returns_error() {
let (pub_pem, priv_pem) = keygen_bytes(768, Some("secret")).unwrap();
let pqf = encrypt_bytes(&pub_pem, b"data").unwrap();
let result = decrypt_bytes(&priv_pem, &pqf, None);
assert!(matches!(result, Err(PqfileError::PassphraseRequired)));
}
#[test]
fn decrypt_bytes_rejects_v3_file() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let mut reader: &[u8] = b"payload";
let mut writer = Vec::new();
encrypt_stream(&pub_pem, 7, CHUNK_SIZE, &mut reader, &mut writer).unwrap();
let result = decrypt_bytes(&priv_pem, &writer, None);
assert!(matches!(
result,
Err(PqfileError::UnsupportedVersion(v)) if version_layout(v) == VERSION_V3
));
}
#[test]
fn decrypt_bytes_rejects_v5_file() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let mut reader: &[u8] = b"payload";
let mut writer = Vec::new();
encrypt_stream(&pub_pem, 7, 4096, &mut reader, &mut writer).unwrap();
let result = decrypt_bytes(&priv_pem, &writer, None);
assert!(matches!(
result,
Err(PqfileError::UnsupportedVersion(v)) if version_layout(v) == VERSION_V5
));
}
#[test]
fn decrypt_rejects_kem_variant_mismatch() {
let (pub_768, _) = keygen_bytes(768, None).unwrap();
let (_, priv_1024) = keygen_bytes(1024, None).unwrap();
let mut enc = Vec::new();
encrypt_stream(&pub_768, 5, CHUNK_SIZE, &mut b"hello".as_slice(), &mut enc).unwrap();
let mut dec = Vec::new();
let result = decrypt_stream(&priv_1024, &mut enc.as_slice(), &mut dec, None);
assert!(matches!(
result,
Err(PqfileError::KemVariantMismatch { .. })
));
}
#[test]
fn decrypt_stream_handles_v2_file() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"v2 backward compat check";
let pqf = encrypt_bytes(&pub_pem, plaintext).unwrap();
let mut reader: &[u8] = &pqf;
let mut output = Vec::new();
decrypt_stream(&priv_pem, &mut reader, &mut output, None).unwrap();
assert_eq!(output, plaintext);
}
#[test]
fn stream_roundtrip_empty() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext: &[u8] = &[];
let mut enc_out = Vec::new();
encrypt_stream(&pub_pem, 0, CHUNK_SIZE, &mut { plaintext }, &mut enc_out).unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[test]
fn stream_roundtrip_small() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"small streaming payload";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[test]
fn stream_roundtrip_exact_chunk_boundary() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = vec![0xDDu8; CHUNK_SIZE];
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[test]
fn stream_roundtrip_multi_chunk() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext: Vec<u8> = (0..u8::MAX).cycle().take(CHUNK_SIZE * 3 + 7).collect();
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[test]
fn stream_roundtrip_with_passphrase() {
let (pub_pem, priv_pem) = keygen_bytes(768, Some("stream-pass")).unwrap();
let plaintext = b"passphrase streaming roundtrip";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(
&priv_pem,
&mut enc_out.as_slice(),
&mut dec_out,
Some("stream-pass"),
)
.unwrap();
assert_eq!(dec_out, plaintext.as_slice());
}
#[test]
fn stream_decrypt_rejects_truncated_ciphertext() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = vec![0u8; CHUNK_SIZE + 100];
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
use crate::format::HEADER_LEN_768;
let truncated = &enc_out[..HEADER_LEN_768 + CHUNK_SIZE + 16];
let mut src: &[u8] = truncated;
let mut dec_out = Vec::new();
let result = decrypt_stream(&priv_pem, &mut src, &mut dec_out, None);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn stream_decrypt_truncated_mid_stream_returns_truncated_error() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = vec![0u8; 2 * CHUNK_SIZE + 100];
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
use crate::format::HEADER_LEN_768;
let keep = HEADER_LEN_768 + 2 * (CHUNK_SIZE + 16);
let truncated = &enc_out[..keep];
let mut src: &[u8] = truncated;
let mut dec_out = Vec::new();
let result = decrypt_stream(&priv_pem, &mut src, &mut dec_out, None);
assert!(matches!(result, Err(PqfileError::Truncated)));
}
#[test]
fn stream_decrypt_rejects_tampered_chunk() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"tamper test payload";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
use crate::format::HEADER_LEN_768;
let flip_pos = HEADER_LEN_768 + 4;
enc_out[flip_pos] ^= 0xFF;
let mut dec_out = Vec::new();
let result = decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn stream_roundtrip_1024() {
let (pub_pem, priv_pem) = keygen_bytes(1024, None).unwrap();
let plaintext = b"ML-KEM-1024 streaming roundtrip";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[test]
fn stream_roundtrip_1024_with_passphrase() {
let (pub_pem, priv_pem) = keygen_bytes(1024, Some("1024-pass")).unwrap();
let plaintext = b"ML-KEM-1024 passphrase roundtrip";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(
&priv_pem,
&mut enc_out.as_slice(),
&mut dec_out,
Some("1024-pass"),
)
.unwrap();
assert_eq!(dec_out, plaintext.as_slice());
}
#[test]
fn decrypt_stream_rejects_unknown_version() {
let (_, priv_pem) = keygen_bytes(768, None).unwrap();
let mut fake: Vec<u8> = b"PQFL".to_vec();
fake.push(0xFF);
let mut out = Vec::new();
let result = decrypt_stream(&priv_pem, &mut fake.as_slice(), &mut out, None);
assert!(matches!(result, Err(PqfileError::UnsupportedVersion(0xFF))));
}
#[test]
fn stream_roundtrip_512() {
let (pub_pem, priv_pem) = keygen_bytes(512, None).unwrap();
let plaintext = b"ML-KEM-512 streaming roundtrip";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[test]
fn stream_roundtrip_512_with_passphrase() {
let (pub_pem, priv_pem) = keygen_bytes(512, Some("512-pass")).unwrap();
let plaintext = b"ML-KEM-512 passphrase roundtrip";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(
&priv_pem,
&mut enc_out.as_slice(),
&mut dec_out,
Some("512-pass"),
)
.unwrap();
assert_eq!(dec_out, plaintext.as_slice());
}
#[test]
fn decrypt_rejects_512_key_on_768_file() {
let (pub_768, _) = keygen_bytes(768, None).unwrap();
let (_, priv_512) = keygen_bytes(512, None).unwrap();
let mut enc = Vec::new();
encrypt_stream(&pub_768, 5, CHUNK_SIZE, &mut b"hello".as_slice(), &mut enc).unwrap();
let mut dec = Vec::new();
let result = decrypt_stream(&priv_512, &mut enc.as_slice(), &mut dec, None);
assert!(matches!(
result,
Err(PqfileError::KemVariantMismatch { .. })
));
}
#[test]
fn stream_roundtrip_custom_chunk_size_small() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"custom chunk size roundtrip";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
512,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[test]
fn stream_roundtrip_custom_chunk_size_multi_chunk() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext: Vec<u8> = (0u8..=255).cycle().take(1024 * 3 + 17).collect();
let chunk_size = 1024;
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
chunk_size,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn v6_roundtrip_small() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"compress-then-encrypt roundtrip";
let mut enc_out = Vec::new();
encrypt_stream_compressed(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
3,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let version_pos = crate::format::MAGIC.len();
assert_eq!(
enc_out[version_pos],
crate::format::VERSION_V6 | crate::format::VERSION_AUTH_BIT
);
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn v6_roundtrip_multi_chunk() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext: Vec<u8> = (0u8..=63).cycle().take(CHUNK_SIZE * 2 + 17).collect();
let mut enc_out = Vec::new();
encrypt_stream_compressed(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
3,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn v6_compressed_ciphertext_smaller_than_v3_for_compressible_input() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = vec![0u8; 256 * 1024];
let mut v3_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut v3_out,
)
.unwrap();
let mut v6_out = Vec::new();
encrypt_stream_compressed(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
3,
&mut plaintext.as_slice(),
&mut v6_out,
)
.unwrap();
assert!(
v6_out.len() < v3_out.len(),
"v6 len {} should be < v3 len {}",
v6_out.len(),
v3_out.len()
);
let mut dec_out = Vec::new();
decrypt_stream(&priv_pem, &mut v6_out.as_slice(), &mut dec_out, None).unwrap();
assert_eq!(dec_out, plaintext);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn v6_roundtrip_rejects_tampered_chunk() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"v6 tamper test";
let mut enc_out = Vec::new();
encrypt_stream_compressed(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
1,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let header_len = {
use crate::format::{
HEADER_LEN_768, V5_CHUNK_SIZE_FIELD_LEN, V6_COMPRESSION_FIELD_LEN,
};
HEADER_LEN_768 + V5_CHUNK_SIZE_FIELD_LEN + V6_COMPRESSION_FIELD_LEN
};
enc_out[header_len + 2] ^= 0xFF;
let mut dec_out = Vec::new();
let result = decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn v5_roundtrip_rejects_tampered_chunk() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"v5 tamper test";
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
4096,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let header_len = {
use crate::format::{HEADER_LEN_768, V5_CHUNK_SIZE_FIELD_LEN};
HEADER_LEN_768 + V5_CHUNK_SIZE_FIELD_LEN
};
enc_out[header_len + 2] ^= 0xFF;
let mut dec_out = Vec::new();
let result = decrypt_stream(&priv_pem, &mut enc_out.as_slice(), &mut dec_out, None);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn parallel_decrypt_roundtrip_small() {
use crate::encrypt::encrypt_stream_parallel;
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"parallel decrypt small";
let mut ct = Vec::new();
encrypt_stream_parallel(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
4,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
decrypt_stream_parallel(&priv_pem, &mut ct.as_slice(), &mut out, None, 4).unwrap();
assert_eq!(out, plaintext);
}
#[test]
fn parallel_decrypt_roundtrip_multi_batch() {
use crate::encrypt::encrypt_stream_parallel;
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE * 12 + 3).collect();
let mut ct = Vec::new();
encrypt_stream_parallel(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
4,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
decrypt_stream_parallel(&priv_pem, &mut ct.as_slice(), &mut out, None, 4).unwrap();
assert_eq!(out, plaintext);
}
#[test]
fn parallel_decrypt_falls_back_for_non_v3_v5() {
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = b"fallback test";
let mut ct = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
decrypt_stream_parallel(&priv_pem, &mut ct.as_slice(), &mut out, None, 4).unwrap();
assert_eq!(out, plaintext);
}
#[test]
fn parallel_decrypt_truncated_mid_stream_returns_truncated_error() {
use crate::format::HEADER_LEN_768;
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext = vec![0u8; 2 * CHUNK_SIZE + 100];
let mut enc_out = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut enc_out,
)
.unwrap();
let keep = HEADER_LEN_768 + 2 * (CHUNK_SIZE + 16);
let mut truncated: &[u8] = &enc_out[..keep];
let mut out = Vec::new();
let result = decrypt_stream_parallel(&priv_pem, &mut truncated, &mut out, None, 4);
assert!(
matches!(result, Err(PqfileError::Truncated)),
"expected Truncated, got {result:?}"
);
}
#[test]
fn v4_multi_recipient_last_slot_matches() {
use crate::encrypt::encrypt_stream_multi;
let (pub1, _priv1) = keygen_bytes(768, None).unwrap();
let (pub2, priv2) = keygen_bytes(768, None).unwrap();
let plaintext = b"last-slot recipient";
let mut enc = Vec::new();
encrypt_stream_multi(
&[pub1.as_str(), pub2.as_str()],
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut enc,
)
.unwrap();
let mut out = Vec::new();
decrypt_stream(&priv2, &mut enc.as_slice(), &mut out, None).unwrap();
assert_eq!(out, plaintext);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn v6_decompression_bomb_rejected_at_original_size_cap() {
use super::LimitedWriter;
use std::io::Write;
let small_limit: u64 = 10;
let mut sink = Vec::new();
let mut lw = LimitedWriter::new(&mut sink, small_limit);
lw.write_all(&[0u8; 10]).unwrap();
let result = lw.write(&[0u8; 1]);
assert!(result.is_err(), "expected error when limit exceeded");
let err = result.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn decrypt_stream_with_progress_fires_and_accurate() {
use std::sync::{Arc, Mutex};
let (pub_pem, priv_pem) = keygen_bytes(768, None).unwrap();
let plaintext: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE * 2 + 77).collect();
let mut ct = Vec::new();
encrypt_stream(
&pub_pem,
plaintext.len() as u64,
CHUNK_SIZE,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let calls: Arc<Mutex<Vec<(u64, u64)>>> = Arc::new(Mutex::new(Vec::new()));
let calls2 = Arc::clone(&calls);
let mut out = Vec::new();
super::decrypt_stream_with_progress(
&priv_pem,
&mut ct.as_slice(),
&mut out,
None,
plaintext.len() as u64,
&move |done, total| {
calls2.lock().unwrap().push((done, total));
},
)
.unwrap();
assert_eq!(out, plaintext);
let log = calls.lock().unwrap().clone();
assert!(!log.is_empty(), "progress callback must fire");
let (last_done, last_total) = *log.last().unwrap();
assert_eq!(last_done, plaintext.len() as u64);
assert_eq!(last_total, plaintext.len() as u64);
}
#[test]
fn v10_passphrase_roundtrip() {
let plaintext = b"hello passphrase world";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"hunter2",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out).unwrap();
assert_eq!(out, plaintext);
}
#[test]
fn v10_custom_params_roundtrip() {
let plaintext = b"custom kdf params";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase_with_params(
"hunter2",
16 * 1024, 2,
4,
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out).unwrap();
assert_eq!(out, plaintext);
}
#[test]
fn v10_wrong_passphrase_fails() {
let plaintext = b"secret";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"correct",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
let result = decrypt_stream_passphrase("wrong", &mut ct.as_slice(), &mut out);
assert!(
matches!(result, Err(PqfileError::DecryptionFailure)),
"wrong passphrase must cause decryption failure"
);
}
#[test]
fn v10_kdf_limit_exceeded() {
let plaintext = b"data";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"pass",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
let result =
decrypt_stream_passphrase_with_limits("pass", 1, 99, &mut ct.as_slice(), &mut out);
assert!(
matches!(result, Err(PqfileError::KdfLimitExceeded { .. })),
"KDF ceiling should be enforced"
);
}
#[test]
fn v10_decrypt_stream_rejects_v10_with_helpful_error() {
let plaintext = b"data";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"pass",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let (_, priv_pem) = crate::keygen::keygen_bytes(768, None).unwrap();
let mut out = Vec::new();
let result = decrypt_stream(&priv_pem, &mut ct.as_slice(), &mut out, None);
assert!(
matches!(
result,
Err(PqfileError::UnsupportedVersion(v)) if version_layout(v) == VERSION_V10
),
"decrypt_stream must reject v10 with UnsupportedVersion"
);
}
#[test]
fn v10_multi_chunk_roundtrip() {
let plaintext: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE * 2 + 7).collect();
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"multipass",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
decrypt_stream_passphrase("multipass", &mut ct.as_slice(), &mut out).unwrap();
assert_eq!(out, plaintext);
}
const V10_FLAGS_OFFSET: usize = 33;
fn keyfile_ciphertext(passphrase: &str, keyfile: &[u8], plaintext: &[u8]) -> Vec<u8> {
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase_keyfile_with_params(
passphrase,
keyfile,
16 * 1024, 2,
4,
plaintext.len() as u64,
&mut &plaintext[..],
&mut ct,
)
.unwrap();
ct
}
#[test]
fn v10_keyfile_roundtrip() {
let plaintext = b"keyfile second factor roundtrip";
let keyfile = b"any bytes work as a keyfile 0123456789";
let ct = keyfile_ciphertext("hunter2", keyfile, plaintext);
let mut out = Vec::new();
decrypt_stream_passphrase_keyfile("hunter2", keyfile, &mut ct.as_slice(), &mut out)
.unwrap();
assert_eq!(out, plaintext);
}
#[test]
fn v10_keyfile_multi_chunk_roundtrip() {
let plaintext: Vec<u8> = (0u8..=255).cycle().take(CHUNK_SIZE * 2 + 7).collect();
let keyfile = b"multi-chunk keyfile";
let ct = keyfile_ciphertext("hunter2", keyfile, &plaintext);
let mut out = Vec::new();
decrypt_stream_passphrase_keyfile("hunter2", keyfile, &mut ct.as_slice(), &mut out)
.unwrap();
assert_eq!(out, plaintext);
}
#[test]
fn v10_keyfile_wrong_keyfile_fails() {
let ct = keyfile_ciphertext("hunter2", b"right keyfile", b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase_keyfile(
"hunter2",
b"wrong keyfile",
&mut ct.as_slice(),
&mut out,
);
assert!(
matches!(result, Err(PqfileError::DecryptionFailure)),
"wrong keyfile must cause decryption failure"
);
}
#[test]
fn v10_keyfile_wrong_passphrase_fails() {
let keyfile = b"right keyfile";
let ct = keyfile_ciphertext("correct", keyfile, b"secret");
let mut out = Vec::new();
let result =
decrypt_stream_passphrase_keyfile("wrong", keyfile, &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn v10_keyfile_missing_returns_keyfile_required() {
let ct = keyfile_ciphertext("hunter2", b"the keyfile", b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::KeyfileRequired)));
}
#[test]
fn v10_keyfile_on_plain_file_returns_keyfile_not_required() {
let plaintext = b"no keyfile involved";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"hunter2",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
let result = decrypt_stream_passphrase_keyfile(
"hunter2",
b"unnecessary keyfile",
&mut ct.as_slice(),
&mut out,
);
assert!(matches!(result, Err(PqfileError::KeyfileNotRequired)));
}
#[test]
fn v10_keyfile_flag_tampering_cannot_bypass_second_factor() {
let mut ct = keyfile_ciphertext("hunter2", b"the keyfile", b"secret");
assert_eq!(
ct[V10_FLAGS_OFFSET], 0x01,
"flags byte moved; update offset"
);
ct[V10_FLAGS_OFFSET] = 0x00;
let mut out = Vec::new();
let result = decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn v10_unknown_header_flags_rejected() {
let plaintext = b"future flags";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"hunter2",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
assert_eq!(
ct[V10_FLAGS_OFFSET], 0x00,
"flags byte moved; update offset"
);
ct[V10_FLAGS_OFFSET] = 0x82;
let mut out = Vec::new();
let result = decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out);
assert!(
matches!(result, Err(PqfileError::UnsupportedHeaderFlags(0x82))),
"unknown flag bits must be rejected, got {result:?}"
);
}
#[test]
fn v10_empty_keyfile_rejected_at_encrypt() {
let mut ct = Vec::new();
let result = crate::encrypt::encrypt_stream_passphrase_keyfile(
"hunter2",
b"",
6,
&mut b"secret".as_slice(),
&mut ct,
);
assert!(matches!(result, Err(PqfileError::Io(_))));
assert!(
ct.is_empty(),
"nothing may be written for a rejected keyfile"
);
}
fn fido2_ciphertext(passphrase: &str, hmac_secret: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase_fido2_with_params(
passphrase,
hmac_secret,
16 * 1024, 2,
4,
plaintext.len() as u64,
&mut &plaintext[..],
&mut ct,
)
.unwrap();
ct
}
#[test]
fn v10_fido2_roundtrip() {
let hmac_secret = [0x5Au8; 32];
let ct = fido2_ciphertext("hunter2", &hmac_secret, b"top secret");
let mut out = Vec::new();
decrypt_stream_passphrase_fido2("hunter2", &hmac_secret, &mut ct.as_slice(), &mut out)
.unwrap();
assert_eq!(out, b"top secret");
}
#[test]
fn v10_fido2_sets_flag_byte() {
let ct = fido2_ciphertext("hunter2", &[0x11u8; 32], b"secret");
assert_eq!(
ct[V10_FLAGS_OFFSET], 0x02,
"FIDO2 flag bit must be V10_FLAG_FIDO2 (0x02)"
);
}
#[test]
fn v10_fido2_wrong_hmac_secret_fails() {
let ct = fido2_ciphertext("hunter2", &[0x22u8; 32], b"secret");
let mut out = Vec::new();
let result =
decrypt_stream_passphrase_fido2("hunter2", &[0x33u8; 32], &mut ct.as_slice(), &mut out);
assert!(
matches!(result, Err(PqfileError::DecryptionFailure)),
"wrong hmac-secret must cause decryption failure"
);
}
#[test]
fn v10_fido2_wrong_passphrase_fails() {
let hmac_secret = [0x44u8; 32];
let ct = fido2_ciphertext("correct", &hmac_secret, b"secret");
let mut out = Vec::new();
let result =
decrypt_stream_passphrase_fido2("wrong", &hmac_secret, &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn v10_fido2_missing_returns_fido2_required() {
let ct = fido2_ciphertext("hunter2", &[0x55u8; 32], b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::Fido2Required)));
}
#[test]
fn v10_fido2_on_plain_file_returns_fido2_not_required() {
let plaintext = b"no fido2 involved";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"hunter2",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
let result =
decrypt_stream_passphrase_fido2("hunter2", &[0x66u8; 32], &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::Fido2NotRequired)));
}
#[test]
fn v10_keyfile_file_rejects_fido2_secret() {
let ct = keyfile_ciphertext("hunter2", b"the keyfile", b"secret");
let mut out = Vec::new();
let result =
decrypt_stream_passphrase_fido2("hunter2", &[0x77u8; 32], &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::KeyfileRequired)));
}
#[test]
fn v10_fido2_file_rejects_keyfile_secret() {
let ct = fido2_ciphertext("hunter2", &[0x88u8; 32], b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase_keyfile(
"hunter2",
b"wrong kind of second factor",
&mut ct.as_slice(),
&mut out,
);
assert!(matches!(result, Err(PqfileError::Fido2Required)));
}
#[test]
fn v10_two_of_three_second_factor_flags_rejected() {
let mut ct = keyfile_ciphertext("hunter2", b"the keyfile", b"secret");
assert_eq!(ct[V10_FLAGS_OFFSET], 0x01);
ct[V10_FLAGS_OFFSET] = 0x03;
let mut out = Vec::new();
let result = decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out);
assert!(
matches!(result, Err(PqfileError::UnsupportedHeaderFlags(0x03))),
"combined flags must be rejected, got {result:?}"
);
}
#[test]
fn v10_three_of_three_second_factor_flags_rejected() {
let mut ct = keyfile_ciphertext("hunter2", b"the keyfile", b"secret");
ct[V10_FLAGS_OFFSET] = 0x07;
let mut out = Vec::new();
let result = decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out);
assert!(
matches!(result, Err(PqfileError::UnsupportedHeaderFlags(0x07))),
"all three flags set must be rejected, got {result:?}"
);
}
fn webauthn_prf_ciphertext(
passphrase: &str,
prf_output: &[u8; 32],
plaintext: &[u8],
) -> Vec<u8> {
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase_webauthn_prf_with_params(
passphrase,
prf_output,
16 * 1024, 2,
4,
plaintext.len() as u64,
&mut &plaintext[..],
&mut ct,
)
.unwrap();
ct
}
#[test]
fn v10_webauthn_prf_roundtrip() {
let prf_output = [0x5Au8; 32];
let ct = webauthn_prf_ciphertext("hunter2", &prf_output, b"top secret");
let mut out = Vec::new();
decrypt_stream_passphrase_webauthn_prf(
"hunter2",
&prf_output,
&mut ct.as_slice(),
&mut out,
)
.unwrap();
assert_eq!(out, b"top secret");
}
#[test]
fn v10_webauthn_prf_sets_flag_byte() {
let ct = webauthn_prf_ciphertext("hunter2", &[0x11u8; 32], b"secret");
assert_eq!(
ct[V10_FLAGS_OFFSET], 0x04,
"WebAuthn PRF flag bit must be V10_FLAG_WEBAUTHN_PRF (0x04)"
);
}
#[test]
fn v10_webauthn_prf_wrong_output_fails() {
let ct = webauthn_prf_ciphertext("hunter2", &[0x22u8; 32], b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase_webauthn_prf(
"hunter2",
&[0x33u8; 32],
&mut ct.as_slice(),
&mut out,
);
assert!(
matches!(result, Err(PqfileError::DecryptionFailure)),
"wrong PRF output must cause decryption failure"
);
}
#[test]
fn v10_webauthn_prf_wrong_passphrase_fails() {
let prf_output = [0x44u8; 32];
let ct = webauthn_prf_ciphertext("correct", &prf_output, b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase_webauthn_prf(
"wrong",
&prf_output,
&mut ct.as_slice(),
&mut out,
);
assert!(matches!(result, Err(PqfileError::DecryptionFailure)));
}
#[test]
fn v10_webauthn_prf_missing_returns_webauthn_prf_required() {
let ct = webauthn_prf_ciphertext("hunter2", &[0x55u8; 32], b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase("hunter2", &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::WebauthnPrfRequired)));
}
#[test]
fn v10_webauthn_prf_on_plain_file_returns_webauthn_prf_not_required() {
let plaintext = b"no webauthn involved";
let mut ct = Vec::new();
crate::encrypt::encrypt_stream_passphrase(
"hunter2",
plaintext.len() as u64,
&mut plaintext.as_slice(),
&mut ct,
)
.unwrap();
let mut out = Vec::new();
let result = decrypt_stream_passphrase_webauthn_prf(
"hunter2",
&[0x66u8; 32],
&mut ct.as_slice(),
&mut out,
);
assert!(matches!(result, Err(PqfileError::WebauthnPrfNotRequired)));
}
#[test]
fn v10_keyfile_file_rejects_webauthn_prf_secret() {
let ct = keyfile_ciphertext("hunter2", b"the keyfile", b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase_webauthn_prf(
"hunter2",
&[0x77u8; 32],
&mut ct.as_slice(),
&mut out,
);
assert!(matches!(result, Err(PqfileError::KeyfileRequired)));
}
#[test]
fn v10_fido2_file_rejects_webauthn_prf_secret() {
let ct = fido2_ciphertext("hunter2", &[0x88u8; 32], b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase_webauthn_prf(
"hunter2",
&[0x99u8; 32],
&mut ct.as_slice(),
&mut out,
);
assert!(matches!(result, Err(PqfileError::Fido2Required)));
}
#[test]
fn v10_webauthn_prf_file_rejects_keyfile_secret() {
let ct = webauthn_prf_ciphertext("hunter2", &[0xAAu8; 32], b"secret");
let mut out = Vec::new();
let result = decrypt_stream_passphrase_keyfile(
"hunter2",
b"wrong kind of second factor",
&mut ct.as_slice(),
&mut out,
);
assert!(matches!(result, Err(PqfileError::WebauthnPrfRequired)));
}
#[test]
fn v10_webauthn_prf_file_rejects_fido2_secret() {
let ct = webauthn_prf_ciphertext("hunter2", &[0xBBu8; 32], b"secret");
let mut out = Vec::new();
let result =
decrypt_stream_passphrase_fido2("hunter2", &[0xCCu8; 32], &mut ct.as_slice(), &mut out);
assert!(matches!(result, Err(PqfileError::WebauthnPrfRequired)));
}
}