Skip to content

v2.2.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 14:25
· 26 commits to main since this release
27b9344

Breaking changes

  • PdfDictionary.Set, TryGet and Get now throw ArgumentNullException for a null key.
    Previously TryGet(null, out _) returned false, Get(null) returned null, and
    Set(null, value) appended an entry that only failed later — with a NullReferenceException out
    of WriteTo, once the dictionary was serialised. All three are Stable API, which is why this is
    recorded here rather than under Security, where the rest of this fix lives: without the guard, a
    null key would behave differently depending on which side of the internal indexing threshold a
    dictionary sits — returning false below it, throwing above it — exactly the property that
    threshold is supposed to be free to move without changing what callers observe. (#208)

Security

  • OwnerPassword = "" beside a real UserPassword produced a file with no real password
    protection at all, in every release from v1.0.0 through v2.1.0.
    ?? treats an empty string as
    a value, not as "unset", so that combination sealed /O and /OE (ISO 32000-2 §7.6.4.4.6,
    Algorithm 9) under the empty password instead of falling back to the user password. At /R 6 an
    empty password fails the /U check and satisfies the /O check, so any conforming reader lands
    on owner access when no password is supplied. That is a property of the file itself, not of the
    order a particular reader tries /U and /O in. Verified concretely: a file written by v2.1.0
    with UserPassword = "hunter2", OwnerPassword = "" opens with no password supplied at all,
    and the catalog, hence the whole object graph, decrypts. The document's confidentiality is gone,
    not merely its permission flags: nothing enforced Permissions, but nothing enforced
    UserPassword either. OwnerPassword = "" beside a non-empty UserPassword now throws instead
    of producing a file. Both passwords empty is unchanged, since an unprotected document is
    legitimate and ISO 32000-2 permits an empty owner password. OwnerPassword = null is unchanged
    too: that is the documented fallback to the user password as owner, and it does not reproduce
    this defect (/O is sealed under the real user password, not the empty string) — though anyone
    who can open the document still holds owner access under it, so Permissions still binds nobody.
    The guard sits in StandardSecurityHandler's constructor, which is Stable API and callable
    directly, and in PdfDocument.Encrypt, so the failure surfaces at the call site rather than at
    Save(); VellumPdf.Layout.Document.Encrypt inherits it by delegation. (EncryptionSetup.TryAuthenticate,
    this library's own authentication order, is why our reader reports such a file's access level as
    owner rather than user — that explains what we report, not why the exposure exists.)

    A document already written with this shape cannot be fixed in place: correcting it means
    re-deriving /O and /OE, which needs a real owner password behind them from the start. Affected
    documents must be re-encrypted from the original plaintext, with a distinct OwnerPassword this
    time. (#211)

  • PdfDictionary lookup is no longer quadratic in the key count. Set and TryGet now build a
    hash index once a dictionary passes 16 entries, rather than scanning the whole entry list on every
    call. The /Encrypt dictionary is parsed, and copied again by EncryptionSetup.DereferenceValues,
    before any password is checked, on a file anyone can send, so a hostile document naming tens of
    thousands of keys there previously cost time quadratic in that count with nothing to show for it:
    opening a fixture with an 80,000-key /Encrypt dictionary took about 27 seconds before this fix and
    well under a second after. EncryptionSetup's /CF cap (MaxCryptFilters, still 64) keeps its
    comment but loses the reason it used to give: every term that touches /Encrypt is linear now, so
    the cap no longer bears any of the weight of bounding this cost. It stays because a real document
    names one or two crypt filters, not sixty-four. (#208)