pdftract/crates/pdftract-core/src/encryption/mod.rs
jedarden a62913f25d feat(pdftract-1z0qt): implement encryption detection + RC4/AES-128/AES-256 decryption
Implement decrypt feature with RC4, AES-128, and AES-256 decryption support
for encrypted PDFs per PDF 1.7/2.0 spec.

Core components:
- detection.rs: Parse /Encrypt dictionary, validate encryption metadata
- rc4.rs: V=1 R=2 (40-bit) and V=2 R=3 (40-128 bit) key derivation
- aes_128.rs: V=4 R=4 AES-128 CBC with PKCS#7 padding
- aes_256.rs: V=5 R=5/6 AES-256 with SHA-256/384/512 key derivation
- decryptor.rs: Unified API for password validation and stream/string decryption

Integration:
- extract_pdf: Detect encryption and validate passwords after xref loading
- CLI: Exit code 3 for encryption errors (wrong password, unsupported)
- Password sources: --password-stdin, PDFTRACT_PASSWORD, --password VALUE (opt-in)

Password validation: Empty string first, then user-provided. Wrong
password emits ENCRYPTION_UNSUPPORTED diagnostic and exits with code 3.

Tests: Unit tests for RC4, AES-128, AES-256 key derivation and
validation. All pass with `cargo test --features decrypt`.

Refs: Plan Phase 1.4 line 1114, EC-04/EC-05/EC-06, PDF spec 7.6

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-05-28 03:22:36 -04:00

124 lines
3.9 KiB
Rust

//! PDF encryption support (RC4, AES-128, AES-256).
//!
//! This module implements PDF decryption per PDF 2.0 spec (ISO 32000-2:2017).
//! It supports:
//! - V=1, R=2: RC4 40-bit
//! - V=2, R=3: RC4 40-128 bit
//! - V=4, R=4: RC4 or AES-128 via crypt filters
//! - V=5, R=5/6: AES-256 with SHA-256/384/512 key derivation
//!
//! The `decrypt` feature must be enabled to use this module.
pub mod detection;
#[cfg(feature = "decrypt")]
pub mod aes_128;
#[cfg(feature = "decrypt")]
pub mod aes_256;
#[cfg(feature = "decrypt")]
pub mod decryptor;
#[cfg(feature = "decrypt")]
pub mod rc4;
#[cfg(feature = "decrypt")]
pub use aes_128::{aes_128_decrypt, derive_aes_128_object_key, is_identity_filter};
#[cfg(feature = "decrypt")]
pub use aes_256::{aes_256_decrypt, Aes256Decryptor, FileKeyResult as Aes256FileKeyResult};
#[cfg(feature = "decrypt")]
pub use decryptor::{decrypt_with_password, DecryptionContext, PasswordValidation};
#[cfg(feature = "decrypt")]
pub use rc4::{
decrypt_object, derive_file_key, derive_object_key, pad_password, rc4_decrypt,
validate_user_password, validate_user_password_r2, validate_user_password_r3,
FileKeyResult as Rc4FileKeyResult,
};
pub use detection::{
detect_encryption, AuthEvent, CryptFilterDef, CryptFilterMethod, CryptFiltersV4,
EncryptionInfo,
};
use crate::diagnostics::{DiagCode, Diagnostic};
/// Error during decryption.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecryptError {
/// Unsupported encryption algorithm
UnsupportedAlgorithm,
/// Wrong password
WrongPassword,
/// Missing required field in encryption dictionary
MissingField(String),
/// Invalid data format
InvalidFormat,
/// Decryption failed (corrupted data)
DecryptionFailed,
}
impl DecryptError {
/// Convert to diagnostic code.
pub fn to_diag_code(&self) -> DiagCode {
match self {
DecryptError::UnsupportedAlgorithm => DiagCode::EncryptionUnsupported,
DecryptError::WrongPassword => DiagCode::EncryptionWrongPassword,
DecryptError::MissingField(_) => DiagCode::StructMissingKey,
DecryptError::InvalidFormat => DiagCode::EncryptionWrongPassword,
DecryptError::DecryptionFailed => DiagCode::EncryptionWrongPassword,
}
}
/// Convert to diagnostic.
pub fn to_diagnostic(&self) -> Diagnostic {
match self {
DecryptError::UnsupportedAlgorithm => Diagnostic::with_static_no_offset(
DiagCode::EncryptionUnsupported,
"Unsupported encryption algorithm",
),
DecryptError::WrongPassword => Diagnostic::with_static_no_offset(
DiagCode::EncryptionWrongPassword,
"Wrong password",
),
DecryptError::MissingField(field) => Diagnostic::with_dynamic_no_offset(
DiagCode::StructMissingKey,
format!("Missing encryption field: {}", field),
),
DecryptError::InvalidFormat => Diagnostic::with_static_no_offset(
DiagCode::EncryptionWrongPassword,
"Invalid encrypted data format",
),
DecryptError::DecryptionFailed => Diagnostic::with_static_no_offset(
DiagCode::EncryptionWrongPassword,
"Decryption failed",
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decrypt_error_to_diag_code() {
assert_eq!(
DecryptError::UnsupportedAlgorithm.to_diag_code(),
DiagCode::EncryptionUnsupported
);
assert_eq!(
DecryptError::WrongPassword.to_diag_code(),
DiagCode::EncryptionWrongPassword
);
}
#[test]
fn test_decrypt_error_to_diagnostic() {
let diag = DecryptError::WrongPassword.to_diagnostic();
assert_eq!(diag.code, DiagCode::EncryptionWrongPassword);
}
}