PDF / PAdES
Sign PDFs with PAdES via signPdf, apply the ICP-Brasil policy (AD-RB), and verify with verifyPdf.
What you'll do: sign PDF bytes in PAdES with signPdf, choose the "pades-ades" or "pades-icp-brasil" (AD-RB) policy, and verify the result with verifyPdf. The Signatures seam, provided by a1SignaturesLayer, does the signing; the PDF module only mutates the document.
Install the package
The PDF module is @signature-kit/pdf. It consumes the seam provided by the A1 signer.
npm install @signature-kit/pdfsignPdf requires the Signatures service in the requirements channel; verifyPdf does not.
Sign a PDF
signPdf(input: PdfSigningRequest) returns an Effect<Uint8Array, PdfError | CmsError | SignatureKitError, Signatures> — the bytes of the signed PDF. Provide Signatures with Effect.provide(a1SignaturesLayer(...)).
import { signPdf } from "@signature-kit/pdf/sign"
import { a1SignaturesLayer } from "@signature-kit/a1/signer"
import { Effect, Redacted } from "effect"
const program = signPdf({
pdf, // Uint8Array — bytes of the original PDF
reason: "Contract approval",
name: "Maria Souza",
location: "New York, US",
signatureLength: 16384, // bytes reserved for the CMS /Contents
}).pipe(
Effect.provide(
a1SignaturesLayer({
pfx, // Uint8Array — PKCS#12 (.pfx/.p12)
password: Redacted.make(process.env.A1_PASSWORD ?? ""),
}),
),
)
const signedPdf = await Effect.runPromise(program) // signed PDF, pades-ades policysignatureLength reserves the bytes of the /Contents field that holds the CMS — too small a value fails the signature. reason and name default to "Digital signature" and "SignatureKit signer". Other optional fields: contactInfo, location, signingTime, timestamp, and appearance.
Two A1 certificates, two signed PDFs
Signatures is an Effect requirement, so certificate choice stays at the call site. Provide one a1SignaturesLayer per signer when two people sign independent PDF outputs from the same source bytes.
import { signPdf } from "@signature-kit/pdf/sign"
import { a1SignaturesLayer } from "@signature-kit/a1/signer"
import { Effect, Redacted } from "effect"
const mariaProgram = signPdf({
pdf: unsignedPdf,
reason: "Maria approved the contract",
name: "Maria Souza",
signatureLength: 16384,
}).pipe(
Effect.provide(
a1SignaturesLayer({
pfx: mariaPfx,
password: Redacted.make(mariaPassword),
}),
),
)
const joaoProgram = signPdf({
pdf: unsignedPdf,
reason: "João approved the contract",
name: "João Silva",
signatureLength: 16384,
}).pipe(
Effect.provide(
a1SignaturesLayer({
pfx: joaoPfx,
password: Redacted.make(joaoPassword),
}),
),
)
const [mariaSignedPdf, joaoSignedPdf] = await Effect.runPromise(
Effect.all([mariaProgram, joaoProgram]),
)This signs two PDF copies under two different PKCS#12 certificates without sharing secrets or process-global signer state. Today signPdf writes one PDF signature per output file; don't model a same-file multi-party workflow by silently re-signing already signed bytes unless your verifier covers that exact flow.
hashAlgorithm accepts "sha256" (→ rsa-sha256, default), "sha512" (→ rsa-sha512), and the legacy "sha1" (→ rsa-sha1). Only "sha384"
fails, with pdf.SIGN_FAILED.
Position a visual signature
appearance.placement.kind: "auto" computes a visible rectangle on the chosen page from the preferred anchor, avoiding collision with existing widgets/annotations — no manual coordinates when you just want a signature in the footer/corner. Without appearance, the signature stays invisible for compatibility.
const signed = await Effect.runPromise(
signPdf({
pdf,
appearance: {
placement: {
kind: "auto", // visible placement, computed by the PDF
page: "last", // auto default; use pageIndex for an exact page
anchor: "bottom-right",
width: 180,
height: 54,
margin: 36,
gap: 8, // clearance against existing widgets/annotations
},
},
}).pipe(Effect.provide(layer)),
)
// Manual: PDF coordinates [left, bottom, right, top]
const manual = { appearance: { placement: { kind: "manual", pageIndex: 0, widgetRect: [72, 72, 216, 108] } } }
// Invisible: /Widget field without a visual area
const invisible = { appearance: { placement: { kind: "invisible" } } }Use kind: "manual" when your application already has reliable coordinates, and kind: "invisible" when the PDF should carry only the cryptographic signature. If auto can't find space within the margins, the effect fails with pdf.SIGNATURE_PLACEMENT_FAILED in the typed channel.
ICP-Brasil PAdES policy
PdfSignaturePolicy has exactly two values: "pades-ades" (default) and "pades-icp-brasil". When you choose "pades-icp-brasil" without icpBrasil, the PDF package uses the pinned PA_PAdES_AD_RB_v1_1 metadata from @signature-kit/cms; no app-side policy assembly or runtime policy fetch is needed. ICP-Brasil signatures reserve 32768 /Contents bytes by default unless you set signatureLength.
// PdfSignaturePolicy = "pades-ades" | "pades-icp-brasil"
// Signature default when policy is omitted: "pades-ades"
const signed = signPdf({
pdf,
policy: "pades-icp-brasil",
}).pipe(Effect.provide(layer))
// Explicit policy input still wins when you need a different AD-RB/AD-RT policy.
const explicit = signPdf({
pdf,
policy: "pades-icp-brasil",
hashAlgorithm: "sha256",
signatureLength: 49152,
icpBrasil: customPolicy,
}).pipe(Effect.provide(layer))The bundled AD-RB object carries policyOid, policyHash, policyHashAlgorithm, and policyUri from PA_PAdES_AD_RB_v1_1.
Keep policyHashAlgorithm consistent with the signature's hashAlgorithm when you provide a custom icpBrasil object — use "sha256" or "sha512" in both.
Merge PDFs
mergePdfs([first, second]) copies pages into a new PDF. It does not preserve source AcroForm fields, document metadata, or catalog-level form state; use it for flattened/page-content workflows, not for preserving interactive forms.
Verify with verifyPdf
verifyPdf(input: PdfVerificationRequest) returns an Effect<PdfVerificationResult, PdfError | CmsError> — without a Signatures requirement, so there is no Effect.provide(...) here. Optionally pass trustedRoots (a list of Uint8Array) to validate the chain against your own roots.
import { verifyPdf } from "@signature-kit/pdf/verify"
import { Effect } from "effect"
// verifyPdf does NOT require the Signatures service — there is no .pipe(Effect.provide(...))
const result = await Effect.runPromise(verifyPdf({ pdf: signedPdf }))
result.valid // boolean — CMS integrity over the byteRange
result.chainValid // boolean — signer chain verified
result.revocationStatus // "checked" | "not_checked" — revocation evidence was evaluated
result.signatureCount // number — signatures found in the PDF
result.byteRange // [number, number, number, number]
result.signerSerialNumber // string | null — serial of the signer certificatebyteRange is the quadruple covered by the signature. An unsigned PDF (no /ByteRange) is not a successful signerSerialNumber: null result — verifyPdf fails on the error channel with pdf.PLACEHOLDER_NOT_FOUND.
Errors you may see
PDF signing and verification fail with codes from the pdf.* family (PdfError); failures from the A1 signer that provides Signatures arrive as signature-kit.*.
pdf.SIGN_FAILED— unsupportedhashAlgorithm(onlysha384is rejected).pdf.SIGNATURE_TOO_LARGE— the CMS exceeded the reserved space; increasesignatureLength.pdf.SIGNATURE_PLACEMENT_FAILED— automatic placement found no space or received invalid dimensions.pdf.INVALID_PDF— the bytes don't form a valid PDF to sign or verify.pdf.PLACEHOLDER_NOT_FOUND—verifyPdffound no/ByteRange, e.g. an unsigned PDF.signature-kit.WRONG_PASSWORD— wrong A1 password when providing the signatures service.
Continue from here
How is this guide?