@f-o-t/e-signature
v1.9.0
Published
PAdES PDF signing with ICP-Brasil compliance
Downloads
570
Readme
@f-o-t/e-signature
PAdES PDF signing with ICP-Brasil compliance. Signs PDF documents using CMS/PKCS#7 SignedData format with support for visual signature appearance, QR codes, and RFC 3161 timestamps.
Installation
bun add @f-o-t/e-signatureFeatures
- Visual signature appearances with clickable validation link (
validar.iti.gov.br) - Automatic horizontal centering of signature stamp on the page
- Visual signature appearances preserve all original page fonts and resources
- Works with PDFs from @react-pdf/renderer with CIDFont fonts
- PAdES-BES and ICP-Brasil compliant signatures
- RFC 3161 timestamp support
- QR code generation for signature verification
- Unified signature layout box: QR + certificate text are vertically aligned as one content block
- Configurable DocMDP permissions for document modification control
- Browser compatible — no
Bufferor Node-only APIs; runs in browsers, Edge Runtime, and Cloudflare Workers - Non-blocking crypto — PBKDF2 key derivation and RSA/ECDSA signing use the native Web Crypto API (
SubtleCrypto) when available, avoiding main-thread freezes; pure-JS implementations serve as automatic fallback
React Hook
useSignPdf(): UseSignPdfReturn
React hook for client-side PDF signing. Import from @f-o-t/e-signature/plugins/react (requires react >= 18 as a peer dependency).
import { useSignPdf } from "@f-o-t/e-signature/plugins/react";
function SignForm() {
const { sign, isSigning, isDone, isError, result, error, download, reset } = useSignPdf();
async function handleSubmit(pdfFile: File, p12File: File) {
try {
await sign({
pdf: pdfFile, // File, Blob, or Uint8Array
p12: p12File, // File, Blob, or Uint8Array
password: "secret",
options: { reason: "Approval", location: "São Paulo", policy: "pades-icp-brasil" },
});
} catch {
// error state is also set on the hook
}
}
if (isSigning) return <p>Signing…</p>;
if (isError) return <p>Error: {error?.message}</p>;
if (isDone) return <button onClick={() => download("signed.pdf")}>Download</button>;
return <button onClick={() => handleSubmit(myPdf, myCert)}>Sign</button>;
}Return value:
| Property | Type | Description |
|---|---|---|
| status | "idle" \| "signing" \| "done" \| "error" | Current lifecycle state |
| isIdle | boolean | No operation in progress |
| isSigning | boolean | Signing is running |
| isDone | boolean | Last sign call succeeded |
| isError | boolean | Last sign call failed |
| result | Uint8Array \| null | Signed PDF bytes (non-null when isDone) |
| error | Error \| null | Failure reason (non-null when isError) |
| sign(input) | (SignInput) => Promise<Uint8Array \| undefined> | Trigger signing; concurrent calls while signing are ignored (returns undefined) |
| download(filename?) | (string?) => void | Trigger browser download of signed PDF; no-op if not done |
| reset() | () => void | Return to idle, clearing result and error |
pdf and p12 accept File, Blob, or Uint8Array — the hook converts File/Blob to Uint8Array automatically before calling signPdf.
Web Worker Signing
Runs signing off the main thread to prevent browser freezes.
Step 1. Create a worker file in your project (e.g. workers/sign-pdf.ts):
import { signPdf } from "@f-o-t/e-signature";
self.onmessage = async (e: MessageEvent) => {
const { id, pdf, options } = e.data;
try {
const result = await signPdf(pdf, options);
self.postMessage({ id, ok: true, result }, { transfer: [result.buffer] });
} catch (err) {
self.postMessage({ id, ok: false, error: err instanceof Error ? err.message : String(err) });
}
};Step 2. Use signPdfInWorker from your component:
import { signPdfInWorker } from "@f-o-t/e-signature/plugins/worker";
const worker = new Worker(
new URL("../workers/sign-pdf", import.meta.url),
{ type: "module" },
);
const signed = await signPdfInWorker(worker, pdfBytes, {
certificate: { p12, password: "secret" },
appearance: "auto",
policy: "pades-icp-brasil",
});
// Reuse the worker for multiple signings, or terminate when done
worker.terminate();signPdfInWorker(worker: Worker, pdf: Uint8Array, options: PdfSignOptions): Promise<Uint8Array>
Same as signPdf but takes a Worker as the first argument. PDF bytes are transferred (zero-copy) to the worker thread.
Automatic Signature Positioning
Using appearance: "auto"
Pass appearance: "auto" to let signPdf automatically detect where to place the visual signature based on the signer's certificate information. By default, auto mode stamps every page of the PDF:
const signed = await signPdf(pdfBytes, {
certificate: { p12, password: "secret" },
appearance: "auto", // stamps all pages automatically
});Using detectSigningPosition() directly
For more control, call detectSigningPosition() to inspect the detected position before signing:
import { detectSigningPosition } from "@f-o-t/e-signature";
const position = detectSigningPosition(pdfBytes, {
signerName: "John Doe",
organization: "Acme Corp",
});
if (position) {
console.log(`Page ${position.page}, confidence: ${position.confidence}`);
}The function returns a DetectedPosition with page, x, y, width, height, and confidence fields, or null if no suitable position is found.
API
signPdf(pdf: Uint8Array | ReadableStream<Uint8Array>, options: PdfSignOptions): Promise<Uint8Array>
Sign a PDF document with a digital certificate. Supports PAdES-BES and PAdES with ICP-Brasil compliance (signing-certificate-v2 and signature-policy attributes).
import { signPdf } from "@f-o-t/e-signature";
const pdfBytes = await Bun.file("document.pdf").bytes();
const p12 = await Bun.file("certificate.pfx").bytes();
const signedPdf = await signPdf(pdfBytes, {
certificate: { p12, password: "secret" },
reason: "Document approval",
location: "Corporate Office",
policy: "pades-icp-brasil",
appearance: {
x: 50,
y: 50,
width: 200,
height: 80,
page: 0,
},
});
await Bun.write("signed.pdf", signedPdf);To stamp multiple pages with the same visual appearance:
const signedPdf = await signPdf(pdfBytes, {
certificate: { p12, password: "secret" },
reason: "Document approval",
appearances: [
{ x: 50, y: 730, width: 200, height: 80, page: 0, showCertInfo: true },
{ x: 50, y: 730, width: 200, height: 80, page: 1, showCertInfo: true },
{ x: 50, y: 730, width: 200, height: 80, page: 2, showCertInfo: true },
],
qrCode: { data: "https://validar.iti.gov.br", size: 128 },
});appearance and appearances can be used simultaneously — both stamps are rendered. An empty appearances: [] is a no-op.
TSA Resilience
Control timeout, retry, and fallback behavior for timestamp requests:
const signedPdf = await signPdf(pdfBytes, {
certificate: { p12, password: "secret" },
timestamp: true,
tsaUrl: TIMESTAMP_SERVERS.VALID,
tsaTimeout: 5000, // 5s per attempt (default: 10000)
tsaRetries: 2, // 2 retries after initial attempt, 3 total (default: 0)
tsaFallbackUrls: [ // tried in order after primary fails
TIMESTAMP_SERVERS.SAFEWEB,
TIMESTAMP_SERVERS.CERTISIGN,
],
});If all servers fail, a TimestampError is thrown with a descriptive message identifying which servers were tried and the last error.
Signing from a ReadableStream
const stream: ReadableStream<Uint8Array> = getFileStream("document.pdf");
const signedPdf = await signPdf(stream, {
certificate: { p12, password: "secret" },
});buildSigningCertificateV2(certDer: Uint8Array): Uint8Array
Build the id-aa-signingCertificateV2 attribute value (RFC 5035). Links the signature to the specific certificate used, preventing substitution attacks.
buildSignaturePolicy(): Promise<Uint8Array>
Build the id-aa-ets-sigPolicyId attribute value. Downloads the ICP-Brasil PAdES signature policy and extracts the embedded hash. The policy document is cached after the first download.
clearPolicyCache(): void
Clear the cached signature policy data, forcing a re-download on the next call.
requestTimestamp(data: Uint8Array, tsaUrl: string, hashAlgorithm?: "sha256" | "sha384" | "sha512", options?: { tsaTimeout?: number; tsaRetries?: number; tsaFallbackUrls?: string[] }): Promise<Uint8Array>
Request an RFC 3161 timestamp from a TSA server. Returns the DER-encoded TimeStampToken.
import { requestTimestamp, TIMESTAMP_SERVERS } from "@f-o-t/e-signature";
const token = await requestTimestamp(signatureBytes, TIMESTAMP_SERVERS.VALID);signPdfBatch(files: BatchSignInput[], options: PdfSignOptions): AsyncGenerator<BatchSignEvent>
Sign multiple PDFs sequentially, yielding progress events. Yields control between each signing to prevent blocking the event loop.
import { signPdfBatch, signPdfBatchToArray, TIMESTAMP_SERVERS } from "@f-o-t/e-signature";
for await (const event of signPdfBatch(
[
{ filename: "doc1.pdf", pdf: pdf1Bytes },
{ filename: "doc2.pdf", pdf: pdf2Bytes, options: { reason: "Custom reason" } },
],
{ certificate: { p12, password: "secret" } },
)) {
switch (event.type) {
case "file_start": console.log(`Signing ${event.filename}...`); break;
case "file_complete": console.log(`Signed ${event.filename}`); break;
case "file_error": console.error(`Failed ${event.filename}: ${event.error}`); break;
case "batch_complete": console.log(`Done: ${event.totalFiles} files, ${event.errorCount} errors`); break;
}
}Per-file options are merged with the base options (per-file takes priority). Error in one file emits a file_error event and continues with the next file.
signPdfBatchToArray(files: BatchSignInput[], options: PdfSignOptions): Promise<...[]>
Convenience wrapper that collects all results:
const results = await signPdfBatchToArray(
[
{ filename: "doc1.pdf", pdf: pdf1Bytes },
{ filename: "doc2.pdf", pdf: pdf2Bytes },
],
{ certificate: { p12, password: "secret" } },
);
for (const r of results) {
if (r.signed) await Bun.write(r.filename, r.signed);
else console.error(`${r.filename}: ${r.error}`);
}Constants
ICP_BRASIL_OIDS
OID constants for ICP-Brasil attributes:
signingCertificateV2--"1.2.840.113549.1.9.16.2.47"signaturePolicy--"1.2.840.113549.1.9.16.2.15"
TIMESTAMP_SERVERS
ICP-Brasil approved TSA server URLs:
VALID--"http://timestamp.valid.com.br/tsa"SAFEWEB--"http://tsa.safeweb.com.br/tsa/tsa"CERTISIGN--"http://timestamp.certisign.com.br"
TIMESTAMP_TOKEN_OID
The id-smime-aa-timeStampToken OID: "1.2.840.113549.1.9.16.2.14"
Types
type PdfSignOptions = {
certificate: {
p12: Uint8Array;
password: string;
name?: string;
};
reason?: string;
location?: string;
contactInfo?: string;
policy?: "pades-ades" | "pades-icp-brasil";
timestamp?: boolean;
tsaUrl?: string;
/** Timeout in ms per TSA attempt (default: 10000) */
tsaTimeout?: number;
/** Number of retry attempts after the initial TSA request fails (default: 0); total attempts = 1 + tsaRetries */
tsaRetries?: number;
/** Fallback TSA server URLs tried in order after primary is exhausted */
tsaFallbackUrls?: string[];
/** Called when timestamping fails (non-fatal). Receives the error for logging/metrics. */
onTimestampError?: (error: unknown) => void;
/** Single visual stamp (false to disable, "auto" for auto-detected positioning) */
appearance?: SignatureAppearance | "auto" | false;
/** Multiple visual stamps — renders one per entry, useful for multi-page documents */
appearances?: SignatureAppearance[];
qrCode?: QrCodeConfig;
docMdpPermission?: 1 | 2 | 3;
};
type SignatureAppearance = {
x: number;
y: number;
width: number;
height: number;
page?: number;
showQrCode?: boolean;
showCertInfo?: boolean;
/** Inner content padding in points (default: 5) */
padding?: number;
/** Rendered QR size in points (default: auto, max 50) */
qrSize?: number;
/** Extra QR X offset in points */
qrOffsetX?: number;
/** Extra QR Y offset in points */
qrOffsetY?: number;
/** Vertical content alignment (alias for verticalAlign) */
contentAlign?: "top" | "middle" | "bottom";
/** Vertical content alignment (default: "top") */
verticalAlign?: "top" | "middle" | "bottom";
};
type QrCodeConfig = {
data?: string;
size?: number;
};
type BatchSignInput = {
/** Filename for identification in events */
filename: string;
/** PDF content as Uint8Array or ReadableStream */
pdf: Uint8Array | ReadableStream<Uint8Array>;
/** Per-file option overrides merged with base options */
options?: Partial<PdfSignOptions>;
};
type BatchSignEvent =
| { type: "file_start"; fileIndex: number; filename: string }
| { type: "file_complete"; fileIndex: number; filename: string; signed: Uint8Array }
| { type: "file_error"; fileIndex: number; filename: string; error: string }
| { type: "batch_complete"; totalFiles: number; errorCount: number };Error Classes
PdfSignError-- Errors during PDF signingSignaturePolicyError-- Errors downloading or parsing the ICP-Brasil policyTimestampError-- Errors requesting timestamps from TSA servers
Validation
The input schema is exported for use in your own validation:
import { pdfSignOptionsSchema } from "@f-o-t/e-signature";