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 of 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, plus RegisterFactory and NewInstance for named instances). 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 follows from how Go builds. A dependency that no compiled package imports is never linked into the binary, and with module-graph pruning (Go 1.17 onwards) its own requirements need not even be loaded 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 links go-crypto and go/errors, plus what go-crypto itself needs (golang.org/x/crypto and cloudflare/circl), and nothing else.
  • 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 a 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 implements an optional interface that the front-end defines and type-asserts for; the library never does.

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 (where BuildKeyResolver turns nil into a stdlib client with a 30-second timeout), and *slog.Logger for logging (nil disables it). 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