From 2b76db2438bbe32970c284925acc97393554d770 Mon Sep 17 00:00:00 2001 From: Page Brooks Date: Mon, 14 Sep 2026 12:28:51 -0700 Subject: [PATCH 1/2] Fix key derivation for escaped encrypt strings; fail on undecryptable PDFs --- CHANGELOG.md | 13 ++++++++++ crypto.c | 54 ++++++++++++++++++++++------------------- ffpdf.1 | 6 +++-- field_map.c | 62 ++++++++++++++++++++++++++---------------------- field_map.h | 6 +++-- form_extractor.c | 20 ++++++++++++---- form_extractor.h | 10 +++++--- main.c | 9 +++---- pdf_filler.c | 3 ++- pdf_lex.c | 40 +++++++++++++++++++++++++++++++ pdf_lex.h | 10 ++++++++ test_e2e.sh | 58 ++++++++++++++++++++++++++++++++++++++++++++ test_field_map.c | 60 ++++++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 280 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c697b..d1bf536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Encrypted PDFs whose `/O`, `/U` or `/ID` strings are written as literal + strings with escapes (`\n`, `\r`, `\ddd` octal, line continuations) now + decrypt. Each escape was read as the escaped letter, so ffpdf derived the + wrong file key and `fields` reported `count: 0` with exit 0 (#14). Literal + strings inside encrypted objects are now decoded the same spec-compliant way. +- An encrypted PDF that cannot be decrypted is now an error instead of a silent + empty or garbled result. This covers files that need a password, use an + unsupported security handler, or derive a key that fails `/U` authentication. + `fields`, `fdf-extract`, `xfdf-extract` and `fill` exit 1 with the reason on + stderr and write nothing to stdout. + ## [0.1.2] - 2026-07-31 ### Added diff --git a/crypto.c b/crypto.c index 951b80e..b01e619 100644 --- a/crypto.c +++ b/crypto.c @@ -391,8 +391,8 @@ static const unsigned char PDF_PAD[32] = { 0x28,0xBF,0x4E,0x5E,0x4E,0x75,0x8A,0x41,0x64,0x00,0x4E,0x56,0xFF,0xFA,0x01,0x08, 0x2E,0x2E,0x00,0xB6,0xD0,0x68,0x3E,0x80,0x2F,0x0C,0xA9,0xFE,0x64,0x53,0x69,0x7A }; -// Parse a hex string "<...>" starting at *p (which points just past '<') into -// buf; returns byte count. Also handles literal "(...)" if p points at '('. +// Parse the hex "<...>" or literal "(...)" string at `v` into buf; returns the +// byte count (at most `cap`). static int parse_pdf_bytes(const char *v, unsigned char *buf, int cap) { if (*v == '<') { v++; @@ -410,13 +410,9 @@ static int parse_pdf_bytes(const char *v, unsigned char *buf, int cap) { return n; } if (*v == '(') { // literal string with escapes - v++; - int n = 0; - while (*v && *v != ')' && n < cap) { - if (*v == '\\' && v[1]) { v++; buf[n++] = (unsigned char)*v++; continue; } - buf[n++] = (unsigned char)*v++; - } - return n; + size_t n; + pdf_literal_bytes(v, buf, (size_t)cap, &n); + return (int)n; } return 0; } @@ -486,7 +482,6 @@ int pdf_crypt_init(PdfCrypt *c, const char *enc, const unsigned char *id0, size_ if (!po || !pu) return 0; int olen = parse_pdf_bytes(po, O, sizeof(O)); int ulen = parse_pdf_bytes(pu, U, sizeof(U)); - (void)ulen; if (c->R >= 5) { // R6 (AES-256): validate empty user password and unwrap the file key. @@ -534,7 +529,28 @@ int pdf_crypt_init(PdfCrypt *c, const char *enc, const unsigned char *id0, size_ md5(buf, n, h); if (c->R >= 3) for (int i = 0; i < 50; i++) md5(h, c->key_len, h); memcpy(c->key, h, c->key_len); - return 1; + + // Authenticate the empty user password by recomputing /U from the derived + // key (Algorithm 4 for R2, Algorithm 5 for R3/R4). A mismatch means the key + // is wrong: the document needs a password, or /O, /P or /ID was misread. + // Accepting it anyway would decrypt every string and stream to garbage. + unsigned char ucheck[32]; + if (c->R < 3) { + rc4(c->key, c->key_len, PDF_PAD, 32, ucheck); + return ulen >= 32 && memcmp(ucheck, U, 32) == 0; + } + unsigned char uin[32 + 64]; + size_t il = id0_len < 64 ? id0_len : 64; + memcpy(uin, PDF_PAD, 32); + memcpy(uin + 32, id0, il); + md5(uin, 32 + il, h); + rc4(c->key, c->key_len, h, 16, ucheck); + for (int i = 1; i <= 19; i++) { + unsigned char k2[16]; + for (int j = 0; j < c->key_len; j++) k2[j] = c->key[j] ^ (unsigned char)i; + rc4(k2, c->key_len, ucheck, 16, ucheck); + } + return ulen >= 16 && memcmp(ucheck, U, 16) == 0; } // Derive the per-object key (Algorithm 1) for RC4/AESV2 into `ok`, returns len. @@ -602,13 +618,7 @@ char *pdf_decrypt_dict_strings(const PdfCrypt *c, int num, int gen, const char * if (hi >= 0) ct[cl++] = (unsigned char)(hi*16); if (*q == '>') q++; } else { - int sdepth = 1; - while (*q && sdepth > 0) { - if (*q == '\\' && q[1]) { q++; ct[cl++] = (unsigned char)*q++; continue; } - if (*q == '(') { sdepth++; ct[cl++] = '('; q++; continue; } - if (*q == ')') { sdepth--; if (sdepth == 0) { q++; break; } ct[cl++] = ')'; q++; continue; } - ct[cl++] = (unsigned char)*q++; - } + q = pdf_literal_bytes(p, ct, len + 1, &cl); } unsigned char *pt = malloc(cl + 1); int pl = pdf_decrypt(c, num, gen, ct, cl, pt); @@ -667,13 +677,7 @@ char *pdf_encrypt_dict_strings(const PdfCrypt *c, int num, int gen, const char * if (hi >= 0) pt[pl++] = (unsigned char)(hi*16); if (*q == '>') q++; } else { - int sdepth = 1; - while (*q && sdepth > 0) { - if (*q == '\\' && q[1]) { q++; pt[pl++] = (unsigned char)*q++; continue; } - if (*q == '(') { sdepth++; pt[pl++] = '('; q++; continue; } - if (*q == ')') { sdepth--; if (sdepth == 0) { q++; break; } pt[pl++] = ')'; q++; continue; } - pt[pl++] = (unsigned char)*q++; - } + q = pdf_literal_bytes(p, pt, len + 1, &pl); } unsigned char *ct = malloc(pl + 40); int cl = pdf_encrypt(c, num, gen, pt, pl, ct, pl + 40); diff --git a/ffpdf.1 b/ffpdf.1 index 0d90ddc..2260518 100644 --- a/ffpdf.1 +++ b/ffpdf.1 @@ -250,8 +250,10 @@ Maximum total decompressed bytes over one run (default 1 GB). Success. .TP .B 1 -An error occurred: invalid usage, a file that could not be opened, or a PDF -whose cross\-reference table could not be parsed. +An error occurred: invalid usage, a file that could not be opened, a PDF +whose cross\-reference table could not be parsed, or an encrypted PDF that +cannot be decrypted (it needs a password, or uses an unsupported security +handler). .TP .B 2 .B fill diff --git a/field_map.c b/field_map.c index b364c26..f124cf3 100644 --- a/field_map.c +++ b/field_map.c @@ -521,23 +521,25 @@ int pdf_doc_id0(unsigned char *out, int cap) { // Detect /Encrypt in the trailer / xref-stream dict and, for the empty user // password, initialize the document crypt handler so subsequent object reads -// decrypt. No-op (and clears any previous handler) when the document is not -// encrypted or the handler is unsupported. -static void pdf_crypt_setup(FILE *f, XRefTable *xref) { +// decrypt. Returns 0 when the document is not encrypted or the handler is +// ready. Returns -1 (after printing why) when /Encrypt is present but unusable: +// every read would yield ciphertext, so callers must stop rather than report an +// empty or garbled form as if it were real. +static int pdf_crypt_setup(FILE *f, XRefTable *xref) { g_doc.encrypted = 0; long sx = find_startxref(f); - if (sx < 0) return; + if (sx < 0) return 0; fseek(f, 0, SEEK_END); long end = ftell(f), span = end - sx; - if (span <= 0) return; + if (span <= 0) return 0; char *buf = malloc((size_t)span + 1); - if (!buf) return; + if (!buf) return 0; fseek(f, sx, SEEK_SET); size_t got = fread(buf, 1, (size_t)span, f); buf[got] = '\0'; const char *ep = strstr(buf, "/Encrypt"); - if (!ep) { free(buf); return; } + if (!ep) { free(buf); return 0; } int enc_obj = parse_ref_num(ep + 8); // First element of the /ID array (part of the R2-4 key derivation). @@ -561,39 +563,41 @@ static void pdf_crypt_setup(FILE *f, XRefTable *xref) { } if (hi >= 0 && id0_len < (int)sizeof(id0)) id0[id0_len++] = (unsigned char)(hi*16); } else if (*b == '(') { - b++; - while (*b && *b != ')' && id0_len < (int)sizeof(id0)) { - if (*b == '\\' && b[1]) { b++; id0[id0_len++] = (unsigned char)*b++; continue; } - id0[id0_len++] = (unsigned char)*b++; - } + size_t n; + pdf_literal_bytes(b, id0, sizeof(id0), &n); + id0_len = (int)n; } } free(buf); - if (enc_obj <= 0) return; // Read the /Encrypt dictionary raw -- the handler is not active yet, so its // /O /U /Perms strings (which are never encrypted) are read verbatim. - char *ed = get_object_raw(f, xref, enc_obj, NULL); - if (ed) { - if (pdf_crypt_init(&g_doc.crypt, ed, id0, id0_len)) { - g_doc.encrypted = 1; - g_doc.enc_obj = enc_obj; - g_doc.enc_id0_len = id0_len < (int)sizeof(g_doc.enc_id0) ? id0_len : (int)sizeof(g_doc.enc_id0); - memcpy(g_doc.enc_id0, id0, g_doc.enc_id0_len); - fprintf(stderr, "Encrypted document: standard handler V%d R%d, %s (empty user password)\n", - g_doc.crypt.V, g_doc.crypt.R, - g_doc.crypt.cfm == CFM_RC4 ? "RC4" : g_doc.crypt.cfm == CFM_AESV2 ? "AES-128" : "AES-256"); - } else { - fprintf(stderr, "WARNING: /Encrypt present but unsupported or non-empty password; " - "reads will fail\n"); - } - free(ed); + char *ed = enc_obj > 0 ? get_object_raw(f, xref, enc_obj, NULL) : NULL; + if (!ed) { + fprintf(stderr, "ERROR: encrypted document, but its /Encrypt dictionary could not be read\n"); + return -1; } + int ok = pdf_crypt_init(&g_doc.crypt, ed, id0, id0_len); + free(ed); + if (!ok) { + fprintf(stderr, "ERROR: encrypted document (V%d R%d) cannot be decrypted: it needs a " + "password or uses an unsupported security handler (only the empty " + "user password is supported)\n", g_doc.crypt.V, g_doc.crypt.R); + return -1; + } + g_doc.encrypted = 1; + g_doc.enc_obj = enc_obj; + g_doc.enc_id0_len = id0_len < (int)sizeof(g_doc.enc_id0) ? id0_len : (int)sizeof(g_doc.enc_id0); + memcpy(g_doc.enc_id0, id0, g_doc.enc_id0_len); + fprintf(stderr, "Encrypted document: standard handler V%d R%d, %s (empty user password)\n", + g_doc.crypt.V, g_doc.crypt.R, + g_doc.crypt.cfm == CFM_RC4 ? "RC4" : g_doc.crypt.cfm == CFM_AESV2 ? "AES-128" : "AES-256"); + return 0; } int build_field_map(FILE *f, XRefTable *xref, int root_obj, FieldMap *map) { decompress_reset(); // fresh per-run decompression budget - pdf_crypt_setup(f, xref); + if (pdf_crypt_setup(f, xref) < 0) return -1; // encrypted but undecryptable if (root_obj <= 0) root_obj = find_root_obj(f); if (root_obj <= 0) return 0; diff --git a/field_map.h b/field_map.h index c319031..4c20c9e 100644 --- a/field_map.h +++ b/field_map.h @@ -38,8 +38,10 @@ typedef struct { // Walk the AcroForm field tree, recording every named node. EXTENDS support must // already be initialised by the caller (init_form_extractor_extends_support). // `root_obj` is the /Root (catalog) object number, or <=0 to have it resolved -// from the trailer. Returns the AcroForm object number (>0) on success, or 0 if -// no AcroForm/Fields were found. Populates `map` (free with field_map_free). +// from the trailer. Returns the AcroForm object number (>0) on success, 0 if no +// AcroForm/Fields were found, or -1 if the document is encrypted but cannot be +// decrypted (the reason is printed to stderr and nothing is walked). Populates +// `map` (free with field_map_free). int build_field_map(FILE *f, XRefTable *xref, int root_obj, FieldMap *map); void field_map_free(FieldMap *map); diff --git a/form_extractor.c b/form_extractor.c index 55b1546..ef6cc41 100644 --- a/form_extractor.c +++ b/form_extractor.c @@ -57,9 +57,10 @@ static void print_xml_attr(const char *name) { } } -void extract_form_fields_xfdf(FILE *f, const XRefTable *xref_table) { +int extract_form_fields_xfdf(FILE *f, const XRefTable *xref_table) { FieldMap map = {0}; int acroform = build_field_map(f, (XRefTable *)xref_table, 0, &map); + if (acroform < 0) { objstm_cache_reset(); return 1; } // reason on stderr printf("\n"); printf("\n"); @@ -96,11 +97,15 @@ void extract_form_fields_xfdf(FILE *f, const XRefTable *xref_table) { field_map_free(&map); objstm_cache_reset(); + return 0; } -void extract_form_fields_fdf(FILE *f, const XRefTable *xref_table) { +int extract_form_fields_fdf(FILE *f, const XRefTable *xref_table) { FieldMap map = {0}; - build_field_map(f, (XRefTable *)xref_table, 0, &map); + if (build_field_map(f, (XRefTable *)xref_table, 0, &map) < 0) { + objstm_cache_reset(); + return 1; // reason on stderr + } printf("%%FDF-1.2\n1 0 obj\n<<\n/FDF\n<<\n/Fields [\n"); for (int i = 0; i < map.count; i++) { @@ -127,6 +132,7 @@ void extract_form_fields_fdf(FILE *f, const XRefTable *xref_table) { field_map_free(&map); objstm_cache_reset(); + return 0; } /* ========================================================================== @@ -317,9 +323,12 @@ static void map_annots_to_pages(FILE *f, XRefTable *xref, int node, int depth, free(d); } -void extract_form_fields_json(FILE *f, const XRefTable *xref_table) { +int extract_form_fields_json(FILE *f, const XRefTable *xref_table) { FieldMap map = {0}; - build_field_map(f, (XRefTable *)xref_table, 0, &map); + if (build_field_map(f, (XRefTable *)xref_table, 0, &map) < 0) { + objstm_cache_reset(); + return 1; // reason on stderr + } // Document-level facts an agent needs to interpret the field list: whether // the form is XFA (AcroForm /XFA) and, if so, whether it is dynamic @@ -449,4 +458,5 @@ void extract_form_fields_json(FILE *f, const XRefTable *xref_table) { free(pmap); field_map_free(&map); objstm_cache_reset(); + return 0; } diff --git a/form_extractor.h b/form_extractor.h index 0069713..80e0363 100644 --- a/form_extractor.h +++ b/form_extractor.h @@ -18,12 +18,16 @@ // Extract the form's fillable fields to stdout, as FDF or XFDF. Both are thin // presentation layers over build_field_map() (see field_map.h). -void extract_form_fields_fdf(FILE *f, const XRefTable *xref_table); -void extract_form_fields_xfdf(FILE *f, const XRefTable *xref_table); +// +// All three extractors return 0 on success, or 1 when the document cannot be +// read (an encrypted PDF that cannot be decrypted); the reason goes to stderr +// and nothing is written to stdout. +int extract_form_fields_fdf(FILE *f, const XRefTable *xref_table); +int extract_form_fields_xfdf(FILE *f, const XRefTable *xref_table); // The `fields` command: list every fillable field as JSON (name, type, current // value, and per-type extras: a choice field's options and combo/multi-select // flags, a checkbox's on-state). Designed for programmatic callers. -void extract_form_fields_json(FILE *f, const XRefTable *xref_table); +int extract_form_fields_json(FILE *f, const XRefTable *xref_table); #endif // FORM_EXTRACTOR_H diff --git a/main.c b/main.c index e687725..a750e0e 100644 --- a/main.c +++ b/main.c @@ -175,16 +175,17 @@ static int cmd_extract(int mode, const char *path) { return 1; } + int rc; if (mode == 2) - extract_form_fields_json(f, &xref_table); + rc = extract_form_fields_json(f, &xref_table); else if (mode == 1) - extract_form_fields_xfdf(f, &xref_table); + rc = extract_form_fields_xfdf(f, &xref_table); else - extract_form_fields_fdf(f, &xref_table); + rc = extract_form_fields_fdf(f, &xref_table); xref_free(&xref_table); fclose(f); - return 0; + return rc; } // Run `fill`: resolve the stdin-FDF and -o conventions, then delegate to diff --git a/pdf_filler.c b/pdf_filler.c index 59ad1da..e08f66e 100644 --- a/pdf_filler.c +++ b/pdf_filler.c @@ -1784,7 +1784,8 @@ static int fill_core(const char *pdf_filename, FdfData *fdf, // are resolved lazily by get_object_raw (only the needed streams are ever // decompressed); objstm_cache_reset() frees that cache at cleanup. int acroform = build_field_map(f, &xref, root, &map); - if (acroform <= 0) { + if (acroform < 0) goto cleanup; // undecryptable; reason on stderr + if (acroform == 0) { fprintf(stderr, "ERROR: no AcroForm found in document catalog\n"); goto cleanup; } diff --git a/pdf_lex.c b/pdf_lex.c index b6a8ab0..01e857c 100644 --- a/pdf_lex.c +++ b/pdf_lex.c @@ -209,6 +209,46 @@ const char *read_literal_alloc(const char *p, char **out) { return p; } +const char *pdf_literal_bytes(const char *p, unsigned char *out, size_t cap, size_t *len) { + size_t n = 0; + int depth = 1; +#define PUT(b) do { if (n < cap) out[n++] = (unsigned char)(b); } while (0) + p++; // skip '(' + while (*p) { + unsigned char c = (unsigned char)*p++; + if (c == '\\') { + if (!*p) break; + c = (unsigned char)*p++; + switch (c) { + case 'n': PUT('\n'); break; + case 'r': PUT('\r'); break; + case 't': PUT('\t'); break; + case 'b': PUT('\b'); break; + case 'f': PUT('\f'); break; + case '\r': if (*p == '\n') p++; break; // line continuation: no byte + case '\n': break; + default: + if (c >= '0' && c <= '7') { // \d, \dd or \ddd octal + int v = c - '0'; + for (int i = 0; i < 2 && *p >= '0' && *p <= '7'; i++) + v = v * 8 + (*p++ - '0'); + PUT(v & 0xff); // high-order overflow ignored + } else { + PUT(c); // \( \) \\ and unknown escapes + } + } + continue; + } + if (c == '(') depth++; + else if (c == ')' && --depth == 0) break; + else if (c == '\r') { if (*p == '\n') p++; c = '\n'; } // bare EOL reads as LF + PUT(c); + } +#undef PUT + *len = n; + return p; +} + char *extract_dict_inner_alloc(const char *obj_dict) { const char *open = strstr(obj_dict, "<<"); if (!open) return NULL; diff --git a/pdf_lex.h b/pdf_lex.h index ebaa394..e7efa49 100644 --- a/pdf_lex.h +++ b/pdf_lex.h @@ -67,6 +67,16 @@ const char *read_literal(const char *p, char *out, size_t out_size); // (caller frees; never truncates). Returns a pointer past the closing ')'. const char *read_literal_alloc(const char *p, char **out); +// Decode the literal string starting at '(' into raw bytes per ISO 32000-1 +// 7.3.4.2: balanced parentheses; the escapes \n \r \t \b \f \( \) \\; octal +// \d, \dd and \ddd; a backslash before an end-of-line continues the line (no +// byte); an unescaped CR, LF or CRLF reads as one LF; any other escaped +// character stands for itself. At most `cap` bytes are stored (the rest of the +// string is still consumed) and *len is set to the count. Binary-safe for +// escaped bytes, so use this wherever exact bytes matter (encryption keys, +// ciphertext). Returns a pointer past the closing ')'. +const char *pdf_literal_bytes(const char *p, unsigned char *out, size_t cap, size_t *len); + // Object number of an indirect reference "N G R" at `p` (leading ws skipped). int parse_ref_num(const char *p); diff --git a/test_e2e.sh b/test_e2e.sh index 5667ac1..1137af9 100755 --- a/test_e2e.sh +++ b/test_e2e.sh @@ -1064,6 +1064,64 @@ for name, enc in variants.items(): PY nplain=$(grep -c '^/T (' "$TMP/out.fdf") printf '%%FDF-1.2\n1 0 obj\n<< /FDF << /Fields [ << /T (%s) /V (ENC_ROUNDTRIP) >> ] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%%%EOF\n' "$NAME" > "$TMP/enc.fdf" + + # An /O written as a literal string with spec escapes (\n, \r, \000) must + # derive the same file key as its hex spelling (issue #14: escapes were read + # as the escaped letter, so every object-stream field vanished, exit 0). A + # key that fails /U authentication must be an error, not an empty list. + python3 - docs/example-form.pdf "$TMP" <<'PY' +import io, re, sys, pikepdf +src, tmp = sys.argv[1], sys.argv[2] +NAMED = {0x0a: b'\\n', 0x0d: b'\\r', 0x09: b'\\t', 0x08: b'\\b', 0x0c: b'\\f'} +def literal(bs): + out = b'(' + for c in bs: + if c in NAMED: out += NAMED[c] + elif c in (0x28, 0x29, 0x5c): out += b'\\' + bytes([c]) + elif c == 0: out += b'\\000' + else: out += bytes([c]) + return out + b')' +pdf = pikepdf.open(src) +for i in range(5000): # R4 /O depends only on the passwords + buf = io.BytesIO() + pdf.save(buf, encryption=pikepdf.Encryption(owner=f"own{i}", user="", R=4, aes=True), + object_stream_mode=pikepdf.ObjectStreamMode.generate) + raw = buf.getvalue() + m = re.search(rb'/O\s*<([0-9a-fA-F]+)>', raw) + O = bytes.fromhex(m.group(1).decode()) + if (0x0a in O or 0x0d in O) and 0 in O: + break +else: + sys.exit("no /O with both an EOL byte and a NUL") +# Same-length, space-padded rewrites keep every xref offset valid. +start, end = m.start(1) - 1, m.end() +lit = literal(O) +assert len(lit) <= end - start +open(f"{tmp}/enc_esc_lit.pdf", "wb").write(raw[:start] + lit + b" " * (end - start - len(lit)) + raw[end:]) +assert len(pikepdf.open(f"{tmp}/enc_esc_lit.pdf").Root.AcroForm.Fields) > 0 +mu = re.search(rb'/U\s*<([0-9a-fA-F]+)>', raw) +flip = b"1" if raw[mu.start(1)] == ord("0") else b"0" +open(f"{tmp}/enc_bad_u.pdf", "wb").write(raw[:mu.start(1)] + flip + raw[mu.start(1) + 1:]) +pdf.save(f"{tmp}/enc_userpw.pdf", encryption=pikepdf.Encryption(owner="o", user="secret", R=4, aes=True)) +PY + json_names() { python3 -c 'import json,sys; print(" ".join(f["name"] for f in json.load(sys.stdin)["fields"]))' 2>/dev/null; } + plain_names=$($BIN fields docs/example-form.pdf 2>/dev/null | json_names) + esc_names=$($BIN fields "$TMP/enc_esc_lit.pdf" 2>/dev/null | json_names) + [ -n "$plain_names" ] && [ "$esc_names" = "$plain_names" ] \ + && pass "escaped literal /O: same file key as hex, fields decrypt" \ + || fail "escaped literal /O: wrong file key (fields: '$esc_names')" + for v in bad_u userpw; do + $BIN fields "$TMP/enc_$v.pdf" > "$TMP/enc_$v.out" 2> "$TMP/enc_$v.err"; ec=$? + [ "$ec" -eq 1 ] && [ ! -s "$TMP/enc_$v.out" ] && grep -q 'cannot be decrypted' "$TMP/enc_$v.err" \ + && pass "$v: fields fails loudly (exit 1, reason on stderr, no stdout)" \ + || fail "$v: fields exit=$ec, stdout $(wc -c < "$TMP/enc_$v.out") bytes" + done + echo '{"FullName": "X"}' > "$TMP/enc_pw.json" + $BIN fill -o "$TMP/enc_userpw_filled.pdf" "$TMP/enc_userpw.pdf" "$TMP/enc_pw.json" 2>/dev/null; ec=$? + [ "$ec" -eq 1 ] && [ ! -e "$TMP/enc_userpw_filled.pdf" ] \ + && pass "userpw: fill exits 1 and writes no output" \ + || fail "userpw: fill exit=$ec" + for v in rc4 aes1 aes2; do n=$($BIN fdf-extract "$TMP/enc_$v.pdf" 2>/dev/null | grep -c '^/T (') [ "$n" = "$nplain" ] && pass "$v: extracted $n fields (decrypted)" || fail "$v: extracted $n/$nplain fields" diff --git a/test_field_map.c b/test_field_map.c index 98686b2..494f0bc 100644 --- a/test_field_map.c +++ b/test_field_map.c @@ -235,6 +235,64 @@ static void test_crypto(void) { } } +static void test_pdf_literal_bytes(void) { + unsigned char b[64]; + size_t n; + const char *rest; + + // Named escapes decode to control bytes, not to the escaped letter. + rest = pdf_literal_bytes("(a\\nb\\r\\t\\b\\f)X", b, sizeof(b), &n); + CHECK(n == 7 && memcmp(b, "a\nb\r\t\b\f", 7) == 0 && *rest == 'X'); + + // Octal: one to three digits, ending at a non-octal character; overflow + // keeps the low byte (\400 -> 0). + pdf_literal_bytes("(\\0\\053\\1234\\400)", b, sizeof(b), &n); + CHECK(n == 5 && b[0] == 0 && b[1] == 053 && b[2] == 0123 && b[3] == '4' && b[4] == 0); + + // \( \) \\ and unknown escapes stand for the character; parentheses nest. + pdf_literal_bytes("(\\(\\)\\\\\\q(x))", b, sizeof(b), &n); + CHECK(n == 7 && memcmp(b, "()\\q(x)", 7) == 0); + + // Backslash-EOL continues the line (no byte); a bare CR or CRLF reads as LF. + pdf_literal_bytes("(ab\\\r\ncd\\\nef\r\ng\rh)", b, sizeof(b), &n); + CHECK(n == 10 && memcmp(b, "abcdef\ng\nh", 10) == 0); + + // Output stops at `cap`, but the whole string is still consumed. + rest = pdf_literal_bytes("(abcdef)Z", b, 3, &n); + CHECK(n == 3 && memcmp(b, "abc", 3) == 0 && *rest == 'Z'); +} + +// pdf_crypt_init on a real R3 (RC4-128, empty user password) /Encrypt dict +// written by qpdf, which reports the file key 71c7db54... Its /O contains LF, +// CR, ')' and high bytes, so a literal spelling needs named and octal escapes. +static void test_crypt_init_r3(void) { + static const unsigned char id0[16] = { + 0x31,0x41,0x59,0x26,0x53,0x58,0x97,0x93,0x23,0x84,0x62,0x64,0x33,0x83,0x27,0x95 }; + #define ENC_HEAD "<" + #define ENC_U_HEX "/U " + PdfCrypt c, c2; + CHECK(pdf_crypt_init(&c, ENC_HEAD ENC_O_HEX ENC_U_HEX ">>", id0, sizeof(id0)) == 1); + CHECK(c.key_len == 16 && hexeq(c.key, 16, "71c7db54f327dfcb809099ee9b59183f")); + + // The same /O as an escaped literal string derives the same key (issue #14). + const char *lit = ENC_HEAD + "/O (\\f'\\314\\260\\324\\030\\)\\244,\\b\\nD\\261\\301O2\\335\\t\\rt\\231\\026" + "\\030\\252\\032t}a\\320\\313:\\220)" ENC_U_HEX ">>"; + CHECK(pdf_crypt_init(&c2, lit, id0, sizeof(id0)) == 1); + CHECK(memcmp(c2.key, c.key, 16) == 0); + + // A key that does not reproduce /U is rejected: a corrupted /U (as for a + // document with a user password), or the wrong /ID. + CHECK(pdf_crypt_init(&c2, ENC_HEAD ENC_O_HEX + "/U >>", + id0, sizeof(id0)) == 0); + CHECK(pdf_crypt_init(&c2, ENC_HEAD ENC_O_HEX ENC_U_HEX ">>", id0, sizeof(id0) - 1) == 0); + #undef ENC_HEAD + #undef ENC_O_HEX + #undef ENC_U_HEX +} + static void test_lzw(void) { // The canonical PDF-spec (ISO 32000, 7.4.4.2) LZWDecode example: the string // "-----A---B" encodes to 80 0B 60 50 22 0C 0C 85 01. @@ -257,6 +315,8 @@ static void test_lzw(void) { int main(void) { test_find_key(); test_crypto(); + test_pdf_literal_bytes(); + test_crypt_init_r3(); test_lzw(); test_match_dict_end(); test_extract_dict_inner(); From 97e3c668bb9b18fc7b267f047e3476d8b4f348e4 Mon Sep 17 00:00:00 2001 From: Page Brooks Date: Mon, 14 Sep 2026 12:35:33 -0700 Subject: [PATCH 2/2] Fix encrypted e2e fixtures under pikepdf 10 --- test_e2e.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test_e2e.sh b/test_e2e.sh index 1137af9..20994e8 100755 --- a/test_e2e.sh +++ b/test_e2e.sh @@ -1069,7 +1069,7 @@ PY # derive the same file key as its hex spelling (issue #14: escapes were read # as the escaped letter, so every object-stream field vanished, exit 0). A # key that fails /U authentication must be an error, not an empty list. - python3 - docs/example-form.pdf "$TMP" <<'PY' + python3 - docs/example-form.pdf "$TMP" <<'PY' || fail "escaped /O, bad /U, user password: fixture generation failed" import io, re, sys, pikepdf src, tmp = sys.argv[1], sys.argv[2] NAMED = {0x0a: b'\\n', 0x0d: b'\\r', 0x09: b'\\t', 0x08: b'\\b', 0x0c: b'\\f'} @@ -1098,7 +1098,8 @@ start, end = m.start(1) - 1, m.end() lit = literal(O) assert len(lit) <= end - start open(f"{tmp}/enc_esc_lit.pdf", "wb").write(raw[:start] + lit + b" " * (end - start - len(lit)) + raw[end:]) -assert len(pikepdf.open(f"{tmp}/enc_esc_lit.pdf").Root.AcroForm.Fields) > 0 +with pikepdf.open(f"{tmp}/enc_esc_lit.pdf") as chk: # hold a reference: newer + assert len(chk.Root.AcroForm.Fields) > 0 # pikepdf frees temporaries mu = re.search(rb'/U\s*<([0-9a-fA-F]+)>', raw) flip = b"1" if raw[mu.start(1)] == ord("0") else b"0" open(f"{tmp}/enc_bad_u.pdf", "wb").write(raw[:mu.start(1)] + flip + raw[mu.start(1) + 1:]) @@ -1117,9 +1118,9 @@ PY || fail "$v: fields exit=$ec, stdout $(wc -c < "$TMP/enc_$v.out") bytes" done echo '{"FullName": "X"}' > "$TMP/enc_pw.json" - $BIN fill -o "$TMP/enc_userpw_filled.pdf" "$TMP/enc_userpw.pdf" "$TMP/enc_pw.json" 2>/dev/null; ec=$? - [ "$ec" -eq 1 ] && [ ! -e "$TMP/enc_userpw_filled.pdf" ] \ - && pass "userpw: fill exits 1 and writes no output" \ + $BIN fill -o "$TMP/enc_userpw_filled.pdf" "$TMP/enc_userpw.pdf" "$TMP/enc_pw.json" 2> "$TMP/enc_userpw_fill.err"; ec=$? + [ "$ec" -eq 1 ] && [ ! -e "$TMP/enc_userpw_filled.pdf" ] && grep -q 'cannot be decrypted' "$TMP/enc_userpw_fill.err" \ + && pass "userpw: fill exits 1, reason on stderr, no output" \ || fail "userpw: fill exit=$ec" for v in rc4 aes1 aes2; do