The trust model¶
This page explains why signing verifies releases the way it does. It is
background reading; for the how, see Configure trust
and Verify a release.
Two questions, separated¶
Verifying a release answers two distinct questions:
- Integrity — were the assets altered after publication?
- Authenticity — is the signer the party we actually trust?
signing separates these cleanly. A detached OpenPGP signature over a
checksums manifest answers integrity: the manifest lists a checksum per asset,
and one signature covers the whole manifest. Verifying the signature proves the
manifest is intact; comparing each asset's hash to its manifest line proves the
asset is intact. Authenticity is the harder question, and it is where the
composite trust model comes in.
Why a single key is not enough¶
If trust rested on one public key from one source, compromising that single source would be enough to forge a "valid" release. Embed the key in the binary and an attacker who controls the build pipeline can swap it. Fetch it from the network and an attacker who controls that endpoint (or its TLS) can swap it.
The model therefore draws trust from two independent anchors:
- Embedded keys — armoured public keys baked into the consuming binary at build time. Tamper-resistant once shipped, but only as trustworthy as the build that embedded them.
- WKD (Web Key Directory) — the publisher's key fetched over HTTPS from a well-known path derived from their email address. Independently controlled by whoever holds the publisher's domain and mail/web infrastructure.
These anchors fail to different adversaries. The embedded key is a build-time artefact; the WKD key is a live network resource. An attacker must subvert both within the same window to forge a release that the composite check accepts.
Cross-check: trust requires agreement¶
A CompositeResolver runs its child resolvers and compares the fingerprints
they return. Trust is granted only when the successful sources agree. If the
embedded key and the WKD key disagree, resolution aborts with
ErrKeyResolverMismatch — and this happens regardless of any other setting. A
disagreement is treated as a possible compromise, never reconciled or silently
resolved in favour of one side.
This is the core security property: the cross-check turns "compromise one source" into "compromise both sources, consistently, at once".
Agreement is exact and total: every successful resolver must return an identical fingerprint set. There is no quorum or majority mode, because a majority vote would let a single compromised anchor be outvoted rather than investigated.
Asking for two anchors does not always get you two¶
BuildKeyResolver with KeySource: "both" builds a cross-checking composite
only when both embedded keys and an external key email are supplied. Give it
one of the two and it degrades to that single anchor — no error, no warning,
and RequireExternalCrosscheck has no effect because no composite is built.
That is a reasonable default for a project part-way through adopting WKD, but
it means the cross-check is a property of your configuration rather than of
your KeySource value. Confirm which you have by reading resolver.Name(): a
real composite reports composite[embedded,wkd:openpgpkey.<domain>].
Fail-open vs fail-closed¶
Agreement is non-negotiable, but availability is a policy choice the consumer
makes through RequireAll (surfaced as RequireExternalCrosscheck in
KeyResolverConfig). It governs only what happens when a child resolver
errors — for example, a WKD fetch times out:
RequireAll: true(fail-closed) — any child error aborts withErrKeyResolverUnavailable. A WKD outage stops the release rather than letting trust quietly collapse back to the embedded key alone. Choose this when the external cross-check is a hard requirement.RequireAll: false(fail-open) — child errors are logged at Warn (via the optional*slog.Logger) and the surviving trust set is returned, provided at least one resolver succeeded. Choose this when WKD reinforces an already-trusted embedded key and you would rather degrade than block on a transient network failure.
The distinction is deliberate: a disagreement is a security signal and always aborts; an error is an availability event whose handling you tune to your threat tolerance.
Why RSA-3072¶
Every key entering a trust set must clear a minimum-strength policy: RSA keys
must be at least 3072 bits. The check runs at trust-set construction
(LoadTrustSet, and the resolvers that build on it), so a weak key fails fast —
during your build or startup — with ErrWeakKey, rather than silently providing
weaker-than-expected protection in the field.
3072-bit RSA is the floor for keys expected to remain trustworthy for years (broadly aligned with a 128-bit security level). Enforcing it at the boundary means the rest of the system can assume every trusted key meets the bar.
The policy accepts exactly two families: Ed25519, in either OpenPGP packet
form, at any size; and RSA at 3072 bits or more. Everything else is
rejected by name — DSA, ElGamal, ECDH, ECDSA, X25519, X448, Ed448,
RSA-encrypt-only — with a catch-all that also rejects any algorithm a future
go-crypto release introduces, so an unknown algorithm fails closed rather
than slipping into a trust set.
The same policy applies to both anchors, and to signing-capable subkeys as well as primary keys — otherwise a weak subkey could validate a signature under a strong primary.
Note the asymmetry between the two halves of the module: verify accepts
Ed25519 keys into a trust set, but openpgpkey cannot mint one — it produces
RSA OpenPGP keys only. An Ed25519 OpenPGP key generated elsewhere verifies here
without trouble.
What this model covers, and what it leaves to you¶
The OpenPGP path also enforces what the key material itself asserts. A revoked
primary key, a revoked identity, a revoked or expired signing subkey, an
expired key and an expired signature all fail verification — judged against the
current clock, and all surfacing as ErrSignatureInvalid.
What is not here is any policy around that: no revocation distribution, no
rotation workflow, no way to distrust a key short of changing what you embed. A
TrustSet is immutable once built. And the minisign artefact path has no
expiry or revocation at all, because the format cannot express either — see
Why there are two signature formats.
See also¶
- What this module does not do — the limits in one place.
- Threat model — what this design does and does not protect against.
- Dependency inversion — why signing backends are injected, keeping the verifier's dependency graph small.
- Configure trust — applying these choices in code.