pkcs11ex

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.

LicenseElixirOTP

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:


Table of contents

Why pkcs11ex?

Real production signing workflows tend to span more than one signature source. A typical fintech / regulated-industry deployment might:

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:

  1. Locate the embedded signature + cert chain.
  2. Append-attack detection (PDF only) — refuse if bytes exist beyond the signed range.
  3. Parse the CMS / XML signature envelope.
  4. Allowlist gatepolicy.resolve/2 then policy.validate/3. No cryptographic check has happened yet.
  5. Recompute the message digest and compare against the embedded value.
  6. 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:

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

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.mdSignCore.{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

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

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:

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

License

Apache 2.0.

Vendored xmerl_c14n retains its original BSD-2-Clause license.