From 9c2d9c02bfd28c58380e70dbcc9e22eb27880c38 Mon Sep 17 00:00:00 2001 From: snipeTR <31975916+snipeTR@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:10:39 +0300 Subject: [PATCH] =?UTF-8?q?fix(crypt):=20R2=E2=80=93R4=20passwords=20with?= =?UTF-8?q?=20non-ASCII=20characters=20open=20(PDFDocEncoding)=20(#905)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revisions 2–4 of the standard security handler take the password in PDFDocEncoding (ISO 32000-2 §7.6.4.3.2, Algorithm 2). PdfCraft authenticates an encrypted file twice, and neither did that: - pdfcraft-crypt took Latin-1 and dropped every other character, so "ı" (0x9A) and "€" (0xA0) vanished and "ılık€" became "lk"; - the renderer (vendored hayro-syntax, "TODO: Convert to PDFDocEncoding") used the UTF-8 bytes, so any non-ASCII password failed, even "Çok güzel", which Latin-1 and PDFDocEncoding write alike; - the engine handed the renderer an owner-recovered user password as Latin-1 characters, which the renderer then read as UTF-8 again. Files that qpdf or Acrobat protected with such passwords could not be opened with the right password. - pdfcraft-crypt encodes R2–R4 passwords in PDFDocEncoding and, for a password with characters PDFDocEncoding lacks (ğ, ş, İ), also tries its UTF-8 bytes, which qpdf writes for those. The PDFDocEncoding table moves here from pdfcraft-cos (which depends on this crate) and cos uses it. - hayro-syntax (vendored patch 14) tries an R2–R4 password as given, then in PDFDocEncoding. - The engine passes a recovered user password to the renderer as UTF-8 when its bytes are UTF-8 and as PDFDocEncoding otherwise, so the renderer gets exactly those bytes back. Co-authored-by: Brandon Thomas --- crates/cos/src/lib.rs | 2 +- crates/cos/src/object.rs | 16 +---- crates/crypt/src/lib.rs | 63 +++++++++++++++--- crates/crypt/src/tests.rs | 72 ++++++++++++++++++++ crates/engine/src/lib.rs | 13 +++- crates/engine/src/tests.rs | 36 ++++++++++ vendor/README.md | 2 + vendor/hayro-syntax/src/crypto/mod.rs | 95 +++++++++++++++++++++++---- 8 files changed, 259 insertions(+), 40 deletions(-) diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 7aa560795..b6fe827e4 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -25,7 +25,7 @@ pub use bytes::Bytes; pub use document::{Document, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, parse_indirect}; -pub use pdfcraft_crypt::{Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler}; +pub use pdfcraft_crypt::{Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, pdfdoc_byte, pdfdoc_char}; pub use writer::{SaveOptions, pdf_date, serialize, write_full, write_incremental}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index a805648e2..7053fd2f2 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -66,27 +66,13 @@ impl PdfString { b => match std::str::from_utf8(b) { // Pure ASCII is identical either way; only non-ASCII UTF-8 needs the switch. Ok(utf8) if !b.is_ascii() => utf8.to_owned(), - _ => b.iter().map(|&c| pdfdoc_char(c)).collect(), + _ => b.iter().map(|&c| pdfcraft_crypt::pdfdoc_char(c)).collect(), }, }; if s.starts_with('\u{FEFF}') { s.trim_start_matches('\u{FEFF}').to_owned() } else { s } } } -/// PDFDocEncoding → Unicode (ISO 32000-2 Annex D). Bytes 0x18–0x1F and 0x80–0xA0 differ from Latin-1. -fn pdfdoc_char(c: u8) -> char { - const HIGH: [char; 33] = [ - '•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł', - 'œ', 'š', 'ž', '\u{FFFD}', '€', - ]; - const LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜']; - match c { - 0x18..=0x1F => LOW[(c - 0x18) as usize], - 0x80..=0xA0 => HIGH[(c - 0x80) as usize], - _ => c as char, - } -} - /// A name object (without the leading `/`, with `#xx` escapes already decoded). pub type Name = Vec; diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 267c8f1c4..9c2c1dab1 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -151,15 +151,19 @@ impl SecurityHandler { let mut recovered_user = None; let (key, auth) = match dict.r { 2..=4 => { - let legacy = legacy_password(pw); - if let Some((k, user)) = owner_key_r4(&dict, id0, &legacy) { - recovered_user = Some(user); - (k, Auth::Owner) - } else if let Some(k) = user_key_r4(&dict, id0, &legacy) { - (k, Auth::User) - } else { - return Err(CryptError::WrongPassword); + let mut found = None; + for legacy in legacy_candidates(pw) { + if let Some((k, user)) = owner_key_r4(&dict, id0, &legacy) { + recovered_user = Some(user); + found = Some((k, Auth::Owner)); + } else if let Some(k) = user_key_r4(&dict, id0, &legacy) { + found = Some((k, Auth::User)); + } + if found.is_some() { + break; + } } + found.ok_or(CryptError::WrongPassword)? } 5 | 6 => { let pw = sasl_password(pw); @@ -300,9 +304,48 @@ impl SecurityHandler { // ── Passwords ────────────────────────────────────────────────────────────────────────────────── -/// Revisions 2–4 use PDFDocEncoding passwords; characters outside Latin-1 are dropped. +/// PDFDocEncoding (ISO 32000-2 Annex D) where it differs from Latin-1: bytes 0x18–0x1F and +/// 0x80–0xA0 (0x9F is undefined). Here rather than in pdfcraft-cos, which decodes text strings +/// with it, because R2–R4 passwords need it too and cos depends on this crate. +const PDFDOC_LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜']; +const PDFDOC_HIGH: [char; 33] = [ + '•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł', 'œ', + 'š', 'ž', '\u{FFFD}', '€', +]; + +/// PDFDocEncoding → Unicode. +pub fn pdfdoc_char(c: u8) -> char { + match c { + 0x18..=0x1F => PDFDOC_LOW[usize::from(c - 0x18)], + 0x80..=0xA0 => PDFDOC_HIGH[usize::from(c - 0x80)], + _ => char::from(c), + } +} + +/// Unicode → PDFDocEncoding, `None` for a character it lacks (ğ, ş, İ, Cyrillic, CJK, …). +pub fn pdfdoc_byte(c: char) -> Option { + let at = |table: &[char], base: u8| table.iter().position(|&t| t == c && t != '\u{FFFD}').and_then(|i| u8::try_from(i).ok()).map(|i| base + i); + at(&PDFDOC_LOW, 0x18).or_else(|| at(&PDFDOC_HIGH, 0x80)).or_else(|| match u32::from(c) { + 0x18..=0x1F | 0x80..=0xA0 => None, + n => u8::try_from(n).ok(), + }) +} + +/// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes. +/// Characters PDFDocEncoding lacks are dropped. fn legacy_password(pw: &str) -> Vec { - pw.chars().filter_map(|c| u8::try_from(u32::from(c)).ok()).take(32).collect() + pw.chars().filter_map(pdfdoc_byte).take(32).collect() +} + +/// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification +/// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives +/// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords. +fn legacy_candidates(pw: &str) -> Vec> { + let mut out = vec![legacy_password(pw)]; + if pw.chars().any(|c| pdfdoc_byte(c).is_none()) { + out.push(pw.bytes().take(32).collect()); + } + out } /// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes. diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index f93f8c646..dc3a1964b 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -163,3 +163,75 @@ fn owner_authentication_recovers_the_user_password() { let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone(); assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..])); } + +/// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys +/// `qpdf --show-encryption-key` reports. +fn qpdf_file(r: i64, o: &str, u: &str) -> EncryptDict { + let mut d = EncryptDict { + filter: b"Standard".to_vec(), + v: if r == 4 { 4 } else { 2 }, + r, + length_bits: 128, + o: hex(o), + u: hex(u), + p: -4, + encrypt_metadata: true, + ..Default::default() + }; + if r == 4 { + d.crypt_filters = vec![(b"StdCF".to_vec(), Method::Aes128)]; + d.stm_f = b"StdCF".to_vec(); + d.str_f = b"StdCF".to_vec(); + } + d +} + +const QPDF_ID: &str = "56b57383aafa8314f3f98222995f5604"; + +#[test] +fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() { + let id = hex(QPDF_ID); + // R3 RC4: user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0); owner "Öğretmen", which + // PDFDocEncoding can't hold, so qpdf wrote its UTF-8 bytes. + let r3 = qpdf_file( + 3, + "d519aa8120ce8c54812b45e5391916fed167b1c84961d90c4ee4c182ad83534b", + "4985c03ddfa3eeb8a90c343512913ec40021446990b9e4114071a4d9104984c1", + ); + let user = SecurityHandler::open(r3.clone(), &id, Some("ılık€")).unwrap(); + assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f"))); + let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap(); + assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f"))); + assert_eq!(owner.recovered_user_password(), Some(vec![0x9A, b'l', 0x9A, b'k', 0xA0])); + // Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it. + for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] { + assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}"); + } + // R4 AES-128: user "şifre" (UTF-8 bytes), owner "owner". + let r4 = qpdf_file( + 4, + "bb4f8f4bd2236569e765906caf64e4429a4c20d6e996fdef963e9b5080f9e083", + "4a965fb91882772cd09ce8bbff2c33270021446990b9e4114071a4d9104984c1", + ); + let user = SecurityHandler::open(r4.clone(), &id, Some("şifre")).unwrap(); + assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("9f19d465cfd8e525d32aa0bdf10acd06"))); + assert_eq!(SecurityHandler::open(r4.clone(), &id, Some("owner")).unwrap().auth(), Auth::Owner); + assert_eq!(SecurityHandler::open(r4, &id, Some("ifre")).unwrap_err(), CryptError::WrongPassword); +} + +#[test] +fn pdfdoc_encoding_round_trips() { + for b in 0..=255u8 { + if b != 0x9F { + assert_eq!(pdfdoc_byte(pdfdoc_char(b)), Some(b), "{b:#04x}"); + } + } + assert_eq!(pdfdoc_byte('ı'), Some(0x9A)); + assert_eq!(pdfdoc_byte('€'), Some(0xA0)); + assert_eq!(pdfdoc_byte('ç'), Some(0xE7)); + for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] { + assert_eq!(pdfdoc_byte(c), None, "{c}"); + } + assert_eq!(legacy_candidates("Çok"), [vec![0xC7, b'o', b'k']]); + assert_eq!(legacy_candidates("şifre"), [b"ifre".to_vec(), "şifre".as_bytes().to_vec()]); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bb124c5ef..3b7a62887 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1812,6 +1812,17 @@ fn keys_after(edit: &Edit) -> Option { } } +/// The recovered user password of an R2–R4 file as text for the renderer, which tries a password +/// as its UTF-8 bytes and then in PDFDocEncoding: UTF-8 bytes (as some writers store passwords +/// PDFDocEncoding can't hold, such as "şifre") stay UTF-8, and other bytes are read as +/// PDFDocEncoding, so either way the renderer gets back exactly these bytes. +fn renderer_password(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(text) => text.to_owned(), + Err(_) => bytes.iter().map(|b| pdfcraft_cos::pdfdoc_char(*b)).collect(), + } +} + /// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error /// message, so one bad file or edit can't take the app and its other documents down. It is a /// safety net for bugs, not a substitute for returning errors. @@ -2129,7 +2140,7 @@ impl Session { Ok(Ok(d)) => d.security().and_then(|s| s.recovered_user_password()), _ => None, }; - let user: String = user.ok_or(OpenError::WrongPassword)?.iter().map(|b| char::from(*b)).collect(); + let user = renderer_password(&user.ok_or(OpenError::WrongPassword)?); (inspect(bytes.clone(), Some(&user))?, Some(user)) } Err(e) => return Err(e), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8d1062948..e0d9390f6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2817,3 +2817,39 @@ fn oversized_exports_clamp_and_report_their_dpi_unless_strict() { let err = export::Exporter::from_source(strict).png(0, 600.0).unwrap_err(); assert!(err.contains("exceeds renderer limits"), "{err}"); } + +#[test] +fn legacy_passwords_with_turkish_characters_open() { + // RC4-128 (R3): user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0), owner "Çağlar" + // (PDFDocEncoding has no ğ: written without it, as pdfcraft-crypt writes R2–R4 passwords). + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let params = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Rc4_128, + user_password: "ılık€", + owner_password: "Çağlar", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }; + cos.set_encryption(¶ms).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap()); + let mut s = Session::new(); + // The renderer used to try only the UTF-8 bytes, and the owner fallback handed it the + // recovered user password as Latin-1: both came back as a wrong password. + for pw in ["ılık€", "Çağlar"] { + let id = s.open("x.pdf", None, bytes.clone(), Some(pw)).unwrap_or_else(|e| panic!("{pw}: {e:?}")); + assert_eq!(page_texts(&s, id), ["Page 1"], "{pw}"); + assert!(s.get(id).unwrap().info.encrypted, "{pw}"); + } + assert!(matches!(s.open("x.pdf", None, bytes, Some("ilik€")), Err(OpenError::WrongPassword))); +} + +#[test] +fn recovered_user_passwords_reach_the_renderer_byte_for_byte() { + assert_eq!(crate::renderer_password(b"pw"), "pw"); + // UTF-8 bytes (qpdf writes "şifre" so; its \xC5\x9F has no PDFDocEncoding reading). + assert_eq!(crate::renderer_password("şifre".as_bytes()), "şifre"); + // PDFDocEncoding bytes: ı = 0x9A, € = 0xA0, Ç = 0xC7. + assert_eq!(crate::renderer_password(&[0x9A, b'l', 0x9A, b'k', 0xA0]), "ılık€"); + assert_eq!(crate::renderer_password(&[0xC7, b'o', b'k']), "Çok"); +} diff --git a/vendor/README.md b/vendor/README.md index 230b28611..650d92964 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -11,6 +11,8 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | +| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | diff --git a/vendor/hayro-syntax/src/crypto/mod.rs b/vendor/hayro-syntax/src/crypto/mod.rs index 2d407ad09..d531be539 100644 --- a/vendor/hayro-syntax/src/crypto/mod.rs +++ b/vendor/hayro-syntax/src/crypto/mod.rs @@ -162,18 +162,27 @@ pub(crate) fn get( }; let mut decryption_key = if revision <= 4 { - let key = decryption_key_rev1234( - password, - encrypt_metadata, - revision, - byte_length, - &owner_string, - permissions, - id, - )?; - authenticate_user_password_rev234(revision, &key, id, &user_string)?; - - key + // PdfCraft patch: (see vendor/README.md hayro-syntax (14)) the password is tried as + // given and then in PDFDocEncoding, which revisions 2–4 specify. + let mut found = Err(DecryptionError::PasswordProtected); + for candidate in legacy_passwords(password) { + found = decryption_key_rev1234( + &candidate, + encrypt_metadata, + revision, + byte_length, + &owner_string, + permissions, + id, + ) + .and_then(|key| { + authenticate_user_password_rev234(revision, &key, id, &user_string).map(|()| key) + }); + if found.is_ok() { + break; + } + } + found? } else { decryption_key_rev56(dict, revision, password, &owner_string, &user_string)? }; @@ -448,6 +457,45 @@ fn compute_hash_rev56( Ok(result) } +/// `PdfCraft` patch: the byte strings to try as a revision 2–4 password. Revisions 2–4 take the +/// password in `PDFDocEncoding` (ISO 32000-2 §7.6.4.3.2, Algorithm 2 a), but the password arrives +/// as UTF-8 text: first the bytes as given (what earlier versions used, and what some writers +/// store), then, for text with characters outside ASCII that `PDFDocEncoding` all has ("ılık€", +/// "Çok güzel"), its `PDFDocEncoding` bytes. +fn legacy_passwords(password: &[u8]) -> Vec> { + let mut out = vec![password.to_vec()]; + if let Ok(text) = core::str::from_utf8(password) + && !text.is_ascii() + && let Some(doc) = text.chars().map(pdf_doc_byte).collect::>>() + { + out.push(doc); + } + out +} + +/// `PdfCraft` patch: `PDFDocEncoding` (ISO 32000-2 Annex D) of `c`, `None` when it has none. +/// It differs from Latin-1 in 0x18–0x1F and 0x80–0xA0 (0x9F is undefined). +fn pdf_doc_byte(c: char) -> Option { + const LOW: [char; 8] = ['˘', 'ˇ', 'ˆ', '˙', '˝', '˛', '˚', '˜']; + const HIGH: [char; 33] = [ + '•', '†', '‡', '…', '—', '–', 'ƒ', '⁄', '‹', '›', '−', '‰', '„', '“', '”', '‘', '’', '‚', + '™', 'fi', 'fl', 'Ł', 'Œ', 'Š', 'Ÿ', 'Ž', 'ı', 'ł', 'œ', 'š', 'ž', '\u{FFFD}', '€', + ]; + let at = |table: &[char], base: u8| { + table + .iter() + .position(|&t| t == c && t != '\u{FFFD}') + .and_then(|i| u8::try_from(i).ok()) + .map(|i| base + i) + }; + at(&LOW, 0x18) + .or_else(|| at(&HIGH, 0x80)) + .or_else(|| match u32::from(c) { + 0x18..=0x1F | 0x80..=0xA0 => None, + n => u8::try_from(n).ok(), + }) +} + /// Algorithm 2: Computing a file encryption key in order to encrypt a document (revision 4 and earlier) fn decryption_key_rev1234( password: &[u8], @@ -460,7 +508,7 @@ fn decryption_key_rev1234( ) -> Result, DecryptionError> { let mut md5_input = vec![]; - // TODO: Convert to PDFDocEncoding. + // (PDFDocEncoding: see `legacy_passwords`, PdfCraft patch.) // a) Pad or truncate password to 32 bytes using PASSWORD_PADDING. let mut padded_password = [0_u8; 32]; let copy_len = password.len().min(32); @@ -672,3 +720,24 @@ fn decryption_key_rev56( // "b". Bytes 0-3 of the decrypted Perms entry, treated as a little-endian integer, are the user // permissions. They shall match the value in the P key. } + +#[cfg(test)] +mod pdfcraft_password_tests { + use super::*; + + #[test] + fn legacy_passwords_are_tried_as_given_then_in_pdf_doc_encoding() { + assert_eq!(legacy_passwords(b"pw"), [b"pw".to_vec()]); + assert_eq!( + legacy_passwords("ılık€".as_bytes()), + ["ılık€".as_bytes().to_vec(), vec![0x9A, b'l', 0x9A, b'k', 0xA0]] + ); + assert_eq!( + legacy_passwords("Çok".as_bytes()), + ["Çok".as_bytes().to_vec(), vec![0xC7, b'o', b'k']] + ); + // PDFDocEncoding has no ş: only the bytes as given. + assert_eq!(legacy_passwords("şifre".as_bytes()), ["şifre".as_bytes().to_vec()]); + assert_eq!(legacy_passwords(&[0xFF, 0x00]), [vec![0xFF, 0x00]]); + } +}