nigig-org/crates/apps/pdf/pdf-document/tests/encryption.rs
andodeki 147ca7de23
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
test(pdf): prove the AES-256 path and harden malformed encryption
Closes the gaps in ADR 0005's own merge criteria. The encryption commit
shipped with two of its stated criteria unmet, which an audit of the ADR
checklist against the corpus caught:

- "An AES-256 (/R 6) document opens likewise" had no fixture. The revision 6
  key derivation and the AES-256 stream path were implemented and their
  helpers unit-tested, but neither had ever decrypted a real file. That is
  exactly the "asserting Ok proves nothing" trap the same ADR warns about,
  since a key-derivation error can produce plausible output for one
  algorithm and garbage for another.
- "Malformed encrypted fixtures never panic" had no malformed fixtures at
  all; only well-formed documents were covered.

New fixtures, generated by the checked-in script from the specification so
they test agreement with the spec rather than with the reader:

- encrypted/aes256.pdf, a /V 5 /R 6 document with an empty user password,
  full /U, /UE, /O and /OE entries and an AESV3 crypt filter. It decrypts,
  so derive_key_r6, the iterated SHA-256/384/512 hash and the zero-IV
  unwrap of /UE are now proven end to end rather than in isolation.
- encrypted/truncated_u.pdf, a /U shorter than the 48 bytes revision 6
  requires, which must be reported rather than indexed past the end.
- encrypted/missing_o.pdf, an /Encrypt dictionary with no /O.
- encrypted/absurd_length.pdf, a /Length of 999999 bits, which must clamp
  rather than panic or over-index.

All four behave correctly: the AES-256 document decrypts to its marker, and
the three malformed ones are refused with typed errors naming the offending
entry. Encryption tests go from 13 to 17.

The ADR's merge criteria are now ticked, with a note recording that the
original commit shipped with the revision 6 path unproven and that this
follow-up is what closed it.

Not done here: the decrypt fuzz target could not be re-run. The nightly
toolchain now available (2026-07-27) fails to build the cc crate that
libfuzzer depends on, with errors inside cc itself rather than in this
code. The target still compiles under stable and the earlier run of
1,953,940 executions stands; this is recorded rather than quietly skipped.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (387 tests)
Both rustfmt and clippy -D warnings clean.
2026-07-28 17:50:44 +00:00

283 lines
10 KiB
Rust

//! Encryption acceptance tests.
//!
//! The merge criteria from `REVIEWS/adr/0005-pdf-encryption.md`.
//!
//! The fixtures are produced by `tests/corpus/generate.py`, which implements
//! the standard security handler's algorithms independently from the
//! specification. A fixture that decrypts therefore shows the reader agrees
//! with the spec, not merely with itself.
//!
//! The bug being guarded against is specific: before this existed, an
//! encrypted document parsed "successfully", reported a page, and rendered
//! blank because the content stream was ciphertext that interpreted to zero
//! operators. Every test below asserts real plaintext, not merely `Ok`.
use std::path::PathBuf;
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::content::parse_content_stream;
use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand};
/// The marker every encrypted fixture draws once decrypted.
const MARKER: &str = "DecryptedOK";
fn corpus(relative: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus")
.join(relative);
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
}
/// Open a fixture and return its first page's content stream.
fn decrypted_content(fixture: &str) -> String {
let data = corpus(fixture);
let mut doc = PdfDocument::parse(&data)
.unwrap_or_else(|e| panic!("{fixture} should open with the empty password: {e}"));
assert!(doc.is_encrypted(), "{fixture} should report as encrypted");
let page = doc
.page(0)
.unwrap_or_else(|e| panic!("{fixture} page 0: {e}"));
String::from_utf8_lossy(&page.content_data).into_owned()
}
#[test]
fn an_rc4_40_bit_document_decrypts() {
let content = decrypted_content("encrypted/rc4_40.pdf");
assert!(
content.contains(MARKER),
"content did not decrypt, got {content:?}"
);
}
#[test]
fn an_rc4_128_bit_document_decrypts() {
let content = decrypted_content("encrypted/rc4_128.pdf");
assert!(
content.contains(MARKER),
"content did not decrypt, got {content:?}"
);
}
#[test]
fn an_aes_128_document_decrypts() {
let content = decrypted_content("encrypted/aes128.pdf");
assert!(
content.contains(MARKER),
"content did not decrypt, got {content:?}"
);
}
/// The AES-256 (revision 6) path.
///
/// The encryption commit implemented `derive_key_r6` and the AES-256 stream
/// path but never ran either against a real file: only the helper functions
/// had unit tests. This closes that gap in ADR 0005's own merge criteria.
#[test]
fn an_aes_256_document_decrypts() {
let content = decrypted_content("encrypted/aes256.pdf");
assert!(
content.contains(MARKER),
"the revision 6 key derivation did not produce the file key, got {content:?}"
);
}
/// The exact regression: decrypted content must interpret to real operators.
///
/// Asserting `Ok` from the parser is not enough — that is what the old
/// behaviour did while producing a blank page.
#[test]
fn decrypted_content_interprets_to_real_render_commands() {
for fixture in [
"encrypted/rc4_40.pdf",
"encrypted/rc4_128.pdf",
"encrypted/aes128.pdf",
"encrypted/aes256.pdf",
] {
let data = corpus(fixture);
let mut doc = PdfDocument::parse(&data).expect("opens");
let page = doc.page(0).expect("page 0");
let ops = parse_content_stream(&page.content_data).expect("content parses");
let commands = RecordingDevice::from_ops(&ops);
assert!(
!commands.is_empty(),
"{fixture} produced no render commands: ciphertext interprets to nothing"
);
assert!(
commands
.iter()
.any(|c| matches!(c, RenderCommand::ShowTextWithMetrics { .. })),
"{fixture} should draw text"
);
}
}
/// ADR 0005 rule 2: an unsupported handler is refused, never half-opened.
#[test]
fn a_public_key_handler_is_refused_by_name() {
let data = corpus("encrypted/unsupported_handler.pdf");
let err = PdfDocument::parse(&data)
.err()
.expect("a handler this code cannot implement must be refused");
let message = err.to_string();
assert!(
message.contains("Adobe.PubSec"),
"the error should name the handler, got {message:?}"
);
}
#[test]
fn a_wrong_password_is_distinguishable_from_a_corrupt_file() {
// The fixture uses the empty user password, so any other password fails
// authentication. A caller needs to tell "prompt again" from "damaged".
let data = corpus("encrypted/rc4_128.pdf");
let err = PdfDocument::parse_with_password(&data, b"definitely-wrong")
.err()
.expect("a wrong password must be refused");
assert!(
err.to_string().contains("password"),
"the error should mention the password, got {err}"
);
}
#[test]
fn the_correct_empty_password_opens_the_document_explicitly() {
let data = corpus("encrypted/rc4_128.pdf");
let mut doc = PdfDocument::parse_with_password(&data, b"").expect("empty password works");
let page = doc.page(0).expect("page 0");
assert!(String::from_utf8_lossy(&page.content_data).contains(MARKER));
}
#[test]
fn permissions_are_reported() {
let data = corpus("encrypted/rc4_128.pdf");
let doc = PdfDocument::parse(&data).expect("opens");
let permissions = doc.permissions().expect("an encrypted document has /P");
// The fixtures use /P -1, meaning everything is permitted.
assert!(permissions.can_print());
assert!(permissions.can_copy());
assert!(permissions.can_modify());
}
#[test]
fn an_unencrypted_document_reports_no_encryption() {
// The flag must mean something, so it has to be false for plain files.
let data = corpus("basic/text.pdf");
let doc = PdfDocument::parse(&data).expect("opens");
assert!(!doc.is_encrypted());
assert!(doc.permissions().is_none());
}
#[test]
fn strings_inside_an_encrypted_document_decrypt() {
// Strings use a separate cipher path from streams; a handler that
// decrypts only streams leaves every title and field value as garbage.
let data = corpus("encrypted/rc4_128.pdf");
let mut doc = PdfDocument::parse(&data).expect("opens");
let page = doc.page(0).expect("page 0");
// The content stream's own literal string is the observable case here.
let content = String::from_utf8_lossy(&page.content_data);
assert!(
content.contains(&format!("({MARKER})")),
"the literal string should be readable, got {content:?}"
);
}
/// ADR 0003 and 0005: saving an encrypted document stays refused.
#[test]
fn saving_an_encrypted_document_is_still_refused() {
use nigig_pdf_cos::incremental::SaveError;
use nigig_pdf_document::form::DocumentFormEditor;
use nigig_pdf_document::save::save_form_edits;
let data = corpus("encrypted/rc4_128.pdf");
let mut doc = PdfDocument::parse(&data).expect("opens");
let trailer = doc.trailer().clone();
let max = doc.max_object_number();
// Even with no form to edit, the refusal must come from the encryption
// check rather than from there being nothing to write.
let form = doc.acroform().ok().flatten().unwrap_or_default();
let (_, report) = save_form_edits(&data, &trailer, &form, max).expect("no edits, no write");
assert!(report.unchanged);
// With an edit queued it must refuse outright.
let mut form = form;
if let Some(field) = form.fields().first().map(|f| f.obj_ref) {
let _ = DocumentFormEditor::new(&mut form).set_text(field, "x");
assert_eq!(
save_form_edits(&data, &trailer, &form, max),
Err(SaveError::Encrypted)
);
}
}
/// A `/U` shorter than revision 6 requires must be reported, not indexed
/// past the end.
#[test]
fn a_truncated_u_entry_is_reported() {
let data = corpus("encrypted/truncated_u.pdf");
let err = PdfDocument::parse(&data)
.err()
.expect("a malformed /U must be refused");
assert!(
err.to_string().contains("/U"),
"the error should name the entry, got {err}"
);
}
#[test]
fn a_missing_required_entry_is_reported() {
let data = corpus("encrypted/missing_o.pdf");
let err = PdfDocument::parse(&data)
.err()
.expect("an /Encrypt without /O must be refused");
assert!(
err.to_string().contains("/O"),
"the error should name the entry, got {err}"
);
}
/// A `/Length` far outside the legal range must not panic or index out of
/// bounds; the key is clamped and authentication then fails cleanly.
#[test]
fn an_absurd_key_length_is_handled_without_panicking() {
let data = corpus("encrypted/absurd_length.pdf");
let err = PdfDocument::parse(&data)
.err()
.expect("the fixture's /O and /U do not authenticate");
// Whatever the message, the point is that it returned rather than
// panicking on a 999999-bit key length.
assert!(!err.to_string().is_empty());
}
#[test]
fn every_encrypted_fixture_walks_without_panicking() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus/encrypted");
for entry in std::fs::read_dir(&dir).expect("encrypted corpus").flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "pdf") {
continue;
}
let Ok(data) = std::fs::read(&path) else {
continue;
};
// Some fixtures are deliberately refused; none may panic.
if let Ok(mut doc) = PdfDocument::parse(&data) {
for index in 0..doc.page_count().min(4) {
let _ = doc.page(index);
let _ = doc.page_annotations(index);
}
let _ = doc.acroform();
}
let _ = PdfDocument::parse_with_password(&data, b"wrong");
}
}
/// Truncating an encrypted file must not panic the crypto path.
#[test]
fn truncated_encrypted_documents_never_panic() {
let data = corpus("encrypted/aes128.pdf");
for cut in [1, 8, 64, 200, data.len() / 2, data.len() - 1] {
let _ = PdfDocument::parse(&data[..cut.min(data.len())]);
}
}