mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-15 06:52:23 +00:00
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:
1 parent
bafefe627a
commit
f233df14ad
12 files changed
+2339
-1
No files matched your search
Generated
+17
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶ms.seed, b"file key", 32));
|
||||
let salts = expand(¶ms.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(¶ms.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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user