Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 21s
doc-engine / coverage (push) Successful in 31s
doc-engine / consumer (push) Failing after 16m57s
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
ADR 0024. This reverses ADR 0005's "never write encryption", and the reason it is safe to reverse is that the facts changed underneath it. A crate that only reads cannot produce weak ciphertext, so refusing to write any was free. Now that Phase 4 creates documents and Phase 5 edits them, the refusal does something worse than protect nobody: open a password-protected file, change one annotation, save, and the output is plaintext. No error, no warning — the protection is silently dropped. That is this project's recurring failure mode in the one place where the consequence is a breach. The principle survives in a narrower form: no hand-rolled crypto, and no weak cipher offered as an option. RC4 stays readable because files use it and is not writable — EncryptionAlgorithm has no RC4 variant, so the refusal is a type, not a runtime check someone can route around. The encryptor is the literal inverse of the decryptor and imports its primitives rather than restating them; two implementations of one algorithm drift, and here they drift towards "decrypts to garbage". Every unit test round-trips through the existing Decryptor. Encryption sits at one choke point: PdfWriter holds the Encryptor and write_object_at encrypts everything passing through. Not per call site — there are twenty-two of those in PdfDocBuilder, and one stream written in the clear inside an encrypted document is not a partial failure, it is a leak that no reader will report because the file is otherwise valid. The /Encrypt dictionary is the single deliberate exemption: it holds the salts a reader needs before it has a key, so encrypting it bricks the file. Verified against implementations we share no code with, now gated in CI: ok qpdf opens it with the password ok it really is AES-256 ok the wrong password is refused ok poppler decrypts the content ok no plaintext in the encrypted file Four mutations, all killed — two only after the tests were strengthened, and both misses are the interesting part: A fixed IV survived two_saves_of_one_document_are_not_byte_identical, because the AES-256 file key is fresh per save and that alone makes the output differ. The property actually needed is narrower: one encryptor, identical plaintext, different bytes. In CBC a repeated IV under one key leaks that two plaintexts are equal. A wrong /Length survived because our own reader recovers by scanning for endstream — a robustness fix from ADR 0023. An independent reader that trusts /Length reads a truncated stream and decrypts garbage. A lenient reader hides a broken writer, which is why the external gate exists. The /Length test itself had a bug first: it searched a from_utf8_lossy view and reported a stream declaring 80 bytes holding 156. Ciphertext is not UTF-8; the replacement characters shifted every offset. Unencrypted output stays byte-reproducible; encrypted output cannot be, and a test asserts that loss rather than leaving it implicit. pdf: 1220 passed (was 1187). pdf-ui: green. Coverage 88.21%, encrypt_write.rs at 96.5%. Signing is NOT started. It needs the trust-anchor decision ADR 0010 deferred: VerificationStatus::Valid is unreachable by construction, and making sign -> verify pass is a policy change, not an implementation detail. The plan's Phase 6 status now says so.
404 lines
15 KiB
Rust
404 lines
15 KiB
Rust
//! Encrypted-write round trip: Phase 6's first exit criterion.
|
|
//!
|
|
//! A writer can only be tested by reading back what it wrote, and an
|
|
//! *encryption* writer has a second obligation on top of that: the
|
|
//! plaintext must not be in the file. Both are asserted here, and the
|
|
//! second one is asserted against the raw bytes rather than through any
|
|
//! API, because an API that decrypts is exactly the thing that cannot see
|
|
//! a leak.
|
|
//!
|
|
//! See `REVIEWS/adr/0024-pdf-encryption-on-save.md`.
|
|
|
|
use nigig_pdf_cos::encrypt_write::{EncryptionAlgorithm, EncryptionSettings};
|
|
use nigig_pdf_cos::object::{PdfDict, PdfObj};
|
|
use nigig_pdf_cos::writer::PdfDocBuilder;
|
|
use nigig_pdf_cos::Permissions;
|
|
use nigig_pdf_document::PdfDocument;
|
|
|
|
const SECRET: &[u8] = b"CONFIDENTIALPAYLOAD";
|
|
|
|
fn helvetica() -> PdfObj {
|
|
let mut f = PdfDict::new();
|
|
f.set("Type", PdfObj::Name("Font".to_string()));
|
|
f.set("Subtype", PdfObj::Name("Type1".to_string()));
|
|
f.set("BaseFont", PdfObj::Name("Helvetica".to_string()));
|
|
PdfObj::Dict(f)
|
|
}
|
|
|
|
/// A one-page document whose content and title both contain `SECRET`.
|
|
fn encrypted_document(settings: Option<EncryptionSettings>) -> Vec<u8> {
|
|
let mut builder = PdfDocBuilder::new();
|
|
builder.add_font("F1", helvetica());
|
|
builder.add_page(
|
|
300.0,
|
|
400.0,
|
|
b"BT /F1 14 Tf 20 200 Td (CONFIDENTIALPAYLOAD) Tj ET",
|
|
);
|
|
builder.set_metadata(nigig_pdf_cos::writer::DocumentMetadata {
|
|
title: Some("CONFIDENTIALPAYLOAD".to_string()),
|
|
..Default::default()
|
|
});
|
|
if let Some(settings) = settings {
|
|
builder.set_encryption(settings);
|
|
}
|
|
builder.finish()
|
|
}
|
|
|
|
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
|
haystack.windows(needle.len()).any(|w| w == needle)
|
|
}
|
|
|
|
fn both_algorithms() -> [EncryptionAlgorithm; 2] {
|
|
[EncryptionAlgorithm::Aes256, EncryptionAlgorithm::Aes128]
|
|
}
|
|
|
|
fn settings_for(algorithm: EncryptionAlgorithm, password: &str) -> EncryptionSettings {
|
|
EncryptionSettings {
|
|
algorithm,
|
|
user_password: password.as_bytes().to_vec(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------- the round trip
|
|
|
|
#[test]
|
|
fn an_encrypted_document_opens_with_its_password() {
|
|
for algorithm in both_algorithms() {
|
|
let pdf = encrypted_document(Some(settings_for(algorithm, "hunter2")));
|
|
let mut doc = PdfDocument::parse_with_password(&pdf, b"hunter2")
|
|
.unwrap_or_else(|e| panic!("{algorithm:?} should open: {e}"));
|
|
|
|
assert_eq!(doc.page_count(), 1);
|
|
let page = doc.page(0).expect("page 0");
|
|
assert_eq!(page.media_box, [0.0, 0.0, 300.0, 400.0]);
|
|
assert!(
|
|
contains(&page.content_data, SECRET),
|
|
"{algorithm:?}: content did not decrypt, got {:?}",
|
|
String::from_utf8_lossy(&page.content_data)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_plaintext_is_not_in_the_file() {
|
|
// The assertion that matters. Everything else can be right and this
|
|
// still wrong: a stream written before the encryptor was attached, or
|
|
// a string the recursive walk missed, leaves readable content in a
|
|
// file every reader will happily call encrypted.
|
|
for algorithm in both_algorithms() {
|
|
let pdf = encrypted_document(Some(settings_for(algorithm, "hunter2")));
|
|
assert!(
|
|
!contains(&pdf, SECRET),
|
|
"{algorithm:?}: plaintext found in the encrypted file"
|
|
);
|
|
}
|
|
|
|
// Control: the same document unencrypted *does* contain it, so the
|
|
// test above is not passing because the fixture lost its payload.
|
|
let plain = encrypted_document(None);
|
|
assert!(
|
|
contains(&plain, SECRET),
|
|
"the unencrypted control must contain the payload, or the leak \
|
|
test proves nothing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn strings_are_encrypted_too_not_just_streams() {
|
|
// The title goes into /Info as a string. Encrypting streams and
|
|
// forgetting strings is the easy half-implementation, and it leaks
|
|
// document titles, form values and annotation contents.
|
|
for algorithm in both_algorithms() {
|
|
let pdf = encrypted_document(Some(settings_for(algorithm, "pw")));
|
|
assert!(
|
|
!contains(&pdf, b"CONFIDENTIALPAYLOAD"),
|
|
"{algorithm:?}: the /Info title was written in the clear"
|
|
);
|
|
let doc = PdfDocument::parse_with_password(&pdf, b"pw").expect("opens");
|
|
assert_eq!(
|
|
doc.info().title.as_deref(),
|
|
Some("CONFIDENTIALPAYLOAD"),
|
|
"{algorithm:?}: the title did not decrypt"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_wrong_password_is_refused() {
|
|
for algorithm in both_algorithms() {
|
|
let pdf = encrypted_document(Some(settings_for(algorithm, "correct")));
|
|
match PdfDocument::parse_with_password(&pdf, b"wrong") {
|
|
Err(_) => {}
|
|
Ok(mut doc) => {
|
|
// If a document is somehow produced, it must not yield the
|
|
// plaintext. Garbage that looks like content is the
|
|
// failure mode to rule out.
|
|
if let Ok(page) = doc.page(0) {
|
|
assert!(
|
|
!contains(&page.content_data, SECRET),
|
|
"{algorithm:?}: the wrong password recovered the content"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_user_password_still_encrypts() {
|
|
// Encrypting with an empty user password is the common "permissions
|
|
// only" case: any reader can open it, but the bytes on disk are still
|
|
// ciphertext and the permissions are declared.
|
|
for algorithm in both_algorithms() {
|
|
let pdf = encrypted_document(Some(settings_for(algorithm, "")));
|
|
assert!(!contains(&pdf, SECRET), "{algorithm:?}: not encrypted");
|
|
let mut doc = PdfDocument::parse(&pdf).expect("opens with no password");
|
|
assert!(doc.is_encrypted());
|
|
assert!(contains(&doc.page(0).unwrap().content_data, SECRET));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_owner_password_also_opens_the_document() {
|
|
for algorithm in both_algorithms() {
|
|
let settings = EncryptionSettings {
|
|
algorithm,
|
|
user_password: b"user".to_vec(),
|
|
owner_password: b"owner".to_vec(),
|
|
..Default::default()
|
|
};
|
|
let pdf = encrypted_document(Some(settings));
|
|
for password in [b"user".as_slice(), b"owner".as_slice()] {
|
|
let mut doc = PdfDocument::parse_with_password(&pdf, password).unwrap_or_else(|e| {
|
|
panic!(
|
|
"{algorithm:?}: {:?} should open the document: {e}",
|
|
String::from_utf8_lossy(password)
|
|
)
|
|
});
|
|
assert!(contains(&doc.page(0).unwrap().content_data, SECRET));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------ structure
|
|
|
|
#[test]
|
|
fn the_encrypt_dictionary_is_written_unencrypted() {
|
|
// It carries the salts and validation hashes a reader needs *before*
|
|
// it has a key. Encrypting it makes the document permanently
|
|
// unopenable — by us and by everyone else.
|
|
for algorithm in both_algorithms() {
|
|
let pdf = encrypted_document(Some(settings_for(algorithm, "pw")));
|
|
let text = String::from_utf8_lossy(&pdf);
|
|
assert!(text.contains("/Filter /Standard"), "{algorithm:?}");
|
|
assert!(
|
|
text.contains("/Encrypt "),
|
|
"{algorithm:?}: no trailer entry"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_file_carries_an_id() {
|
|
// Revisions 2-4 mix /ID into key derivation, so a missing or changed
|
|
// /ID makes the file unopenable. Written for both revisions because
|
|
// every other producer writes it.
|
|
for algorithm in both_algorithms() {
|
|
let pdf = encrypted_document(Some(settings_for(algorithm, "pw")));
|
|
assert!(
|
|
String::from_utf8_lossy(&pdf).contains("/ID ["),
|
|
"{algorithm:?}: no /ID in the trailer"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_are_carried_into_the_saved_file() {
|
|
// All bits set except print (bit 3) and copy (bit 5); the spec
|
|
// numbers permission bits from 1, so those are shifts of 2 and 4.
|
|
let restricted = Permissions(!(1 << 2) & !(1 << 4));
|
|
for algorithm in both_algorithms() {
|
|
let settings = EncryptionSettings {
|
|
algorithm,
|
|
user_password: b"pw".to_vec(),
|
|
permissions: restricted,
|
|
..Default::default()
|
|
};
|
|
let pdf = encrypted_document(Some(settings));
|
|
let doc = PdfDocument::parse_with_password(&pdf, b"pw").expect("opens");
|
|
let permissions = doc.permissions().expect("permissions present");
|
|
assert!(!permissions.can_print(), "{algorithm:?}");
|
|
assert!(!permissions.can_copy(), "{algorithm:?}");
|
|
assert!(permissions.can_annotate(), "{algorithm:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn two_saves_of_one_document_are_not_byte_identical() {
|
|
// Encryption must not be deterministic: the file key, the salts and
|
|
// every IV are fresh each time. Identical output would mean a fixed
|
|
// IV, which leaks whether two documents share content.
|
|
//
|
|
// This is the one place the reproducible-build property of ADR 0023 is
|
|
// deliberately given up, and it is given up for a reason.
|
|
let a = encrypted_document(Some(settings_for(EncryptionAlgorithm::Aes256, "pw")));
|
|
let b = encrypted_document(Some(settings_for(EncryptionAlgorithm::Aes256, "pw")));
|
|
assert_ne!(a, b, "encryption is deterministic, so the IV is fixed");
|
|
|
|
// Both still open, so the difference is randomness and not corruption.
|
|
for pdf in [a, b] {
|
|
let mut doc = PdfDocument::parse_with_password(&pdf, b"pw").expect("opens");
|
|
assert!(contains(&doc.page(0).unwrap().content_data, SECRET));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn one_encryptor_gives_two_identical_payloads_different_ciphertext() {
|
|
// `two_saves_of_one_document_are_not_byte_identical` cannot see a
|
|
// fixed IV: the AES-256 file key is fresh per save, so the output
|
|
// differs even with a constant IV. Removing the IV randomisation
|
|
// survived that test.
|
|
//
|
|
// Holding one encryptor fixed isolates the IV, which is the property
|
|
// that actually matters: in CBC, encrypting identical plaintext under
|
|
// one key with a repeated IV produces identical ciphertext and leaks
|
|
// that the two are the same.
|
|
use nigig_pdf_cos::encrypt_write::Encryptor;
|
|
|
|
for algorithm in both_algorithms() {
|
|
let enc =
|
|
Encryptor::new(&settings_for(algorithm, "pw"), b"fixed-file-id").expect("encryptor");
|
|
let a = enc.encrypt_stream(1, 0, b"identical payload").expect("a");
|
|
let b = enc.encrypt_stream(1, 0, b"identical payload").expect("b");
|
|
assert_ne!(
|
|
a, b,
|
|
"{algorithm:?}: same key, same object, same plaintext gave the \
|
|
same bytes, so the IV is not random"
|
|
);
|
|
// And the IV really is the first 16 bytes, not something else that
|
|
// happens to differ.
|
|
assert_ne!(a[..16], b[..16], "{algorithm:?}: the IV block is constant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_stream_length_describes_the_ciphertext_not_the_plaintext() {
|
|
// /Length must count the encrypted bytes: the IV and PKCS#7 padding
|
|
// make the stream longer than its plaintext. A reader that trusts a
|
|
// short /Length truncates the stream and decrypts garbage — and the
|
|
// round-trip tests miss it, because our own reader recovers by
|
|
// scanning for `endstream` (a defect ADR 0023 had to fix).
|
|
//
|
|
// Worked on **bytes**, not on a lossy UTF-8 view: ciphertext is not
|
|
// valid UTF-8, and `from_utf8_lossy` substitutes replacement
|
|
// characters that shift every offset. The first draft of this test did
|
|
// exactly that and reported a 156-byte stream declaring 80.
|
|
let pdf = encrypted_document(Some(settings_for(EncryptionAlgorithm::Aes256, "pw")));
|
|
|
|
let mut checked = 0usize;
|
|
let mut cursor = 0usize;
|
|
while let Some(rel) = pdf[cursor..].windows(7).position(|w| w == b"stream\n") {
|
|
let at = cursor + rel;
|
|
// `endstream` also contains "stream", so skip those matches.
|
|
if at >= 3 && &pdf[at - 3..at] == b"end" {
|
|
cursor = at + 7;
|
|
continue;
|
|
}
|
|
let head = &pdf[..at];
|
|
let Some(pos) = head.windows(8).rposition(|w| w == b"/Length ") else {
|
|
cursor = at + 7;
|
|
continue;
|
|
};
|
|
let declared: usize = head[pos + 8..]
|
|
.iter()
|
|
.take_while(|b| b.is_ascii_digit())
|
|
.map(|b| (b - b'0') as usize)
|
|
.fold(0, |acc, d| acc * 10 + d);
|
|
|
|
let body_start = at + 7;
|
|
let Some(end) = pdf[body_start..]
|
|
.windows(10)
|
|
.position(|w| w == b"\nendstream")
|
|
else {
|
|
cursor = body_start;
|
|
continue;
|
|
};
|
|
assert_eq!(
|
|
declared, end,
|
|
"a stream declares /Length {declared} but holds {end} bytes"
|
|
);
|
|
checked += 1;
|
|
cursor = body_start + end;
|
|
}
|
|
assert!(
|
|
checked >= 1,
|
|
"no streams were checked, so this proves nothing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unencrypted_document_is_unchanged_by_this_work() {
|
|
// The encryptor is opt-in. A document with no settings must be byte
|
|
// reproducible, as ADR 0023 requires.
|
|
let a = encrypted_document(None);
|
|
let b = encrypted_document(None);
|
|
assert_eq!(a, b, "unencrypted output stopped being reproducible");
|
|
let mut doc = PdfDocument::parse(&a).expect("opens");
|
|
assert!(!doc.is_encrypted());
|
|
assert!(contains(&doc.page(0).unwrap().content_data, SECRET));
|
|
}
|
|
|
|
// -------------------------------------------------------------- content
|
|
|
|
#[test]
|
|
fn a_large_document_encrypts_every_page() {
|
|
// Several pages, several streams. A per-object key derivation that is
|
|
// wrong for object 12 shows up here and not in a one-page test.
|
|
let mut builder = PdfDocBuilder::new();
|
|
builder.add_font("F1", helvetica());
|
|
for i in 0..12 {
|
|
builder.add_page(
|
|
200.0,
|
|
200.0,
|
|
format!("BT /F1 12 Tf 10 100 Td (PAGE{i:02}SECRET) Tj ET").as_bytes(),
|
|
);
|
|
}
|
|
builder.set_encryption(settings_for(EncryptionAlgorithm::Aes256, "pw"));
|
|
let pdf = builder.finish();
|
|
|
|
for i in 0..12 {
|
|
let marker = format!("PAGE{i:02}SECRET");
|
|
assert!(
|
|
!contains(&pdf, marker.as_bytes()),
|
|
"page {i} was written in the clear"
|
|
);
|
|
}
|
|
|
|
let mut doc = PdfDocument::parse_with_password(&pdf, b"pw").expect("opens");
|
|
assert_eq!(doc.page_count(), 12);
|
|
for i in 0..12 {
|
|
let marker = format!("PAGE{i:02}SECRET");
|
|
let page = doc.page(i).expect("page");
|
|
assert!(
|
|
contains(&page.content_data, marker.as_bytes()),
|
|
"page {i} did not decrypt to its own content"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn binary_content_survives_encryption() {
|
|
// A content stream is compressed by default, so its bytes are already
|
|
// binary. This checks the padding and IV handling on data that is not
|
|
// conveniently block-aligned text.
|
|
let mut builder = PdfDocBuilder::new();
|
|
let content: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
|
|
builder.set_compress(false);
|
|
builder.add_page(100.0, 100.0, &content);
|
|
builder.set_encryption(settings_for(EncryptionAlgorithm::Aes256, "pw"));
|
|
let pdf = builder.finish();
|
|
|
|
let mut doc = PdfDocument::parse_with_password(&pdf, b"pw").expect("opens");
|
|
assert_eq!(doc.page(0).expect("page 0").content_data, content);
|
|
}
|