Skip to content

Configuration fields

Every exported configuration struct in the module, field by field. There is no config file and no environment-variable reading anywhere in this module: each struct is populated by the calling program.

verify.KeyResolverConfig — where trust comes from

Passed to verify.BuildKeyResolver together with zero or more armoured embedded keys.

Field Type Default when zero Notes
KeySource string "both" Accepts "embedded", "external", "both". Lower-cased and trimmed before matching, so " Both " works. Anything else is an error.
ExternalKeyEmail string none The release address whose WKD directory holds the external key. Required for "external". A whitespace-only value counts as unset.
RequireExternalCrosscheck bool false Becomes CompositeResolver.RequireAll. true makes a child-resolver error fatal.
HTTPClient *http.Client &http.Client{Timeout: 30 * time.Second} Used for WKD fetches. The default sets a timeout and nothing else — no redirect policy, no TLS floor.
Logger *slog.Logger none — warnings are discarded Only ever written to by CompositeResolver when RequireAll is false. Nothing else in the module logs.

What BuildKeyResolver returns for each combination

KeySource alone does not determine the result. What you get also depends on whether you supplied embedded keys and an email:

KeySource Embedded keys ExternalKeyEmail Result
"embedded" yes any Embedded resolver
"embedded" no any Error: key_source=embedded requires embedded keys (WithEmbeddedKeys)
"external" any set WKD resolver
"external" any unset Error: key_source=external requires update.external_key_email
"both" yes set CompositeResolver over both — the cross-check runs
"both" yes unset Embedded resolver only. No cross-check, no error, no warning.
"both" no set WKD resolver only. No cross-check, no error, no warning.
"both" no unset Error: key_source=both requires embedded keys, an external key email, or both
anything else any any Error: unknown key_source %q (want embedded, external, or both)

The two bold rows are the trap. "both" is a request, not a guarantee: with only one anchor configured it degrades to that single anchor silently, and the resulting resolver behaves exactly like a single-source one. Setting RequireExternalCrosscheck: true does not rescue it, because that flag is only read when a CompositeResolver is actually constructed.

To confirm the cross-check is really running, check the resolver's name before you rely on it. A composite reports composite[embedded,wkd:openpgpkey.<domain>]; a degraded one reports just embedded or wkd:openpgpkey.<domain>.

resolver, err := verify.BuildKeyResolver(cfg, embeddedKey)
if err != nil {
    return err
}
if !strings.HasPrefix(resolver.Name(), "composite[") {
    return fmt.Errorf("expected a cross-checked resolver, got %q", resolver.Name())
}

Embedded keys are parsed and strength-checked inside BuildKeyResolver, so a weak or malformed embedded key surfaces there as an error rather than later.

verify.WKDResolverConfig — a single WKD anchor

Passed to verify.NewWKDResolver when you compose resolvers by hand.

Field Type Required Notes
Email string yes Error wkd: email is required when empty.
HTTPClient *http.Client yes Error wkd: HTTPClient is required when nil. Unlike BuildKeyResolver, this constructor does not substitute a default.
URLOverride string no Test hook. Replaces the scheme and host of both derived URLs while keeping the WKD path and query.

Email must contain an @ that is neither the first nor the last character. The domain is lower-cased and then validated: it must be non-empty, contain no / or \, contain no .., have no leading or trailing dot, and consist only of letters, digits, - and .. Anything else is rejected before a request is made, so a hostile address cannot reshape the request target.

The local part is not lower-cased for the ?l= query parameter — it is passed through url.QueryEscape as given — but it is lower-cased before hashing into the hu/ filename, as the WKD specification requires.

verify.CompositeResolver — cross-checking several anchors

A struct with exported fields rather than a constructor, so build it directly.

Field Type Default when zero Notes
Resolvers []KeyResolver none Empty gives composite: no resolvers configured at Resolve time. Children run concurrently.
RequireAll bool false true: any child error aborts with ErrKeyResolverUnavailable. false: child errors are logged and skipped, provided at least one child succeeded.
Logger *slog.Logger none Receives one Warn per failed child, and only when RequireAll is false.

Fingerprint agreement between the children that succeeded is checked regardless of RequireAll, and a disagreement always aborts with ErrKeyResolverMismatch. Agreement is exact and total — the fingerprint sets must be identical. There is no quorum or m-of-n mode.

With one successful child there is nothing to compare, so no cross-check runs. That is how a fail-open composite behaves during a WKD outage.

openpgpkey.Options — writing a WKD tree

Passed to openpgpkey.WriteWKDTree.

Field Type Default when zero Notes
Method Method MethodAdvanced MethodAdvanced ("advanced") serves from openpgpkey.<domain> and includes an extra <domain>/ path segment. MethodDirect ("direct") serves from the domain itself. Any other value is an error.
SubmissionAddress string not written When set it must parse as an RFC 5322 address, or WriteWKDTree fails.

The domain argument is validated with the same rules as WKDResolverConfig.Email's domain, which is what keeps a pathological value from escaping outDir/.well-known/openpgpkey.

openpgpkey.Entry — one address and its keys

Field Type Required Notes
Email string yes Must parse with net/mail. Only the local part is hashed into the hu/ filename.
Keys [][]byte yes Each element may be ASCII-armoured or already binary; the format is detected per key. An Entry with no keys is an error.

WriteWKDTree needs at least one Entry. Several entries sharing an Email merge into one published file, so passing the same address twice is equivalent to passing one entry holding all the keys.

minisign.Options — comments and timestamp

Passed to minisign.Sign and minisign.SignDigest.

Field Type Default when zero Notes
UntrustedComment string minisign public key <KEYID> Not covered by any signature. Never make a trust decision on it.
TrustedComment string built from Filename, Time and Project Covered by the global signature. Setting it makes Filename, Project and Time irrelevant.
Filename string empty base name Only filepath.Base of it is used. Ignored when TrustedComment is set.
Project string omitted When set, appends project:<Project> and key:<KEYID> to the default trusted comment. Ignored when TrustedComment is set.
Time time.Time time.Now() Zero means the signature embeds the current time and is not reproducible. Pin it from the release tag or SOURCE_DATE_EPOCH for byte-identical re-signing.

Both comments are rejected if they contain a carriage return or newline (ErrInvalidComment) or exceed their length limit (ErrCommentTooLong). The limits are in Limits, defaults and tunables.

Function arguments worth spelling out

A few arguments are not obvious from their names.

openpgpkey.ArmoredPublicKey(signer, name, email, creationTime) bakes creationTime into both the key packet and its self-signature. Two calls with the same signer and a different creationTime produce different fingerprints, so keep it stable: a rotation is a new key, not a new timestamp on the old one.

openpgpkey.DetachSign(signer, publicKey, data, sigCreationTime) takes the armoured public key you previously minted, and rebuilds the OpenPGP entity from it so the fingerprint in the signature matches what verifiers expect. The signer's RSA public half is compared against the one inside publicKey, and a mismatch is refused. sigCreationTime is the signature's own creation-time subpacket, independent of the key's; it is truncated to whole seconds because that is all an OpenPGP v4 signature packet stores.

minisign.SignDigest(signer, digest, opts) requires digest to be exactly BLAKE2b-512 output (64 bytes). Any other length is refused rather than signed.