Skip to content

v2.1.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 07:22
· 30 commits to main since this release
ae5112d

Breaking changes

  • A password-protected document now reaches PdfPreflight as PdfPasswordException, which no
    existing catch covers.
    Every prior version threw UnsupportedPdfFeatureException, and so
    NotSupportedException, for any /Encrypt at all, so that is what a caller of
    PdfPreflight.Validate or PdfPreflight.DetectClaimedProfiles wrote to detect an encrypted file.
    Both are Stable API in a Stable package, and both now open an encrypted document whose empty user
    password suffices, and throw PdfPasswordException for one that needs a non-empty password. That
    exception derives from Exception directly, and deliberately so: a document the reader
    understands but was not given the credentials for is not an unsupported feature. An existing
    catch (NotSupportedException) around either method therefore lets it through. Catch
    PdfPasswordException beside it. (#97)

  • PdfDocument.DocumentId now throws ArgumentException for a value that is not 16 bytes.
    Previously any other length was accepted and then written as no /ID at all — silently. ISO
    32000-2 Table 15 requires /ID once /Encrypt is present, so on an encrypted document that
    produced a file qpdf rejects outright ("invalid /ID in trailer dictionary"), with nothing to tell
    the caller which value caused it. On an unencrypted document the old behaviour merely omitted an
    optional entry, so code that set a wrong-length id and relied on that omission now sees an
    exception. DocumentId is Stable API, which is why this is recorded here rather than under
    Fixed. (#97)

Added

  • A committed corpus of encrypted PDFs, one per standard-security-handler /V+/R combination.
    Generated once with qpdf and committed rather than shelled out for at test time, so the corpus is
    byte-identical on CI and locally and leaves no silently-skipped gate. A guard test pins each fixture
    by SHA-256 as well as /V, /R and /CFM: qpdf refuses to write RC4 without --allow-weak-crypto
    and still leaves a zero-byte file behind, and two fixtures are both /V 4 /R 4, differing only in
    the cipher — neither an existence check nor a /V+/R check would notice either. Groundwork for
    the decrypt side. (#99)

  • Hand-written RC4 and MD5 primitives for the legacy (/V 1–2) decryption path. Internal only —
    no public surface yet, no reader wiring, no security handler; that is a separate change. The BCL
    has never shipped RC4, and its MD5 type defers to the OS crypto library everywhere except Browser
    WASM, where MD5 is unsupported outright, so decrypting an old PDF under Blazor WASM would otherwise
    be a dead end. PdfDocument's /ID generation (ISO 32000-2 §14.4) now goes through this MD5 too,
    in place of the BCL call it used before, so the codebase is actually clear of CA5351 (flags MD5 as
    weak) rather than clear of it only on the new, not-yet-wired decryption path — and Browser WASM
    document writing, not just decryption, no longer depends on a platform MD5 that isn't there. RC4 is
    verified against all three vectors in draft-kaukonen-cipher-arcfour-03 Appendix A, including the
    309-byte vector that runs the keystream past its first 256-byte cycle; MD5 against the full RFC
    1321 §A.5 suite plus a length sweep across the padding and block boundaries, and a differential
    sweep against the BCL. /ID itself is pinned by a known-answer test, because nothing else
    pinned it: every golden document sets its own id, and the computed one folds in a millisecond
    timestamp, so no snapshot could cover it. Groundwork for the decrypt side. (#97)

  • The decrypt side of the Standard security handler, covering every /V+/R combination the
    committed corpus (#99) exercises: /V 1//R 2 (RC4-40) through /V 5//R 6 (AES-256).

    Internal only: no public surface, no PdfReader wiring, no /Encrypt gate; a decrypting reader
    is separate work. Covers Algorithm 2 (file key from a password), Algorithms 4/5 and 7 (verifying
    a user or owner password), Algorithm 2.A and the R6 permission check for /V 5, and the
    per-object key that folds in the object's generation number, which is why this had to wait for
    #121. Verified against every corpus fixture: deriving the file key from both the correct and a
    wrong password for each one, and decrypting a real content stream to the exact bytes qpdf's own
    encryption produced — checked against an external tool's output, not only internal consistency.
    The /EncryptMetadata false fixtures pin that Algorithm 2 step (f) shifts the derived key, not
    just /U. The empty user password most encrypted PDFs actually use had no fixture when this
    landed and was covered by independently computed vectors; enc-aes-128-emptyuser.pdf, added with
    the reader wiring below, covers it end to end. (#97)

  • Decryption on read: PdfReader.Open takes a password and reads encrypted PDFs. The Standard
    security handler at /V 1, 2, 4 and 5 and /R 2 through 6 — RC4-40 through RC4-128, AES-128
    (/AESV2) and AES-256 (/AESV3) — plus the /Crypt filter (ISO 32000-2 §7.4.10) and crypt
    filters naming different methods for strings and streams. Strings are decrypted in Resolve under
    the identity of the indirect object containing them (ISO 32000-1 §7.6.2, Algorithm 1) and stream
    bodies on the decode path; ParsedStream.RawBody still holds the verbatim file bytes, which
    StreamRule and HexStringRule need for byte offsets and lengths.

    The supplied password is tried as the owner password first and the user password second, so one
    that satisfies both reports the higher-privilege access. A wrong one throws the new
    PdfPasswordException. PdfDocumentReader.Encryption reports /V, /R, the stream and string
    ciphers, key length, permissions, /EncryptMetadata and which password authenticated — no key
    material, and no /O, /U, /OE or /UE.

    What is left in the clear, per the spec: the trailer /ID, the /Encrypt dictionary's own
    strings, cross-reference streams (§7.5.8.2, body and dictionary alike), streams whose data lives
    in an external file (§7.6.1), the document's metadata stream under /EncryptMetadata false
    (Table 21 — a page's or an XObject's metadata is not exempt), and a signature dictionary's
    /Contents, which ISO 32000-1 leaves unstated: a signer patches those hex digits into
    already-serialized bytes, so decrypting them would corrupt /ByteRange verification. That last
    exemption covers /Type /Sig, /Type /DocTimeStamp, and a /Type-less dictionary carrying a
    /ByteRange array with a string /Contents, since Table 252 makes /Type optional. qpdf agrees
    on the shape that matters most: it leaves a /Type /Sig dictionary's /Contents byte-identical
    while encrypting that same dictionary's /Reason, /Location and /M, and does so whether or not
    the dictionary is reachable from a signature field. Its rule keys on /Type /Sig alone, so it does
    encrypt /Contents on the other two shapes exempted here — meaning a document qpdf encrypted after
    signing can still hand back an archive timestamp's ciphertext. This exemption is the reading that
    cannot corrupt a signature, not a claim about what every producer does.

    At /R 5 and 6 the permissions come from /Perms (ISO 32000-2 Algorithm 13), the copy sealed
    under the file key, rather than the dictionary's /P, which nothing protects at those revisions.
    Only where the document carries a /Perms that recovers, though: Table 21 does not require the
    entry, so deleting it — or corrupting one byte of it, which fails Algorithm 13's marker check —
    falls back to /P and reports whatever an editor wrote there. qpdf, poppler and pdfium all behave
    the same way, and refusing the file over an optional entry would make this the only reader that
    cannot open it. PdfEncryptionInfo.Permissions documents the distinction.

    Verified against the committed corpus (#99): for the eleven rows built from the baseline with the
    u/o password pair, the page content decrypts to the baseline's bytes, /Info /Title to its
    exact expected text, and each opens under both passwords. The other rows take their own passwords
    or are not the baseline's object graph, and their own tests say what each pins. Nine fixtures were
    added for this work, covering an empty user password, an object stream with a cross-reference
    stream, nested strings, a 40-character password, one password serving as both roles, a non-ASCII
    password whose /U is PDFDocEncoding-derived, an incremental update over an encrypted document,
    a linearized document, and one combining linearization, object streams and cleartext metadata.
    (#97)

  • A committed corpus of PDFs not produced by VellumPdf's own writer. Test-only; nothing ships.
    Every reader fixture before this one came from VellumPdf's writer, which only ever emits
    generation 0 and never a hybrid-reference file or another producer's object-stream layout — the
    #121 review found three defects that shared exactly that root cause. Sourced from qpdf and
    poppler where a tool can produce the shape, hand-built where none can: qpdf recomputes /Length
    on every write, so it cannot produce a /Length-mismatched file, and separately its own
    documentation states "We do not support creation of hybrid files." Covers object streams,
    cross-reference streams, linearization, a poppler-produced incremental update, a
    nonzero-generation catalog surviving both a read and a poppler-appended revision, a freed object
    number reused at a bumped generation, and three damaged-file shapes (a truncated tail, an
    out-of-range startxref, and a /Length that disagrees with the real stream body). One fixture
    pins ISO 32000-2 §7.5.8.4's "hidden object" convention; qpdf is the independent oracle for the
    hidden object itself. A second, related fixture puts the same free-then-redefine shape in a
    single revision, a shape §7.5.8.4's normative sentence doesn't cover — tracked as an open erratum
    in pdf-association/pdf-issues#237, whose discussion so far favours a reading VellumPdf
    deliberately differs from (#206) — documented as pinning VellumPdf's current behavior on a
    contested construct, not a conformance claim. Mutation testing found every mutation the corpus
    could catch also broke a pre-existing synthetic test: it closes a dialect-confidence gap in the
    reader's coverage, not a gap in its logic. (#196)

Changed

  • PdfReader.Open no longer rejects an encrypted document out of hand. Every prior version threw
    UnsupportedPdfFeatureException on /Encrypt; it now reads the document, and the cases that
    remain unsupported are narrower: /Filter /Adobe.PubSec, /V 3 (whose algorithm ISO 32000-1
    Table 20 leaves unpublished), and a /StrF naming a crypt filter method this library does not
    implement, all at Open. An unresolvable /StmF fails later, at the first decode, because a
    document whose streams cannot be decrypted still has readable strings.

    A file that needs a non-empty password throws PdfPasswordException, which no catch written
    against the old behaviour covers. See Breaking changes above.

    PdfDocumentReader.Dispose clears the file encryption key, where it used to do nothing, so a
    disposed reader is now unusable: resolving an object on one throws ObjectDisposedException
    rather than decrypting against a zeroed key.

    vellum-preflight reports a password-protected file as an error line rather than crashing, and
    PdfPreflight.Validate reports a document whose streams cannot be decoded as unevaluable instead
    of failing it against whichever clauses its rules happened to be checking. ISO 19005-2 §6.1.3
    forbids /Encrypt, which the reader used to enforce by refusing to open such files at all;
    FileTrailerRule checks it now. (#97)

  • Dependency versions across the board, none of which change what ships. PublicApiAnalyzers
    moves to 5.6.0, Microsoft.NET.Test.Sdk to 18.9.0, Verify.XunitV3 to 31.28.0, CsCheck to 4.8.0,
    coverlet.collector to 10.0.1, and Microsoft.SourceLink.GitHub to 10.0.400. Every one is build- or
    test-time only. System.Security.Cryptography.Pkcs, the single third-party runtime dependency this
    repository ships, was already current at 10.0.11.

    SourceLink looks like the exception and is not. The SDK has imported it implicitly since .NET 8 and
    steps aside only when GeneratePathProperty is set, which this repository does not set — so
    packing at 10.0.400, at 8.0.0, and with the reference deleted yields byte-identical symbols and
    package metadata. Source stepping works, but not because of this version (#202).

    xunit stays on 3.x. 4.0.0 moves to Microsoft.Testing.Platform, which the .NET 10 SDK will not run
    through the VSTest target, so it needs its own migration (#200).

  • PdfIndirectReference and PdfIndirectObject honour generation, which changes four members
    on surfaces Stable/Shipped since 2.0.0.
    PdfIndirectReference.WriteTo now emits the real
    generation instead of a hardcoded 0; Equals narrowed, so new PdfIndirectReference(5) no
    longer equals a parsed 5 1 R — a genuine break for anything keying a collection on this type;
    GetHashCode returns different (now deterministic, unlike HashCode.Combine's per-process
    salt) values than in 2.0.0; and PdfIndirectObject.Reference returns new(ObjectNumber, Generation) rather than new(ObjectNumber), unobservable unless the object was built through
    the new three-argument constructor. See Fixed, below, for why. (#121)

  • Encrypted documents now emit different /P and /Perms bytes. Two reserved bits that
    ISO 32000-2 Table 22 requires set for R >= 3 were always emitted as 0; they are forced on now,
    so a byte-for-byte diff against a document encrypted with an earlier version will show this on
    every encrypted output. Permissions actually granted are unaffected. See Fixed, below, for why.
    (#189)

  • EncryptMetadata = false now genuinely leaves the metadata stream unencrypted. If you
    already set this to false, upgrading changes what your output exposes: the whole XMP packet
    becomes readable without the password — dc:title, dc:creator, dc:description,
    dc:language, xmp:CreatorTool, pdf:Producer, and the creation and modification dates.
    See Fixed, below, for why. (#182)

Fixed

  • PDFDocEncoding treated 0xA0 as Latin-1 does. That byte is EURO SIGN in Annex D, not NO-BREAK
    SPACE, so a password containing could not be encoded at all while one containing U+00A0 was
    encoded as a Euro sign. Both are now right: reaches 0xA0, and U+00A0 has no representation, so
    a candidate containing it is dropped rather than silently altered. The code points Annex D marks
    Undefined — 0x7F, 0x9F, 0xAD and twenty-one more — keep encoding as themselves: this encoding
    exists to reproduce the bytes a producer hashed, and dropping a candidate over one of them would
    stop a correct password from opening its document. (#97)

  • The clean-room check now scans commit messages, not only files. CLAUDE.md forbids a
    disallowed reference library's name anywhere in the tree, commit messages included, but the gate
    only ever read working-tree files — so a message naming one passed CI and merged into public
    history, where it cannot be corrected without rewriting it. CI checks out full history for this;
    the check skips silently where no base ref resolves, since it is a second line of defence over the
    file scan and a shallow checkout is not a finding. (#97)

  • An /Encrypt dictionary could declare unboundedly many crypt filters. Everything the handler
    reads out of that dictionary runs before the password is checked, and dictionary lookup is a
    linear scan — so copying an /CF with sixteen thousand entries cost about 1.4 s on a 520 KB
    file where eight thousand cost about 0.45 s, and the gap widens with the square. A conforming
    document names one or two; more than 64 is now refused. SECURITY.md says what remains true
    rather than claiming more: parsing a dictionary with very many keys is quadratic whether or not
    the file is encrypted, and bounding input size is the caller's job. (#97)

  • A document written without an owner password would have opened to anyone. The handler falls
    back to the user password when no owner password is given, as PdfEncryptionSettings.OwnerPassword
    documents — but nothing depended on that fallback, so a one-token edit removing it passed every
    test in the solution while deriving /O from the empty string. Every such file would then have
    opened at owner privilege for a caller supplying nothing. The clause is now pinned. (#97)

  • vellum-preflight reported nothing at all for two kinds of file. An encrypted document whose
    /StmF names a crypt filter its own /CF does not define, and a file that is not a PDF, both
    exited 2 with an empty stderr on the default invocation, while the same files named their problem
    precisely when a profile was given with -p. Profile auto-detection opens the document before the
    validation loop does, and only the loop had the diagnosis. (#97)

  • An object referenced from inside /Encrypt came back as ciphertext, silently. Authentication
    runs before a decryptor exists — which is what keeps /O, /U, /OE and /UE out of string
    decryption — and §7.6.1 lets every non-string entry of that dictionary be an indirect reference.
    Following one cached its target undecrypted, so a document whose /Encrypt pointed at an object
    it also used handed that object's strings back as ciphertext to everything that read it
    afterwards, with no exception and nothing to distinguish it from a decrypted value. The cache is
    now dropped once the decryptor exists, keeping only the encryption dictionary itself. (#97)

  • vellum-preflight crashed on a public-key-encrypted file given with no arguments. Profile
    auto-detection opens the document before the validation loop's own handler is reached, so an
    unsupported security handler escaped as an unhandled exception where the password case beside it
    had already been fixed. (#97)

  • /Encrypt /Filter was the one entry read before indirect values were resolved. An indirect
    one was reported as a handler named /(missing) and the document refused, though §7.6.1 requires
    only the encryption dictionary's strings to be direct. (#97)

  • An indirect /CFM or crypt-filter /Length was not resolved. §7.6.1 requires only the
    encryption dictionary's STRINGS to be direct objects, so either may be an indirect reference. The
    dereferenced copy the handler works on covers /CF and its per-filter dictionaries but stops one
    level short of their values. A /CFM that reads as missing is indistinguishable from one naming a
    cipher this handler does not implement, which fails hard on the first stream after the document has
    already opened; an unresolved crypt-filter /Length silently disables both the cipher-implied key
    size and the per-cipher clamps, which derives the wrong key and reports the correct password as
    wrong. (#97)

  • An encrypted document whose trailer /ID was absent or empty would not open. Algorithm 2
    step (e) appends /ID[0] to the MD5 input, and appending nothing is well defined — the producer
    that omitted the entry hashed the same bytes the reader now does, so the derivation lands on its
    key. Table 15 does require /ID alongside /Encrypt, but qpdf and poppler both open such a file,
    and refusing it made a document readable everywhere except here. (#97)

  • A stream whose declared /Length landed on ), {, } or a lone > failed the parse. The
    parser recovers from a wrong /Length by scanning for endstream, but the token read that
    detects the mismatch threw on those bytes instead of falling through to the scan. Encryption makes
    it ordinary rather than exotic: ciphertext is high-entropy, so a stale length lands on one of them
    a few percent of the time. (#97)

  • A reference's generation number is honoured instead of discarded. PdfIndirectReference
    carried only an object number, and the parser dropped a parsed N G R's middle field too, so
    every reference read from a document looked like generation 0. With an xref table keyed on
    object number alone, 10 2 R resolved to whatever object 10 held at generation 0 instead of
    nothing, and a document with a legitimately nonzero generation anywhere — including its own
    /Root — either resolved the wrong object or failed to open. (#121)

  • A rewritten object at a nonzero generation now round-trips through AppendRevision. The
    incremental-update writer hardcoded every re-emitted object, including the catalog, to
    generation 0. Long-term-validation and archive-timestamp signing both rewrite the catalog, so
    a base document whose catalog sat at a nonzero generation got a trailer /Root that disagreed
    with the object header and xref entry next to it and failed to reopen; rewriting any other
    object at the wrong generation failed the same way but silently, resolving to nothing with no
    exception. PdfIndirectObject gains a matching Generation and a three-argument constructor
    to carry the real value through. (#121)

  • A freed object number no longer resurfaces from an older revision. Classic-table f
    entries and xref-stream type-0 rows were discarded instead of recorded, so an object deleted
    in the newest revision could still resolve from a stale entry in an older one. Both are now
    tracked, scoped per revision so a hybrid file's /XRefStm still resolves an object that an
    earlier section marks free. That pairing is how such a file hides an object from a
    classic-table-only reader: the older section's free entry is what a PDF 1.4 consumer finds,
    while a PDF 1.5 consumer takes the cross-reference stream's entry and ignores it. (#121)

  • A malformed generation field no longer takes down the document, or aliases onto the wrong
    object.
    A sloppy but unambiguous field (space-padded rather than zero-padded) still parses.
    One that is genuinely unparseable, or exceeds the ISO 32000-2 §7.5.4 ceiling of 65535, is
    recorded as unknown rather than guessed at 0, so the object's header takes over instead of the
    object going unresolvable at every generation. A reference whose own token is unparseable,
    negative, or exceeds 65535 no longer aborts the document either; it simply matches no real
    xref entry, the same outcome an ordinary mismatch already produces. (#121)

  • An /Encrypt entry present only in a hybrid file's XRefStm dictionary is no longer
    missed.
    The classic-trailer check alone can't see it. ISO 32000-2 §7.5.8.4 permits a
    hybrid-reference producer to put /Encrypt on the XRefStm dictionary instead, so such a file
    parsed as if it were plain — producing garbage rather than UnsupportedPdfFeatureException.
    (#183)

  • A stream body that happens to contain the literal bytes endstream no longer truncates
    there.
    The endstream scan took the first occurrence with no check at all, so a binary stream
    (an embedded font subset, a compressed image) that contained those nine bytes lost everything
    past them, silently. It now prefers a candidate whose following bytes look like endobj or the
    next object's header, checked independently of whether an EOL precedes the marker — requiring
    the EOL first sent an earlier version of this fix past a real but non-conformant terminator into
    a later object's, silently absorbing everything in between. Falls back to an EOL-preceded match,
    then to the first literal occurrence, so it can never do worse than the naive scan it replaces.
    Bounded per stream so a file with many such streams can't turn recovery into quadratic work.
    (#105)

  • A startxref more than 2048 bytes from EOF is found again. The backward search window was
    too tight for a file padded after %%EOF (some producers reserve a byte-range window for a
    signature added later); it's now 1 MiB. The search itself now scans backward from EOF too, so
    its cost tracks how far back the marker actually is instead of paying for the full window on
    every open. (#105)

  • /P bits 7-8 are now set, as ISO 32000-2 Table 22 requires for R >= 3. PdfPermissions
    has no flag at 1 << 6 / 1 << 7 — the enum goes straight from Annotate to FillForms
    so those two reserved bits were always emitted as 0 regardless of the comment above the code
    claiming otherwise. This changes the /P and /Perms bytes emitted for every encrypted
    document
    (/Perms wraps /P, so it moves too); a diff against a document encrypted with an
    earlier version will show this. Permissions actually granted are unaffected. (#189)

  • Encrypting a PDF/UA-1 document no longer fails with a PDF/A error. The Save() guard
    tested Conformance != PdfConformance.None, so PDF/UA-1 (ISO 14289-1, which has no rule
    against encryption) was rejected under a message that named ISO 19005-2 §6.3.1, a clause it
    isn't subject to. PDF/UA-1 now has its own check instead: encrypting with permissions that
    omit content extraction (PdfPermissions.Extract) is rejected, because ISO 14289-1 §7.16
    requires that assistive technology be able to extract content, and Save() would otherwise
    emit a document that fails its own declared conformance by construction. (#188)

  • /EncryptMetadata false now actually exempts the metadata stream. The flag was written
    into the /Encrypt dictionary and the /Perms block, but nothing stopped the metadata
    stream's own body from being encrypted anyway, contradicting ISO 32000-2 §7.6.2 and the flag
    sitting right next to it. If you already set this to false, upgrading changes what your
    output exposes
    : the whole XMP packet is now genuinely cleartext — dc:title, dc:creator,
    dc:description, dc:language, xmp:CreatorTool, pdf:Producer, and the creation and
    modification dates — where the bug previously encrypted it despite the flag. Leave it at the
    default true unless that exposure is a requirement you've weighed. (#182)