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.

render sign timestamp verify audit

The suite is three packages plus a Next.js starter:

PackageRoleRuntime
@e-sig/coreThe engine — render, cert issuance, PKCS#7/PAdES signing (+ RFC-3161 TSA), verification, and the storage-adapter interfaces.Node, stack-agnostic
@e-sig/supabaseReference adapters: cert store, audit-log store, PDF storage over Supabase (Postgres + Storage).Supabase
@e-sig/reactUI: 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. Rendering needs a local Chrome; for a zero-dependency runnable version that signs an existing PDF (no browser), copy examples/quickstart.

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.

FieldMeaning
pdfUnsigned PDF bytes (Buffer/Uint8Array).
keyPem, certPemSigner private key + certificate (PEM).
name, reason, location, contactInfoSignature dictionary metadata shown in the PDF signature panel.
signingTimeOptional signing time (Date).
tsaOptional RFC-3161 timestamp transport → upgrades to CAdES-T. See below.
padesStricttrue = strict PAdES B-B: also drops the PAdES-forbidden signing-time signed attribute.
signatureLengthOverride 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 tsa is supplied and signatureLength is omitted, the /Contents budget defaults to 30720 (vs 8192) to fit the token + TSA chain. Overflow is rejected, never truncated.
  • Degradation: required: false yields a valid CAdES-B signature and sets tsaError; required: true rethrows.
  • Verification enforces the RFC-3161 §2.4.2 binding: the token's messageImprint must equal sha256(SignerInfo.signature), else ok: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.

FieldMeaning
oktrue only when structure + digest + signature (+ TSA binding, if present) all pass.
digestValidRecomputed document digest matches the signed messageDigest.
signatureValidRSA signature over the signed attributes verifies against the signer cert.
signerCommonNameSigner cert CN.
byteRangeThe /ByteRange array the signature covers.
timestamped, timestampTime, tsaCommonNamePresent 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({
  html,                      // fully-rendered, signature-embedded HTML
  tenantId: "acme",
  subjectName: "Acme Corp",
  passphrase,                // key-at-rest passphrase (≥ 24 chars)
  signer: { name: "Jane Doe", email: "jane@acme.example" },
  certStore, auditStore, storage,
  pathPrefix: "acme/doc-42",
  reason: "Service Agreement acceptance",
  tsa,                       // optional → CAdES-T
});
// result.signedPdfUrl, result.auditLogId, result.certId,
// result.certFingerprint, result.timestamped

You bring the three store implementations; @e-sig/supabase is a ready reference set.

Envelopes — multi-signer + signing links

An envelope tracks N signers over one document. Each signer gets an opaque single-use signing token (32-byte CSPRNG) minted at creation and returned exactly once — only its SHA-256 hash is persisted, so a leaked store cannot forge signing links. Signing order is a 1-based integer: equal order signs in parallel, lower orders gate higher ones. A decline voids the envelope.

import {
  createEnvelope, resolveSigningToken, recordSignature,
  composeEnvelopeHtml, signDocument,
} from "@e-sig/core";

// 1. Create — returns each signer's raw token ONCE. Email them as links.
const { envelope, signingTokens } = await createEnvelope({
  store,                                  // your EnvelopeStore (or FsEnvelopeStore)
  tenantId: "acme",
  title: "Master Service Agreement",
  html: agreementHtml,
  signers: [
    { name: "Ada Lovelace",  email: "ada@acme.example",  roleLabel: "CEO",     order: 1 },
    { name: "Grace Hopper",  email: "grace@acme.example", roleLabel: "Witness", order: 2 },
  ],
  expiresAt: new Date(Date.now() + 14 * 864e5),
});

// 2. Signing surface: resolve the token from the link…
const res = await resolveSigningToken({ store, token });
// res.status: "ok" | "not_your_turn" | "already_signed" | "expired" | "voided" | "completed" | "invalid"

// 3. …record the drawn signature (single-use; order-gated).
const updated = await recordSignature({ store, token, signatureImageDataUrl });

// 4. When updated.status === "completed": compose + apply the cryptographic seal.
const finalHtml = composeEnvelopeHtml(updated, { platformLabel: "Acme sign" });
await signDocument({ html: finalHtml, /* stores, tenant, passphrase, … */ });

One seal, not N. Signatures are collected as drawn images per signer; the completed envelope receives a single PKCS#7 seal over the composed document. Sequential PDF re-signing (one CMS signature per signer) is deliberately out of scope — the signer/verifier pair handles a single /ByteRange.

Persistence is one small EnvelopeStore interface (insert, update, findById, findByTokenHash). A filesystem implementation ships in @e-sig/core/fs.

Storage adapters

e-sig keeps persistence, auth, and UI out of the core. You implement three small interfaces (or use @e-sig/supabase):

InterfaceResponsibility
CertStorePersist + look up per-tenant certs (encrypted keys). Methods: findActive, insert, deactivate, findExpiring.
AuditLogStoreAppend-only signing audit records (insert) for ESIGN/UETA evidence.
PdfStorageStoreStore the signed PDF bytes (upload) and return a locator.
// Postgres + Storage (multi-tenant production)
import {
  SupabaseCertStore,
  SupabaseAuditLogStore,
  SupabasePdfStorageStore,
} from "@e-sig/supabase";

// …or a bare directory (dev, demos, CLIs, single-node) — no services at all:
import {
  FsCertStore,
  FsAuditLogStore,     // append-only NDJSON
  FsPdfStorageStore,
  FsEnvelopeStore,
} from "@e-sig/core/fs";

For Supabase: apply migrations/0001_esig_self_contained.sql (tenant-keyed org_signing_certs + esig_audit_log tables) and replace the esig_tenant_member() stub with your membership check. The fs adapters are single-process (atomic-replace JSON state) — reach for Supabase (or your own implementation) when multiple processes sign concurrently.

React UI

@e-sig/react ships the signature-capture surface so you don't build it from scratch:

ComponentUse
SignaturePadCanvasDraw-to-sign canvas → PNG data URL.
SelfSignFlowFull self-sign flow (review → sign → submit).
SelfSignedReceiptPost-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; padesStrict for 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:

  1. Closed ecosystem: import your org cert into the relevant trust stores. Sufficient for internal / B2B flows where both sides know the issuer.
  2. Public trust: plug an AATL/CA-issued signer (or a qualified TSP) into the same signPdf path — 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:

ExportKind
generateSelfSignedCert, encryptKeyPem, decryptKeyPemCerts & keys
ensureActiveCertPer-tenant cert lifecycle
renderHtmlToPdfHTML → PDF
signPdf, PemSignerSigning
verifyPdfStructure / verifyPdfSignatureVerification
buildTimeStampReq, parseTimeStampResp, parseTstInfo, OID_TIMESTAMP_TOKENRFC-3161 timestamps
signDocumentEnd-to-end orchestrator
createEnvelope, resolveSigningToken, recordSignature, declineEnvelope, voidEnvelope, composeEnvelopeHtml, EnvelopeErrorMulti-signer envelopes
CertStore, AuditLogStore, PdfStorageStore, EnvelopeStoreAdapter interfaces (types)
FsCertStore, FsAuditLogStore, FsPdfStorageStore, FsEnvelopeStoreFilesystem adapters (@e-sig/core/fs)
Signer, SigningCertPem, SignedPdfMetadata, TsaTransportShared types

Full consumer guide: packages/esig-core/CONSUMING.md in the repo.