craft-pdf: crypt ports of two open PdfCraft changes (revision 2 reserved bits, attachments-only encryption) (#952)

* 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.

* craft-pdf: crypt create sets every bit revision 2 reserves

Revision 2 reserves bits 7 to 32 of /P, later revisions bits 7-8 and 13-32.
create left bits 9 to 12 as the caller gave them for RC4-40.

Ported from storytold/pdfcraft#2789 (the create half; the accessors already
read /P by revision).

* craft-pdf: crypt can encrypt only embedded files

create_attachments_only builds AES encryption whose /StmF and /StrF are
/Identity and whose /EFF is the standard filter (ISO 32000-2 §7.6.6). RC4-40
and RC4-128 have no crypt filters and are refused.

Ported from storytold/pdfcraft#5071 (the crypt half). The test is new: the
source change tests it through PdfCraft's object layer.

---------

Co-authored-by: Ahmad Mujahid <me@ahmadmu.com>
Co-authored-by: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com>
This commit is contained in:
Dylan M. Taylor
2026-11-11 20:04:13 -05:00
committed by GitHub
co-authored by Ahmad Mujahid Harrison Booth
parent 9e78e96c16
commit 8b7f64e2e0
2 changed files with 65 additions and 2 deletions
+19 -2
View File
@@ -726,8 +726,10 @@ pub fn file_id(seed: &[u8; 32]) -> Vec<u8> {
/// 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;
// R2 reserves bits 7-32; later revisions reserve bits 7-8 and 13-32.
// Reserved bits must be 1, and bits 1-2 must be 0 (§7.6.4.2).
let reserved = if params.algorithm == Algorithm::Rc4_40 { !0x3F } else { !0xFFF | 0xC0 };
let p = (params.permissions | reserved) & !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);
@@ -792,5 +794,20 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
}
}
/// Like [`create`], but only embedded file streams are encrypted (`/EFF` names the standard
/// filter; `/StmF` and `/StrF` are `/Identity`, §7.6.6). Needs crypt filters, so RC4-40 and
/// RC4-128 (V < 4) are refused rather than silently encrypting everything.
pub fn create_attachments_only(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, CryptError> {
if matches!(params.algorithm, Algorithm::Rc4_40 | Algorithm::Rc4_128) {
return Err(CryptError::Unsupported("encrypting only attachments needs AES (crypt filters)".into()));
}
let h = create(params, id0)?;
let mut dict = h.dict.clone();
dict.stm_f = b"Identity".to_vec();
dict.str_f = b"Identity".to_vec();
dict.ef_f = b"StdCF".to_vec();
Ok(SecurityHandler::new(dict, h.key, h.auth, h.recovered_user))
}
#[cfg(test)]
mod tests;
+46
View File
@@ -447,3 +447,49 @@ fn pdfdoc_encoding_round_trips() {
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())]);
}
#[test]
fn r2_create_sets_every_bit_revision_2_reserves() {
// ISO 32000-1 section 7.6.3.2: -44 permits printing and copying only.
let d = new(Algorithm::Rc4_40, "user", "owner", 0x14).dict().clone();
assert_eq!(d.p, -44);
assert_eq!(new(Algorithm::Rc4_40, "user", "owner", 0).dict().p, -64, "R2 reserves bits 7 through 32");
let user = SecurityHandler::open(d, ID, Some("user")).unwrap();
assert_eq!(user.dict().p, -44);
let p = user.permissions();
assert_eq!((p.print(), p.print_high_quality(), p.copy(), p.extract_for_accessibility()), (true, true, true, true));
assert_eq!((p.modify(), p.annotate(), p.fill_forms(), p.assemble()), (false, false, false, false));
for alg in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] {
assert_eq!(new(alg, "user", "owner", 0x14).dict().p, -3884, "{alg:?}");
}
}
#[test]
fn attachments_only_encrypts_embedded_files_and_nothing_else() {
let params = |algorithm| NewEncryption {
algorithm,
user_password: "user",
owner_password: "owner",
permissions: -4,
encrypt_metadata: true,
seed: [7; 32],
};
for alg in [Algorithm::Aes128, Algorithm::Aes256] {
let h = create_attachments_only(&params(alg), ID).unwrap();
let d = h.dict().clone();
assert_eq!((d.stm_f.as_slice(), d.str_f.as_slice(), d.ef_f.as_slice()), (&b"Identity"[..], &b"Identity"[..], &b"StdCF"[..]), "{alg:?}");
assert!(!h.encrypts_strings(), "{alg:?}");
assert!(!h.decrypts(StreamKind::Normal) && h.decrypts(StreamKind::EmbeddedFile), "{alg:?}");
assert_eq!(h.encrypt_string(3, 0, b"plain"), b"plain", "{alg:?}");
assert_eq!(h.encrypt_stream(3, 0, b"page content", StreamKind::Normal), b"page content", "{alg:?}");
let stored = h.encrypt_stream(4, 0, b"attachment", StreamKind::EmbeddedFile);
assert_ne!(stored, b"attachment", "{alg:?}");
// The file a reader opens: the same dictionary, the user password.
let reader = SecurityHandler::open(d, ID, Some("user")).unwrap();
assert_eq!(reader.decrypt_stream(4, 0, &stored, StreamKind::EmbeddedFile), b"attachment", "{alg:?}");
assert_eq!(reader.decrypt_stream(3, 0, b"page content", StreamKind::Normal), b"page content", "{alg:?}");
}
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128] {
assert!(matches!(create_attachments_only(&params(alg), ID), Err(CryptError::Unsupported(_))), "{alg:?}");
}
}