mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
265 lines
14 KiB
Rust
265 lines
14 KiB
Rust
//! Security changes must stay coherent when undo crosses the last saved revision (#3276).
|
|
use super::*;
|
|
use pdfcraft_cos::{Algorithm, Document as CosDocument, NewEncryption, Object};
|
|
|
|
/// A two-page file with a title and XMP metadata, protected with `user`/`owner` and no
|
|
/// restrictions. `inline` moves `/Encrypt` into the trailer, as some writers store it.
|
|
fn encrypted_fixture(algorithm: Algorithm, inline: bool) -> Arc<Vec<u8>> {
|
|
let mut doc = CosDocument::open(Arc::new(fixture(2))).unwrap();
|
|
pdfcraft_organize::set_info(&mut doc, "Title", "Security regression title").unwrap();
|
|
let mut metadata = pdfcraft_cos::Dict::new();
|
|
metadata.set(b"Type".to_vec(), Object::name("Metadata"));
|
|
metadata.set(b"Subtype".to_vec(), Object::name("XML"));
|
|
let metadata = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(metadata, b"<metadata>Security regression</metadata>".to_vec())));
|
|
doc.update_dict(doc.root().unwrap(), |d| d.set(b"Metadata".to_vec(), Object::Ref(metadata))).unwrap();
|
|
doc.set_encryption(&NewEncryption {
|
|
algorithm,
|
|
user_password: "user",
|
|
owner_password: "owner",
|
|
permissions: -1,
|
|
encrypt_metadata: true,
|
|
seed: [7; 32],
|
|
})
|
|
.unwrap();
|
|
if inline {
|
|
let r = doc.trailer().reference(b"Encrypt").unwrap();
|
|
let dict = doc.get(r).as_dict().cloned().unwrap();
|
|
doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict));
|
|
}
|
|
Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())
|
|
}
|
|
|
|
/// Check the saved file, not the still-correct in-memory snapshot. A catalog/page-count
|
|
/// check alone accepts the broken reverse transition with two blank pages.
|
|
fn assert_saved_content(s: &Session, id: DocId, bytes: Arc<Vec<u8>>, password: Option<&str>, encrypted: bool) -> Result<(), String> {
|
|
let check = s.check_save(id, &bytes);
|
|
let plain_marker = bytes.windows(b"(Page 1)".len()).any(|w| w == b"(Page 1)");
|
|
let mut reopened = Session::new();
|
|
let restored = reopened.open("saved.pdf", None, bytes.clone(), password);
|
|
let restored = restored.map_err(|e| format!("check_save={check:?}, plaintext marker={plain_marker}, reopen={e:?}"))?;
|
|
let texts = page_texts(&reopened, restored);
|
|
if check.is_err() || texts != ["Page 1", "Page 2"] {
|
|
return Err(format!("check_save={check:?}, plaintext marker={plain_marker}, text={texts:?}"));
|
|
}
|
|
assert_eq!(reopened.get(restored).unwrap().info.encrypted, encrypted);
|
|
assert_eq!(reopened.get(restored).unwrap().info_value("Title"), s.get(id).unwrap().info_value("Title"));
|
|
fn metadata(doc: &CosDocument) -> Option<Vec<u8>> {
|
|
let catalog = doc.get(doc.root()?);
|
|
let stream = doc.resolve(catalog.as_dict()?.get(b"Metadata")?);
|
|
let Object::Stream(stream) = stream.as_ref() else { return None };
|
|
Some(stream.decoded().unwrap().to_vec())
|
|
}
|
|
assert_eq!(metadata(&reopened.get(restored).unwrap().editor.as_ref().unwrap().cos), metadata(&s.get(id).unwrap().editor.as_ref().unwrap().cos));
|
|
if encrypted {
|
|
assert!(!plain_marker, "encrypted saves must not expose page content");
|
|
assert!(CosDocument::open_with_password(bytes, Some("wrong")).is_err());
|
|
} else {
|
|
// The fixture's content is stored unfiltered: without this, the check above could pass
|
|
// only because the writer started compressing streams.
|
|
assert!(plain_marker, "a plain save shows the page content");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove protection, save, undo, save: the file is encrypted again with its original passwords.
|
|
#[test]
|
|
fn undo_saved_security_removal_restores_encrypted_content() {
|
|
let mut failures = Vec::new();
|
|
for algorithm in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] {
|
|
for inline in [false, true] {
|
|
for password in ["owner", "user"] {
|
|
let mut s = Session::new();
|
|
let id = s.open("encrypted.pdf", None, encrypted_fixture(algorithm, inline), Some(password)).unwrap();
|
|
s.apply(id, Edit::RemoveProtection).unwrap();
|
|
let plain = s.save_bytes(id).unwrap();
|
|
assert_saved_content(&s, id, plain.clone(), None, false).unwrap();
|
|
s.mark_saved(id, plain, None).unwrap();
|
|
s.undo(id).unwrap();
|
|
assert_saved_content(&s, id, s.save_full_bytes(id).unwrap(), Some(password), true).unwrap();
|
|
let restored = s.save_bytes(id).unwrap();
|
|
if let Err(error) = assert_saved_content(&s, id, restored.clone(), Some(password), true) {
|
|
failures.push(format!("{algorithm:?}, inline={inline}, {password}: {error}"));
|
|
continue;
|
|
}
|
|
// Both original passwords still work; saving also retains this session's rights.
|
|
assert_saved_content(&s, id, restored.clone(), Some("user"), true).unwrap();
|
|
assert_saved_content(&s, id, restored.clone(), Some("owner"), true).unwrap();
|
|
s.mark_saved(id, restored.clone(), None).unwrap();
|
|
assert!(!s.get(id).unwrap().dirty);
|
|
assert_eq!(*s.save_bytes(id).unwrap(), *restored, "an unchanged save is byte-stable");
|
|
s.redo(id).unwrap();
|
|
let plain = s.save_bytes(id).unwrap();
|
|
assert_saved_content(&s, id, plain.clone(), None, false).unwrap();
|
|
s.mark_saved(id, plain, None).unwrap();
|
|
}
|
|
}
|
|
}
|
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
|
}
|
|
|
|
/// Protect, save, undo, save: the file is plain again and readable without a password.
|
|
#[test]
|
|
fn undo_saved_protection_restores_plain_content() {
|
|
let mut failures = Vec::new();
|
|
for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] {
|
|
let (mut s, id) = session_with(2);
|
|
s.apply(id, Edit::Protect(Protection { algorithm, ..protection(Some("user"), Some("owner")) })).unwrap();
|
|
let protected = s.save_bytes(id).unwrap();
|
|
assert_saved_content(&s, id, protected.clone(), Some("owner"), true).unwrap();
|
|
s.mark_saved(id, protected, None).unwrap();
|
|
s.undo(id).unwrap();
|
|
assert_saved_content(&s, id, s.save_full_bytes(id).unwrap(), None, false).unwrap();
|
|
let restored = s.save_bytes(id).unwrap();
|
|
if let Err(error) = assert_saved_content(&s, id, restored.clone(), None, false) {
|
|
failures.push(format!("{algorithm:?}: {error}"));
|
|
continue;
|
|
}
|
|
s.mark_saved(id, restored.clone(), None).unwrap();
|
|
assert!(!s.get(id).unwrap().dirty);
|
|
assert_eq!(*s.save_bytes(id).unwrap(), *restored);
|
|
s.redo(id).unwrap();
|
|
let protected = s.save_bytes(id).unwrap();
|
|
assert_saved_content(&s, id, protected.clone(), Some("owner"), true).unwrap();
|
|
s.mark_saved(id, protected, None).unwrap();
|
|
}
|
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
|
}
|
|
|
|
/// Undoing a saved change of algorithm, permissions, metadata encryption or key restores the
|
|
/// original security dictionary, even though the password strings never changed.
|
|
#[test]
|
|
fn undo_saved_rekey_with_the_same_passwords_restores_original_security() {
|
|
let mut failures = Vec::new();
|
|
// Identical password strings are not a security identity: changing algorithm, permission
|
|
// bits, metadata encryption, or simply generating a fresh AES-256 key changes the output.
|
|
for (algorithm, copy, encrypt_metadata) in [
|
|
(Algorithm::Rc4_128, true, true),
|
|
(Algorithm::Aes128, true, true),
|
|
(Algorithm::Aes256, false, true),
|
|
(Algorithm::Aes256, true, false),
|
|
(Algorithm::Aes256, true, true),
|
|
] {
|
|
let (mut s, id) = session_with(2);
|
|
s.apply(id, Edit::Protect(protection(Some("user"), Some("owner")))).unwrap();
|
|
let protected = s.save_bytes(id).unwrap();
|
|
s.mark_saved(id, protected, None).unwrap();
|
|
let original = s.get(id).unwrap().editor.as_ref().unwrap().cos.output_handler().unwrap().dict().clone();
|
|
s.apply(id, Edit::Protect(Protection { algorithm, copy, encrypt_metadata, ..protection(Some("user"), Some("owner")) })).unwrap();
|
|
let protected = s.save_bytes(id).unwrap();
|
|
s.mark_saved(id, protected, None).unwrap();
|
|
s.undo(id).unwrap();
|
|
let restored = s.save_bytes(id).unwrap();
|
|
let editor = s.get(id).unwrap().editor.as_ref().unwrap();
|
|
assert_eq!(editor.keys, editor.saved_keys, "password strings alone cannot detect this transition");
|
|
if let Err(error) = assert_saved_content(&s, id, restored.clone(), Some("owner"), true) {
|
|
failures.push(format!("{algorithm:?}, copy={copy}, metadata={encrypt_metadata}: {error}"));
|
|
continue;
|
|
}
|
|
let reopened = CosDocument::open_with_password(restored, Some("owner")).unwrap();
|
|
assert_eq!(reopened.security().unwrap().dict(), &original);
|
|
}
|
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
|
}
|
|
|
|
/// A full save renumbers `/Encrypt`; undoing past it must not leave the restored dictionary
|
|
/// encrypted as an ordinary object.
|
|
#[test]
|
|
fn undo_encrypted_compaction_restores_the_encrypt_dictionary_exemption() {
|
|
let mut cos = CosDocument::open(Arc::new(fixture(3))).unwrap();
|
|
cos.set_encryption(&NewEncryption {
|
|
algorithm: Algorithm::Aes256,
|
|
user_password: "user",
|
|
owner_password: "owner",
|
|
permissions: -1,
|
|
encrypt_metadata: true,
|
|
seed: [7; 32],
|
|
})
|
|
.unwrap();
|
|
let original = Arc::new(write_full(&cos, &SaveOptions::default()).unwrap());
|
|
let original = CosDocument::open_with_password(original, Some("owner")).unwrap();
|
|
let mut s = Session::new();
|
|
let id = s.open("compact.pdf", None, original.bytes().clone(), Some("owner")).unwrap();
|
|
s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
|
|
let compact = s.save_full_bytes(id).unwrap();
|
|
let compact_doc = CosDocument::open_with_password(compact.clone(), Some("owner")).unwrap();
|
|
assert_eq!(original.security().unwrap().dict(), compact_doc.security().unwrap().dict());
|
|
assert_ne!(original.trailer().reference(b"Encrypt"), compact_doc.trailer().reference(b"Encrypt"), "compaction renumbered /Encrypt");
|
|
s.mark_saved(id, compact, None).unwrap();
|
|
s.undo(id).unwrap();
|
|
let restored = s.save_bytes(id).unwrap();
|
|
s.check_save(id, &restored).unwrap();
|
|
assert!(!restored.windows(b"(Page 1)".len()).any(|w| w == b"(Page 1)"), "page content stays encrypted");
|
|
assert!(CosDocument::open(restored.clone()).is_err() && CosDocument::open_with_password(restored.clone(), Some("wrong")).is_err());
|
|
let mut reopened = Session::new();
|
|
let id = reopened.open("restored.pdf", None, restored, Some("user")).unwrap();
|
|
assert!(reopened.get(id).unwrap().info.encrypted);
|
|
assert_eq!(page_texts(&reopened, id), ["Page 1", "Page 2", "Page 3"]);
|
|
}
|
|
|
|
/// A restricted user who unlocks with the owner password, removes protection, saves and undoes
|
|
/// gets the original restrictions back, not an unrestricted or owner-only file.
|
|
#[test]
|
|
fn undo_saved_removal_after_unlock_keeps_the_user_restrictions() {
|
|
for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] {
|
|
let (mut s, id) = session_with(2);
|
|
s.apply(id, Edit::Protect(Protection { algorithm, ..protection(Some("user"), Some("owner")) })).unwrap();
|
|
let protected = s.save_bytes(id).unwrap();
|
|
let p = CosDocument::open_with_password(protected.clone(), Some("user")).unwrap().permissions().unwrap();
|
|
assert!(!p.owner && !p.copy() && !p.modify(), "{algorithm:?}: the fixture restricts the user");
|
|
|
|
let mut s = Session::new();
|
|
let id = s.open("restricted.pdf", None, protected, Some("user")).unwrap();
|
|
assert!(matches!(s.apply(id, Edit::RemoveProtection), Err(EditError::NotPermitted(_))), "{algorithm:?}");
|
|
s.unlock(id, "owner").unwrap();
|
|
s.apply(id, Edit::RemoveProtection).unwrap();
|
|
let plain = s.save_bytes(id).unwrap();
|
|
assert_saved_content(&s, id, plain.clone(), None, false).unwrap();
|
|
s.mark_saved(id, plain, None).unwrap();
|
|
s.undo(id).unwrap();
|
|
let restored = s.save_bytes(id).unwrap();
|
|
assert_saved_content(&s, id, restored.clone(), Some("owner"), true).unwrap();
|
|
let user = CosDocument::open_with_password(restored.clone(), Some("user")).unwrap();
|
|
let p = user.permissions().unwrap();
|
|
assert!(!p.owner && !p.copy() && !p.modify(), "{algorithm:?}: restrictions survive the undo and save");
|
|
assert!(CosDocument::open_with_password(restored.clone(), Some("owner")).unwrap().permissions().unwrap().owner, "{algorithm:?}");
|
|
s.mark_saved(id, restored.clone(), None).unwrap();
|
|
assert!(!s.get(id).unwrap().dirty, "{algorithm:?}");
|
|
assert_eq!(*s.save_bytes(id).unwrap(), *restored, "{algorithm:?}: an unchanged save is byte-stable");
|
|
}
|
|
}
|
|
|
|
/// Undo across a save that kept the same security still appends to the last saved file.
|
|
#[test]
|
|
fn undo_ordinary_encrypted_edits_keeps_the_last_saved_revision() {
|
|
let mut s = Session::new();
|
|
let id = s.open("encrypted.pdf", None, encrypted_fixture(Algorithm::Aes256, false), Some("user")).unwrap();
|
|
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
|
let saved = s.save_bytes(id).unwrap();
|
|
s.mark_saved(id, saved.clone(), None).unwrap();
|
|
s.undo(id).unwrap();
|
|
let restored = s.save_bytes(id).unwrap();
|
|
assert!(restored.starts_with(saved.as_slice()), "compatible encryption still appends to the latest saved bytes");
|
|
assert_saved_content(&s, id, restored.clone(), Some("user"), true).unwrap();
|
|
s.mark_saved(id, restored, None).unwrap();
|
|
s.redo(id).unwrap();
|
|
let rotated = s.save_bytes(id).unwrap();
|
|
assert_saved_content(&s, id, rotated, Some("user"), true).unwrap();
|
|
}
|
|
|
|
/// Undoing a saved password change restores the original passwords; the new ones no longer open
|
|
/// the file.
|
|
#[test]
|
|
fn undo_saved_password_change_restores_original_passwords() {
|
|
let mut s = Session::new();
|
|
let id = s.open("encrypted.pdf", None, encrypted_fixture(Algorithm::Aes256, true), Some("owner")).unwrap();
|
|
s.apply(id, Edit::Protect(protection(Some("new-user"), Some("new-owner")))).unwrap();
|
|
let changed = s.save_bytes(id).unwrap();
|
|
s.mark_saved(id, changed, None).unwrap();
|
|
s.undo(id).unwrap();
|
|
let restored = s.save_bytes(id).unwrap();
|
|
assert_saved_content(&s, id, restored.clone(), Some("user"), true).unwrap();
|
|
assert_saved_content(&s, id, restored.clone(), Some("owner"), true).unwrap();
|
|
assert!(CosDocument::open_with_password(restored.clone(), Some("new-user")).is_err());
|
|
assert!(CosDocument::open_with_password(restored, Some("new-owner")).is_err());
|
|
}
|