From 8a8ae1361373219d34f4fa1112cbafe1944aa70d Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 18:14:03 +0200 Subject: [PATCH 01/11] fix(kernel): always set /P bit 10 when writing an encryption dictionary ISO 32000-2 Table 22, bit 10 of /P: deprecated in PDF 2.0, but writers "shall always set this bit to 1" for compatibility with readers on earlier specifications. StandardSecurityHandler only set it when the caller included PdfPermissions.Extract, so Permissions = All & ~Extract produced a Table 22 violation, and PDF/UA-1 7.16-1 fails on that shape. Fold bit 10 into the always-on mask (0xFFFFF0C0 -> 0xFFFFF2C0). ComputePerms reads PValue to build the Algorithm 10 block, so the R6 /Perms seal moves with /P: a freshly written file's seal now covers bit 10 too, not just bits 7-8. Permissions = None now writes /P -3392 instead of -3904; Copy writes -3376 instead of -3888. The qpdf oracle test pinning Copy's value moves accordingly, and now also asserts qpdf's "extract for accessibility" line flips from "not allowed" to "allowed". Extract stays on the Kernel enum with no PublicAPI change: its written bit no longer depends on the flag, but PdfDocument's PDF/UA-1 guard still uses it to catch a caller's declared intent mismatch. The pre-#397 fixture that violates 7.16-1 (committed for #138) can no longer be produced by the plain writer, so its test comments and Assets/README.md are updated from future to past tense. Closes #397. --- CHANGELOG.md | 9 ++ src/VellumPdf.Kernel/Document/PdfDocument.cs | 16 ++-- .../Encryption/PdfPermissions.cs | 8 +- .../Encryption/StandardSecurityHandler.cs | 7 +- .../Assets/README.md | 6 +- .../UaEncryptionPermissionsRuleTests.cs | 14 +-- .../VellumPdf.Kernel.Tests/EncryptionTests.cs | 96 +++++++++++++++++-- .../PdfValidatorOracleTests.cs | 19 +++- 8 files changed, 142 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0848c52f..a551f72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --password"; a non-empty `--password` that does not open the file prints "the supplied --password does not open it". (#138) +### Fixed + +- **`/P` bit 10 is now always set on a newly written `/Encrypt` dictionary.** ISO 32000-2 Table 22 + deprecates this bit in PDF 2.0 and requires writers to set it regardless of the permissions + requested; the Standard security handler previously set it only when `PdfPermissions.Extract` was + included, which produced a Table 22 violation and failed PDF/UA-1 §7.16-1. `Permissions = None` + now writes `/P -3392` instead of `-3904`, and `Copy` writes `-3376` instead of `-3888`; at `/R` 6 + the `/Perms` seal (Algorithm 10) changes with it, since it seals the same `/P` value. (#397) + ## [2.3.0] - 2026-09-01 ### Breaking changes diff --git a/src/VellumPdf.Kernel/Document/PdfDocument.cs b/src/VellumPdf.Kernel/Document/PdfDocument.cs index ab7a555f..24034f9c 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 bit + // is deprecated in PDF 2.0 and writers shall always set it), 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 enforces that, so reject rather than silently + // let the mismatch through: PdfEncryptionSettings.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/PdfPermissions.cs b/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs index 0c58d5df..d7c41e06 100644 --- a/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs +++ b/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs @@ -28,7 +28,13 @@ public enum PdfPermissions /// Fill in existing interactive form fields. FillForms = 1 << 8, - /// Extract text and graphics (disability accessibility support). + /// + /// Historical accessibility-extraction bit. ISO 32000-2 Table 22 deprecates this bit in + /// PDF 2.0: readers shall ignore it, 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 for + /// reading files written to earlier specifications, and for PdfDocument's PDF/UA-1 + /// check on 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..e47ed66e 100644 --- a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs +++ b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs @@ -53,10 +53,13 @@ public StandardSecurityHandler(PdfEncryptionSettings settings) // Bits 7–8 (positions 6–7 from LSB) must be 1 for R >= 3 — 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) is the deprecated accessibility-extraction bit: 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 bits 0 and 1. 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..3b9ceb02 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") 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 diff --git a/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs b/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs index ad0443cb..7b546154 100644 --- a/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs +++ b/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs @@ -12,8 +12,8 @@ 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. /// public sealed class UaEncryptionPermissionsRuleTests { @@ -24,7 +24,7 @@ public sealed class UaEncryptionPermissionsRuleTests /// /// 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. + /// §7.16-1. P = (0xFFFFF2C0 | (0xF3C & 0xFFF)) & ~3 = -4 by hand. /// [Fact] public void CompliantDocument_AllPermissions_bit10Set_noFinding() @@ -41,11 +41,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.Kernel.Tests/EncryptionTests.cs b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs index c416782e..1e2e3ffe 100644 --- a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs +++ b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs @@ -138,12 +138,90 @@ 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); + // Bits 2..5, 8..9 and 11 should be 0; bits 6..7 (0xC0) and bit 10 (0x200) are + // forced to 1 regardless of the requested permissions (ISO 32000-2 Table 22: + // bits 7-8 must be 1 for R >= 3, and bit 10, deprecated in PDF 2.0, must always + // be 1 so readers on earlier specifications keep treating extraction 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 mutation that lands on the same wrong answer the fixed implementation + /// would produce cannot slip through both at once. + /// + /// 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 on bits 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, since 0x2C0's bits are + /// already covered by 0xF3C), 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 byte that actually reaches 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 settings 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 settings computes the identical /P and therefore seals the identical value; + /// only /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_writesExpectedPAndPerms() + { + var bytes = SaveEncrypted("u", "o", permissions: PdfPermissions.All & ~PdfPermissions.Extract); + var text = Encoding.Latin1.GetString(bytes); + + var declared = Regex.Match(text, @"/P (-?\d+)"); + Assert.True(declared.Success, "no /P found in the written document"); + 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 +1082,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 +1108,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..c43b3978 100644 --- a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs +++ b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs @@ -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) is the deprecated + // accessibility-extraction bit; 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 + // bit 10 reads as allowed 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); } From ea2c5c504c1cdd0f051fee3f861b4c24f12e58dc Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 18:17:05 +0200 Subject: [PATCH 02/11] style(tests): drop an em dash from the qpdf permissions comment --- tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs index c43b3978..9b0e9cf1 100644 --- a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs +++ b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs @@ -276,7 +276,7 @@ public void AesEncrypted_QpdfShowEncryption_ReportsReservedPermissionBitsSet() // Cross-check that qpdf's plain-language report matches the permissions the // document was actually built with (Copy granted; Modify/Assemble withheld), and that - // bit 10 reads as allowed at R >= 3 — the pre-#397 writer reported "not allowed" here + // bit 10 reads as allowed 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); From bf20cfde687dddfbffb8fc77e78406b2bc4597ff Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 18:51:11 +0200 Subject: [PATCH 03/11] docs(kernel): describe the /P bit-10 change where the guard is documented Round-1 review of #399 found prose that still described the pre-#397 writer as current: the kernel guide said omitting Extract changed the written /P (it no longer does, the guard now checks declared intent), the Conformance fixture README presented a recipe the current writer cannot reproduce, and the reserved-bit comment in EncryptionTests mixed 0-based positions with Table 22's 1-based bit numbers. The CHANGELOG bullet now also names the All & ~Extract value and the reader-visible consequence (Extract reported for documents written with narrower permissions). --- CHANGELOG.md | 6 ++++-- docs/kernel-guide.md | 9 +++++++-- .../Encryption/StandardSecurityHandler.cs | 2 +- .../Assets/README.md | 10 ++++++---- .../VellumPdf.Kernel.Tests/EncryptionTests.cs | 18 ++++++++++-------- 5 files changed, 28 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a551f72d..10890d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,8 +52,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). deprecates this bit in PDF 2.0 and requires writers to set it regardless of the permissions requested; the Standard security handler previously set it only when `PdfPermissions.Extract` was included, which produced a Table 22 violation and failed PDF/UA-1 §7.16-1. `Permissions = None` - now writes `/P -3392` instead of `-3904`, and `Copy` writes `-3376` instead of `-3888`; at `/R` 6 - the `/Perms` seal (Algorithm 10) changes with it, since it seals the same `/P` value. (#397) + now writes `/P -3392` instead of `-3904`, `Copy` writes `-3376` instead of `-3888`, and + `All & ~Extract` writes `-4`, 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 with narrower permissions + 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..eb913a05 100644 --- a/docs/kernel-guide.md +++ b/docs/kernel-guide.md @@ -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 the 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 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/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs index e47ed66e..25141eb0 100644 --- a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs +++ b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs @@ -50,7 +50,7 @@ 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) is the deprecated accessibility-extraction bit: Table 22 diff --git a/tests/VellumPdf.Conformance.Tests/Assets/README.md b/tests/VellumPdf.Conformance.Tests/Assets/README.md index 3b9ceb02..e77e59c6 100644 --- a/tests/VellumPdf.Conformance.Tests/Assets/README.md +++ b/tests/VellumPdf.Conformance.Tests/Assets/README.md @@ -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 code, so this block documents the file rather than reproducing it): ```csharp using var doc = new PdfDocument(); @@ -44,9 +45,10 @@ 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`) — +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 Table 22 +arithmetic with the pre-#397 mask (`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. diff --git a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs index 1e2e3ffe..5dfb2fe5 100644 --- a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs +++ b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs @@ -138,10 +138,11 @@ public void Permissions_None_clears_user_bits() Permissions = PdfPermissions.None, }); - // Bits 2..5, 8..9 and 11 should be 0; bits 6..7 (0xC0) and bit 10 (0x200) are - // forced to 1 regardless of the requested permissions (ISO 32000-2 Table 22: - // bits 7-8 must be 1 for R >= 3, and bit 10, deprecated in PDF 2.0, must always - // be 1 so readers on earlier specifications keep treating extraction as allowed). + // 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, deprecated in PDF 2.0, which writers shall always set so readers on earlier + // specifications keep treating extraction as allowed. Assert.Equal(0x200, handler.PValue & 0xF3C); Assert.Equal(0xC0, handler.PValue & 0xC0); } @@ -192,12 +193,13 @@ public void PValue_matchesHandDerivedKnownAnswer(PdfPermissions permissions, int /// in-memory value. /// /// The /Perms seal (Algorithm 10) is checked too, but against a handler built with - /// the same settings rather than against the bytes PdfDocument.Save wrote: the handler + /// 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 settings computes the identical /P and therefore seals the identical value; - /// only /U, /O, /UE, /OE and the random padding differ between the + /// 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] @@ -1108,7 +1110,7 @@ public void EncryptedDocument_permsIsTheSealedCopyOfP_notJustSixteenBytes() using var reader = PdfReader.Open(patched, new PdfReaderOptions { Password = "u" }); - // The seal wins: print plus the bit-10 the writer always sets, not the everything the + // 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); } From 2f356917cdc20b72c12c6704c93aae342b633c93 Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 18:58:12 +0200 Subject: [PATCH 04/11] test(conformance): stop crediting bit 10 to the Extract flag The handler's mask forces bit 10 on since #397, so the fixture doc that attributed it to PdfPermissions.Extract described the pre-fix writer. --- .../UaEncryptionPermissionsRuleTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs b/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs index 7b546154..bb0e4ea1 100644 --- a/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs +++ b/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs @@ -22,9 +22,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 = (0xFFFFF2C0 | (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() From 9504460f8c5aceeb106706b76c4f362307da4cf4 Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 19:28:39 +0200 Subject: [PATCH 05/11] test(aot): expect the sealed Extract bit the writer now sets The AOT smoke harness tampers /P and checks that the /Perms seal wins, but its expected value was the pre-#397 writer's Print alone. Since the mask forces bit 10 on, the sealed copy reads Print | Extract, exactly as the Kernel suite's twin assertion already expects, so the aot-smoke CI job failed on all three runners. Also correct the hand-derivation for Permissions = All in the Kernel KAT (All adds only Extract over All & ~Extract, which the mask supplies anyway), narrow the test name to what it checks on disk, require the written document to carry exactly one /P, and settle the remaining wording: "positions 0-1 (Table 22 bits 1-2)", no "for R >= 3" on the reserved bits, "extraction for accessibility" for bit 10, and the fixture provenance in past tense everywhere. --- docs/kernel-guide.md | 2 +- eng/aot/VellumPdf.AotSmoke/Program.cs | 12 +++++++----- src/VellumPdf.Kernel/Document/PdfDocument.cs | 2 +- .../Encryption/StandardSecurityHandler.cs | 2 +- tests/VellumPdf.Conformance.Tests/Assets/README.md | 9 ++++----- .../VellumPdf.Conformance.Tests.csproj | 4 ++-- tests/VellumPdf.Kernel.Tests/EncryptionTests.cs | 14 ++++++++------ .../PdfValidatorOracleTests.cs | 4 ++-- 8 files changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/kernel-guide.md b/docs/kernel-guide.md index eb913a05..4fe3a55b 100644 --- a/docs/kernel-guide.md +++ b/docs/kernel-guide.md @@ -546,7 +546,7 @@ 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. The written `/P` no longer -depends on the flag (ISO 32000-2 Table 22 has writers always set bit 10, and +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 a PDF/UA-1 document says the opposite of what the profile promises, and the save refuses rather than guessing. 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 24034f9c..9adcf12a 100644 --- a/src/VellumPdf.Kernel/Document/PdfDocument.cs +++ b/src/VellumPdf.Kernel/Document/PdfDocument.cs @@ -561,7 +561,7 @@ public void Save(Stream destination) // is deprecated in PDF 2.0 and writers shall always set it), 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 enforces that, so reject rather than silently + // even though the bit written no longer records that, so reject rather than silently // let the mismatch through: PdfEncryptionSettings.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. diff --git a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs index 25141eb0..37b13e14 100644 --- a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs +++ b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs @@ -57,7 +57,7 @@ public StandardSecurityHandler(PdfEncryptionSettings settings) // 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: 0xFFFFF2C0 | 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)((0xFFFFF2C0u | (uint)(enabledBits & 0xFFF)) & ~0x3u); diff --git a/tests/VellumPdf.Conformance.Tests/Assets/README.md b/tests/VellumPdf.Conformance.Tests/Assets/README.md index e77e59c6..27a9d24b 100644 --- a/tests/VellumPdf.Conformance.Tests/Assets/README.md +++ b/tests/VellumPdf.Conformance.Tests/Assets/README.md @@ -31,7 +31,7 @@ only feeds it in at R≤4), so this file's `/P` and its `/Perms` seal agree, and same way any other well-formed R6 document does. Provenance (run against the pre-#397 writer at `1a85a66`; the current writer produces `/P -4` from -the same code, so this block documents the file rather than reproducing it): +the same recipe, so this block documents the file rather than reproducing it): ```csharp using var doc = new PdfDocument(); @@ -48,10 +48,9 @@ doc.Save(stream); 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 Table 22 arithmetic with the pre-#397 mask (`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. +`/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. SHA-256: `d7a788dc6463cc3f63325aaf27b0b71d56c0bc1501b1174e6334bad2fe66e324` diff --git a/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj b/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj index 14550d9f..27f502ba 100644 --- a/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj +++ b/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj @@ -44,8 +44,8 @@ jpx-encrypted-emptyuser.pdf - diff --git a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs index 5dfb2fe5..611565ca 100644 --- a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs +++ b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs @@ -142,7 +142,7 @@ public void Permissions_None_clears_user_bits() // 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, deprecated in PDF 2.0, which writers shall always set so readers on earlier - // specifications keep treating extraction as allowed. + // specifications keep treating extraction for accessibility as allowed. Assert.Equal(0x200, handler.PValue & 0xF3C); Assert.Equal(0xC0, handler.PValue & 0xC0); } @@ -167,8 +167,9 @@ public void Permissions_None_clears_user_bits() /// 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, since 0x2C0's bits are - /// already covered by 0xF3C), so the result is again 0xFFFFFFFC = -4. + /// 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)] @@ -203,13 +204,14 @@ public void PValue_matchesHandDerivedKnownAnswer(PdfPermissions permissions, int /// two handlers, none of which this test depends on. /// [Fact] - public void EncryptedDocument_allWithoutExtract_writesExpectedPAndPerms() + public void EncryptedDocument_allWithoutExtract_writesExpectedP() { var bytes = SaveEncrypted("u", "o", permissions: PdfPermissions.All & ~PdfPermissions.Extract); var text = Encoding.Latin1.GetString(bytes); - var declared = Regex.Match(text, @"/P (-?\d+)"); - Assert.True(declared.Success, "no /P found in the written document"); + // A single-page document carries exactly one /P, so a lone match also proves the regex + // did not pick up some other integer-valued /P key elsewhere in the file. + var declared = Assert.Single(Regex.Matches(text, @"/P (-?\d+)")); Assert.Equal("-4", declared.Groups[1].Value); var handler = new StandardSecurityHandler(new PdfEncryptionSettings diff --git a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs index 9b0e9cf1..3009cac8 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. From afe54d13dfdf553f8e90bdf3b45c032ce6accc86 Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 20:07:59 +0200 Subject: [PATCH 06/11] docs(kernel): say the bit-10 restriction is deprecated, not the bit Table 22 deprecates the accessibility-extraction restriction that bit 10 expressed; the bit itself is live and writers shall set it. Four comments and the CHANGELOG entry said "the bit is deprecated", and one Kernel test comment still qualified bits 7-8 with "for R >= 3", which Table 22 does not. Reword all of them, note on PdfPermissions.Extract and PdfEncryptionInfo.Permissions that the reader reports the bit for every file written since #397, give the pre-#397 -516 value in the CHANGELOG and the fixture provenance, and mark Extract in the kernel guide's settings table as no longer affecting the written /P. --- CHANGELOG.md | 13 +++++++------ docs/kernel-guide.md | 6 +++--- src/VellumPdf.Kernel/Document/PdfDocument.cs | 5 +++-- .../Encryption/PdfEncryptionInfo.cs | 6 +++++- .../Encryption/PdfPermissions.cs | 11 ++++++----- .../Encryption/StandardSecurityHandler.cs | 5 +++-- .../VellumPdf.Conformance.Tests/Assets/README.md | 6 +++--- .../UaEncryptionPermissionsRuleTests.cs | 3 ++- tests/VellumPdf.Kernel.Tests/EncryptionTests.cs | 16 +++++++++------- .../PdfValidatorOracleTests.cs | 3 ++- 10 files changed, 43 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10890d24..073c2e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,12 +48,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **`/P` bit 10 is now always set on a newly written `/Encrypt` dictionary.** ISO 32000-2 Table 22 - deprecates this bit in PDF 2.0 and requires writers to set it regardless of the permissions - requested; the Standard security handler previously set it only when `PdfPermissions.Extract` was - included, which 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`, the same value as `All`; at `/R` 6 the `/Perms` seal (Algorithm 10) +- **`/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 narrower permission set 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 with narrower permissions therefore reads back with `Extract` included in `PdfEncryptionInfo.Permissions`. (#397) diff --git a/docs/kernel-guide.md b/docs/kernel-guide.md index 4fe3a55b..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 @@ -548,8 +548,8 @@ ISO 14289-1 §7.16 requires that assistive technology can still extract 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 a PDF/UA-1 document says the opposite of what -the profile promises, and the save refuses rather than guessing. +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. --- diff --git a/src/VellumPdf.Kernel/Document/PdfDocument.cs b/src/VellumPdf.Kernel/Document/PdfDocument.cs index 9adcf12a..24e419b4 100644 --- a/src/VellumPdf.Kernel/Document/PdfDocument.cs +++ b/src/VellumPdf.Kernel/Document/PdfDocument.cs @@ -557,8 +557,9 @@ public void Save(Stream destination) // PDF/UA-1 does not prohibit encryption, but it requires that content remain // 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 bit - // is deprecated in PDF 2.0 and writers shall always set it), so this guard checks + // 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 diff --git a/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs b/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs index 16d61ab2..e04c8280 100644 --- a/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs +++ b/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs @@ -71,7 +71,11 @@ 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 own 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/PdfPermissions.cs b/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs index d7c41e06..9c6a6026 100644 --- a/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs +++ b/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs @@ -29,11 +29,12 @@ public enum PdfPermissions FillForms = 1 << 8, /// - /// Historical accessibility-extraction bit. ISO 32000-2 Table 22 deprecates this bit in - /// PDF 2.0: readers shall ignore it, 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 for - /// reading files written to earlier specifications, and for PdfDocument's PDF/UA-1 - /// check on the caller's declared intent. + /// 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, 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, diff --git a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs index 37b13e14..e4e09fa1 100644 --- a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs +++ b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs @@ -53,8 +53,9 @@ public StandardSecurityHandler(PdfEncryptionSettings settings) // 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) is the deprecated accessibility-extraction bit: Table 22 - // says readers shall ignore it and writers shall always set it to 1, so it is forced on + // 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: 0xFFFFF2C0 | enabledLowBits, then clear positions 0-1 (Table 22 bits 1-2). diff --git a/tests/VellumPdf.Conformance.Tests/Assets/README.md b/tests/VellumPdf.Conformance.Tests/Assets/README.md index 27a9d24b..2c7bd94a 100644 --- a/tests/VellumPdf.Conformance.Tests/Assets/README.md +++ b/tests/VellumPdf.Conformance.Tests/Assets/README.md @@ -46,9 +46,9 @@ doc.Save(stream); ``` 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 Table 22 -arithmetic with the pre-#397 mask (`P = (0xFFFFF0C0 | (enabledBits & 0xFFF)) & ~3`), that makes -`/P` equal `-516` (`0xFFFFFDFC`). `UaEncryptionPermissionsRuleTests` asserts the committed file's +1 << 9`) while leaving every other bit as `StandardSecurityHandler` set it for `All`. By that +mask's arithmetic (`P = (0xFFFFF0C0 | (enabledBits & 0xFFF)) & ~3`), `/P` comes 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. diff --git a/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs b/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs index bb0e4ea1..630f2e84 100644 --- a/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs +++ b/tests/VellumPdf.Conformance.Tests/UaEncryptionPermissionsRuleTests.cs @@ -13,7 +13,8 @@ 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 = (0xFFFFF2C0 | (enabledBits & 0xFFF)) & ~3, -/// bit 10 forced on since #397), not read back from whatever the writer happened to produce. +/// 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 { diff --git a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs index 611565ca..ec225d44 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 @@ -149,8 +149,8 @@ public void Permissions_None_clears_user_bits() /// /// Known-answer values for /P, worked out by hand from Table 22 rather than copied from - /// a program run, so a mutation that lands on the same wrong answer the fixed implementation - /// would produce cannot slip through both at once. + /// 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. @@ -162,7 +162,7 @@ public void Permissions_None_clears_user_bits() /// 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 on bits 0-1, which is -4. Bit 10 being + /// 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. /// @@ -190,7 +190,7 @@ public void PValue_matchesHandDerivedKnownAnswer(PdfPermissions permissions, int /// /// 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 byte that actually reaches disk rather than only the handler's + /// #397 names, and checks the bytes that actually 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 @@ -209,8 +209,10 @@ public void EncryptedDocument_allWithoutExtract_writesExpectedP() var bytes = SaveEncrypted("u", "o", permissions: PdfPermissions.All & ~PdfPermissions.Extract); var text = Encoding.Latin1.GetString(bytes); - // A single-page document carries exactly one /P, so a lone match also proves the regex - // did not pick up some other integer-valued /P key elsewhere in the file. + // 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 page back-references + // (/P n 0 R), 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); diff --git a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs index 3009cac8..04e43f28 100644 --- a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs +++ b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs @@ -276,7 +276,8 @@ public void AesEncrypted_QpdfShowEncryption_ReportsReservedPermissionBitsSet() // Cross-check that qpdf's plain-language report matches the permissions the // document was actually built with (Copy granted; Modify/Assemble withheld), and that - // bit 10 reads as allowed at R >= 3. The pre-#397 writer reported "not allowed" here + // 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); From 31e05e8d45e14bfbfd0c7d9752aaa266ba27ddcd Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 20:39:07 +0200 Subject: [PATCH 07/11] docs(kernel): scope the bit-10 CHANGELOG claim to sets omitting Extract The entry said any narrower permission set produced the Table 22 violation. Only sets that omitted PdfPermissions.Extract did: All minus Modify includes Extract and always wrote bit 10 set. Two comments still called the bit itself deprecated, the /P-regex comment misdescribed the structure tree's /P as a page reference, and PdfEncryptionSettings. Permissions, the property a caller actually sets, said nothing about the change. Reflow the orphaned comment lines the earlier rewordings left behind. --- CHANGELOG.md | 12 ++++++------ src/VellumPdf.Kernel/Document/PdfDocument.cs | 4 ++-- .../Encryption/PdfEncryptionInfo.cs | 7 ++++--- .../Encryption/PdfEncryptionSettings.cs | 7 ++++++- src/VellumPdf.Kernel/Encryption/PdfPermissions.cs | 7 ++++--- .../Encryption/StandardSecurityHandler.cs | 4 ++-- tests/VellumPdf.Conformance.Tests/Assets/README.md | 10 +++++----- tests/VellumPdf.Kernel.Tests/EncryptionTests.cs | 12 +++++++----- .../PdfValidatorOracleTests.cs | 13 ++++++------- 9 files changed, 42 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 073c2e8b..77a27237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,12 +51,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/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 narrower permission set 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 with narrower permissions - therefore reads back with `Extract` included in `PdfEncryptionInfo.Permissions`. (#397) + 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/src/VellumPdf.Kernel/Document/PdfDocument.cs b/src/VellumPdf.Kernel/Document/PdfDocument.cs index 24e419b4..59f902cb 100644 --- a/src/VellumPdf.Kernel/Document/PdfDocument.cs +++ b/src/VellumPdf.Kernel/Document/PdfDocument.cs @@ -559,8 +559,8 @@ public void Save(Stream destination) // 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 + // 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: PdfEncryptionSettings.Permissions defaults to All (which diff --git a/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs b/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs index e04c8280..c4026af7 100644 --- a/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs +++ b/src/VellumPdf.Kernel/Encryption/PdfEncryptionInfo.cs @@ -72,9 +72,10 @@ public sealed class PdfEncryptionInfo public int KeyLengthBits { get; } /// - /// /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 own since #397), so its presence says nothing about the author's intent. + /// /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 diff --git a/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs b/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs index 9fa1501f..eb9e0daa 100644 --- a/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs +++ b/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs @@ -41,7 +41,12 @@ 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 it still fails the PDF/UA-1 + /// 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 9c6a6026..61e7a32c 100644 --- a/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs +++ b/src/VellumPdf.Kernel/Encryption/PdfPermissions.cs @@ -31,9 +31,10 @@ public enum PdfPermissions /// /// 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, including the ones this library writes, and because + /// 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, diff --git a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs index e4e09fa1..d26ea222 100644 --- a/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs +++ b/src/VellumPdf.Kernel/Encryption/StandardSecurityHandler.cs @@ -55,8 +55,8 @@ public StandardSecurityHandler(PdfEncryptionSettings settings) // 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. + // 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: 0xFFFFF2C0 | enabledLowBits, then clear positions 0-1 (Table 22 bits 1-2). var enabledBits = (int)settings.Permissions; diff --git a/tests/VellumPdf.Conformance.Tests/Assets/README.md b/tests/VellumPdf.Conformance.Tests/Assets/README.md index 2c7bd94a..28f7f743 100644 --- a/tests/VellumPdf.Conformance.Tests/Assets/README.md +++ b/tests/VellumPdf.Conformance.Tests/Assets/README.md @@ -21,8 +21,8 @@ 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 the pre-#397 writer and committed, because #397 ("Kernel: always set /P bit 10 in the encryption -dictionary") made bit 10 unconditional, so there is no longer a way to produce this shape from -the writer itself. +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 @@ -48,9 +48,9 @@ doc.Save(stream); 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` comes 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. +(`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.Kernel.Tests/EncryptionTests.cs b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs index ec225d44..0c1b078c 100644 --- a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs +++ b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs @@ -141,8 +141,9 @@ public void Permissions_None_clears_user_bits() // 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, deprecated in PDF 2.0, which writers shall always set so readers on earlier - // specifications keep treating extraction for accessibility as allowed. + // 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); } @@ -210,9 +211,10 @@ public void EncryptedDocument_allWithoutExtract_writesExpectedP() 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 page back-references - // (/P n 0 R), which this regex would match as well. A lone match therefore proves the - // value came from /Encrypt and not from one of those. + // 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); diff --git a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs index 04e43f28..ca0511c9 100644 --- a/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs +++ b/tests/VellumPdf.Layout.Tests/PdfValidatorOracleTests.cs @@ -261,11 +261,11 @@ public void AesEncrypted_QpdfShowEncryption_ReportsReservedPermissionBitsSet() // Pin the exact value, not just the reserved-bit mask: for Permissions = Copy this is // -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) is the deprecated - // accessibility-extraction bit; 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. + // | 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, @@ -277,8 +277,7 @@ public void AesEncrypted_QpdfShowEncryption_ReportsReservedPermissionBitsSet() // Cross-check that qpdf's plain-language report matches the permissions the // 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. + // 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); From 93908476db7870524abf4a39771e16850ae5410a Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 21:22:57 +0200 Subject: [PATCH 08/11] docs(kernel): finish the guard comment reflow and scope the settings doc The round-4 reflow of the PDF/UA-1 guard comment in PdfDocument.cs moved the mid-sentence break instead of removing it, leaving "Omitting Extract from" alone on a line; the paragraph is now wrapped end to end. PdfEncryptionSettings.Permissions said omitting Extract "still fails the PDF/UA-1 guard" without the precondition every other doc site states: the guard fires only for a PdfConformance.PdfUA1 document. Smaller wording: the Conformance test csproj comment no longer says "bit" twice for the committed fixture, the Assets README keeps one tense inside its /P derivation, and a filler "actually" leaves a test comment. --- src/VellumPdf.Kernel/Document/PdfDocument.cs | 11 +++++------ .../Encryption/PdfEncryptionSettings.cs | 5 +++-- tests/VellumPdf.Conformance.Tests/Assets/README.md | 2 +- .../VellumPdf.Conformance.Tests.csproj | 6 +++--- tests/VellumPdf.Kernel.Tests/EncryptionTests.cs | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/VellumPdf.Kernel/Document/PdfDocument.cs b/src/VellumPdf.Kernel/Document/PdfDocument.cs index 59f902cb..31506474 100644 --- a/src/VellumPdf.Kernel/Document/PdfDocument.cs +++ b/src/VellumPdf.Kernel/Document/PdfDocument.cs @@ -560,12 +560,11 @@ public void Save(Stream destination) // 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: PdfEncryptionSettings.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. + // 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/PdfEncryptionSettings.cs b/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs index eb9e0daa..1f2ba351 100644 --- a/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs +++ b/src/VellumPdf.Kernel/Encryption/PdfEncryptionSettings.cs @@ -44,8 +44,9 @@ public sealed class PdfEncryptionSettings /// /// 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 it still fails the PDF/UA-1 - /// guard on PdfDocument.Save. + /// (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/tests/VellumPdf.Conformance.Tests/Assets/README.md b/tests/VellumPdf.Conformance.Tests/Assets/README.md index 28f7f743..1e43609e 100644 --- a/tests/VellumPdf.Conformance.Tests/Assets/README.md +++ b/tests/VellumPdf.Conformance.Tests/Assets/README.md @@ -47,7 +47,7 @@ doc.Save(stream); 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` comes out as `-516` +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. diff --git a/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj b/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj index 27f502ba..d035d2dc 100644 --- a/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj +++ b/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj @@ -44,9 +44,9 @@ jpx-encrypted-emptyuser.pdf - diff --git a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs index 0c1b078c..843a7d5f 100644 --- a/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs +++ b/tests/VellumPdf.Kernel.Tests/EncryptionTests.cs @@ -191,7 +191,7 @@ public void PValue_matchesHandDerivedKnownAnswer(PdfPermissions permissions, int /// /// 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 actually reach disk rather than only the handler's + /// #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 From eb0ce361a05228e29437d34144ed43ed8b73a9a6 Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 21:53:02 +0200 Subject: [PATCH 09/11] docs(tests): reflow the bit-10-clear fixture comment in the csproj The round-5 shortening of the first two lines pushed the class list down and left a 64-column line between two 90-column ones. The comment is XML-only; no build input changes. --- .../VellumPdf.Conformance.Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj b/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj index d035d2dc..f218ef8c 100644 --- a/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj +++ b/tests/VellumPdf.Conformance.Tests/VellumPdf.Conformance.Tests.csproj @@ -46,8 +46,8 @@ enc-aes-256-p-bit10-clear.pdf From 0626bb10b7ca3d4613b4ae1ac040559ecd6d7da1 Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 22:16:56 +0200 Subject: [PATCH 10/11] docs(kernel): pair the bit-10 fix with a Changed entry, as #189 did The previous Table 22 mask widening (#189, 2.1.0) filed the root cause under Fixed and the byte-level consequence for downstream diffs under Changed. #397 is the same change class, wider in consequence: a document written without Extract now also reports Extract on re-opening. Filing it in both places keeps that consequence visible to a reader who only scans Changed. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a27237..d3c556a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,12 @@ 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 shows it wherever `Extract` was omitted, 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 From 6a5c49acfbd90683b0b8f9052c401f5a1e3573b4 Mon Sep 17 00:00:00 2001 From: Timothy van der Ham Date: Wed, 2 Sep 2026 22:37:38 +0200 Subject: [PATCH 11/11] docs(kernel): give the Changed entry's diff sentence a clear subject "shows it wherever Extract was omitted" left "it" without an antecedent and restated the headline's own condition. The #189 entry's shape ("will show this difference") reads cleanly. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c556a8..9b9941d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --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 shows it wherever `Extract` was omitted, and the permissions - such a document reports on re-opening now include `Extract`. Documents written with `Extract` + 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)