Software-key implementations of SignCore.Signer for PKCS#12 (.p12 / .pfx) bundles and PKCS#8 PEM private keys.

Part of the pkcs11ex family. Pair with sign_core to sign PDFs, XMLs, or JWS payloads with filesystem-resident keys.

When to use

  • Vendor-issued PKCS#12 bundles (e.g., government tax-authority signing certs)
  • Cloud / server deployments where keys live as PEM files alongside certs
  • Dev/test environments where standing up a SoftHSM2 instance is overkill
  • Migration paths from legacy systems that ship .p12 instead of HSM access

If your production deployment runs against a hardware HSM, use pkcs11ex instead — keep soft_signer out of the dep tree to enforce "no software signing" at the package boundary.

PKCS#12

{:ok, signer} = SoftSigner.PKCS12.load("invoice-signer.p12", password: "...")

{:ok, signed_pdf} =
  SignCore.PDF.sign(pdf,
    signer: signer,
    alg: :PS256,
    x5c: SoftSigner.PKCS12.cert_chain(signer)   # P12 carries its own chain
  )

P12 decryption shells out to the openssl pkcs12 CLI — pure-Erlang PKCS#12 decode is fragile across vendor encodings, so we let openssl handle the bytes-to-PEM step. Sign math runs through :public_key.sign/3 with PSS padding for :PS256 or PKCS#1 v1.5 for :RS256.

Errors:

  • {:error, :bundle_not_found} — file path doesn't exist
  • {:error, {:openssl, "PKCS#12 password incorrect"}} — bad password
  • {:error, {:openssl, msg}} — other openssl failures

PKCS#8 PEM (key + separate cert)

{:ok, signer} =
  SoftSigner.PKCS8.load(
    key_path: "/keys/legal-proxy.key.pem",
    cert_path: "/keys/legal-proxy.cert.pem",
    password: "..."        # only if the PEM is encrypted
  )

{:ok, signed_pdf} =
  SignCore.PDF.sign(pdf,
    signer: signer,
    alg: :PS256,
    x5c: SoftSigner.PKCS8.cert_chain(signer)
  )

Supports:

  • Unencrypted PKCS#8 (-----BEGIN PRIVATE KEY-----)
  • Encrypted PKCS#8 (-----BEGIN ENCRYPTED PRIVATE KEY-----) with :password
  • PKCS#1 RSA (-----BEGIN RSA PRIVATE KEY-----) — the older format

You can supply key and cert from in-memory PEM strings instead of paths:

{:ok, signer} =
  SoftSigner.PKCS8.load(
    key_pem: System.get_env("SIGNING_KEY_PEM"),
    cert_pem: File.read!("/keys/legal-proxy.cert.pem")
  )

The cert PEM may contain a single certificate or a chain (leaf first, then intermediates). Errors:

  • {:error, :missing_key} / {:error, :missing_cert} — neither path nor pem supplied
  • {:error, {:pem_not_found, path}} — supplied path doesn't exist
  • {:error, :no_pem_entries} — file isn't valid PEM
  • {:error, :no_rsa_private_key} — PEM has no key entry
  • {:error, :no_cert_entries} — cert PEM has no Certificate entries
  • {:error, :password_required} — encrypted key, no :password supplied
  • {:error, :wrong_password} — encrypted key, bad password

Why two structs not one

Both SoftSigner.PKCS12 and SoftSigner.PKCS8 produce the same internal shape (%{rsa_key, leaf_der, chain_ders}) and share their defimpl SignCore.Signer logic. They're separate modules because the load contract differs:

  • PKCS#12 takes a single file path and a password. The cert chain comes for free.
  • PKCS#8 takes separate key + cert sources. The cert chain is whatever the caller supplies.

Keeping them separate makes the type signature of each load function unambiguous and avoids a Boolean opt to switch between modes.

Algorithm support

  • :PS256 — RSASSA-PSS, SHA-256, MGF1-SHA-256, salt length 32 (the JOSE convention)
  • :RS256 — RSASSA-PKCS1-v1_5, SHA-256

Adding ECDSA / Ed25519 is a small extension to the defimpl block; not implemented yet because the production keys we've encountered are all RSA-2048.

License

Apache 2.0.