craft-pdf: the standard security handler from PdfCraft (crypt) (#897)

* craft-pdf: the standard security handler from PdfCraft (`crypt`)

Adds the `crypt` feature: PdfCraft's pdfcraft-crypt crate (revision
2b65ab000bfe0997dec5fbed2769d3da3799355b) as craft_pdf::crypt, with its unit,
integration and property tests. The code is unchanged; doc comments and test
imports name the new module.

It decrypts and encrypts strings and streams for revisions 2 to 6 and creates
encryption dictionaries. The reader keeps its own decryptor.

* craft-pdf: the crypt unit tests build for wasm

The property test in src/crypt/tests.rs needs proptest, which is a
dev-dependency of the non-wasm targets only. It is now compiled for those
targets only; the other unit tests still build for wasm.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 19:21:20 -05:00
1 parent bafefe627a
commit f233df14ad
12 files changed
+2339 -1

No files matched your search

Generated
+17 -1
View File
@@ -926,7 +926,7 @@ version = "0.1.0"
name = "craft-cms"
version = "0.1.0"
dependencies = [
"md-5",
"md-5 0.10.6",
"moxcms",
"rayon",
]
@@ -1371,6 +1371,7 @@ dependencies = [
name = "craft-pdf"
version = "0.1.0"
dependencies = [
"aes",
"bytemuck",
"hayro",
"hayro-cmap",
@@ -1378,9 +1379,14 @@ dependencies = [
"hayro-syntax",
"krilla",
"kurbo 0.13.1",
"md-5 0.11.0",
"proptest",
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"ttf-parser",
"wasm-bindgen-test",
"zeroize",
]
[[package]]
@@ -4083,6 +4089,16 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
+10
View File
@@ -18,6 +18,7 @@ raster = ["interpret", "dep:hayro", "dep:bytemuck"]
write = ["dep:krilla", "dep:thiserror"]
write-pdf = ["write", "krilla/pdf"]
write-simple-text = ["write", "krilla/simple-text"]
crypt = ["dep:aes", "dep:md-5", "dep:sha2", "dep:stringprep", "dep:zeroize", "dep:thiserror"]
[dependencies]
hayro-syntax = { workspace = true, optional = true }
@@ -26,6 +27,12 @@ hayro = { workspace = true, optional = true }
bytemuck = { workspace = true, optional = true }
krilla = { workspace = true, optional = true }
thiserror = { workspace = true, optional = true }
# The standard security handler (`crypt`): pure Rust, MIT OR Apache-2.0, builds for wasm.
aes = { version = "0.9", optional = true }
md-5 = { version = "0.11", optional = true }
sha2 = { version = "0.11", optional = true }
stringprep = { version = "0.1", optional = true }
zeroize = { version = "1", optional = true }
[dev-dependencies]
hayro-syntax = "=0.7.2"
@@ -34,5 +41,8 @@ hayro-cmap = "=0.1.0"
kurbo = "0.13"
ttf-parser = "=0.25.1"
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
proptest = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
+31
View File
@@ -12,6 +12,7 @@ No feature is enabled by default.
| `write` | Guarded Krilla authoring; no shared reader dependency |
| `write-pdf` | `write` plus Krilla's embedded-PDF support |
| `write-simple-text` | `write` plus Krilla's optional text shaper and `draw_text` |
| `crypt` | The standard security handler: decrypt, encrypt and create encryption. No reader or writer dependency |
## Reading and rendering
@@ -109,6 +110,36 @@ read-only consumer, then repeat with `--target wasm32-unknown-unknown` and the t
These backend checks supplement the root workspace gates; excluded vendor crates are not
covered by `cargo test --workspace` alone.
## Encryption
`crypt` is the PDF standard security handler (ISO 32000-2 §7.6) for code that edits or writes
PDF files. The reader decrypts on its own and does not use it. It covers revisions 2 to 6:
40-bit and 128-bit RC4, AES-128 crypt filters, and AES-256 (R5 is read, R6 is read and written).
- `SecurityHandler::open(dict, id0, password)` authenticates the owner password first, then the
user password. Passwords are tried as PDFDocEncoding and as typed (R2–R4), or in their
SASLprep form and as typed (R5/R6). `authenticate_owner` upgrades a user handler.
`without_key` is for a file opened without a password whose only encrypted data is behind
crypt filters authorised when an embedded file is opened; that data is left as stored.
- `decrypt_string`, `encrypt_string`, `decrypt_stream` and `encrypt_stream` take the object
number and generation. `StreamKind` selects the crypt filter: `/StmF`, `/EFF` for embedded
files, or a stream's own `/Crypt` filter name. `encrypts_metadata` says whether the caller
passes `/Metadata` streams through at all. AES decryption is tolerant: a trailing partial
block is ignored and bad padding is left in.
- `permissions()` gives the `/P` flags; for R5/R6 a permission counts only when `/Perms` grants
it too. Owners have every permission.
- `create(&NewEncryption, id0)` builds the `/Encrypt` values for RC4-40, RC4-128, AES-128 or
AES-256. The caller supplies 32 bytes of entropy (`seed`), so the module needs no OS access
and builds for the web. `file_id` derives a file identifier from the same seed.
- `EncryptDict` holds the dictionary's values. The caller reads them from, and writes them to,
its own object model; the module does not parse PDF syntax. Keys and prepared passwords are
zeroized when dropped.
The code and its tests come from PdfCraft's `pdfcraft-crypt` crate (revision
`2b65ab000bfe0997dec5fbed2769d3da3799355b`), unchanged apart from the module paths. Tests:
`cargo test -p craft-pdf --no-default-features --features crypt`. `tests/crypt/qpdf_literals.rs`
checks key derivation against values another implementation wrote; only the numbers are stored.
## Writing
Plain `write` retains Krilla raster images without its optional text shaper. Applications
+796
View File
@@ -0,0 +1,796 @@
//! The PDF standard security handler (ISO 32000-2 §7.6), for code that writes or edits PDF files.
//! The reader (`read`) decrypts on its own; this module also encrypts and creates encryption.
//!
//! Supports every revision of the standard handler:
//! - R2 (40-bit RC4), R3 (RC4 up to 128-bit), R4 (crypt filters: RC4 or AES-128);
//! - R5 (the deprecated AES-256 extension) and R6 (AES-256, PDF 2.0).
//!
//! It authenticates with the user or owner password (owner first, as viewers do) and derives
//! per-object keys. It decrypts and encrypts strings and streams, and honours crypt filters,
//! including `Identity` and embedded-file-only encryption. It can also *create* encryption
//! dictionaries, for an app's security settings and for test fixtures.
//!
//! The module knows nothing about a PDF object model. Callers pass in the values of the
//! `/Encrypt` dictionary and the first element of the trailer `/ID`.
mod aes_cbc;
mod rc4;
use md5::{Digest as _, Md5};
use sha2::{Sha256, Sha384, Sha512};
use zeroize::Zeroizing;
pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt};
pub use rc4::rc4;
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum CryptError {
#[error("the password is incorrect")]
WrongPassword,
#[error("unsupported security handler: {0}")]
Unsupported(String),
#[error("malformed encryption dictionary: {0}")]
Malformed(String),
}
/// 32-byte padding string used by revisions 2–4 (§7.6.4.3.2, step a).
pub const PADDING: [u8; 32] = [
0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80,
0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A,
];
/// How a crypt filter encrypts (`/CFM`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Method {
/// `None` / the `Identity` filter: data is not encrypted.
Identity,
/// `V2`: RC4.
Rc4,
/// `AESV2`: AES-128-CBC.
Aes128,
/// `AESV3`: AES-256-CBC.
Aes256,
}
/// The values of an `/Encrypt` dictionary that the handler needs.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct EncryptDict {
/// `/Filter` (must be `Standard`).
pub filter: Vec<u8>,
pub v: i64,
pub r: i64,
/// Key length in bits (`/Length`; 40 when absent).
pub length_bits: i64,
pub o: Vec<u8>,
pub u: Vec<u8>,
pub oe: Vec<u8>,
pub ue: Vec<u8>,
pub perms: Vec<u8>,
pub p: i32,
pub encrypt_metadata: bool,
/// Crypt filters (`/CF`): name → (method, key length in bytes if given).
pub crypt_filters: Vec<(Vec<u8>, Method)>,
pub stm_f: Vec<u8>,
pub str_f: Vec<u8>,
pub ef_f: Vec<u8>,
}
/// Which password authenticated the document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Auth {
Owner,
User,
}
/// Permission flags from `/P`, read the way the handler's revision reads them (ISO 32000-1:2008,
/// Table 22). Owners have every permission.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Permissions {
pub bits: i32,
pub owner: bool,
/// The `/R` value of the handler that read `bits`.
pub revision: i64,
}
impl Permissions {
fn bit(&self, n: u32) -> bool {
self.owner || (self.bits >> (n - 1)) & 1 == 1
}
/// Revision 3 and later read the full flag list of Table 22; revision 2 has its own.
fn is_revision_3_or_later(&self) -> bool {
self.revision >= 3
}
pub fn print(&self) -> bool {
self.bit(3)
}
/// Modify contents other than the operations below.
pub fn modify(&self) -> bool {
self.bit(4)
}
pub fn copy(&self) -> bool {
self.bit(5)
}
pub fn annotate(&self) -> bool {
self.bit(6)
}
pub fn fill_forms(&self) -> bool {
if self.is_revision_3_or_later() { self.bit(9) || self.bit(6) } else { self.bit(6) }
}
/// Revision 2 covers accessibility with the copy flag (bit 5); revision 3 and later with bit 10.
pub fn extract_for_accessibility(&self) -> bool {
if self.is_revision_3_or_later() { self.bit(10) } else { self.bit(5) }
}
/// Insert, rotate or delete pages; create bookmarks and thumbnails. Revision 3 and later grant
/// it by bit 11 alone. Table 22 defines bit 11 for those revisions only, so at revision 2 the
/// modify flag (bit 4) covers it.
pub fn assemble(&self) -> bool {
if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) }
}
/// Take pages out into another document (Extract, Split, a source of Combine or Insert
/// Pages): [`Self::assemble`] and copying (bit 5), since the pages carry their text and
/// graphics into a new file without the document's protection (#5081). "Any except
/// extracting pages" sets bit 4 but not bit 11, so at revision 3 and later it grants neither.
pub fn extract_pages(&self) -> bool {
self.assemble() && self.copy()
}
/// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those
/// revisions only: at revision 2 the print flag (bit 3) is all there is.
pub fn print_high_quality(&self) -> bool {
if self.is_revision_3_or_later() { self.print() && self.bit(12) } else { self.print() }
}
}
/// What kind of stream is being decrypted (selects the crypt filter).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StreamKind<'a> {
Normal,
/// An embedded file stream (uses `/EFF`).
EmbeddedFile,
/// The stream names its own crypt filter (`/Filter [/Crypt …] /DecodeParms << /Name … >>`).
Named(&'a [u8]),
}
/// An authenticated security handler for one document.
#[derive(Clone, Debug)]
pub struct SecurityHandler {
dict: EncryptDict,
key: Zeroizing<Vec<u8>>,
auth: Auth,
recovered_user: Option<Zeroizing<Vec<u8>>>,
methods: Methods,
}
/// The crypt filter methods of a handler, resolved once: a document controls both the number of
/// filters and the number of strings, so a search of `/CF` per string would multiply them.
#[derive(Clone, Debug)]
struct Methods {
string: Method,
stream: Method,
embedded_file: Method,
named: std::collections::HashMap<Vec<u8>, Method>,
}
impl Methods {
fn new(dict: &EncryptDict) -> Self {
let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len());
for (name, method) in &dict.crypt_filters {
#[cfg(test)]
tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1));
// The first of two filters with one name wins, as a search would find it.
named.entry(name.clone()).or_insert(*method);
}
let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named };
methods.string = methods.get(&dict.str_f);
methods.stream = methods.get(&dict.stm_f);
methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f });
methods
}
fn get(&self, name: &[u8]) -> Method {
if name == b"Identity" || name.is_empty() {
return Method::Identity;
}
#[cfg(test)]
tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1));
self.named.get(name).copied().unwrap_or(Method::Identity)
}
}
impl SecurityHandler {
fn new(dict: EncryptDict, key: Zeroizing<Vec<u8>>, auth: Auth, recovered_user: Option<Zeroizing<Vec<u8>>>) -> Self {
let methods = Methods::new(&dict);
Self { dict, key, auth, recovered_user, methods }
}
/// Authenticate with `password` (tried as owner password, then user password). `None`
/// tries the empty password, which opens documents that only have an owner password.
pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result<Self, CryptError> {
if !dict.filter.is_empty() && dict.filter != b"Standard" {
return Err(CryptError::Unsupported(format!("/Filter /{}", String::from_utf8_lossy(&dict.filter))));
}
let pw = password.unwrap_or("");
let mut recovered_user = None;
let (key, auth) = match dict.r {
2..=4 => {
let mut found = None;
for legacy in legacy_candidates(pw) {
if let Some((k, user)) = owner_key_r4(&dict, id0, &legacy) {
recovered_user = Some(user);
found = Some((k, Auth::Owner));
} else if let Some(k) = user_key_r4(&dict, id0, &legacy) {
found = Some((k, Auth::User));
}
if found.is_some() {
break;
}
}
found.ok_or(CryptError::WrongPassword)?
}
5 | 6 => {
let pw = sasl_password(pw);
if let Some(k) = key_r6(&dict, &pw, true)? {
(k, Auth::Owner)
} else if let Some(k) = key_r6(&dict, &pw, false)? {
(k, Auth::User)
} else {
return Err(CryptError::WrongPassword);
}
}
r => return Err(CryptError::Unsupported(format!("revision {r}"))),
};
Ok(Self::new(dict, key, auth, recovered_user))
}
/// A handler with no file key, for a document opened without a password whose strings and
/// streams aren't encrypted and whose encrypted crypt filters are all ones the caller found
/// authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6,
/// Table 25). Data such a filter encrypts is left as stored, read and written alike, so it
/// keeps its encrypted bytes; [`SecurityHandler::decrypts`] tells which. `None` when strings or
/// streams are encrypted, which needs the key, or the handler is older than crypt filters (V4).
pub fn without_key(dict: EncryptDict) -> Option<Self> {
if dict.v < 4 {
return None;
}
let methods = Methods::new(&dict);
if methods.string != Method::Identity || methods.stream != Method::Identity {
return None;
}
Some(Self { dict, key: Zeroizing::new(Vec::new()), auth: Auth::User, recovered_user: None, methods })
}
/// Whether the handler has the file key: false for [`SecurityHandler::without_key`].
pub fn has_key(&self) -> bool {
!self.key.is_empty()
}
/// Whether data of `kind` is encrypted, so that reading it needs the file key.
pub fn decrypts(&self, kind: StreamKind) -> bool {
self.method(Some(kind)) != Method::Identity
}
/// Whether data left as stored by a handler without the key still decrypts after its object
/// is renumbered: only AES-256 doesn't mix the object number into the key (Algorithm 1.A).
pub fn survives_renumbering(&self) -> bool {
[self.methods.string, self.methods.stream, self.methods.embedded_file]
.into_iter()
.chain(self.methods.named.values().copied())
.all(|m| matches!(m, Method::Identity | Method::Aes256))
}
/// Upgrade to owner access with the owner (permissions) password, as when a restricted
/// document is unlocked after opening. The user password, or any other, is `WrongPassword`.
/// Both passwords unlock the same file key, so what was decrypted so far stays valid; a
/// different key is refused too.
pub fn authenticate_owner(&self, id0: &[u8], password: &str) -> Result<Self, CryptError> {
let owner = Self::open(self.dict.clone(), id0, Some(password))?;
if owner.auth != Auth::Owner || owner.key != self.key {
return Err(CryptError::WrongPassword);
}
Ok(owner)
}
pub fn auth(&self) -> Auth {
self.auth
}
/// For R2–R4 documents opened with the owner password: the user password, which owner
/// authentication recovers (Algorithm 7). Lets components that only accept user passwords
/// open the document too.
pub fn recovered_user_password(&self) -> Option<Zeroizing<Vec<u8>>> {
self.recovered_user.clone()
}
/// For R5/R6, `/P` is stored in clear and again in `/Perms`, encrypted with the file key
/// (Algorithm 13). Where the two differ, a permission counts only when both grant it, so an
/// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before.
pub fn permissions(&self) -> Permissions {
let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p);
Permissions { bits, owner: self.auth == Auth::Owner, revision: self.dict.r }
}
/// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`.
fn perms_p(&self) -> Option<i32> {
if !matches!(self.dict.r, 5 | 6) || !self.has_key() {
return None;
}
let plain = Zeroizing::new(aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false));
let p = i32::from_le_bytes(*plain.first_chunk::<4>()?);
(plain.get(9..12) == Some(&b"adb"[..])).then_some(p)
}
pub fn dict(&self) -> &EncryptDict {
&self.dict
}
/// The file encryption key (for tests and diagnostics).
pub fn file_key(&self) -> &[u8] {
&self.key
}
/// Whether `/Metadata` streams are encrypted.
pub fn encrypts_metadata(&self) -> bool {
self.dict.encrypt_metadata
}
/// The method for strings or a kind of stream.
fn method(&self, stream: Option<StreamKind>) -> Method {
if self.dict.v < 4 {
return Method::Rc4;
}
match stream {
None => self.methods.string,
Some(StreamKind::Normal) => self.methods.stream,
Some(StreamKind::EmbeddedFile) => self.methods.embedded_file,
Some(StreamKind::Named(n)) => self.methods.get(n),
}
}
/// Algorithm 1 (§7.6.3.2): the key for one object. AES-256 uses the file key directly
/// (Algorithm 1.A, §7.6.3.3).
///
/// For an RC4 crypt filter under `/V 4`, a file key shorter than 16 bytes is first extended
/// to 16 bytes with zeros. The standard doesn't say so (Algorithm 1 takes the n-byte key as
/// it is), but that is how Acrobat writes and reads such files: a `/V 4` RC4 crypt filter
/// with a 40- or 48-bit key (pdf.js issue 19484). `hayro-syntax` (the `read` feature) reads them
/// the same way, so a page it shows is one this module can read and save. AES-128 keys are
/// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only
/// seen with RC4. Every key [`create`] makes under `/V 4` is 16 bytes already.
fn object_key(&self, method: Method, num: u32, generation: u16) -> Zeroizing<Vec<u8>> {
if method == Method::Aes256 {
return self.key.clone();
}
let mut key = self.key.clone();
if method == Method::Rc4 && self.dict.v == 4 && key.len() < 16 {
key.resize(16, 0);
}
let mut h = Md5::new();
h.update(&key);
h.update(&num.to_le_bytes()[..3]);
h.update(generation.to_le_bytes());
if method == Method::Aes128 {
h.update(b"sAlT");
}
let mut digest = Zeroizing::new(h.finalize().to_vec());
digest.truncate((key.len() + 5).min(16));
digest
}
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
// Without the key, encrypted data stays as stored (see `without_key`).
if !self.has_key() {
return data.to_vec();
}
match method {
Method::Identity => data.to_vec(),
Method::Rc4 => rc4(&self.object_key(method, num, generation), data),
Method::Aes128 | Method::Aes256 => {
let key = self.object_key(method, num, generation);
if encrypt {
// Deterministic IV derived from the key, object and content: saves are
// reproducible and no IV repeats for different plaintexts.
let mut h = Sha256::new();
h.update(&key);
h.update(num.to_le_bytes());
h.update(generation.to_le_bytes());
h.update(data);
let digest = h.finalize();
let mut iv = [0u8; 16];
for (d, s) in iv.iter_mut().zip(digest.iter()) {
*d = *s;
}
let mut out = iv.to_vec();
out.extend(aes_cbc_encrypt(&key, &iv, data, true));
out
} else {
let Some((iv, body)) = data.split_first_chunk::<16>() else {
return Vec::new(); // only (part of) an IV: empty plaintext
};
aes_cbc_decrypt(&key, iv, body, true)
}
}
}
}
pub fn decrypt_string(&self, num: u32, generation: u16, data: &[u8]) -> Vec<u8> {
self.apply(self.method(None), num, generation, data, false)
}
pub fn encrypt_string(&self, num: u32, generation: u16, data: &[u8]) -> Vec<u8> {
self.apply(self.method(None), num, generation, data, true)
}
pub fn decrypt_stream(&self, num: u32, generation: u16, data: &[u8], kind: StreamKind) -> Vec<u8> {
self.apply(self.method(Some(kind)), num, generation, data, false)
}
pub fn encrypt_stream(&self, num: u32, generation: u16, data: &[u8], kind: StreamKind) -> Vec<u8> {
self.apply(self.method(Some(kind)), num, generation, data, true)
}
/// Whether strings are encrypted at all (false with `/StrF /Identity`).
pub fn encrypts_strings(&self) -> bool {
self.method(None) != Method::Identity
}
}
// ── Passwords ──────────────────────────────────────────────────────────────────────────────────
/// PDFDocEncoding (ISO 32000-2 Annex D) where it differs from Latin-1: bytes 0x18–0x1F and
/// 0x80–0xA0 (0x9F is undefined). Here because R2–R4 passwords need it; an object layer that
/// decodes text strings can use it too.
const PDFDOC_LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜'];
const PDFDOC_HIGH: [char; 33] = [
'•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł', 'œ',
'š', 'ž', '\u{FFFD}', '€',
];
/// PDFDocEncoding → Unicode.
pub fn pdfdoc_char(c: u8) -> char {
match c {
0x18..=0x1F => PDFDOC_LOW[usize::from(c - 0x18)],
0x80..=0xA0 => PDFDOC_HIGH[usize::from(c - 0x80)],
_ => char::from(c),
}
}
/// Unicode → PDFDocEncoding, `None` for a character it lacks (ğ, ş, İ, Cyrillic, CJK, …).
pub fn pdfdoc_byte(c: char) -> Option<u8> {
let at = |table: &[char], base: u8| table.iter().position(|&t| t == c && t != '\u{FFFD}').and_then(|i| u8::try_from(i).ok()).map(|i| base + i);
at(&PDFDOC_LOW, 0x18).or_else(|| at(&PDFDOC_HIGH, 0x80)).or_else(|| match u32::from(c) {
0x18..=0x1F | 0x80..=0xA0 => None,
n => u8::try_from(n).ok(),
})
}
/// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes.
/// Characters PDFDocEncoding lacks are dropped.
fn legacy_password(pw: &str) -> Zeroizing<Vec<u8>> {
Zeroizing::new(pw.chars().filter_map(pdfdoc_byte).take(32).collect())
}
/// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification
/// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives
/// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords.
fn legacy_candidates(pw: &str) -> Vec<Zeroizing<Vec<u8>>> {
let mut out = vec![legacy_password(pw)];
if pw.chars().any(|c| pdfdoc_byte(c).is_none()) {
out.push(Zeroizing::new(pw.bytes().take(32).collect()));
}
out
}
/// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes.
fn sasl_password(pw: &str) -> Zeroizing<Vec<u8>> {
let prepped = Zeroizing::new(stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string()));
let mut bytes = Zeroizing::new(prepped.as_bytes().to_vec());
bytes.truncate(127);
bytes
}
fn pad(pw: &[u8]) -> Zeroizing<[u8; 32]> {
let mut out = Zeroizing::new(PADDING);
let n = pw.len().min(32);
out[..n].copy_from_slice(&pw[..n]);
out[n..].copy_from_slice(&PADDING[..32 - n]);
out
}
fn key_len(d: &EncryptDict) -> usize {
if d.r == 2 { 5 } else { (d.length_bits.clamp(40, 128) / 8) as usize }
}
// ── Revisions 2–4 ──────────────────────────────────────────────────────────────────────────────
/// Algorithm 2: file key from a (user) password.
fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Zeroizing<Vec<u8>> {
let n = key_len(d);
let mut h = Md5::new();
h.update(*pad(pw));
h.update(&d.o[..d.o.len().min(32)]);
h.update(d.p.to_le_bytes());
h.update(id0);
if d.r >= 4 && !d.encrypt_metadata {
h.update([0xFF; 4]);
}
let mut digest = Zeroizing::new(h.finalize().to_vec());
if d.r >= 3 {
for _ in 0..50 {
digest = Zeroizing::new(Md5::digest(&digest[..n]).to_vec());
}
}
digest.truncate(n);
digest
}
/// Algorithms 4/5: the `/U` value a key produces.
fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec<u8> {
if d.r == 2 {
return rc4(key, &PADDING);
}
let mut h = Md5::new();
h.update(PADDING);
h.update(id0);
let mut x = rc4(key, &h.finalize());
for i in 1..=19u8 {
let k = Zeroizing::new(key.iter().map(|b| b ^ i).collect::<Vec<u8>>());
x = rc4(&k, &x);
}
x.extend_from_slice(&[0u8; 16]);
x
}
/// Algorithm 6: authenticate a user password; returns the file key.
///
/// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the
/// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation
/// fails we retry the other way.
fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
let check = |d: &EncryptDict| {
let key = file_key_r4(d, id0, pw);
let u = u_value_r4(d, id0, &key);
let n = if d.r == 2 { 32 } else { 16 };
(d.u.len() >= n && u[..n] == d.u[..n]).then_some(key)
};
check(d).or_else(|| (d.r >= 4 && !d.encrypt_metadata).then(|| check(&EncryptDict { encrypt_metadata: true, ..d.clone() })).flatten())
}
/// The RC4 key derived from an owner password (Algorithm 3, steps a–d).
fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Zeroizing<Vec<u8>> {
let n = key_len(d);
let mut digest = Zeroizing::new(Md5::digest(*pad(owner_pw)).to_vec());
if d.r >= 3 {
for _ in 0..50 {
digest = Zeroizing::new(Md5::digest(&*digest).to_vec());
}
}
digest.truncate(n);
digest
}
type SecretBytes = Zeroizing<Vec<u8>>;
/// Algorithm 7: authenticate an owner password (recover the user password from `/O`).
/// Returns the file key and the recovered user password (padding removed).
fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(SecretBytes, SecretBytes)> {
let rk = owner_rc4_key(d, pw);
let mut user = Zeroizing::new(d.o[..d.o.len().min(32)].to_vec());
if d.r == 2 {
user = Zeroizing::new(rc4(&rk, &user));
} else {
for i in (0..=19u8).rev() {
let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::<Vec<u8>>());
user = Zeroizing::new(rc4(&k, &user));
}
}
let key = user_key_r4(d, id0, &user)?;
// The recovered value is the padded password: strip the padding suffix.
let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32));
user.truncate(len);
Some((key, user))
}
// ── Revisions 5–6 ──────────────────────────────────────────────────────────────────────────────
/// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B).
fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Zeroizing<Vec<u8>> {
let mut k = Zeroizing::new({
let mut h = Sha256::new();
h.update(pw);
h.update(salt);
h.update(udata);
h.finalize().to_vec()
});
if r == 5 {
return k;
}
let mut round = 0u32;
loop {
let mut k1 = Zeroizing::new(Vec::with_capacity(64 * (pw.len() + k.len() + udata.len())));
for _ in 0..64 {
k1.extend_from_slice(pw);
k1.extend_from_slice(&k);
k1.extend_from_slice(udata);
}
// `k` is always a SHA-2 digest (32, 48 or 64 bytes): key = its first 16 bytes, IV = the next 16.
let Some((key, rest)) = k.split_first_chunk::<16>() else { break };
let Some(iv) = rest.first_chunk::<16>() else { break };
let e = Zeroizing::new(aes_cbc_encrypt(key, iv, &k1, false));
let m = e.iter().take(16).map(|b| u32::from(*b)).sum::<u32>() % 3;
k = Zeroizing::new(match m {
0 => Sha256::digest(&*e).to_vec(),
1 => Sha384::digest(&*e).to_vec(),
_ => Sha512::digest(&*e).to_vec(),
});
round += 1;
if round >= 64 && e.last().is_none_or(|b| u32::from(*b) <= round - 32) {
break;
}
}
k.truncate(32);
k
}
/// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key.
fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result<Option<Zeroizing<Vec<u8>>>, CryptError> {
if d.u.len() < 48 || d.o.len() < 48 {
return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into()));
}
let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) };
if *hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] {
return Ok(None);
}
if encrypted.len() < 32 {
return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into()));
}
let k = hash_r6(d.r, pw, &hashed[40..48], udata);
Ok(Some(Zeroizing::new(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false))))
}
// ── Creating encryption ────────────────────────────────────────────────────────────────────────
/// Which encryption to create.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Algorithm {
/// R2, 40-bit RC4 (legacy; for tests and compatibility only).
Rc4_40,
/// R3, 128-bit RC4.
Rc4_128,
/// R4, AES-128 crypt filters.
Aes128,
/// R6, AES-256 (PDF 2.0; the default for new documents).
Aes256,
}
/// A password as [`create`] prepares it: for R2–R4 its PDFDocEncoding (characters it lacks
/// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed,
/// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8,
/// at most 127 bytes.
fn prepared_password(algorithm: Algorithm, password: &str) -> Zeroizing<Vec<u8>> {
match algorithm {
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password),
Algorithm::Aes256 => sasl_password(password),
}
}
/// The bytes new encryption with `algorithm` derives its keys from for `password`: for R2–R4 its
/// PDFDocEncoding cut or padded to exactly 32 bytes (Algorithm 2); for R6 its SASLprep (the
/// password as given if SASLprep refuses it), at most 127 bytes of UTF-8. Two passwords with the
/// same bytes are the same password to the file, and a password with the bytes of `""` opens it
/// without asking, so callers can refuse either before encrypting. Different bytes can still give
/// the same key (RC4-40 keeps 5 bytes of a hash). `create` still uses the user password for an
/// empty R2–R4 owner password.
pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec<u8> {
let prepared = prepared_password(algorithm, password);
match algorithm {
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(),
Algorithm::Aes256 => prepared.to_vec(),
}
}
/// Parameters for new encryption.
#[derive(Clone, Debug)]
pub struct NewEncryption<'a> {
pub algorithm: Algorithm,
pub user_password: &'a str,
pub owner_password: &'a str,
pub permissions: i32,
pub encrypt_metadata: bool,
/// 32 bytes of entropy for keys and salts (the caller supplies randomness, so this crate
/// needs no OS access and tests are deterministic).
pub seed: [u8; 32],
}
/// Expand `seed` into `n` bytes (SHA-256 in counter mode).
fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec<u8> {
let mut out = Vec::new();
let mut counter = 0u32;
while out.len() < n {
let mut h = Sha256::new();
h.update(seed);
h.update(label);
h.update(counter.to_le_bytes());
out.extend_from_slice(&h.finalize());
counter = counter.saturating_add(1);
}
out.truncate(n);
out
}
/// Sixteen bytes of file identifier from `seed` (SHA-256 in counter mode, label `id`).
///
/// `/ID` is written in the clear. Deriving it the same way as the file key keeps that public
/// value from being an unkeyed hash of the seed.
pub fn file_id(seed: &[u8; 32]) -> Vec<u8> {
expand(seed, b"id", 16)
}
/// Build the `/Encrypt` values and an authenticated (owner) handler for a new encryption.
pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, CryptError> {
// Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2).
let p = (params.permissions | !0xFFF | 0xC0) & !0x3;
let mut d = EncryptDict { filter: b"Standard".to_vec(), p, encrypt_metadata: params.encrypt_metadata, ..Default::default() };
let user = prepared_password(params.algorithm, params.user_password);
let owner = prepared_password(params.algorithm, params.owner_password);
match params.algorithm {
Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => {
(d.v, d.r, d.length_bits) = match params.algorithm {
Algorithm::Rc4_40 => (1, 2, 40),
Algorithm::Rc4_128 => (2, 3, 128),
_ => (4, 4, 128),
};
if params.algorithm == Algorithm::Aes128 {
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes128)];
d.stm_f = b"StdCF".to_vec();
d.str_f = b"StdCF".to_vec();
} else {
d.encrypt_metadata = true; // V < 4 always encrypts metadata
}
let owner = if params.owner_password.is_empty() { user.clone() } else { owner };
// Algorithm 3: /O
let rk = owner_rc4_key(&d, &owner);
let mut o = rc4(&rk, &*pad(&user));
if d.r >= 3 {
for i in 1..=19u8 {
let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::<Vec<u8>>());
o = rc4(&k, &o);
}
}
d.o = o;
let key = file_key_r4(&d, id0, &user);
d.u = u_value_r4(&d, id0, &key);
Ok(SecurityHandler::new(d, key, Auth::Owner, Some(user)))
}
Algorithm::Aes256 => {
(d.v, d.r, d.length_bits) = (5, 6, 256);
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes256)];
d.stm_f = b"StdCF".to_vec();
d.str_f = b"StdCF".to_vec();
let key = Zeroizing::new(expand(&params.seed, b"file key", 32));
let salts = expand(&params.seed, b"salts", 32);
// Algorithm 8: /U, /UE
let mut u = hash_r6(6, &user, &salts[0..8], &[]).to_vec();
u.extend_from_slice(&salts[0..16]);
let uk = hash_r6(6, &user, &salts[8..16], &[]);
d.ue = aes_cbc_encrypt(&uk, &[0; 16], &key, false);
// Algorithm 9: /O, /OE
let mut o = hash_r6(6, &owner, &salts[16..24], &u).to_vec();
o.extend_from_slice(&salts[16..32]);
let ok = hash_r6(6, &owner, &salts[24..32], &u);
d.oe = aes_cbc_encrypt(&ok, &[0; 16], &key, false);
d.u = u;
d.o = o;
// Algorithm 10: /Perms
let mut perms = [0u8; 16];
perms[..4].copy_from_slice(&p.to_le_bytes());
perms[4..8].copy_from_slice(&[0xFF; 4]);
perms[8] = if d.encrypt_metadata { b'T' } else { b'F' };
perms[9..12].copy_from_slice(b"adb");
perms[12..16].copy_from_slice(&expand(&params.seed, b"perms", 4));
d.perms = aes_cbc_encrypt(&key, &[0; 16], &perms, false);
Ok(SecurityHandler::new(d, key, Auth::Owner, None))
}
}
}
#[cfg(test)]
mod tests;
+89
View File
@@ -0,0 +1,89 @@
//! AES-CBC (128 or 256-bit key, chosen by key length) with optional PKCS#5 padding.
//!
//! Decryption is tolerant, as viewers are: a trailing partial block is ignored, and invalid
//! padding leaves the data unpadded rather than failing.
use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
use aes::{Aes128, Aes256};
enum Cipher {
A128(Box<Aes128>),
A256(Box<Aes256>),
}
impl Cipher {
fn new(key: &[u8]) -> Self {
if let Some(k) = key.first_chunk::<32>() {
Cipher::A256(Box::new(Aes256::new(&Array::from(*k))))
} else {
let mut k = [0u8; 16];
let n = key.len().min(16);
k[..n].copy_from_slice(&key[..n]);
Cipher::A128(Box::new(Aes128::new(&Array::from(k))))
}
}
fn encrypt(&self, block: &mut [u8; 16]) {
let mut b = Array::from(*block);
match self {
Cipher::A128(c) => c.encrypt_block(&mut b),
Cipher::A256(c) => c.encrypt_block(&mut b),
}
*block = b.into();
}
fn decrypt(&self, block: &mut [u8; 16]) {
let mut b = Array::from(*block);
match self {
Cipher::A128(c) => c.decrypt_block(&mut b),
Cipher::A256(c) => c.decrypt_block(&mut b),
}
*block = b.into();
}
}
/// Encrypt; with `pad`, PKCS#5 padding is added (otherwise `data` must be whole blocks, and a
/// trailing partial block is zero-padded).
pub fn aes_cbc_encrypt(key: &[u8], iv: &[u8; 16], data: &[u8], pad: bool) -> Vec<u8> {
let c = Cipher::new(key);
let mut buf = data.to_vec();
if pad {
let n = 16 - buf.len() % 16;
buf.extend(std::iter::repeat_n(n as u8, n));
} else if !buf.len().is_multiple_of(16) {
buf.resize(buf.len().div_ceil(16) * 16, 0);
}
let mut prev = *iv;
for b in buf.as_chunks_mut::<16>().0 {
for (x, p) in b.iter_mut().zip(prev) {
*x ^= p;
}
c.encrypt(b);
prev = *b;
}
buf
}
/// Decrypt; with `pad`, PKCS#5 padding is removed when valid.
pub fn aes_cbc_decrypt(key: &[u8], iv: &[u8; 16], data: &[u8], pad: bool) -> Vec<u8> {
let c = Cipher::new(key);
let blocks = data.as_chunks::<16>().0;
let mut out = Vec::with_capacity(blocks.len() * 16);
let mut prev = *iv;
for &cipher in blocks {
let mut b = cipher;
c.decrypt(&mut b);
for (x, p) in b.iter_mut().zip(prev) {
*x ^= p;
}
out.extend_from_slice(&b);
prev = cipher;
}
if pad && let Some(&n) = out.last() {
let n = n as usize;
if (1..=16).contains(&n) && n <= out.len() && out[out.len() - n..].iter().all(|b| *b as usize == n) {
out.truncate(out.len() - n);
}
}
out
}
+22
View File
@@ -0,0 +1,22 @@
//! RC4 (used by PDF security handler revisions 2–4). Encryption and decryption are the same.
pub fn rc4(key: &[u8], data: &[u8]) -> Vec<u8> {
if key.is_empty() {
return data.to_vec();
}
let mut s: [u8; 256] = std::array::from_fn(|i| i as u8);
let mut j: u8 = 0;
for i in 0..256 {
j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]);
s.swap(i, j as usize);
}
let (mut i, mut j) = (0u8, 0u8);
data.iter()
.map(|b| {
i = i.wrapping_add(1);
j = j.wrapping_add(s[i as usize]);
s.swap(i as usize, j as usize);
b ^ s[s[i as usize].wrapping_add(s[j as usize]) as usize]
})
.collect()
}
+449
View File
@@ -0,0 +1,449 @@
use super::*;
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
#[test]
fn file_id_is_a_one_way_16_byte_identifier() {
let seed = [7u8; 32];
let id = file_id(&seed);
assert_eq!(id.len(), 16);
assert_eq!(id, file_id(&seed));
assert_ne!(id, seed[..16]);
assert_ne!(id, file_id(&[8u8; 32]));
}
#[test]
fn rc4_reference_vectors() {
assert_eq!(rc4(b"Key", b"Plaintext"), hex("bbf316e8d940af0ad3"));
assert_eq!(rc4(b"Wiki", b"pedia"), hex("1021bf0420"));
assert_eq!(rc4(b"Secret", b"Attack at dawn"), hex("45a01f645fc35b383552544b9bf5"));
}
#[test]
fn aes_cbc_nist_sp800_38a_vectors() {
let iv: [u8; 16] = hex("000102030405060708090a0b0c0d0e0f").try_into().unwrap();
let plain = hex("6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51");
let k128 = hex("2b7e151628aed2a6abf7158809cf4f3c");
let c128 = aes_cbc_encrypt(&k128, &iv, &plain, false);
assert_eq!(c128, hex("7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b2"));
assert_eq!(aes_cbc_decrypt(&k128, &iv, &c128, false), plain);
let k256 = hex("603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4");
let c256 = aes_cbc_encrypt(&k256, &iv, &plain, false);
assert_eq!(c256, hex("f58c4c04d6e5f1ba779eabfb5f7bfbd69cfc4e967edb808d679f777bc6702c7d"));
assert_eq!(aes_cbc_decrypt(&k256, &iv, &c256, false), plain);
}
#[test]
fn aes_padding_is_added_and_tolerantly_removed() {
let key = [7u8; 16];
let iv = [1u8; 16];
for n in [0, 1, 15, 16, 17, 100] {
let data: Vec<u8> = (0..n as u8).collect();
let c = aes_cbc_encrypt(&key, &iv, &data, true);
assert_eq!(c.len() % 16, 0);
assert_eq!(aes_cbc_decrypt(&key, &iv, &c, true), data);
}
// Garbage (bad padding, partial block) decrypts without panicking.
let _ = aes_cbc_decrypt(&key, &iv, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], true);
}
const ID: &[u8] = b"0123456789abcdef";
fn new(alg: Algorithm, user: &str, owner: &str, perms: i32) -> SecurityHandler {
create(
&NewEncryption { algorithm: alg, user_password: user, owner_password: owner, permissions: perms, encrypt_metadata: true, seed: [42; 32] },
ID,
)
.unwrap()
}
const ALL: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
#[test]
fn created_encryption_authenticates_owner_and_user() {
for alg in ALL {
let made = new(alg, "user pw", "owner pw", 0b0100); // print only
let d = made.dict().clone();
let owner = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap();
assert_eq!(owner.auth(), Auth::Owner, "{alg:?}");
assert_eq!(owner.file_key(), made.file_key(), "{alg:?}");
assert!(owner.permissions().modify(), "owners can do everything");
let user = SecurityHandler::open(d.clone(), ID, Some("user pw")).unwrap();
assert_eq!(user.auth(), Auth::User, "{alg:?}");
assert_eq!(user.file_key(), made.file_key(), "{alg:?}");
assert!(user.permissions().print() && !user.permissions().modify() && !user.permissions().copy(), "{alg:?}");
assert_eq!(SecurityHandler::open(d.clone(), ID, Some("nope")).unwrap_err(), CryptError::WrongPassword, "{alg:?}");
assert_eq!(SecurityHandler::open(d, ID, None).unwrap_err(), CryptError::WrongPassword, "{alg:?}");
}
}
#[test]
fn the_owner_password_upgrades_a_user_session() {
for alg in ALL {
let d = new(alg, "", "secret", 0b0100).dict().clone();
let user = SecurityHandler::open(d, ID, None).unwrap();
assert!(!user.permissions().modify(), "{alg:?}");
// The (empty) user password and wrong ones don't unlock anything.
assert_eq!(user.authenticate_owner(ID, "").unwrap_err(), CryptError::WrongPassword, "{alg:?}");
assert_eq!(user.authenticate_owner(ID, "nope").unwrap_err(), CryptError::WrongPassword, "{alg:?}");
let owner = user.authenticate_owner(ID, "secret").unwrap();
assert_eq!(owner.auth(), Auth::Owner, "{alg:?}");
assert_eq!(owner.file_key(), user.file_key(), "{alg:?}");
assert!(owner.permissions().modify(), "{alg:?}");
}
// A separate open password is not the permissions password.
for alg in ALL {
let d = new(alg, "open", "perms", 0b0100).dict().clone();
let user = SecurityHandler::open(d, ID, Some("open")).unwrap();
assert_eq!(user.authenticate_owner(ID, "open").unwrap_err(), CryptError::WrongPassword, "{alg:?}");
assert_eq!(user.authenticate_owner(ID, "perms").unwrap().auth(), Auth::Owner, "{alg:?}");
}
}
#[test]
fn empty_user_password_opens_without_asking() {
for alg in ALL {
let d = new(alg, "", "secret", -1).dict().clone();
let h = SecurityHandler::open(d, ID, None).unwrap();
assert_eq!(h.auth(), Auth::User, "{alg:?}");
}
}
#[test]
fn strings_and_streams_round_trip_and_are_scrambled() {
for alg in ALL {
let h = new(alg, "", "o", -1);
let text = b"Confidential quarterly numbers".to_vec();
let enc = h.encrypt_string(12, 0, &text);
assert_ne!(enc, text, "{alg:?}");
assert_eq!(h.decrypt_string(12, 0, &enc), text, "{alg:?}");
if alg != Algorithm::Aes256 {
// R2–R4 derive a key per object; R6 uses the file key everywhere (§7.6.3.3).
assert_ne!(h.decrypt_string(13, 0, &enc), text, "object keys differ per object ({alg:?})");
}
let data: Vec<u8> = (0..1000u32).map(|i| (i * 7) as u8).collect();
let enc = h.encrypt_stream(5, 0, &data, StreamKind::Normal);
assert_eq!(h.decrypt_stream(5, 0, &enc, StreamKind::Normal), data, "{alg:?}");
// Deterministic: the same object encrypts to the same bytes (reproducible saves).
assert_eq!(h.encrypt_stream(5, 0, &data, StreamKind::Normal), enc);
}
}
thread_local! {
/// Crypt filter names compared or looked up, on this thread.
pub(crate) static FILTER_COMPARES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[test]
fn crypt_filters_are_looked_up_once_per_handler() {
let plain = new(Algorithm::Aes128, "", "o", -1);
let mut d = plain.dict().clone();
let n = 5_000;
let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect();
filters.append(&mut d.crypt_filters);
d.crypt_filters = filters;
FILTER_COMPARES.with(|c| c.set(0));
let h = SecurityHandler::open(d, ID, None).unwrap();
let strings = 1_000;
let enc = plain.encrypt_string(7, 0, b"secret");
for _ in 0..strings {
assert_eq!(h.decrypt_string(7, 0, &enc), b"secret");
assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s");
}
assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x");
let compares = FILTER_COMPARES.with(|c| c.get());
assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons");
}
#[test]
fn identity_crypt_filters_leave_data_alone() {
let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone();
// Embedded-file-only encryption: strings and normal streams are not encrypted.
d.str_f = b"Identity".to_vec();
d.stm_f = b"Identity".to_vec();
d.ef_f = b"StdCF".to_vec();
let h = SecurityHandler::open(d, ID, None).unwrap();
assert!(!h.encrypts_strings());
assert_eq!(h.decrypt_stream(3, 0, b"plain", StreamKind::Normal), b"plain");
let enc = h.encrypt_stream(3, 0, b"attachment", StreamKind::EmbeddedFile);
assert_ne!(enc, b"attachment");
assert_eq!(h.decrypt_stream(3, 0, &enc, StreamKind::EmbeddedFile), b"attachment");
assert_eq!(h.decrypt_stream(3, 0, b"x", StreamKind::Named(b"Identity")), b"x");
}
#[test]
fn r6_passwords_are_saslprepped() {
// U+00AD (soft hyphen) is mapped to nothing by SASLprep; "ª" normalises to "a" (NFKC).
let d = new(Algorithm::Aes256, "SªSL\u{AD}prep", "o", -1).dict().clone();
assert!(SecurityHandler::open(d.clone(), ID, Some("SaSLprep")).is_ok());
assert!(SecurityHandler::open(d, ID, Some("SaSL-prep")).is_err());
}
#[test]
fn effective_passwords_are_the_bytes_the_keys_are_made_from() {
// AES-256: SASLprep drops soft hyphens, makes other spaces plain and folds compatibility
// characters (NFKC); at most 127 bytes of UTF-8.
let r6 = |pw: &str| effective_password(Algorithm::Aes256, pw);
assert_eq!(r6("\u{AD}"), r6(""), "a soft hyphen alone is no password");
assert_eq!(r6("\u{FF21}"), r6("A"), "fullwidth A");
assert_eq!(r6("\u{FB01}"), r6("fi"), "the fi ligature");
assert_eq!(r6("a\u{3000}b"), b"a b", "an ideographic space");
assert_ne!(r6("A"), r6("a"), "case matters");
assert_eq!(r6(&"é".repeat(100)).len(), 127);
// RC4 and AES-128: PDFDocEncoding, padded or cut to exactly 32 bytes.
let (a31, a32) = ("a".repeat(31), "a".repeat(32));
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
assert_eq!(effective_password(alg, &format!("{a32}1")), a32.as_bytes(), "{alg:?}");
assert_eq!(effective_password(alg, &format!("1{a32}")), effective_password(alg, &format!("1{a32}2")), "{alg:?}");
assert_ne!(effective_password(alg, &format!("1{a32}")), effective_password(alg, &format!("2{a32}")), "{alg:?}");
assert_eq!(effective_password(alg, &a31), effective_password(alg, &format!("{a31}(")), "{alg:?}: the padding starts with '('");
assert_eq!(effective_password(alg, ""), PADDING, "{alg:?}");
assert_eq!(effective_password(alg, "ılık€")[..5], [0x9A, b'l', 0x9A, b'k', 0xA0], "{alg:?}");
}
}
#[test]
fn passwords_with_the_same_effective_bytes_are_one_password() {
// A file protected with "\u{FF21}" (fullwidth A) opens with "A".
let d = new(Algorithm::Aes256, "\u{FF21}", "owner", 0b0100).dict().clone();
assert_eq!(SecurityHandler::open(d.clone(), ID, Some("A")).unwrap().auth(), Auth::User);
assert_eq!(SecurityHandler::open(d, ID, Some("a")).unwrap_err(), CryptError::WrongPassword);
// A soft hyphen is no open password at all.
let d = new(Algorithm::Aes256, "\u{AD}", "owner", 0b0100).dict().clone();
assert_eq!(SecurityHandler::open(d, ID, None).unwrap().auth(), Auth::User);
// Open and permissions passwords with the same bytes: the open password gives owner rights.
let d = new(Algorithm::Aes256, "\u{FB01}", "fi", 0b0100).dict().clone();
assert_eq!(effective_password(Algorithm::Aes256, "\u{FB01}"), effective_password(Algorithm::Aes256, "fi"));
assert_eq!(SecurityHandler::open(d, ID, Some("\u{FB01}")).unwrap().auth(), Auth::Owner);
let a31 = "a".repeat(31);
let pairs = [(format!("{a31}a1"), format!("{a31}a2")), (a31.clone(), format!("{a31}("))];
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
for (open, perms) in &pairs {
assert_eq!(effective_password(alg, open), effective_password(alg, perms), "{alg:?}");
let d = new(alg, open, perms, 0b0100).dict().clone();
assert_eq!(SecurityHandler::open(d, ID, Some(open)).unwrap().auth(), Auth::Owner, "{alg:?} {open:?}");
}
}
}
#[test]
fn unsupported_handlers_are_reported() {
let mut d = new(Algorithm::Aes128, "", "o", -1).dict().clone();
d.filter = b"Adobe.PubSec".to_vec();
assert!(matches!(SecurityHandler::open(d.clone(), ID, None), Err(CryptError::Unsupported(_))));
d.filter = b"Standard".to_vec();
d.r = 7;
assert!(matches!(SecurityHandler::open(d, ID, None), Err(CryptError::Unsupported(_))));
}
#[test]
fn permission_bits_follow_table_22() {
let p = Permissions { bits: -3904, owner: false, revision: 3 }; // 0xFFFFF0C0: only reserved bits set
assert!(!p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.assemble());
let p = Permissions { bits: -1, owner: false, revision: 3 };
assert!(p.print() && p.modify() && p.copy() && p.annotate() && p.assemble() && p.fill_forms());
let p = Permissions { bits: 0, owner: true, revision: 3 };
assert!(p.modify(), "owners ignore /P");
}
/// Page extraction (#5081) is assembly as the revision reads it, and copying (bit 5).
#[test]
fn page_extraction_needs_assembly_and_copying() {
let bit = |n: u32| 1i32 << (n - 1);
let user = |bits: i32, revision: i64| Permissions { bits, owner: false, revision };
// "Any except extracting pages" (bits 4, 6, 9; copying off) at revision 3 and later.
assert!(!user(bit(4) | bit(6) | bit(9), 4).extract_pages());
// Pages may change in place, but copying is withheld.
assert!(user(bit(11), 6).assemble() && !user(bit(11), 6).extract_pages());
assert!(user(bit(11) | bit(5), 6).extract_pages());
// Revision 2: bit 4 covers assembly, and its reserved bit 11 grants nothing.
assert!(user(bit(4), 2).assemble() && !user(bit(4) | bit(11), 2).extract_pages(), "copying withheld");
assert!(user(bit(4) | bit(5), 2).extract_pages());
assert!(!user(bit(5) | bit(11), 2).extract_pages(), "no assembly without bit 4 at revision 2");
assert!(Permissions { bits: 0, owner: true, revision: 2 }.extract_pages(), "owners ignore /P");
}
#[test]
fn perms_limits_what_an_edited_p_grants() {
let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only
let open = |d: &EncryptDict, pw| SecurityHandler::open(d.clone(), ID, Some(pw)).unwrap().permissions();
let mut d = made.dict().clone();
assert_eq!(open(&d, "user pw").bits, d.p, "/P and /Perms agree");
d.p = -4; // /P edited to allow everything, /Perms as written
let p = open(&d, "user pw");
assert!(p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.fill_forms() && !p.assemble());
assert!(open(&d, "owner pw").modify(), "owners ignore /P");
d.p = -3904; // a stricter /P still applies
assert!(!open(&d, "user pw").print());
// No /Perms, or one that does not decrypt to an `adb` block: /P as before.
for perms in [Vec::new(), vec![0; 16], made.dict().perms[..15].to_vec()] {
let d = EncryptDict { p: -4, perms, ..made.dict().clone() };
assert!(open(&d, "user pw").modify());
}
// R2 to R4 have no /Perms: /P is part of their file key, so an edited /P does not open.
let d = EncryptDict { p: -4, ..new(Algorithm::Aes128, "user pw", "owner pw", 0b0100).dict().clone() };
assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap_err(), CryptError::WrongPassword);
}
// Not built for the web: proptest is a dev-dependency of the other targets only.
#[cfg(not(target_arch = "wasm32"))]
proptest::proptest! {
#[test]
fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60),
u in proptest::collection::vec(proptest::num::u8::ANY, 0..60),
r in 0i64..9, len in -10i64..300) {
let d = EncryptDict { filter: b"Standard".to_vec(), v: 4, r, length_bits: len, o, u, p: -1, encrypt_metadata: true, ..Default::default() };
let _ = SecurityHandler::open(d, ID, Some("x"));
}
}
/// A `/V 4` RC4 crypt filter with a 40- or 48-bit key, as Acrobat writes it (pdf.js issue 19484):
/// the file key is extended to 16 bytes with zeros before Algorithm 1, so each object's key is
/// all 16 bytes of MD5(key, zeros, object, generation). Everything else keeps Algorithm 1 as the
/// standard has it, n + 5 bytes (at most 16) of MD5(key, object, generation[, "sAlT"]): a `/V 2`
/// file with the same short key, a full 128-bit `/V 4` RC4 key, and AESV2, short (88 bits) or
/// full. Each object key is computed here by hand; data written with it must decrypt.
#[test]
fn a_v4_rc4_key_shorter_than_16_bytes_is_extended_with_zeros() {
let text = b"Page tree in an object stream".to_vec();
let cases =
[(4, 40, Method::Rc4), (4, 48, Method::Rc4), (2, 40, Method::Rc4), (4, 128, Method::Rc4), (4, 88, Method::Aes128), (4, 128, Method::Aes128)];
for (v, bits, method) in cases {
let mut d = EncryptDict {
filter: b"Standard".to_vec(),
v,
r: if v == 4 { 4 } else { 3 },
length_bits: bits,
o: vec![7; 32],
p: -3392,
encrypt_metadata: v != 4,
..Default::default()
};
if v == 4 {
d.crypt_filters = vec![(b"StdCF".to_vec(), method)];
d.stm_f = b"StdCF".to_vec();
d.str_f = b"StdCF".to_vec();
}
let key = file_key_r4(&d, ID, b"");
assert_eq!(key.len(), (bits / 8) as usize);
d.u = u_value_r4(&d, ID, &key);
let h = SecurityHandler::open(d, ID, None).unwrap();
let (num, generation) = (13u32, 0u16);
let extended = v == 4 && method == Method::Rc4 && key.len() < 16;
let mut md5 = Md5::new();
md5.update(&key);
if extended {
md5.update(vec![0u8; 16 - key.len()]);
}
md5.update(&num.to_le_bytes()[..3]);
md5.update(generation.to_le_bytes());
if method == Method::Aes128 {
md5.update(b"sAlT");
}
let digest = md5.finalize();
let n = if extended { 16 } else { key.len() };
let object_key = digest[..(n + 5).min(16)].to_vec();
let case = format!("V{v}, {bits}-bit key, {method:?}");
let written = if method == Method::Rc4 {
rc4(&object_key, &text)
} else {
let iv = [3u8; 16];
[iv.to_vec(), aes_cbc_encrypt(&object_key, &iv, &text, true)].concat()
};
assert_eq!(h.decrypt_string(num, generation, &written), text, "{case}");
assert_eq!(h.decrypt_stream(num, generation, &written, StreamKind::Normal), text, "{case}");
let saved = h.encrypt_stream(num, generation, &text, StreamKind::Normal);
if method == Method::Rc4 {
assert_eq!(saved, written, "saved as it was read: {case}");
}
assert_eq!(h.decrypt_stream(num, generation, &saved, StreamKind::Normal), text, "{case}");
}
}
#[test]
fn owner_authentication_recovers_the_user_password() {
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
let d = new(alg, "user pw", "owner pw", -1).dict().clone();
let h = SecurityHandler::open(d.clone(), ID, Some("owner pw")).unwrap();
let _: &Zeroizing<Vec<u8>> = &h.key;
let _: &Option<Zeroizing<Vec<u8>>> = &h.recovered_user;
assert_eq!(h.recovered_user_password().as_deref().map(Vec::as_slice), Some(&b"user pw"[..]), "{alg:?}");
assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None);
}
let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone();
assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref().map(Vec::as_slice), Some(&b""[..]));
}
/// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys
/// `qpdf --show-encryption-key` reports.
fn qpdf_file(r: i64, o: &str, u: &str) -> EncryptDict {
let mut d = EncryptDict {
filter: b"Standard".to_vec(),
v: if r == 4 { 4 } else { 2 },
r,
length_bits: 128,
o: hex(o),
u: hex(u),
p: -4,
encrypt_metadata: true,
..Default::default()
};
if r == 4 {
d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes128)];
d.stm_f = b"StdCF".to_vec();
d.str_f = b"StdCF".to_vec();
}
d
}
const QPDF_ID: &str = "56b57383aafa8314f3f98222995f5604";
#[test]
fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() {
let id = hex(QPDF_ID);
// R3 RC4: user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0); owner "Öğretmen", which
// PDFDocEncoding can't hold, so qpdf wrote its UTF-8 bytes.
let r3 = qpdf_file(
3,
"d519aa8120ce8c54812b45e5391916fed167b1c84961d90c4ee4c182ad83534b",
"4985c03ddfa3eeb8a90c343512913ec40021446990b9e4114071a4d9104984c1",
);
let user = SecurityHandler::open(r3.clone(), &id, Some("ılık€")).unwrap();
assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f")));
let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap();
assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f")));
assert_eq!(owner.recovered_user_password(), Some(Zeroizing::new(vec![0x9A, b'l', 0x9A, b'k', 0xA0])));
// Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it.
for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] {
assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}");
}
// R4 AES-128: user "şifre" (UTF-8 bytes), owner "owner".
let r4 = qpdf_file(
4,
"bb4f8f4bd2236569e765906caf64e4429a4c20d6e996fdef963e9b5080f9e083",
"4a965fb91882772cd09ce8bbff2c33270021446990b9e4114071a4d9104984c1",
);
let user = SecurityHandler::open(r4.clone(), &id, Some("şifre")).unwrap();
assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("9f19d465cfd8e525d32aa0bdf10acd06")));
assert_eq!(SecurityHandler::open(r4.clone(), &id, Some("owner")).unwrap().auth(), Auth::Owner);
assert_eq!(SecurityHandler::open(r4, &id, Some("ifre")).unwrap_err(), CryptError::WrongPassword);
}
#[test]
fn pdfdoc_encoding_round_trips() {
for b in 0..=255u8 {
if b != 0x9F {
assert_eq!(pdfdoc_byte(pdfdoc_char(b)), Some(b), "{b:#04x}");
}
}
assert_eq!(pdfdoc_byte('ı'), Some(0x9A));
assert_eq!(pdfdoc_byte('€'), Some(0xA0));
assert_eq!(pdfdoc_byte('ç'), Some(0xE7));
for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] {
assert_eq!(pdfdoc_byte(c), None, "{c}");
}
assert_eq!(legacy_candidates("Çok"), [Zeroizing::new(vec![0xC7, b'o', b'k'])]);
assert_eq!(legacy_candidates("şifre"), [Zeroizing::new(b"ifre".to_vec()), Zeroizing::new("şifre".as_bytes().to_vec())]);
}
+2
View File
@@ -4,6 +4,8 @@
//! Advanced backend re-exports are coupled to the pinned backend, not a universal document model.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(feature = "crypt")]
pub mod crypt;
#[cfg(feature = "interpret")]
pub use hayro_interpret as interpret;
mod link;
+7
View File
@@ -0,0 +1,7 @@
//! Tests of the `crypt` feature, linked into one test binary. Not built for the web: proptest
//! does not build there.
#![cfg(all(feature = "crypt", not(target_arch = "wasm32")))]
mod permissions_revision;
mod proptests;
mod qpdf_literals;
@@ -0,0 +1,126 @@
//! The permission accessors of `craft_pdf::crypt::Permissions`, checked against the flag table of
//! ISO 32000-1:2008 (Table 22). Expected values come from that table only.
//!
//! Bit n has the mask `1 << (n - 1)`. Readers use bits 3 to 6 and 9 to 12 and ignore the rest.
use craft_pdf::crypt::{Algorithm, Auth, NewEncryption, Permissions, SecurityHandler, create};
const ID: &[u8] = b"0123456789abcdef";
const T: bool = true;
const F: bool = false;
/// The eight accessors in this order: print, high-quality print, modify, copy, accessibility,
/// annotate, fill forms, assemble.
fn cells(p: &Permissions) -> [bool; 8] {
[p.print(), p.print_high_quality(), p.modify(), p.copy(), p.extract_for_accessibility(), p.annotate(), p.fill_forms(), p.assemble()]
}
/// Creates a file with `flags` at revision `revision`, opens it with the user password, and
/// returns the user's permissions.
fn user_view(alg: Algorithm, revision: i64, flags: i32) -> Permissions {
let made = create(
&NewEncryption { algorithm: alg, user_password: "user", owner_password: "owner", permissions: flags, encrypt_metadata: true, seed: [42; 32] },
ID,
)
.expect("the handler is created");
assert_eq!(made.dict().r, revision, "the algorithm writes revision {revision}");
let opened = SecurityHandler::open(made.dict().clone(), ID, Some("user")).expect("the user password opens the file");
assert_eq!(opened.auth(), Auth::User, "the user password gives user access");
opened.permissions()
}
/// Revision 3 and later: each row is a flag word and the expected eight cells.
const REVISION_3_ROWS: &[(&str, i32, [bool; 8])] = &[
("every meaningful flag set, ignored flags clear", -196, [T, T, T, T, T, T, T, T]),
("every flag set, ignored flags set too", -1, [T, T, T, T, T, T, T, T]),
("reserved flags only", -4096, [F, F, F, F, F, F, F, F]),
("ignored flags set, meaningful flags clear", -3901, [F, F, F, F, F, F, F, F]),
("modify flag clear", -9, [T, T, F, T, T, T, T, T]),
("copy flag clear", -17, [T, T, T, F, T, T, T, T]),
("annotate flag clear", -33, [T, T, T, T, T, F, T, T]),
("annotate and fill-existing flags clear", -289, [T, T, T, T, T, F, F, T]),
("annotate flag set, fill-existing flag clear", -257, [T, T, T, T, T, T, T, T]),
("accessibility flag clear", -513, [T, T, T, T, F, T, T, T]),
("high-quality flag clear, print flag set", -2049, [T, F, T, T, T, T, T, T]),
("bits 3 to 12 set, reserved bits clear", 4092, [T, T, T, T, T, T, T, T]),
("positive flag word, bit 32 clear", 2_147_483_647, [T, T, T, T, T, T, T, T]),
("revision 2 example read at revision 3", -44, [T, T, F, T, T, F, T, T]),
];
#[test]
fn revision_three_rows_hold_through_a_created_and_reopened_file() {
for (name, bits, want) in REVISION_3_ROWS {
assert_eq!(cells(&user_view(Algorithm::Rc4_128, 3, *bits)), *want, "{name}: flag word {bits:#010x}");
}
}
#[test]
fn owner_access_gives_every_flag() {
let made = create(
&NewEncryption {
algorithm: Algorithm::Rc4_128,
user_password: "user",
owner_password: "owner",
permissions: 0,
encrypt_metadata: true,
seed: [42; 32],
},
ID,
)
.expect("the handler is created");
assert_eq!(made.auth(), Auth::Owner, "the owner password gives owner access");
assert_eq!(cells(&made.permissions()), [T; 8]);
}
#[test]
fn revision_two_print_modify_copy_and_annotate_follow_the_table() {
for (bits, want) in [(-4, [T, T, T, T]), (-44, [T, F, T, F]), (-17, [T, T, F, T])] {
let p = user_view(Algorithm::Rc4_40, 2, bits);
assert_eq!([p.print(), p.modify(), p.copy(), p.annotate()], want, "revision 2, flag word {bits}");
}
}
#[test]
fn revision_two_with_every_flag_set_grants_accessibility_and_form_filling() {
let p = user_view(Algorithm::Rc4_40, 2, -4);
assert!(p.extract_for_accessibility(), "bit 5 is set, and at revision 2 it covers accessibility");
assert!(p.fill_forms(), "bit 6 is set");
}
#[test]
fn revision_three_assembly_needs_its_own_flag() {
let p = user_view(Algorithm::Rc4_128, 3, -1025);
assert!(p.modify(), "bit 4 is set");
assert!(!p.assemble(), "assembly needs bit 11; bit 4 alone does not grant it");
}
#[test]
fn revision_three_high_quality_print_needs_the_print_flag() {
let p = user_view(Algorithm::Rc4_128, 3, -5);
assert!(!p.print(), "bit 3 is clear");
assert!(!p.print_high_quality(), "high-quality print needs bit 3 and bit 12");
}
#[test]
fn revision_two_accessibility_follows_the_copy_flag() {
let p = user_view(Algorithm::Rc4_40, 2, -17);
assert!(!p.copy(), "bit 5 is clear");
assert!(!p.extract_for_accessibility(), "at revision 2 accessibility is bit 5");
}
#[test]
fn revision_two_form_filling_ignores_bit_nine() {
let p = user_view(Algorithm::Rc4_40, 2, -44);
assert!(!p.annotate(), "bit 6 is clear");
assert!(!p.fill_forms(), "at revision 2 form filling is bit 6 alone");
}
#[test]
fn revision_two_ignores_the_flags_only_later_revisions_define() {
// Bit 4 is clear and bit 11 set: bit 11 means nothing at revision 2, so assembly stays refused.
let p = user_view(Algorithm::Rc4_40, 2, -44);
assert!(!p.assemble(), "at revision 2 assembly is bit 4 alone");
// Bit 3 is set and bit 12 clear: printing at revision 2 has one quality.
let p = user_view(Algorithm::Rc4_40, 2, -2049);
assert!(p.print_high_quality(), "at revision 2 high-quality print is bit 3 alone");
}
+464
View File
@@ -0,0 +1,464 @@
//! 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 (apps
//! open 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/crypt/tests.rs` (the pdfcraft #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 craft_pdf::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 pdfcraft #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, pdfcraft #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");
}
}
+326
View File
@@ -0,0 +1,326 @@
//! Encryption numbers from an **outside implementation**. Six of the seven rows below are qpdf's own,
//! written by qpdf core 12.2.0 through pikepdf 9.11, and their `/O`, `/U`, `/OE`, `/UE`, `/Perms`
//! and `/ID` values were copied out of those files rather than computed here. The seventh row,
//! `crafted-r3-40bit-hand-derived`, is ours and says so where it is defined - no producer we can run
//! here writes revision 3 at 40 bits (qpdf core 12.2.0 emits `/Length 128` at `R=3`, measured below),
//! so nothing outside computed that one either.
//!
//! That distinction is the whole point. `create()` and `open()` share one derivation, so a wrong-but
//! self-consistent implementation round-trips cleanly against itself: across two review rounds we
//! broke the key derivation several ways and the unit suite stayed green (deleting Algorithm 2 step f
//! leaves all 17 unit tests passing while our rows fail). Quoting an outsider's bytes
//! breaks the circle - `open()` recomputes `/U` (or the revision 6 hash chain) and compares it to what
//! was stored, so a derivation that diverges cannot authenticate at all.
//!
//! Revisions covered: 2 (export-grade RC4, where the five-byte key makes a truncation error visible that
//! every pre-existing R3/R4 vector in this repository hides as a no-op), 3, and 4 and 6 each with metadata
//! encrypted *and* plain. For revision 4 that pairing is the only outside view of Algorithm 2 step
//! f's four `FF` bytes; revision 6 shows the same flag differently - its `/U` differs between the two
//! metadata variants, which is measured here rather than attributed to a named algorithm step. Only the
//! numbers are committed - never the PDFs, and no generator script that lives outside
//! this repository. To re-derive the six qpdf rows, take one blank 200x200 page and save it with
//! pikepdf 9.11 (qpdf core 12.2.0) using `Encryption(owner=OWNER, user=USER,`
//! `allow=Permissions(extract=False), R=<revision>, aes=…, metadata=…)` plus `save(..., static_id=True)`,
//! then read `/Encrypt` back. Below revision 4 both flags are forced rather than chosen, because pikepdf
//! refuses AES and refuses encrypted metadata there - so those two rows need `aes=False, metadata=False`,
//! while revisions 4 and 6 take `aes=True` with the case's own metadata flag. Run to run, every quoted
//! field of those rows reproduces at revisions 2-4, and whole files too for revisions 2 and 3; a
//! revision 4 file falls short of that by one stream, because `/CFM /AESV2` prepends a fresh random IV.
//! Revision 6 reproduces structurally only, its salts being new each run. What stays checked rather than
//! trusted - `open()` recovers the user password from `/O` and verifies `/U` at revisions 2-4
//! (Algorithm 7), matches both stored hashes at revision 6 and unwraps `/OE`/`/UE` into the file key
//! there; this file then decrypts `/Perms` with that key, which authentication itself never reads. One
//! thing no assertion here verifies is the crypt-filter names: renaming a row's `/StmF` or replacing its
//! filter set leaves all three tests green, so those are held to review rather than to a test. The
//! rows come from PdfCraft, which uses qpdf as an oracle (its `crates/oracle/README.md`).
use craft_pdf::crypt::{Auth, EncryptDict, Method, SecurityHandler, aes_cbc_decrypt};
const USER: &str = "open pw";
const OWNER: &str = "owner pw";
/// One file qpdf wrote. Bytes are held as hex so the table stays a `const`: the literals on screen are
/// then exactly what qpdf emitted, with no runtime step in between to trust.
struct Case {
label: &'static str,
v: i64,
r: i64,
length_bits: i64,
p: i32,
encrypt_metadata: bool,
id0_hex: &'static str,
o_hex: &'static str,
u_hex: &'static str,
oe_hex: &'static str,
ue_hex: &'static str,
perms_hex: &'static str,
crypt_filters: fn() -> Vec<(Vec<u8>, Method)>,
stm_f: &'static str,
str_f: &'static str,
key_len_bytes: usize,
}
impl Case {
fn dict(&self) -> EncryptDict {
EncryptDict {
filter: b"Standard".to_vec(),
v: self.v,
r: self.r,
length_bits: self.length_bits,
o: hex(self.o_hex),
u: hex(self.u_hex),
oe: hex(self.oe_hex),
ue: hex(self.ue_hex),
perms: hex(self.perms_hex),
p: self.p,
encrypt_metadata: self.encrypt_metadata,
crypt_filters: (self.crypt_filters)(),
stm_f: self.stm_f.as_bytes().to_vec(),
str_f: self.str_f.as_bytes().to_vec(),
ef_f: Vec::new(),
}
}
fn id0(&self) -> Vec<u8> {
hex(self.id0_hex)
}
}
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn no_filters() -> Vec<(Vec<u8>, Method)> {
Vec::new()
}
fn aes128_filter() -> Vec<(Vec<u8>, Method)> {
vec![(b"StdCF".to_vec(), Method::Aes128)]
}
fn aes256_filter() -> Vec<(Vec<u8>, Method)> {
vec![(b"StdCF".to_vec(), Method::Aes256)]
}
/// `/ID[0]` is shared by every case because qpdf wrote them with `static_id`.
const CASES: &[Case] = &[
Case {
label: "r2-40bit",
v: 1,
r: 2,
length_bits: 40,
p: -28,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "166a894580aae60ae93fa9d83fc576f26f13dbe4574ce6c57497bec3ece7f125",
u_hex: "69ff2fb98261fd094174f93f0388859f356080ca486a38ce2d5ac23b98f5f89c",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: no_filters,
stm_f: "",
str_f: "",
key_len_bytes: 5,
},
Case {
label: "r3-128rc4",
v: 2,
r: 3,
length_bits: 128,
p: -1044,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848",
u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: no_filters,
stm_f: "",
str_f: "",
key_len_bytes: 16,
},
Case {
label: "r4-aes128-meta-enc",
v: 4,
r: 4,
length_bits: 128,
p: -1044,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848",
u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: aes128_filter,
stm_f: "StdCF",
str_f: "StdCF",
key_len_bytes: 16,
},
Case {
label: "r4-aes128-meta-plain",
v: 4,
r: 4,
length_bits: 128,
p: -1044,
encrypt_metadata: false,
id0_hex: "31415926535897932384626433832795",
o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848",
u_hex: "d6d363fc817815701f0242a581cb39e60122456a91bae5134273a6db134c87c4",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: aes128_filter,
stm_f: "StdCF",
str_f: "StdCF",
key_len_bytes: 16,
},
Case {
label: "r6-aes256-meta-enc",
v: 5,
r: 6,
length_bits: 256,
p: -1044,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "1c791d66227fdfd7c1d225eb81fb3408d8bcfac1a0ff71cc57b10eb29029e5f6965cccb6b94e461d9da354a9dbd6eea7",
u_hex: "64ffbf79168ed7b325267cdba8dae211d4c2c5ebdffd339c7ac36c33e26f23b7f478c60ae42d50b5d20ceac56d659151",
oe_hex: "d2f6ef0bcc6ce93228be760c7eee8f4259af7a6044a4c4e04a0d48ab0893ef1e",
ue_hex: "963035aa24fb009e6d26b3cffd31492ffe6f4e563e4dcae8d7b7e3203c4a2f56",
perms_hex: "5675a86560873d2532e856a37045247e",
crypt_filters: aes256_filter,
stm_f: "StdCF",
str_f: "StdCF",
key_len_bytes: 32,
},
Case {
label: "r6-aes256-meta-plain",
v: 5,
r: 6,
length_bits: 256,
p: -1044,
encrypt_metadata: false,
id0_hex: "31415926535897932384626433832795",
o_hex: "e227e586c4dc5a362c7271d960565cf3a37145e33af5bf5a08c73b52a1d819e152c737176dc08774e400798d6534e95e",
u_hex: "dbb84b847bb2d80865cc3c5a7c751d4c659d2c96ed1750f8be6f9c3b93fe3d11f494b43e5f17a21c85f5a4b171055afb",
oe_hex: "8c31f2392bc2a093850269a3404cbe01cea38e5e0fe308fe8ac50f5222cff103",
ue_hex: "2b0c44e4bddb3c9c01431b4ed3c7da42dbd1ef47e610d626ec2124535a0a4469",
perms_hex: "adbb69fb9fffde31ba90aaaf5eb4bdaf",
crypt_filters: aes256_filter,
stm_f: "StdCF",
str_f: "StdCF",
key_len_bytes: 32,
},
// HAND-DERIVED, not produced by qpdf: an independent re-implementation of ISO Algorithms 2/3
// (n = 5) computed this pair, cross-validated by having the same routine reproduce qpdf's
// n = 16 `/O` byte for byte. It exists because no producer we can run here emits R3 at 40 bits -
// qpdf core 12.2.0 writes `/Length 128` there, and this crate's own create() maps Rc4_40 to revision 2 - so
// the only route we have to `key_len` = 5 with the derivation loop RUNNING (r >= 3) is to craft
// it by hand. With that combination live, the loop's truncation stops being a no-op: mutating
// `Md5::digest(&digest[..n])` to hash the whole digest makes this password stop authenticating.
Case {
label: "crafted-r3-40bit-hand-derived",
v: 2,
r: 3,
length_bits: 40,
p: -1044,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "50e50251fd20acb28ba50013142d19a47249d06b977d28b63cb3ce9d56fb5d50",
u_hex: "000756f24fe648256830aec6155ca6f100000000000000000000000000000000",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: no_filters,
stm_f: "",
str_f: "",
key_len_bytes: 5,
},
];
#[test]
fn outside_derived_dictionaries_authenticate_with_the_intended_role() {
for case in CASES {
let d = case.dict();
let id0 = case.id0();
// The user password must open as a user. Owners are tried first, so an `Owner` here would mean
// the two passwords had collapsed onto one set of bytes - on these spellings they have not.
let user = SecurityHandler::open(d.clone(), &id0, Some(USER))
.unwrap_or_else(|e| panic!("{}: qpdf's user password was refused by our reader: {e}", case.label));
assert_eq!(user.auth(), Auth::User, "{}", case.label);
// The owner password must open as owner: revisions 2-4 recover the user password from `/O`
// (Algorithm 7), revision 6 hashes against qpdf's own `/O`.
let owner =
SecurityHandler::open(d.clone(), &id0, Some(OWNER)).unwrap_or_else(|e| panic!("{}: qpdf's owner password was refused: {e}", case.label));
assert_eq!(owner.auth(), Auth::Owner, "{}", case.label);
// A wrong password may never open it; and the key must be exactly as wide as the revision
// promises - those five bytes are what makes a revision 2 truncation detectable at all.
assert!(SecurityHandler::open(d.clone(), &id0, Some("not the password")).is_err(), "{}: a wrong password opened", case.label);
assert_eq!(user.file_key().len(), case.key_len_bytes, "{}: file key width", case.label);
assert_eq!(owner.file_key(), user.file_key(), "{}: whichever password opens it, one key", case.label);
// A user session must not claim owner rights. We deliberately do NOT assert individual
// permission flags here: which bit means "extract" moves between revision 2 and revisions 4
// (qpdf wrote `/P = -28` for the export-grade file, where our accessor's bit-10 reading of
// "extract for accessibility" is a reserved field), and pinning that mapping belongs with the
// permissions tests, not with this oracle. What matters here is only that authentication, role
// and key width agree with what an outside implementation produced.
assert!(!user.permissions().owner, "{}: a user session claimed owner rights", case.label);
}
}
#[test]
fn metadata_flag_from_outside_changes_the_file_key_it_certifies() {
// `/EncryptMetadata` false folds four set bytes into a revision 4 key (Algorithm 2, step f) and
// changes what revision 6 stores in `/U`. qpdf wrote both variants of each, so an implementation
// that ignored the flag would give identical keys. Internally this is no longer untested -
// `tests/it/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests
// still share our derivation, so what these rows add is a second implementation's own numbers.
for (enc_label, plain_label) in [("r4-aes128-meta-enc", "r4-aes128-meta-plain"), ("r6-aes256-meta-enc", "r6-aes256-meta-plain")] {
let a = CASES.iter().find(|c| c.label == enc_label).expect("encrypted-metadata fixture present");
let b = CASES.iter().find(|c| c.label == plain_label).expect("plain-metadata fixture present");
let ha = SecurityHandler::open(a.dict(), &a.id0(), Some(USER)).unwrap_or_else(|e| panic!("{enc_label}: refused by our reader: {e}"));
let hb = SecurityHandler::open(b.dict(), &b.id0(), Some(USER))
.unwrap_or_else(|e| panic!("{plain_label}: refused by our reader - is step f being ignored? {e}"));
assert_ne!(ha.file_key(), hb.file_key(), "{enc_label} and {plain_label} must not share a key: the metadata flag is hashed in");
assert!(ha.encrypts_metadata(), "{enc_label} reports encrypted metadata");
assert!(!hb.encrypts_metadata(), "{plain_label} reports plain metadata, as qpdf wrote it");
}
// The outsider's numbers must differ across revisions; equality here would mean a transcription
// error, which would quietly void every assertion above.
let r2 = CASES.iter().find(|c| c.r == 2).unwrap();
let r3 = CASES.iter().find(|c| c.r == 3).unwrap();
assert_ne!(hex(r2.u_hex), hex(r3.u_hex), "revision 2 and revision 3 /U differ because the key width differs");
assert_eq!(r2.u_hex.len(), 64, "revision 2 /U is RC4 over the padding: 32 bytes");
assert_eq!(r3.u_hex.len(), 64, "revision 3 /U is 32 bytes: sixteen derived, plus padding qpdf fills (our crafted row leaves it zero)");
}
#[test]
fn qpdf_perms_decode_to_the_shape_both_implementations_agree_on() {
// `/Perms` is the one value in this dictionary that no test here verified before: `cos` writes it
// out and reads it back, but nothing decrypts it. Under qpdf's own bytes we can - `/P`
// little-endian, four set bytes, `T`/`F`, the `adb` sentinel (Algorithm 10 here; vendored lopdf
// checks the same fields as its Algorithm 13, though only three bytes of `/P`).
let mut checked = 0;
for case in CASES.iter().filter(|c| !c.perms_hex.is_empty()) {
let h = SecurityHandler::open(case.dict(), &case.id0(), Some(USER))
.unwrap_or_else(|e| panic!("{}: refused before /Perms could be read: {e}", case.label));
let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &hex(case.perms_hex), false);
assert_eq!(open.len(), 16, "{}: /Perms is one encrypted block", case.label);
assert_eq!(&open[..4], &case.p.to_le_bytes(), "{}: /P must round trip against qpdf's bytes", case.label);
assert_eq!(&open[4..8], &[0xFF; 4], "{}: the four bytes after /P are set", case.label);
assert_eq!(open[8], if case.encrypt_metadata { b'T' } else { b'F' }, "{}: metadata byte as qpdf wrote it", case.label);
assert_eq!(&open[9..12], b"adb", "{}: sentinel from qpdf's bytes", case.label);
assert_ne!(&open[12..16], &[0u8; 4], "{}: qpdf's filler is not frozen at zero", case.label);
checked += 1;
}
assert!(checked >= 2, "expected /Perms on both revision 6 cases; saw {checked}");
}