From 0a6d0f3689c82658906a5a020701beebfb30ffa8 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:00:14 +0000 Subject: [PATCH] fix(crypt): derive a new file id from the encryption seed /ID is SHA-256 of the seed under the label id, not an unkeyed DefaultHasher digest. The salts test is a characterization; failing closed when randomness is missing stays the regression guard. Co-authored-by: jonathanwxh-cell --- crates/cos/src/document.rs | 47 ++++++++++++++++++++++++++------------ crates/crypt/src/lib.rs | 10 +++++++- crates/crypt/src/tests.rs | 10 ++++++++ crates/engine/src/tests.rs | 6 +++++ 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index ae00234d0e..46b57d1717 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -792,8 +792,7 @@ impl Document { let id0 = match self.trailer.get(b"ID") { Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(), _ => { - let mut id = generated_id(¶ms.seed); - id.truncate(16); + let id = pdfcraft_crypt::file_id(¶ms.seed); let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true }); self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); id @@ -2191,18 +2190,6 @@ fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option { (from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle) } -/// A file identifier derived from entropy supplied by the caller. -fn generated_id(seed: &[u8; 32]) -> Vec { - use std::hash::{Hash, Hasher}; - let mut out = Vec::new(); - for i in 0..2u8 { - let mut h = std::collections::hash_map::DefaultHasher::new(); - (seed, i, b"pdfcraft id").hash(&mut h); - out.extend_from_slice(&h.finish().to_be_bytes()); - } - out -} - /// The object number a classic xref subsection really starts at. A common producer bug heads the /// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so /// every row would be read one number too high and object 1 as free. Such a subsection is read @@ -2250,6 +2237,38 @@ const MAX_XREF_OBJECTS: usize = 8_388_607; mod tests { use super::*; + #[test] + fn a_missing_file_id_is_derived_from_the_encryption_seed() { + let mut doc = Document::new_empty(); + let seed = [9u8; 32]; + doc.set_encryption(&pdfcraft_crypt::NewEncryption { + algorithm: pdfcraft_crypt::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed, + }) + .unwrap(); + let id = doc.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone()); + assert_eq!(id.as_deref(), Some(pdfcraft_crypt::file_id(&seed).as_slice())); + + let mut kept = Document::new_empty(); + let fixed = Object::String(crate::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true }); + kept.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![fixed.clone(), fixed])); + kept.set_encryption(&pdfcraft_crypt::NewEncryption { + algorithm: pdfcraft_crypt::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed, + }) + .unwrap(); + let still = kept.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone()); + assert_eq!(still.as_deref(), Some(b"0123456789abcdef".as_slice())); + } + #[test] fn a_read_state_matches_a_clone_and_no_changed_document() { let mut doc = Document::new_empty(); diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 82b598688a..ba9a63581c 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -602,12 +602,20 @@ fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec { h.update(label); h.update(counter.to_le_bytes()); out.extend_from_slice(&h.finalize()); - counter += 1; + counter = counter.saturating_add(1); } out.truncate(n); out } +/// Sixteen bytes of file identifier from `seed` (SHA-256 in counter mode, label `id`). +/// +/// `/ID` is written in the clear. Deriving it the same way as the file key keeps that public +/// value from being an unkeyed hash of the seed. +pub fn file_id(seed: &[u8; 32]) -> Vec { + expand(seed, b"id", 16) +} + /// Build the `/Encrypt` values and an authenticated (owner) handler for a new encryption. pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { // Bits 7โ€“8 and 13โ€“32 must be 1; bits 1โ€“2 must be 0 (ยง7.6.4.2). diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 5110e79998..e684f1ef2e 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -4,6 +4,16 @@ fn hex(s: &str) -> Vec { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() } +#[test] +fn file_id_is_a_one_way_16_byte_identifier() { + let seed = [7u8; 32]; + let id = file_id(&seed); + assert_eq!(id.len(), 16); + assert_eq!(id, file_id(&seed)); + assert_ne!(id, seed[..16]); + assert_ne!(id, file_id(&[8u8; 32])); +} + #[test] fn rc4_reference_vectors() { assert_eq!(rc4(b"Key", b"Plaintext"), hex("bbf316e8d940af0ad3")); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6895f188f3..ceb3f1c34f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1886,6 +1886,12 @@ fn passwords_that_encryption_would_make_empty_or_equal_are_refused() { #[test] fn fresh_sessions_protecting_identical_documents_produce_different_salts() { + // Characterization only: two sessions' salts differ. A per-call RandomState already did + // that before Protect drew from the platform CSPRNG, so this does not prove the source. + // `protect_fails_closed_when_randomness_is_unavailable` is the guard. It does not compile + // against a Protect with no CSPRNG hook, and it fails closed when that hook errors or + // returns zeros. Comparing the stored function with `getrandom::fill` is not reliable: + // one function can have different addresses in different codegen units. let raw = Arc::new(fixture(1)); let encrypt_bytes = |saved: Arc>, key: &[u8]| -> Vec { let doc = pdfcraft_cos::Document::open_with_password(saved, Some("secret")).unwrap();