README.md

# soft_signer

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

Part of the [`pkcs11ex` family](https://github.com/utaladriz/pkcs11ex). Pair with [`sign_core`](https://hex.pm/packages/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`](https://hex.pm/packages/pkcs11ex) instead — keep `soft_signer` out of the dep tree to enforce "no software signing" at the package boundary.

## PKCS#12

```elixir
{: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)

```elixir
{: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:

```elixir
{: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.