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,
}
#[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>,
pub user_salt: [u8; 16],
pub owner_salt: [u8; 16],
}
#[cfg(feature = "pdf-encryption")]
impl PdfEncryption {
pub fn new(
user_password: &str,
owner_password: &str,
permissions: u32,
) -> Result<Self, String> {
let algorithm = EncryptionAlgorithm::AES128;
let user_salt = generate_salt().ok_or_else(entropy_unavailable)?;
let owner_salt = generate_salt().ok_or_else(entropy_unavailable)?;
let encryption_key = derive_encryption_key(user_password, &user_salt);
Ok(PdfEncryption {
algorithm,
user_password: user_password.to_string(),
owner_password: owner_password.to_string(),
permissions,
encryption_key,
user_salt,
owner_salt,
})
}
pub fn build_encryption_dictionary(&self) -> String {
let (v, r, length) = match self.algorithm {
EncryptionAlgorithm::AES128 => (5, 6, 16),
EncryptionAlgorithm::None => return String::new(),
};
let o_hash = compute_hash(&self.owner_password, &self.user_salt, &self.owner_salt);
let o_hex = hex_encode(&o_hash);
let u_hash = compute_hash(&self.user_password, &self.user_salt, &[]);
let u_hex = hex_encode(&u_hash);
let p = self.permissions as i32;
format!(
"<< /Filter /Standard /Length {length} /V {v} /R {r} /O <{o_hex}> /U <{u_hex}> /P {p} /StmF /StdCF /StrF /StdCF >>"
)
}
pub fn encrypt_content(&self, plaintext: &[u8]) -> Result<String, String> {
if self.algorithm == EncryptionAlgorithm::None {
return Err(
"cannot encrypt content with the `None` algorithm; use an AES-128 key".to_string()
);
}
let iv = generate_salt().ok_or_else(entropy_unavailable)?;
let ciphertext = aes128_cbc_encrypt(&self.encryption_key, &iv, plaintext);
let mut payload = Vec::with_capacity(iv.len() + ciphertext.len());
payload.extend_from_slice(&iv);
payload.extend_from_slice(&ciphertext);
Ok(format!("<{}>", hex_encode(&payload)))
}
}
#[cfg(feature = "pdf-encryption")]
pub(crate) fn security_profile_encryption(
security: &PdfSecurity,
) -> Option<Result<PdfEncryption, String>> {
if *security == PdfSecurity::default() {
return None;
}
let permissions = encode_permission_flags(security);
let user_password = security.user_password.as_deref().unwrap_or("");
let owner_password = security.owner_password.as_deref().unwrap_or("");
Some(PdfEncryption::new(user_password, owner_password, permissions))
}
#[cfg(feature = "pdf-encryption")]
fn encode_permission_flags(security: &PdfSecurity) -> u32 {
let mut permissions: u32 = 0xFFFF_F0C0;
if security.print_permission {
permissions |= 1 << 2;
}
if security.edit_permission {
permissions |= 1 << 3;
}
if security.copy_permission {
permissions |= 1 << 4;
}
if security.annotation_permission {
permissions |= 1 << 5;
}
permissions
}
#[cfg(feature = "pdf-encryption")]
pub fn encrypt_pdf(
content: &[u8],
user_password: &str,
owner_password: &str,
) -> Result<Vec<u8>, String> {
let salt = generate_salt().ok_or_else(entropy_unavailable)?;
let key = derive_encryption_key(user_password, &salt);
let iv = generate_salt().ok_or_else(entropy_unavailable)?;
let encrypted = aes128_cbc_encrypt(&key, &iv, content);
let mut enc = PdfEncryption {
algorithm: EncryptionAlgorithm::AES128,
user_password: user_password.to_string(),
owner_password: owner_password.to_string(),
permissions: 0xFFFFFFFCu32, encryption_key: key,
user_salt: salt,
owner_salt: [0u8; 16],
};
enc.owner_salt = generate_salt().ok_or_else(entropy_unavailable)?;
let dict = enc.build_encryption_dictionary();
let mut result = dict.into_bytes();
result.push(b'\n');
result.extend_from_slice(&salt);
result.extend_from_slice(&iv);
result.extend_from_slice(&encrypted);
Ok(result)
}
#[cfg(feature = "pdf-encryption")]
fn entropy_unavailable() -> String {
"the OS entropy source is unavailable, so a cryptographically random salt/IV cannot be \
generated; refusing to fall back to a predictable value"
.to_string()
}
#[cfg(feature = "pdf-encryption")]
fn generate_salt() -> Option<[u8; 16]> {
let mut salt = [0u8; 16];
match getrandom::fill(&mut salt) {
Ok(()) => Some(salt),
Err(error) => {
log::error!(
"[pdf] the OS entropy source is unavailable ({error}); refusing to derive a salt \
from a predictable value"
);
None
}
}
}
#[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 = key.into();
let iv_arr = iv.into();
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(
"AES-128-CBC encryption cannot fail for a buffer that was sized as \
plaintext.len() + 16 and Pkcs7 padding, so the allocator or key length is wrong",
);
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);
#[cfg(not(feature = "pdf-encryption"))]
{
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").expect("entropy is available in tests");
assert!(!result.is_empty(), "Encrypted output should not be empty");
assert!(result.len() > 32, "Output should contain salt + 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 encrypt_pdf_writes_the_salt_so_the_output_is_decryptable() {
let content = b"decryptable payload";
let out = encrypt_pdf(content, "pw", "owner").expect("entropy");
let dict_end =
out.iter().position(|&b| b == b'\n').expect("the dictionary ends with a newline");
let payload = &out[dict_end + 1..];
assert!(payload.len() >= 32, "salt + IV must be present after the dictionary");
let salt: [u8; 16] = payload[..16].try_into().expect("16 bytes");
let expected_key = derive_encryption_key("pw", &salt);
let mut enc = PdfEncryption::new("pw", "owner", 0xFFFFFFFC).expect("entropy");
enc.user_salt = salt;
enc.encryption_key = expected_key.clone();
assert_eq!(enc.encryption_key, expected_key);
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_encryption_dictionary_has_correct_entries() {
let enc = PdfEncryption::new("user", "owner", 0xFFFFFFFC).expect("entropy");
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 /StdCF"));
assert!(dict.contains("/StrF /StdCF"));
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn the_dictionary_is_stable_across_rebuilds() {
let enc = PdfEncryption::new("user", "owner", 0xFFFFFFFC).expect("entropy");
assert_eq!(
enc.build_encryption_dictionary(),
enc.build_encryption_dictionary(),
"the dictionary must read the stored salts, not mint new ones"
);
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_same_password_produces_same_key() {
let salt = generate_salt().expect("entropy");
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).expect("entropy");
let enc2 = PdfEncryption::new("pass2", "owner2", 0xFFFFFFFC).expect("entropy");
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_and_varies() {
let salt = generate_salt().expect("entropy is available in tests");
assert_eq!(salt.len(), 16);
assert!(!salt.iter().all(|&b| b == 0), "Salt should not be all zeros");
let other = generate_salt().expect("entropy");
assert_ne!(salt, other, "a CSPRNG must not repeat in consecutive draws");
}
#[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");
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_encryption_dictionary_has_crypt_filter_entries() {
let enc = PdfEncryption::new(
"user",
"owner",
encode_permission_flags(&PdfSecurity {
user_password: Some("user".to_string()),
owner_password: Some("owner".to_string()),
print_permission: false,
edit_permission: true,
copy_permission: false,
annotation_permission: false,
}),
)
.expect("entropy");
let dict = enc.build_encryption_dictionary();
assert!(dict.contains("/StmF /StdCF"));
assert!(dict.contains("/StrF /StdCF"));
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_permission_flags_map_to_standard_bits() {
let permissions = encode_permission_flags(&PdfSecurity {
user_password: Some("u".to_string()),
owner_password: None,
print_permission: false,
edit_permission: true,
copy_permission: false,
annotation_permission: true,
});
assert_eq!(permissions & (1 << 2), 0, "print denied -> bit 3 clear");
assert_ne!(permissions & (1 << 3), 0, "edit allowed -> bit 4 set");
assert_eq!(permissions & (1 << 4), 0, "copy denied -> bit 5 clear");
assert_ne!(permissions & (1 << 5), 0, "annotate allowed -> bit 6 set");
assert_eq!(permissions & 0xFFFF_F000, 0xFFFF_F000);
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_default_profile_arms_no_encryption() {
assert!(security_profile_encryption(&PdfSecurity::default()).is_none());
assert!(security_profile_encryption(&PdfSecurity {
user_password: Some("pw".to_string()),
owner_password: None,
print_permission: true,
edit_permission: true,
copy_permission: true,
annotation_permission: true,
})
.is_some());
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_encrypt_content_emits_hex_wrapped_iv_plus_ciphertext() {
let enc = PdfEncryption::new("pw", "owner", 0xFFFF_F0C0).expect("entropy");
let payload = enc.encrypt_content(b"stream body").expect("encryption succeeds");
assert!(payload.starts_with('<') && payload.ends_with('>'));
let hex_body = &payload[1..payload.len() - 1];
assert!(hex_body.chars().all(|ch| ch.is_ascii_hexdigit()));
assert_eq!(hex_body.len() % 32, 0, "IV + ciphertext must be whole AES blocks");
assert!(hex_body.len() >= 64, "IV (16 bytes) + at least one block");
let other = enc.encrypt_content(b"stream body").expect("encryption succeeds");
assert_ne!(payload, other, "each stream must get its own random IV");
}
#[cfg(feature = "pdf-encryption")]
#[test]
fn test_encrypt_content_rejects_none_algorithm() {
let enc = PdfEncryption {
algorithm: EncryptionAlgorithm::None,
user_password: String::new(),
owner_password: String::new(),
permissions: 0,
encryption_key: vec![0u8; 16],
user_salt: [0u8; 16],
owner_salt: [0u8; 16],
};
assert!(enc.encrypt_content(b"payload").is_err());
}
}