Skip to content

KeyProvider contract and adapters

This page covers the outbound port every signing back-end implements, the four adapters that ship today, and the HSM wiring operators reach for in production. For the rotation lifecycle that sits above this port see Deployment scoping and rotation; for the entry point and ubiquitous language see the index.

The KeyProvider port

KeyProvider is the outbound port every signing back-end implements. Implementations are the only code in the system that ever touches private key material; the plexsphere-signer process speaks to them exclusively through this interface so the "private halves never leave the custodian process" invariant stays enforceable at the type level.

go
type KeyProvider interface {
    Generate(ctx context.Context, scope Scope, keyID KeyID) (handleID string, publicKey [32]byte, err error)
    Sign(ctx context.Context, handleID string, digest [sha512.Size]byte) (signature []byte, err error)
    PublicKey(ctx context.Context, handleID string) (publicKey [32]byte, err error)
    Retire(ctx context.Context, handleID string) error
}

Every method returns domain sentinels — ErrKeyNotFound, ErrProviderUnavailable, ErrKeyRetired — so application services never couple to PKCS #11 return codes or cloud-provider SDK error types. Retire is idempotent: retiring an already-retired handle must not error. The port's full contract lives in internal/signing/ports.go.

Adapter: software (dev-only)

internal/signing/providers/software/ is a dev-only crypto/ed25519 provider backed by a single 32-byte seed mounted from a Kubernetes Secret. Per-KeyID Ed25519 material is deterministically derived via HKDF-SHA512 keyed by the seed with an info string of scope.String() + "|" + keyID.String(). The constructor emits exactly one slog.Warn line at boot carrying a dev-only substring; the /livez handler surfaces a matching warning flag so operators never learn after the fact that a production cluster was running the software provider.

Because HKDF is pure, every process holding the same seed derives byte-identical keypairs under the same (scope, KeyID) pair. Sign and PublicKey rely on that property: when the provider sees a handle ID it has not previously Generate()-d — for example a rotation opened by a sibling Pod such as the cmd/signer-e2e-demo fixture's rotate-open job — it parses the (scope, KeyID) back out of the software:<scope>|<key_id> handle layout and re-runs the HKDF expansion on demand. An unparseable handle still surfaces ErrKeyNotFound, which keeps the port-level contract "no such handle" indistinguishable from "wrong provider address" at the call site.

Retire stays strict — an unknown handle returns ErrKeyNotFound even when the layout would parse — because the retired flag on the provider is a LOCAL defence-in-depth check. The authoritative "retired" signal lives on signing_key.state, which SignerService.guardSignState inspects before the provider is consulted; keeping Retire's strict contract lets operator tooling distinguish "never existed" from "already retired" inside one process without conflating the two.

Adapter: PKCS #11 (production HSM)

internal/signing/providers/pkcs11/ is the reference production trust anchor: a thin wrapper over github.com/miekg/pkcs11 targeting the PKCS #11 v3.0 EDDSA mechanism set. Compiled only under the pkcs11 build tag; a sibling stub file with the inverse !pkcs11 tag keeps the package importable without dragging the CGO binding into the default build. Operator wiring — vendor module paths, slot numbering, PIN handling, per-key CKA_LABEL — is documented in the HSM backing section below.

Adapter: cloud KMS (AWS / GCP / Azure)

Three sibling adapters plug the signer into managed key services, each gated by its own build tag so the default build never pulls the cloud SDK graphs in:

A binary built without the matching tag refuses to start when --key-provider=<name> selects the adapter: the CLI returns an instructive error asking the operator to rebuild with the right tag. The fall-through is deliberate — a silent swap to the software provider would turn a production wiring mistake into an invisible dev-mode deployment.

Adapter: testdouble (unit + integration fixture)

internal/signing/providers/testdouble/ is an in-memory, concurrency-safe KeyProvider for unit tests, integration tests, and Chainsaw E2E fixtures. It records every Generate / Sign / PublicKey / Retire invocation on an ordered Call log and exposes InjectFailure / ClearFailure so tests exercise the ErrProviderUnavailable and ErrRotationInProgress paths deterministically. The double is never wired into a running plexsphere binary — no production package imports it.

HSM backing

The internal/signing/providers/pkcs11/ adapter is a reference implementation against the PKCS #11 v3.0 API, guarded by the pkcs11 build tag.

Vendor slot wiring

Production operators wire the following parameters through the Signer binary's --pkcs11-module, --pkcs11-slot, --pkcs11-pin, and --pkcs11-label flags:

  • ModulePath — absolute path to the vendor's PKCS #11 .so (SoftHSM dev: /usr/lib/softhsm/libsofthsm2.so; AWS CloudHSM: /opt/cloudhsm/lib/libcloudhsm_pkcs11.so; Thales Luna: vendor-provided).
  • Slot — the numeric slot the vendor assigns to the plexsphere application. One slot per Domain; SaaS deployments create one slot per Domain at onboarding.
  • Pin — the user-level PIN. The PIN is injected through a Kubernetes Secret mount, NEVER through a command-line flag in production; the flag is a fall-through for ephemeral local dev only.
  • Label — the CKA_LABEL prefix under which plexsphere stores keypairs in the slot. The full label format is `<Label>:<scope>:<key_id>` so an operator with pkcs11-tool can grep for all plexsphere-owned objects.

Supported mechanisms

Only modules advertising CKM_EDDSA (PKCS #11 v3.0 §6.25) are supported. Run pkcs11-tool --module <ModulePath> --list-mechanisms and confirm CKM_EDDSA is listed before provisioning the slot.

CI gate

Smoke tests live behind //go:build pkcs11 && integration_pkcs11 and are triggered by the integration-pkcs11 label on a PR. The CI job boots SoftHSM in a container and runs go test -tags=pkcs11,integration_pkcs11 ./internal/signing/providers/pkcs11/.... Locally, operators can reproduce with:

shell
PKCS11_MODULE=/usr/lib/softhsm/libsofthsm2.so \
  go test -tags=pkcs11,integration_pkcs11 \
  ./internal/signing/providers/pkcs11/...