e-sig documentation
A self-hosted PDF e-signature SDK. Render, cryptographically sign, timestamp, verify, and audit — entirely inside your own infrastructure.
Introduction
e-sig is an MIT-licensed toolkit for adding real cryptographic PDF signing to your own product. There is no SaaS in the loop: your certificates, your database, your object storage, your audit trail. No metering, no per-document fees.
The suite is three packages plus a Next.js starter:
| Package | Role | Runtime |
|---|---|---|
@e-sig/core | The engine — render, cert issuance, PKCS#7/PAdES signing (+ RFC-3161 TSA), verification, and the storage-adapter interfaces. | Node, stack-agnostic |
@e-sig/supabase | Reference adapters: cert store, audit-log store, PDF storage over Supabase (Postgres + Storage). | Supabase |
@e-sig/react | UI: draw-to-sign pad, self-sign flow, receipt. | React 18/19 |
Trust vs. validity — read this first. e-sig produces signatures that are cryptographically valid (the math verifies and any edit breaks them), but the default signing cert is self-issued. Stock Adobe Reader shows "validity unknown" until that cert is trusted (import into an org trust store, or plug in an AATL/CA signer). e-sig verifies the signature math and document integrity — it does not, by itself, assert third-party trust. See Security & compliance.
Install
Published to the public npm registry — no auth or registry config needed.
npm i @e-sig/core # engine only
npm i @e-sig/core @e-sig/supabase @e-sig/react # full stack
Runtime requirements: Node ≥ 20, ESM. The only crypto dependencies are node-forge and the @signpdf/* packages. HTML→PDF rendering uses puppeteer-core (bring your own Chrome, or @sparticuz/chromium on Lambda).
Quickstart
Issue a cert, render an agreement to PDF, sign it, and verify the result:
import {
generateSelfSignedCert,
renderHtmlToPdf,
signPdf,
verifyPdfSignature,
} from "@e-sig/core";
// 1. Issue a one-off signing cert (in production, persist + reuse — see below).
const cert = generateSelfSignedCert({ subjectName: "Acme Corp" });
// 2. Render HTML → unsigned PDF.
const unsigned = await renderHtmlToPdf({
html: `<h1>Service Agreement</h1><p>Signed by Jane Doe.</p>`,
});
// 3. Sign it (PKCS#7 detached, ETSI.CAdES.detached subfilter).
const { signedPdf } = await signPdf({
pdf: unsigned,
keyPem: cert.keyPem,
certPem: cert.certPem,
reason: "Service Agreement acceptance",
location: "https://acme.example",
contactInfo: "jane@example.com",
name: "Jane Doe",
});
// 4. Verify cryptographically.
const v = verifyPdfSignature(signedPdf);
console.log(v.ok, v.digestValid, v.signatureValid, v.signerCommonName);
// → true, true, true, "E-sig (Acme Corp)"
ok === true only when structure, document digest, and the RSA signature all pass. A single flipped byte under the signature makes ok=false and digestValid=false.
Certificates & keys
generateSelfSignedCert() produces an RSA-2048 X.509 cert suitable for PKCS#7 detached PDF signing (128-bit CSPRNG serial, digitalSignature + nonRepudiation key usage, emailProtection EKU, subject key identifier).
const { keyPem, certPem, fingerprint, notBefore, notAfter } =
generateSelfSignedCert({ subjectName: "Acme Corp" });
subjectName is ASCII-only — node-forge miscounts DER byte length for non-ASCII subject values on round-trip, so the guard rejects them up front.
Encrypting keys at rest
Wrap the private key with AES-256-GCM (scrypt-derived from a passphrase) before persisting it, so a database leak doesn't hand over signing authority:
import { encryptKeyPem, decryptKeyPem } from "@e-sig/core";
const blob = encryptKeyPem(keyPem, passphrase); // Uint8Array, opaque
// ... store blob ...
const keyPemBack = decryptKeyPem(blob, passphrase);
The passphrase must be ≥ 24 characters (high-entropy env secret). Layout is version | salt | iv | authTag | ciphertext; a wrong passphrase or any tampering surfaces as a GCM auth-tag error.
Cert lifecycle per tenant
ensureActiveCert() caches one active cert per tenant over a CertStore you supply, generating on first use and reusing thereafter (no churn):
import { ensureActiveCert } from "@e-sig/core";
const { cert, certPem, keyPem } = await ensureActiveCert({
store, tenantId: "acme", subjectName: "Acme Corp", passphrase,
});
Rendering HTML → PDF
renderHtmlToPdf() turns a document template into a PDF with headless Chromium. It auto-detects Lambda (@sparticuz/chromium) vs local/system Chrome.
const pdf = await renderHtmlToPdf({
html,
format: "Letter", // any puppeteer PaperFormat
printBackground: true,
javascriptEnabled: false, // default — see note
timeoutMs: 30_000,
});
JavaScript is disabled by default. Document templates are static HTML; executing interpolated/untrusted HTML with scripting on is an SSRF / data-exfiltration surface. Only set javascriptEnabled: true if your templates genuinely need in-page scripting.
Signing
signPdf() injects a signature placeholder and embeds a PKCS#7 detached signature under the ETSI.CAdES.detached subfilter, with the ESS signing-certificate-v2 attribute (RFC 5035) binding the signer cert into the signed data.
| Field | Meaning |
|---|---|
pdf | Unsigned PDF bytes (Buffer/Uint8Array). |
keyPem, certPem | Signer private key + certificate (PEM). |
name, reason, location, contactInfo | Signature dictionary metadata shown in the PDF signature panel. |
signingTime | Optional signing time (Date). |
tsa | Optional RFC-3161 timestamp transport → upgrades to CAdES-T. See below. |
padesStrict | true = strict PAdES B-B: also drops the PAdES-forbidden signing-time signed attribute. |
signatureLength | Override the /Contents placeholder budget (bytes). |
const { signedPdf } = await signPdf({
pdf, keyPem, certPem,
name: "Jane Doe",
reason: "DUA acceptance",
location: "acme.example",
contactInfo: "legal@acme.example",
padesStrict: true, // strict PAdES B-B
});
The result opens cleanly in Preview / Adobe Reader with a valid signature panel; any post-signing edit invalidates the signature.
Trusted timestamps (CAdES-T)
Pass a tsa transport to embed an RFC-3161 TimeStampToken, upgrading the signature from CAdES-B to CAdES-T. The token is added as the id-aa-timeStampToken unsigned attribute over the SignerInfo signature value.
e-sig performs no network egress — you inject the POST, so the package stays dependency-free. The TSA only ever receives a SHA-256 hash, never the document:
import type { TsaTransport } from "@e-sig/core";
const tsa: TsaTransport = {
required: false, // false = degrade to CAdES-B on TSA failure; true = throw
fetch: async (reqDerBytes) => {
const res = await fetch("http://timestamp.digicert.com", {
method: "POST",
headers: { "Content-Type": "application/timestamp-query" },
body: reqDerBytes,
});
return new Uint8Array(await res.arrayBuffer());
},
};
const { signedPdf, timestamped, tsaError } = await signPdf({
pdf, keyPem, certPem, name: "Acme", reason: "acceptance",
location: "", contactInfo: "", tsa,
});
- Budget: when
tsais supplied andsignatureLengthis omitted, the/Contentsbudget defaults to 30720 (vs 8192) to fit the token + TSA chain. Overflow is rejected, never truncated. - Degradation:
required: falseyields a valid CAdES-B signature and setstsaError;required: truerethrows. - Verification enforces the RFC-3161 §2.4.2 binding: the token's
messageImprintmust equalsha256(SignerInfo.signature), elseok:false.
Verification
verifyPdfStructure() (aliased verifyPdfSignature()) is fully cryptographic: it recomputes SHA-256 over the ByteRange-covered bytes and compares to the signed messageDigest, and RSA-verifies the signature over the DER-encoded signed attributes.
| Field | Meaning |
|---|---|
ok | true only when structure + digest + signature (+ TSA binding, if present) all pass. |
digestValid | Recomputed document digest matches the signed messageDigest. |
signatureValid | RSA signature over the signed attributes verifies against the signer cert. |
signerCommonName | Signer cert CN. |
byteRange | The /ByteRange array the signature covers. |
timestamped, timestampTime, tsaCommonName | Present when a valid RFC-3161 token is embedded. |
const v = verifyPdfStructure(signedPdf);
if (!v.ok) throw new Error("signature invalid");
// v.digestValid, v.signatureValid, v.signerCommonName,
// v.timestamped, v.timestampTime, v.tsaCommonName
signDocument() — end-to-end
The optional orchestrator ties the pieces together over your stores: ensure the tenant cert, render, sign (optional TSA), persist the signed PDF, and write an audit-log entry — one call.
import { signDocument } from "@e-sig/core";
const result = await signDocument({
stores: { cert: certStore, audit: auditStore, storage: pdfStore },
tenantId: "acme",
subjectName: "Acme Corp",
passphrase,
html,
signer: { name: "Jane Doe", reason: "acceptance", contactInfo: "jane@acme.example" },
tsa, // optional
});
// result.signedPdf, result.storage, result.audit, result.verification
You bring the three store implementations; @e-sig/supabase is a ready reference set.
Storage adapters
e-sig keeps persistence, auth, and UI out of the core. You implement three small interfaces (or use @e-sig/supabase):
| Interface | Responsibility |
|---|---|
CertStore | Persist + look up per-tenant certs (encrypted keys). Methods: findActive, insert, deactivate, findExpiring. |
AuditLogStore | Append-only signing audit records (insert) for ESIGN/UETA evidence. |
PdfStorageStore | Store the signed PDF bytes (upload) and return a locator. |
import {
SupabaseCertStore,
SupabaseAuditLogStore,
SupabasePdfStorageStore,
} from "@e-sig/supabase";
Apply migrations/0001_esig_self_contained.sql (tenant-keyed esig_cert + esig_audit_log tables) and replace the esig_tenant_member() stub with your membership check.
React UI
@e-sig/react ships the signature-capture surface so you don't build it from scratch:
| Component | Use |
|---|---|
SignaturePadCanvas | Draw-to-sign canvas → PNG data URL. |
SelfSignFlow | Full self-sign flow (review → sign → submit). |
SelfSignedReceipt | Post-sign receipt with verification details. |
import { SelfSignFlow } from "@e-sig/react";
<SelfSignFlow
documentHtml={html}
onSigned={(receipt) => { /* persist / redirect */ }}
/>
Signature images are validated as image data URLs before they reach the PDF template.
Security & compliance
What e-sig gives you
- Integrity: the signed ByteRange is hashed and RSA-signed; any later edit fails verification.
- Attribution: the signer cert + signature dictionary (name/reason/time) bind who signed and why.
- PAdES: ETSI.CAdES.detached subfilter with ESS signing-certificate-v2;
padesStrictfor B-B. - CAdES-T: optional RFC-3161 timestamp proves the signature existed at a point in time.
- Evidence: an append-only audit log per signing event for ESIGN / UETA / 21 CFR §11 support.
The self-signed trust model
By default the signing cert is self-issued. That makes signatures cryptographically valid but not automatically trusted by third-party readers — Adobe shows "validity unknown" until the cert (or its issuer) is in a trust store. Two ways to establish trust:
- Closed ecosystem: import your org cert into the relevant trust stores. Sufficient for internal / B2B flows where both sides know the issuer.
- Public trust: plug an AATL/CA-issued signer (or a qualified TSP) into the same
signPdfpath — the code is signer-agnostic.
Not legal advice. ESIGN/UETA validity depends on intent to sign, attribution, record integrity, and retention — process concerns e-sig helps evidence but does not adjudicate. Confirm your specific compliance requirements with counsel.
API reference
Everything exported from @e-sig/core:
| Export | Kind |
|---|---|
generateSelfSignedCert, encryptKeyPem, decryptKeyPem | Certs & keys |
ensureActiveCert | Per-tenant cert lifecycle |
renderHtmlToPdf | HTML → PDF |
signPdf, PemSigner | Signing |
verifyPdfStructure / verifyPdfSignature | Verification |
buildTimeStampReq, parseTimeStampResp, parseTstInfo, OID_TIMESTAMP_TOKEN | RFC-3161 timestamps |
signDocument | End-to-end orchestrator |
CertStore, AuditLogStore, PdfStorageStore | Adapter interfaces (types) |
Signer, SigningCertPem, SignedPdfMetadata, TsaTransport | Shared types |
Full consumer guide: packages/esig-core/CONSUMING.md in the repo.