The signing boundary
The SignerAdapter is the capability seam that decides where signing power comes from, swappable without touching document formats.
The core idea: the SignerAdapter is the seam where local signing power enters. It answers where bytes are signed — an A1 .pfx or HSM — while document formats (core, XML, PDF) talk to one fixed shape. Provider APIs own upstream remote ceremonies separately.
The SignerAdapter contract
The type below is the literal definition from @signature-kit/signatures. Its doc-comment pins the rule this page details: the signer owns where signing power comes from, and never owns format mutation.
/**
* The capability seam. A signer owns "where the signing power comes from".
* It never owns document-format mutation (XML/PDF live in format modules).
*/
export type SignerAdapter = {
readonly id: string;
inspect(): Effect.Effect<SignerIdentity, SignatureKitError>;
certificate(): Effect.Effect<Certificate, SignatureKitError>;
importSigningKey(algorithm: SignatureAlgorithm): Effect.Effect<CryptoKey, SignatureKitError>;
sign(input: SignInput): Effect.Effect<SignatureArtifact, SignatureKitError>;
verify(input: VerifyInput): Effect.Effect<VerificationResult, SignatureKitError>;
};The six members of the seam
Every SignerAdapter implementation delivers exactly these six members:
id: string— identity of the signing backend. A1 uses"a1". Provider APIs such as Clicksign, Assinafy, ZapSign, DocuSeal, and Documenso are declared through the Alchemy Resource constructor pattern (for exampleClicksignSignatureRequest("id", props)) and do not go through this XML/PDF mutation seam.inspect()— returnsEffect<SignerIdentity, SignatureKitError>withsubject,issuer,serialNumber,thumbprint,validFrom,validToanddocument?(CPF/CNPJ when ICP-Brasil).certificate()— returnsEffect<Certificate, SignatureKitError>, the full certificate (certPem,certificateDer,publicKeyDerand theprivateKeyPem: Redacted<string>).importSigningKey(algorithm)— materializes the signingCryptoKeyfor the requestedSignatureAlgorithm("rsa-sha256"or"rsa-sha512"; the catalog also accepts the legacy, discouraged"rsa-sha1").sign(input)— takesSignInput({ content, algorithm }) and returns aSignatureArtifact({ algorithm, signature }).verify(input)— takesVerifyInput({ content, signature, algorithm }) and returnsVerificationResult({ valid: boolean, algorithm: SignatureAlgorithm }).
Every member fails through the typed SignatureKitError error channel — never through a loose exception. The retryable
flag is decided per call, not fixed by code.
Building an A1
The @signature-kit/a1 package implements the seam from a .pfx/.p12 file. loadA1SignerAdapter(options) parses it asynchronously and returns a ready SignerAdapter with id "a1".
npm install @signature-kit/signatures @signature-kit/a1import { loadA1SignerAdapter } from "@signature-kit/a1/signer"
import { Effect, Redacted } from "effect"
// loadA1SignerAdapter -> Effect<SignerAdapter, SignatureKitError>
const signer = yield* loadA1SignerAdapter({
pfx, // Uint8Array — PKCS#12 bytes (start at 0x30)
password: Redacted.make(process.env.A1_PASSWORD ?? ""),
})
signer.id // "a1"
const identity = yield* signer.inspect() // SignerIdentity (e-CPF / e-CNPJ)
const artifact = yield* signer.sign({ content, algorithm: "rsa-sha256" })A1SignerOptions has exactly two keys — pfx: Uint8Array and password: Redacted.Redacted<string>. The bytes
must be raw PKCS#12 (start at 0x30); an empty buffer is rejected.
Providing it as a service
The signatures package defines Signatures as a Context.Service with tag "@signature-kit/signatures/Signatures". You never implement it by hand: pass a SignerAdapter to signaturesLayer(signer) and get a Layer<Signatures> back (via Layer.succeed). The accessors signatures.{inspect, certificate, importSigningKey, sign, verify} declare the dependency on Signatures; the layer satisfies it.
import { signaturesLayer, signatures } from "@signature-kit/signatures"
import { loadA1SignerAdapter } from "@signature-kit/a1/signer"
import { Effect, Redacted } from "effect"
const program = Effect.gen(function* () {
// Signatures accessors — they require the service, they don't know the backend.
const identity = yield* signatures.inspect()
const artifact = yield* signatures.sign({ content, algorithm: "rsa-sha256" })
const result = yield* signatures.verify({
content,
signature: artifact.signature,
algorithm: artifact.algorithm,
})
return result.valid
})
const run = Effect.gen(function* () {
const signer = yield* loadA1SignerAdapter({ pfx, password: Redacted.make(pwd) })
// signaturesLayer(signer) -> Layer<Signatures> (Layer.succeed)
return yield* program.pipe(Effect.provide(signaturesLayer(signer)))
})
await Effect.runPromise(run)Automatic provider for formats
For formats, A1 offers a shortcut: a1SignaturesLayer(options) returns a Layer<Signatures, SignatureKitError> directly, with no adapter to instantiate first. signXml and signPdf require Signatures in their requirements channel; hand over that layer via Effect.provide.
npm install @signature-kit/xml @signature-kit/pdfimport { signXml } from "@signature-kit/xml/sign"
import { xmlRuntimeLayer } from "@signature-kit/xml/runtime"
import { signPdf } from "@signature-kit/pdf/sign"
import { a1SignaturesLayer } from "@signature-kit/a1/signer"
import { Effect, Redacted } from "effect"
// a1SignaturesLayer(options) -> Layer<Signatures, SignatureKitError>
const layer = a1SignaturesLayer({ pfx, password: Redacted.make(pwd) })
export const program = Effect.gen(function* () {
// The formats require Signatures; the layer is the only thing that changes per backend.
const signedXml = yield* signXml({ xml, referenceId: "nfe-1" })
.pipe(Effect.provide(layer), Effect.provide(xmlRuntimeLayer))
const signedPdf = yield* signPdf({ pdf, policy: "pades-icp-brasil" })
.pipe(Effect.provide(layer))
return { signedXml, signedPdf }
})
await Effect.runPromise(program)verifyXml and verifyPdf do not require Signatures — verification doesn't need the seam, because the public key comes
from the document itself.
Swapping the backend
Because the seam is a fixed-shape port, swapping where signing power comes from means swapping the argument of signaturesLayer(...). The signing work is identical; nothing about the format changes.
import { signPdf } from "@signature-kit/pdf/sign"
import { signaturesLayer } from "@signature-kit/signatures"
import type { SignerAdapter } from "@signature-kit/signatures"
import { Effect } from "effect"
// The seam is just a port: any SignerAdapter works, as long as it has an id.
declare const a1Signer: SignerAdapter // from loadA1SignerAdapter (id "a1")
declare const hsmSigner: SignerAdapter // another backend that satisfies the contract
const job = signPdf({ pdf, policy: "pades-ades" })
const program = Effect.gen(function* () {
// Same job, two backends — nothing about the PDF changes.
const withA1 = yield* job.pipe(Effect.provide(signaturesLayer(a1Signer)))
const withHsm = yield* job.pipe(Effect.provide(signaturesLayer(hsmSigner)))
return { withA1, withHsm }
})
await Effect.runPromise(program)Any value that satisfies SignerAdapter works. Here the A1 comes from loadA1SignerAdapter; another backend just needs to
deliver the same six members and an id.
Where the seam ends
The doc-comment's rule is deliberate: the SignerAdapter owns where signing power comes from, and never document mutation. It operates over content: Uint8Array and returns a signature: Uint8Array — opaque bytes. Embedding a signature into an enveloped XML or a PAdES PDF is the job of the format modules (@signature-kit/xml, @signature-kit/pdf), which consume the seam through Effect.provide and stay backend-neutral because of it.
That cut keeps the application stable: the XML doesn't know whether the key came from a .pfx or an HSM, and the signer doesn't know whether its bytes are an NF-e or a PDF. Each side knows only its half of the boundary.
Errors you might see
Every seam failure is a typed SignatureKitError. The most common ones when building and using the signer:
signature-kit.WRONG_PASSWORD
signature-kit.NO_PRIVATE_KEY
signature-kit.UNSUPPORTED_ALGORITHM
SignatureAlgorithm outside the sign catalog, which instead fails as signature-kit.INVALID_INPUT.signature-kit.KEY_IMPORT_FAILED
signature-kit.SIGN_FAILED
How is this guide?