Skip to main content

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 /Prev that 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 /ByteRange checked against the file and against the revision it closes, and the gap it leaves checked against its own /Contents.
  • Permissions: DocMDP (its P), FieldMDP (from /Lock and 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, in AdCodicem.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 by PdfReaderLimits.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 /XRefStm names 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 %%EOF after the first-page trailer — startxref 0 in 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 %%EOF that 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 update startxref cannot reach, a section the chain leaves out — is measured by reading that tail alone, and exposed as TrailingBytes (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 /Prev found 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: startxref is 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. CopyTo writes 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), for a older than b, takes every object number the revisions after a up to b define, and sets its definition at b against its definition at a: 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:

CoverageWhen
WholeFileIt ends at the last revision's end and nothing follows
WholeRevisionIt ends at a revision's end and later revisions, or trailing bytes, follow
PartialIt ends anywhere else, or leaves bytes other than its own /Contents uncovered
MalformedThe 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
PlaceholderUnsigned: names or zeros in the /ByteRange, a /Contents of zeros — node-signpdf's PDFKit file
NotApplicableA legacy /UR with no byte range at all
UndecidableThe 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 /Reference entry whose /TransformMethod is /DocMDP, and its /TransformParams /P — 2 when absent —, with /Perms /DocMDP naming the same dictionary. Two certifications, a P outside 1 to 3, a /Perms entry that names no field's signature, or a certification after an approval signature is reported (signature.permissions-malformed); a P out of range is read as 1, the most restrictive, since rejecting by default is the only safe reading.
  • FieldMDP: the signature field's /Lock (/Action All, Include or Exclude, /Fields, and in PDF 2.0 its own /P), and the /Reference entry 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 /Annots with their appearances and pop-ups, the document security store, /Perms, /Info and 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 /Annots is an annotation change; a catalog whose only changes are /DSS or a new signature field's entry in /AcroForm is that; any other key is other.
ClassWhatpyHanko's level
UnchangedRedefined identical, or rewritten unchangedNONE
SecurityStoreThe security store, its certificates, OCSP responses, CRLs and /VRI, and the catalog's /DSSLTA_UPDATES
DocumentTimestampA document time stamp's field, widget and dictionary, and the AcroForm entries that list itLTA_UPDATES
FormFillA field's /V, a widget's /AS, a widget's regenerated /AP, /NeedAppearancesFORM_FILLING
NewSignatureAn empty signature field signed, or one added, with its widget, appearance and /LockFORM_FILLING
AnnotationAn annotation other than a widget added, changed or removed, with its appearance and pop-up, and the page's /AnnotsANNOTATIONS
Metadata/Info and the catalog's metadata streamthe level pyHanko gives it on the first corpus document that has one, and the ISO text
UnreferencedAn object the revision defines and nothing reachable refers torecorded, not scored
OtherEverything else: content streams, resources, fonts, the page tree, catalog keys outside the above, an object of the signed revision redefined to something elseOTHER
  • Allowed: DocMDP P=1 allows Unchanged, SecurityStore and DocumentTimestamp; P=2 adds FormFill and NewSignature; P=3 adds Annotation. A field lock forbids FormFill on 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 each Other after 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 — or NotAnalyzed, 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 is Other; hide-and-replace, a later revision whose catalog or page tree shows a document the signed revision carried unseen, is Other. Replace through a form field whose appearance lies over signed content is a FormFill, 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 an Other after an approval signature. Signature wrapping is a gap that is not /Contents; universal signature forgery, a /ByteRange or /Contents missing, null or empty, is Malformed or Placeholder.

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; with AllowInvalidatingSignatures it is written, and each signature it breaks reported as write.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​

RuleSeverityMeaning
signature.byte-range-malformedWarningNot pairs of integers, negative, overlapping, outside the file, not starting at 0
signature.gap-not-contentsErrorThe 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-coveredWarningThe covered bytes end before or inside a revision, or skip bytes outside the gap
signature.dictionary-in-object-streamWarningThe signature dictionary sits in an object stream, where its /Contents cannot be excluded
signature.unsigned-placeholderInformationA signature field whose value is a placeholder
signature.permissions-malformedWarningTwo 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-changeErrorA 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-changeWarningA 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​

  1. T25, if still open, and sections recorded. A synthetic regression — a /Prev a 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.
  2. Revisions. Grouping (hybrid pairs, linearized pairs, empty updates), ends, trailing bytes, the unreliable history of a rebuilt index; the revision.* diagnostics; the manifest's expect.revisions, derivedFrom and their schema. Proved by synthetic files of each shape — tables, streams, hybrid, linearized then updated, an update glued to %%EOF, a startxref a byte early — and by CorpusRevisionTests against pyHanko's count recorded for every committed document; integration: pyHanko in a Python container. Leaves opening one.
  3. 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.
  4. Comparing revisions. PdfObjectComparer, Compare, rewritten unchanged. Proved by unit tests per outcome; on the corpus, the metadata-only revisions of pdfmaker25-home-office-eia differ by metadata alone, the revision of illustrator-irs-pub1-english that 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.
  5. 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's evaluate_signature_coverage() and signed_revision, and the ranges equal pdfsig's. Leaves permissions.
  6. 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.
  7. 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's evaluate_modifications() with its default policy on each. Leaves the rules.
  8. The signature.* rules. The eight rules, docs/website/docs/reference/validation-rules.md, the manifest's findings. Proved by CorpusValidationTests.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.
  9. 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 /Info edit 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.
  10. Budgets, remote corpus, documentation. RevisionBenchmarks with MemoryDiagnoser — 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 green Remote corpus run, recorded in docs/status.md with its date.

Tests required​

Unit

  • Revisions: every grouping shape; an end with LF, CR, CR LF and none; startxref 0 in a first-page trailer; an empty update; trailing junk; an unreachable update; a rebuilt index; a /Prev loop and a chain at MaxXRefSectionCount (the existing guard, reported under its code, the history marked incomplete).
  • Opening a revision: never a read past its end, counted; CopyTo byte-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 /Contents that 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, /FT inherited three levels up.
  • Permissions: P absent, 1, 2, 3, 0, 4, a name; two certifications; /Lock against /Reference.
  • Classification: each class from a minimal update; each attack of the design, crafted; a Metadata change 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's signed_revision and evaluate_signature_coverage(), and evaluate_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​

DocumentsBehaviorVerified by
Every committed document, encrypted ones included — revisions need no keyThe 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 reportsCorpusRevisionTests.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 bytesCorpusRevisionTests.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 sectionsCorpusSignatureTests.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 certificationEvery 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 reasonCorpusSignatureTests.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 forgeryEach 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 allowCorpusSignatureTests.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-formThe 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 /PermsCorpusSignatureTests.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 formRevisions and coverage computed without the key; classification reported as not analyzed until M16, never guessed from ciphertextCorpusSignatureTests.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 signaturesThe malformed range reported, the cycle walked once, no exception; a single-save signature covering the whole file is whole-fileCorpusSignatureTests.Malformed_signatures_are_reported_never_trusted
Every documentExactly the findings the manifest declares, signature.* includedCorpusValidationTests.Every_document_produces_exactly_its_declared_findings
itext-govinfo-us-code-certifiedAn 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 acceptedCorpusSignatureTests.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 updateOpening 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 runCorpusRevisionTests.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 /Reason holding the word trailer, a range that ends without its line end, a signature closing a linearized revision — beside some 25 remote ones under signed-*, pades-b-*, docmdp, fieldmdp, document-timestamp, dss, vri, multiple-signatures and many-signatures: certifications at P=1 (Avow) and P=2 (Slovak NBU, with MD5 object digests), Foxit's single-save signatures, the legacy adbe.x509.rsa_sha1 and adbe.pkcs7.sha1 subfilters, 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-update or incremental-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​

NeedWhyPriorityLikely 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 transformationThe roadmap's acceptance names them, and no real document in the corpus carries a forbidden change1Generated 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 oneNo 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 it1Generated 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 entryAcceptance rests on the referee's opinion recorded in advance, never on our reader's1Generated 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 source1Generated here: pyHanko over documents/contract/chromium-contract-fr.pdf
A real document annotated or countersigned in Acrobat after someone else signed itAcrobat rewrites /Info and the XMP with every save, so its real updates mix classes — metadata with annotations or signatures — that crafted files keep apart2A contribution (docs/corpus-contributions.md)
A signature dictionary stored in an object streamThe coverage it earns is designed but met in no file3Generated here: a crafted variant of the signed twin

Traps​

  • A linearized file has two %%EOF markers and one revision, and several OPF entries carry the feature incremental-updates for that reason alone. In pyhanko/acrobat-reader-signed-twice, Acrobat Reader linearized the document as it signed it: its /L is 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 startxref a 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 /Reason contains trailer. Revisions come from the chain, never from a keyword search.
  • /Contents is 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 /Info and XMP changes. A classification that calls them Other calls 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, /FT inherited, 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.md and docs/website/docs/reference/validation-rules.md: the signature family, its eight rules and their severities; the families list gains signature.
  • docs/website/docs/reference/diagnostics.md: revision.end-not-found and revision.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.signatures and derivedFrom, and the referee that writes them — and the one exception: usage-rights signatures (/Perms /UR3), which neither pyHanko nor pdfsig reads, have their coverage derived from the file's own %%EOF positions, as file.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 in docs/website/docs/reference/validation-rules.md, and every corpus entry's findings includes them.
  • The write guard refuses what a certification or a lock forbids, and reports what the caller insists on.
  • expect.revisions and expect.signatures are in the schema and in the manifest, written by pyHanko; derivedFrom names 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 corpus run recorded in docs/status.md.
  • Unit tests cover every behavior above, its degenerate and its hostile cases.
  • Integration tests run pyHanko, qpdf, pikepdf and pdfsig in containers.
  • RevisionBenchmarks measures enumeration, opening a revision and classification with MemoryDiagnoser; docs/status.md records the figures.
  • The documentation site publishes revisions, coverage, classification and the signature rules.
  • Every page of Documentation is written in its Diátaxis section, one mode per page (ADR 47).