fix(kernel): always set /P bit 10 when writing an encryption dictionary - #399
Merged
Conversation
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.
…nted 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).
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.
Tim81
force-pushed
the
fix/kernel-encrypt-p-bit10
branch
from
September 2, 2026 16:58
f7bb6ca to
2f35691
Compare
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.
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.
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.
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.
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.
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.
"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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #397.
Why
ISO 32000-2 §7.6.4.2 Table 22, bit 10 of
/P: "Not used. This bit was previously used to determine whether content could be extracted for the purposes of accessibility, however, that restriction has been deprecated in PDF 2.0. PDF readers shall ignore this bit and PDF writers shall always set this bit to 1 to ensure compatibility with PDF readers following earlier specifications." This library writes%PDF-2.0, andStandardSecurityHandlerset the bit only when the caller passedPdfPermissions.Extract. APermissions = All & ~Extractdocument therefore violated Table 22 and failed PDF/UA-1 §7.16-1 (the veraPDF test is(P & 512) == 512), which is why #396 had to commit a pre-fix fixture.What
StandardSecurityHandler.cs: the always-on mask becomes0xFFFFF2C0(bits 7, 8 and 10 forced on; bits 1 and 2 cleared; bits 13 to 32 reserved as 1).ComputePerms(Algorithm 10) readsPValue, so the/R 6/Permsseal moves with it.PdfPermissions.Extractstays on the Shipped enum, no[Obsolete], no PublicAPI change; its XML doc now says the writer sets the bit regardless of the flag and readers ignore it. The flag still describes files written to earlier specifications and still feedsPdfDocument's PDF/UA-1 guard.PdfDocument's PDF/UA-1 guard (encrypting a PDF/UA-1 document withoutExtractthrows) stays, by maintainer decision: it names a caller's declared-intent mistake. Its comment no longer implies the written bit depends on the flag.EncryptionSetupreports/Perms ?? /Pmasked withPdfPermissions.Alland never refuses a clear bit 10 (readers "shall ignore this bit"). The committed Conformance: PDF/UA §7.16-1 — validate encrypted-file /P permission bit #138 fixtures (enc-aes-256-p-bit10-clear.pdfand its-4twins) open and report exactly as before; Conformance.Tests with veraPDF required is green on them.Expected values, derived from Table 22 by hand
P = (0xFFFFF2C0 | (bits & 0xFFF)) & ~3, withAll = 0xF3C,Copy = 0x10,Extract = 0x200:/PNone0x2C00xFFFFF2C0= -3392 (was -3904)Copy0x2D00xFFFFF2D0= -3376 (was -3888)All & ~Extract0xD3C | 0x2C0 = 0xFFC0xFFFFFFFC= -4 (was -516)All0xFFCTests
EncryptionTests:Permissions_None_clears_user_bitsnow expectsPValue & 0xF3C == 0x200; a new Theory (PValue_matchesHandDerivedKnownAnswer) pins the four values above onStandardSecurityHandler.PValue; one end-to-end test (EncryptedDocument_allWithoutExtract_writesExpectedP) pins-4in the written/Encryptdictionary forAll & ~Extractand re-checks it through the decrypted/Permsblock; the/Permsseal test (Permissions = Print) now expects the reader to reportPrint | Extract, the direct consequence of the fix.PdfValidatorOracleTests(qpdf,Permissions = Copy): pinnedP = -3888becomesP = -3376, and the test now also assertsextract for accessibility: allowed. qpdf reports bit 10 on that line at/R >= 3, and on the same document the pre-fix writer producedextract for accessibility: not allowed, the discriminating pre-fix measurement. qpdf 12.3.2 output on the new bytes:StandardSecurityDecryptorTestsp: -3904literals are parsed inputs and are unchanged.Comments in
UaEncryptionPermissionsRuleTestsandAssets/README.mdthat described the pre-fix writer in the present or future tense now describe it as the pre-Kernel: always set /P bit 10 in the encryption dictionary (ISO 32000-2 Table 22) #397 writer; the arithmetic they cite still yields the committed fixture's/P -516.Review fix-ups (round 1)
The reserved-bit comment in
EncryptionTestscounted bit positions in two frames at once; it now says which positions are counted from the LSB (0-based: 2..5, 8, 10, 11 cleared; 6..7 and 9 forced on) and maps them to Table 22's 1-based bits 7-8 and 10.docs/kernel-guide.mddescribed the PDF/UA-1 guard as if omittingExtractstill changed the written/P; both paragraphs now say the emitted bit is set either way, so the guard checks the caller's declared intent against the profile's promise.The Conformance
Assets/README.mdprovenance block presented its recipe as reproducible; it is labelled as a run against the pre-Kernel: always set /P bit 10 in the encryption dictionary (ISO 32000-2 Table 22) #397 writer at1a85a66, and the arithmetic paragraph names the pre-Kernel: always set /P bit 10 in the encryption dictionary (ISO 32000-2 Table 22) #397 mask it uses.The CHANGELOG bullet adds the
All & ~Extractvalue (-4, same asAll) and the reader-visible consequence: a document written withoutExtractreads back withExtractinPdfEncryptionInfo.Permissions.The handler's mask comment quotes Table 22's "Reserved. Must be 1." for bits 7-8 (it is unconditional there, not tied to
/R); the/Permstest doc says "same permissions" and names the inputsDecryptPermsBlockForTestfixes.The
Permissions = Allfixture doc inUaEncryptionPermissionsRuleTestscredited bit 10 toPdfPermissions.Extract; the mask forces it now, and the doc says so.Review fix-ups (round 2)
eng/aot/VellumPdf.AotSmoke/Program.cs) tampers/Pand checks that the/Permsseal wins, but still expected the pre-fix writer'sPrintalone; with bit 10 forced on the sealed copy readsPrint | Extract, so theaot-smokejob failed on all three runners. The expectation now matches the Kernel suite's twin assertion, and the surrounding comment says why. Found by the round-2 reviewer runningrun-aot-smoke.ps1on a pristine checkout; it was not in the Verification list before, and is now.Permissions = Allhand-derivation inEncryptionTestsclaimed0x2C0's bits were "already covered by0xF3C", which is backwards. It now says the only bitAlladds overAll & ~ExtractisExtract(0x200), which the mask supplies anyway.EncryptedDocument_allWithoutExtract_writesExpectedPAndPermsverified the/Permsof a second handler, not the one on disk; renamed to…writesExpectedP. Its/Pregex now requires exactly one match in the written document rather than taking the first.Review fix-ups (round 3)
/Encryptdictionary"); it now names the Theory onPValueand the single end-to-end test that reads the file.EncryptionTestsstill said bits 7-8 "must be 1 for R >= 3"; Table 22 has no revision qualifier there; round 1 had removed the same words from the handler comment and round 2 from the qpdf oracle comment.PdfPermissions.Extract, the PDF/UA-1 guard, the handler comment) was imprecise: Table 22 deprecates the restriction the bit expressed, and the bit itself is live, with writers required to set it. All four now say so.PdfPermissions.ExtractandPdfEncryptionInfo.PermissionsXML docs now state that the reader reportsExtractfor any file whose bit 10 is set, including every file this library writes since Kernel: always set /P bit 10 in the encryption dictionary (ISO 32000-2 Table 22) #397, so the "readers shall ignore it" quote does not read as a claim about this reader.All & ~Extract:-516, not onlyNoneandCopy) and says which permission sets produced the violation (round 4 corrected that to "any set omittingExtract"); the kernel guide'sPdfEncryptionSettingstable marksExtractas no longer changing the written/P./Pregex comment gives the real reason there is exactly one match (no structure tree, AcroForm or signature in the fixture, since the writer's other/Pkeys are page back-references the regex would also match); the KAT summary says a mask bug cannot be blessed by an expectation taken from its own output; the Assets README calls-516"that mask's arithmetic" rather than Table 22's; the qpdf oracle comment says qpdf consults bit 10 for the line at R >= 3.The round-3 CI-equivalence lens re-ran every gate and all seven test assemblies at
9504460with oracles required (0 failures; the 12 Reader skips are pre-existingThirdPartyreconstruction cases) and the mutation0xFFFFF2C0->0xFFFFF0C0, which six tool-free Kernel tests, the qpdf oracle KAT and the AOT smoke all catch. The blast-radius lens confirmed the only committed/Psnapshot (GoldenTests.Encrypted_projection.verified.txt,-4fromAll) is unchanged and that patching a written/P -3376file to-3888makes qpdf report a/Permsmismatch (exit 3), so the oracle'sexit == 0assertion is not vacuous.Review fix-ups (round 4)
Extractdid:All & ~ModifyincludesExtractand always wrote bit 10 set. The entry and its last sentence now say "any permission set that omittedExtract" and "a document written withoutExtract".afe54d1missed still called the bit itself deprecated (the reserved-bits KAT inEncryptionTests, the value-pinning comment inPdfValidatorOracleTests); both now say the restriction is what PDF 2.0 deprecates./P-regex comment called the writer's other/Pkeys page back-references;PdfStructureTreewrites/Pas a structure element's parent. It now says "indirect references (a widget's or signature's page, a structure element's parent)", which is the property the regex argument rests on.PdfEncryptionSettings.Permissions, the property a caller sets, gains a sentence saying that omittingExtractno longer clears a bit in the written/Pbut still fails the PDF/UA-1 guard;PdfPermissions.Extractpoints at the/Permscopy for/R5 and 6.PdfEncryptionInfo.Permissionsreads "this library's writer has since Kernel: always set /P bit 10 in the encryption dictionary (ISO 32000-2 Table 22) #397"; the orphaned short comment lines the earlier rewordings left behind are reflowed (one survived into round 5, see below).The round-4 CI-equivalence lens re-ran every gate and the Kernel, Layout, Conformance, Reader and Cli test assemblies at
afe54d1with oracles required (0 failures, the same 12 pre-existing Reader skips) and the mutation0xFFFFF2C0->0xFFFFF0C0(7 kills: six Kernel tests and the qpdf KAT). The blast-radius lens confirmed the Reader reportsExtractonly when bit 10 is set in whichever value it decodes (/Permsat/R5-6,/Pbelow), so the committed-516fixture still reads back withoutExtract, and that noREADME, guide, snapshot or workflow outside the diff pins a/Pvalue.Review fix-ups (round 5)
PdfDocument.csmoved the mid-sentence break instead of removing it, so "Omitting Extract from" still sat alone on a line while31e05e8and the round-4 bullet above said the orphans were gone. The paragraph is now wrapped end to end.PdfEncryptionSettings.Permissionssaid omittingExtract"still fails the PDF/UA-1 guard" without the precondition every other doc site states; it now says aPdfConformance.PdfUA1document fails the guard./Pderivation, and a filler "actually" leaves a test comment. The round-4 bullet above that said "all five test assemblies" now names them.bf20cfd, 73-character subject;9390847, 72 characters, one over the house limit;8a8ae13, which still calls the bit itself deprecated) are history on this branch. The repository's squash default isCOMMIT_MESSAGES, so a baregh pr merge --squashwould copy all of them intomain; this PR is merged with an explicit--subject(the PR title) and--bodythat say the restriction is deprecated, so neither wording reachesmain.The round-5 CI-equivalence lens re-ran all eight gates at
31e05e8, including the coverage-threshold script fromci.yml(89.2 % merged over 31 869 lines, every assembly above the 40 % floor) and the PDF 2.0 inventory--check; the full solution in Release ran 5904 total, 0 failed, 14 skipped (the 12 Reader fixture-layout skips plus two transientpdftoppmprobe timeouts thatOracleGate.Transientnever escalates), and the0xFFFFF2C0->0xFFFFF0C0mutation reproduced round 4's 7 kills. The blast-radius lens listed all ten writer sites that emit a/Pkey and confirmed the nine outside/Encryptare indirect references, that31e05e8changed no code token (comment-stripped hashes identical), and that the only committed/Psnapshot is unchanged.Review fix-ups (round 6)
COMMIT_MESSAGES(gh api repos/Tim81/VellumPDF --jq .squash_merge_commit_message), which would concatenate every branch commit message. The bullet now says the merge passes--subjectand--bodyexplicitly, and discloses9390847's 72-character subject alongsidebf20cfd's.### Fixedonly. Encryption: /P bits 7-8 are never set, contradicting the code comment #189, the previous Table 22 mask widening (2.1.0), paired its root-cause entry underFixedwith aChangedentry saying encrypted output bytes differ from earlier versions; Kernel: always set /P bit 10 in the encryption dictionary (ISO 32000-2 Table 22) #397 is the same change class with a wider consequence, so it now has the same pair. TheChangedentry says which documents are affected (those written withoutExtract), that they now reportExtracton re-opening, and that documents written withExtractcarry the same/Pvalue as before.The round-6 prose lens re-read Table 22 at physical pp. 94-95 and confirmed all six deprecation sites scope it to the restriction, re-derived the mask and the four
/Pvalues, counted the em dashes over the whole branch diff (2 added, 6 removed, both additions rewrites of lines that already carried one), and confirmed every test count and the ten/Pwriter sites named in this body. The blast-radius lens re-derived every/Pliteral the branch adds or changes from Table 22 by hand (all matching), enumerated every consumer ofPValueand of the Algorithm 10 seal acrosssrc/andeng/(two changed, the rest shown not to need an edit), read all 22 committed encrypted PDFs with qpdf (exactly one bit-10-clear file,P = -516; the other 21 are-4), recomputed the four SHA-256 pins, and confirmed noPublicAPI*file changes.CI at
9390847and ateb0ce36both failed on the same test this branch does not touch,EncryptDictionaryDenialOfServiceTests.HugeEncryptDictionary_opensUnderTimeout([Fact(Timeout = 10_000)], from #215), cancelled at 10.1 s and 10.0 s on the shared runner; it takes 0.33 s here in Release, and the same runner ran a 30 s-bounded Reader test from #398 at 33 s and 52 s the same day. The AOT smoke jobs were green on all three runners both times. A rerun of theeb0ce36job passed with no code change, and the next push (0626bb1) passed first time. That timeout is a runner-load flake in a main-branch test; #400 tracks it together with the same-day flake in #398.Verification
dotnet build,dotnet format --verify-no-changes, clean-room check: green atea2c5c5,bf20cfd,9504460,afe54d1,31e05e8,9390847,eb0ce36,0626bb1and6a5c49a. At31e05e8: Kernel.Tests 1398 total, 0 failed; the qpdf oracle class 39 total, 0 failed;UaEncryptionPermissionsRuleTests5 total, 0 failed (oracles required);pwsh ./eng/aot/run-aot-smoke.ps1printsAOT smoke PASSED, exit 0 (it failed at2f35691, see round 2). At9390847(comment-only):EncryptionTests58/0,UaEncryptionPermissionsRuleTests5/0,EncryptedFixtureDigestTests4/0.dotnet execwithREQUIRE_ORACLES=1,QPDF_HOME,POPPLER_HOMEandVERAPDF_HOMEset (PowerShell): Kernel.Tests 1398 total, 0 failed, 0 skipped; Layout.Tests 220 total, 0 failed, 0 skipped. Reader.Tests 1029 total, 0 failed, 12 skipped; Conformance.Tests with veraPDF required 1281 total, 0 failed, 0 skipped (skip counts re-measured by the round-1 reviewer from a pristine checkout ofea2c5c5with all three*_HOMEset; the earlier figures came from a run withoutPOPPLER_HOME).