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:

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({
  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):

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

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
CertStore, AuditLogStore, PdfStorageStoreAdapter interfaces (types)
Signer, SigningCertPem, SignedPdfMetadata, TsaTransportShared types

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