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 — with the two cross-checked so they 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
}

Inject an HTTP client and logger

WKD fetches use the *http.Client you supply; nil falls back to a stdlib client with a 30-second timeout. 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, // nil → stdlib 30s client
})
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).

Harden the WKD fetch

The default client — used 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)
        }
        return http.ErrUseLastResponse
    },
}

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

See also