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 and further signatures keep it valid; anything else — an annotation included — does not.
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 every revision after a signature (or after any revision) against the restrictions
in force: which objects moved, 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.verdict; // 'unchanged' | 'permitted' | 'forbidden' | 'indeterminate'
analysis.mode; // 'authoritative', or 'exploratory' when you passed a level
for (const step of analysis.steps) {
step.older;
step.newer; // the revision pair
step.verdict;
step.findings; // per object: the change, the rule, or the unexplained reference
}Two 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.
A truncated value is never permitted and a broken chain is indeterminate: the analysis fails
closed. An object rewritten in a later revision without a change in value is judged a
modification at every level: Acrobat rejected such a rewrite of a page and a signed widget after
an approval signature. The engine’s own writer never produces one — a save writes only what
changed, and an annotation added and removed again writes nothing — so the rule only ever fires on
files other producers wrote.
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 } — 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 baselinefillwhen only approval signatures exist. It drivesanalyze()and every verdict. So an annotation added after an approval signature is allowed and then judged forbidden — the signature still verifies cryptographically, but a validator says the document changed in a way it does not permit, which is exactly what Acrobat says. A signer who wants comments to keep a signature valid certifies withP=3.
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.