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 withverify.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¶
- The trust model: composite agreement, fail-open and fail-closed, the strength policy.
- Configuration fields: every field, its default, and what happens when it is wrong.
- Errors: the sentinels matched above.
- Verify a release: using the resulting
TrustSet. - The full API and runnable
Exampletests: pkg.go.dev/gitlab.com/phpboyscout/go/signing/verify.