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); }