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
afmpegcheckingffmpeg-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/cryptoandcloudflare/circl), and nothing else. - A tool signing via the
localbackend 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¶
- Implement a custom backend: do it.
- The trust model: what the verifier guarantees.
- Threat model, including how the library's own distribution integrity is established.