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.

License Elixir OTP

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 pdfsig and libxmlsec1 xmlsec1 standards conformance
  • ✅ DigiCert RFC 3161 TSA (B-T timestamps)

Table of contents

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

PackagePurposeHex deps to use
sign_coreSigner-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.
pkcs11exPKCS#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_signerSoftware-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_auditOptional 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:

  • pkcs11ex only in your dep tree → can never software-sign. Pkcs11ex.Signer only knows how to call the NIF; the absence of soft_signer enforces the "no software signing" invariant at the package boundary.
  • soft_signer only → no NIF compilation step, no PKCS#11 stack, no slot supervisor.
  • sign_core only → 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

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

  • Elixir 1.19+ / Erlang/OTP 28+. Older versions may work but aren't tested.
  • Rust 1.85+ with edition 2021 — required to build the pkcs11ex NIF (over the cryptoki crate).
  • 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

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 pkcs11ex and soft_signer as new sister libraries depending on sign_core.
  • More algorithms. ECDSA (:ES256 / :ES384) and Ed25519 (:EdDSA) algorithm adapters in SignCore.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 :conformance suite. 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 cryptoki crate maintainers for the high-level PKCS#11 binding our NIF wraps.
  • Chris Doggett and the esaml authors for the original xmerl_c14n we vendored and patched for OTP 28.
  • The X509 hex 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

Apache 2.0.

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