use pdfrum_object::{Dict, Name, Resolve, names};
use crate::Error;
use crate::key::SmallKey;
use crate::primitives::{
BLOCK, aes_cbc_decrypt, aes_cbc_encrypt, ct_eq, md5, md5_parts, sha256, sha256_parts, sha384,
sha512,
};
use crate::rc4::{rc4, rc4_in_place};
pub const PAD: [u8; 32] = [
0x28, 0xbf, 0x4e, 0x5e, 0x4e, 0x75, 0x8a, 0x41, 0x64, 0x00, 0x4e, 0x56, 0xff, 0xfa, 0x01, 0x08,
0x2e, 0x2e, 0x00, 0xb6, 0xd0, 0x68, 0x3e, 0x80, 0x2f, 0x0c, 0xa9, 0xfe, 0x64, 0x53, 0x69, 0x7a,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cipher {
None,
Rc4,
Aes,
}
impl Cipher {
const fn label(self) -> &'static str {
match self {
Self::None => "identity",
Self::Rc4 => "RC4",
Self::Aes => "AES",
}
}
const fn accepts_key_len(self, len: usize) -> bool {
match self {
Self::None => true,
Self::Rc4 => 5 <= len && len <= 16,
Self::Aes => matches!(len, 16 | 24 | 32),
}
}
}
#[derive(Debug, Clone)]
pub struct EncryptParams {
pub version: i64,
pub revision: i64,
pub permissions: u32,
pub cipher: Cipher,
pub embedded_cipher: Option<Cipher>,
pub string_cipher: Cipher,
pub key_len: usize,
pub encrypt_metadata: bool,
pub o: Box<[u8]>,
pub u: Box<[u8]>,
pub oe: Box<[u8]>,
pub ue: Box<[u8]>,
pub perms: Box<[u8]>,
}
pub fn parse_encrypt_dict(dict: &Dict, r: &impl Resolve) -> Result<EncryptParams, Error> {
let filter = dict.name(names::FILTER);
if filter != Some(names::STANDARD) {
let spelling = filter.map(|n| n.as_bytes().into()).unwrap_or_default();
return Err(Error::UnsupportedHandler(spelling));
}
let version = dict.int(names::V, r).unwrap_or(0);
let revision = dict.int(names::R, r).unwrap_or(0);
let permissions = as_u32(dict.int(names::P, r).unwrap_or(-1));
let encrypt_metadata = dict.bool(names::ENCRYPT_METADATA).unwrap_or(true);
let (cipher, string_cipher, key_len) = resolve_cipher(dict, version, r)?;
let embedded_cipher = embedded_cipher(dict, version, cipher, r)?;
Ok(EncryptParams {
version,
embedded_cipher,
revision,
permissions,
cipher,
string_cipher,
key_len,
encrypt_metadata,
o: byte_string(dict, names::O, r),
u: byte_string(dict, names::U, r),
oe: byte_string(dict, names::OE, r),
ue: byte_string(dict, names::UE, r),
perms: byte_string(dict, names::PERMS, r),
})
}
fn as_u32(value: i64) -> u32 {
#[expect(
clippy::cast_possible_truncation,
reason = "the C-int wrap is the semantic"
)]
let narrowed = value as i32;
narrowed.cast_unsigned()
}
fn byte_string(dict: &Dict, key: &Name, r: &impl Resolve) -> Box<[u8]> {
dict.byte_string(key, r).unwrap_or_default().into()
}
fn resolve_cipher(
dict: &Dict,
version: i64,
r: &impl Resolve,
) -> Result<(Cipher, Cipher, usize), Error> {
let (cipher, string_cipher, key_bits) = if version >= 4 {
let (stream_name, string_name) = crypt_filter_names(dict, r);
let stream_identity = is_identity(&stream_name);
let string_identity = is_identity(&string_name);
if stream_identity && string_identity {
return Ok((Cipher::None, Cipher::None, 0));
}
let filters = dict.dict(names::CF, r).ok_or(Error::MalformedEncryptDict(
"/CF is missing or not a dictionary",
))?;
let name = if stream_identity {
string_name
} else {
stream_name
};
let filter = filters
.dict(&name, r)
.ok_or_else(|| Error::MissingCryptFilter(name.as_bytes().into()))?;
let bits = if version == 4 {
match filter.int(names::LENGTH, r).unwrap_or(0) {
0 => dict.int(names::LENGTH, r).unwrap_or(128),
bits => bits,
}
} else {
dict.int(names::LENGTH, r).unwrap_or(256)
};
if bits < 0 {
return Err(Error::MalformedEncryptDict("/Length is negative"));
}
let bits = if bits < 40 { bits * 8 } else { bits };
let method = filter.byte_string(names::CFM, r).unwrap_or_default();
let resolved = if method == b"AESV2" || method == b"AESV3" {
Cipher::Aes
} else {
Cipher::Rc4
};
let stream = if stream_identity {
Cipher::None
} else {
resolved
};
let string = if string_identity {
Cipher::None
} else {
resolved
};
(stream, string, bits)
} else if version > 1 {
let bits = dict.int(names::LENGTH, r).unwrap_or(40);
(Cipher::Rc4, Cipher::Rc4, bits)
} else {
(Cipher::Rc4, Cipher::Rc4, 40)
};
let key_len = usize::try_from(key_bits / 8)
.map_err(|_| Error::MalformedEncryptDict("/Length is negative"))?;
let effective = if cipher == Cipher::None {
string_cipher
} else {
cipher
};
if key_len > 32 || !effective.accepts_key_len(key_len) {
return Err(Error::CipherKeyLength {
cipher: effective.label(),
len: key_len,
});
}
Ok((cipher, string_cipher, key_len))
}
fn embedded_cipher(
dict: &Dict,
version: i64,
stream_cipher: Cipher,
r: &impl Resolve,
) -> Result<Option<Cipher>, Error> {
if version < 4 {
return Ok(None);
}
let Some(name) = dict.byte_string(names::EFF, r) else {
return Ok(None);
};
if name == dict.byte_string(names::STM_F, r).unwrap_or_default() {
return Ok(None);
}
if name == names::IDENTITY.as_bytes() {
return Ok(Some(Cipher::None));
}
let filters = dict.dict(names::CF, r).ok_or(Error::MalformedEncryptDict(
"/CF is missing or not a dictionary",
))?;
let Some(filter) = filters.dict(&Name::from(name.as_slice()), r) else {
return Ok(None);
};
let method = filter.byte_string(names::CFM, r).unwrap_or_default();
let cipher = if method == b"AESV2" || method == b"AESV3" {
Cipher::Aes
} else {
Cipher::Rc4
};
Ok((cipher != stream_cipher).then_some(cipher))
}
fn crypt_filter_names(dict: &Dict, r: &impl Resolve) -> (Name, Name) {
let named = |key| match dict.byte_string(key, r) {
Some(bytes) if !bytes.is_empty() => Name::from(bytes.as_slice()),
_ => names::IDENTITY.clone(),
};
(named(names::STM_F), named(names::STR_F))
}
fn is_identity(name: &Name) -> bool {
name.as_bytes() == names::IDENTITY.as_bytes()
}
fn pad_password(password: &[u8]) -> [u8; 32] {
let mut out = [0u8; 32];
let taken = password.len().min(out.len());
if let (Some(head), Some(source)) = (out.get_mut(..taken), password.get(..taken)) {
head.copy_from_slice(source);
}
if let (Some(tail), Some(fill)) = (out.get_mut(taken..), PAD.get(..32 - taken)) {
tail.copy_from_slice(fill);
}
out
}
fn file_key_r234(
p: &EncryptParams,
password: &[u8],
file_id: &[u8],
ignore_metadata: bool,
) -> SmallKey {
let passcode = pad_password(password);
let perm = p.permissions.to_le_bytes();
let metadata_tag = [0xFFu8; 4];
let revision_3_or_later = p.revision >= 3;
let mut parts: Vec<&[u8]> = vec![&passcode, &p.o, &perm];
if !file_id.is_empty() {
parts.push(file_id);
}
if !ignore_metadata && revision_3_or_later && !p.encrypt_metadata {
parts.push(&metadata_tag);
}
let mut digest = md5_parts(&parts);
let copy_len = p.key_len.min(digest.len());
if revision_3_or_later {
for _ in 0..50 {
digest = digest.get(..copy_len).map_or(digest, md5);
}
}
SmallKey::from_prefix(&digest, p.key_len)
}
fn check_user_password_r234(
p: &EncryptParams,
password: &[u8],
file_id: &[u8],
ignore_metadata: bool,
) -> Option<SmallKey> {
let key = file_key_r234(p, password, file_id, ignore_metadata);
let stored = p.u.get(..16)?;
if p.revision == 2 {
let encrypted = rc4(key.bytes(), &PAD);
return encrypted
.get(..16)
.is_some_and(|got| ct_eq(got, stored))
.then_some(key);
}
let mut test = [0u8; 32];
let copied = p.u.len().min(test.len());
if let (Some(head), Some(source)) = (test.get_mut(..copied), p.u.get(..copied)) {
head.copy_from_slice(source);
}
let mut round_key = [0u8; 32];
for round in (0..20u8).rev() {
for (slot, byte) in round_key.iter_mut().zip(key.bytes()) {
*slot = byte ^ round;
}
rc4_in_place(round_key.get(..key.len()).unwrap_or_default(), &mut test);
}
let expected = if file_id.is_empty() {
md5(&PAD)
} else {
md5_parts(&[&PAD, file_id])
};
test.get(..16)
.zip(expected.get(..16))
.is_some_and(|(got, want)| ct_eq(got, want))
.then_some(key)
}
fn recover_user_password(p: &EncryptParams, owner_password: &[u8]) -> Vec<u8> {
let Some(stored) = p.o.get(..32) else {
return Vec::new();
};
let mut digest = md5(&pad_password(owner_password));
if p.revision >= 3 {
for _ in 0..50 {
digest = md5(&digest);
}
}
let key = SmallKey::from_prefix(&digest, p.key_len);
let mut buf = [0u8; 32];
buf.copy_from_slice(stored);
if p.revision == 2 {
rc4_in_place(key.bytes(), &mut buf);
} else {
let mut round_key = [0u8; 32];
for round in (0..20u8).rev() {
for (slot, byte) in round_key.iter_mut().zip(key.bytes()) {
*slot = byte ^ round;
}
rc4_in_place(round_key.get(..key.len()).unwrap_or_default(), &mut buf);
}
}
let mut len = buf.len();
while len > 0 && PAD.get(len - 1) == buf.get(len - 1) {
len -= 1;
}
buf.get(..len).unwrap_or_default().to_vec()
}
fn check_password_aes256(p: &EncryptParams, password: &[u8], owner: bool) -> Option<[u8; 32]> {
let owner_entry: &[u8; 48] = p.o.get(..48)?.try_into().ok()?;
let user_entry: &[u8; 48] = p.u.get(..48)?.try_into().ok()?;
let entry = if owner { owner_entry } else { user_entry };
let vector = owner.then_some(user_entry);
let validation_salt: [u8; 8] = entry.get(32..40)?.try_into().ok()?;
let key_salt: [u8; 8] = entry.get(40..48)?.try_into().ok()?;
let hash = |salt: [u8; 8]| -> [u8; 32] {
if p.revision >= 6 {
revision6_hash(password, salt, vector)
} else {
match vector {
Some(v) => sha256_parts(&[password, &salt, v]),
None => sha256_parts(&[password, &salt]),
}
}
};
if !entry
.get(..32)
.is_some_and(|got| ct_eq(got, hash(validation_salt).as_slice()))
{
return None;
}
let intermediate = hash(key_salt);
let encrypted = if owner { &p.oe } else { &p.ue };
let mut file_key: [u8; 32] = encrypted.get(..32)?.try_into().ok()?;
aes_cbc_decrypt(&intermediate, &[0u8; BLOCK], &mut file_key).ok()?;
check_perms(p, &file_key).then_some(file_key)
}
fn check_perms(p: &EncryptParams, file_key: &[u8; 32]) -> bool {
if p.perms.is_empty() {
return false;
}
let mut block = [0u8; BLOCK];
let copied = p.perms.len().min(block.len());
match (block.get_mut(..copied), p.perms.get(..copied)) {
(Some(head), Some(source)) => head.copy_from_slice(source),
_ => return false,
}
if aes_cbc_decrypt(file_key, &[0u8; BLOCK], &mut block).is_err() {
return false;
}
if block.get(9..12) != Some(b"adb") {
return false;
}
let Some(word) = block.get(..4).and_then(|w| <[u8; 4]>::try_from(w).ok()) else {
return false;
};
if u32::from_le_bytes(word) != p.permissions {
return false;
}
block.get(8) == Some(&b'F') || p.encrypt_metadata
}
pub(crate) fn revision6_hash(
password: &[u8],
salt: [u8; 8],
vector: Option<&[u8; 48]>,
) -> [u8; 32] {
revision6_hash_counted(password, salt, vector).0
}
fn revision6_hash_counted(
password: &[u8],
salt: [u8; 8],
vector: Option<&[u8; 48]>,
) -> ([u8; 32], u32) {
let mut state: Vec<u8> = match vector {
Some(v) => sha256_parts(&[password, &salt, v]),
None => sha256_parts(&[password, &salt]),
}
.to_vec();
let mut block_size = 32usize;
let mut round = 0u32;
loop {
let piece = state.get(..block_size).unwrap_or(&state);
let mut content = Vec::with_capacity(64 * (password.len() + piece.len() + 48));
for _ in 0..64 {
content.extend_from_slice(password);
content.extend_from_slice(piece);
if let Some(v) = vector {
content.extend_from_slice(v);
}
}
let (Some(key), Some(iv)) = (
state.get(..16),
state.get(16..32).and_then(|s| <[u8; 16]>::try_from(s).ok()),
) else {
return ([0u8; 32], round);
};
if aes_cbc_encrypt(key, &iv, &mut content).is_err() {
return ([0u8; 32], round);
}
let Some(head) = content.get(..16) else {
return ([0u8; 32], round);
};
state = match big_order_64_bits_mod3(head) {
0 => {
block_size = 32;
sha256(&content).to_vec()
}
1 => {
block_size = 48;
sha384(&content).to_vec()
}
_ => {
block_size = 64;
sha512(&content).to_vec()
}
};
round += 1;
let last = u32::from(content.last().copied().unwrap_or(0));
if round >= 64 && round.saturating_sub(32) >= last {
break;
}
}
let hash = state
.get(..32)
.and_then(|s| <[u8; 32]>::try_from(s).ok())
.unwrap_or([0u8; 32]);
(hash, round)
}
fn big_order_64_bits_mod3(data: &[u8]) -> u64 {
let mut acc = 0u64;
for chunk in data.as_chunks::<4>().0.iter().take(4) {
let word = u32::from_be_bytes(*chunk);
acc = ((acc << 32) | u64::from(word)) % 3;
}
acc
}
const R6_PASSWORD_BYTES: usize = 127;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PasswordEncoding {
#[default]
AsGiven,
SaslPrepped,
Latin1ToUtf8,
Utf8ToLatin1,
}
#[derive(Debug, Clone)]
pub(crate) struct Unlocked {
pub key: SmallKey,
pub encoding: PasswordEncoding,
}
pub(crate) fn try_password(
p: &EncryptParams,
password: &[u8],
owner: bool,
file_id: &[u8],
) -> Option<Unlocked> {
if let Some(prepped) = r6_prepared(p.revision, password)
&& prepped.as_slice() != password.get(..R6_PASSWORD_BYTES).unwrap_or(password)
&& let Some(key) = check_password(p, &prepped, owner, file_id)
{
return Some(Unlocked {
key,
encoding: PasswordEncoding::SaslPrepped,
});
}
if let Some(key) = check_password(p, password, owner, file_id) {
return Some(Unlocked {
key,
encoding: PasswordEncoding::AsGiven,
});
}
if password.is_ascii() {
return None;
}
let (converted, encoding) = if p.revision >= 5 {
(latin1_to_utf8(password), PasswordEncoding::Latin1ToUtf8)
} else {
(utf8_to_latin1(password), PasswordEncoding::Utf8ToLatin1)
};
check_password(p, &converted, owner, file_id).map(|key| Unlocked { key, encoding })
}
pub(crate) fn r6_prepared(revision: i64, password: &[u8]) -> Option<Vec<u8>> {
if revision != 6 {
return None;
}
let text = core::str::from_utf8(password).ok()?;
let prepared = crate::saslprep::saslprep(text)?;
let mut bytes = prepared.into_bytes();
bytes.truncate(R6_PASSWORD_BYTES);
Some(bytes)
}
fn check_password(
p: &EncryptParams,
password: &[u8],
owner: bool,
file_id: &[u8],
) -> Option<SmallKey> {
if p.revision >= 5 {
let capped = password.get(..R6_PASSWORD_BYTES).unwrap_or(password);
return check_password_aes256(p, capped, owner).map(SmallKey::from_full);
}
let effective = if owner {
recover_user_password(p, password)
} else {
password.to_vec()
};
check_user_password_r234(p, &effective, file_id, false)
.or_else(|| check_user_password_r234(p, &effective, file_id, true))
}
fn latin1_to_utf8(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
for &byte in bytes {
let mut buf = [0u8; 4];
out.extend_from_slice(char::from(byte).encode_utf8(&mut buf).as_bytes());
}
out
}
fn utf8_to_latin1(bytes: &[u8]) -> Vec<u8> {
const MAX_CODE_POINT: u32 = 0x0010_FFFF;
let mut out = Vec::with_capacity(bytes.len());
let mut remaining = 0u32;
let mut code_point = 0u32;
let emit = |cp: u32, out: &mut Vec<u8>| {
if cp <= MAX_CODE_POINT {
#[expect(clippy::cast_possible_truncation, reason = "narrowing is the semantic")]
out.push(cp as u8);
}
};
for &unit in bytes {
match unit {
0x00..=0x7F => {
remaining = 0;
emit(u32::from(unit), &mut out);
}
0x80..=0xBF => {
if remaining > 0 {
remaining -= 1;
code_point = (code_point << 6) | u32::from(unit & 0x3F);
if remaining == 0 {
emit(code_point, &mut out);
}
}
}
0xC0..=0xDF => {
remaining = 1;
code_point = u32::from(unit & 0x1F);
}
0xE0..=0xEF => {
remaining = 2;
code_point = u32::from(unit & 0x0F);
}
0xF0..=0xF7 => {
remaining = 3;
code_point = u32::from(unit & 0x07);
}
0xF8..=0xFF => remaining = 0,
}
}
out
}
#[cfg(test)]
mod tests {
use super::{
Cipher, PasswordEncoding, big_order_64_bits_mod3, latin1_to_utf8, pad_password,
recover_user_password, revision6_hash, revision6_hash_counted, try_password,
utf8_to_latin1,
};
use crate::test_fixtures::{self, unhex};
#[test]
fn padding_fills_from_the_front_of_the_pad() {
assert_eq!(pad_password(b""), super::PAD);
let padded = pad_password(b"abc");
assert_eq!(padded.get(..3), Some(&b"abc"[..]));
assert_eq!(padded.get(3..), super::PAD.get(..29));
}
#[test]
fn a_long_password_is_truncated_to_thirty_two_bytes() {
let long = [b'z'; 40];
assert_eq!(pad_password(&long), [b'z'; 32]);
assert_eq!(pad_password(&long), pad_password(&long[..32]));
}
#[test]
fn the_hardened_hash_runs_between_sixty_four_and_two_hundred_eighty_seven_rounds() {
for seed in 0..6u8 {
let salt = [seed; 8];
let vector = [seed.wrapping_mul(3); 48];
for vec in [None, Some(&vector)] {
let (_, rounds) = revision6_hash_counted(b"password", salt, vec);
assert!(
(64..=287).contains(&rounds),
"{rounds} rounds for seed {seed}"
);
}
}
}
#[test]
fn the_hardened_hash_depends_on_every_input() {
let salt = [1u8; 8];
let vector = [2u8; 48];
let plain = revision6_hash(b"pw", salt, None);
assert_ne!(plain, revision6_hash(b"pw", salt, Some(&vector)));
assert_ne!(plain, revision6_hash(b"pX", salt, None));
assert_ne!(plain, revision6_hash(b"pw", [2u8; 8], None));
assert_eq!(plain, revision6_hash(b"pw", salt, None));
}
#[test]
fn the_hardened_hash_accepts_an_empty_password() {
let (hash, rounds) = revision6_hash_counted(b"", [0u8; 8], None);
assert_ne!(hash, [0u8; 32]);
assert!((64..=287).contains(&rounds));
}
#[test]
fn mod3_fold_agrees_with_the_byte_sum() {
for seed in 0..64u8 {
let data: Vec<u8> = (0..16u8)
.map(|i| i.wrapping_mul(seed).wrapping_add(i))
.collect();
let sum: u32 = data.iter().map(|&b| u32::from(b)).sum();
assert_eq!(
big_order_64_bits_mod3(&data),
u64::from(sum % 3),
"data {data:?}"
);
}
}
#[test]
fn mod3_fold_reads_only_the_first_sixteen_bytes() {
let mut data = vec![0u8; 32];
assert_eq!(big_order_64_bits_mod3(&data), 0);
if let Some(byte) = data.get_mut(20) {
*byte = 1;
}
assert_eq!(big_order_64_bits_mod3(&data), 0);
if let Some(byte) = data.get_mut(3) {
*byte = 1;
}
assert_eq!(big_order_64_bits_mod3(&data), 1);
}
#[test]
fn latin1_widening_is_a_scalar_per_byte() {
assert_eq!(latin1_to_utf8(b"\xe2ge"), b"\xc3\xa2ge");
assert_eq!(latin1_to_utf8(b"h\xf4tel"), b"h\xc3\xb4tel");
assert_eq!(latin1_to_utf8(b"ascii"), b"ascii");
assert!(latin1_to_utf8(b"").is_empty());
}
#[test]
fn utf8_narrowing_keeps_the_low_byte() {
assert_eq!(utf8_to_latin1(b"\xc3\xa2ge"), b"\xe2ge");
assert_eq!(utf8_to_latin1(b"h\xc3\xb4tel"), b"h\xf4tel");
assert_eq!(utf8_to_latin1(b"ascii"), b"ascii");
assert_eq!(utf8_to_latin1("\u{2ae2}".as_bytes()), b"\xe2");
}
#[test]
fn utf8_narrowing_drops_invalid_bytes_silently() {
assert_eq!(utf8_to_latin1(b"a\x80b"), b"ab");
assert_eq!(utf8_to_latin1(b"a\xc3"), b"a");
assert_eq!(utf8_to_latin1(b"\xf8\xff"), b"");
assert_eq!(utf8_to_latin1(b"a\xc3\xa2"), b"a\xe2");
}
#[test]
fn conversions_round_trip_on_latin1_text() {
for text in [&b"\xe2ge"[..], b"h\xf4tel", b"", b"plain"] {
assert_eq!(utf8_to_latin1(&latin1_to_utf8(text)), text);
}
}
#[test]
fn revision_2_fixture() {
let p = test_fixtures::r2();
let id = unhex("2b778de1bcef1733b35e680882812409");
assert_eq!((p.cipher, p.key_len), (Cipher::Rc4, 5));
for owner_password in [&b"\xe2ge"[..], b"\xc3\xa2ge"] {
let unlocked = try_password(&p, owner_password, true, &id)
.unwrap_or_else(|| panic!("owner {owner_password:?}"));
assert_eq!(unlocked.key.len(), 5);
}
for user_password in [&b"h\xf4tel"[..], b"h\xc3\xb4tel"] {
assert!(
try_password(&p, user_password, false, &id).is_some(),
"user {user_password:?}"
);
}
assert!(try_password(&p, b"tiger", true, &id).is_none());
assert!(try_password(&p, b"tiger", false, &id).is_none());
}
#[test]
fn revision_2_records_the_encoding_that_worked() {
let p = test_fixtures::r2();
let id = unhex("2b778de1bcef1733b35e680882812409");
let latin1 = try_password(&p, b"\xe2ge", true, &id).expect("latin-1 owner");
assert_eq!(latin1.encoding, PasswordEncoding::AsGiven);
let utf8 = try_password(&p, b"\xc3\xa2ge", true, &id).expect("utf-8 owner");
assert_eq!(utf8.encoding, PasswordEncoding::Utf8ToLatin1);
assert_eq!(latin1.key.bytes(), utf8.key.bytes());
}
#[test]
fn revision_3_fixture() {
let p = test_fixtures::r3();
let id = unhex("9b744068bb5efbe920baaba6da63c2bf");
assert_eq!((p.cipher, p.key_len), (Cipher::Rc4, 16));
assert_eq!(p.u.get(16..), Some(&[0u8; 16][..]));
for owner_password in [&b"\xe2ge"[..], b"\xc3\xa2ge"] {
assert!(
try_password(&p, owner_password, true, &id).is_some(),
"owner {owner_password:?}"
);
}
for user_password in [&b"h\xf4tel"[..], b"h\xc3\xb4tel"] {
let unlocked = try_password(&p, user_password, false, &id)
.unwrap_or_else(|| panic!("user {user_password:?}"));
assert_eq!(unlocked.key.len(), 16);
}
assert!(try_password(&p, b"tiger", false, &id).is_none());
}
#[test]
fn short_owner_entry_recovers_nothing() {
for len in [0usize, 1, 16, 31] {
let mut p = test_fixtures::r3();
p.o = vec![0xAB; len].into();
assert!(
recover_user_password(&p, b"a").is_empty(),
"/O of {len} bytes"
);
let id = unhex("9b744068bb5efbe920baaba6da63c2bf");
assert!(try_password(&p, b"a", true, &id).is_none());
}
}
#[test]
fn a_missing_file_id_changes_the_derived_key() {
let p = test_fixtures::r3();
let id = unhex("9b744068bb5efbe920baaba6da63c2bf");
assert!(try_password(&p, b"h\xf4tel", false, &id).is_some());
assert!(try_password(&p, b"h\xf4tel", false, &[]).is_none());
}
#[test]
fn the_pdfjs_saslprep_fixture_needs_the_preparation() {
let dict = test_fixtures::saslprep_r6_dict();
let p = super::parse_encrypt_dict(&dict, &pdfrum_object::NoResolve)
.unwrap_or_else(|e| panic!("{e:?}"));
let raw = "S\u{00AA}SL\u{00AD}prep".as_bytes();
let unlocked =
try_password(&p, raw, false, &[]).unwrap_or_else(|| panic!("the prepared candidate"));
assert_eq!(unlocked.encoding, PasswordEncoding::SaslPrepped);
let prepped = try_password(&p, b"SaSLprep", false, &[])
.unwrap_or_else(|| panic!("the prepared spelling"));
assert_eq!(prepped.encoding, PasswordEncoding::AsGiven);
assert_eq!(unlocked.key.bytes(), prepped.key.bytes());
assert!(try_password(&p, b"SASLprep", false, &[]).is_none());
}
#[test]
fn a_decomposed_and_a_composed_password_prepare_alike() {
assert_eq!(
super::r6_prepared(6, "cafe\u{0301}".as_bytes()),
super::r6_prepared(6, "caf\u{00E9}".as_bytes()),
);
assert_eq!(
super::r6_prepared(6, "cafe\u{0301}".as_bytes()).as_deref(),
Some("caf\u{00E9}".as_bytes()),
);
}
#[test]
fn only_revision_six_is_prepared() {
let decomposed = "cafe\u{0301}".as_bytes();
assert!(super::r6_prepared(6, decomposed).is_some());
for revision in [2i64, 3, 4, 5] {
assert_eq!(
super::r6_prepared(revision, decomposed),
None,
"R{revision}"
);
}
}
#[test]
fn the_cut_is_at_byte_one_hundred_twenty_seven_not_at_a_character() {
let mut password = "a".repeat(126);
password.push('\u{00E9}');
let prepared =
super::r6_prepared(6, password.as_bytes()).unwrap_or_else(|| panic!("preparable"));
assert_eq!(prepared.len(), 127);
assert_eq!(prepared.get(126), Some(&0xC3));
assert!(core::str::from_utf8(&prepared).is_err());
let long = "z".repeat(130);
let cut = super::r6_prepared(6, long.as_bytes()).unwrap_or_else(|| panic!("preparable"));
assert_eq!(cut, "z".repeat(127).into_bytes());
}
#[test]
fn a_password_past_one_hundred_twenty_seven_bytes_is_cut_for_every_candidate() {
let dict = test_fixtures::saslprep_r6_dict();
let p = super::parse_encrypt_dict(&dict, &pdfrum_object::NoResolve)
.unwrap_or_else(|e| panic!("{e:?}"));
let mut overlong = b"SaSLprep".to_vec();
overlong.resize(200, b'!');
assert!(try_password(&p, &overlong, false, &[]).is_none());
let mut padded = b"SaSLprep".to_vec();
padded.resize(127, b'!');
let short = try_password(&p, &padded, false, &[]);
let mut long = padded.clone();
long.resize(130, b'?');
assert_eq!(
short.is_some(),
try_password(&p, &long, false, &[]).is_some()
);
}
#[test]
fn a_prohibited_password_falls_through_to_the_raw_bytes() {
assert_eq!(super::r6_prepared(6, "a\u{202A}b".as_bytes()), None);
assert_eq!(super::r6_prepared(6, b"\xe2ge"), None);
let dict = test_fixtures::r6_dict();
let p = super::parse_encrypt_dict(&dict, &pdfrum_object::NoResolve)
.unwrap_or_else(|e| panic!("{e:?}"));
let raw = try_password(&p, b"h\xf4tel", false, &[])
.unwrap_or_else(|| panic!("the transcode candidate"));
assert_eq!(raw.encoding, PasswordEncoding::Latin1ToUtf8);
let utf8 = try_password(&p, "h\u{00F4}tel".as_bytes(), false, &[])
.unwrap_or_else(|| panic!("the bytes as given"));
assert_eq!(utf8.encoding, PasswordEncoding::AsGiven);
}
#[test]
fn an_ascii_password_reports_the_bytes_as_given() {
let dict = test_fixtures::r6_dict();
let p = super::parse_encrypt_dict(&dict, &pdfrum_object::NoResolve)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(try_password(&p, b"tiger", false, &[]).is_none());
assert_eq!(
super::r6_prepared(6, b"tiger").as_deref(),
Some(&b"tiger"[..])
);
}
}