@e-sig/core
v0.6.0
Published
Self-contained PKCS#7 PDF signing — no SaaS, no metering, no per-doc fees. Render→sign→verify engine + pluggable persistence interfaces + end-to-end signDocument() orchestrator.
Maintainers
Readme
@e-sig/core — Portable In-Platform E-Signature
Self-contained PKCS#7 PDF signing — no SaaS, no metering, no per-doc fees. Battle-tested in production at opendelphi.org.
This directory is the portable core of the Opendelphi e-signature pipeline. Drop it (plus a tiny adapter you write) into any TypeScript / Node.js project to add real cryptographic signing of PDFs.
What it does
Given an HTML document and a person who wants to sign it, this library:
- Renders the HTML to a PDF (headless Chromium via
puppeteer-core; scripting disabled by default). - Generates or reuses a self-signed RSA-2048 X.509 cert for the signing tenant.
- Embeds a PKCS#7 detached signature under the ETSI.CAdES.detached subfilter, with the ESS signing-certificate-v2 attribute binding the signer cert into the signed data. Pass
padesStrict: truefor strict PAdES B-B (also drops the PAdES-forbiddensigning-timeattribute). - Produces a PDF that opens cleanly in Preview / Adobe Reader with a valid signature panel — any post-signing edit invalidates the signature.
verifyPdfSignature()checks this cryptographically (recomputes the digest over the signed ByteRange and RSA-verifies the signature). - (Optional) Adds a post-quantum hybrid seal — Ed25519 + ML-DSA-65 (FIPS 204) — embedded under the classical signature so the PDF stays Acrobat-valid while gaining quantum resistance. See Post-quantum seal.
Trust vs. validity. The signature is cryptographically valid, but the cert is self-issued — stock Adobe Reader shows "validity unknown" until the cert is trusted (org trust-store import, or plug in an AATL/CA signer). This verifies the signature math and integrity, not third-party trust. See the compliance notes below.
That's the whole thing. It's ~600 lines of TypeScript with zero runtime dependencies on Supabase, Next.js, or any SaaS.
What it does NOT do
By design — these are wrapper concerns:
- Storage of the signed PDF. You decide where (S3, local disk, Supabase Storage, …).
- Auth / authorization. You decide who's allowed to sign.
- UI. You build the signature-capture surface.
- Persistence of the cert pool. Implement the
CertStoreadapter interface. - Audit logging. Implement the
AuditLogStoreadapter interface.
The library gives you crypto + rendering. You bring persistence + UI + auth.
Files
| File | Purpose | Project-agnostic? |
|---|---|---|
| pem-signer.ts | Custom @signpdf Signer driven by raw PEM key+cert (bypasses node-forge's broken P12 round-trip — see Background below) | ✅ |
| cert-issuer.ts | Generate self-signed RSA-2048 X.509; AES-256-GCM-wrap private keys for at-rest storage | ✅ |
| render-pdf.ts | HTML → PDF via puppeteer-core; auto-detects Lambda vs local Chrome | ✅ |
| sign-pdf.ts | Combine placeholder injection + PKCS#7 sign (ETSI.CAdES) | ✅ |
| verify-pdf.ts | Structural verifier (parses /ByteRange + PKCS#7 blob, returns diagnostics) | ✅ |
| pq-seal.ts | Hybrid Ed25519 + ML-DSA-65 (FIPS 204) post-quantum seal — keygen, sign, verify | ✅ |
| pq-embed.ts | Embed/extract the seal in a PDF as an append-only incremental update | ✅ |
| pq-verify.ts | verifyPqSeal + verifyDocument (classical and post-quantum verdict) | ✅ |
| pq-lifecycle.ts | ensureActivePqKeys / rotatePqKeys over a bring-your-own PqKeyStore | ✅ |
| signature-block.ts | HTML helper to render N signature blocks for multi-party flows | ✅ |
| types.ts | Shared TS types (Signer, SigningCertPem, …) | ✅ |
| index.ts | Public re-export barrel | ✅ |
Install
npm install \
@signpdf/signpdf \
@signpdf/utils \
@signpdf/placeholder-plain \
node-forge \
puppeteer-core \
@sparticuz/chromium # only on Lambda; skip for local-onlyPlus @types/node-forge if you're using TypeScript.
If you're on Next.js, you MUST externalize the binary-adjacent packages in next.config.ts:
const nextConfig = {
serverExternalPackages: [
"@sparticuz/chromium",
"puppeteer-core",
"node-forge",
"@signpdf/signpdf",
"@signpdf/utils",
"@signpdf/placeholder-plain",
],
// The chromium binary tarball is a static asset; tell file-tracing to
// include it in your e-sig route's bundle.
outputFileTracingIncludes: {
"/api/your-esig-route": [
"./node_modules/@sparticuz/chromium/bin/**",
],
},
};30-second example
import {
generateSelfSignedCert,
renderHtmlToPdf,
signPdf,
verifyPdfStructure,
} from "@e-sig/core";
// 1. Issue a one-off cert (in real life, persist + reuse).
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 at ${new Date().toISOString()}.</p>`,
});
// 3. Sign it.
const { signedPdf } = await signPdf({
pdf: unsigned,
keyPem: cert.keyPem,
certPem: cert.certPem,
reason: "Service Agreement acceptance",
location: "https://acme.example",
contactInfo: "[email protected]",
name: "Jane Doe",
});
// 4. Verify the result cryptographically (also exported as verifyPdfSignature).
// ok === true only when structure + document digest + RSA signature all pass;
// a single flipped byte under the signature makes ok=false / digestValid=false.
const verify = verifyPdfStructure(signedPdf);
console.log(verify.ok, verify.digestValid, verify.signatureValid, verify.signerCommonName);
// → true, true, true, "E-sig (Acme Corp)"
// 5. Persist + serve. Up to you.
require("fs").writeFileSync("./signed.pdf", signedPdf);That's it. Open signed.pdf in Preview — signature panel shows valid (self-signed).
RFC 3161 trusted timestamps (CAdES-T)
Pass a tsa transport to signPdf 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 (OID 1.2.840.113549.1.9.16.2.14)
computed over the SignerInfo signatureValue (RFC 3161 §2.4.1).
The package 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 or any PHI:
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,
reason: "DUA acceptance", location: "opendelphi.org",
contactInfo: "[email protected]", name: "Acme Research Institute",
tsa,
});
const v = verifyPdfStructure(signedPdf);
// v.timestamped, v.timestampTime (ISO), v.tsaCommonName
// v.ok is false if the §2.4.2 binding check fails (imprint != sha256(sigValue))Notes:
- Budget: when
tsais supplied andsignatureLengthis omitted, the/Contentsplaceholder budget defaults to 30720 (vs8192without a TSA) to fit the TimeStampToken plus the TSA certificate chain. An overflow is rejected, never silently truncated. - Degradation: with
required: false(default), a TSA error produces a valid CAdES-B signature and setstsaError; withrequired: truethe error is rethrown. - Verification enforces the RFC 3161 §2.4.2 binding: the token's
messageImprintmust equalsha256(SignerInfo.signature), elseok:false.
See CONSUMING.md for the full consumer guide.
Post-quantum seal — ML-DSA-65 (FIPS 204)
Harvest-now-decrypt-later is a real threat to long-lived signed documents: a
signature that is only RSA/ECDSA today is forgeable the day a cryptographically
relevant quantum computer exists. @e-sig/core can add a hybrid post-quantum
seal — Ed25519 + ML-DSA-65 (the FIPS 204 module-lattice signature, née
Dilithium) — to any signed PDF, without giving up compatibility.
How it stays compatible: the seal does not replace the PKCS#7/PAdES RSA signature (no mainstream PDF reader validates ML-DSA in PAdES yet). The seal is embedded first, as an append-only incremental update, and the classical RSA signature is applied on top — so it cryptographically covers the seal. Adobe Acrobat still shows a valid signature; your verifier additionally confirms the quantum-resistant layer. This is the hybrid migration path NIST/CNSA 2.0 recommend over a hard cutover.
import {
generateSelfSignedCert,
generatePqKeyBundle,
loadPqSigningKeys,
signPdf,
verifyDocument,
} from "@e-sig/core";
const cert = generateSelfSignedCert({ subjectName: "Acme Corp" });
// One hybrid key bundle per signer/tenant (persist wrapPqKeyBundle(...) at rest;
// or use ensureActivePqKeys with a PqKeyStore — see below).
const pqKeys = loadPqSigningKeys(generatePqKeyBundle().bundle);
const { signedPdf } = await signPdf({
pdf: unsigned,
keyPem: cert.keyPem,
certPem: cert.certPem,
reason: "Service Agreement acceptance",
location: "",
contactInfo: "[email protected]",
name: "Jane Doe",
pqSeal: { keys: pqKeys }, // ← adds the post-quantum seal
});
// One call, two verdicts:
const v = verifyDocument(signedPdf);
console.log("classical (PAdES/RSA):", v.classical.ok); // → true (Acrobat-grade)
console.log("post-quantum (ML-DSA-65):", v.postQuantum.ok); // → true
console.log("PQ signer fingerprint:", v.postQuantum.mldsa65Fpr);
// v.ok === true only when BOTH layers verify AND the seal lies inside the
// RSA-signed region. Tampering with one byte of the document fails BOTH.Managed keys. ensureActivePqKeys({ store, tenantId, passphrase }) mints +
wraps a bundle on first use and reuses it thereafter (implement the small
PqKeyStore interface against your DB, same pattern as CertStore);
rotatePqKeys(...) rolls to a fresh key while old documents keep verifying
against the public key embedded in each seal. signDocument({ pq: { keys } })
threads it through the end-to-end orchestrator and records the PQ key id +
ML-DSA fingerprint in the audit row.
Trust model (v1). The seal carries the raw ML-DSA-65 public key + its SHA-256 fingerprint; a relying party pins/publishes the expected fingerprint (TOFU). Assert it in-band:
verifyDocument(signedPdf, {
expectedMldsa65Fpr: "<the signer's published fingerprint>", // fails if it differs
requirePq: true, // fails if no seal present (no silent downgrade)
});X.509 identity (RFC 9881). For an enterprise-shaped identity, issue a
self-signed ML-DSA-65 X.509 certificate (OID 2.16.840.1.101.3.4.3.18) —
its SubjectPublicKeyInfo and signature are ML-DSA-65, so it parses and verifies
in OpenSSL 3.5+ (validated against 3.6). Bind it to a seal at verify time:
const cert = issueMlDsaCertificate({ keys, subjectName: "Acme Inc" }); // publish cert.certPem
verifyDocument(signedPdf, { signerCert: cert.certPem }); // fails unless the cert is valid AND owns the seal's keyverifyMlDsaCertificate() checks the self-signature, algorithm, and validity
window; certMatchesPqSeal() ties it to a seal by public-key fingerprint. Both
seal signatures are required — if either Ed25519 or ML-DSA-65 fails (or the
seal's fingerprint/keyId don't match its keys), the seal fails.
Persisting certs + audit logs across requests
For real usage you need to:
- Cache certs per tenant so you don't regenerate on every sign.
- Encrypt private keys at rest so a DB leak doesn't compromise signing authority.
- Log every sign for ESIGN / UETA / 21 CFR §11 compliance evidence.
The library provides adapter interfaces (CertStore, AuditLogStore — see ../adapters/types.ts). Implement them against your DB.
A reference Supabase implementation lives at ../adapters/supabase.ts (~150 lines). It works against any schema with these two tables — copy the migration from supabase/migrations/00106_esig_self_contained.sql for the canonical shape, or write your own.
CertStore interface
interface CertStore {
findActive(tenantId: string): Promise<StoredCert | null>;
insert(input: { tenantId; generated; keyPemEncrypted; rotatedFromId? }): Promise<StoredCert>;
deactivate(id: string): Promise<void>;
findExpiring(withinDays: number): Promise<StoredCert[]>;
}AuditLogStore interface
interface AuditLogStore {
insert(entry: AuditLogEntry): Promise<AuditLogRow>;
}Then use the convenience helper ensureActiveCert from ../adapters/supabase.ts as a template:
const result = await ensureActiveCert({
store: new YourCertStore(...),
tenantId: "acme-corp",
subjectName: "Acme Corp",
passphrase: process.env.ESIG_CERT_PASSPHRASE!,
});
// result.certPem + result.keyPem ready to feed into signPdf()Compliance posture
The Opendelphi production wire-up uses this library for HIPAA-bound Data Use Agreements and is mapped against:
- ESIGN Act § 7001 (R1–R5) — Intent, Consent to electronic, Attribution, Integrity, Retention. R4 Integrity is fully covered by the crypto core; R1/R2/R3/R5 are wrapper concerns.
- UETA § 9 + § 13 — Attribution + system attribution log.
- 21 CFR § 11.50 / § 11.70 — FDA-grade requirements where applicable.
See .planning/phases/19-esig-primitives-spike/19-04-ESIGN-GAPS.md for the full mapping.
Not legal advice. Talk to your lawyer about whether this satisfies the regulatory framework for your specific use case.
Background — why this exists
Why not DocuSign / DocuSeal / Documenso?
Per-document metering (~$0.20/sig) made the unit economics painful at scale. And every signed PDF flowed through a third-party processor — making HIPAA + GDPR compliance harder than it had to be.
This library is what you reach for when "no SaaS, no metering, no fees" is a hard requirement.
Why not pdf-lib?
pdf-lib is the most popular Node PDF library, but it hasn't shipped a release since 2021. Documenso uses @libpdf/core instead — same conclusion here. (Neither is actually used by this core — we drive puppeteer for rendering and @signpdf + node-forge for signing, both actively maintained.)
Why not PKCS#12?
We tried. node-forge.pkcs12.toPkcs12Asn1 produces P12 bundles whose MAC neither node-forge nor openssl can verify. Looks like a long-standing BMPString-password-derivation bug. We bypass it entirely — the PemSigner takes raw PEM and drives forge.pkcs7 directly.
Bugs to avoid (we hit these so you don't have to)
- Don't use
node-forge.pkcs12.toPkcs12Asn1— see above. - ASCII-only cert subject names —
forge.pki.certificateFromPemmis-counts bytes for non-ASCII (em-dash in OU breaks PEM round-trip with "Too few bytes to parse DER"). @signpdf/signpdfv3 ESM default import is opaque — use the named export:import { SignPdf } from "@signpdf/signpdf"andnew SignPdf().sign(...).@sparticuz/chromiumis Lambda-only — locally, use system Chrome via theexecutablePathoverride.- Storing key + cert in one PEM file is fragile —
forge.pem.decodeextracts blocks correctly, but re-encoding the cert block from a multi-block buffer doesn't survivecertificateFromPemround-trip. Store them as separate files / DB columns.
Performance
End-to-end on Vercel Lambda (cold start), tested against opendelphi.org in production:
- Render HTML → unsigned PDF: ~2.5 s (cold) / ~0.5 s (warm)
- Generate cert (first sign per tenant): ~0.8 s (RSA-2048 keygen dominates)
- PKCS#7 sign: ~0.1 s
- Upload + audit + DB row flip: ~0.3 s
- Total cold-start round-trip: ~4.5 s
Subsequent signs reuse the cached cert → ~1–1.5 s warm.
License
Same as the parent project. The core/ directory is intentionally self-contained so it can be vendored under your own license.
Acknowledgments
- @signpdf for the PKCS#7 placeholder + signing infrastructure.
- node-forge for the X.509 + PKCS#7 + crypto primitives.
- Documenso + DocuSeal CE as reference implementations (read-only — no code copied — see
.planning/phases/19-esig-primitives-spike/19-02-PATTERNS-OBSERVED.mdfor the lessons borrowed).
