fix(crypt): /Perms limits what an edited /P allows

AES-256 files (R5 and R6) store the permissions twice: in clear in /P,
and in /Perms, encrypted with the file key (ISO 32000-2, Algorithm 13).
We wrote /Perms but never read it, so a document opened with the user
password got whatever /P said, even when /P no longer matched /Perms.

permissions() now decrypts /Perms and, when it holds a valid block
(marked "adb"), counts a permission only if both /P and /Perms grant
it. Without a readable /Perms, /P applies as before. R2 to R4 need no
check: /P is part of their file key, so an edited /P does not open.
This commit is contained in:
oracola91
2026-10-15 17:56:45 +03:00
parent afa8e1843c
commit 00fb10cf1e
3 changed files with 38 additions and 2 deletions
+15 -1
View File
@@ -203,8 +203,22 @@ impl SecurityHandler {
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 {
Permissions { bits: self.dict.p, owner: self.auth == Auth::Owner }
let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p);
Permissions { bits, owner: self.auth == Auth::Owner }
}
/// `/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) {
return None;
}
let plain = 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 {
+22
View File
@@ -212,6 +212,28 @@ fn permission_bits_follow_table_22() {
assert!(p.modify(), "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);
}
proptest::proptest! {
#[test]
fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60),
+1 -1
View File
@@ -214,7 +214,7 @@ milestone = "M1"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"]
evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"]
notes = ""
[[feature]]