SignatureKit
Signers

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/pdf

signPdf 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(...)).

sign-pdf.ts
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 policy

signatureLength 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.

two-a1-signers.ts
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.

position-pdf-signature.ts
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.

icp-brasil-policy.ts
// 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.

verify-pdf.ts
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 certificate

byteRange 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.*.

Continue from here

How is this guide?

On this page