EmbedPDF

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-compat in 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 signed

Every 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 level

Three 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 on

What 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:

  • enforced is 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, a P=2 certification refuses annotations, a P=1 one refuses form fill, a FieldMDP-locked field refuses writes. The refusals surface as ProtectedDocument. A plain approval signature declares nothing: enforced is null, and nothing but a rewrite (which would erase the signature rather than invalidate it) is refused.
  • judged is what a validator holds later changes to: the declared level, or the approval baseline annotate when only approval signatures exist. It drives analyze() 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 with P=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() and analyze() 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#

EventFired by
signature.preparedprepare
signature.completedcomplete, with the full result
signature.abortedabort
document.versioneda completed signature, here or (on the cloud) elsewhere: re-read every byte-level fact

Next#

Was this page helpful?

Your feedback goes directly to the documentation team.