v2.1.0
Breaking changes
-
A password-protected document now reaches
PdfPreflightasPdfPasswordException, which no
existingcatchcovers. Every prior version threwUnsupportedPdfFeatureException, and so
NotSupportedException, for any/Encryptat all, so that is what a caller of
PdfPreflight.ValidateorPdfPreflight.DetectClaimedProfileswrote 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 throwPdfPasswordExceptionfor one that needs a non-empty password. That
exception derives fromExceptiondirectly, 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
PdfPasswordExceptionbeside it. (#97) -
PdfDocument.DocumentIdnow throwsArgumentExceptionfor a value that is not 16 bytes.
Previously any other length was accepted and then written as no/IDat all — silently. ISO
32000-2 Table 15 requires/IDonce/Encryptis 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.DocumentIdis 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+/Rcombination.
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,/Rand/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+/Rcheck would notice either. Groundwork for
the decrypt side. (#99) -
Hand-written RC4 and MD5 primitives for the legacy (
/V1–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 itsMD5type 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/IDgeneration (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./IDitself 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+/Rcombination the
committed corpus (#99) exercises:/V1//R2 (RC4-40) through/V5//R6 (AES-256).
Internal only: no public surface, noPdfReaderwiring, no/Encryptgate; 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/V5, 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 falsefixtures 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.Opentakes a password and reads encrypted PDFs. The Standard
security handler at/V1, 2, 4 and 5 and/R2 through 6 — RC4-40 through RC4-128, AES-128
(/AESV2) and AES-256 (/AESV3) — plus the/Cryptfilter (ISO 32000-2 §7.4.10) and crypt
filters naming different methods for strings and streams. Strings are decrypted inResolveunder
the identity of the indirect object containing them (ISO 32000-1 §7.6.2, Algorithm 1) and stream
bodies on the decode path;ParsedStream.RawBodystill holds the verbatim file bytes, which
StreamRuleandHexStringRuleneed 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.Encryptionreports/V,/R, the stream and string
ciphers, key length, permissions,/EncryptMetadataand which password authenticated — no key
material, and no/O,/U,/OEor/UE.What is left in the clear, per the spec: the trailer
/ID, the/Encryptdictionary'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/ByteRangeverification. That last
exemption covers/Type /Sig,/Type /DocTimeStamp, and a/Type-less dictionary carrying a
/ByteRangearray with a string/Contents, since Table 252 makes/Typeoptional. qpdf agrees
on the shape that matters most: it leaves a/Type /Sigdictionary's/Contentsbyte-identical
while encrypting that same dictionary's/Reason,/Locationand/M, and does so whether or not
the dictionary is reachable from a signature field. Its rule keys on/Type /Sigalone, so it does
encrypt/Contentson 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
/R5 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/Permsthat 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/Pand 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.Permissionsdocuments the distinction.Verified against the committed corpus (#99): for the eleven rows built from the baseline with the
u/opassword pair, the page content decrypts to the baseline's bytes,/Info /Titleto 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/Uis 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-rangestartxref, and a/Lengththat 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.Openno longer rejects an encrypted document out of hand. Every prior version threw
UnsupportedPdfFeatureExceptionon/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/StrFnaming a crypt filter method this library does not
implement, all atOpen. An unresolvable/StmFfails 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 nocatchwritten
against the old behaviour covers. See Breaking changes above.PdfDocumentReader.Disposeclears the file encryption key, where it used to do nothing, so a
disposed reader is now unusable: resolving an object on one throwsObjectDisposedException
rather than decrypting against a zeroed key.vellum-preflightreports a password-protected file as an error line rather than crashing, and
PdfPreflight.Validatereports 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;
FileTrailerRulechecks 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 whenGeneratePathPropertyis 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). -
PdfIndirectReferenceandPdfIndirectObjecthonour generation, which changes four members
on surfaces Stable/Shipped since 2.0.0.PdfIndirectReference.WriteTonow emits the real
generation instead of a hardcoded0;Equalsnarrowed, sonew PdfIndirectReference(5)no
longer equals a parsed5 1 R— a genuine break for anything keying a collection on this type;
GetHashCodereturns different (now deterministic, unlikeHashCode.Combine's per-process
salt) values than in 2.0.0; andPdfIndirectObject.Referencereturnsnew(ObjectNumber, Generation)rather thannew(ObjectNumber), unobservable unless the object was built through
the new three-argument constructor. See Fixed, below, for why. (#121) -
Encrypted documents now emit different
/Pand/Permsbytes. 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 = falsenow 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
/Encryptdictionary 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/CFwith 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.mdsays 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, asPdfEncryptionSettings.OwnerPassword
documents — but nothing depended on that fallback, so a one-token edit removing it passed every
test in the solution while deriving/Ofrom 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-preflightreported nothing at all for two kinds of file. An encrypted document whose
/StmFnames a crypt filter its own/CFdoes 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
/Encryptcame back as ciphertext, silently. Authentication
runs before a decryptor exists — which is what keeps/O,/U,/OEand/UEout 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/Encryptpointed 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-preflightcrashed 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 /Filterwas 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
/CFMor crypt-filter/Lengthwas 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/CFand its per-filter dictionaries but stops one
level short of their values. A/CFMthat 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/Lengthsilently 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
/IDwas 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/IDalongside/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
/Lengthlanded on),{,}or a lone>failed the parse. The
parser recovers from a wrong/Lengthby scanning forendstream, 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 parsedN 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 Rresolved 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/Rootthat 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.PdfIndirectObjectgains a matchingGenerationand 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/XRefStmstill 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
/Encryptentry present only in a hybrid file'sXRefStmdictionary 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/Encrypton the XRefStm dictionary instead, so such a file
parsed as if it were plain — producing garbage rather thanUnsupportedPdfFeatureException.
(#183) -
A stream body that happens to contain the literal bytes
endstreamno 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 likeendobjor 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
startxrefmore 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) -
/Pbits 7-8 are now set, as ISO 32000-2 Table 22 requires for R >= 3.PdfPermissions
has no flag at1 << 6/1 << 7— the enum goes straight fromAnnotatetoFillForms—
so those two reserved bits were always emitted as 0 regardless of the comment above the code
claiming otherwise. This changes the/Pand/Permsbytes emitted for every encrypted
document (/Permswraps/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
testedConformance != 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, andSave()would otherwise
emit a document that fails its own declared conformance by construction. (#188) -
/EncryptMetadata falsenow actually exempts the metadata stream. The flag was written
into the/Encryptdictionary and the/Permsblock, 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
defaulttrueunless that exposure is a requirement you've weighed. (#182)