M04 — Revisions and signature coverage
State: to do — Depends on: M03 — Crypto-free by ADR 41
Goal
Say what a document looked like at each of its revisions, what every signature in it covers, and what changed after each signature — without any cryptography.
A signature's cryptography answers one question: were these bytes signed with this key? Everything around it needs none, and is where signed documents are actually attacked: which bytes, which revision, and whether what came after was allowed. A shadow attack leaves a signature that verifies over a revision nobody is shown; an incremental saving attack appends content a viewer displays and a signature does not cover. The same analysis stops the library's own writer from voiding a certification in silence, before M05 repairs, M06 merges and M09 stamps signed files.
Scope
In:
- T25 first — a
/Prevthat misses its section drops it in silence — unless M02 has fixed it already, as its exit criteria require: a revision history cannot leave out what the reader hid. - The revision history: each save's sections, grouped into revisions, each revision's end, the bytes after the last one; recorded while indexing, grouped on first use.
- Revision n opened read-only as a lazy index cut at its own end, and copied out byte for byte.
- Comparing two revisions: the objects added, changed, rewritten unchanged and freed between them.
- A public, read-only signature model: every signature field, document time stamp and usage-rights
signature, its
/ByteRangechecked against the file and against the revision it closes, and the gap it leaves checked against its own/Contents. - Permissions:
DocMDP(itsP),FieldMDP(from/Lockand from the signature's/Reference), and the presence of usage rights. - Change classification: every change made after a signature given a class — form fill, annotation, new signature, document time stamp, security store, metadata, other — and checked against the permissions in force, so that shadow attacks and incremental saving attacks are reported.
- The
signature.*rule family in M02's structural profile. - The write guard: M03's incremental save refuses a change set a certification or a field lock forbids, unless the caller insists, and the call M05, M06 and M09 make before they write.
- M03's cancellation and progress convention, on enumeration, comparison and classification (stage
Comparing).
Out, explicitly:
- Anything cryptographic — parsing the CMS in
/Contents, the digest over the byte range, the signature value, certificate chains, revocation, time-stamp tokens, the legacy MD5 object digests of a/Reference— is M27's, inAdCodicem.Pdf.Signing(ADR 41). M04 says covers, never valid. - Signing, certifying, adding a security store — M26 and M27.
- The meaning of usage rights, and their removal — M16. M04 records their presence and their coverage.
- Decryption — M16. An encrypted document gets its revisions and its coverage, which need no key; its change classification waits, reported as not analyzed.
- Seeing what a change looks like — M24's visual diff on M25's rasterizer. A change that is allowed but covers signed content with an appearance is reported as the change it is, not judged by eye.
- Removing earlier revisions — M19's sanitization. Repairing a signed document — M05, which uses M04 to say which revision each remedy lands in.
Design
The revision history
PdfRevisionHistory document.Revisions: the revisions oldest first; IsReliable; TrailingBytes
PdfRevision Index; End, the offset just past its %%EOF line; StartXRef; Sections (offset and kind);
Trailer; Open() → a read-only PdfDocument; CopyTo(Stream) → its bytes, verbatim
PdfRevisionDiff history.Compare(a, b): objects added, changed, rewritten unchanged and freed, by
identifier, computed on demand
- Recorded at opening, grouped on first use. The reader already walks the cross-reference chain; it now
keeps, per section, its offset, its kind — a table, a stream, a hybrid table with its
/XRefStm— and its/Prev: a few integers each, bounded byPdfReaderLimits.MaxXRefSectionCount, so M04 adds no bound for ADR 34 to classify. Opening reads nothing more than it did. - A revision is what one save wrote. A hybrid file's table and the stream its
/XRefStmnames are one section. A linearized file's first-page section and its main section are one revision: the linearization dictionary at the head of the file names the main section by its/T, and the%%EOFafter the first-page trailer —startxref 0in two corpus files — ends nothing. An update whose section is empty, as Word's hybrid files write, is a revision that changes nothing. - A revision ends just past the
%%EOFthat follows its last-written section, line end included when there is one (LF, CR or CR LF), found by reading a small window after that section's trailer. No marker there: the end is unknown, reported (revision.end-not-found), and the coverage of a signature closing that revision undecidable rather than guessed. - After the last revision: whatever lies past its end — junk after
%%EOF, an updatestartxrefcannot reach, a section the chain leaves out — is measured by reading that tail alone, and exposed asTrailingBytes(revision.trailing-bytes, information). A signature over the last reachable revision is then whole revision, not whole file. - A rebuilt index has no chain. When the reader rebuilt it, the history is reconstructed from the
trailers the rebuild's scan found and marked
IsReliable = false; every signature analysis on it says so. - After T25, a
/Prevfound a few bytes off is a relocated section of its revision, reported as the reader reports it; one never found leaves a revision reported missing, never silently merged into the next.
Opening and extracting a revision
PdfRevision.Open()runs the M01 reader over a slice of the source that ends at the revision's end:startxrefis read in that slice's tail and the chain followed from there, so the index read is exactly that revision's — its own section and the earlier ones — and nothing past its end is even reachable. The slice shares the document's source without owning it; objects stay lazy.- The view is read-only: it refuses a change set, since an update of an old revision would fork the history.
CopyTowrites its bytes verbatim — Acrobat's View signed version, and the file a referee opens.
Comparing revisions
- The objects a revision defines come from its sections' entries, read again on demand through its slice;
nothing is kept between calls.
Compare(a, b), foraolder thanb, takes every object number the revisions afteraup tobdefine, and sets its definition atbagainst its definition ata: added, freed, or redefined. - A redefined object is compared by
PdfObjectComparer, internal and in the core — M03's graph comparer stays a test oracle, independent of what it checks: direct content in depth, references by identifier, streams by dictionary and raw bytes, and, only when the raw bytes differ under the same filters, by their decoded bytes under the document's limits. Equal decoded content is rewritten unchanged: Acrobat recompresses what it re-saves. - An object stream an update rewrites — PDFMaker 10's update rewrites the pages held in one — is compared object by object, never as a stream.
- Memory follows the objects the later revisions define, not the document; the token is checked per object.
Signatures
PdfSignatureInfo FieldName (fully qualified); Kind; Filter; SubFilter; the signing time as written (text,
never read against a clock); ByteRange (the pairs as read); Contents (offset and length
of the <…> string in the file); Coverage; Revision (the index it closes, or null);
Permissions
PdfSignatureKind Approval | Certification | DocumentTimestamp | UsageRights
PdfSignatureCoverage WholeFile | WholeRevision | Partial | Malformed | Placeholder | NotApplicable | Undecidable
PdfSignaturePermissions DocMdp (1, 2, 3 or none); FieldLock (All, Include or Exclude, and the field names);
where each was declared; whether /Lock and /Reference disagree
document.Signatures lists them, lazily. They are found by M03's walk, made public: fields whose /FT —
inherited — is /Sig, their widgets and their /V; /Perms /DocMDP, which must name one of them;
/Perms /UR3 and the legacy /UR; /Type /DocTimeStamp with the ETSI.RFC3161 subfilter. The walk is
iterative with a visited set: EU DSS's DSS-1872 file holds a signature widget that is its own /Parent.
Coverage. The /ByteRange must be pairs of non-negative integers, ascending, not overlapping, starting
at 0 and inside the file, leaving exactly one gap; the gap must hold exactly this signature's /Contents
string, from its < to its >, and nothing but hexadecimal digits between them; the signature dictionary
must lie inside the covered bytes; and the covered bytes must end at a revision's end, with its line end or
without it — DocuSign's seal ends right after %%EOF, GPO's certification after its line feed, DILA's after
a CR LF. Then:
| Coverage | When |
|---|---|
WholeFile | It ends at the last revision's end and nothing follows |
WholeRevision | It ends at a revision's end and later revisions, or trailing bytes, follow |
Partial | It ends anywhere else, or leaves bytes other than its own /Contents uncovered |
Malformed | The array or the gap is not what the specification requires, or the dictionary sits in an object stream, where no byte range can exclude its /Contents |
Placeholder | Unsigned: names or zeros in the /ByteRange, a /Contents of zeros — node-signpdf's PDFKit file |
NotApplicable | A legacy /UR with no byte range at all |
Undecidable | The revision's end is unknown, or the history unreliable |
The /Contents string is located by lexing the signature dictionary's own bytes, at its offset, with the M01
lexer and its bounded window — never by searching for a < from the gap's edge. Every /ByteRange value is
checked against the file's length before it is used, and none sizes an allocation; the array is as long as
the parser admitted, under MaxObjectLength, and is read in one pass.
Permissions
- DocMDP: the signature's
/Referenceentry whose/TransformMethodis/DocMDP, and its/TransformParams /P— 2 when absent —, with/Perms /DocMDPnaming the same dictionary. Two certifications, aPoutside 1 to 3, a/Permsentry that names no field's signature, or a certification after an approval signature is reported (signature.permissions-malformed); aPout of range is read as 1, the most restrictive, since rejecting by default is the only safe reading. - FieldMDP: the signature field's
/Lock(/ActionAll, Include or Exclude,/Fields, and in PDF 2.0 its own/P), and the/Referenceentry whose transform is/FieldMDP. Where the two disagree, the union of what they lock applies, reported. - Usage rights: present or not; their transform parameters kept for M16.
- Legacy object digests in a
/Reference(/DigestMethod /MD5,/DigestValue,/DigestLocation, in the Avow and Slovak NBU files) are recorded and left unverified: M27.
Change classification
For every signature closing revision k, each object a revision after k adds, redefines or frees gets a role and a class, then a verdict against the permissions in force.
- Roles come from walking, in the latest revision, only the structures that may legitimately change —
the AcroForm field tree with its widgets and their appearance streams, each page's
/Annotswith their appearances and pop-ups, the document security store,/Perms,/Infoand the metadata stream — and the containers that lead to them: the catalog, the pages, the AcroForm dictionary. Walks are iterative, with visited sets, bounded by the index. - A redefined container is compared key by key, and its changed keys decide its class: a page whose only
change is its
/Annotsis an annotation change; a catalog whose only changes are/DSSor a new signature field's entry in/AcroFormis that; any other key is other.
| Class | What | pyHanko's level |
|---|---|---|
Unchanged | Redefined identical, or rewritten unchanged | NONE |
SecurityStore | The security store, its certificates, OCSP responses, CRLs and /VRI, and the catalog's /DSS | LTA_UPDATES |
DocumentTimestamp | A document time stamp's field, widget and dictionary, and the AcroForm entries that list it | LTA_UPDATES |
FormFill | A field's /V, a widget's /AS, a widget's regenerated /AP, /NeedAppearances | FORM_FILLING |
NewSignature | An empty signature field signed, or one added, with its widget, appearance and /Lock | FORM_FILLING |
Annotation | An annotation other than a widget added, changed or removed, with its appearance and pop-up, and the page's /Annots | ANNOTATIONS |
Metadata | /Info and the catalog's metadata stream | the level pyHanko gives it on the first corpus document that has one, and the ISO text |
Unreferenced | An object the revision defines and nothing reachable refers to | recorded, not scored |
Other | Everything else: content streams, resources, fonts, the page tree, catalog keys outside the above, an object of the signed revision redefined to something else | OTHER |
- Allowed:
DocMDPP=1 allowsUnchanged,SecurityStoreandDocumentTimestamp; P=2 addsFormFillandNewSignature; P=3 addsAnnotation. A field lock forbidsFormFillon the fields it locks, from the signature that set it. An approval signature with neither allows every change by the specification — and M04 still reports eachOtherafter it, since that is precisely an incremental saving attack: content a viewer shows and no signature covers. - Verdict per signature:
Intact(nothing after it),AllowedChanges,ForbiddenChanges— each change naming the permission it breaks — orNotAnalyzed, with the reason: encrypted, unreliable history, undecidable coverage. - The attacks this reports. Of the shadow attacks (Mainka, Mladenov, Rohlmann, Schwenk, NDSS 2021):
hide, an object that covered content in the signed revision removed or redefined, is
Other; replace through a redefined font or content stream isOther; hide-and-replace, a later revision whose catalog or page tree shows a document the signed revision carried unseen, isOther. Replace through a form field whose appearance lies over signed content is aFormFill, allowed under P=2, and reported as a fill of the named field — only a rendering sees the overlap (M24, M25). The incremental saving attack (Mladenov et al., 2019) is anOtherafter an approval signature. Signature wrapping is a gap that is not/Contents; universal signature forgery, a/ByteRangeor/Contentsmissing, null or empty, isMalformedorPlaceholder.
The write guard
- Before an incremental save, M03's writer classifies the pending change set as if it were the next revision.
A change a certification or a lock forbids is refused with
PdfSignatureInvalidationException, naming the signature and the permission; withAllowInvalidatingSignaturesit is written, and each signature it breaks reported aswrite.signature-invalidated, with the permission. A full rewrite was refused by M03 already. - The same check,
WouldBreakSignatures(changeSet), is the internal call M05, M06 and M09 make before they write: the roadmap's "refused, or reported when the caller insists".
The signature.* rules
| Rule | Severity | Meaning |
|---|---|---|
signature.byte-range-malformed | Warning | Not pairs of integers, negative, overlapping, outside the file, not starting at 0 |
signature.gap-not-contents | Error | The bytes left out are not exactly this signature's /Contents: something no validator digests sits there — the shape of signature wrapping, on which validators disagree |
signature.revision-partially-covered | Warning | The covered bytes end before or inside a revision, or skip bytes outside the gap |
signature.dictionary-in-object-stream | Warning | The signature dictionary sits in an object stream, where its /Contents cannot be excluded |
signature.unsigned-placeholder | Information | A signature field whose value is a placeholder |
signature.permissions-malformed | Warning | Two certifications, a P out of range, a /Perms entry naming no field's signature, a lock that disagrees with its reference, a certification after an approval signature |
signature.forbidden-change | Error | A later revision makes a change a certification or a lock forbids: some validators notice and some do not, which is M02's definition of an error |
signature.unexplained-change | Warning | A change of class Other after an approval signature that no permission restricts |
Severities follow M02's bar (ADR 45): Error only where the reader cannot vouch that it reads the document as
written. Every signed corpus
entry's findings gains what these rules report on it.
Expectations from a referee
The manifest gains expect.revisions (a count) and expect.signatures (per signature: field, revision
closed, coverage, and the modification level of what follows), written for committed and remote entries by
build_corpus.py running pyHanko — pinned in requirements.txt — and never by our reader; the schema gains
both. A disagreement is recorded with its reason, as conformanceValid records veraPDF's verdict; M27 adds
EU DSS's verdict to each signature, beside pyHanko's.
The schema also gains derivedFrom — the document a derived variant comes from and the name of its recorded
transformation in build_corpus.py —, which today sits only in a producer string ("derived from
chromium-invoice-fr.pdf") and which the crafted fixtures are the first to need in number; the decrypted
twins, stripped fonts and turned scans of later milestones use it too.
Slices
- T25, if still open, and sections recorded. A synthetic regression — a
/Preva few bytes off, one pointing nowhere — then the relocated or missing section reported; the QMF manual (remote, unsupported for T25) supported. The reader keeps each section's offset, kind and/Prev. Proved by the regression tests, and the laziness test of M01, unchanged: opening reads no more. Leaves the grouping. - Revisions. Grouping (hybrid pairs, linearized pairs, empty updates), ends, trailing bytes, the unreliable
history of a rebuilt index; the
revision.*diagnostics; the manifest'sexpect.revisions,derivedFromand their schema. Proved by synthetic files of each shape — tables, streams, hybrid, linearized then updated, an update glued to%%EOF, astartxrefa byte early — and byCorpusRevisionTestsagainst pyHanko's count recorded for every committed document; integration: pyHanko in a Python container. Leaves opening one. - Opening and extracting a revision. The slice,
Open,CopyTo, the read-only view. Proved by a counting source: opening revision n reads no byte past its end and no object body; integration: each revision's copy, handed to qpdf (--check,--show-npages) and pikepdf, opens with the page count and the object numbers our view reports. Leaves comparison. - Comparing revisions.
PdfObjectComparer,Compare, rewritten unchanged. Proved by unit tests per outcome; on the corpus, the metadata-only revisions ofpdfmaker25-home-office-eiadiffer by metadata alone, the revision ofillustrator-irs-pub1-englishthat removes text differs by its content stream, Word's empty update by nothing; integration: a pikepdf script over two revision copies lists the objects that differ, and agrees. Leaves signatures. - Signatures and their coverage.
PdfSignatureInfo, the walk, the location of/Contents, the coverage table,expect.signatures. Proved by a unit test per coverage and per hostile byte range; on every signed committed document, coverage and revision equal pyHanko'sevaluate_signature_coverage()andsigned_revision, and the ranges equalpdfsig's. Leaves permissions. - Permissions. DocMDP, FieldMDP from both places, usage rights, legacy digests recorded. Proved by the GPO certification read as P=1 with its lock, DocuSign's lock read, and the remote certifications — the Avow sample at P=1, the Slovak NBU seal at P=2 with a lock, Foxit's and Adobe Sign's — read as pyHanko reads them; hostile permissions in unit tests. Leaves classification.
- Change classification. Roles, classes, verdicts,
NotAnalyzed. Proved by the signed corpus documents with later revisions, each classified as pyHanko's difference analysis classifies it, and by the crafted fixtures of the acceptance conditions; integration: pyHanko'sevaluate_modifications()with its default policy on each. Leaves the rules. - The
signature.*rules. The eight rules,docs/website/docs/reference/validation-rules.md, the manifest'sfindings. Proved byCorpusValidationTests.Every_document_produces_exactly_its_declared_findings, now with them, and a triggering, a silent and a legal-but-unusual document per rule. Leaves the guard. - The write guard. The classification of a pending change set, the refusal, the insisted path,
WouldBreakSignatures; M03's incremental acceptance tests insist explicitly, since what they assert is byte preservation, not permission. Proved by an/Infoedit of the GPO certification refused, and written and reported when insisted, pyHanko then calling it a suspicious modification; a DSS-shaped update of the same file allowed. Leaves the budgets. - Budgets, remote corpus, documentation.
RevisionBenchmarkswithMemoryDiagnoser— enumerating the 48 updates of EU DSS's sheet, opening revision 0 of the United States Code, classifying the 25 signatures; the site. Proved by the laziness condition on the remote documents and a greenRemote corpusrun, recorded indocs/status.mdwith its date.
Tests required
Unit
- Revisions: every grouping shape; an end with LF, CR, CR LF and none;
startxref 0in a first-page trailer; an empty update; trailing junk; an unreachable update; a rebuilt index; a/Prevloop and a chain atMaxXRefSectionCount(the existing guard, reported under its code, the history marked incomplete). - Opening a revision: never a read past its end, counted;
CopyTobyte-identical to the file's prefix; the view refusing a change set. - Comparison: added, freed, redefined identical, recompressed identical, changed; an object stream rewritten with one object changed; an object redefined in every one of 48 revisions.
- Coverage, one test per row of the table, and the hostile: an odd count, negative values, overlapping or
descending pairs, a value past the file, 10,000 pairs, names, reals or references inside the array, an
indirect
/ByteRange, a/Contentsthat is a name or a reference, odd hexadecimal, a gap that holds two objects, a gap that is right but belongs to another signature, the dictionary in an object stream. - Fields: a cycle in the tree, a chain 100,000 kids deep walked without recursion, a signature dictionary held
by two fields,
/FTinherited three levels up. - Permissions: P absent, 1, 2, 3, 0, 4, a name; two certifications;
/Lockagainst/Reference. - Classification: each class from a minimal update; each attack of the design, crafted; a
Metadatachange alone; an unreferenced object; an encrypted input reported not analyzed. - The guard: refusal, insisted path, and an empty change set that is never refused.
- Cancellation and progress on enumeration, comparison and classification, per M03's property.
Integration — each tool in a container (ADR 27):
- pyHanko: the revision count (
XRefCache.total_revisions), each signature'ssigned_revisionandevaluate_signature_coverage(), andevaluate_modifications()under its default difference policy — all of which need no trust anchor. - qpdf and pikepdf: each revision's copy opened on its own — page count, object numbers, the objects two copies differ by. Neither enumerates revisions, which is why the copies are what they judge.
- poppler's
pdfsig: each signature's signed ranges, and whether it says the whole document is signed.
Acceptance conditions
| Documents | Behavior | Verified by |
|---|---|---|
| Every committed document, encrypted ones included — revisions need no key | The number of revisions pyHanko counts; each revision's copy opens in qpdf and pikepdf — with its password where the manifest records one; pdfmaker9-word-distiller-aes128-unknown-open-password, whose password nobody recorded, is left out of the copy check with that reason — with the page count and object numbers our view of it reports | CorpusRevisionTests.Every_document_has_the_revisions_an_independent_tool_sees, RevisionRefereeTests.Each_revision_opens_on_its_own_as_we_describe_it |
The committed documents of unusual history: linearized then updated (pdfmaker5-distiller5-va-select-agents, whose update's /Prev goes through the first-page section; pdfmaker25-home-office-eia, whose updates change only metadata; illustrator-irs-pub1-english, whose update removes text), indesign13-pdfua1-german-book-chapter (four revisions), indesign-irs-pub1-russian (six updates over a hybrid index), libreoffice-cerfa-13983-form (re-saved four times by other tools), word-invoice-fr (an empty update), pdfmaker10-word-dh-care-bill-factsheet (an update that rewrites pages inside an object stream), handwritten-dual-startxref (a section the chain leaves out) | Linearized pairs counted as one revision; each comparison of consecutive revisions lists exactly the objects pikepdf finds different between their copies — metadata alone, the content stream that lost its text, nothing for the empty update; the section outside the chain reported as trailing bytes | CorpusRevisionTests.Consecutive_revisions_differ_by_what_an_independent_tool_sees |
The signed committed documents: itext-govinfo-us-code-certified (GPO: DocMDP P=1 and a lock, closing the second revision), fop-dictao-dila-signed-joafe-notice, pyhanko/acrobat-reader-signed-twice, node-signpdf/skia-chrome74-node-signpdf-reason-contains-trailer, antenna-house-legilux-memorial-pades-lta (an unsigned base, a seal, a security store, a document time stamp), fop22-legilux-memorial-seal-renewed-timestamps (an unsigned base, a seal, a security store, two document time stamps), docusign-pdfkit-gsa-sf30-contract-modification (a seal with a lock in the first revision, then a security store) | Each signature's field, kind, revision closed and coverage are pyHanko's; its ranges are pdfsig's; a signature closing the last revision is whole-file, any other whole-revision — DocuSign's only seal among them, its range ending at %%EOF without the line end, since a security store follows; the first of pyHanko's Acrobat signatures closes a linearized revision of two sections | CorpusSignatureTests.Each_signature_covers_the_revision_pyhanko_says_it_covers, PdfsigRefereeTests.Signed_ranges_are_the_ones_pdfsig_reads |
| The same documents' later revisions, and remote ones: EU DSS's 25 signatures in 48 updates, the Word 2019 DIIA B-LT and B-LTA files, the NeoOffice seals and the Mentana file with their document time stamps, node-signpdf's two signatures over OpenOffice, the re-signed BOE decree, the Canadian IMM 1344 form whose usage rights were added after its certification | Every later change carries a class, and the class maps to the level pyHanko's difference analysis gives it; none is forbidden, since each is a security store, a time stamp or a new signature — or, for the Canadian form, whatever pyHanko and the ISO text make of usage rights added after a certification, recorded with its reason | CorpusSignatureTests.Changes_after_signing_are_classified_as_pyhanko_classifies_them |
| To be added (see What it lacks): crafted from a signed twin of our contract — a DocMDP P=2 certification then a field filled and a countersignature; P=1 then a security store and a document time stamp; an annotation added under P=2 and under P=3; a locked field changed and an unlocked one; shadow hide, replace (by font, and by a field's appearance) and hide-and-replace; an incremental saving attack after an approval signature; signature wrapping; universal signature forgery | Each allowed change is classified and raises no finding; each forbidden one is signature.forbidden-change, the wrapping signature.gap-not-contents, the forgery a malformed or placeholder coverage, the saving attack signature.unexplained-change, the appearance replace a fill of the named field; wherever we report a forbidden change, pyHanko's difference analysis reports a suspicious modification or a level the permission does not allow | CorpusSignatureTests.Shadow_attacks_and_forbidden_changes_are_reported |
pdfkit-node-signpdf-unsigned-placeholder; the usage rights of livecycle-irs-1040-2022-xfa-ur3, livecycle-es9-cerfa-14880-xfa-form and indesign-acrobat-hmcts-n208-form | The placeholder covers nothing and earns signature.unsigned-placeholder; each usage-rights signature is listed with its coverage, taken from the file's %%EOF positions since neither pyHanko nor pdfsig reads /Perms | CorpusSignatureTests.Placeholders_and_usage_rights_are_described_not_trusted |
The encrypted signed documents: pikepdf/livecycle-dod-dd293-aes128-xfa, livecycle-uscis-ar11-xfa-form; remote, the Adobe Sign certification under AES-128 and the Canadian IMM 1344 form | Revisions and coverage computed without the key; classification reported as not analyzed until M16, never guessed from ciphertext | CorpusSignatureTests.Encrypted_documents_get_coverage_without_decryption |
Remote signed documents with damage: EU DSS's DSS-1872 file (startxref and byte range past the end, /Contents shorter than its gap, a widget that is its own parent), Foxit's certification over a garbage /BBox (damage inside the signed range), Foxit's single-save signatures | The malformed range reported, the cycle walked once, no exception; a single-save signature covering the whole file is whole-file | CorpusSignatureTests.Malformed_signatures_are_reported_never_trusted |
| Every document | Exactly the findings the manifest declares, signature.* included | CorpusValidationTests.Every_document_produces_exactly_its_declared_findings |
itext-govinfo-us-code-certified | An incremental save that edits /Info is refused (P=1); insisted on, it is written, reported, and pyHanko calls it a suspicious modification; an update that adds only a security store is accepted | CorpusSignatureTests.A_save_that_would_void_a_certification_is_refused |
| The 9,302-page United States Code and EU DSS's 48-update sheet (remote); the committed 1000-page journal after an M03 update | Opening revision 0 reads no byte past its end and nothing but its sections and trailers, counted; enumerating 48 revisions reads only their sections; within budgets recorded in docs/status.md. M04 closes only on a green Remote corpus run | CorpusRevisionTests.Opening_a_revision_reads_only_its_own_index |
Corpus
What the corpus holds
- Signatures: the seven committed signed documents above — approval, certification with DocMDP P=1 and a
field lock, PAdES B-LTA, renewed document time stamps, a commercial e-signature with its security store
added later, a
/Reasonholding the wordtrailer, a range that ends without its line end, a signature closing a linearized revision — beside some 25 remote ones undersigned-*,pades-b-*,docmdp,fieldmdp,document-timestamp,dss,vri,multiple-signaturesandmany-signatures: certifications at P=1 (Avow) and P=2 (Slovak NBU, with MD5 object digests), Foxit's single-save signatures, the legacyadbe.x509.rsa_sha1andadbe.pkcs7.sha1subfilters, RSASSA-PSS and ECDSA seals, DocuSign, Adobe Sign and Yousign. - Unsigned and odd: the PDFKit placeholder (
byterange-name-placeholders,signature-contents-all-zero), unsigned signature fields (unsigned-signature-field, the Cerfa 12156 and OPM OF-306 forms), usage rights (usage-rights-ur3,ur3-usage-rights,ur-signature-without-byterange), signed and encrypted forms. - Damage near signatures:
byterange-past-eof,signature-contents-shorter-than-byterange,damage-inside-signed-byte-range,field-tree-cycle,startxref-off-by-one,startxref-points-before-xref-keyword,update-glued-to-eof-marker,unreferenced-objects-in-update. - Histories without signatures: 47 committed documents with
incremental-updateorincremental-updates— linearized then updated, metadata-only updates, an update that removes text, an empty update, an update that rewrites an object stream — and remote ones up to 48 updates and a dashboard's nine (nine-incremental-updates). - Scale: the 1000-page journal committed; the 9,302-page United States Code, signed in an update, remote.
What it lacks
| Need | Why | Priority | Likely source |
|---|---|---|---|
| Crafted attack fixtures — shadow hide, replace (by font and by field appearance) and hide-and-replace, an incremental saving attack, signature wrapping, universal signature forgery — each derived from a signed document by a recorded transformation | The roadmap's acceptance names them, and no real document in the corpus carries a forbidden change | 1 | Generated here: pyHanko over the signed twin of our contract, then byte-level edits in build_corpus.py; the Ruhr-Universität Bochum's published exploit files (pdf-insecurity.org) as a second source, remote unless their license allows committing |
| Certified documents with later changes, allowed and forbidden: P=2 then a fill and a countersignature, P=1 then a security store and a document time stamp, an annotation under P=2 and under P=3, a locked field changed beside an unlocked one | No committed certification is followed by any change: the GPO's closes the last revision, and the remote certified files end with their certification as far as their entries say, but for the Canadian form, which adds only usage rights after it | 1 | Generated here: pyHanko's certify, fill, sign and offline test time-stamper, over a fictitious test PKI, pinned in requirements.txt |
| pyHanko's revision count and per-signature verdicts — revision, coverage, modification level — recorded in the manifest for every committed and remote entry | Acceptance rests on the referee's opinion recorded in advance, never on our reader's | 1 | Generated here: build_corpus.py --committed-only and --remote running pyHanko |
| A signed twin of our contract (also M03's) | The base every crafted fixture derives from, reproducible from our own source | 1 | Generated here: pyHanko over documents/contract/chromium-contract-fr.pdf |
| A real document annotated or countersigned in Acrobat after someone else signed it | Acrobat rewrites /Info and the XMP with every save, so its real updates mix classes — metadata with annotations or signatures — that crafted files keep apart | 2 | A contribution (docs/corpus-contributions.md) |
| A signature dictionary stored in an object stream | The coverage it earns is designed but met in no file | 3 | Generated here: a crafted variant of the signed twin |
Traps
- A linearized file has two
%%EOFmarkers and one revision, and several OPF entries carry the featureincremental-updatesfor that reason alone. Inpyhanko/acrobat-reader-signed-twice, Acrobat Reader linearized the document as it signed it: its/Lis exactly where the first signature's range ends, so three markers make two revisions. Counting markers is not counting revisions; referees differ on it, and the manifest records pyHanko's count with any disagreement's reason. - Where a byte range ends varies: after
%%EOF(DocuSign), after its line feed (GPO), after its CR LF (DILA). All three are the whole revision. - A
startxrefa byte or a line off (node-signpdf, Mentana) is where the reader found the section, not where the file says; revision boundaries follow the sections' true positions. - Keywords inside strings: node-signpdf's
/Reasoncontainstrailer. Revisions come from the chain, never from a keyword search. /Contentsis found by lexing its dictionary, not by looking for a<near the gap: a wrapped signature puts a plausible<exactly there.- Certification is not always first — GPO certifies in its second revision, over an unsigned first — and ISO 32000-2 wants a certification to precede approval signatures, which is what the rule checks, not the revision index.
- DSS and document time stamps under P=1. PAdES and ISO 32000-2 permit them after any certification; the exact text of ISO 32000-2 12.8.2.2.2 is checked before the rule is written, and pyHanko's policy compared, so that no archiving update is called forbidden.
- Acrobat touches metadata on every save: a countersignature or a comment arrives with
/Infoand XMP changes. A classification that calls themOthercalls every real countersigned file tampered. - Some shadow attacks are allowed changes. A form field whose appearance covers signed text is a fill a P=2 certification permits; saying so plainly, with the field's name, is honest; calling it forbidden would be wrong, and calling it harmless would be worse.
- Ciphertext is not content. AES strings carry a random initialization vector: two encryptions of the same value differ, so an encrypted document's changes cannot be classified before M16 decrypts them.
- The field tree is hostile: cycles, a widget that is its own parent,
/FTinherited, one signature dictionary under two fields. Walk iteratively, with a visited set. - An object stream rewritten by an update changes as a stream when only one object inside it changed.
- A rebuilt index has no history. Report it unreliable; never present a guessed revision as fact.
- Covers is not valid. Every page of the documentation, every name in the API and every message says covers; valid waits for M27.
Documentation
docs/website/docs/concepts/revisions-and-signatures.md(new): revisions, coverage and its verdicts, change classes and permissions, the attacks reported, and what M04 deliberately does not claim.docs/website/docs/guides/revisions.md(new): opening or extracting a revision, and reading what each signature covers and what changed after it.docs/website/docs/concepts/writing.md: the write guard, and when a save is refused.docs/website/docs/concepts/validation.mdanddocs/website/docs/reference/validation-rules.md: thesignaturefamily, its eight rules and their severities; the families list gainssignature.docs/website/docs/reference/diagnostics.md:revision.end-not-foundandrevision.trailing-bytes.docs/website/docs/introduction.md: revisions and signature coverage added to what the library does.docs/corpus.md,tests/corpus/manifest.schema.json:expect.revisions,expect.signaturesandderivedFrom, and the referee that writes them — and the one exception: usage-rights signatures (/Perms /UR3), which neither pyHanko norpdfsigreads, have their coverage derived from the file's own%%EOFpositions, asfile.eof-missing's expectation is derived from its last bytes, and their entries say so.docs/architecture.md: the sections kept at opening and the revision slice, in §3.1.docs/status.md: the measurements, T25 closed if M04 closed it.
Exit criteria
- T25 is fixed, by M02 or as M04's first slice, with its regression tests; the QMF manual is supported.
- Revisions are enumerated, opened read-only, copied out and compared, with the laziness condition measured.
- The signature model, its coverage, its permissions and the change classification exist, with no cryptography in the core.
- The eight
signature.*rules are in the structural profile and indocs/website/docs/reference/validation-rules.md, and every corpus entry'sfindingsincludes them. - The write guard refuses what a certification or a lock forbids, and reports what the caller insists on.
-
expect.revisionsandexpect.signaturesare in the schema and in the manifest, written by pyHanko;derivedFromnames each derived variant's source and transformation. - The acceptance conditions above pass on the corpus, in CI, with no document skipped, the crafted
fixtures included, and on a green
Remote corpusrun recorded indocs/status.md. - Unit tests cover every behavior above, its degenerate and its hostile cases.
- Integration tests run pyHanko, qpdf, pikepdf and
pdfsigin containers. -
RevisionBenchmarksmeasures enumeration, opening a revision and classification withMemoryDiagnoser;docs/status.mdrecords the figures. - The documentation site publishes revisions, coverage, classification and the
signaturerules. - Every page of Documentation is written in its Diátaxis section, one mode per page (ADR 47).