Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
was given, or an empty one was (`--password ""`), and either way it prints "supply it with
--password"; a non-empty `--password` that does not open the file prints "the supplied
--password does not open it". (#138)
- **Encrypted documents written without `PdfPermissions.Extract` emit different `/P` and `/Perms`
bytes.** Bit 10 of `/P` is now always set, so a byte-for-byte diff against the same document
encrypted with an earlier version will show this difference, and the permissions such a
document reports on re-opening now include `Extract`. Documents written with `Extract`
(the default, since `All` includes it) carry the same `/P` value as before. See Fixed, below,
for why. (#397)

### Fixed

- **`/P` bit 10 is now always set on a newly written `/Encrypt` dictionary.** The restriction this
bit expressed is deprecated in PDF 2.0, and ISO 32000-2 Table 22 requires writers to set the bit
regardless of the permissions requested; the Standard security handler previously set it only
when `PdfPermissions.Extract` was included, so any permission set that omitted `Extract`
produced a Table 22 violation and failed PDF/UA-1 §7.16-1. `Permissions = None` now writes
`/P -3392` instead of `-3904`, `Copy` writes `-3376` instead of `-3888`, and `All & ~Extract`
writes `-4` instead of `-516`, the same value as `All`; at `/R` 6 the `/Perms` seal
(Algorithm 10) changes with it, since it seals the same `/P` value. A document written without
`Extract` therefore reads back with `Extract` included in `PdfEncryptionInfo.Permissions`. (#397)

## [2.3.0] - 2026-09-01

Expand Down
11 changes: 8 additions & 3 deletions docs/kernel-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ doc.Save(stream);
|---|---|---|
| `UserPassword` | `string?` | Password required to open the file |
| `OwnerPassword` | `string?` | Null makes `UserPassword` serve as both, so anyone who can open the document holds owner access and `Permissions` restricts nobody — pass a distinct password when the permissions need to bind on someone who knows the user password |
| `Permissions` | `PdfPermissions` | Flags: `Print`, `Modify`, `Copy`, `Annotate`, `FillForms`, `Extract`, `Assemble`, `PrintHighRes`, `All`, `None` |
| `Permissions` | `PdfPermissions` | Flags: `Print`, `Modify`, `Copy`, `Annotate`, `FillForms`, `Extract`, `Assemble`, `PrintHighRes`, `All`, `None`. `Extract` no longer changes the written `/P`: bit 10 is always set since #397 (see the PDF/UA-1 note below) |
| `EncryptMetadata` | `bool` | `false` leaves the whole XMP metadata stream as cleartext even though the rest of the document is encrypted: title, author, subject, language, creator tool, producer, and the creation and modification dates (default `true`) |

**Guard:** an empty `OwnerPassword` beside a non-empty `UserPassword` throws. That combination
Expand All @@ -545,7 +545,11 @@ supplying no password at all — a document-confidentiality failure, not just an
It has its own, narrower guard instead — `PdfPermissions.Extract` must be
included in `Permissions` (the default, `All`, already includes it), because
ISO 14289-1 §7.16 requires that assistive technology can still extract
content from an encrypted, accessible document.
content from an encrypted, accessible document. The written `/P` no longer
depends on that flag (ISO 32000-2 Table 22 has writers always set bit 10, and
the handler does since #397), so the guard checks the caller's declared
intent: omitting `Extract` from `Permissions` on a PDF/UA-1 document says the
opposite of what the profile promises, and the save refuses rather than guessing.

---

Expand Down Expand Up @@ -626,7 +630,8 @@ their `/Resources` dictionary.
**PDF/A and encryption are mutually exclusive.** Attempting both triggers a
guard at save time. **PDF/UA-1 and encryption are not** — but the
`Permissions` set on `Encrypt(...)` must include `PdfPermissions.Extract`, or
the save is rejected instead of silently emitting a non-conformant file.
the save is rejected: the emitted bit would be set either way, so the guard
catches a declared intent that contradicts the PDF/UA-1 claim.

**Standard-14 fonts are not embedded.** For PDF/A or environments where
viewers may not have the built-in fonts installed, use
Expand Down
12 changes: 7 additions & 5 deletions eng/aot/VellumPdf.AotSmoke/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,12 @@
}

// /Perms, decrypted. Reading Permissions off the untouched document proves nothing — the reader
// falls back to the dictionary's /P when the seal fails, and /P says Print too, so the check
// passed even with the /Perms decryption stubbed to return zeroes. Editing /P in the written
// bytes is what separates the two sources: the edit claims everything, and a reader that reads
// the seal still reports print alone.
// falls back to the dictionary's /P when the seal fails, and /P carries the same value, so the
// check passed even with the /Perms decryption stubbed to return zeroes. Editing /P in the
// written bytes is what separates the two sources: the edit claims everything, and a reader
// that reads the seal still reports what was sealed. That is Print plus Extract, not Print
// alone: since #397 the writer sets /P bit 10 whatever the caller asked for (ISO 32000-2
// Table 22 has writers always set it), and the reader reports the sealed bit as Extract.
var text = Encoding.Latin1.GetString(encrypted);
var pAt = text.IndexOf("/P -", StringComparison.Ordinal);
if (pAt < 0)
Expand All @@ -203,7 +205,7 @@

using (var sealedReader = PdfReader.Open(tampered, new PdfReaderOptions { Password = "aot-user" }))
{
if (sealedReader.Encryption!.Permissions != PdfPermissions.Print)
if (sealedReader.Encryption!.Permissions != (PdfPermissions.Print | PdfPermissions.Extract))
{
Console.Error.WriteLine(
$"FAIL: /Perms did not override an edited /P — got {sealedReader.Encryption.Permissions}");
Expand Down
16 changes: 9 additions & 7 deletions src/VellumPdf.Kernel/Document/PdfDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -556,13 +556,15 @@ public void Save(Stream destination)
"Remove Encrypt() or clear Conformance before calling Save().");

// PDF/UA-1 does not prohibit encryption, but it requires that content remain
// extractable for assistive technology (ISO 14289-1 §7.16, carried by the
// /P bit 10 = PdfPermissions.Extract per ISO 32000-2 Table 22). Reject rather
// than silently force the bit on: PdfEncryptionSettings.Permissions defaults to
// All (which already includes Extract), so this only fires when the caller made
// an explicit, narrower permission choice — overriding that choice for them would
// trade one silent defect (unreadable by assistive tech) for another (a permission
// set that doesn't match what was requested).
// extractable for assistive technology (ISO 14289-1 §7.16). The written /P bit 10
// is always 1 regardless of PdfPermissions.Extract (ISO 32000-2 Table 22: the
// restriction it expressed is deprecated in PDF 2.0 and writers shall always set the
// bit), so this guard checks the caller's declared intent, not the emitted bit.
// Omitting Extract from PdfEncryptionSettings.Permissions says "do not allow
// accessibility extraction" even though the bit written no longer records that, so
// reject rather than silently let the mismatch through: Permissions defaults to All
// (which already includes Extract), so this only fires when the caller made an
// explicit, narrower permission choice that contradicts what PDF/UA-1 requires.
if (Conformance == PdfConformance.PdfUA1
&& _encryptionSettings is { } uaEncryptionSettings
&& (uaEncryptionSettings.Permissions & PdfPermissions.Extract) == 0)
Expand Down
7 changes: 6 additions & 1 deletion src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ public sealed class PdfEncryptionInfo
/// </summary>
public int KeyLengthBits { get; }

/// <summary><c>/P</c>, decoded into the individual permission flags it grants.</summary>
/// <summary>
/// <c>/P</c>, decoded into the individual permission flags it grants.
/// <see cref="PdfPermissions.Extract"/> is included whenever bit 10 is set, which every writer
/// following ISO 32000-2 Table 22 does (this library's writer has since #397), so its presence
/// says nothing about the author's intent.
/// </summary>
/// <remarks>
/// At <c>/R</c> 5 and 6 this is the copy sealed inside <c>/Perms</c> under the file key, not the
/// dictionary's <c>/P</c> — the two are inputs to nothing at those revisions, so an editor can
Expand Down
8 changes: 7 additions & 1 deletion src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ public sealed class PdfEncryptionSettings
/// </summary>
public string? OwnerPassword { get; init; }

/// <summary>Access permissions. Defaults to <see cref="PdfPermissions.All"/>.</summary>
/// <summary>
/// Access permissions. Defaults to <see cref="PdfPermissions.All"/>. Omitting
/// <see cref="PdfPermissions.Extract"/> no longer clears a bit in the written <c>/P</c>
/// (ISO 32000-2 Table 22 has writers always set bit 10), but a
/// <see cref="Document.PdfConformance.PdfUA1"/> document still fails the guard on
/// <c>PdfDocument.Save</c>.
/// </summary>
public PdfPermissions Permissions { get; init; } = PdfPermissions.All;

/// <summary>
Expand Down
10 changes: 9 additions & 1 deletion src/VellumPdf.Kernel/Encryption/PdfPermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ public enum PdfPermissions
/// <summary>Fill in existing interactive form fields.</summary>
FillForms = 1 << 8,

/// <summary>Extract text and graphics (disability accessibility support).</summary>
/// <summary>
/// Historical accessibility-extraction bit. The restriction it expressed is deprecated in
/// PDF 2.0 (ISO 32000-2 Table 22): readers shall ignore the bit, and writers shall always set it
/// regardless of what the caller passes here. The written <c>/P</c> bit no longer depends on
/// this flag. It is kept because <see cref="PdfEncryptionInfo.Permissions"/> reports it for
/// any file whose bit 10 is set (at <c>/R</c> 5 and 6, in the <c>/Perms</c> copy; see that
/// property's remarks), including the ones this library writes, and because
/// <c>PdfDocument</c>'s PDF/UA-1 check reads it as the caller's declared intent.
/// </summary>
Extract = 1 << 9,

/// <summary>Assemble the document (insert/delete pages, create bookmarks).</summary>
Expand Down
10 changes: 7 additions & 3 deletions src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,17 @@ public StandardSecurityHandler(PdfEncryptionSettings settings)

// Derive /P integer (ISO 32000-2 Table 22).
// Bits 1–2 (positions 0–1 from LSB) are reserved = 0.
// Bits 7–8 (positions 6–7 from LSB) must be 1 for R >= 3 — PdfPermissions has no
// Bits 7–8 (positions 6–7 from LSB) are "Reserved. Must be 1." — PdfPermissions has no
// flag at 1<<6/1<<7 (the enum jumps Annotate=1<<5 straight to FillForms=1<<8), so
// those two bits are forced on here rather than sourced from the caller's flags.
// Bit 10 (position 9 from LSB) carried the accessibility-extraction restriction that
// PDF 2.0 deprecates: Table 22 says readers shall ignore it and writers shall always set
// it to 1, so it is forced on here too, independent of whether the caller passed
// PdfPermissions.Extract.
// Bits 13–32 (positions 12–31) are reserved = 1.
// Pattern: 0xFFFFF0C0 | enabledLowBits, then clear bits 0 and 1.
// Pattern: 0xFFFFF2C0 | enabledLowBits, then clear positions 0-1 (Table 22 bits 1-2).
var enabledBits = (int)settings.Permissions;
PValue = (int)((0xFFFFF0C0u | (uint)(enabledBits & 0xFFF)) & ~0x3u);
PValue = (int)((0xFFFFF2C0u | (uint)(enabledBits & 0xFFF)) & ~0x3u);

var userPw = PasswordBytes(settings.UserPassword);
// Null falls back to the user password (the documented behaviour); ThrowIfOwnerPasswordWouldBeIgnored
Expand Down
21 changes: 11 additions & 10 deletions tests/VellumPdf.Conformance.Tests/Assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,18 @@ Generated once with qpdf (empty user password, owner `o`, AES-128) from the exac

A §7.16-1 violator for `UaEncryptionPermissionsRuleTests`: its `/Encrypt` dictionary's `/P` entry
has bit 10 clear, which ISO 32000-2 Table 22 says a writer "shall always set". Built once with
this library's current writer and committed, because #397 ("Kernel: always set /P bit 10 in the
encryption dictionary") will make bit 10 unconditional and leave no way to produce this shape from
the writer once it lands.
the pre-#397 writer and committed, because #397 ("Kernel: always set /P bit 10 in the encryption
dictionary (ISO 32000-2 Table 22)") made bit 10 unconditional, so there is no longer a way to
produce this shape from the writer itself.

The writer emits AES-256 (`/V 5 /R 6`): `StandardSecurityHandler` implements only one
Standard-security-handler configuration, so every document `PdfDocument.Encrypt` writes is
V=5/R=6 regardless of what permissions it carries. At R6, `/P` is not a key input (Algorithm 2
only feeds it in at R≤4), so this file's `/P` and its `/Perms` seal agree, and it opens the
same way any other well-formed R6 document does.

Provenance:
Provenance (run against the pre-#397 writer at `1a85a66`; the current writer produces `/P -4` from
the same recipe, so this block documents the file rather than reproducing it):

```csharp
using var doc = new PdfDocument();
Expand All @@ -44,12 +45,12 @@ doc.Encrypt(new PdfEncryptionSettings
doc.Save(stream);
```

`Permissions = All & ~Extract` clears bit 10 (`PdfPermissions.Extract = 1 << 9`) while leaving every
other bit as `StandardSecurityHandler` would set it for `All`. By Table 22 arithmetic
(`P = (0xFFFFF0C0 | (enabledBits & 0xFFF)) & ~3`), that makes `/P` equal `-516` (`0xFFFFFDFC`) —
`UaEncryptionPermissionsRuleTests` asserts the committed file's own `/P` still reads `-516` before
trusting anything else about it, so a regenerated file with the bit accidentally set cannot make
the rule test vacuous.
Under that writer's mask, `Permissions = All & ~Extract` cleared bit 10 (`PdfPermissions.Extract =
1 << 9`) while leaving every other bit as `StandardSecurityHandler` set it for `All`. By that
mask's arithmetic (`P = (0xFFFFF0C0 | (enabledBits & 0xFFF)) & ~3`), `/P` came out as `-516`
(`0xFFFFFDFC`). `UaEncryptionPermissionsRuleTests` asserts the committed file's own `/P` still
reads `-516` before trusting anything else about it, so a regenerated file with the bit
accidentally set cannot make the rule test vacuous.

SHA-256: `d7a788dc6463cc3f63325aaf27b0b71d56c0bc1501b1174e6334bad2fe66e324`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ namespace VellumPdf.Conformance.Tests;
/// <summary>
/// ISO 14289-1 §7.16-1: an encrypted document's <c>/Encrypt</c> dictionary must have <c>/P</c> bit
/// 10 set. Expected <c>/P</c> values below are derived from ISO 32000-2 Table 22 arithmetic
/// (<c>StandardSecurityHandler</c>'s <c>P = (0xFFFFF0C0 | (enabledBits &amp; 0xFFF)) &amp; ~3</c>),
/// not read back from whatever the writer happened to produce.
/// (<c>StandardSecurityHandler</c>'s <c>P = (0xFFFFF2C0 | (enabledBits &amp; 0xFFF)) &amp; ~3</c>,
/// bit 10 forced on since #397), not read back from whatever the writer happened to produce. The
/// committed fixture's <c>-516</c> comes from the pre-#397 mask <c>0xFFFFF0C0</c>.
/// </summary>
public sealed class UaEncryptionPermissionsRuleTests
{
Expand All @@ -22,9 +23,10 @@ public sealed class UaEncryptionPermissionsRuleTests
// ── Fixture 1: compliant, writer-built ────────────────────────────────────────────────────────

/// <summary>
/// <c>Permissions = All</c> sets every bit <c>StandardSecurityHandler</c> can set, including
/// bit 10 (<c>PdfPermissions.Extract</c>) — the writer's ordinary output already satisfies
/// §7.16-1. <c>P = (0xFFFFF0C0 | (0xF3C &amp; 0xFFF)) &amp; ~3 = -4</c> by hand.
/// <c>Permissions = All</c> sets every bit <c>StandardSecurityHandler</c> can set; bit 10 is
/// forced on by the handler's mask since #397 whatever the flags say, so the writer's
/// ordinary output already satisfies §7.16-1.
/// <c>P = (0xFFFFF2C0 | (0xF3C &amp; 0xFFF)) &amp; ~3 = -4</c> by hand.
/// </summary>
[Fact]
public void CompliantDocument_AllPermissions_bit10Set_noFinding()
Expand All @@ -41,11 +43,11 @@ public void CompliantDocument_AllPermissions_bit10Set_noFinding()
// ── Fixture 2: violating, committed binary ────────────────────────────────────────────────────

/// <summary>
/// <c>Assets/enc-aes-256-p-bit10-clear.pdf</c> was built once with the current writer, with
/// <c>Assets/enc-aes-256-p-bit10-clear.pdf</c> was built once with the pre-#397 writer, with
/// <c>Permissions = All &amp; ~Extract</c>: <c>P = (0xFFFFF0C0 | (0xD3C &amp; 0xFFF)) &amp; ~3
/// = -516</c> by hand. It is committed rather than regenerated because #397 will make the
/// writer set bit 10 unconditionally, leaving no way to reproduce this shape once it lands
/// (see <c>Assets/README.md</c>).
/// = -516</c> by hand under that writer's mask. It is committed rather than regenerated because
/// #397 made the writer set bit 10 unconditionally, so there is no longer a way to produce this
/// shape from the writer itself (see <c>Assets/README.md</c>).
/// </summary>
[Fact]
public void ViolatingFixture_bit10Clear_reportsOneError()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@
<EmbeddedResource Include="Assets\jpx-encrypted-emptyuser.pdf">
<LogicalName>jpx-encrypted-emptyuser.pdf</LogicalName>
</EmbeddedResource>
<!-- Built once by this library's own writer with the /P bit 10 accessibility bit deliberately
cleared, then committed — see Assets/README.md for exactly how, and why #397 will remove
the writer's ability to reproduce it. Used by UaEncryptionPermissionsRuleTests,
UaEncryptionPermissionsVeraPdfTests, and EncryptedFixtureDigestTests, which pins every
<!-- Built once by the pre-#397 writer with /P bit 10 deliberately cleared, then committed;
see Assets/README.md for exactly how, and why #397 removed the writer's ability to
reproduce it. Read by three test classes: UaEncryptionPermissionsRuleTests,
UaEncryptionPermissionsVeraPdfTests and EncryptedFixtureDigestTests, which pins every
fixture's SHA-256 to Assets/README.md. -->
<EmbeddedResource Include="Assets\enc-aes-256-p-bit10-clear.pdf">
<LogicalName>enc-aes-256-p-bit10-clear.pdf</LogicalName>
Expand Down
Loading