Skip to content

Dependency inversion

This page explains why signing defines backends as an interface and lets consumers inject them, rather than bundling cloud SDKs. The practical recipe is Implement a custom backend.

The problem with bundling backends

Release signing in the real world happens against AWS KMS, GCP KMS, Azure Key Vault, or a PKCS#11 HSM. The obvious design would be to ship integrations for all of them in the library. That choice is poisonous for a light library:

  • The AWS SDK alone is enormous, and GCP, Azure, and HSM stacks each add more.
  • A consumer that only ever verifies releases (the common case — think afmpeg checking ffmpeg-wasi's assets) would inherit every cloud SDK transitively, despite never signing anything.
  • Security review and supply-chain surface scale with the dependency graph. A verifier should not have to audit three cloud SDKs it never calls.

Inverting the dependency

So the library inverts the relationship. It defines a minimal contract:

type Backend interface {
    Name() string
    NewSigner(ctx context.Context, keyID string) (crypto.Signer, error)
}

and a global registry (Register, Get, Names). The consumer implements the backend in its own module, imports the heavy SDK there, and registers the backend from init(). The library depends on crypto.Signer — a standard library interface — and never on any provider SDK. Control is inverted: the high-level policy (how to sign and verify) lives in the library; the low-level detail (which KMS, which SDK) lives in the consumer and is plugged in.

A light local (PEM) backend ships as the default reference implementation, so the common signing path works out of the box without any cloud dependency.

Why this keeps consumer graphs tiny

This is not just tidiness — it is a property of Go's module graph. A dependency that is never imported by any compiled package is pruned: it is not linked into the binary, and with module-graph pruning it need not even be downloaded to build. Because the AWS/GCP/Azure SDKs are imported only by the consumer's backend package, they enter a binary's graph only when that binary blank-imports that backend.

The consequences:

  • A verify-only tool depends on go-crypto and cockroachdb/errors, full stop.
  • A tool signing via the local backend adds no cloud SDK.
  • A tool signing via AWS KMS pulls in the AWS SDK — but only because it chose to import its own KMS backend, not because the library forced it.

signing.Names() reflects exactly this: it reports the backends a given binary actually compiled in, which is the set of backend packages that binary chose to import.

Activate by side effect

Backends are turned on by blank import — the same pattern as net/http/pprof, the image/* decoders, and database drivers. Importing the package for its init() side effect registers the backend; resolving it by name (signing.Get) dispatches to it. This keeps activation explicit and local to main, and keeps the core free of any flag or plugin machinery — a backend that needs CLI flags declares an optional interface that the front-end type-asserts, never the library.

The stdlib-seam corollary

The same instinct shapes the rest of the API. Where a heavier design would invent its own abstractions, signing accepts standard-library types at its seams: crypto.Signer for keys, *http.Client for WKD fetches (nil → a stdlib 30s-timeout client), and *slog.Logger for logging (nil → disabled). Each seam is a place a consumer can inject a hardened or instrumented implementation without the library taking on a dependency to support it.

See also