use crate::pdf::types::*;
pub(crate) fn serialize_security_diagnostics_entries(security: &PdfSecurity) -> String {
if *security == PdfSecurity::default() {
return String::new();
}
format!(
"% RW-NOTE: PDF encryption requested but not implemented (print={}, edit={}, copy={}, annot={}) — document is NOT encrypted",
security.print_permission,
security.edit_permission,
security.copy_permission,
security.annotation_permission,
)
}
pub(crate) fn parse_security_diagnostics(text: &str) -> Option<PdfSecurity> {
if !text.contains("RW-NOTE: PDF encryption") {
return None;
}
let user_password = parse_legacy_password(text).unwrap_or_default();
let owner_password = parse_legacy_owner_password(text).unwrap_or_default();
let print_permission = text.contains("print=true");
let edit_permission = text.contains("edit=true");
let copy_permission = text.contains("copy=true");
let annotation_permission = text.contains("annot=true");
Some(PdfSecurity {
user_password: if user_password.is_empty() { None } else { Some(user_password) },
owner_password: if owner_password.is_empty() { None } else { Some(owner_password) },
print_permission,
edit_permission,
copy_permission,
annotation_permission,
})
}
fn parse_legacy_password(text: &str) -> Option<String> {
if text.contains("/RWUserPassword") {
parse_pdf_literal_by_key(text, "/RWUserPassword")
} else {
parse_comment_password(text, "password=\"")
}
}
fn parse_legacy_owner_password(text: &str) -> Option<String> {
if text.contains("/RWOwnerPassword") {
parse_pdf_literal_by_key(text, "/RWOwnerPassword")
} else {
parse_comment_password(text, "owner=\"")
}
}
fn parse_comment_password(text: &str, key: &str) -> Option<String> {
let start = text.find(key)? + key.len();
let rest = text.get(start..)?;
let end = rest.find('"')?;
let value = rest[..end].to_string();
if value.is_empty() || value == "''" {
None
} else {
Some(value)
}
}
fn parse_pdf_literal_by_key(text: &str, key: &str) -> Option<String> {
let start = text.find(key)? + key.len();
let rest = text.get(start..)?.trim_start();
let literal_start = rest.find('(')? + 1;
let literal_tail = rest.get(literal_start..)?;
let literal_end = literal_tail.find(')')?;
Some(literal_tail[..literal_end].to_string())
}
#[cfg(feature = "pdf-encryption")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncryptionAlgorithm {
None,
AES128,
AES256,
}
#[cfg(feature = "pdf-encryption")]
#[derive(Debug, Clone)]
pub struct PdfEncryption {
pub algorithm: EncryptionAlgorithm,
pub user_password: String,
pub owner_password: String,
pub permissions: u32,
pub encryption_key: Vec<u8>,
}
#[cfg(feature = "pdf-encryption")]
impl PdfEncryption {
pub fn new(user_password: &str, owner_password: &str, permissions: u32) -> Self {
let algorithm = EncryptionAlgorithm::AES128;
let salt = generate_salt();
let encryption_key = derive_encryption_key(user_password, &salt);
PdfEncryption {
algorithm,
user_password: user_password.to_string(),
owner_password: owner_password.to_string(),
permissions,
encryption_key,
}
}
pub fn build_encryption_dictionary(&self) -> String {
let (v, r, length) = match self.algorithm {
EncryptionAlgorithm::AES128 => (5, 6, 16),
EncryptionAlgorithm::AES256 => (5, 6, 32),
EncryptionAlgorithm::None => return String::new(),
};
let user_salt = generate_salt();
let owner_salt = generate_salt();
let o_hash = compute_hash(&self.owner_password, &user_salt, &owner_salt);
let o_hex = hex_encode(&o_hash);
let u_hash = compute_hash(&self.user_password, &user_salt, &[]);
let u_hex = hex_encode(&u_hash);
let p = self.permissions as i32;
format!(
"\n<< /Filter /Standard /Length {length} /V {v} /R {r} /O <{o_hex}> /U <{u_hex}> /P {p} /StmF /StmCrypt /StrF /StmCrypt >>"
)
}
}
#[cfg(feature = "pdf-encryption")]
pub fn encrypt_pdf(content: &[u8], user_password: &str, owner_password: &str) -> Vec<u8> {
let salt = generate_salt();
let key = derive_encryption_key(user_password, &salt);
let iv = generate_salt(); let encrypted = aes128_cbc_encrypt(&key, &iv, content);
let enc = PdfEncryption {
algorithm: EncryptionAlgorithm::AES128,
user_password: user_password.to_string(),
owner_password: owner_password.to_string(),
permissions: 0xFFFFFFFCu32, encryption_key: key,
};
let dict = enc.build_encryption_dictionary();
let mut result = dict.into_bytes();
result.push(b'\n');
result.extend_from_slice(&iv);
result.extend_from_slice(&encrypted);
result
}
#[cfg(feature = "pdf-encryption")]
fn generate_salt() -> [u8; 16] {
use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64;
let mut state = seed;
let mut salt = [0u8; 16];
for byte in salt.iter_mut() {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
*byte = (state >> 32) as u8;
}
salt
}
#[cfg(feature = "pdf-encryption")]
fn derive_encryption_key(password: &str, salt: &[u8]) -> Vec<u8> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(salt);
let hash = hasher.finalize();
hash[..16].to_vec()
}
#[cfg(feature = "pdf-encryption")]
fn compute_hash(password: &str, salt1: &[u8], salt2: &[u8]) -> Vec<u8> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(salt1);
hasher.update(salt2);
let hash = hasher.finalize();
hash.to_vec()
}
#[cfg(feature = "pdf-encryption")]
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(feature = "pdf-encryption")]
fn aes128_cbc_encrypt(key: &[u8], iv: &[u8; 16], plaintext: &[u8]) -> Vec<u8> {
use aes::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit};
use aes::Aes128;
use cbc::Encryptor;
type Aes128Cbc = Encryptor<Aes128>;
let key_arr = aes::cipher::generic_array::GenericArray::from_slice(key);
let iv_arr = aes::cipher::generic_array::GenericArray::from_slice(iv);
let cipher = Aes128Cbc::new(key_arr, iv_arr);
let mut out = vec![0u8; plaintext.len() + 16];
let encrypted = cipher
.encrypt_padded_b2b_mut::<Pkcs7>(plaintext, &mut out)
.expect("CBC encryption should not fail with valid padding");
encrypted.to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pdf::types::PdfSecurity;
#[test]
fn test_default_security_returns_empty() {
let security = PdfSecurity::default();
let result = serialize_security_diagnostics_entries(&security);
assert_eq!(result, "");
}
#[test]
fn test_non_default_security_emits_note() {
let security = PdfSecurity {
user_password: Some("hello".to_string()),
owner_password: Some("world".to_string()),
print_permission: true,
edit_permission: false,
copy_permission: true,
annotation_permission: false,
};
let result = serialize_security_diagnostics_entries(&security);
assert!(result.contains("RW-NOTE: PDF encryption"));
assert!(result.contains("NOT encrypted"));
assert!(result.contains("print=true"));
assert!(result.contains("edit=false"));
assert!(result.contains("copy=true"));
assert!(result.contains("annot=false"));
assert!(!result.contains("hello"));
assert!(!result.contains("world"));
assert!(!result.contains("password="));
}
#[test]
fn test_round_trip_via_comment_format() {
#[cfg(not(feature = "pdf-encryption"))]
{
let security = PdfSecurity {
user_password: Some("test123".to_string()),
owner_password: None,
print_permission: false,
edit_permission: true,
copy_permission: false,
annotation_permission: true,
};
let serialized = serialize_security_diagnostics_entries(&security);
let parsed = parse_security_diagnostics(&serialized);
assert!(parsed.is_some());
let parsed = parsed.unwrap();
assert!(!parsed.print_permission);
assert!(parsed.edit_permission);
assert!(!parsed.copy_permission);
assert!(parsed.annotation_permission);
assert!(!serialized.contains("test123"));
assert!(parsed.user_password.is_none());
assert!(parsed.owner_password.is_none());
assert!(serialized.contains("NOT encrypted"));
}
}
#[test]
fn test_parse_old_custom_key_format() {
let text = "% RW-NOTE: PDF encryption not implemented (password=\"secret\", owner=\"admin\", print=true, edit=false)";
let parsed = parse_security_diagnostics(text);
assert!(parsed.is_some());
let parsed = parsed.unwrap();
assert_eq!(parsed.user_password, Some("secret".to_string()));
assert_eq!(parsed.owner_password, Some("admin".to_string()));
assert!(parsed.print_permission);
assert!(!parsed.edit_permission);
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_encrypt_pdf_creates_non_empty_output() {
let content = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\nxref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n9\n%%EOF\n";
let result = encrypt_pdf(content, "userpass", "ownerpass");
assert!(!result.is_empty(), "Encrypted output should not be empty");
assert!(result.len() > 16, "Output should contain IV + ciphertext");
let result_str = String::from_utf8_lossy(&result);
assert!(result_str.contains("/Filter"));
assert!(result_str.contains("/Standard"));
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_encryption_dictionary_has_correct_entries() {
let enc = PdfEncryption::new("user", "owner", 0xFFFFFFFC);
let dict = enc.build_encryption_dictionary();
assert!(dict.contains("/Filter /Standard"));
assert!(dict.contains("/Length 16"));
assert!(dict.contains("/V 5"));
assert!(dict.contains("/R 6"));
assert!(dict.contains("/O <"));
assert!(dict.contains("/U <"));
assert!(dict.contains("/P"));
assert!(dict.contains("/StmF /StmCrypt"));
assert!(dict.contains("/StrF /StmCrypt"));
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_same_password_produces_same_key() {
let salt = generate_salt();
let key1 = derive_encryption_key("mypassword", &salt);
let key2 = derive_encryption_key("mypassword", &salt);
assert_eq!(key1, key2, "Same password + same salt should produce same key");
assert_eq!(key1.len(), 16, "AES-128 key should be 16 bytes");
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_different_passwords_produce_different_encryption_dictionaries() {
let enc1 = PdfEncryption::new("pass1", "owner1", 0xFFFFFFFC);
let enc2 = PdfEncryption::new("pass2", "owner2", 0xFFFFFFFC);
let dict1 = enc1.build_encryption_dictionary();
let dict2 = enc2.build_encryption_dictionary();
assert_ne!(dict1, dict2, "Different passwords should produce different dictionaries");
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_generate_salt_is_non_zero() {
let salt = generate_salt();
assert_eq!(salt.len(), 16);
let all_zero = salt.iter().all(|&b| b == 0);
assert!(!all_zero, "Salt should not be all zeros");
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_aes128_cbc_encrypt_produces_valid_output() {
let key = b"0123456789abcdef"; let iv = b"fedcba9876543210"; let plaintext = b"Hello, PDF encryption!";
let ciphertext = aes128_cbc_encrypt(key, iv, plaintext);
assert_eq!(ciphertext.len() % 16, 0, "Ciphertext length must be a multiple of 16");
assert_ne!(
ciphertext.as_slice(),
&plaintext[..],
"Ciphertext should differ from plaintext"
);
assert!(ciphertext.len() > plaintext.len(), "Ciphertext should be longer due to padding");
}
}