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 nine published packages plus a Next.js starter example:
| Package | Role |
|---|---|
@e-sig/core | The engine — render, cert issuance, PKCS#7/PAdES signing (+ RFC-3161 TSA), verification, and the storage-adapter interfaces. |
@e-sig/supabase | Reference adapters: cert store, audit-log store, PDF storage over Supabase (Postgres + Storage). |
@e-sig/react | Draw-to-sign UI: signature pad, self-sign flow, receipt. |
@e-sig/uuaid | Opt-in adapter: stamps the acting AI agent's UUAID into the audit log and anchors the audit hash-chain. |
@e-sig/uaid-exch | Preview of the IAASO Exchange Profile (ADR-006): wraps core envelopes as per-transaction exchange records. |
@e-sig/worm | WORM archival adapter: signed PDFs + audit-chain exports to S3 Object-Lock. |
@e-sig/hsm-pkcs11 | PKCS#11 adapter: keep the signing key in AWS CloudHSM, YubiHSM 2, SoftHSM2, or any Cryptoki provider. |
@e-sig/mcp | MCP server for agent-driven signing — envelopes, delivery, reminders, lifecycle webhooks, signer identity, Pillar delivery. See Agent signing. |
@e-sig/pillar-bridge | Optional agent-to-agent delivery bridge for @e-sig/mcp over UUAID's Pillar substrate. See Agent-to-agent (Pillar). |
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.
| 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({
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):
| 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. |
// 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:
| 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.
MCP server (@e-sig/mcp)
An MCP (Model Context Protocol) server for agent-driven e-signature workflows: agents draft, send, and track envelopes over @e-sig/core; a human holds the pen by default. Cryptographic control of signing stays with the human — no tool can produce a signature, and no tool can return a raw signing link, unless an operator explicitly opts in (ESIG_MCP_RETURN_LINKS=1, local demos only).
# zero setup — no passphrase, no data dir, no Chrome
npx @e-sig/mcp demo --auto
# wire it in for real: writes ./esig-data/, a .esig-mcp.env, and a ready .mcp.json snippet
npx @e-sig/mcp init
Three env vars start it: ESIG_MCP_PASSPHRASE (≥ 24 chars — encrypts the tenant's signing cert + PQ key bundle at rest), ESIG_MCP_DELIVERY (no default — see Email delivery and reminders), and ESIG_MCP_DATA_DIR.
Tool surface
| Tool | Kind | What it does |
|---|---|---|
esig_create_envelope | prepare | Create an envelope from exactly one of html or a PDF docId + a signer list; dispatches signing links through the configured delivery channel. |
esig_envelope_status | read | One envelope's status, phase, per-signer state (incl. verified identity), seal state, and sealed PDF path once sealed. |
esig_identity_challenge | prepare | Issue (or re-issue) the sole-control challenge a signer's wallet/agent signs to satisfy an identity requirement. |
esig_ingest_document | prepare | Store PDF bytes in a content-addressed workdir; returns a docId for a Chrome-free PDF envelope, or for esig_verify_document. |
esig_list_envelopes | read | List envelopes for this server's tenant, optionally filtered by status. |
esig_list_events | read | An envelope's lifecycle events, oldest first (since filters to events after a timestamp). |
esig_reseal | prepare | Retry producing the sealed PDF for a completed envelope whose seal step failed or never ran. |
esig_send_reminder | prepare | Resend a signing reminder to one pending signer, or every pending signer. |
esig_verify_document | read | Verify a PDF's classical signature and, if present, its post-quantum seal — by path (confined to ESIG_MCP_DOCS_ROOT), base64, or a prior docId. |
esig_void_envelope | prepare | Cancel a pending or partially-signed envelope. |
esig_whoami | read | This server's tenant, enabled modes, caps, seal readiness, and public cert/PQ fingerprints — never key material. |
Modes: H today; A/C refuse to start by design. There is no tool that signs. esig_sign_as_agent and esig_cosign_start (modes A/C — an agent signing as itself, and dual-key co-sign) are v0.2, gated behind a RedTeam review: ESIG_MCP_MODES containing A or C refuses to build a config at all, so there is no reachable code path in this package that can start a server for a mode it doesn't implement.
Email delivery and reminders
ESIG_MCP_DELIVERY has no default — an operator must pick where signing links go:
| Value | Where the link goes |
|---|---|
file | One JSON receipt per envelope, <ESIG_MCP_DATA_DIR>/outbox/<envelopeId>.json (mode 0600) — the quickstart channel. |
console | Printed to stderr — opt-in only, loud startup warning (stderr is the agent harness's own log in a stdio deployment). |
webhook | POSTed to ESIG_MCP_DELIVERY_WEBHOOK_URL. |
email | SMTP or SES — below. |
pillar | Sealed over UUAID's Pillar substrate — see Agent-to-agent (Pillar). |
ESIG_MCP_DELIVERY=email
ESIG_MCP_EMAIL_TRANSPORT=smtp # or "ses"
ESIG_MCP_EMAIL_FROM="Acme <noreply@acme.com>"
ESIG_MCP_EMAIL_REPLY_TO=support@acme.com # optional
ESIG_MCP_EMAIL_SUBJECT_PREFIX="[Acme] " # optional
# smtp:
ESIG_MCP_SMTP_HOST=smtp.example.com
ESIG_MCP_SMTP_PORT=587 # 465 implies implicit TLS even without SMTP_SECURE=1
ESIG_MCP_SMTP_USER=...
ESIG_MCP_SMTP_PASS=...
# ses (needs the optional peer dependency @aws-sdk/client-sesv2):
ESIG_MCP_SES_REGION=us-east-1
TLS rules, stated exactly. STARTTLS is required by default; ESIG_MCP_SMTP_ALLOW_PLAINTEXT=1 skips it entirely. Server certificate verification against the system CA is on by default; ESIG_MCP_SMTP_ALLOW_UNVERIFIED_TLS=1 turns it off (rejectUnauthorized:false) — a loud startup warning prints every time that flag is set. Leave both unset for anything beyond a trusted loopback receiver.
smtp is dependency-free (node:net/node:tls only — EHLO, STARTTLS, AUTH PLAIN/LOGIN). ses calls SESv2 SendEmail through @aws-sdk/client-sesv2, an optional peer dependency this package never installs — without it, ESIG_MCP_EMAIL_TRANSPORT=ses fails at first send with a clear install error, not a silent no-op.
Reminders
ESIG_MCP_REMINDERS=24h,72h # durations after creation; default off
ESIG_MCP_REMINDER_MAX=3 # hard cap per signer
Requires ESIG_MCP_DELIVERY=email — refused at startup otherwise, since there's no other channel to resend the original link through. An in-process 60-second tick sends a reminder to each still-pending signer whose next scheduled reminder is due; esig_send_reminder(envelopeId, signerId?) sends one on demand, counted against the server's shared hourly rate window (the same one envelope creation and other write tools draw from). The scheduler's automatic sends take no rate limit at all — so a burst of manual tool calls can never starve the reminders a human is actually waiting on, though a manual call can itself be refused while the hourly window is exhausted.
Link custody — the one custody change. Core mints each signing token once and never re-mints it, so a reminder needs the original link. When reminders are configured, every signer's link is stored encrypted at rest (AES-256-GCM under ESIG_MCP_PASSPHRASE) and decrypted only inside the reminder-sending path — no tool ever returns it, exactly as link-free as before. It's erased the moment the state it exists to resend has passed: per-signer the moment they sign, and for the whole envelope on decline/void/expiry/completion.
Lifecycle events and webhooks
Every state change on an envelope appends an event to its log (metadata.mcp.events[], capped at 200). esig_envelope_status returns the last 10; esig_list_events(envelopeId, since?) returns them all, oldest first.
| Event | Fires when |
|---|---|
envelope.created | esig_create_envelope succeeds. |
envelope.viewed | GET /sign/<token> first resolves ok for a given signer (once per signer). |
envelope.signed | A signer's signature is recorded. |
envelope.declined | A signer declines — voids the whole envelope, attributed to that signer. |
envelope.completed | Every signer has signed (independent of whether sealing then succeeds). |
envelope.sealed / envelope.seal_failed | The seal step (automatic, or esig_reseal) succeeds or fails. |
envelope.voided | esig_void_envelope. |
envelope.expired | A lazy 60-second tick catches any envelope nobody happened to poll. |
envelope.reminder_sent | A reminder (automatic or esig_send_reminder) is sent. |
signer.identity_verified / signer.identity_rejected | A signer identity check passes or fails. |
12 types total (the two seal/decline pairs above each share a row). data — and every other field — never contains a signing link, token, proof, or document byte.
Webhook delivery
ESIG_MCP_EVENTS_WEBHOOK_URL=https://your-app.example.com/esig-events
ESIG_MCP_EVENTS_WEBHOOK_SECRET="a random secret, at least 32 characters"
Operator config only — no tool can ever set or change it. Every event is persisted to <ESIG_MCP_DATA_DIR>/events/queue/<eventId>.json before any delivery attempt, then delivered in order, per envelope, at-least-once: a non-2xx response, a timeout (10s), or any 3xx redirect (never followed) counts as a failure and retries with exponential backoff — 1m → 2m → 4m → 8m → 16m → 32m, up to 6 attempts — after which the event is parked dead (audited webhook.dead_lettered).
Every request carries Content-Type: application/json, User-Agent: esig-mcp/<version>, X-Esig-Event-Id, X-Esig-Timestamp (ISO-8601), and X-Esig-Signature: sha256=<hex> — an HMAC-SHA256 over timestamp + "." + body, keyed by the webhook secret:
import crypto from "node:crypto";
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// constant-time compare against the X-Esig-Signature header, reject
// anything older than 5 minutes, and dedupe by X-Esig-Event-Id — at-least-
// once delivery means a retried event can legitimately arrive twice.
SSRF / private-range policy. Both webhook URLs must be https:// — ESIG_MCP_ALLOW_INSECURE_WEBHOOK=1 (the link-delivery channel) and ESIG_MCP_ALLOW_INSECURE_EVENTS_WEBHOOK=1 (the events channel) are deliberately separate flags, so relaxing one never relaxes the other. Before every send, the target host is resolved fresh and refused if any address is loopback, link-local (169.254.0.0/16 — covers the cloud-metadata address — and fe80::/10), RFC1918, unique-local (fc00::/7), unspecified, or an IPv4-mapped IPv6 literal wrapping any of those — unless ESIG_MCP_ALLOW_PRIVATE_WEBHOOK=1. The request then connects to that exact vetted address, never letting the HTTP stack re-resolve the hostname itself (closing a DNS-rebinding TOCTOU); the Host header and TLS SNI stay on the original hostname.
Signer identity (L0–L2)
Bind who signed to a verifiable identity — UUAID identifiers and IAASO TAE assurance levels (ADR-006). Off by default; set it per envelope, per server floor, or both.
| Level | What's proven | What the server checks |
|---|---|---|
none | Nothing (default). | — |
L0 | Nothing — self-asserted uuaid. | Well-formed, and matches the pinned uuaid if one was set at creation. |
L1 | Sole control of a key, self-asserted identity. | An eddsa-jcs-2022 DataIntegrityProof over a server-issued, single-use, 15-minute challenge, verified locally against the key in proof.verificationMethod. |
L1p | Key↔uuaid binding by construction. | Like L1, plus: when the uuaid is uuaid:foundation:agent:<localId>, localId must equal localIdFromEd25519Key(proof key) — no registry needed. |
L2 | Key↔uuaid binding, third-party verified. | L1, plus: the registry's signed badge (GET /iaaso/v1/badge/{uuaid}) verifies against the pinned ESIG_MCP_UUAID_REGISTRY_SIGNING_KEY, and its subject matches both the presented key and the proving uuaid. |
Docs honesty. L0 and L1 bind a uuaid to a signer only by self-assertion — the signer says "this uuaid is mine," and at L1 proves they control a specific key, but nothing checks that claim against anything outside the request. Only L2 actually verifies the key↔uuaid binding, against the UUAID registry.
{
"identity": {
"minLevel": "L1",
"signers": [{ "index": 0, "uuaid": "uuaid:foundation:agent:<uuid>" }]
}
}
minLevel may only raise the server's ESIG_MCP_IDENTITY_MIN_LEVEL floor (default none) for that one envelope, never lower it. Obtain a challenge with the esig_identity_challenge tool or GET /sign/<token>/challenge — both idempotently re-issue the same live challenge until it's consumed or expires.
Refusal codes
| Code | Meaning |
|---|---|
L1_PROOF_REQUIRED | Level ≥ L1 requested but no proof presented. |
L1_PROOF_INVALID | The Ed25519 signature over the challenge doesn't verify. |
L1_NO_CHALLENGE / L1_NONCE_CONSUMED / L1_CHALLENGE_EXPIRED | No challenge was issued, it was already used, or its 15-minute TTL passed. |
L1P_KEY_UUAID_MISMATCH | A foundation:agent-shaped uuaid whose local id doesn't derive from the proof key — refused, never silently accepted as plain L1. |
L2_UUAID_NOT_FOUND | The registry has no badge for this uuaid (tombstoned or absent). |
L2_BADGE_SUBJECT_MISMATCH | A badge signed for a different subject that happens to share the presentation key. |
L2_REGISTRY_URL_CHANGED | The server's currently configured registry differs from the one this envelope pinned at creation. |
L2_REGISTRY_UNAVAILABLE | A down/unreachable/malformed registry response — a hard failure, never a silent drop to L1. |
Pre-verified identity via proof sources. A recipient agent can reply with a sealed identity proof instead of a human ever pasting JSON — esig-mcp polls its own inbox (see Agent-to-agent (Pillar) below), runs the same verification path POST /sign's identityProof uses, and stores the result bound to that signer's challenge. The approval page then shows "Identity verified" and POST /sign accepts the signature with no identityProof at all.
Agent-to-agent over Pillar
Reach a signer that is itself an agent — no inbound HTTP, no email — over Pillar (IAASO-3050), UUAID's agent-to-agent communication substrate: signed, end-to-end encrypted envelopes over a store-and-forward carrier. Delivered by the optional peer package @e-sig/pillar-bridge (published, 0.1.0) — @e-sig/mcp never depends on it, and loads it only with a dynamic import() the moment it's actually needed.
| Wire kind | Sender → recipient | What travels |
|---|---|---|
esig:sign-request | esig-mcp → signer | Signing link + sole-control challenge, sealed end-to-end. Refused (no send) without an expiresAt. |
esig:event | esig-mcp → subscribers | One lifecycle event (see above), sealed the same way as the webhook payload. |
esig:identity-proof | signer's agent → esig-mcp | A DataIntegrityProof (+ optional credential) over an issued challenge — see above. |
esig:sealed | reserved | Not implemented by this bridge. |
ESIG_PILLAR_HOME # keychain dir, default <ESIG_MCP_DATA_DIR>/pillar
ESIG_PILLAR_PASSPHRASE # required with pillar — same ≥24-char floor as ESIG_MCP_PASSPHRASE
ESIG_PILLAR_CARRIERS # required with pillar — comma-separated https:// carrier URLs
ESIG_PILLAR_SUBSCRIBERS # optional JSON [{uuaid, publicKey}] — lifecycle-event subscribers
ESIG_PILLAR_PROOF_POLL # seconds between inbox long-polls for identity proofs, default 1
ESIG_MCP_PILLAR_ALLOW_UNREGISTERED # "1" to allow a signer with no UUAID registry badge
Sender verification, in order. Before an esig:identity-proof payload is ever decrypted or handed to a caller: (1) a pre-decrypt size cap (default 512 KiB, the community carrier-tier floor) is checked against the envelope's own serialized size; (2) the envelope's transport signature is verified against the sender's key before decryption; (3) the now-authenticated sender must pass an injectable allowlist — default deny, omitting it refuses every sender; (4) a per-sender rate cap (default 30/minute) applies. Anything that fails any of these is dropped and counted, never logged in detail.
The hash-pinned shim. Importing Pillar's own entry point pulls its full dependency graph — libp2p, native better-sqlite3. This bridge instead resolves and imports only the five small files it actually needs (envelope sealing/opening, keychain, JCS canonicalization, the carrier HTTP client, tier grants), asserted at startup against a pinned sha256 table for each supported Pillar version — a version drift or an unrecognized import throws unless ESIG_PILLAR_ALLOW_UNPINNED=1 is set (loud, audited escape hatch). Said honestly: npm install @e-sig/pillar-bridge still pulls libp2p and better-sqlite3 as installed dependencies — this shim only keeps them out of the running process, not out of node_modules.
Verifying a counter-signed document (A2A recipe)
import { verifyDocument } from "@e-sig/core";
// 1. Transport: envelope.open() already proved the sealed message came
// from the claimed sender's Pillar key (see "Sender verification" above).
// 2. Document seal: check the PDF itself before counter-signing.
const v = verifyDocument(pdfBytes, {
expectedUuaid: senderUuaid,
requirePq: true,
});
if (!v.ok || !v.postQuantum.uuaidMatches) {
throw new Error("seal identity does not match the claimed sender");
}
// only now countersign / accept
Honest limitation. MCP mode A — the server signing as a dedicated agent identity — is designed and explicitly gated OFF (ESIG_MCP_MODES refuses to start with A); it is not implemented. The working path for "an agent signs as itself" today is @e-sig/core's SDK directly: signPdf({ ..., pqSeal: { keys, uuaid } }) embeds the agent's UUAID in the post-quantum seal, verifiable with verifyDocument above.
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 |
createEnvelope, resolveSigningToken, recordSignature, declineEnvelope, voidEnvelope, composeEnvelopeHtml, EnvelopeError | Multi-signer envelopes |
CertStore, AuditLogStore, PdfStorageStore, EnvelopeStore | Adapter interfaces (types) |
FsCertStore, FsAuditLogStore, FsPdfStorageStore, FsEnvelopeStore | Filesystem adapters (@e-sig/core/fs) |
Signer, SigningCertPem, SignedPdfMetadata, TsaTransport | Shared types |
Full consumer guide: packages/esig-core/CONSUMING.md in the repo.