Skip to content

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:

  1. Integrity — were the assets altered after publication?
  2. 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 with ErrKeyResolverUnavailable. 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