mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:59:48 +00:00
21e7903ff2
Adds crates/crypt/tests/proptests.rs (464 lines): eight properties over the payload and ciphertext side of the standard security handler. Tests only - no product code, no new dependency (proptest was already a dev-dependency here). Upstream's src/tests.rs covers this handler well with fixed vectors (including files qpdf wrote) and one property over malformed dictionaries. Its encrypted round trips run three shapes - a 30-byte text, a 1000-byte ramp and a 10-byte attachment. Not walked before: empty content, block boundaries (15/17), bytes handed to the decoder at lengths it never encrypted, key widths only key_len decides (5/16/16/32), /Perms verified by nothing, and Algorithm 2 step f for unencrypted metadata on revisions below 4. Evidence: 25 one-line breakages of crypt/src, each run against these properties AND upstream --lib; restored and byte-compared per row; verdicts from cargo exit codes. 16 seen here, several by nothing else in the repository (/Num fold width both directions, RC4-40 key length, /EFF selection, Identity name guard, IV scoping, step f, /Perms tail). Named blind spots: four hash_r6 breaks stay green because create() and open() share that function - the companion branch quotes qpdf's own numbers to close them. Gates: cargo test -p pdfcraft-crypt 25 passed / 0 failed; clippy --all-targets -D warnings clean; fmt clean. Default 256 cases, nothing lowered. Co-authored-by: Nick Nyssen <314538108+amoisttowe1@users.noreply.github.com>
465 lines
30 KiB
Rust
465 lines
30 KiB
Rust
//! Property tests for what the security handler does to *content*, and to the ciphertext a
|
||
//! stranger's file hands back.
|
||
//!
|
||
//! `src/tests.rs` covers this handler carefully with fixed vectors — including the `/O`, `/U` and
|
||
//! file keys qpdf 12.4.2 produced for real files — plus one property over malformed dictionaries.
|
||
//! What it does not vary is the *payload*. Upstream's encrypted round trips run three shapes: a
|
||
//! 30-byte text, a 1000-byte ramp, and a 10-byte attachment in `identity_crypt_filters_leave_data_alone`.
|
||
//! The shapes nobody walks are the ones that break CBC and readers: empty content, one block minus
|
||
//! one, a ciphertext cut short by a damaged `/Length`, and keys of a length `create` never builds.
|
||
//!
|
||
//! - plaintext survives encryption at every length, for each algorithm and each crypt filter;
|
||
//! - truncated or mutated ciphertext produces bytes rather than a panic (`AGENTS.md` §4: PdfCraft
|
||
//! opens files from strangers, and encrypted streams in the wild do get truncated);
|
||
//! - object keys are scoped to their object, and AES-256's documented exception still holds;
|
||
//! - a file opens for exactly the passwords whose effective bytes match, stated as a rule over
|
||
//! generated Unicode passwords instead of the three examples in `src/tests.rs` (the #1185 rule);
|
||
//! - the AES-CBC and RC4 primitives invert each other for any key length, since `/Length` and
|
||
//! crypt filters from a damaged file reach them with shapes `create` would never build.
|
||
|
||
use pdfcraft_crypt::{
|
||
Algorithm, Auth, CryptError, Method, NewEncryption, PADDING, SecurityHandler, StreamKind, aes_cbc_decrypt, aes_cbc_encrypt, create,
|
||
effective_password, pdfdoc_byte, rc4,
|
||
};
|
||
use proptest::prelude::*;
|
||
|
||
/// The first element of `/ID`, which every R2–R4 derivation hashes in (Algorithm 2).
|
||
const ID: &[u8] = b"\x01\x23\x45\x67\x89\xab\xcd\xef\x0f\xed\xcb\xa9\x87\x65\x43\x21";
|
||
|
||
fn algorithms() -> impl Strategy<Value = Algorithm> {
|
||
prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256])
|
||
}
|
||
|
||
/// Content at the lengths where CBC padding goes wrong, plus anything in between.
|
||
fn payloads() -> impl Strategy<Value = Vec<u8>> {
|
||
prop_oneof![
|
||
Just(Vec::new()),
|
||
Just(vec![0x5cu8; 1]),
|
||
Just(vec![0xa7u8; 15]),
|
||
Just(vec![0x3fu8; 16]),
|
||
Just(vec![0x9du8; 17]),
|
||
Just(vec![0x10u8; 32]),
|
||
prop::collection::vec(any::<u8>(), 1..96),
|
||
]
|
||
}
|
||
|
||
/// Passwords from an alphabet the two encodings disagree about: SASLprep deletes U+00AD, folds
|
||
/// U+FF21 to "A", U+FB01 to "fi" and U+3000 to a space (revisions 5–6), while PDFDocEncoding has
|
||
/// no byte for 'ğ' or CJK at all, which sends revisions 2–4 down their UTF-8 fallback.
|
||
const PASSWORD_CHARS: [char; 12] = ['a', 'B', '7', ' ', '-', '\u{AD}', '\u{FF21}', '\u{FB01}', 'ı', 'ğ', '日', '\u{A0}'];
|
||
|
||
fn passwords() -> impl Strategy<Value = String> {
|
||
prop::collection::vec(prop::sample::select(PASSWORD_CHARS.to_vec()), 0..12).prop_map(|chars| chars.into_iter().collect())
|
||
}
|
||
|
||
/// `bytes` cut to 32 and padded with the standard padding string (Algorithm 2, step a).
|
||
fn padded(bytes: &[u8]) -> Vec<u8> {
|
||
let mut out = PADDING;
|
||
let n = bytes.len().min(32);
|
||
out[..n].copy_from_slice(&bytes[..n]);
|
||
out.to_vec()
|
||
}
|
||
|
||
/// Every byte string the handler may authenticate `pw` as. Revisions 2–4 use PDFDocEncoding, and
|
||
/// for a password holding characters it lacks they also try the raw UTF-8 bytes (qpdf writes such
|
||
/// passwords that way); revisions 5–6 use the SASLprep bytes. `effective_password` publishes the
|
||
/// first of these per algorithm, and it is what callers such as Protect check before encrypting.
|
||
fn candidates(algorithm: Algorithm, pw: &str) -> Vec<Vec<u8>> {
|
||
let mut out = vec![effective_password(algorithm, pw)];
|
||
if algorithm != Algorithm::Aes256 && pw.chars().any(|c| pdfdoc_byte(c).is_none()) {
|
||
out.push(padded(&pw.as_bytes().iter().copied().take(32).collect::<Vec<u8>>()));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// What `create` keys `/O` from: the owner password, except on revisions 2–4 with no owner
|
||
/// password given, where it falls back to the user password's bytes.
|
||
fn owner_target(algorithm: Algorithm, user: &str, owner: &str) -> Vec<u8> {
|
||
if algorithm != Algorithm::Aes256 && owner.is_empty() { effective_password(algorithm, user) } else { effective_password(algorithm, owner) }
|
||
}
|
||
|
||
/// Which password `pw` is for this file. Owners are tried before users, so bytes that match both
|
||
/// authenticate as owner — the order viewers use. `None` means it opens nothing.
|
||
fn expected_auth(algorithm: Algorithm, user_target: &[u8], owner_target: &[u8], pw: &str) -> Option<Auth> {
|
||
let tried = candidates(algorithm, pw);
|
||
if tried.iter().any(|c| c == owner_target) {
|
||
Some(Auth::Owner)
|
||
} else if tried.iter().any(|c| c == user_target) {
|
||
Some(Auth::User)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn make(algorithm: Algorithm, user: &str, owner: &str) -> SecurityHandler {
|
||
create(&NewEncryption { algorithm, user_password: user, owner_password: owner, permissions: -1, encrypt_metadata: true, seed: [0x5e; 32] }, ID)
|
||
.unwrap()
|
||
}
|
||
|
||
proptest! {
|
||
/// Encrypting then decrypting returns the input at every length, as a string and as each kind
|
||
/// of stream — and is byte-for-byte reproducible, because re-running a save must not silently
|
||
/// produce different bytes. Payloads of at least one block must come back changed: RC4 would
|
||
/// need an all-zero keystream to leave them alone, and CBC cannot shrink a padded block.
|
||
#[test]
|
||
fn content_survives_the_trip_at_every_length(alg in algorithms(), data in payloads()) {
|
||
let h = make(alg, "user", "owner");
|
||
|
||
// The key width each algorithm promises: 5 bytes for export-grade 40-bit RC4, 16 for
|
||
// AES-128 and RC4-128, 32 for AES-256. `key_len` is the only place that decides this and
|
||
// nothing in either suite reads it back, so `if d.r == 2 { 5 }` becoming `{ 4 }` today
|
||
// produces a file key of the wrong size that every test still calls fine.
|
||
let want_len = match alg { Algorithm::Rc4_40 => 5, Algorithm::Aes128 | Algorithm::Rc4_128 => 16, Algorithm::Aes256 => 32 };
|
||
prop_assert_eq!(h.file_key().len(), want_len, "{:?} must use a {} byte file key", alg, want_len);
|
||
|
||
let h = make(alg, "user", "owner");
|
||
for num in [0u32, 1, 4096, u32::MAX] {
|
||
let generation = (num % 65_535) as u16;
|
||
|
||
let enc = h.encrypt_string(num, generation, &data);
|
||
prop_assert_eq!(h.decrypt_string(num, generation, &enc), &data[..], "string round trip at {} bytes ({:?})", data.len(), alg);
|
||
prop_assert_eq!(h.encrypt_string(num, generation, &data), enc, "string encryption must be reproducible ({:?})", alg);
|
||
|
||
for kind in [StreamKind::Normal, StreamKind::EmbeddedFile, StreamKind::Named(b"StdCF")] {
|
||
let enc = h.encrypt_stream(num, generation, &data, kind);
|
||
prop_assert_eq!(h.decrypt_stream(num, generation, &enc, kind), &data[..], "stream round trip at {} bytes ({:?}, {:?})", data.len(), alg, kind);
|
||
prop_assert_eq!(h.encrypt_stream(num, generation, &data, kind), enc, "{:?} {:?} encryption must be reproducible", alg, kind);
|
||
}
|
||
|
||
if data.len() >= 16 {
|
||
prop_assert_ne!(h.encrypt_string(num, generation, &data), &data[..], "one block left in plain sight ({:?})", alg);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Ciphertext of any length — truncated by a damaged `/Length`, padded out with garbage, or
|
||
/// byte-flipped inside — must decrypt to bytes and never read past the end. AES discards a
|
||
/// trailing partial block and leaves invalid padding in place rather than failing, so its
|
||
/// output can only be a whole number of blocks minus at most one; RC4 keeps its length
|
||
/// whatever the bytes are. An input shorter than the IV has nothing left to decode.
|
||
#[test]
|
||
fn damaged_ciphertext_never_panics(alg in algorithms(), enc in prop::collection::vec(any::<u8>(), 0..64)) {
|
||
let h = make(alg, "user", "owner");
|
||
for cut in 0..=enc.len() {
|
||
let broken = &enc[..cut];
|
||
let out = h.decrypt_string(7, 3, broken);
|
||
if alg == Algorithm::Rc4_40 || alg == Algorithm::Rc4_128 {
|
||
prop_assert_eq!(out.len(), cut, "RC4 must be length-preserving ({:?})", alg);
|
||
} else {
|
||
let blocks = cut.saturating_sub(16) / 16;
|
||
prop_assert!(out.len() <= blocks * 16, "{:?}: {} bytes of ciphertext yielded {} decrypted bytes", alg, cut, out.len());
|
||
prop_assert!(blocks * 16 - out.len() <= 16, "{:?}: padding removed more than one block", alg);
|
||
}
|
||
}
|
||
|
||
// Flip every byte of a real ciphertext in turn: no position may crash the reader.
|
||
let full = h.encrypt_string(7, 3, b"twenty-four bytes of PDF content");
|
||
for i in 0..full.len() {
|
||
let mut mutated = full.clone();
|
||
mutated[i] ^= 0xFF;
|
||
let _ = h.decrypt_string(7, 3, &mutated);
|
||
}
|
||
}
|
||
|
||
/// A crypt filter name decides whether content is encrypted at all: PDF 2.0 permits
|
||
/// `/StmF /Identity` with attachments still encrypted, and a name that appears in neither `/CF`
|
||
/// nor as `Identity` resolves to Identity rather than failing. The reported answer and the
|
||
/// bytes must agree, at every length including empty (where AES still adds a whole block).
|
||
/// `/EFF` is set here so embedded files and normal streams must take *different* paths: the
|
||
/// mix-up that leaves an attachment in plain sight, or encrypts a stream its own filter says
|
||
/// should not be. And sometimes `/CF` carries an entry *named* `Identity` that claims
|
||
/// `/CFM /AESV2`: a producer writing nonsense. The name wins and the bytes stay plain, since
|
||
/// encrypting what both sides agreed is plaintext would write a file other readers cannot open.
|
||
#[test]
|
||
fn crypt_filters_decide_what_gets_encrypted(
|
||
data in payloads(),
|
||
name in prop::sample::select(vec!["Identity", "StdCF", "Nope"]),
|
||
bogus_identity_in_cf in any::<bool>(),
|
||
) {
|
||
let mut d = make(Algorithm::Aes256, "", "owner").dict().clone();
|
||
d.str_f = name.as_bytes().to_vec();
|
||
d.stm_f = b"Identity".to_vec();
|
||
d.ef_f = b"StdCF".to_vec();
|
||
if bogus_identity_in_cf {
|
||
d.crypt_filters.push((b"Identity".to_vec(), Method::Aes256));
|
||
}
|
||
let h = SecurityHandler::open(d, ID, None).unwrap();
|
||
|
||
// Only `StdCF` really encrypts; `Identity` does not even when `/CF` defines it, and a name
|
||
// in neither place resolves to Identity rather than failing.
|
||
let strings_encrypted = name == "StdCF";
|
||
prop_assert_eq!(h.encrypts_strings(), strings_encrypted, "/StrF /{} reported {}", name, strings_encrypted);
|
||
|
||
let enc = h.encrypt_string(3, 0, &data);
|
||
if strings_encrypted {
|
||
prop_assert_ne!(&enc, &data, "/StrF /{} left strings in plain sight", name);
|
||
prop_assert!(enc.len() > data.len(), "AES always pads: {} bytes of ciphertext for {} plaintext", enc.len(), data.len());
|
||
prop_assert_eq!(h.decrypt_string(3, 0, &enc), &data[..], "/StrF /{} round trip", name);
|
||
} else {
|
||
prop_assert_eq!(&enc, &data, "/StrF /{} must pass strings through", name);
|
||
}
|
||
|
||
// A normal stream follows `/StmF /Identity` here, whatever the string filter says.
|
||
prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Normal), &data[..], "normal streams follow /StmF Identity");
|
||
// An embedded file follows `/EFF /StdCF`, so it is encrypted at every length — empty
|
||
// included, where AES still emits a padded block — and comes back intact.
|
||
let ef = h.encrypt_stream(3, 0, &data, StreamKind::EmbeddedFile);
|
||
prop_assert_ne!(&ef, &data[..], "/EFF /StdCF left an embedded file in plain sight at {} bytes", data.len());
|
||
prop_assert_eq!(h.decrypt_stream(3, 0, &ef, StreamKind::EmbeddedFile), &data[..], "embedded file round trip");
|
||
// An unknown named filter is Identity, not an error; a known one uses its real method.
|
||
prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"Missing")), &data[..], "an unknown crypt filter must not encrypt");
|
||
prop_assert_ne!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"StdCF")), &data[..], "a filter named /StdCF must encrypt");
|
||
}
|
||
|
||
/// Object keys are scoped to the object (Algorithm 1, §7.6.3.3): RC4 and AES-128 fold the
|
||
/// object number and generation into the key, so another object must not read these bytes back.
|
||
/// AES-256 is the documented exception — revisions 5 and 6 use the file key for every object.
|
||
/// The comparisons that decrypt under a foreign number carry the weight: wrong keystream bytes
|
||
/// match the plaintext only by a chance of one in 256 per byte. Checking that *encryption*
|
||
/// differs would prove less, because our initial vector already covers `/Num`.
|
||
#[test]
|
||
fn object_keys_are_scoped_to_their_object(alg in prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128]), data in prop::collection::vec(any::<u8>(), 8..40)) {
|
||
let h = make(alg, "user", "owner");
|
||
|
||
let enc = h.encrypt_string(100, 0, &data);
|
||
prop_assert_eq!(h.decrypt_string(100, 0, &enc), &data[..], "{:?} reads its own object back", alg);
|
||
prop_assert_ne!(h.decrypt_string(101, 0, &enc), &data[..], "{:?}: another object number must not decrypt these bytes", alg);
|
||
prop_assert_ne!(h.decrypt_string(100, 1, &enc), &data[..], "{:?}: another generation must not decrypt these bytes", alg);
|
||
|
||
// Algorithm 1 folds the LOW THREE bytes of `/Num` into the key. Two directions are pinned:
|
||
// numbers that differ inside those three bytes must separate (a fold to `[..2]` would let
|
||
// object 0 and object 65_536 share a key, silently handing one object's plaintext to another),
|
||
// and numbers that differ only ABOVE them must share it — the fold is three bytes by design,
|
||
// so a widening to four would break this and nothing else in the crate notices.
|
||
let low = h.encrypt_string(0, 0, &data);
|
||
prop_assert_ne!(&h.decrypt_string(65_536, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide; 0 and 65_536 must not share a key", alg);
|
||
prop_assert_ne!(&h.decrypt_string(1 << 23, 0, &low)[..], &data[..], "{:?}: the third byte of /Num must still reach the key", alg);
|
||
prop_assert_eq!(&h.decrypt_string(1 << 24, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide, so a difference above bit 23 shares the key by design", alg);
|
||
|
||
let r6 = make(Algorithm::Aes256, "user", "owner");
|
||
let enc6 = r6.encrypt_string(100, 0, &data);
|
||
prop_assert_eq!(&r6.decrypt_string(101, 3, &enc6)[..], &data[..], "AES-256 uses the file key for every object (§7.6.3.3)");
|
||
// ...which is exactly why the initial vector has to vary: revisions 5 and 6 reuse one key
|
||
// for the whole file, so two objects holding the same bytes must still produce different
|
||
// ciphertext. Identical bytes for a different `/Num` would be CBC initial-vector reuse —
|
||
// the flaw that leaks structure in encrypted data.
|
||
prop_assert_ne!(&r6.encrypt_string(101, 0, &data)[..], &enc6[..], "AES-256 reused the same initial vector for two objects");
|
||
prop_assert_ne!(&r6.encrypt_string(100, 1, &data)[..], &enc6[..], "AES-256 reused the same initial vector across generations");
|
||
}
|
||
|
||
/// A file created for `user` and `owner` opens for exactly the passwords whose effective bytes
|
||
/// equal one of theirs, and reports the role that match earned — owner first, since viewers try
|
||
/// `/O` before `/U`. Everything else is `WrongPassword`: never an opening, and no panic on odd
|
||
/// Unicode. This is the rule #1185 became (bytes decide, so a soft hyphen alone is no password
|
||
/// and U+FF21 is "A"), stated for all inputs rather than the three examples in `src/tests.rs`.
|
||
/// It also pins #1762's upgrade path: a user session gains owner rights on the owner password
|
||
/// and on nothing else.
|
||
#[test]
|
||
fn authentication_accepts_exactly_the_matching_passwords(
|
||
alg in algorithms(),
|
||
mut user in passwords(),
|
||
owner in prop::sample::select(vec!["owner".to_string(), String::new(), "Owner\u{AD}".into(), "\u{FF21}".into()]),
|
||
extra in passwords(),
|
||
) {
|
||
// `user` may have been grown past the 32-byte cut above; create with that spelling.
|
||
// Grow the legacy password PAST the 32-byte cut BEFORE the file is built from it, so a
|
||
// partner differing only beyond byte 32 is the same password to this file.
|
||
if alg != Algorithm::Aes256 {
|
||
user = format!("{user}{}", "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2));
|
||
}
|
||
|
||
let h = make(alg, &user, &owner);
|
||
let d = h.dict().clone();
|
||
let user_bytes = effective_password(alg, &user);
|
||
let owner_bytes = owner_target(alg, &user, &owner);
|
||
|
||
// A collision engineered by construction, not by luck. Revisions 2-4 cut the password at
|
||
// 32 bytes (Algorithm 2), so any spelling that agrees on the first 32 is ONE password;
|
||
// revisions 5-6 run SASLprep, which deletes U+00AD. Growing past byte 32 guarantees the
|
||
// partner is a different string with identical bytes at every draw — earlier drafts here
|
||
// claimed `(` alone did it, which is false: appending "(" to a short password DISPLACES the
|
||
// padding (pad("a") is [61,28,BF..], pad("a(") is [61,28,28,BF..]), and this alphabet draws
|
||
// at most 11 characters, so chance produced zero legacy collisions in 1,566 measured cases.
|
||
|
||
let mut suspects: Vec<String> = vec![user.clone(), owner.clone(), String::new()];
|
||
// Near misses are PREPENDED: appending to a legacy password already past the cut would be
|
||
// another collision rather than a refusal, which would silently thin the negative testing.
|
||
let mut near_misses: Vec<String> = vec![format!("q{user}"), extra.clone()];
|
||
if alg == Algorithm::Aes256 && !user.is_empty() {
|
||
near_misses.push(format!("{user}q"));
|
||
}
|
||
|
||
let collision_partner = if alg == Algorithm::Aes256 { format!("{user}\u{AD}") } else { format!("{user}Z") };
|
||
prop_assert_ne!(&collision_partner[..], &user[..], "the collision partner must be a different string");
|
||
prop_assert_eq!(effective_password(alg, &collision_partner), effective_password(alg, &user), "{:?}: {:?} must become the same bytes as {:?}", alg, collision_partner, user);
|
||
|
||
// The mistake itself, pinned so it cannot come back: a short legacy password plus "(" is NOT the
|
||
// same password, because it shifts the padding by one byte instead of filling a slot.
|
||
if alg != Algorithm::Aes256 {
|
||
prop_assert_ne!(effective_password(alg, "a("), effective_password(alg, "a"), "appending ( to a short password displaces the padding; that is not a collision");
|
||
prop_assert_eq!(effective_password(alg, &"a".repeat(31)), effective_password(alg, &format!("{}(", "a".repeat(31))), "at exactly 31 characters ( does land in the padding slot, and THAT is a collision");
|
||
}
|
||
|
||
suspects.extend(near_misses);
|
||
suspects.push(collision_partner.clone());
|
||
suspects.dedup();
|
||
|
||
for suspect in &suspects {
|
||
let want = expected_auth(alg, &user_bytes, &owner_bytes, suspect);
|
||
match (want, SecurityHandler::open(d.clone(), ID, Some(suspect))) {
|
||
(Some(role), Ok(got)) => {
|
||
prop_assert_eq!(got.auth(), role, "{:?}: {:?} should open as {:?}", alg, suspect, role);
|
||
prop_assert_eq!(got.file_key(), h.file_key(), "{:?}: whichever password opens the file, the key is the same", alg);
|
||
}
|
||
(Some(role), Err(e)) => prop_assert!(false, "{:?}: {:?} must open as {:?} but gave {:?}", alg, suspect, role, e),
|
||
(None, Ok(got)) => prop_assert!(false, "{:?}: {:?} is not a password for this file, yet it opened as {:?}", alg, suspect, got.auth()),
|
||
(None, Err(CryptError::WrongPassword)) => {}
|
||
(None, Err(e)) => prop_assert!(false, "{:?}: {:?} gave {}, expected WrongPassword", alg, suspect, e),
|
||
}
|
||
|
||
// The owner password upgrades a user session; nothing else does. When the two share
|
||
// bytes, `create` gave /O the user's own bytes (revisions 2–4) or left it empty
|
||
// (AES-256, #1185's sharp edge), so the open password *is* an owner password here.
|
||
if let Ok(session) = SecurityHandler::open(d.clone(), ID, Some(&user)) {
|
||
let is_owner_password = expected_auth(alg, &user_bytes, &owner_bytes, suspect) == Some(Auth::Owner);
|
||
let upgrade = session.authenticate_owner(ID, suspect).map(|u| u.auth());
|
||
if is_owner_password {
|
||
prop_assert_eq!(upgrade, Ok(Auth::Owner), "{:?}: {:?} must unlock owner rights", alg, suspect);
|
||
} else {
|
||
prop_assert_eq!(upgrade, Err(CryptError::WrongPassword), "{:?}: {:?} must not grant owner rights", alg, suspect);
|
||
}
|
||
}
|
||
}
|
||
|
||
// `None` means no password was offered: the handler tries the empty string, and nothing
|
||
// else (a file with a real open password never opens silently).
|
||
let want_none = expected_auth(alg, &user_bytes, &owner_bytes, "");
|
||
match (want_none, SecurityHandler::open(d.clone(), ID, None)) {
|
||
(Some(role), Ok(got)) => prop_assert_eq!(got.auth(), role, "{:?}: no password opened as {:?}", alg, got.auth()),
|
||
(None, Ok(got)) => prop_assert!(false, "{:?}: the empty password is not valid, yet no password opened as {:?}", alg, got.auth()),
|
||
(Some(role), Err(e)) => prop_assert!(false, "{:?}: the empty password should open as {:?} but gave {:?}", alg, role, e),
|
||
(None, Err(CryptError::WrongPassword)) => {}
|
||
(None, Err(e)) => prop_assert!(false, "{:?}: no password gave {}, expected WrongPassword", alg, e),
|
||
}
|
||
}
|
||
|
||
/// The AES-CBC and RC4 primitives taken directly: a damaged file's `/Length` and crypt filters
|
||
/// hand them keys and data of shapes `create` would never build, so they must invert each other
|
||
/// for any key length (`Cipher::new` takes AES-256 at 32 bytes and zero-padded AES-128 below
|
||
/// it, identically on both sides of the trip) and every payload length.
|
||
#[test]
|
||
fn primitives_are_invertible_for_any_key_and_length(
|
||
key in prop::collection::vec(any::<u8>(), 1..40),
|
||
data in payloads(),
|
||
iv in prop::collection::vec(any::<u8>(), 16..24),
|
||
) {
|
||
let iv: [u8; 16] = *iv.as_slice().first_chunk::<16>().expect("the iv strategy draws at least 16 bytes; a silent zero fallback would hide a generator change");
|
||
|
||
let enc = aes_cbc_encrypt(&key, &iv, &data, true);
|
||
prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &enc, true), &data[..], "padded AES round trip at {} bytes", data.len());
|
||
prop_assert!(enc.len() >= 16 && enc.len().is_multiple_of(16), "PKCS#5 makes whole blocks: {} bytes for {} plaintext", enc.len(), data.len());
|
||
|
||
// Without padding, a partial block is zero-padded rather than refused, and extra trailing
|
||
// bytes decrypt to more output instead of being dropped.
|
||
let raw = aes_cbc_encrypt(&key, &iv, &data, false);
|
||
prop_assert_eq!(raw.len(), data.len().div_ceil(16) * 16, "unpadded AES output is whole blocks");
|
||
let mut longer = raw.clone();
|
||
longer.extend_from_slice(b"0123456789abcdef");
|
||
prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &longer, false).len(), raw.len() + 16, "a whole extra block decrypts to a whole extra block");
|
||
|
||
// RC4 undoes itself at every length; an empty key encrypts nothing, which is why the
|
||
// handler never derives an empty object key.
|
||
prop_assert_eq!(rc4(&key, &rc4(&key, &data)), &data[..], "RC4 round trip at {} bytes", data.len());
|
||
prop_assert_eq!(rc4(&[], &data), &data[..], "an empty RC4 key passes bytes through");
|
||
}
|
||
|
||
/// `/Perms` certifies the permission bits and `/EncryptMetadata`: decrypted with the file key it
|
||
/// must hold `/P` little-endian, four set bytes, 'T' or 'F', and the `adb` sentinel (Algorithm 10).
|
||
/// Nothing else in this crate reads it — `cos` writes it out and no code takes it back apart — so
|
||
/// without this property a flipped metadata byte would tell every other viewer the wrong thing about
|
||
/// our own file while all our tests stayed green. The older revisions we create emit no `/Perms`
|
||
/// at all, which is recorded here as well so a change in what goes out shows up rather than
|
||
/// shipping quietly.
|
||
#[test]
|
||
fn perms_value_agrees_with_the_permissions_it_certifies(permissions in any::<i32>(), encrypt_metadata in any::<bool>()) {
|
||
let h = create(
|
||
&NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x5e; 32] },
|
||
ID,
|
||
)
|
||
.unwrap();
|
||
|
||
prop_assert_eq!(h.dict().perms.len(), 16, "/Perms is one encrypted block");
|
||
let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &h.dict().perms, false);
|
||
|
||
// Bits 1-2 clear and the reserved bits set (§7.6.4.2 Table 22) is what `/P` must say, and the
|
||
// encrypted certificate has to repeat it byte for byte.
|
||
let normalised = (permissions | !0xFFF | 0xC0) & !0x3;
|
||
prop_assert_eq!(&open[..4], &normalised.to_le_bytes(), "/Perms must carry /P");
|
||
prop_assert_eq!(h.dict().p, normalised, "the /P written must match the permission bits drawn");
|
||
prop_assert_eq!(&open[4..8], &[0xFF; 4], "the four bytes following /P are set");
|
||
prop_assert_eq!(open[8], if encrypt_metadata { b'T' } else { b'F' }, "/Perms must report /EncryptMetadata");
|
||
prop_assert_eq!(&open[9..12], b"adb", "the adb sentinel must be present");
|
||
|
||
// A user session's answers come from those same bits, not from the owner shortcut.
|
||
let as_user = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap();
|
||
prop_assert_eq!(as_user.permissions().print(), normalised & 0b100 != 0, "/P bit 3 decides printing");
|
||
prop_assert_eq!(as_user.permissions().copy(), normalised & 0b1_0000 != 0, "/P bit 5 decides copying");
|
||
prop_assert!(!as_user.permissions().owner, "the user password must not report owner rights");
|
||
|
||
// Bytes 13-16 of /Perms are the non-repeating filler. Nothing reads them, so pinning that
|
||
// they move with the seed is the only way to notice a producer that freezes them at zero.
|
||
let other = create(
|
||
&NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x11; 32] },
|
||
ID,
|
||
)
|
||
.unwrap();
|
||
let open_other = aes_cbc_decrypt(other.file_key(), &[0u8; 16], &other.dict().perms, false);
|
||
prop_assert_ne!(&open[12..16], &open_other[12..16], "the four filler bytes of /Perms must follow the seed, not sit constant");
|
||
|
||
// Revisions below 5 carry no /Perms; pinned so the boundary of what we emit is explicit.
|
||
for older in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
|
||
let legacy = make(older, "user", "owner");
|
||
prop_assert!(legacy.dict().perms.is_empty(), "{:?} must emit no /Perms", older);
|
||
}
|
||
}
|
||
/// Revisions 4 mix four set bytes into every file key when `/EncryptMetadata` is false (Algorithm
|
||
/// 2, step f), so the same password over the same `/ID` must produce a *different* key than when
|
||
/// metadata is encrypted. Every other test in this crate builds `encrypt_metadata: true` only,
|
||
/// which is exactly why that step is reachable but never walked: switching it off entirely changed
|
||
/// nothing any test could see. This also records the rule that revisions below 4 always encrypt
|
||
/// metadata whatever was asked, so the boundary between the two is named rather than assumed.
|
||
#[test]
|
||
fn metadata_flag_changes_the_key_it_claims_to_change(data in payloads()) {
|
||
let build = |encrypt_metadata: bool| {
|
||
create(&NewEncryption { algorithm: Algorithm::Aes128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata, seed: [0x5e; 32] }, ID).unwrap()
|
||
};
|
||
let (yes, no) = (build(true), build(false));
|
||
|
||
// Algorithm 2, step f: revisions 4 hash four set bytes into every key when metadata stays
|
||
// plain, so two files with identical passwords and the same /ID cannot share a key.
|
||
prop_assert_ne!(yes.file_key(), no.file_key(), "R4 must fold four set bytes into the key when /EncryptMetadata is false (Algorithm 2, step f)");
|
||
prop_assert_eq!(no.dict().encrypt_metadata, false, "the flag must reach the dictionary");
|
||
prop_assert!(!no.encrypts_metadata(), "/EncryptMetadata false must report itself honestly");
|
||
prop_assert!(yes.encrypts_metadata(), "and true must report true");
|
||
|
||
// Both still open on the same password and round trip at every length: a derivation that
|
||
// drifted would show up as an unreadable file rather than passing quietly.
|
||
for h in [&yes, &no] {
|
||
let open = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap();
|
||
prop_assert_eq!(open.file_key(), h.file_key(), "the user password must still derive this file's key");
|
||
let enc = h.encrypt_string(7, 0, &data);
|
||
prop_assert_eq!(&open.decrypt_string(7, 0, &enc)[..], &data[..], "round trip at {} bytes", data.len());
|
||
}
|
||
|
||
// And the two files must not read each other's content: different keys, different plaintext.
|
||
if data.len() >= 8 {
|
||
prop_assert_ne!(&yes.decrypt_string(7, 0, &no.encrypt_string(7, 0, &data))[..], &data[..], "a key built for plain metadata must not decrypt a file whose keys encrypt it");
|
||
}
|
||
|
||
// Revisions below 4 always encrypt metadata, whatever the caller asked for.
|
||
let legacy = create(&NewEncryption { algorithm: Algorithm::Rc4_128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata: false, seed: [0x5e; 32] }, ID).unwrap();
|
||
prop_assert!(legacy.dict().encrypt_metadata && legacy.encrypts_metadata(), "V<4 always encrypts metadata, as create() documents");
|
||
}
|
||
|
||
}
|