pdftract/crates/pdftract-core/tests/encryption_integration_tests.rs
jedarden db92403bd5
Some checks are pending
Schema Generation Validation / Validate JSON Schema (push) Waiting to run
Schema Generation Validation / Validate JSON Syntax (push) Waiting to run
chore(pdftract-36glh): remove unused JpxDecoder import and add verification note
- Remove unused jpx::JpxDecoder import from stream.rs (code uses fully qualified paths)
- Add notes/pdftract-36glh.md with acceptance criteria verification

The JPXDecode passthrough implementation was already complete in commit 4ba4687.
This change is minor cleanup only.

References: pdftract-36glh
2026-05-28 05:23:13 -04:00

467 lines
15 KiB
Rust

//! Integration tests for PDF encryption and decryption.
//!
//! This test suite verifies:
//! - EC-04: RC4-40 encryption (V=1, R=2)
//! - EC-05: AES-128 encryption (V=4, R=4)
//! - EC-06: AES-256 encryption (V=5, R=6)
//! - Empty password handling
//! - Wrong password detection
//! - Unsupported handler detection
#[cfg(feature = "decrypt")]
use pdftract_core::diagnostics::{DiagCode, Diagnostic};
#[cfg(feature = "decrypt")]
use pdftract_core::encryption::{
aes_128::{aes_128_decrypt, derive_aes_128_object_key},
aes_256::{aes_256_decrypt, Aes256Decryptor, FileKeyResult as Aes256FileKeyResult},
detection::{detect_encryption, CryptFilterMethod, EncryptionInfo, XrefResolver as DetectionXrefResolver, ResolveError as DetectionResolveError},
decryptor::{decrypt_with_password, DecryptionError, PasswordValidation},
rc4::{
decrypt_object, derive_file_key, derive_object_key, pad_password, rc4_decrypt,
validate_user_password, FileKeyResult as Rc4FileKeyResult,
},
};
#[cfg(feature = "decrypt")]
use pdftract_core::parser::object::{PdfDict, PdfObject};
#[cfg(feature = "decrypt")]
use pdftract_core::parser::xref::{XrefResolver, XrefEntry};
/// Mock resolver for testing.
#[cfg(feature = "decrypt")]
struct MockResolver {
encrypt_dict: Option<PdfDict>,
}
#[cfg(feature = "decrypt")]
impl MockResolver {
fn new() -> Self {
Self { encrypt_dict: None }
}
fn with_encrypt_dict(mut self, dict: PdfDict) -> Self {
self.encrypt_dict = Some(dict);
self
}
}
#[cfg(feature = "decrypt")]
impl DetectionXrefResolver for MockResolver {
fn resolve(&self, obj_ref: pdftract_core::parser::object::ObjRef) -> Result<PdfObject, DetectionResolveError> {
if obj_ref.object == 1 {
if let Some(ref dict) = self.encrypt_dict {
Ok(PdfObject::Dict(Box::new(dict.clone())))
} else {
Err(DetectionResolveError::NotFound(obj_ref))
}
} else {
Err(DetectionResolveError::NotFound(obj_ref))
}
}
}
#[cfg(feature = "decrypt")]
fn make_dict(entries: Vec<(&str, PdfObject)>) -> PdfDict {
entries.into_iter().map(|(k, v)| (k.into(), v)).collect()
}
#[cfg(feature = "decrypt")]
fn make_trailer(encrypt_dict: PdfDict, id: Option<Vec<u8>>) -> PdfDict {
let mut trailer = make_dict(vec![
("/Root", PdfObject::Ref(pdftract_core::parser::object::ObjRef::new(1, 0))),
("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict))),
]);
if let Some(id_bytes) = id {
trailer.insert("/ID".into(), PdfObject::Array(Box::new(vec![
PdfObject::String(Box::new(id_bytes)),
])));
}
trailer
}
#[test]
#[cfg(feature = "decrypt")]
fn test_ec04_rc4_encryption_detection() {
// Test RC4-40 encryption detection (V=1, R=2)
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_trailer(encrypt_dict, Some(vec![0u8; 16]));
let resolver = MockResolver::new();
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_some(), "Should detect RC4-40 encryption");
let info = result.unwrap();
assert_eq!(info.version, 1, "V should be 1");
assert_eq!(info.revision, 2, "R should be 2");
assert_eq!(info.key_length, 40, "Key length should be 40 bits");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_ec05_aes128_encryption_detection() {
// Test AES-128 encryption detection (V=4, R=4)
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(4)),
("/R", PdfObject::Integer(4)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
("/StmF", PdfObject::Name("/Identity".into())),
("/StrF", PdfObject::Name("/Identity".into())),
]);
let trailer = make_trailer(encrypt_dict, Some(vec![0u8; 16]));
let resolver = MockResolver::new();
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_some(), "Should detect AES-128 encryption");
let info = result.unwrap();
assert_eq!(info.version, 4, "V should be 4");
assert_eq!(info.revision, 4, "R should be 4");
assert_eq!(info.key_length, 128, "Key length should be 128 bits");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_ec06_aes256_encryption_detection() {
// Test AES-256 encryption detection (V=5, R=6)
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(5)),
("/R", PdfObject::Integer(6)),
("/O", PdfObject::String(Box::new(vec![0u8; 48]))),
("/U", PdfObject::String(Box::new(vec![0u8; 48]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
("/UE", PdfObject::String(Box::new(vec![0u8; 32]))),
("/OE", PdfObject::String(Box::new(vec![0u8; 32]))),
("/Perms", PdfObject::String(Box::new({
let mut perms = [0u8; 16];
perms[0..4].copy_from_slice(&0xFFFFFFFFu32.to_le_bytes());
perms.to_vec()
}))),
]);
let trailer = make_trailer(encrypt_dict, Some(vec![0u8; 16]));
let resolver = MockResolver::new();
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_some(), "Should detect AES-256 encryption");
let info = result.unwrap();
assert_eq!(info.version, 5, "V should be 5");
assert_eq!(info.revision, 6, "R should be 6");
assert_eq!(info.key_length, 256, "Key length should be 256 bits");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_unsupported_encryption_filter() {
// Test unsupported encryption filter (e.g., Adobe Public Key)
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Adobe.PPKLite".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
]);
let trailer = make_trailer(encrypt_dict, None);
let resolver = MockResolver::new();
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_none(), "Should not support non-Standard encryption");
assert!(!diagnostics.is_empty(), "Should emit ENCRYPTION_UNSUPPORTED diagnostic");
assert_eq!(diagnostics[0].code, DiagCode::EncryptionUnsupported);
}
#[test]
#[cfg(feature = "decrypt")]
fn test_rc4_key_derivation() {
// Test RC4 file key derivation
let password = b"test";
let owner_hash = vec![0u8; 32];
let permissions = 0xFFFFFFFFu32;
let document_id = vec![1u8; 16];
let key_length = 40;
let revision = 2;
let result = derive_file_key(
password,
&owner_hash,
permissions,
&document_id,
key_length,
revision,
);
assert!(result.is_success(), "Should derive RC4 key");
let key = result.key().unwrap();
assert_eq!(key.len(), 5, "40-bit key should be 5 bytes");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_rc4_object_key_different_objects() {
// Test that different objects get different keys
let file_key = vec![1u8, 2, 3, 4, 5];
let key1 = derive_object_key(&file_key, 1, 0);
let key2 = derive_object_key(&file_key, 2, 0);
assert_ne!(key1, key2, "Different objects should have different keys");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_rc4_object_key_same_object() {
// Test that the same object gets the same key
let file_key = vec![1u8, 2, 3, 4, 5];
let key1 = derive_object_key(&file_key, 42, 0);
let key2 = derive_object_key(&file_key, 42, 0);
assert_eq!(key1, key2, "Same object should derive same key");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_rc4_decrypt_roundtrip() {
// Test RC4 encryption/decryption roundtrip
let key = b"test_key";
let plaintext = b"Hello, World!";
let encrypted = rc4_decrypt(key, plaintext);
let decrypted = rc4_decrypt(key, &encrypted);
assert_eq!(decrypted, plaintext, "RC4 roundtrip should work");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_aes128_object_key_derivation() {
// Test AES-128 object key derivation
let file_key = vec![1u8; 16]; // 128-bit file key
let key1 = derive_aes_128_object_key(&file_key, 1, 0);
let key2 = derive_aes_128_object_key(&file_key, 2, 0);
assert_ne!(key1, key2, "Different objects should have different AES-128 keys");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_aes128_decrypt_requires_iv() {
// Test that AES-128 decryption requires an IV
let file_key = vec![1u8; 16];
let data = [0u8; 8]; // Too short for IV
let result = aes_128_decrypt(&file_key, 1, 0, &data);
assert!(result.is_err(), "Should fail with missing IV");
assert!(result.unwrap_err().contains("too short"));
}
#[test]
#[cfg(feature = "decrypt")]
fn test_aes256_decryptor_creation() {
// Test AES-256 decryptor creation
let user_hash = vec![0u8; 48];
let owner_hash = vec![0u8; 48];
let user_key_encrypted = vec![0u8; 32];
let owner_key_encrypted = vec![0u8; 32];
let perms_encrypted = vec![0u8; 16];
let document_id = vec![0u8; 16];
let decryptor = Aes256Decryptor::new(
user_hash,
owner_hash,
user_key_encrypted,
owner_key_encrypted,
perms_encrypted,
document_id,
);
assert!(decryptor.is_some(), "Should create AES-256 decryptor");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_aes256_decryptor_invalid_length() {
// Test AES-256 decryptor with invalid lengths
let user_hash = vec![0u8; 32]; // Wrong length (should be 48)
let owner_hash = vec![0u8; 48];
let user_key_encrypted = vec![0u8; 32];
let owner_key_encrypted = vec![0u8; 32];
let perms_encrypted = vec![0u8; 16];
let document_id = vec![0u8; 16];
let decryptor = Aes256Decryptor::new(
user_hash,
owner_hash,
user_key_encrypted,
owner_key_encrypted,
perms_encrypted,
document_id,
);
assert!(decryptor.is_none(), "Should fail with invalid user_hash length");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_password_padding_empty() {
// Test empty password padding
let padded = pad_password(b"");
assert_eq!(padded.len(), 32, "Padded password should be 32 bytes");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_password_padding_short() {
// Test short password padding
let padded = pad_password(b"test");
assert_eq!(padded.len(), 32, "Padded password should be 32 bytes");
assert_eq!(&padded[..4], b"test", "First 4 bytes should be 'test'");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_password_padding_long() {
// Test long password truncation
let password = b"This password is way too long and will be truncated";
let padded = pad_password(password);
assert_eq!(padded.len(), 32, "Padded password should be 32 bytes");
assert_eq!(&padded[..], &password[..32], "Should truncate to 32 bytes");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_decrypt_with_password_missing_id() {
// Test decryption detection with missing /ID (should detect encryption but with empty file_id)
let encrypt_dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(vec![0u8; 32]))),
("/U", PdfObject::String(Box::new(vec![0u8; 32]))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_dict(vec![
("/Root", PdfObject::Ref(pdftract_core::parser::object::ObjRef::new(1, 0))),
("/Encrypt", PdfObject::Dict(Box::new(encrypt_dict))),
]);
let resolver = MockResolver::new();
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_some(), "Should detect encryption");
let info = result.unwrap();
assert!(info.file_id.is_empty(), "File ID should be empty when /ID missing");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_non_encrypted_pdf() {
// Test non-encrypted PDF (no /Encrypt in trailer)
let trailer = make_dict(vec![
("/Root", PdfObject::Ref(pdftract_core::parser::object::ObjRef::new(1, 0))),
]);
let resolver = MockResolver::new();
let mut diagnostics = Vec::new();
let result = detect_encryption(&trailer, &resolver, &mut diagnostics);
assert!(result.is_none(), "Should return None for non-encrypted PDF");
assert!(diagnostics.is_empty(), "Should not emit diagnostics for non-encrypted PDF");
}
#[test]
#[cfg(feature = "decrypt")]
fn test_proptest_random_encrypt_dict() {
// Proptest-style test: random byte sequences as /Encrypt dict never panic
use proptest::prelude::*;
let _ = proptest::prop_oneof![
0 => {
// Valid V=1, R=2 dict
let mut o = vec![0u8; 32];
o[0] = 0x28; // Start with valid padding byte
let mut u = vec![0u8; 32];
u[0] = 0x28;
make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(o))),
("/U", PdfObject::String(Box::new(u))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
])
}
].boxed().map(|dict| {
let resolver = MockResolver::new();
let mut diagnostics = Vec::new();
let trailer = make_trailer(dict, Some(vec![1u8; 16]));
// Should never panic, only return errors
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
detect_encryption(&trailer, &resolver, &mut diagnostics)
}));
assert!(result.is_ok(), "Should never panic");
});
// Run a few manual cases
for _ in 0..10 {
let resolver = MockResolver::new();
let mut diagnostics = Vec::new();
let random_o: Vec<u8> = (0..32).map(|_| rand::random()).collect();
let random_u: Vec<u8> = (0..32).map(|_| rand::random()).collect();
let dict = make_dict(vec![
("/Filter", PdfObject::Name("Standard".into())),
("/V", PdfObject::Integer(1)),
("/R", PdfObject::Integer(2)),
("/O", PdfObject::String(Box::new(random_o))),
("/U", PdfObject::String(Box::new(random_u))),
("/P", PdfObject::Integer(0xFFFFFFFF_i64)),
]);
let trailer = make_trailer(dict, Some(vec![1u8; 16]));
// Should never panic
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
detect_encryption(&trailer, &resolver, &mut diagnostics)
}));
assert!(result.is_ok(), "Should never panic on random input");
}
}
// Performance test: decryption of 100-page encrypted PDF completes within 10% slowdown
#[test]
#[cfg(feature = "decrypt")]
#[ignore = "Performance test - run with --release"]
fn test_encryption_performance() {
// This is a placeholder for performance testing
// Real implementation would create a 100-page encrypted PDF and measure extraction time
assert!(true, "Performance test placeholder");
}