Production-grade digital signatures for Elixir — PDF (PAdES B-B / B-T), XML (XAdES B-B / B-T), and JWS (RFC 7797) — backed by HSMs, smart-card tokens, or software keys.
This repository hosts a family of Hex packages that compose into a single signing toolkit. Pick the ones that match your deployment and ignore the rest.
Validated end-to-end against:
- ✅ SafeNet eToken (USB hardware token)
- ✅ SoftHSM2 (CI / dev fixtures)
- ✅ GCP Cloud HSM via libkmsp11
- ✅ PKCS#12 software bundles + PKCS#8 PEM private keys
- ✅ Poppler
pdfsigand libxmlsec1xmlsec1standards conformance - ✅ DigiCert RFC 3161 TSA (B-T timestamps)
Table of contents
- Why pkcs11ex?
- Packages
- Quick start
- Trust model — read this before you ship
- Architecture
- Documentation
- Versioning & stability
- Compatibility
- Examples
- Contributing
- Acknowledgements
- License
Why pkcs11ex?
Real production signing workflows tend to span more than one signature source. A typical fintech / regulated-industry deployment might:
- Sign legal-compliance PDFs with an HSM-resident corporate key.
- Sign invoices for a tax authority (e.g., Chilean SII, Spanish FNMT) with a vendor-issued PKCS#12 bundle.
- Verify inbound JWS payloads from partners, with a strict allowlist of who may sign.
- Cryptographically anchor an audit trail with RFC 3161 timestamps from a public TSA.
pkcs11ex ships all four paths through one cohesive toolkit. The signature-source abstraction (SignCore.Signer) means the same SignCore.PDF.sign(pdf, signer: ...) call works whether the signer is a hardware token, a P12 file, a PEM key, or a future cloud KMS provider you write yourself.
It's designed for engineers who need to ship signed artifacts that external standards-compliant verifiers will accept — not just our own pipelines. Every release is gated on a conformance suite that runs the output through Poppler pdfsig and libxmlsec xmlsec1.
Packages
| Package | Purpose | Hex deps to use |
|---|---|---|
sign_core | Signer-agnostic format primitives — PDF Reader/Writer, CMS, XML/XAdES, X509, Policy, Algorithm, the SignCore.Signer protocol. The format adapters (PDF/XML/JWS) live here. | Always (transitively pulled by the providers below). Stand-alone for verify-only deployments. |
pkcs11ex | PKCS#11 hardware provider — slot supervisor, session pool, PIN handling, NIF over cryptoki. Ships Pkcs11ex.Signer plus convenience wrappers around SignCore.{PDF,XML,JWS}. | Hardware tokens (SafeNet eToken, Luna), cloud HSMs (GCP Cloud HSM, libkmsp11), SoftHSM2. |
soft_signer | Software-key provider — SoftSigner.PKCS12 for .p12/.pfx bundles, SoftSigner.PKCS8 for PEM private keys (encrypted or not) plus separate cert. | Filesystem-resident keys: vendor-issued PKCS#12, classic key.pem + cert.pem deployments, dev/test fixtures. |
pkcs11ex_audit | Optional audit-trail sister library — append-only hash-chained entries with RFC 3161 timestamp anchoring. | Compliance-driven workflows that need provable signature provenance over time. |
The packages are released independently to Hex but live in one git tree (Phoenix-style monorepo). Cross-cutting changes ship as a single PR; consumers only depend on what they need.
Quick start
Sign a PDF with a hardware token
# mix.exs
def deps, do: [
{:pkcs11ex, "~> 1.0"} # transitively pulls sign_core
]{:ok, signed_pdf} =
Pkcs11ex.PDF.sign(pdf_bytes,
signer: {:legal_proxy, :signing}, # slot supervisor reference
alg: :PS256,
x5c: leaf_cert_der,
pin: "..." # or use a :pin_callback
)
{:ok, _subject_id} = Pkcs11ex.PDF.verify(signed_pdf)Runnable demo against a real SafeNet eToken →
Sign a PDF with a PKCS#12 bundle
# mix.exs
def deps, do: [
{:soft_signer, "~> 1.0"} # transitively pulls sign_core
]{:ok, signer} = SoftSigner.PKCS12.load("invoice-signer.p12", password: "...")
{:ok, signed_pdf} =
SignCore.PDF.sign(pdf_bytes,
signer: signer,
alg: :PS256,
x5c: SoftSigner.PKCS12.cert_chain(signer) # chain comes for free with P12
)Sign a PDF with a PKCS#8 PEM (key + separate cert)
{:ok, signer} =
SoftSigner.PKCS8.load(
key_path: "/keys/legal-proxy.pem",
cert_path: "/keys/legal-proxy.crt",
password: "..." # only if the PEM is encrypted
)
{:ok, signed_pdf} =
SignCore.PDF.sign(pdf,
signer: signer,
alg: :PS256,
x5c: SoftSigner.PKCS8.cert_chain(signer)
)Sign XML (XAdES B-B)
{:ok, signer} = SoftSigner.PKCS12.load("signer.p12", password: "...")
{:ok, signed_xml} =
SignCore.XML.sign(xml_doc,
signer: signer,
alg: :PS256,
x5c: SoftSigner.PKCS12.cert_chain(signer)
)Add an RFC 3161 timestamp (B-T)
{:ok, signed_pdf} =
Pkcs11ex.PDF.sign(pdf,
signer: {:legal_proxy, :signing},
alg: :PS256,
x5c: leaf_cert_der,
tsa_url: "http://timestamp.digicert.com",
tsa_timeout: 15_000,
placeholder_size: 16_384 # B-T pushes signature size over the default
)Same pattern works for SignCore.XML.sign/2. The timestamp is fetched from the TSA, anchored to the signature, and embedded in unsignedAttrs (PAdES) or <xades:UnsignedSignatureProperties> (XAdES).
Verify-only deployment (no signer code shipped at all)
# mix.exs
def deps, do: [
{:sign_core, "~> 1.0"} # no NIF, no openssl, no providers
]{:ok, _subject_id} = SignCore.PDF.verify(signed_pdf)
{:ok, _subject_id} = SignCore.XML.verify(signed_xml)
{:ok, _subject_id} = SignCore.JWS.verify(jws, payload)Trust model
pkcs11ex treats sender-supplied certificates (the x5c header in JWS, SignerIdentifier in CMS, KeyInfo in XAdES) as untrusted input.
A signature is accepted only after the configured Pkcs11ex.Policy resolves the sender against an allowlist (typically the SHA-256 of the leaf certificate's SubjectPublicKeyInfo). There is no path through this library that trusts a sender solely because their certificate chains to a CA.
Concretely, every verify operation runs:
- Locate the embedded signature + cert chain.
- Append-attack detection (PDF only) — refuse if bytes exist beyond the signed range.
- Parse the CMS / XML signature envelope.
- Allowlist gate —
policy.resolve/2thenpolicy.validate/3. No cryptographic check has happened yet. - Recompute the message digest and compare against the embedded value.
- Verify the signature math via
:public_key.verify/4.
Steps 1–4 short-circuit before any expensive math. An attacker can't push verify into a CPU oracle by submitting crafted inputs.
See docs/specs/specs.md §7.1 for the canonical algorithm and docs/specs/api.md §2.3 for the policy contract.
Architecture
Signer abstraction
The SignCore.Signer protocol is the seam between format adapters (PDF/XML/JWS) and signature sources (HSM/PKCS#12/PKCS#8/cloud KMS). Every provider ships a struct that implements the protocol:
%Pkcs11ex.Signer{slot_ref: :foo, key_ref: :bar} # PKCS#11 hardware
%SoftSigner.PKCS12{rsa_key: ..., leaf_der: ..., ...} # PKCS#12 software
%SoftSigner.PKCS8{rsa_key: ..., leaf_der: ..., ...} # PKCS#8 PEM software
# All three drop into the same call:
SignCore.PDF.sign(pdf, signer: any_of_the_above, alg: :PS256, ...)Adding a new provider is a struct + a defimpl SignCore.Signer block — no changes to the format adapters. See sign_core/README.md for a worked example.
Layer-bounded auditability
Each package ships a deliberate slice of capability:
pkcs11exonly in your dep tree → can never software-sign.Pkcs11ex.Signeronly knows how to call the NIF; the absence ofsoft_signerenforces the "no software signing" invariant at the package boundary.soft_signeronly → no NIF compilation step, no PKCS#11 stack, no slot supervisor.sign_coreonly → verify-only by package boundary.
This is the audit-confidence story: which capabilities exist in a build is determined by mix.lock, not runtime configuration.
Layered design
┌───────────────────────────────────────────────────────────────────┐
│ sign_core │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Layer 3 — Format adapters │ │
│ │ SignCore.{PDF,XML,JWS}.{sign,verify} │ │
│ │ Take a `:signer` opt — provider-agnostic. │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CMS / XAdES / x5c machinery │ │
│ │ Reader, Writer, Builder, Canonicalizer, X509, Policy │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ SignCore.Signer protocol │ │
│ └─────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
↑ ↑ ↑
│ │ │
┌───────┴────────────┐ ┌──────────────┴──────────┐ ┌──────┴───────┐
│ pkcs11ex │ │ soft_signer │ │ (your KMS, │
│ Layer 2: sign_b… │ │ PKCS12 / PKCS8 loaders │ │ PC/SC, …) │
│ Layer 1: NIF / │ │ :public_key.sign/3 │ │ │
│ Slot.Server … │ │ via openssl decrypt │ │ │
└────────────────────┘ └─────────────────────────┘ └──────────────┘Documentation
docs/specs/specs.md— architecture, threat model, layered design.docs/specs/api.md— public API: configuration, behaviours, surface functions, error taxonomy, telemetry, Mix tasks.sign_core/README.md— verify-only deployments, signer protocol, custom signer walkthrough.soft_signer/README.md— PKCS#12 vs PKCS#8 ergonomics, error taxonomy.pkcs11ex_audit/README.md— append-only audit log + RFC 3161 anchoring.examples/— runnable demos.
Versioning & stability
pkcs11ex is pre-1.0 and currently path-deps inside the monorepo. Hex publishing for sign_core and soft_signer is on the roadmap. The public API surfaces documented in docs/specs/api.md — SignCore.{PDF,XML,JWS}.{sign,verify}, SignCore.Signer, Pkcs11ex.{PDF,XML,JWS} wrappers, SoftSigner.{PKCS12,PKCS8}.load/2 — are stable in shape and the test suite holds the contract steady. Internal modules (the Reader/Writer mechanics, CMS encoding, exc-c14n shim) may evolve more freely until the 1.0 cut.
When 1.0 ships, semantic versioning applies to the public API as documented in api.md.
Compatibility
- Elixir 1.19+ / Erlang/OTP 28+. Older versions may work but aren't tested.
- Rust 1.85+ with edition 2021 — required to build the
pkcs11exNIF (over thecryptokicrate). - macOS / Linux. Windows isn't tested but the cryptoki dependency supports it.
pkcs11ex ships its own NIF (Rust + Rustler) — separate from p11ex's C NIF. They're sibling libraries at different abstraction levels: p11ex is "I want to call C_FindObjects directly", pkcs11ex is "I want to sign a PDF and have the plumbing handled." Coexistence in one BEAM is supported.
The XML adapter (sign_core/lib/sign_core/xml/c14n/) vendors a patched copy of xmerl_c14n (BSD-2-Clause) — the upstream Hex package crashes on OTP 28's xmlAttribute record shapes for unprefixed attributes without a default namespace. The patch is a single do_canonical_name/3 clause documented inline.
Examples
examples/safenet-etoken/— sign a PDF with a real SafeNet eToken; cross-verify withpdfsig.examples/gcp-cloud-hsm/—runtime.exs+kmsp11.yamlfor GCP Cloud HSM via libkmsp11.
Testing
mix deps.get
mix compile # builds the Rust crate via Rustler
mix test # 307+ tests, no SoftHSM/eToken/conformance dependencies
Optional test layers, all opt-in:
# SoftHSM2 + softhsm2-util on PATH
mix test --include softhsm
# Real SafeNet eToken plugged in (driver auto-detected on macOS)
PKCS11EX_SAFENET_PIN=... PKCS11EX_SAFENET_KEY_LABEL=... \
mix test --include safenet
# Standards-compliant external verifier conformance (pdfsig, xmlsec1)
brew install poppler libxmlsec1
mix test --include conformance
# All of the above + RFC 3161 TSA round-trip against DigiCert
mix test --include conformance --include safenet
The maximum-coverage run executes 329 tests in ~20s.
Contributing
Contributions are welcome. Some areas where help is especially useful:
- More signers. Cloud KMS providers (AWS KMS, Azure Key Vault), PC/SC smart-card readers, hardware wallets — drop in beside
pkcs11exandsoft_signeras new sister libraries depending onsign_core. - More algorithms. ECDSA (
:ES256/:ES384) and Ed25519 (:EdDSA) algorithm adapters inSignCore.Algorithm. The behaviour is small (~40 LOC for PS256); the format adapters are already algorithm-agnostic. - Conformance corpus. The W3C exc-c14n test suite and the ETSI XAdES test corpus aren't yet wired into our
:conformancesuite. PRs welcome. - Docs. Especially worked examples for less-common deployments (kubernetes secret-mounted PEMs, AWS Secrets Manager, etc.).
For non-trivial features, please open an issue first to discuss approach. Architectural changes need to fit the trust-model invariants documented in docs/specs/specs.md §7.
Acknowledgements
- The Rust
cryptokicrate maintainers for the high-level PKCS#11 binding our NIF wraps. - Chris Doggett and the esaml authors for the original
xmerl_c14nwe vendored and patched for OTP 28. - The
X509hex package authors for the certificate-building primitives that made the test suite possible. - The Poppler and libxmlsec1 teams for the standards-compliant external verifiers we use as conformance gates.
License
Vendored xmerl_c14n retains its original BSD-2-Clause license.