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.
| Question | Status | |
|---|---|---|
Q1 | Was anything appended after signing? | shipped since 1.0 |
Q2 | Do the signed bytes still hash to the signed digest? | shipped in 1.15.0 |
Q3 | Is the certificate trusted? | declined |
Q4 | Was 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 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.