M1: a V4 RC4 file with a 40- or 48-bit key reads and saves (#6397) (#6398)

This commit is contained in:
mtlcl410 authored and GitHub committed 2026-11-09 08:48:43 +08:00
1 parent 1a28606c64
commit e6b6216fd9
3 files changed
+81 -5

No files matched your search

+16 -3
View File
@@ -302,20 +302,33 @@ impl SecurityHandler {
}
}
/// Algorithm 1 (§7.6.3.3): the key for one object. AES-256 uses the file key directly.
/// Algorithm 1 (§7.6.3.2): the key for one object. AES-256 uses the file key directly
/// (Algorithm 1.A, §7.6.3.3).
///
/// For an RC4 crypt filter under `/V 4`, a file key shorter than 16 bytes is first extended
/// to 16 bytes with zeros. The standard doesn't say so (Algorithm 1 takes the n-byte key as
/// it is), but that is how Acrobat writes and reads such files: a `/V 4` RC4 crypt filter
/// with a 40- or 48-bit key (pdf.js issue 19484). The renderer's `hayro-syntax` reads them
/// the same way, so a page it shows is one this crate can read and save. AES-128 keys are
/// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only
/// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already.
fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec<u8> {
if method == Method::Aes256 {
return self.key.clone();
}
let mut key = self.key.clone();
if method == Method::Rc4 && self.dict.v == 4 && key.len() < 16 {
key.resize(16, 0);
}
let mut h = Md5::new();
h.update(&self.key);
h.update(&key);
h.update(&num.to_le_bytes()[..3]);
h.update(generation.to_le_bytes());
if method == Method::Aes128 {
h.update(b"sAlT");
}
let digest = h.finalize();
digest[..(self.key.len() + 5).min(16)].to_vec()
digest[..(key.len() + 5).min(16)].to_vec()
}
fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec<u8> {
+63
View File
@@ -280,6 +280,69 @@ proptest::proptest! {
}
}
/// A `/V 4` RC4 crypt filter with a 40- or 48-bit key, as Acrobat writes it (pdf.js issue 19484):
/// the file key is extended to 16 bytes with zeros before Algorithm 1, so each object's key is
/// all 16 bytes of MD5(key, zeros, object, generation). Everything else keeps Algorithm 1 as the
/// standard has it, n + 5 bytes (at most 16) of MD5(key, object, generation[, "sAlT"]): a `/V 2`
/// file with the same short key, a full 128-bit `/V 4` RC4 key, and AESV2, short (88 bits) or
/// full. Each object key is computed here by hand; data written with it must decrypt.
#[test]
fn a_v4_rc4_key_shorter_than_16_bytes_is_extended_with_zeros() {
let text = b"Page tree in an object stream".to_vec();
let cases =
[(4, 40, Method::Rc4), (4, 48, Method::Rc4), (2, 40, Method::Rc4), (4, 128, Method::Rc4), (4, 88, Method::Aes128), (4, 128, Method::Aes128)];
for (v, bits, method) in cases {
let mut d = EncryptDict {
filter: b"Standard".to_vec(),
v,
r: if v == 4 { 4 } else { 3 },
length_bits: bits,
o: vec![7; 32],
p: -3392,
encrypt_metadata: v != 4,
..Default::default()
};
if v == 4 {
d.crypt_filters = vec![(b"StdCF".to_vec(), method)];
d.stm_f = b"StdCF".to_vec();
d.str_f = b"StdCF".to_vec();
}
let key = file_key_r4(&d, ID, b"");
assert_eq!(key.len(), (bits / 8) as usize);
d.u = u_value_r4(&d, ID, &key);
let h = SecurityHandler::open(d, ID, None).unwrap();
let (num, generation) = (13u32, 0u16);
let extended = v == 4 && method == Method::Rc4 && key.len() < 16;
let mut md5 = Md5::new();
md5.update(&key);
if extended {
md5.update(vec![0u8; 16 - key.len()]);
}
md5.update(&num.to_le_bytes()[..3]);
md5.update(generation.to_le_bytes());
if method == Method::Aes128 {
md5.update(b"sAlT");
}
let digest = md5.finalize();
let n = if extended { 16 } else { key.len() };
let object_key = digest[..(n + 5).min(16)].to_vec();
let case = format!("V{v}, {bits}-bit key, {method:?}");
let written = if method == Method::Rc4 {
rc4(&object_key, &text)
} else {
let iv = [3u8; 16];
[iv.to_vec(), aes_cbc_encrypt(&object_key, &iv, &text, true)].concat()
};
assert_eq!(h.decrypt_string(num, generation, &written), text, "{case}");
assert_eq!(h.decrypt_stream(num, generation, &written, StreamKind::Normal), text, "{case}");
let saved = h.encrypt_stream(num, generation, &text, StreamKind::Normal);
if method == Method::Rc4 {
assert_eq!(saved, written, "saved as it was read: {case}");
}
assert_eq!(h.decrypt_stream(num, generation, &saved, StreamKind::Normal), text, "{case}");
}
}
#[test]
fn owner_authentication_recovers_the_user_password() {
for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
+2 -2
View File
@@ -226,8 +226,8 @@ milestone = "M1"
status = "shipped"
commands = []
tools = []
evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone", "crates/cos/tests/it/encryption.rs::a_crypt_filter_selected_by_reference_is_used", "crates/cos/tests/it/encryption.rs::an_identity_crypt_filter_selected_by_reference_leaves_a_stream_alone", "crates/render/src/raster/tests.rs::a_stream_with_a_crypt_filter_of_its_own_is_drawn"]
notes = "a stream can name its own filter (/Crypt in /Filter, its name in /DecodeParms), read and written, each part given directly or by reference, in cos and in the renderer; /StmF, /StrF, /EFF and /CFM by reference too. A /Filter given by reference selects the crypt filter, but Stream::decoded still refuses it, so such a stream is decrypted and not decoded outside the renderer."
evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone", "crates/cos/tests/it/encryption.rs::a_crypt_filter_selected_by_reference_is_used", "crates/cos/tests/it/encryption.rs::an_identity_crypt_filter_selected_by_reference_leaves_a_stream_alone", "crates/render/src/raster/tests.rs::a_stream_with_a_crypt_filter_of_its_own_is_drawn", "crates/crypt/src/tests.rs::a_v4_rc4_key_shorter_than_16_bytes_is_extended_with_zeros"]
notes = "a stream can name its own filter (/Crypt in /Filter, its name in /DecodeParms), read and written, each part given directly or by reference, in cos and in the renderer; /StmF, /StrF, /EFF and /CFM by reference too. A /Filter given by reference selects the crypt filter, but Stream::decoded still refuses it, so such a stream is decrypted and not decoded outside the renderer. A /V 4 RC4 filter with a 40- or 48-bit key reads as Acrobat writes it, the key extended to 16 bytes with zeros before Algorithm 1, in cos as in the renderer (pdf.js issue 19484)."
[[feature]]
id = "core.attachment-only-encryption"