@scanmate/seal
v0.24.6
Published
Is a signed PDF still the document that was signed? Verifies each signature's own bytes: the digest, the signature, what it covers, and who signed - without claiming legal validity.
Maintainers
Readme
@scanmate/seal
Is a signed PDF still the document that was signed?
The rest of this suite compares a returned document with the one that was issued: pixels, words, fields, boxes. This asks the other question, of a document that came back born-digital and signed: has the file changed since someone signed it? A signature answers that in its own bytes, and nothing else here can.
Install
npm install @scanmate/sealimport { readFile } from 'node:fs/promises'
import { verifySignatures } from '@scanmate/seal'
const report = await verifySignatures(await readFile('agreement-signed.pdf'))
report.signed // the file carries a signature at all
report.unbroken // every signature intact, and the last covers the whole file
report.signatures[0].signer?.subject // CN=A Person, O=A Company
report.signatures[0].problems // why not, when notWhat it checks
| | |
|---|---|
| intact | The bytes this signature covers digest to what the signature says, and that digest is signed by the key of the certificate it carries. In short: the document is, byte for byte, what was signed. |
| whole | The signature covers the whole file. A PDF may be signed and then added to - a second signature, a form filled - so intact and not whole means "this much is vouched for, and there is more". uncovered counts the bytes. |
| signer | What the certificate says about itself: subject, issuer, serial, validity, and whether it signed itself. |
| signedAt | /M, what the signing software recorded. Evidence of nothing on its own - a clock the signer controls - but reported, and compared against the certificate's validity. |
Each problem is named rather than lumped into a boolean: digest-mismatch (the document changed, or is not what was signed), signature-invalid, not-covered (bytes appended after signing), certificate-expired, unreadable.
What it does not check, and will not claim
This does not tell you a signature is legally valid. That question is about trust, and trust is not in the file:
- No trust list. Whether the signing certificate chains to a root anyone recognises - Adobe's AATL, the EU trusted lists, your own organisation's - is not checked. A self-signed certificate made five minutes ago verifies here exactly as a qualified one does, and
signer.selfSignedis how you tell them apart. - No revocation. A certificate revoked the day after signing still verifies. OCSP and CRL are network protocols with their own failure modes, and answering them badly is worse than not answering.
- No timestamp validation. A signature timestamp, where one exists, is not checked against a time authority;
signedAtis only what the file says. - It does not sign. This reads and verifies; it never writes a signature.
What is left is the part that is in the file, and it is the part that catches a document edited after signing - which is the question this suite exists to ask. Treat unbroken: true as "the bytes are as signed", and answer "should this signer be trusted?" with your own policy, or with a qualified validation service.
How it reads a PDF
By its bytes, with no PDF library. That is not a shortcut: /Contents holds a signature over the file's own bytes, so a signature dictionary cannot be compressed into an object stream or encrypted without the offsets in /ByteRange ceasing to mean anything. Every signed PDF therefore carries its signatures in plain sight - and reading them this way still works on a file too damaged for a parser to open, which is exactly the file whose signature you most want to check. A spec covers that case: a wrecked cross-reference table, a signature still read, and the tampering reported.
The cryptography is pkijs over Node's own WebCrypto. The tests sign for real: a key, a self-signed certificate and a PDF signed over its /ByteRange, made in the test rather than committed as a fixture, because a fixture cannot prove a verifier works.
What it has been tried on
Signatures this package makes itself, in its tests: RSA with SHA-256, one signer, a detached CMS in adbe.pkcs7.detached, with the certificate carried in the signature. Those cover the paths that matter - digest, signature, coverage, dates, and picking the signer out of a carried chain - and each failure is provoked rather than imagined: a byte changed, bytes appended, a certificate out of date, a wrecked cross-reference table.
And three real signed documents, run locally and not committed - private papers, which is the only kind there is: a property reservation signed through Adobe Sign in ETSI.CAdES.detached, and two adbe.pkcs7.detached signatures, one from a commercial signing service and one from a named individual. All three verified as intact, and one of them was found to carry 59,945 bytes appended after signing, reported as not-covered with the earlier bytes still vouched for. That is not a pass mark for every producer, but it is a measurement rather than a hope, and two of the three exercised paths the tests here cannot make: a CAdES signature, and a full certificate chain.
Those real files also found two defects that no self-made fixture could:
- A signature dictionary was read by taking the nearest
<<before/ByteRange, which on real signatures lands inside/Prop_Build <</App<<…>>>>- the nested dictionary naming the signing software./SubFilterand everything else in the real dictionary went unread. Brackets are now balanced, and a run of>>>>is counted as two closers rather than three. - The signer was read as the first certificate carried. A real signature carries a chain, and the first of it is as often the authority as the signer; one document reported a certificate authority where a person had signed. The signer is now matched by the issuer and serial that
SignerInfonames.
Still unmeasured: DocuSign, a qualified European provider, ECDSA or RSA-PSS keys, signature timestamps, and a document with more than one signature. pkijs implements all of them, so they are expected to work - expected, not measured. A file of yours is how any of those stops being a guess.
Alongside the rest
const report = await verifySignatures(returned)
if (!report.unbroken) {
// The file changed after it was signed: no comparison of pixels is needed to know that.
}
// And for what the paper says, rather than what the bytes do:
const audit = await new Scanmate(issued, returned).audit()A born-digital return with an unbroken signature still needs the audit: a signature proves nobody edited the file, not that the document says what was agreed.
