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 nine published packages plus a Next.js starter example:

PackageRole
@e-sig/coreThe engine — render, cert issuance, PKCS#7/PAdES signing (+ RFC-3161 TSA), verification, and the storage-adapter interfaces.
@e-sig/supabaseReference adapters: cert store, audit-log store, PDF storage over Supabase (Postgres + Storage).
@e-sig/reactDraw-to-sign UI: signature pad, self-sign flow, receipt.
@e-sig/uuaidOpt-in adapter: stamps the acting AI agent's UUAID into the audit log and anchors the audit hash-chain.
@e-sig/uaid-exchPreview of the IAASO Exchange Profile (ADR-006): wraps core envelopes as per-transaction exchange records.
@e-sig/wormWORM archival adapter: signed PDFs + audit-chain exports to S3 Object-Lock.
@e-sig/hsm-pkcs11PKCS#11 adapter: keep the signing key in AWS CloudHSM, YubiHSM 2, SoftHSM2, or any Cryptoki provider.
@e-sig/mcpMCP server for agent-driven signing — envelopes, delivery, reminders, lifecycle webhooks, signer identity, Pillar delivery. See Agent signing.
@e-sig/pillar-bridgeOptional 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.

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.

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

ToolKindWhat it does
esig_create_envelopeprepareCreate an envelope from exactly one of html or a PDF docId + a signer list; dispatches signing links through the configured delivery channel.
esig_envelope_statusreadOne envelope's status, phase, per-signer state (incl. verified identity), seal state, and sealed PDF path once sealed.
esig_identity_challengeprepareIssue (or re-issue) the sole-control challenge a signer's wallet/agent signs to satisfy an identity requirement.
esig_ingest_documentprepareStore PDF bytes in a content-addressed workdir; returns a docId for a Chrome-free PDF envelope, or for esig_verify_document.
esig_list_envelopesreadList envelopes for this server's tenant, optionally filtered by status.
esig_list_eventsreadAn envelope's lifecycle events, oldest first (since filters to events after a timestamp).
esig_resealprepareRetry producing the sealed PDF for a completed envelope whose seal step failed or never ran.
esig_send_reminderprepareResend a signing reminder to one pending signer, or every pending signer.
esig_verify_documentreadVerify 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_envelopeprepareCancel a pending or partially-signed envelope.
esig_whoamireadThis 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:

ValueWhere the link goes
fileOne JSON receipt per envelope, <ESIG_MCP_DATA_DIR>/outbox/<envelopeId>.json (mode 0600) — the quickstart channel.
consolePrinted to stderr — opt-in only, loud startup warning (stderr is the agent harness's own log in a stdio deployment).
webhookPOSTed to ESIG_MCP_DELIVERY_WEBHOOK_URL.
emailSMTP or SES — below.
pillarSealed 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.

EventFires when
envelope.createdesig_create_envelope succeeds.
envelope.viewedGET /sign/<token> first resolves ok for a given signer (once per signer).
envelope.signedA signer's signature is recorded.
envelope.declinedA signer declines — voids the whole envelope, attributed to that signer.
envelope.completedEvery signer has signed (independent of whether sealing then succeeds).
envelope.sealed / envelope.seal_failedThe seal step (automatic, or esig_reseal) succeeds or fails.
envelope.voidedesig_void_envelope.
envelope.expiredA lazy 60-second tick catches any envelope nobody happened to poll.
envelope.reminder_sentA reminder (automatic or esig_send_reminder) is sent.
signer.identity_verified / signer.identity_rejectedA 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.

LevelWhat's provenWhat the server checks
noneNothing (default).
L0Nothing — self-asserted uuaid.Well-formed, and matches the pinned uuaid if one was set at creation.
L1Sole 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.
L1pKey↔uuaid binding by construction.Like L1, plus: when the uuaid is uuaid:foundation:agent:<localId>, localId must equal localIdFromEd25519Key(proof key) — no registry needed.
L2Key↔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

CodeMeaning
L1_PROOF_REQUIREDLevel ≥ L1 requested but no proof presented.
L1_PROOF_INVALIDThe Ed25519 signature over the challenge doesn't verify.
L1_NO_CHALLENGE / L1_NONCE_CONSUMED / L1_CHALLENGE_EXPIREDNo challenge was issued, it was already used, or its 15-minute TTL passed.
L1P_KEY_UUAID_MISMATCHA foundation:agent-shaped uuaid whose local id doesn't derive from the proof key — refused, never silently accepted as plain L1.
L2_UUAID_NOT_FOUNDThe registry has no badge for this uuaid (tombstoned or absent).
L2_BADGE_SUBJECT_MISMATCHA badge signed for a different subject that happens to share the presentation key.
L2_REGISTRY_URL_CHANGEDThe server's currently configured registry differs from the one this envelope pinned at creation.
L2_REGISTRY_UNAVAILABLEA 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 kindSender → recipientWhat travels
esig:sign-requestesig-mcp → signerSigning link + sole-control challenge, sealed end-to-end. Refused (no send) without an expiresAt.
esig:eventesig-mcp → subscribersOne lifecycle event (see above), sealed the same way as the webhook payload.
esig:identity-proofsigner's agent → esig-mcpA DataIntegrityProof (+ optional credential) over an issued challenge — see above.
esig:sealedreservedNot 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; 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.