
Security News
White House Authorizes Private Companies to Conduct Offensive Cyber Operations
A new federal program will let vetted U.S. cybersecurity firms help investigate and disrupt foreign cybercrime groups under government direction.
@e-sig/core
Advanced tools
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.
@e-sig/core — Portable In-Platform E-SignatureSelf-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.
Given an HTML document and a person who wants to sign it, this library:
puppeteer-core; scripting disabled by default).padesStrict: true for strict PAdES B-B (also drops the PAdES-forbidden signing-time attribute).verifyPdfSignature() checks this cryptographically (recomputes the digest over the signed ByteRange and RSA-verifies the signature).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.
By design — these are wrapper concerns:
CertStore adapter interface.AuditLogStore adapter interface.The library gives you crypto + rendering. You bring persistence + UI + auth.
| 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 | ✅ |
npm install \
@signpdf/signpdf \
@signpdf/utils \
@signpdf/placeholder-plain \
node-forge \
puppeteer-core \
@sparticuz/chromium # only on Lambda; skip for local-only
Plus @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/**",
],
},
};
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: "jane@example.com",
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).
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: "legal@acme.org", 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:
tsa is supplied and signatureLength is omitted, the
/Contents placeholder budget defaults to 30720 (vs 8192 without a TSA)
to fit the TimeStampToken plus the TSA certificate chain. An overflow is
rejected, never silently truncated.required: false (default), a TSA error produces a
valid CAdES-B signature and sets tsaError; with required: true the error
is rethrown.messageImprint must equal sha256(SignerInfo.signature), else ok:false.See CONSUMING.md for the full consumer guide.
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: "jane@example.com",
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 key
verifyMlDsaCertificate() 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.
For real usage you need to:
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.
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[]>;
}
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()
The Opendelphi production wire-up uses this library for HIPAA-bound Data Use Agreements and is mapped against:
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.
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.
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.)
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.
node-forge.pkcs12.toPkcs12Asn1 — see above.forge.pki.certificateFromPem mis-counts bytes for non-ASCII (em-dash in OU breaks PEM round-trip with "Too few bytes to parse DER").@signpdf/signpdf v3 ESM default import is opaque — use the named export: import { SignPdf } from "@signpdf/signpdf" and new SignPdf().sign(...).@sparticuz/chromium is Lambda-only — locally, use system Chrome via the executablePath override.forge.pem.decode extracts blocks correctly, but re-encoding the cert block from a multi-block buffer doesn't survive certificateFromPem round-trip. Store them as separate files / DB columns.End-to-end on Vercel Lambda (cold start), tested against opendelphi.org in production:
Subsequent signs reuse the cached cert → ~1–1.5 s warm.
Same as the parent project. The core/ directory is intentionally self-contained so it can be vendored under your own license.
.planning/phases/19-esig-primitives-spike/19-02-PATTERNS-OBSERVED.md for the lessons borrowed).FAQs
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.
The npm package @e-sig/core receives a total of 10 weekly downloads. As such, @e-sig/core popularity was classified as not popular.
We found that @e-sig/core demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
A new federal program will let vetted U.S. cybersecurity firms help investigate and disrupt foreign cybercrime groups under government direction.

Research
/Security News
The campaign amassed more than 75,000 installs by targeting Russian-speaking users seeking access to blocked services.

Company News
Open source maintainers are under more pressure than ever. We're raising our open source program from the Team plan to the Business plan, free.