Digital signatures
A digital signature is a fact about bytes: a /ByteRange and a /Contents that seal a byte
prefix of the file. The engine reads those facts, judges what changed after them, builds and seals a
signing candidate, and installs the sealed bytes as the document’s new version. It never holds a key:
the cryptography (building and verifying the CMS, judging trust) lives in
@embedpdf/core-signature, which you bring a signer to.
Three ideas carry everything below:
- A revision is a byte prefix defined by the cross-reference chain. A signature covers the
revision it was written in (
coverage: 'whole-revision') or it covers something else, and the engine tells you which. - Signing seals. A completed signature does not edit the document; it produces a new version of it. Locally the session moves to the sealed bytes; on the cloud the document’s head moves to a new immutable version and every layer sits over it.
- A signature raises two questions. What a signer declared (a certification’s DocMDP level, a
field lock) is subtracted from what the caller may do, like encryption permission bits. What a
validator will conclude about later changes is a policy, and the engine judges the way the
recipient’s validator does: after a plain approval signature, form fill-in, further signatures and
commenting keep it valid (Acrobat’s own wording under such a signature); a page or field
structure change does not. The rules were checked case by case against Acrobat on a synthetic
corpus of 91 signed documents (
signature-compatin the engine’s test fixtures); the few places where the engine is deliberately stricter are recorded there.
Everything here is on doc.signatures. It is undefined on an engine that does not implement
signatures, so check once:
if (!doc.signatures) throw new Error('this engine cannot sign');Reading#
list() returns the revisions, every signature field with its signed state, and the protection in
force:
const snapshot = await doc.signatures.list();
snapshot.chainValid; // false = the xref chain is broken; every byte fact is indeterminate
snapshot.revisions; // oldest first: { index, end, xrefOffset, signatureIndex }
for (const sig of snapshot.signatures) {
sig.fieldName; // 'sig'
sig.signed; // has a /V
sig.coverage; // 'whole-revision' | 'partial' | 'malformed' | null
sig.revisionIndex; // which revision it seals (whole-revision only)
sig.signer; // { name, reason, location, contactInfo, claimedTime } — claims, unverified
sig.docMdp; // 1 | 2 | 3 when it certifies
sig.catalogCertification; // /Root /Perms /DocMDP names it
sig.fieldMdp;
sig.lock; // FieldMDP transform / field /Lock
}
snapshot.protection; // { enforced, judged, certification, fieldLocks, policyVersion }The bytes a verifier needs are read straight from the loaded file:
const cms = await doc.signatures.contents({ kind: 'fqn', name: 'sig' }); // DER /Contents, padding stripped
const digest = await doc.signatures.digest({ kind: 'fqn', name: 'sig' }, 'sha256'); // hash of the /ByteRange
const revision = await doc.signatures.revisionBytes(0); // the exact bytes revision 0 signedEvery read describes the bytes the document was loaded from. Unsaved edits are not part
of any revision until prepare snapshots them.
What changed since a signature#
analyze() judges the document as it is now against the revision a signature sealed — its net
state, the way Acrobat judges an approval signature: a page changed and byte-restored later is
unchanged, and each signature has its own reference point. A certification is also replayed
revision by revision, so an intermediate violation invalidates it even when the final state no
longer shows it. The result names which objects changed, what each change is, and the rule that
permits it — or the reference no rule explains.
const analysis = await doc.signatures.analyze({ since: { signatureIndex: 0 } });
analysis.current.verdict; // 'unchanged' | 'permitted' | 'forbidden' | 'indeterminate'
analysis.current.primary; // the finding that decided it
analysis.current.findings; // per object: the change, the rule, or the unexplained reference
analysis.restrictions; // the certification level and this signature's own locks it was judged under
analysis.later.revisionCount; // revisions appended after the sealed one
analysis.later.undoneObjectNumbers; // objects changed in between that hold the sealed value again
analysis.mode; // 'authoritative', or 'exploratory' when you passed a levelThree options shape the question:
until:'persisted'(default) judges the saved revisions;'working-copy'snapshots the session’s unsaved edits as one more revision first, which is how a viewer previews “would this fill be allowed”;{ revisionIndex }stops at a revision.exploratoryLevel: evaluate as if a modification level ('lta' | 'fill' | 'annotate') were in force. The result is tagged exploratory and never becomes a verdict.detail:'summary'(default) carries the verdict and its findings;'full'also carries every pairwise step between the two revisions (analysis.steps), with each step’s changes.
A verdict is one of four words. unchanged: nothing about the document differs from what the
signature sealed — an object written again with the same value (Acrobat: “not modified”) does
not count as a change. permitted: something changed, and every change is one the policy allows
(a fill, a further signature, a comment). forbidden: a proven violation. indeterminate: the
evidence to decide is missing — a truncated value, an unreadable object, a broken chain, or a
document Acrobat itself cannot verify later changes to (a first cross-reference section with
holes, a reachable object that is nothing but a reference). Missing evidence never becomes a
pass, and a violation is only proven on evidence.
Signing: two phases#
Signing is prepare then complete, with the CMS built in between by whoever holds the key.
import { buildDetachedCms, createTestSigner, profileFor } from '@embedpdf/core-signature';
const prepared = await doc.signatures.prepare({
field: { kind: 'fqn', name: 'sig' },
certify: { permission: 2 }, // a certification: fill and sign remain allowed
attribution: { name: 'Bob Singor', reason: 'Approved' },
appearance: { pdf: artworkPdf, pageIndex: 0 }, // a PDF page drawn into the widget
});
// prepared.digest is the hash over the sealed candidate's /ByteRange — sign THAT.
const signer = await createTestSigner(); // a throwaway self-signed key, for tests and demos
const cms = await buildDetachedCms({
digest: prepared.digest,
hash: prepared.algorithm,
profile: profileFor(prepared.subFilter),
signer,
});
const result = await doc.signatures.complete({
signingId: prepared.signingId,
cms,
expectedVersion: prepared.expectedVersion,
});
result.version; // { sha256, byteLength } — the version the sealed bytes became
result.protection; // what the document's signatures forbid from now onWhat happens underneath: prepare snapshots the document as it is (unsaved edits become their own
revision), writes the signature value with a reserved /Contents, seals the /ByteRange, and parks
the candidate. The live document is read-only until complete or abort (SigningPending).
complete verifies the CMS is one DER object that fits, writes it in, reads the sealed bytes back
through the signature model, and only then installs them. expectedVersion must be what prepare
returned (SigningVersionMismatch otherwise). A replay with the same CMS answers
already-completed; a different CMS is refused.
sign() does the three steps in one call, and aborts the candidate if anything fails:
import { sign, webCryptoSigner } from '@embedpdf/core-signature';
await sign(doc, {
field: { kind: 'fqn', name: 'sig' },
signer: webCryptoSigner({ privateKey, certificateChain }), // or a `CmsSigner` over your signing service
attribution: { reason: 'Approved' },
});A signer is either a RawSigner (you hold the key; the package builds the CMS) or a CmsSigner
(your service or HSM returns the CMS for the digest). Nothing but the digest ever leaves.
Signing rides doc.sign; a certification additionally needs doc.sign.certify.
A locked field, a certification that forbids the edit, or a required seed-value
entry the engine does not implement all answer SignatureRefused with the reason
in the message.
Validating#
validateSignatures combines the engine’s byte facts, the CMS cryptography, trust, and the revision
analysis into one verdict per signature:
import { validateSignatures } from '@embedpdf/core-signature';
const verdicts = await validateSignatures(doc, {
trust: { anchors: async () => [rootCertificateDer] }, // omit → 'valid-untrusted' at best
until: 'working-copy', // judge unsaved edits too: what the file a save produces will say
});
for (const v of verdicts) {
v.integrity; // 'valid' | 'invalid' | 'indeterminate' — the bytes vs. the signed digest
v.cryptography; // the CMS verified with its own certificate
v.trust; // chain to your anchors
v.modifications; // { verdict, basis, laterRevisions?, undone? } — what changed after it, and whether the loaded bytes or the working copy were judged
v.summary; // 'valid' | 'valid-untrusted' | 'invalid' | 'indeterminate'
}Protection: enforced vs. judged#
snapshot.protection answers two different questions:
enforcedis what a signer declared: a certification’s/P, a signed field’s/Lock/P. The engine refuses what it forbids — a certified document loses page assembly and field authoring, aP=2certification refuses annotations, aP=1one refuses form fill, a FieldMDP-locked field refuses writes. The refusals surface asProtectedDocument. A plain approval signature declares nothing:enforcedisnull, and nothing but a rewrite (which would erase the signature rather than invalidate it) is refused.judgedis what a validator holds later changes to: the declared level, or the approval baselineannotatewhen only approval signatures exist. It drivesanalyze()and every verdict. So a field filled or a comment added after an approval signature is allowed and judged permitted — Acrobat reads such a signature as “Form Fill-in, Signing and Commenting are allowed” — while a text field added or deleted, a page edited, or a signed field’s structure changed is judged forbidden: the signature still verifies cryptographically, but a validator says the document changed in a way it does not permit. A signer who wants to forbid comments certifies withP=2.
Read the policy back to grey out what a user cannot do before they try, and to warn about what they
can do but should not. An engine constructed with signedDocumentPolicy: 'permit' turns enforcement
off for tools whose job is to produce or test invalid files; judgement never depends on it.
Signature fields and appearances#
Signature fields are ordinary form fields of family signature. They can be authored:
await doc.forms.createField({
family: 'signature',
name: 'sig',
widget: { pageObjectNumber, rect: { left: 50, bottom: 50, right: 250, top: 120 } },
});A signature’s appearance is a page of a PDF drawn into the widget: pass it to prepare as
appearance. To draw a mark into an unsigned field without signing — the visual “sign” of a viewer
that has no signer — use doc.forms.setSignatureAppearance(ref, { pdf }); the field stays unsigned and
the call is refused once the field is signed, because that appearance is sealed with the signature.
On the cloud#
Over @cloudpdf/engine the same calls work, and two things are worth knowing:
- A completed signature publishes a new immutable version of the document.
list()andanalyze()of the working copy read the layer; signed bytes (contents,digest,revisionBytes, history) are served per version and cached forever. Refresh your manifest after a completion: the base sha moved. - A layer that fell behind the head (someone else published) cannot sign until it is rebased
(
StaleBase). A signing waits at most fifteen minutes for its CMS (SigningExpired).
Events#
| Event | Fired by |
|---|---|
signature.prepared | prepare |
signature.completed | complete, with the full result |
signature.aborted | abort |
document.versioned | a completed signature, here or (on the cloud) elsewhere: re-read every byte-level fact |
Next#
Your feedback goes directly to the documentation team.