The four questions inside "validate this signature"

Two are answered from the bytes alone. The other two need a trust store and a clock, and a report that changes with the network is not a report anyone can replay.

Reference: Signature signals

"Validate this signature" is four questions wearing one phrase. Two are arithmetic over the file's own bytes: was anything appended after signing, and do the signed bytes still hash to the digest that was signed. Two need the outside world: is the certificate trusted, and was it revoked. This engine answers the first two and declines the last two. The split is not laziness. It is the product's one contract applied to cryptography: identical bytes produce an identical report.

 QuestionStatus
Q1Was anything appended after signing?shipped since 1.0
Q2Do the signed bytes still hash to the signed digest?shipped in 1.15.0
Q3Is the certificate trusted?declined
Q4Was it revoked?declined

Why the two declined questions stay declined

Q4 is an OCSP call: asking the certificate authority, live over the network, whether it has since revoked this certificate. A report that depends on the network and the wall clock is not "same bytes, same report". That property is what this product sells.

Q3 looks cheaper and is worse. Answering it means shipping a root store, the bundled list of authorities a certificate is allowed to chain up to. That list is data, and data expires. A stale one does not degrade gracefully. It returns "untrusted" for a chain rooted in an authority added after our last release, silently, from a container nobody redeployed. A wrong "untrusted" on a genuine government-signed document is the worst output this product can produce. Both questions stay out.

The report says where to take those questions instead. For ICP-Brasil documents, the chain validation this engine does not do is exactly what validar.iti.gov.br does. The two checks compose. Neither substitutes for the other.

Q2 is arithmetic: hash the spans, read one value out of the blob

A PDF signature declares a /ByteRange: the spans of the file it covers. Hash those spans. Then pull messageDigest out of the PKCS#7 blob, the standard envelope a signature travels in (the newer name for the same thing is CMS). That value is the digest the signer committed to. Compare the two numbers. node:crypto does the hashing, so there is no dependency, no trust store, no network and no clock.

And it catches what Q1 cannot. Coverage finds bytes appended outside the signed range, which has entirely benign readings: a second signer, an added annotation, a filled form field. A digest mismatch has none. The fixture that justifies the family is signed-integrity-broken.pdf. Its /ByteRange spans the whole file, so signature-coverage reports info while the signed content is altered. Extent is not validity. Before 1.15.0 the report could not tell the two apart.

The check, in one line of intent

sha256(bytes[/ByteRange spans]) == PKCS#7 signedAttrs.messageDigest

Getting that one value costs a walk through ASN.1, the nested binary format the envelope is written in. That walk is where most of the engineering sits. The DER reader (src/engine/der.ts) is hand-written, the same trade as the ZIP, XML and JPEG readers in this repository. The input is attacker-controlled, and a general-purpose ASN.1 library is a large unbounded parser that would run on it. This one reads tags, lengths and OIDs. It does not know what a certificate is.

What it refuses is the design:

  • Indefinite-length encodings. DER forbids them, its looser sibling BER permits them, and some signature producers emit BER. Supporting them means scanning for end-of-content octets, the one construct whose cost is not bounded by a declared length. A blob using them is reported as unreadable rather than parsed approximately. An unreadable signature is a disclosed gap. A mis-parsed one is a wrong answer about whether a document was altered.
  • Lengths past the buffer, lengths declared in more than four bytes, and any node whose content would end before it starts. A child can never escape its parent, which is the property the whole walk rests on.
  • Recursion. The walk is iterative and depth is a counter the caller carries, so nothing here can blow the stack. Nothing throws, either. Every failure returns null, and the caller turns null into a disclosed "could not read" rather than a verdict.

Two refusals protect honest documents rather than catching dishonest ones.

The old adbe.pkcs7.sha1 form is declined, not special-cased. It puts the document digest inside the signature's own payload, the CMS eContent, which makes messageDigest the digest of that. Check it the usual way and you get a mismatch on a perfectly good signature.

A /ByteRange that is negative, overlapping, reordered or past the end of the file is refused rather than clamped. Overlapping spans would let a crafted file feed the same bytes twice and manufacture any digest it liked.

What "verified" is allowed to mean

signature-integrity.verified reports at info and adds nothing to the risk score. It is a fact about bytes, not a statement that the document is trustworthy. An unknown signer's valid signature over altered content is still a valid signature.

The other direction gets the same care. unreadable is reported at low rather than left out, because silence reads exactly like "checked and fine".

The fixtures carry no cryptography

The test fixtures hold a structurally correct signature envelope with a real messageDigest and no certificate, no key, no actual RSA signature. That is the right fixture, not a shortcut. It is exactly what the check reads. A real signature would test nothing this code does, and it would put a private key in the repository.

The signature also says what it allowed, and that is a dictionary, not DER

Engine 1.20.0 added the second half. A certification signature, the kind that locks a document rather than merely endorsing it, declares at signing time what may follow it. /DocMDP /P is that declaration (ISO 32000-1 §12.8.2.2). 1 means nothing at all, 2 means form fill-in and further signatures, 3 adds annotations. signature-permissions compares that number against what the appended revisions actually wrote.

Coverage says "bytes were appended, and here are four reasons that might be fine". This says "bytes were appended, and this signature said that was not allowed". Since engine 1.32.0 the comparison is reported per revision. A certified contract with six appends now names the one append that broke the certification, instead of reporting a total.

Two false positives had to be designed out first. Both would have fired on exactly the documents this engine is aimed at.

Filling in a form field is the commonest legitimate operation on a certified PDF. It regenerates the field's appearance stream, an untyped object that looks exactly like page content unless you check what refers to it. So the counter that feeds this family counts only objects reached from a page's /Contents. That is stricter than it needed to be anywhere else.

Appending a /DSS store, the bundle of certificates and revocation data a validator will want years from now, is what keeps a signature checkable. It happens to documents certified "no changes permitted" as a matter of routine. So that append reads info when the catalog explains it, and low when nothing does.

What the corpus said about all of this

Phase 0 ran the same day 1.20.0 shipped: 915 corpus PDFs, 200 of them signed. A medium for "a signature declares a certification the catalog never adopted" fired five times and was wrong five times. Every one was a Brazilian government contract written by Microsoft Word LTSC. Word puts a /DocMDP transform on the first signature and never writes the catalog's matching /Perms entry. The finding had said the mismatch "means either the catalog or the signature was rewritten". It means Microsoft Word. It ships at info now and says so in words.

The number that bounds everything else: zero of those 915 documents are certified. Not one /Perms /DocMDP in the corpus. So the /P comparison, the bulk of what 1.20.0 shipped, has no population to measure against. Every high row in its table is still a prior, a guess I have not yet tested against real files. That is a debt, it is written down, and it is why the cover of this post is a zero.

The same run said something worth hearing about the market this product lives in. 190 of the 200 signed documents carry an approval signature, which declares nothing about what may follow it. So the quiet info finding is nearly the whole family on real documents: "the signature covers the whole file" means extent, not verification. ITI's validator calls the same situation indeterminada, and the Portuguese report uses that word.

Run it on a signed document you trust

The fastest way to see the four questions come apart is to inspect a document you know is honestly signed. A contract with two signers, say, or a government form you filled in after it was certified. The report shows coverage, integrity and permissions answering separately. That is the point.

Check a PDF: free, no account, parsed in memory and discarded with the response. If the report says verified at info and nothing else, you have seen the honest output for an ordinary signed file. You have also seen why it is not a verdict.