diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0848c52f..9b9941d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/kernel-guide.md b/docs/kernel-guide.md
index 6249b986..62c42215 100644
--- a/docs/kernel-guide.md
+++ b/docs/kernel-guide.md
@@ -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
@@ -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.
---
@@ -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
diff --git a/eng/aot/VellumPdf.AotSmoke/Program.cs b/eng/aot/VellumPdf.AotSmoke/Program.cs
index 42e92a5f..9054808c 100644
--- a/eng/aot/VellumPdf.AotSmoke/Program.cs
+++ b/eng/aot/VellumPdf.AotSmoke/Program.cs
@@ -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)
@@ -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}");
diff --git a/src/VellumPdf.Kernel/Document/PdfDocument.cs b/src/VellumPdf.Kernel/Document/PdfDocument.cs
index ab7a555f..31506474 100644
--- a/src/VellumPdf.Kernel/Document/PdfDocument.cs
+++ b/src/VellumPdf.Kernel/Document/PdfDocument.cs
@@ -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)
diff --git a/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs b/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs
index 16d61ab2..c4026af7 100644
--- a/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs
+++ b/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs
@@ -71,7 +71,12 @@ public sealed class PdfEncryptionInfo
///
public int KeyLengthBits { get; }
- /// /P, decoded into the individual permission flags it grants.
+ ///
+ /// /P, decoded into the individual permission flags it grants.
+ /// 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.
+ ///
///
/// At /R 5 and 6 this is the copy sealed inside /Perms under the file key, not the
/// dictionary's /P — the two are inputs to nothing at those revisions, so an editor can
diff --git a/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs b/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs
index 9fa1501f..1f2ba351 100644
--- a/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs
+++ b/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs
@@ -41,7 +41,13 @@ public sealed class PdfEncryptionSettings
///
public string? OwnerPassword { get; init; }
- /// Access permissions. Defaults to .
+ ///
+ /// Access permissions. Defaults to . Omitting
+ /// no longer clears a bit in the written /P
+ /// (ISO 32000-2 Table 22 has writers always set bit 10), but a
+ /// document still fails the guard on
+ /// PdfDocument.Save.
+ ///
public PdfPermissions Permissions { get; init; } = PdfPermissions.All;
///
diff --git a/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs b/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs
index 0c58d5df..61e7a32c 100644
--- a/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs
+++ b/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs
@@ -28,7 +28,15 @@ public enum PdfPermissions
/// Fill in existing interactive form fields.
FillForms = 1 << 8,
- /// Extract text and graphics (disability accessibility support).
+ ///
+ /// 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 /P bit no longer depends on
+ /// this flag. It is kept because reports it for
+ /// any file whose bit 10 is set (at /R 5 and 6, in the /Perms copy; see that
+ /// property's remarks), including the ones this library writes, and because
+ /// PdfDocument's PDF/UA-1 check reads it as the caller's declared intent.
+ ///
Extract = 1 << 9,
/// Assemble the document (insert/delete pages, create bookmarks).
diff --git a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs
index 2750896c..d26ea222 100644
--- a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs
+++ b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs
@@ -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
diff --git a/tests/VellumPdf.Conformance.Tests/Assets/README.md b/tests/VellumPdf.Conformance.Tests/Assets/README.md
index 595c306e..1e43609e 100644
--- a/tests/VellumPdf.Conformance.Tests/Assets/README.md
+++ b/tests/VellumPdf.Conformance.Tests/Assets/README.md
@@ -20,9 +20,9 @@ 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
@@ -30,7 +30,8 @@ V=5/R=6 regardless of what permissions it carries. At R6, `/P` is not a key inpu
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();
@@ -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`
diff --git a/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs b/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs
index ad0443cb..630f2e84 100644
--- a/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs
+++ b/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs
@@ -12,8 +12,9 @@ namespace VellumPdf.Conformance.Tests;
///
/// ISO 14289-1 §7.16-1: an encrypted document's /Encrypt dictionary must have /P bit
/// 10 set. Expected /P values below are derived from ISO 32000-2 Table 22 arithmetic
-/// (StandardSecurityHandler's P = (0xFFFFF0C0 | (enabledBits & 0xFFF)) & ~3),
-/// not read back from whatever the writer happened to produce.
+/// (StandardSecurityHandler's P = (0xFFFFF2C0 | (enabledBits & 0xFFF)) & ~3,
+/// bit 10 forced on since #397), not read back from whatever the writer happened to produce. The
+/// committed fixture's -516 comes from the pre-#397 mask 0xFFFFF0C0.
///
public sealed class UaEncryptionPermissionsRuleTests
{
@@ -22,9 +23,10 @@ public sealed class UaEncryptionPermissionsRuleTests
// ── Fixture 1: compliant, writer-built ────────────────────────────────────────────────────────
///
- /// Permissions = All sets every bit StandardSecurityHandler can set, including
- /// bit 10 (PdfPermissions.Extract) — the writer's ordinary output already satisfies
- /// §7.16-1. P = (0xFFFFF0C0 | (0xF3C & 0xFFF)) & ~3 = -4 by hand.
+ /// Permissions = All sets every bit StandardSecurityHandler 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.
+ /// P = (0xFFFFF2C0 | (0xF3C & 0xFFF)) & ~3 = -4 by hand.
///
[Fact]
public void CompliantDocument_AllPermissions_bit10Set_noFinding()
@@ -41,11 +43,11 @@ public void CompliantDocument_AllPermissions_bit10Set_noFinding()
// ── Fixture 2: violating, committed binary ────────────────────────────────────────────────────
///
- /// Assets/enc-aes-256-p-bit10-clear.pdf was built once with the current writer, with
+ /// Assets/enc-aes-256-p-bit10-clear.pdf was built once with the pre-#397 writer, with
/// Permissions = All & ~Extract: P = (0xFFFFF0C0 | (0xD3C & 0xFFF)) & ~3
- /// = -516 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 Assets/README.md).
+ /// = -516 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 Assets/README.md).
///
[Fact]
public void ViolatingFixture_bit10Clear_reportsOneError()
diff --git a/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj b/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj
index 14550d9f..f218ef8c 100644
--- a/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj
+++ b/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj
@@ -44,10 +44,10 @@
jpx-encrypted-emptyuser.pdf
-
enc-aes-256-p-bit10-clear.pdf
diff --git a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs
index c416782e..843a7d5f 100644
--- a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs
+++ b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs
@@ -111,7 +111,7 @@ public void Permissions_All_sets_expected_high_bits()
[Fact]
public void Permissions_reserved_bits_7_and_8_are_always_set()
{
- // ISO 32000-2 Table 22: bits 7-8 (positions 6-7 from LSB) must be 1 for R >= 3,
+ // ISO 32000-2 Table 22: bits 7-8 (positions 6-7 from LSB) are "Reserved. Must be 1.",
// regardless of the caller's requested permissions. PdfPermissions has no flag
// at 1<<6/1<<7, so nothing the caller passes can turn these off.
var allOff = new StandardSecurityHandler(new PdfEncryptionSettings
@@ -138,12 +138,98 @@ public void Permissions_None_clears_user_bits()
Permissions = PdfPermissions.None,
});
- // Bits 2..5 and 8..11 should be 0; bits 6..7 (0xC0) are forced to 1
- // regardless of the requested permissions (ISO 32000-2 Table 22).
- Assert.Equal(0, handler.PValue & 0xF3C);
+ // Positions counted from the LSB (0-based): 2..5, 8, 10 and 11 should be 0, while
+ // 6..7 (0xC0) and 9 (0x200) are forced to 1 regardless of the requested permissions.
+ // In Table 22's 1-based numbering those are bits 7-8 ("Reserved. Must be 1.") and
+ // bit 10, whose accessibility restriction PDF 2.0 deprecates and which writers shall
+ // always set so readers on earlier specifications keep treating extraction for
+ // accessibility as allowed.
+ Assert.Equal(0x200, handler.PValue & 0xF3C);
Assert.Equal(0xC0, handler.PValue & 0xC0);
}
+ ///
+ /// Known-answer values for /P, worked out by hand from Table 22 rather than copied from
+ /// a program run, so a bug in the mask cannot be blessed by an expectation taken from the same
+ /// buggy output.
+ ///
+ /// None: enabledBits = 0. (0xFFFFF2C0 | 0) & ~3 = 0xFFFFF2C0. As a signed
+ /// 32-bit value, 0x100000000 - 0xFFFFF2C0 = 0xD40 = 3392, so -3392.
+ ///
+ /// Copy (1<<4 = 0x10): (0xFFFFF2C0 | 0x10) & ~3 = 0xFFFFF2D0.
+ /// 0x100000000 - 0xFFFFF2D0 = 0xD30 = 3376, so -3376.
+ ///
+ /// All & ~Extract: All is
+ /// Print|Modify|Copy|Annotate|FillForms|Extract|Assemble|PrintHighRes
+ /// = 0x4|0x8|0x10|0x20|0x100|0x200|0x400|0x800 = 0xF3C. Minus Extract (0x200) is
+ /// 0xD3C. 0xFFFFF2C0 | 0xD3C: low 12 bits 0x2C0 | 0xD3C = 0xFFC, so the
+ /// result is 0xFFFFFFFC, already clear at positions 0-1, which is -4. Bit 10 being
+ /// forced on independently of the caller's flags is exactly why dropping Extract no
+ /// longer moves this value away from what All itself produces.
+ ///
+ /// All: enabledBits = 0xF3C. 0xFFFFF2C0 | 0xF3C: low 12 bits
+ /// 0x2C0 | 0xF3C = 0xFFC, identical to the previous case: the only bit All adds
+ /// over All & ~Extract is Extract (0x200), which the mask supplies
+ /// anyway, so the result is again 0xFFFFFFFC = -4.
+ ///
+ [Theory]
+ [InlineData(PdfPermissions.None, -3392)]
+ [InlineData(PdfPermissions.Copy, -3376)]
+ [InlineData(PdfPermissions.All & ~PdfPermissions.Extract, -4)]
+ [InlineData(PdfPermissions.All, -4)]
+ public void PValue_matchesHandDerivedKnownAnswer(PdfPermissions permissions, int expected)
+ {
+ var handler = new StandardSecurityHandler(new PdfEncryptionSettings
+ {
+ UserPassword = "pw",
+ Permissions = permissions,
+ });
+
+ Assert.Equal(expected, handler.PValue);
+ }
+
+ ///
+ /// End-to-end version of : saves a full AES-256
+ /// R6 document (the writer's only mode) with All & ~Extract, the exact permission set
+ /// #397 names, and checks the bytes that reach disk rather than only the handler's
+ /// in-memory value.
+ ///
+ /// The /Perms seal (Algorithm 10) is checked too, but against a handler built with
+ /// the same permissions rather than against the bytes PdfDocument.Save wrote: the handler
+ /// Save constructs internally is not exposed, so there is no way from outside to recover
+ /// the file key that sealed that specific document's /Perms. PValue is a pure
+ /// function of settings.Permissions with no random input, so a second handler built from
+ /// the same permissions computes the identical /P and therefore seals the identical value;
+ /// the passwords (DecryptPermsBlockForTest fixes the user password it derives the file
+ /// key from), /U, /O, /UE, /OE and the random padding differ between the
+ /// two handlers, none of which this test depends on.
+ ///
+ [Fact]
+ public void EncryptedDocument_allWithoutExtract_writesExpectedP()
+ {
+ var bytes = SaveEncrypted("u", "o", permissions: PdfPermissions.All & ~PdfPermissions.Extract);
+ var text = Encoding.Latin1.GetString(bytes);
+
+ // SaveEncrypted builds no structure tree, AcroForm or signature, so the only /P key in the
+ // file is the /Encrypt entry: the writer's other /P keys are indirect references
+ // (/P n 0 R: a widget's or signature's page, a structure element's parent), which this
+ // regex would match as well. A lone match therefore proves the value came from /Encrypt
+ // and not from one of those.
+ var declared = Assert.Single(Regex.Matches(text, @"/P (-?\d+)"));
+ Assert.Equal("-4", declared.Groups[1].Value);
+
+ var handler = new StandardSecurityHandler(new PdfEncryptionSettings
+ {
+ UserPassword = "TestPass@2026",
+ Permissions = PdfPermissions.All & ~PdfPermissions.Extract,
+ });
+ Assert.Equal(-4, handler.PValue);
+
+ var permsPlain = DecryptPermsBlockForTest(handler);
+ var pFromPerms = (int)(permsPlain[0] | (permsPlain[1] << 8) | (permsPlain[2] << 16) | (permsPlain[3] << 24));
+ Assert.Equal(-4, pFromPerms);
+ }
+
// ── Two-pass determinism: different keys each time ─────────────────────
[Fact]
@@ -1004,9 +1090,10 @@ public void EncryptedDocument_trailerId_isNotEncrypted()
/// makes this the class of defect only another implementation sees.
///
/// Editing /P in the written bytes is what separates the two sources. The document
- /// is written granting print only; the edit declares full permissions over a seal that says
- /// otherwise, and a reader that reads the seal still reports print alone. Same byte count, so
- /// every cross-reference offset stays valid.
+ /// is written granting print, and the writer always sets bit 10 as well (ISO 32000-2 Table 22,
+ /// #397); the edit declares full permissions over a seal that says otherwise, and a reader that
+ /// reads the seal still reports the document's narrower grant. Same byte count, so every
+ /// cross-reference offset stays valid.
///
[Fact]
public void EncryptedDocument_permsIsTheSealedCopyOfP_notJustSixteenBytes()
@@ -1029,8 +1116,9 @@ public void EncryptedDocument_permsIsTheSealedCopyOfP_notJustSixteenBytes()
using var reader = PdfReader.Open(patched, new PdfReaderOptions { Password = "u" });
- // The seal wins: print only, not the everything the edited /P now claims.
- Assert.Equal(PdfPermissions.Print, reader.Encryption!.Permissions);
+ // The seal wins: Print plus the bit 10 the writer always sets, not the everything the
+ // edited /P now claims.
+ Assert.Equal(PdfPermissions.Print | PdfPermissions.Extract, reader.Encryption!.Permissions);
}
///
diff --git a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs
index a46cd503..ca0511c9 100644
--- a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs
+++ b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs
@@ -240,8 +240,8 @@ public void AesEncrypted_QpdfShowEncryption_ReportsAESV3()
[Fact]
public void AesEncrypted_QpdfShowEncryption_ReportsReservedPermissionBitsSet()
{
- // #189: bits 7-8 (positions 6-7 from LSB) of /P must be 1 for R >= 3
- // (ISO 32000-2 Table 22), independent of which permissions were requested.
+ // #189: bits 7-8 (positions 6-7 from LSB) of /P must be 1 (ISO 32000-2
+ // Table 22), independent of which permissions were requested.
// Read the value qpdf parsed from our raw /P bytes rather than decrypting
// our own /Perms block — a decrypt-and-compare against /Perms is derived
// from the same PValue field and is structurally blind to this bug class.
@@ -260,17 +260,26 @@ public void AesEncrypted_QpdfShowEncryption_ReportsReservedPermissionBitsSet()
var p = int.Parse(match.Groups[1].Value);
// Pin the exact value, not just the reserved-bit mask: for Permissions = Copy this is
- // -3888 (0xFFFFF0D0 as a signed int32 — 0xFFFFF000 reserved-high | 0xC0 reserved-bits-7-8
- // | 0x10 Copy). A mask-only assertion would pass even if some unrelated bit in /P were
- // wrong; pinning the value is the known-answer test CLAUDE.md asks for.
- Assert.Equal(-3888, p);
+ // -3376 (0xFFFFF2D0 as a signed int32 — 0xFFFFF000 reserved-high | 0xC0 reserved-bits-7-8
+ // | 0x200 bit 10 | 0x10 Copy). Bit 10 (ISO 32000-2 Table 22) carried the
+ // accessibility-extraction restriction PDF 2.0 deprecates; writers shall always set it to 1
+ // regardless of the permissions requested (#397), which is why it is forced on here
+ // alongside bits 7-8. A mask-only assertion would pass even if some unrelated bit in /P
+ // were wrong; pinning the value is the known-answer test CLAUDE.md asks for.
+ Assert.Equal(-3376, p);
Assert.True(
(p & 0xC0) == 0xC0,
$"Expected reserved bits 7-8 (0xC0) set in /P; qpdf reported P={p} (0x{(uint)p:X8}).\nstdout: {stdout}");
+ Assert.True(
+ (p & 0x200) == 0x200,
+ $"Expected bit 10 (0x200) set in /P; qpdf reported P={p} (0x{(uint)p:X8}).\nstdout: {stdout}");
// Cross-check that qpdf's plain-language report matches the permissions the
- // document was actually built with (Copy granted; Modify/Assemble withheld).
+ // document was actually built with (Copy granted; Modify/Assemble withheld), and that
+ // qpdf consults bit 10 for that line at R >= 3. The pre-#397 writer reported "not allowed"
+ // here because it left bit 10 clear whenever the caller omitted PdfPermissions.Extract.
Assert.Contains("extract for any purpose: allowed", stdout, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("extract for accessibility: allowed", stdout, StringComparison.OrdinalIgnoreCase);
Assert.Contains("modify document assembly: not allowed", stdout, StringComparison.OrdinalIgnoreCase);
Assert.Contains("modify anything: not allowed", stdout, StringComparison.OrdinalIgnoreCase);
}