# Public API Specification: pkcs11ex
This document specifies the public Elixir API of `pkcs11ex`. The architectural rationale lives in [`specs.md`](./specs.md); this document defines what library users interact with.
| Section | Status |
|---|---|
| §1 Configuration Schema | **canonical** |
| §2 Behaviours (`Pkcs11ex.Algorithm`, `Pkcs11ex.Format`, `Pkcs11ex.Policy`) | **canonical** |
| §3 Surface Functions (`sign`, `verify`, `with_pin`, …) | **canonical** |
| §4 Errors and Telemetry | **canonical** |
| §5 Mix Tasks (`pkcs11ex.import_p12`, …) | **canonical** |
---
## 1. Configuration Schema
### 1.1 Overview
`pkcs11ex` is configured at the OTP application level via `Application` env (typically populated from `config/runtime.exs`). The schema is validated by `NimbleOptions` at supervisor start; invalid configuration prevents boot with a path-qualified error.
```elixir
# config/runtime.exs
config :pkcs11ex,
signature_header: "JWS-Signature",
allowed_algs: [:PS256],
default_slot: :platform,
trust_policy: Pkcs11ex.Policy.PinnedRegistry,
session_timeout: :timer.minutes(5),
driver_pins: %{
"/usr/lib/libeTPkcs11.so" =>
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
slots: [
platform: [
type: :cloud_hsm,
driver: "/opt/google/kmsp11/libkmsp11.so",
driver_config: "/etc/pkcs11ex/kmsp11.yaml",
keys: [
signing: [label: "platform-signing-key", cert_label: "platform-cert"]
]
],
legal_proxy: [
type: :token,
driver: "/usr/lib/libeTPkcs11.so",
slot_match: {:token_label, "Legal Proxy A"},
pin_callback: {MyApp.PINPrompt, :prompt, []},
keys: [
signing: [label: "proxy-signing-key", cert_label: "proxy-cert"]
]
]
]
```
### 1.2 Top-level keys
| Key | Type | Default | Notes |
|---------------------|----------------------------|------------------------------------|-------|
| `:signature_header` | `String.t()` | `"JWS-Signature"` | HTTP header for the JWS-detached transport (specs.md §3.1). |
| `:allowed_algs` | `[atom()]`, non-empty | `[:PS256]` | Accepted `alg` values for sign and verify. `:none` is hardcoded reject regardless of this list. |
| `:default_slot` | `atom()` (must reference `:slots`) | required when `:slots` non-empty | Slot used by `Pkcs11ex.sign_bytes/2` (and any format adapter that delegates to it) when no `:signer` opt is given. |
| `:trust_policy` | `module()` | `Pkcs11ex.Policy.PinnedRegistry` | Implements `Pkcs11ex.Policy` (specified in §2). Drives verify-side cert resolution. |
| `:session_timeout` | `non_neg_integer()` (ms) | `:timer.minutes(5)` | Inactivity timeout for PIN-protected sessions. Ignored by `:cloud_hsm` slots. |
| `:driver_pins` | `%{path => sha256_hex}` | `%{}` | Optional driver integrity pins (specs.md §4.1). When set, the loader verifies on-disk SHA-256 before `dlopen`. |
| `:slots` | `keyword()` of slot configs | `[]` | Slot definitions; see §1.3. Empty is valid for verify-only deployments. |
| `:telemetry_prefix` | `[atom()]` | `[:pkcs11ex]` | Prefix for emitted `:telemetry` events. |
### 1.3 Per-slot schema
Each slot is keyed by an atom — the `slot_ref` used throughout the API.
| Key | Type | Default | Notes |
|-----------------------|---------------------------------------------------------------|----------------------------------------|-------|
| `:type` | `:cloud_hsm` \| `:token` \| `:soft_hsm` | required | Drives concurrency model (specs.md §5.2). `:cloud_hsm` → session-per-thread pool. `:token` → single-session-pinned. `:soft_hsm` → like `:cloud_hsm` but `dirty_cpu`. |
| `:driver` | absolute path | required | PKCS#11 vendor library (`.so` / `.dll` / `.dylib`). Existence is checked at boot. |
| `:driver_config` | absolute path | `nil` | Vendor-specific config (e.g., `libkmsp11`'s YAML). Passed via `CK_C_INITIALIZE_ARGS.pReserved` for vendors that consume it. |
| `:slot_match` | `{:slot_id, integer()}` \| `{:token_label, String.t()}` | `{:slot_id, 0}` | How to identify the target slot inside the loaded module. `:token_label` triggers a discovery scan and matches `CK_TOKEN_INFO.label`. |
| `:pin_callback` | `{module, atom, [term]}` (MFA) | required for `:token`; forbidden for `:cloud_hsm` | Returns `{:ok, pin :: binary()}` \| `{:error, term}`. PIN is consumed once and never stored (specs.md §4.2). |
| `:keys` | `keyword()` of key configs | `[]` (verify-only slot) | See §1.4. |
| `:allowed_algs` | `[atom()]` | inherits global | Per-slot override. Effective allowlist = `MapSet.intersection(global, slot)`. |
| `:session_pool_size` | `pos_integer()` | `1` | Only for `:cloud_hsm` / `:soft_hsm` (rejected at config validation for `:token` since login state lives on a single session). When `> 1`, `Pkcs11ex.SlotSupervisor` starts N independent `Slot.Server` workers and `Slot.sign`/`verify` round-robin across them via `Pkcs11ex.Slot.Pool`. Stateful ops (`login`, `logout`, `import_keypair`, `status`, `get_config`) target worker 1. |
| `:lazy` | `boolean()` | `true` for `:token`, `false` otherwise | `true` → opened on first use (avoids a PIN prompt at boot). `false` → opened eagerly (catches config errors immediately). |
| `:reauthentication` | `:prompt` \| `:fail` | `:prompt` | After session timeout, `:prompt` re-invokes `pin_callback`; `:fail` returns `{:error, :reauthentication_required}` and requires explicit `Pkcs11ex.Slot.login/2`. |
### 1.4 Per-key schema
Inside a slot's `:keys`, each entry is keyed by an atom — the **logical key id**. The pair `{slot_ref, key_ref}` (e.g., `{:platform, :signing}`) addresses a signing identity end-to-end.
| Key | Type | Default | Notes |
|----------------|-----------------|--------------------------|-------|
| `:label` | `String.t()` | one of `:label` / `:id` is required | Matches `CKA_LABEL` of the private key object. |
| `:id` | `binary()` | one of `:label` / `:id` is required | Matches `CKA_ID`. Use when labels collide; common for HSMs that auto-label. |
| `:cert_label` | `String.t()` | inherits `:label` | `CKA_LABEL` of the certificate object used to populate `x5c`. Mutually exclusive with `:cert_id`. |
| `:cert_id` | `binary()` | inherits `:id` | `CKA_ID` of the certificate object. Mutually exclusive with `:cert_label`. |
| `:alg` | `atom()` | inferred from key type | Pin a specific signing algorithm to this key. Otherwise the caller picks from the effective allowlist; the call fails if the chosen alg is incompatible with the key type. |
### 1.5 Boot-time validation rules
The library refuses to start if any of:
1. `:allowed_algs` is empty.
2. `:allowed_algs` contains an unknown algorithm (anything outside `specs.md` §3.5).
3. `:default_slot` references a slot not in `:slots`.
4. A `:type :token` slot lacks `:pin_callback`.
5. A `:type :cloud_hsm` slot defines `:pin_callback`.
6. A slot's `:driver` does not exist on disk.
7. `:driver_pins` contains an entry for a slot's driver and the on-disk SHA-256 does not match.
8. A key has neither `:label` nor `:id`.
9. A key has both `:cert_label` and `:cert_id`.
10. A per-slot `:allowed_algs` has an empty intersection with the global allowlist.
11. Two slots share the same `:driver` path with **different** `:driver_config` (PKCS#11 modules are loaded once per `.so`; conflicting init args are unresolvable).
Error messages identify the offending key path (e.g., `slots.legal_proxy.pin_callback: missing for :type :token`).
### 1.6 Configuration sources and layering
- **Compile-time** (`config/config.exs`, `config/<env>.exs`): structural defaults — PIN callback module names, telemetry prefix, default header.
- **Runtime** (`config/runtime.exs`): host- and environment-specific values — driver paths, key labels, pin digests, slot ids.
- **No environment-variable shorthand.** The library does not read env vars directly; use `System.get_env/1` inside `runtime.exs`. This keeps the configuration surface fully visible in one place.
### 1.7 Verify-only deployments
A deployment that only **verifies** incoming JWS needs:
- `:allowed_algs`
- `:trust_policy`
- `:signature_header`
`:slots` may be empty; `:default_slot` is then omitted. Sign-side calls in this configuration return `{:error, :no_signing_slot}`.
### 1.8 Hot configuration changes
The configuration schema is **immutable for the lifetime of the OTP application**. Changes require a restart. Specifically:
- Adding/removing slots: restart.
- Changing `:driver_pins`: restart.
- Changing `:allowed_algs`: restart. (Operations can disable an alg, but the library re-reads on boot — there is no hot-reload to avoid time-of-check/time-of-use windows.)
- Trust policy changes that the policy module handles internally (e.g., reloading an enrollment table) are handled by the policy itself, not by `pkcs11ex`.
### 1.9 Reference: known algorithm atoms
For `:allowed_algs` and the per-key `:alg`:
| Atom | JOSE `alg` | Compatible key type |
|----------|------------|---------------------|
| `:PS256` | `PS256` | RSA ≥ 2048 |
| `:RS256` | `RS256` | RSA ≥ 2048 |
| `:ES256` | `ES256` | EC P-256 |
| `:EdDSA` | `EdDSA` | Ed25519 (future) |
The validator rejects any atom outside this set.
---
## 2. Behaviours
`pkcs11ex` exposes three extension points: **algorithms** (Layer 2), **formats** (Layer 3), and **trust policies** (verification). Implementations plug in through behaviours. Drivers are not an extension point — the Rust bridge talks to whatever PKCS#11 module is loaded at deployment time.
### 2.1 `Pkcs11ex.Algorithm`
Adapts a JOSE `alg` to a hash, a PKCS#11 mechanism, and the wire-format signature encoding for the calling context.
```elixir
defmodule Pkcs11ex.Algorithm do
@type alg :: atom()
@type key_type :: :rsa | :ec | :ed25519
@type mechanism :: term()
@type signature :: binary()
@type encoding_context :: :jose | :der # :jose for JWS; :der for X.509/CMS contexts (PDF, XML)
@callback alg() :: alg()
@callback compatible_key_types() :: [key_type()]
@callback hash() :: :sha256 | :sha384 | :sha512 | :none
@callback signing_mechanism() :: mechanism()
@callback verifying_mechanism() :: mechanism()
@callback encode_signature(raw :: binary(), encoding_context()) :: {:ok, signature()} | {:error, term()}
@callback decode_signature(signature(), encoding_context()) :: {:ok, raw :: binary()} | {:error, term()}
end
```
The mechanism descriptor is opaque to Elixir; the Rust bridge translates it to `CK_MECHANISM` plus parameters. Elixir never builds PKCS#11 binary structures directly. Header / signed-attribute validation is the format adapter's responsibility (§2.2), not the algorithm's.
The `encoding_context` parameter exists for ES256: PKCS#11 returns DER `SEQUENCE(r, s)`, which is what X.509/CMS expects (PDF, XML), but JWS requires fixed-width raw `r‖s` (RFC 7518). All other algorithms ignore the context.
**Built-in implementations:**
| Module | `alg()` | Notable behavior |
|------------------------------|----------|---------------------------------------------------------------------------------------------------|
| `Pkcs11ex.Algorithm.PS256` | `:PS256` | `CKM_RSA_PKCS_PSS` with SHA-256 / MGF1-SHA-256 / 32-byte salt. Identity signature encoding. |
| `Pkcs11ex.Algorithm.RS256` | `:RS256` | `CKM_SHA256_RSA_PKCS`. Identity signature encoding. |
| `Pkcs11ex.Algorithm.ES256` | `:ES256` | `CKM_ECDSA` over a SHA-256 digest. Encoding strips DER → IEEE P1363 raw `r‖s`. |
| `Pkcs11ex.Algorithm.EdDSA` | `:EdDSA` | Future. `CKM_EDDSA`; vendor support uneven. |
**Adding a custom algorithm.** Implement the behaviour and register the module under `:algorithms`:
```elixir
config :pkcs11ex,
algorithms: %{
PS256: Pkcs11ex.Algorithm.PS256,
PS512: MyApp.PS512Algorithm
}
```
This extends the known-set check in §1.5 rule 2.
### 2.2 `Pkcs11ex.Format`
Adapts a document or transport format (JWS, PDF, XML, custom) to and from the signing primitives. A format adapter is responsible for: building the bytes-to-sign from the application's input, assembling the signed artifact from the resulting signature, and the inverse parse / verify path.
```elixir
defmodule Pkcs11ex.Format do
@type input :: term() # format-specific (payload binary, PDF builder, XML doc, ...)
@type artifact :: term() # format-specific signed output
@type prepared :: %Pkcs11ex.Prepared{
signing_input: iodata() | Enumerable.t(),
alg: atom(),
encoding_context: :jose | :der,
context: map() # opaque, returned to assemble/3
}
@type parsed :: %Pkcs11ex.Parsed{
signed_input: iodata(),
signature: binary(),
alg: atom(),
encoding_context: :jose | :der,
signer_hint: term() # JWS x5c, CMS SignerIdentifier, XAdES KeyInfo, etc.
}
@callback name() :: atom() # :jws, :pdf, :xml, ...
@callback prepare(input(), opts :: keyword()) :: {:ok, prepared()} | {:error, term()}
@callback assemble(input(), signature :: binary(), context :: map(), opts :: keyword()) ::
{:ok, artifact()} | {:error, term()}
@callback parse(artifact(), opts :: keyword()) :: {:ok, parsed()} | {:error, term()}
end
```
**Built-in implementations:**
| Module | `name()` | Notes |
|-------------------------|----------|---------------------------------------------------------------------------------|
| `SignCore.JWS` | `:jws` | RFC 7515 / 7797 detached. `encoding_context: :jose`. |
| `SignCore.PDF` | `:pdf` | PAdES B-B; B-T via `:tsa_url`. `encoding_context: :der`. |
| `SignCore.XML` | `:xml` | XML-DSig + XAdES B-B; B-T via `:tsa_url`. `encoding_context: :der`. |
**Adding a custom format.** Implement the behaviour and register the module under `:formats`:
```elixir
config :pkcs11ex,
formats: %{
jws: Pkcs11ex.JWS,
my_proto: MyApp.MyProtoFormat
}
```
Format adapters call only the Layer 2 primitives (`Pkcs11ex.sign_bytes/2`, `Pkcs11ex.verify_bytes/4`, `Pkcs11ex.digest/2`); they never reach into Layer 1 directly.
### 2.3 `Pkcs11ex.Policy`
Resolves the signer's certificate from a JWS header and decides whether the signer is currently authorized.
```elixir
defmodule Pkcs11ex.Policy do
@type header :: map()
@type cert :: %Pkcs11ex.X509{}
@type chain :: [cert()]
@type subject_id :: term()
@callback resolve(header(), opts :: keyword()) ::
{:ok, cert(), chain()} | {:error, term()}
@callback validate(cert(), chain(), opts :: keyword()) ::
{:ok, subject_id()} | {:error, term()}
end
```
**Hard invariant — sender-supplied certs are untrusted input.** The verify pipeline treats the certificate from `x5c` (or any equivalent format-supplied hint) as untrusted until matched against an allowlist the verifier maintains. `resolve/2` MUST return `{:error, :unknown_signer}` when no allowlist entry matches; the pipeline aborts **before any cryptographic math runs**. There is no built-in policy and no documented recipe that trusts a sender-supplied certificate solely because its chain validates to a CA. See `specs.md` §7.1.
The verify pipeline calls `resolve/2` first; on `{:error, :unknown_signer}` it aborts (cheap denial of unknowns). It then enforces validity period and algorithm/key compatibility (library-owned, never skipped), and finally calls `validate/3`, where authorization decisions live (subject permitted for this message type / value range / endpoint, policy OID checks, etc.). The returned `subject_id` is propagated to telemetry and to the verify result.
**Built-in implementations:**
| Module | Trust model | Allowlist mechanism |
|----------------------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
| `Pkcs11ex.Policy.PinnedRegistry` *(default)* | SPKI pinning. No chain, no CA, no revocation protocol. | `{spki_sha256_hex → subject_id}` registry. Onboarding adds an entry; off-boarding deletes one. SPKI pin (not cert pin) survives routine cert re-issuance with the same key. |
| `Pkcs11ex.Policy.CASignedAllowlist` | Chain-to-CA validation **AND** per-subject allowlist. | Validates the chain to a configured CA bundle, **then** requires the leaf's SPKI hash (or DN) to be in an explicit allowlist. Both gates must pass. Pluggable `crl_fetcher` and `ocsp_check` callbacks for revocation; no built-in fetchers. |
| `Pkcs11ex.Policy.Allow` *(test only)* | None. | Accepts any signer. **Refuses to start under `Mix.env() == :prod`.** |
A "CA bundle without allowlist" policy is not provided and not documented as a recipe. See `specs.md` §10 Non-Goals.
**`PinnedRegistry` configuration:**
```elixir
config :pkcs11ex, Pkcs11ex.Policy.PinnedRegistry,
pins: [
{"a3f1...d29c", :acme_corp},
{"7e2b...4810", :beta_inc}
]
```
Runtime updates: `Pkcs11ex.Policy.PinnedRegistry.put(spki_sha256_hex, subject_id)` and `delete/1`. State is held in a protected ETS table owned by the application supervisor. Off-boarding a counterparty is `delete/1` — local, instant, no protocol.
**`CASignedAllowlist` configuration:**
```elixir
config :pkcs11ex, Pkcs11ex.Policy.CASignedAllowlist,
ca_bundle: "/etc/pkcs11ex/ca-bundle.pem",
allow: [
{:spki_sha256, "a3f1...d29c", :sii_taxpayer_acme},
{:dn_match, "CN=*,O=Acme Corp,C=CL", :sii_taxpayer_acme}
],
crl_fetcher: nil, # MFA returning {:ok, [%CRL{}]} or {:error, _}
ocsp_check: nil # MFA returning :good | :revoked | :unknown
```
At least one of `crl_fetcher` or `ocsp_check` MUST be set in production; the library refuses to start otherwise. (This is enforced because most government-PKI deployments are exactly the case where revocation matters most.)
**Helpers.** `Pkcs11ex.Policy.Helpers` ships composable functions for building custom policies without re-implementing RFC 5280:
```elixir
Pkcs11ex.Policy.Helpers.spki_sha256(cert) :: binary()
Pkcs11ex.Policy.Helpers.validity_now(cert, opts) :: :ok | {:error, :expired | :not_yet_valid}
Pkcs11ex.Policy.Helpers.alg_compatible?(alg, cert) :: boolean()
Pkcs11ex.Policy.Helpers.path_validate(leaf, intermediates, anchors) :: {:ok, path} | {:error, reason}
Pkcs11ex.Policy.Helpers.basic_constraints_ok?(cert) :: boolean()
Pkcs11ex.Policy.Helpers.key_usage_includes?(cert, usage) :: boolean()
Pkcs11ex.Policy.Helpers.eku_includes?(cert, oid) :: boolean()
```
Custom policies that bypass `PinnedRegistry` and `CASignedAllowlist` MUST still implement an allowlist gate; the helpers above do not enforce this — the policy author does. Documentation makes this obligation explicit.
#### 2.3.1 The Verification Algorithm
Every format adapter's `verify/3` runs through the same canonical pipeline. Steps 1–6 happen *before* the cryptographic math (step 7), giving cheap denial of unknown / disallowed / expired / wrong-alg signatures without spending CPU on RSA/ECC verification.
```
Input: signed_artifact, payload (or signed bytes), opts
Output: {:ok, subject_id} | {:error, reason}
1. Format parse (adapter)
parsed = Format.parse(signed_artifact, opts)
parsed = %{signed_input, signature, alg, encoding_context, signer_hint}
On bad envelope → {:error, :malformed_<format>}
On missing header → {:error, :missing_required_header}
2. Algorithm allowlist gate (library, mandatory)
if alg == :none → {:error, :disallowed_alg} # hardcoded
if alg ∉ effective_allowed_algs(opts) → {:error, :disallowed_alg}
if alg ∉ registered_algorithms() → {:error, :unsupported_alg}
3. Identity resolution (policy)
{cert, chain} = Policy.resolve(signer_hint, opts)
On allowlist miss → {:error, :unknown_signer} # ABORT — no math runs
On hint disagreement → {:error, :hint_mismatch}
4. Validity window (library, mandatory, NOT skippable)
for c in [cert | chain]:
if now < c.notBefore - skew → {:error, :cert_not_yet_valid}
if now > c.notAfter + skew → {:error, :cert_expired}
5. Algorithm/key compatibility (library, mandatory, NOT skippable)
if cert.spki.key_type ∉ alg.compatible_key_types() → {:error, :incompatible_alg}
6. Authorization (policy)
{:ok, subject_id} = Policy.validate(cert, chain, opts)
May internally run: chain validation, revocation, subject-permitted checks,
value/endpoint policy. Errors propagate as :chain_invalid, :incomplete_chain,
:untrusted_signer, :cert_revoked, :crl_unavailable, :ocsp_unavailable,
:revocation_unknown, {:policy_failed, reason}.
7. Cryptographic verification (Layer 2)
:ok = Pkcs11ex.verify_bytes(signed_input, signature, cert.public_key,
alg: alg, encoding_context: encoding_context)
On math failure → {:error, :signature_invalid}
8. expected_subject gate (library, only if opt set)
if subject_id != opts[:expected_subject]
→ {:error, {:unexpected_subject, got: subject_id, want: opts[:expected_subject]}}
9. Return {:ok, subject_id}
```
Steps 1, 2, 4, 5, 7, 8 are library-owned and cannot be opted out by a policy. Steps 3 and 6 are policy-owned. Telemetry events `[:pkcs11ex, :verify, :start | :stop | :exception]` span the whole pipeline; `:queue_time` covers any waiting in step 7.
#### 2.3.2 Identity Resolution (`kid` / `x5c` / `x5t#S256`)
The `signer_hint` payload depends on format:
| Format | Hint shape |
|--------|-----------------------------------------------------------------|
| JWS | `%{x5c: [b64_der, ...], kid: "...", x5t#S256: "..."}` (any subset) |
| PDF | CMS `SignerIdentifier` (issuerAndSerialNumber or subjectKeyIdentifier) |
| XML | `<KeyInfo>` element (X509Data, KeyName, SubjectKeyIdentifier) |
Resolution rules within the policy:
- **`x5c` / equivalent embedded cert present** → leaf cert is the *candidate* (still untrusted). Additional certs form the candidate chain.
- **`x5t#S256` present, no embedded cert** → SHA-256 of a cert the verifier has stored. Look up in the local registry; if absent → `:unknown_signer`.
- **`kid` present, nothing else** → opaque identifier passed verbatim to the policy. **Required:** the policy MUST resolve `kid` to a cert held in the verifier's registry. `kid` alone, without a registry mapping, is broken — it lets the sender claim any identity.
- **Multiple hints present** → the policy reconciles. `PinnedRegistry`: prefers `SPKI(x5c-leaf)`; falls back to `x5t#S256`. `CASignedAllowlist`: requires `x5c` with the full chain (no AIA chasing). If hints disagree (e.g., `x5t#S256` doesn't match the leaf in `x5c`) → `:hint_mismatch`.
The format adapter passes the hint to the policy verbatim; policies decide which hint(s) to honor.
#### 2.3.3 Validity, Algorithm Compatibility, Constraint Checks
Validity (library-owned, every verify):
- `notBefore - max_clock_skew ≤ now ≤ notAfter + max_clock_skew`
- `:max_clock_skew` defaults to 30s; configurable per call. Negative values rejected at boot.
- All certs in the chain are checked, not just the leaf.
Algorithm/key compatibility (library-owned, every verify):
- `Algorithm.compatible_key_types()` for the header `alg` MUST include the cert's SPKI key type.
- PS256 + EC cert → reject. ES256 + RSA cert → reject.
Constraint checks (policy-owned, only when chain validation runs):
- **Basic Constraints:** every non-leaf cert MUST have `cA: true`. Leaf MUST have `cA: false` (or absent).
- **Key Usage:** non-leaf certs MUST include `keyCertSign`. Leaf SHOULD include `digitalSignature`; if KU is absent (RFC 5280 §4.2.1.3 — any usage allowed), policies are recommended to reject in production but the library does not enforce this.
- **Extended Key Usage:** checked against a policy-supplied OID list (e.g., `id-kp-emailProtection`, `id-kp-clientAuth`, `id-kp-codeSigning`). An unconstrained leaf passes. Policies that mandate a specific EKU (e.g., regulatory non-repudiation OIDs) supply the list.
`Pkcs11ex.Policy.Helpers.basic_constraints_ok?/1`, `key_usage_includes?/2`, and `eku_includes?/2` implement the canonical interpretation; policies should compose these rather than reading X.509 extensions directly.
#### 2.3.4 Path Validation and Revocation
For `CASignedAllowlist` and any custom policy that does CA-chain validation:
**Path validation** (`Pkcs11ex.Policy.Helpers.path_validate/4`):
- Backed by OTP `:public_key.pkix_path_validation/3`.
- Trust anchors come from a deployment-supplied PEM/DER bundle (`:ca_bundle` config key on the policy).
- `:max_depth` opt (default 8) caps chain length.
- **No AIA chasing.** Senders MUST include the full chain (leaf + all intermediates, no root) in the format envelope. A missing intermediate yields `{:error, :incomplete_chain}`.
- **Cross-signed paths:** OTP returns the first valid path; the library does not enumerate alternatives.
**Revocation** (pluggable; no built-in HTTP fetcher):
| Callback | Signature | Returns |
|-----------------|-----------------------------------------------------------|------------------------------------------------------------------------------------------------------|
| `:crl_fetcher` | `MFA → ({issuer_dn, opts}) :: {:ok, [%CRL{}]} \| {:error, term}` | The CRLs the library will check serial numbers against. Called once per verification, cached for the call. |
| `:ocsp_check` | `MFA → (cert, chain, opts) :: :good \| :revoked \| :unknown \| {:error, term}` | Real-time check or stapled-response evaluation. |
Behavior:
- A `CASignedAllowlist` policy refuses to start in production if neither `:crl_fetcher` nor `:ocsp_check` is set.
- `:revoked` → `{:error, :cert_revoked}`.
- `:unknown` → `{:error, :revocation_unknown}` by default. Configurable per policy via `:revocation_unknown_policy: :allow` (NOT recommended; logs a warning at boot).
- Callback raising or `{:error, _}` → `{:error, :crl_unavailable}` or `{:error, :ocsp_unavailable}`. **Default posture: revocation unavailable = abort.** This is the right default for high-value workflows; flip it deliberately, not by accident.
- **OCSP stapling** (RFC 6961 / 7633): if the format envelope carries a stapled response, it is passed to `:ocsp_check` as a hint; the callback decides whether to trust it (typically: signature on the response chains to a trusted OCSP responder, response timestamp within a configured freshness window).
#### 2.3.5 Subject Matching and Allowlist Encoding
Allowlist entries for `CASignedAllowlist` (and similar policies):
| Entry | Match strategy | Notes |
|---------------------------------------------|-----------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| `{:spki_sha256, hex, subject_id}` | Exact match on the leaf's SPKI SHA-256. | **Recommended.** Survives routine cert re-issuance with same key. |
| `{:dn_match, pattern, subject_id}` | Match the leaf's DN against `pattern`. | Use when the CA is trusted to bind the DN to the right entity. |
| `{:cert_sha256, hex, subject_id}` | Whole-cert SHA-256. | Discouraged — forces re-pinning on every renewal. |
DN pattern syntax for `:dn_match`:
- Exact DN: `"CN=Acme Corp,O=Acme,C=CL"` — string equality after normalization.
- Wildcard CN: `"CN=*,O=Acme,C=CL"` — only the CN component may be `*`. All other components must match exactly. No partial wildcards (`CN=Acme*`) are supported.
DN normalization (RFC 5280 §7.1, applied before comparison):
- Whitespace folded.
- Case-insensitive comparison on `caseIgnoreString` attributes (CN, O, OU, ...).
- Lowercase hex on `octetString` attributes.
- Backed by `:public_key`'s `pkix_normalize_name/1`.
**Allowlist precedence:** SPKI matches are checked first; DN matches only if no SPKI entry matched. This bounds the cost of misconfigured DN patterns and makes SPKI the "fast path".
#### 2.3.6 Failure Mode Map
Mapping each pipeline step to error reasons (full taxonomy in §4.1):
| Step | Failure | Reason |
|------|------------------------------------------|-------------------------------------------------|
| 1 | Bad envelope | `:malformed_jws` / `:malformed_pdf` / `:malformed_xml` |
| 1 | Missing required header | `:missing_required_header` |
| 1 | `b64`/`crit` violation (JWS) | `:b64_crit_violation` |
| 2 | `alg` not allowed | `:disallowed_alg` |
| 2 | `alg` not registered | `:unsupported_alg` |
| 3 | Allowlist miss | `:unknown_signer` |
| 3 | Hints disagree | `:hint_mismatch` |
| 4 | Cert expired | `:cert_expired` |
| 4 | Cert not yet valid | `:cert_not_yet_valid` |
| 5 | Alg / key type mismatch | `:incompatible_alg` |
| 6 | Chain validation failed | `:chain_invalid` |
| 6 | Chain incomplete (no AIA chasing) | `:incomplete_chain` |
| 6 | Subject not on allowlist | `:untrusted_signer` |
| 6 | Cert revoked | `:cert_revoked` |
| 6 | CRL fetcher failed / unavailable | `:crl_unavailable` |
| 6 | OCSP responder failed / unavailable | `:ocsp_unavailable` |
| 6 | Revocation status unknown | `:revocation_unknown` |
| 6 | Custom policy failure | `{:policy_failed, reason}` |
| 7 | Math failed | `:signature_invalid` |
| 8 | `expected_subject` mismatch | `{:unexpected_subject, got: ..., want: ...}` |
The reasons listed at step 6 are emitted by `validate/3`; the policy is responsible for choosing the precise atom and for consistency. The library propagates them as-is and adds the `:error_reason` and `:error_class` to telemetry metadata.
---
## 3. Surface Functions
Surfaces are organized by layer:
- **§3.1 `Pkcs11ex`** — Layer 2 primitives. Format-agnostic.
- **§3.2 `Pkcs11ex.JWS`** — Layer 3 JWS adapter.
- **§3.3 `Pkcs11ex.PDF`** — Layer 3 PAdES adapter.
- **§3.4 `Pkcs11ex.XML`** — Layer 3 XML-DSig / XAdES adapter.
- **§3.5 `Pkcs11ex.Slot`** — slot lifecycle and introspection.
- **§3.6 `Pkcs11ex.PIN`** — scoped PIN helper.
- **§3.7 `Pkcs11ex.JWS.Plug`** — Phoenix / Plug verifier for JWS over HTTP.
- **§3.8 `Pkcs11ex.PKCS12`** — read-only loader for certificates and chains from `.p12`/`.pfx` bundles. Never exposes private keys.
### 3.1 `Pkcs11ex` top-level (Layer 2 primitives)
```elixir
@type signer_ref :: {slot_ref :: atom(), key_ref :: atom()} | atom()
@type pubkey :: %Pkcs11ex.PubKey{} | %Pkcs11ex.X509{}
@spec sign_bytes(iodata() | Enumerable.t(), opts :: keyword()) ::
{:ok, signature :: binary()} | {:error, term()}
@spec sign_bytes!(iodata() | Enumerable.t(), opts :: keyword()) :: binary()
@spec verify_bytes(iodata() | Enumerable.t(), signature :: binary(), pubkey(), opts :: keyword()) ::
:ok | {:error, term()}
@spec digest(iodata(), alg :: atom()) :: binary()
@spec digest_stream(Enumerable.t(), alg :: atom()) :: binary()
```
**`sign_bytes/2` options:**
| Opt | Type | Default | Notes |
|-----------------------|------------------|----------------------------------------------------|------------------------------------------------------------------------------------------------|
| `:signer` | `signer_ref()` | `{default_slot, :signing}` | Atom shorthand resolves to `{default_slot, key_ref}`. |
| `:alg` | `atom()` | key-pinned alg, else first compatible allowed alg | Must be in the slot's effective allowlist. |
| `:encoding_context` | `:jose \| :der` | `:der` | Format adapters override; raw users typically want `:der` (X.509/CMS-shaped output). |
| `:precomputed_digest` | `binary()` | `nil` | If supplied, the bytes are interpreted as already digested; the library skips hashing and uses a raw-sign mechanism. Mutually exclusive with streaming input. |
**`verify_bytes/4` options:** symmetric to sign — `:alg` (default: inferred from key type), `:encoding_context` (default: `:der`), `:precomputed_digest`.
**`digest/2` and `digest_stream/2`:** the canonical hash for an `alg`. The mapping is fixed by `Pkcs11ex.Algorithm.hash/0`: `:PS256 → :sha256`, etc. Streaming exists for multi-GB artifacts (PDF/XML signing).
The `!` variants raise `Pkcs11ex.Error`. Use only where errors are programming bugs.
### 3.2 `Pkcs11ex.JWS`
```elixir
@type jws :: binary() # "header..signature"
@type payload :: iodata()
@type subject_id :: term()
@spec sign(payload(), opts :: keyword()) :: {:ok, jws()} | {:error, term()}
@spec sign!(payload(), opts :: keyword()) :: jws()
@spec verify(jws(), payload(), opts :: keyword()) ::
{:ok, subject_id()} | {:error, term()}
@spec verify!(jws(), payload(), opts :: keyword()) :: subject_id()
@spec chain_sign(jws(), payload(), opts :: keyword()) ::
{:ok, jws(), subject_id()} | {:error, term()}
```
**`sign/2` options:**
| Opt | Type | Default | Notes |
|------------------|----------------|----------------------------------------------------|------------------------------------------------------------------------------------------------|
| `:signer` | `signer_ref()` | `{default_slot, :signing}` | As Layer 2. |
| `:alg` | `atom()` | key-pinned alg, else first compatible allowed alg | Must be in the effective allowlist. |
| `:extra_headers` | `map()` | `%{}` | Merged into the protected header. `alg`, `b64`, `crit`, `x5c` are reserved; overwriting errors.|
**`verify/3` options:**
| Opt | Type | Default | Notes |
|---------------------|-------------|--------------------------|----------------------------------------------------------------|
| `:trust_policy` | `module()` | global `:trust_policy` | Per-call override. |
| `:policy_opts` | `keyword()` | `[]` | Forwarded to `resolve/2` and `validate/3`. |
| `:expected_subject` | `term()` | `nil` | If set, the policy-returned `subject_id` must equal this term. |
**`chain_sign/3`:** verifies `inner_jws` against the trust policy, builds an outer payload as specified in `specs.md` §4.1, signs it with the configured signer (defaults match `sign/2`), and returns `{:ok, outer_jws, inner_subject_id}`. Inner verify failure aborts before any outer signing.
### 3.3 `Pkcs11ex.PDF`
PAdES B-B / B-T sign + verify. Convenience wrapper around `SignCore.PDF` that pre-configures the PKCS#11 signer; under the hood the orchestrator lives in `sign_core` and is provider-agnostic.
```elixir
@spec sign(pdf_in :: binary(), opts :: keyword()) ::
{:ok, pdf_out :: binary()} | {:error, term()}
@spec verify(pdf :: binary(), opts :: keyword()) ::
{:ok, subject_id()} | {:error, term()}
```
`sign/2` required opts: `:x5c` (leaf-first chain) plus PKCS#11 keying
opts (`:module`, `:slot_id`, `:pin`, `:key_label`, or canonical
`:signer`). Optional: `:alg` (`:PS256` default, `:RS256`),
`:signing_time`, `:placeholder_size`, `:reason`, `:location`,
`:contact_info`, **`:tsa_url`** + `:tsa_timeout` (PAdES B-T —
attaches an RFC 3161 TimeStampToken as the
`id-aa-signatureTimeStampToken` CMS unsigned attribute; raise
`:placeholder_size` to ~16 KiB to fit the TST). The output is the
original PDF plus an incremental update with a `/Sig` dict whose
`/Contents` is the HSM-produced CMS.
`verify/2` runs in this order — every step is a refusal point:
1. Locate the (single) `/Sig` dict and extract `/ByteRange` +
`/Contents`. v1 refuses multiple `/Sig` dicts.
2. **Append-attack detection.** Refuse if `c + d` ≠ file size.
3. Parse the CMS `ContentInfo`.
4. **Allowlist gate (architectural invariant).** Synthesise a
JOSE-style header from the embedded chain and run it through
`Pkcs11ex.Policy` — `resolve/2` then `validate/3`. The chain is
untrusted input until both succeed.
5. Match `SHA-256(signed_input)` against the CMS `messageDigest`.
6. Verify the signature math.
Streaming input (`Enumerable.t()`) is post-v1.
### 3.4 `Pkcs11ex.XML`
XAdES Baseline B (B-B) and B-T sign + verify on top of W3C XML-DSig. Convenience wrapper around `SignCore.XML` (pre-configured with the PKCS#11 signer); the provider-agnostic orchestrator lives in `sign_core`.
```elixir
@spec sign(doc :: binary(), opts :: keyword()) ::
{:ok, signed_doc :: binary()} | {:error, term()}
@spec verify(doc :: binary(), opts :: keyword()) ::
{:ok, subject_id()} | {:error, term()}
```
`sign/2` required opts: `:x5c` (leaf-first chain) plus PKCS#11
keying opts (`:module`, `:slot_id`, `:pin`, `:key_label`, or
canonical `:signer`). Optional: `:alg` (`:PS256` default,
`:RS256`), `:signing_time`, **`:tsa_url`** + `:tsa_timeout`
(XAdES B-T — attaches an RFC 3161 TimeStampToken as
`<xades:UnsignedProperties>` →
`<xades:UnsignedSignatureProperties>` →
`<xades:SignatureTimeStamp>`, per ETSI EN 319 132-1 §5.4.1; the
TST hash covers the canonicalised `<ds:SignatureValue>` element
bytes). Output is the original XML with an enveloped
`<ds:Signature>` element spliced before the root's closing tag,
carrying the XAdES `<xades:QualifyingProperties>` including
`<xades:SigningCertificateV2>` (RFC 5035 IssuerSerialV2).
Canonicalisation: **Exclusive XML Canonicalization 1.0** is
mandatory and the only choice in v1. Digest method: SHA-256.
Signature method URIs: `xmldsig-more#rsa-sha256` (RS256),
`xmldsig-more#sha256-rsa-MGF1` (PS256, RFC 4051).
`verify/2` runs in this order — every step is a refusal point:
1. Locate the (single) `<ds:Signature>`. v1 refuses
`:multiple_signatures_unsupported_in_v1`.
2. Extract `<ds:KeyInfo>` chain plus the XAdES context
(SignedInfo, SignatureValue, SignedProperties, CertDigest,
IssuerSerialV2).
3. **Allowlist gate (architectural invariant).** Synthesise a
JOSE-style header from the chain and run the configured
`Pkcs11ex.Policy`. The chain is untrusted input until both
`resolve/2` and `validate/3` succeed.
4. Verify XAdES `<SigningCertificateV2>` actually binds the leaf
from `<KeyInfo>`: `SHA-256(leaf_der) == <CertDigest>`,
`<IssuerSerialV2>` matches the leaf's issuer + serial.
5. Recompute data `<Reference>` digest: enveloped-signature
transform (excise the `<Signature>` element) + exc-c14n +
SHA-256.
6. Recompute SignedProperties `<Reference>` digest: exc-c14n
subtree + SHA-256.
7. Math: `:public_key.verify` with the right padding for the
signature method URI.
v1 limitations: enveloped signatures only (detached and
enveloping XML-DSig modes are post-v1); the base document must
not already contain a `<ds:Signature>` (multi-sig is post-v1).
### 3.5 `Pkcs11ex.Slot`
Operational surface for slot lifecycle and introspection.
```elixir
@type state :: :idle | :logged_in | :expired | :error
@spec login(slot_ref :: atom(), opts :: keyword()) :: :ok | {:error, term()}
@spec logout(slot_ref :: atom()) :: :ok | {:error, term()}
@spec list() :: [%{ref: atom(), type: atom(), state: state()}]
@spec list_keys(slot_ref :: atom()) ::
[%{ref: atom(), label: String.t() | nil, alg: atom() | nil}]
@spec status(slot_ref :: atom()) :: %{state: state(), last_login: integer() | nil}
```
`login/2` is rarely needed — `sign/2` triggers login transparently via the slot's `pin_callback`. Use it explicitly when:
- the slot is configured `reauthentication: :fail` and you want to control prompt timing;
- you want to provide a one-shot PIN via `opts[:pin]` (scripts), bypassing the callback.
`logout/1` calls `C_Logout` and closes the session. Subsequent signing calls re-login through the callback.
### 3.6 `Pkcs11ex.PIN`
```elixir
@spec with_pin(binary(), (() -> result)) :: result when result: any()
```
Scopes a PIN to a single closure. Useful for tests and one-shot scripts:
```elixir
Pkcs11ex.PIN.with_pin(System.get_env("TOKEN_PIN"), fn ->
{:ok, jws} = Pkcs11ex.JWS.sign(payload, signer: {:legal_proxy, :signing})
end)
```
Outside `with_pin`, the library never reads PINs from process state — the registered `pin_callback` is the only path.
### 3.7 `Pkcs11ex.JWS.Plug`
Plug for Phoenix / Plug applications that verify on every request.
```elixir
plug Pkcs11ex.JWS.Plug,
header: :default, # uses configured :signature_header
on_failure: :halt_401,
policy_opts: [],
assign: :pkcs11ex_subject
```
Behavior:
1. Reads the configured signature header.
2. **Captures the raw body before `Plug.Parsers`.** Must be installed before any body-parsing plug. If installed after, returns `{:error, :body_already_consumed}`.
3. Calls `Pkcs11ex.JWS.verify/3`.
4. On success, assigns `subject_id` under `:assign`. On failure, `:on_failure` controls behavior:
- `:halt_401` — sends 401, halts the conn.
- `:halt_403` — sends 403.
- `{:assign, key}` — assigns `{:error, reason}` under `key` and continues (lets the controller decide).
The plug is opt-in; `pkcs11ex` does not assume Phoenix. Equivalent plugs for non-JWS protocols (e.g., `Pkcs11ex.PDF.Plug` for PDF upload verification) are out of scope for v1.
### 3.8 `Pkcs11ex.PKCS12`
Read-only loader for certificates and chains from PKCS#12 (`.p12` / `.pfx`) bundles. **Never returns the private key**, even if one is present in the bundle — only a flag indicating its presence. This is a deliberate design choice; see `specs.md` §10 (Non-Goals).
```elixir
@type bundle :: %Pkcs11ex.PKCS12.Bundle{
leaf: %Pkcs11ex.X509{},
chain: [%Pkcs11ex.X509{}],
has_private_key: boolean(),
friendly_name: String.t() | nil
}
@spec load(source, opts :: keyword()) :: {:ok, bundle()} | {:error, term()}
when source: String.t() | binary()
@spec load!(source, opts :: keyword()) :: bundle()
when source: String.t() | binary()
```
**Input.** `source` is either a filesystem path (string) or the raw bundle bytes (binary). Both forms exist because some workflows have the bundle in hand without ever writing it to disk.
**Options:**
| Opt | Type | Default | Notes |
|--------------|---------------|---------|------------------------------------------------------------------------------------------------|
| `:password` | `binary()` | `nil` | Required for encrypted bundles. Same lifecycle rules as PINs — the binary is consumed once and never persisted. Pass `nil` only for the rare unencrypted bundle. |
| `:max_chain` | `pos_integer()` | `8` | Hard cap on chain length. Bundles with more certificates fail loading. |
**Implementation backing.** OTP `:public_key` does not ship PKCS#12 ASN.1 schemas, so v1 of this loader shells out to the `openssl pkcs12` CLI (universally available on Linux / macOS / Windows-OpenSSL builds, well-tested across encryption variants). Passwords are passed via process env (`-password env:VAR`), never on the command line. A native Rust PKCS#12 parser via the existing Rustler bridge is on the roadmap as a follow-up; the public API is stable across the swap.
**Common usage patterns:**
```elixir
# 1. Load a CA bundle for trust policy use
{:ok, %{leaf: ca}} = Pkcs11ex.PKCS12.load("/etc/pkcs11ex/trust-anchor.p12", password: ca_pw)
# 2. Hybrid: cert chain from P12, signing key from a PKCS#11 token
{:ok, %{leaf: cert, chain: chain}} = Pkcs11ex.PKCS12.load(p12_bytes, password: pw)
# ...attach `chain` to JWS x5c, sign with PKCS#11 key
```
**Anti-pattern (rejected by the API):** there is no way to pass a `Pkcs11ex.PKCS12` bundle to `Pkcs11ex.sign_bytes/2` or any format adapter as a *signer*. Signers are PKCS#11 references. If you need to sign with a P12-resident key, either (a) provision the key into a SoftHSM slot via `mix pkcs11ex.import_p12` (§5) and sign via the normal PKCS#11 path, or (b) use `:public_key` directly outside this library.
---
## 4. Errors and Telemetry
### 4.1 Error taxonomy
All public functions return `{:error, term}` on failure. The term is one of:
- a bare atom, for predictable branchable failures;
- a tagged tuple `{atom, payload}`, when diagnostic data must travel (e.g., raw PKCS#11 return code);
- a `Pkcs11ex.Error` struct, for failures that benefit from contextual fields.
Configuration errors are **raised**, not returned: invalid configuration prevents boot.
| Reason | Class | Notes |
|-----------------------------------------|------------------|--------------------------------------------------------------------------------|
| `:slot_not_found` | slot/session | `slot_ref` not in `:slots`. |
| `:slot_not_logged_in` | slot/session | Token slot needs `Pkcs11ex.Slot.login/2` first. |
| `:reauthentication_required` | slot/session | Session expired; only emitted when `reauthentication: :fail`. |
| `:session_pool_exhausted` | slot/session | Cloud HSM pool saturated; raise `:session_pool_size` or check HSM RTT. |
| `{:driver_load_failed, posix_err}` | slot/session | `dlopen` failed. |
| `{:driver_pin_mismatch, expected, got}` | slot/session | Driver SHA-256 doesn't match `:driver_pins` entry. |
| `:key_not_found` | key/cert | No object matched `:label` / `:id` in the slot. |
| `:cert_not_found` | key/cert | No certificate matched in the slot for `x5c` population. |
| `:incompatible_alg` | key/cert | Requested `alg` is not in `compatible_key_types/0` for the resolved key. |
| `:no_signing_slot` | config/runtime | Sign called in a verify-only deployment. |
| `:no_signature` | PDF | `Pkcs11ex.PDF.verify/2` got a PDF that doesn't carry a `/Sig` dict. |
| `:multiple_signatures_unsupported_in_v1` | PDF | More than one `/Sig` dict in the PDF; multi-signature support is post-v1. |
| `:malformed_signature_contents` | PDF | `/Contents` couldn't be hex-decoded. |
| `:byte_range_out_of_bounds` | PDF | `/ByteRange` claimed bytes past the end of the file. |
| `:message_digest_mismatch` | PDF | `SHA-256` of the bytes covered by `/ByteRange` doesn't match the CMS `messageDigest` attribute — canonical tampered-byte signal. |
| `:incremental_update_after_signature` | PDF | Bytes exist beyond the signed range (`c + d < byte_size(pdf)`); fail-fast detection of the PAdES "append attack". |
| `{:malformed_pdf, atom}` | PDF | Reader-side structural failure: `:startxref_not_found`, `:xref_keyword_missing`, `:xref_subsection_header_invalid`, `:xref_entry_malformed`, `:trailer_keyword_missing`, `:xref_stream_unsupported`, `:prev_chain_cycle`, etc. |
| `{:writer, :existing_acroform_unsupported_in_v1}` | PDF | Base PDF already carries `/AcroForm`; v1 won't merge. |
| `{:writer, :placeholder_size_out_of_range}` | PDF | `:placeholder_size` outside `[256, 1 MiB]`. |
| `{:writer, :cms_der_too_large}` | PDF | The CMS DER won't fit the prepared `/Contents` placeholder — caller must raise `:placeholder_size`. |
| `{:malformed_xml, term}` | XML | `:xmerl_scan` failed to parse the input. |
| `:no_signature_element` | XML | `Pkcs11ex.XML.verify/2` got an XML document with no `<ds:Signature>`. |
| `:digest_mismatch` | XML | A `<ds:Reference>`'s recomputed digest differs from the embedded `<ds:DigestValue>`. Canonical tampered-byte signal. |
| `:xades_cert_digest_mismatch` | XML | XAdES `<CertDigest>` does not match `SHA-256(leaf_der)` from `<KeyInfo>`. |
| `:xades_issuer_serial_mismatch` | XML | XAdES `<IssuerSerialV2>` does not match the leaf cert's issuer + serial. |
| `{:c14n, atom \| reason}` | XML | `xmerl_c14n` failed (e.g. `:unsupported_canonicalization`). |
| `{:unsupported_signature_method, uri}` | XML | `<ds:SignatureMethod>` URI is not one we wired up (only RFC 4051 `rsa-sha256` and `sha256-rsa-MGF1` in v1). |
| `{:bt_failed, :pkcs11ex_audit_not_loaded}` | PDF / XML | `:tsa_url` was supplied but the optional `pkcs11ex_audit` dependency isn't loaded. Add it to your deps to enable B-T. |
| `{:bt_failed, {:tsa_status, n}}` | PDF / XML | TSA returned a non-granted PKIStatus (other than `granted (0)` / `grantedWithMods (1)`). |
| `{:bt_failed, {:tsa_http, reason}}` | PDF / XML | The TSA HTTP request failed (network error, timeout). The TST is not attached and no signature is produced. |
| `{:bt_failed, {:tsa_http_status, n}}` | PDF / XML | TSA returned a non-200 HTTP status. |
| `{:bt_failed, :missing_time_stamp_token}` | PDF / XML | TSA response decoded as PKIStatus granted but contained no TimeStampToken element. |
| `{:bt_failed, {:malformed_tsa_response, _}}` | PDF / XML | TSA response wasn't a parseable DER SEQUENCE. |
| `:malformed_jws` | JWS | Header not parseable, signature segment missing/extra. |
| `:missing_required_header` | JWS | One of `alg`, `crit`, `x5c` is absent. |
| `:b64_crit_violation` | JWS | `b64` is `false` but not in `crit`, or vice versa (RFC 7797 §6). |
| `:disallowed_alg` | JWS | Header `alg` not in the effective allowlist. |
| `:unsupported_alg` | JWS | Header `alg` is not registered in `:algorithms`. |
| `{:cms_codec, type, reason}` | CMS | OTP ASN.1 codec rejected an encode/decode for `type` (e.g. `:ContentInfo`, `:SignerInfo`, `:SignedAttributes`). Treat as malformed input. |
| `:missing_digest` | CMS | `Pkcs11ex.CMS.SignedAttributes.build/1` called without `:digest`. |
| `:invalid_digest` | CMS | `:digest` was not a binary (e.g. iolist, charlist). |
| `:empty_certificate_chain` | CMS | `Pkcs11ex.CMS.SignedData.build/3` called with `certificates: []`. |
| `{:unsupported_digest_algorithm, atom}` | CMS | Only `:sha256` is wired in v1. |
| `{:unsupported_signature_algorithm, atom}` | CMS | Only `:rsa_sha256` and `:rsa_pss_sha256` are wired in v1. |
| `:invalid_leaf_certificate` | CMS | First entry of `:certificates` was not parseable as X.509. |
| `:invalid_certificate_entry` | CMS | A non-leaf chain entry was not a `Pkcs11ex.X509` struct or DER binary. |
| `:not_signed_data` / `{:not_signed_data, oid}` | CMS | `Pkcs11ex.CMS.SignedData.parse/1` got a ContentInfo whose contentType is not `id-signedData`. |
| `:no_signer_info` | CMS | Parsed SignedData carried zero SignerInfos. |
| `:multiple_signer_info_unsupported_in_v1` | CMS | Parsed SignedData carried more than one SignerInfo. Multi-signer support is post-v1. |
| `:no_certificates` | CMS | Parsed SignedData omitted the `certificates` SET (degenerate signer-only CMS not supported in v1). |
| `:unsupported_certificate_choice` | CMS | Parsed SignedData embeds an attribute-cert / extended-cert / `other` CHOICE; only plain X.509 is supported. |
| `:invalid_embedded_certificate` | CMS | Embedded certificate failed `:public_key.pkix_decode_cert/2`. |
| `:leaf_certificate_not_found_in_chain` | CMS | SignerInfo `issuerAndSerialNumber` did not match any embedded certificate. |
| `:subject_key_identifier_unsupported_in_v1` | CMS | SignerInfo uses `subjectKeyIdentifier`; only `issuerAndSerialNumber` is supported in v1. |
| `{:missing_attribute, oid}` | CMS | Required signed attribute (e.g. `id-contentType`, `id-messageDigest`) absent. |
| `{:multi_value_attribute, oid}` | CMS | Signed attribute carried zero or >1 values; v1 expects exactly one. |
| `:unknown_signer` | trust policy | `Pkcs11ex.Policy.resolve/2` returned no allowlist match (§2.3.1 step 3). |
| `:hint_mismatch` | trust policy | Multiple identity hints in the header disagree (`x5c` vs `x5t#S256` vs `kid`). |
| `:untrusted_signer` | trust policy | `Pkcs11ex.Policy.validate/3` rejected the signer. |
| `:cert_expired` | trust policy | A cert in the chain is past `notAfter` (with `:max_clock_skew` applied). |
| `:cert_not_yet_valid` | trust policy | A cert in the chain is before `notBefore` (with `:max_clock_skew` applied). |
| `:chain_invalid` | trust policy | Chain validation failed at `:public_key.pkix_path_validation/3`. |
| `:incomplete_chain` | trust policy | The sender omitted required intermediates; `pkcs11ex` does not chase AIA. |
| `:cert_revoked` | trust policy | CRL or OCSP returned a revoked status for a cert in the chain. |
| `:crl_unavailable` | trust policy | `:crl_fetcher` failed or raised; revocation could not be checked. |
| `:ocsp_unavailable` | trust policy | `:ocsp_check` failed or raised; revocation could not be checked. |
| `:revocation_unknown` | trust policy | Revocation responder returned `:unknown` and `:revocation_unknown_policy` is `:abort` (default). |
| `{:policy_failed, reason}` | trust policy | Policy returned a custom failure. |
| `{:unexpected_subject, got, want}` | trust policy | `:expected_subject` opt set on `verify/3` and the resolved subject didn't match. |
| `:signature_invalid` | crypto | Mathematical verification failed. |
| `{:pkcs11_error, ck_rv}` | crypto | Raw PKCS#11 return code (e.g., `:CKR_PIN_INCORRECT`). |
| `:pin_required` | PIN | `pin_callback` returned `{:error, _}` or no callback configured. |
| `:pin_incorrect` | PIN | Driver returned `CKR_PIN_INCORRECT`. |
| `:pin_locked` | PIN | Driver returned `CKR_PIN_LOCKED` (vendor lockout). |
| `:p12_invalid` | PKCS#12 | Bundle bytes are malformed or not a valid PKCS#12 structure. |
| `:p12_password_incorrect` | PKCS#12 | Decryption failed; password mismatch or corrupted bundle. |
| `:p12_chain_too_long` | PKCS#12 | Chain exceeded `:max_chain`. |
| `{:p12_unsupported_kdf, oid}` | PKCS#12 | Bundle uses a KDF/cipher OID not supported by `:public_key` (rare; legacy). |
`Pkcs11ex.Error` exception:
```elixir
defexception [:reason, :path, :context]
```
Used for `!` variants and config errors. `:path` carries the config key path on config errors (e.g., `[:slots, :legal_proxy, :pin_callback]`).
### 4.2 Telemetry
Events are spans (`:start` / `:stop` / `:exception`), prefixed with the configured `:telemetry_prefix` (default `[:pkcs11ex]`).
| Event | When |
|--------------------------------------------------------|-----------------------------------------------------------------------------|
| `[:pkcs11ex, :sign, :start \| :stop \| :exception]` | Every `Pkcs11ex.sign_bytes/2` and every format-adapter `sign/2`. |
| `[:pkcs11ex, :verify, :start \| :stop \| :exception]` | Every `Pkcs11ex.verify_bytes/4` and every format-adapter `verify/3`. |
| `[:pkcs11ex, :digest, :start \| :stop]` | `Pkcs11ex.digest/2` and `digest_stream/2` (only when bytes total > 1 MiB). |
| `[:pkcs11ex, :session, :open]` | Slot session opened. |
| `[:pkcs11ex, :session, :close]` | Slot session closed (logout, timeout, shutdown). |
| `[:pkcs11ex, :session, :timeout]` | Session expired due to inactivity. |
| `[:pkcs11ex, :login, :start \| :stop \| :exception]` | Token login round-trip (PIN entry happens before `:start`). |
| `[:pkcs11ex, :driver, :load]` | PKCS#11 module loaded (one per process per `.so`). |
**Measurements.** `:duration` (native time, on `:stop` / `:exception`); `:system_time` (on `:start`); `:queue_time` (on `:stop` for sign/verify — time spent waiting for a session in the pool); `:byte_count` (on `:stop` for sign/verify/digest — bytes hashed or signed).
**Metadata.** `:slot_ref`, `:key_ref`, `:alg`, `:format` (`:jws` / `:pdf` / `:xml` / `:raw`), `:encoding_context` (`:jose` / `:der`), `:subject_id` (verify only), `:signer_subject_id` (chain_sign only), `:error_class`, `:error_reason`. Metadata never carries PIN, signature bytes, payload bytes, certificate private key fields, or any raw format envelope.
**Stable contract.** Event names, measurement keys, and metadata keys are part of the public API. New keys may be added; existing ones are not removed without a major-version bump.
---
## 5. Mix Tasks
Mix tasks are tooling, not runtime API. They live under `mix/tasks/` and are invoked from a developer or operator shell. They are **not** callable from request paths or runtime code.
### 5.1 `mix pkcs11ex.import_p12`
Imports the key and certificate from a PKCS#12 bundle into a write-permitted PKCS#11 slot. Intended for: SoftHSM provisioning in dev / CI; one-shot loading of taxpayer / legal-proxy certificates into file-backed tokens; fixture setup in test suites. **Not** intended for production HSMs (most reject software-key import by design; cloud HSMs do so categorically).
```bash
mix pkcs11ex.import_p12 \
--in legal_proxy.p12 \
--slot legal_proxy \
--label proxy-signing-key \
[--cert-label proxy-cert] \
[--id 0x01]
```
**Arguments:**
| Flag | Required | Notes |
|----------------|----------|---------------------------------------------------------------------------------------------|
| `--in` | yes | Path to the `.p12` / `.pfx` bundle. |
| `--slot` | yes | A configured slot reference (must exist in `:slots` and be write-permitted). |
| `--label` | yes | `CKA_LABEL` for the imported private key. |
| `--cert-label` | no | `CKA_LABEL` for the certificate. Defaults to `--label`. |
| `--id` | no | `CKA_ID` (hex) for both objects. Auto-generated if omitted. |
**Prompts (never logged):**
1. PKCS#12 bundle password.
2. Slot user PIN (required for the underlying `C_Login`).
Both are read via `IO.gets/2` with terminal echo disabled, scoped to the task process, and zeroized on the Rust side after use. A `--password-from-env <NAME>` and `--pin-from-env <NAME>` variant is provided for non-interactive CI; both are documented as suitable only for ephemeral CI runners.
**Failure modes:** the task surfaces the same error reasons as `Pkcs11ex.PKCS12.load/2` plus the standard PKCS#11 errors for `C_CreateObject`. A common one is `{:pkcs11_error, :CKR_ATTRIBUTE_READ_ONLY}` from production HSMs — a clear signal that this is the wrong tool for that target.
### 5.2 Future tasks (placeholder)
- `mix pkcs11ex.list_slots` — diagnostic listing of slots, tokens, and keys visible to the configured drivers.
- `mix pkcs11ex.driver_pin <path>` — compute the SHA-256 of a driver `.so` for `:driver_pins` config.
These are convenience wrappers and ship in Phase 2 alongside the SafeNet integration.