Skip to content

Configure trust

Choose how a TrustSet is sourced: from keys embedded in your binary, from a key published over WKD, or from both, cross-checked so that the two must agree. The high-level entry point is verify.BuildKeyResolver; for full control you can compose a CompositeResolver directly.

For the reasoning behind cross-checking independent sources, read The trust model.

Sources at a glance

KeySource Anchors used Network?
"embedded" keys baked into the binary no
"external" a key fetched via WKD for an email yes
"both" (default) embedded and WKD, cross-checked, when both are configured yes

Confirm you actually got a cross-check

"both" requests two anchors; it does not guarantee them. If you supply embedded keys but no ExternalKeyEmail (or an email but no keys), BuildKeyResolver returns a single-source resolver, with no cross-check, no error and no warning. RequireExternalCrosscheck: true does not change that, because the flag is only read when a composite is genuinely constructed.

Check the resolver's name before relying on the guarantee. A real composite reports composite[embedded,wkd:openpgpkey.<domain>]; a degraded one reports embedded or wkd:openpgpkey.<domain> on its own.

resolver, err := verify.BuildKeyResolver(cfg, embeddedKey)
if err != nil {
    return err
}
if !strings.HasPrefix(resolver.Name(), "composite[") {
    return fmt.Errorf("cross-check not configured: resolver is %q", resolver.Name())
}

The full matrix of inputs to outcomes is in Configuration fields.

Build a resolver from config

BuildKeyResolver takes a KeyResolverConfig plus any embedded armoured keys, and returns a KeyResolver. Call Resolve to obtain a verified TrustSet:

resolver, err := verify.BuildKeyResolver(verify.KeyResolverConfig{
    KeySource:                 "both", // "embedded" | "external" | "both"
    ExternalKeyEmail:          "release@example.test",
    RequireExternalCrosscheck: true,
}, embeddedKey)
if err != nil {
    return err
}

trust, err := resolver.Resolve(ctx)
if err != nil {
    return err // see the error cases below
}
// use trust.VerifyManifestSignature(...) as usual

KeySource defaults to "both" when empty. ExternalKeyEmail is required for "external", and enables the WKD leg of "both" when set.

Fail-closed vs fail-open

RequireExternalCrosscheck maps to CompositeResolver.RequireAll and controls how a child resolver error (such as a WKD fetch failure) is handled:

  • true (fail-closed). Any child error aborts with verify.ErrKeyResolverUnavailable. Use this when the external cross-check is mandatory: a WKD outage must stop the release, not silently downgrade trust to the embedded key alone.
  • false (fail-open). Child errors are logged at Warn (if you injected a *slog.Logger) and the surviving trust set is returned, as long as at least one resolver succeeded. Use this when WKD is a best-effort reinforcement of an already-trusted embedded key.

A fingerprint disagreement between sources is different: it always aborts with verify.ErrKeyResolverMismatch, regardless of RequireExternalCrosscheck. Sources that disagree are never reconciled; that is the whole point of the cross-check.

trust, err := resolver.Resolve(ctx)
switch {
case errors.Is(err, verify.ErrKeyResolverMismatch):
    // embedded and WKD keys disagree — treat as a possible compromise
    return err
case errors.Is(err, verify.ErrKeyResolverUnavailable):
    // no usable source (fail-closed, or every source failed)
    return err
case err != nil:
    return err
}

Keep a key in the trust set without letting it sign

Some keys belong in the trust set without being allowed to sign a release. The usual case is an offline rotation-authority key: it is published beside the release keys, so the embedded and WKD sets must both include it to agree. But a release signed with it should be refused.

Name such keys in AnchorOnlyFingerprints:

resolver, err := verify.BuildKeyResolver(verify.KeyResolverConfig{
    ExternalKeyEmail:       "release@example.test",
    AnchorOnlyFingerprints: []string{"2B26658409047ED08B56CEBDCF5B8DBB5D9F19C2"},
}, releaseKey, rotationAuthorityKey)

The key still appears in TrustSet.Fingerprints, so the cross-check compares the same sets as before. Only signing changes: a manifest signed by an anchor-only key fails VerifyManifestSignature with verify.ErrSignatureInvalid.

Inject an HTTP client and logger

BuildKeyResolver fetches WKD keys with the *http.Client you supply, and falls back to a stdlib client with a 30-second timeout when it is nil. The *slog.Logger receives fail-open warnings; nil disables logging entirely.

resolver, err := verify.BuildKeyResolver(verify.KeyResolverConfig{
    KeySource:                 "both",
    ExternalKeyEmail:          "release@example.test",
    RequireExternalCrosscheck: false, // fail-open: warnings go to Logger
    HTTPClient:                hardenedClient, // custom TLS / proxy / instrumentation
    Logger:                    slog.New(handler),
}, embeddedKey)

Compose resolvers directly

For arrangements BuildKeyResolver does not cover (multiple WKD emails, a bespoke KeyResolver, or precise control over which resolvers must agree), build a CompositeResolver yourself. Any type implementing the KeyResolver interface (Name() string, Resolve(ctx) (*TrustSet, error)) can be a child.

embedded := verify.NewEmbeddedResolver(embeddedKey)

wkd, err := verify.NewWKDResolver(verify.WKDResolverConfig{
    Email:      "release@example.test",
    HTTPClient: hardenedClient, // required: nil is an error here
})
if err != nil {
    return err
}

composite := &verify.CompositeResolver{
    Resolvers:  []verify.KeyResolver{embedded, wkd},
    RequireAll: true, // fail-closed on a child error
    Logger:     slog.New(handler),
}

trust, err := composite.Resolve(ctx)

The same semantics apply: agreement is mandatory (mismatch always aborts), and RequireAll governs only the response to a child error. To inspect the WKD URLs a resolver will query, use verify.WKDURLs(email).

Unlike BuildKeyResolver, NewWKDResolver substitutes no default client: a nil HTTPClient returns wkd: HTTPClient is required.

Harden the WKD fetch

The default client that BuildKeyResolver builds when HTTPClient is nil sets a 30-second timeout and nothing else. In particular it applies no redirect policy, so a WKD host that answers with a redirect to an http:// URL will be followed. The HTTPS check covers the initial URL only. If that matters, inject a client that refuses it:

client := &http.Client{
    Timeout: 30 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        if req.URL.Scheme != "https" {
            return fmt.Errorf("refusing redirect to %s", req.URL.Scheme)
        }
        if len(via) >= 10 {
            return fmt.Errorf("stopped after %d redirects", len(via))
        }
        return nil
    },
}

Returning nil follows an HTTPS redirect as usual. A custom CheckRedirect replaces Go's built-in ten-redirect limit, hence the len(via) check.

The same client is the place to raise a TLS floor or pin certificates. Nothing in this module does either for you.

See also