Skip to content

The trust model

Two questions, two mechanisms

Fetching an artefact raises two separate questions, and they need separate answers.

Who published these bytes? A signature answers that. It says a holder of a particular private key vouched for this content.

Are these the bytes they published? A digest answers that. It says the content has not changed since it was measured.

Neither is sufficient alone, and the reasons are not symmetrical.

A digest pinned in source — the obvious first design, and the one krites started with — cannot be rotated or revoked, and says nothing about origin. A table of hashes in a Go file means every new artefact version is a code change in every consumer, and a compromised publisher who can also send a merge request can update the table alongside the artefact.

A signature alone leaves a gap between the manifest and the file. The signature covers checksums.txt. If nothing checks the artefact against that manifest, an attacker who can serve one file but not rewrite the signed manifest gets exactly the opening they need — and the failure is silent, because a wrong artefact loads and runs.

So: the signature establishes the publisher, and the digest ties the bytes to what that publisher actually signed.

Order matters

The signature over the manifest is verified before the artefact is fetched, and before the manifest is parsed.

Parsing first would mean reasoning about the contents of a document whose provenance nobody has established — and the parser is the first thing an attacker reaches. Fetching first would mean a caller could end up holding unverified bytes because a later step failed.

The consequence is a property you can rely on: at the moment Resolve returns an error, nothing untrusted has been written to disk.

Two anchors, and why the second one is not optional

trust.Estate requires two sources to agree:

  • the key embedded in the calling binary, and
  • the key served over WKD at openpgpkey.phpboyscout.uk.

Each covers the other's weakness. An embedded key is immune to network attack but frozen at build time — it cannot be revoked, and a build from before a key rotation trusts a key that should no longer be trusted. A WKD key is current but fetched over the network, so it is exactly what an attacker in the path would like to control.

Requiring both means an attacker must compromise the build and the DNS/TLS path within the same window.

Why a downloaded key proves nothing

The channel publishes release.asc beside the artefacts. This package never fetches it, and there is deliberately no constant naming it.

A key retrieved from the same place as the signature it validates is not a trust anchor. Whoever can serve you a malicious artefact and a matching signature can serve you the key that verifies both. The anchor has to come from somewhere the attacker does not control — compiled into your binary, and published at an independent host.

Fail closed, and why that is not configurable

go/signing's CompositeResolver defaults to fail-open: if the WKD fetch fails, it logs a warning and proceeds on the embedded key alone.

That is the right default for self-update. A tool behind a broken corporate proxy should still be able to patch itself; refusing to update because a key server is unreachable makes the tool less safe, not more.

It is the wrong default here. Artefact resolution has no such pressure to continue — an unreachable WKD endpoint means try again later. Left fail-open, an attacker who can make openpgpkey.phpboyscout.uk unreachable (a captive portal, a poisoned resolver, a firewall rule) silently downgrades dual verification to single, and the only evidence is a log line nobody reads.

So trust.Estate sets the crosscheck requirement, and does not expose an option to relax it. Existing as a package is most of its value: the decision is made once, rather than by every consumer writing their own resolver configuration — and getting it wrong by omission, since the unsafe version is the default.

What is deliberately still trusted

Being explicit about the edges:

  • Your build. A key embedded in a compromised binary is a key an attacker chose. Nothing here defends against that; it is the anchor everything else hangs from.
  • The cache, after the fact. Bytes are verified on the way in, not on the way out. Anyone who can write to your cache directory can substitute an artefact. See Choose a cache location.
  • The publisher's judgement. A signature proves the estate published an artefact. It does not prove the artefact is good, or that the upstream project it mirrors was not itself compromised. Approving an artefact is a human review against artifacts.yaml.
  • The name. A signature proves who published a filename, not that the filename is safe to use as a path. Hence sanitisation before anything touches the filesystem.

Why an interface rather than a hard dependency

Verifier is a one-method interface, and the estate implementation lives in a subpackage rather than the root.

Partly practical: it keeps the root package free of third-party dependencies, so importing artifacts does not link an OpenPGP implementation, and it makes the resolver testable without a key.

Partly a boundary worth having. What counts as an acceptable signature is a deployment's decision. A consumer running a private mirror signed by their own key should not have to fork the resolver to do it. But the default path is one call, and building your own Verifier is a visible, deliberate act — which is what a decision about trust anchors should be.