resolvers

package
v1.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 11, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

Documentation

Index

Constants

View Source
const DefaultContentTimeout = 30 * time.Second

DefaultContentTimeout bounds one content fetch. An agent is blocked on the call that triggered it, so a fetch that has not answered by now is more useful as a reported failure than as a hang.

View Source
const DefaultMaxContentBytes int64 = 8 << 20

DefaultMaxContentBytes caps one fetched body at 8 MiB. This is a memory bound on the fetching process, not a judgement about how large licensed content may be: the body is buffered whole and held for the life of the call, and a batch fetches one per item.

View Source
const WBADirectoryPath = "/.well-known/http-message-signatures-directory"

WBADirectoryPath is the well-known path a WBA identity directory is served at (Web Bot Auth; the identity half of the split — the commercial overlay stays in /.well-known/fora.json).

Variables

View Source
var (
	// ErrUnknownKey re-exports helpers.ErrUnknownKey so the active-key selectors'
	// "no candidate" verdict (empty / nil / all-malformed directory, or a bound that
	// scanned none) is checkable from this package — errors.Is-identical to the
	// sentinel WBAKeyResolver.Resolve surfaces for an unknown thumbprint — without a
	// caller importing helpers directly. It is the DISTINCT counterpart to
	// ErrKeyExpired: no candidate at all, versus a candidate that was out of window.
	ErrUnknownKey = helpers.ErrUnknownKey
	// ErrKeyRevoked signals the thumbprint is present in the directory host's
	// current revocation snapshot.
	ErrKeyRevoked = errors.New("resolvers: key revoked")
	// ErrKeyExpired signals the key exists but is outside its
	// [not_before, not_after) validity window.
	ErrKeyExpired = errors.New("resolvers: key outside validity window")
	// ErrDirectoryUnavailable signals the WBA directory could not be fetched or
	// parsed. It is deliberately errors.Is-DISTINCT from ErrUnknownKey: a
	// fail-closed composite must be able to halt on a directory outage rather
	// than fall through as if the key were merely unknown.
	ErrDirectoryUnavailable = errors.New("resolvers: WBA directory unavailable")
	// ErrRevocationUnevaluated signals that the key resolved, but its directory
	// declares a revocation_url whose snapshot has never been fetched (unreachable
	// or not host-anchored) — so revocation was NEVER EVALUATED, which is distinct
	// from "evaluated and not revoked". Only surfaced when RequireRevocation is
	// set; the default keeps the prior best-effort behavior. It lets a caller that
	// treats revocation as mandatory fail closed instead of trusting an
	// unevaluated key.
	ErrRevocationUnevaluated = errors.New("resolvers: key revocation unevaluated")
)

WBA-resolution sentinels. AUTHORITY CONTRACT: the resolver surfaces these verdicts RAW and makes no security decision on the caller's behalf. Whether a revoked/expired/unavailable verdict is authoritative (halt a composite resolver, fail closed) or maskable into ErrUnknownKey (fall through to the next delegate, e.g. a lazy-registration path) is the CALLER's decision — composite ordering and any masking live in the application.

View Source
var ErrEndpointRefused = errors.New("resolvers: well-known manifest advertises an unusable endpoint")

ErrEndpointRefused signals that a manifest WAS read and advertises an endpoint this resolver will not hand back: one on a host unrelated to the domain that served the manifest, or one carrying userinfo.

Distinct from ErrNoEndpoint and from a transport failure because it is a VERDICT — the Exchange answered, and the answer is not usable. A caller that classifies retryability reads this as final rather than as something to try again in a moment.

View Source
var ErrExchangeNotPermitted = errors.New("resolvers: exchange domain not permitted by policy")

ErrExchangeNotPermitted signals that the deployment's Allow overlay excluded this domain. It is raised BEFORE anything is dialled, so it says nothing about whether the Exchange exists or answers — only that this deployment declined to ask. Distinct from a transport failure because the remedy is a configuration change here, not a retry.

View Source
var ErrManifestNotExchange = errors.New("resolvers: well-known manifest does not describe an exchange")

ErrManifestNotExchange signals that the document served at the domain's well-known path describes some other role. Registration requirements are an Exchange's to publish, so a manifest claiming to be an agent, a broker or a publisher is refused rather than read for members it has no business carrying. A manifest naming no role at all is refused the same way: the field is required by the contract, and treating an absent role as "probably an Exchange" would make the check advisory.

View Source
var ErrManifestUnusable = errors.New("resolvers: well-known manifest cannot be used by this reader")

ErrManifestUnusable signals that the document arrived and this reader cannot use it. It is a VERDICT and not a failed read: the bytes were served, and the next attempt gets the same ones, so a caller told to retry retries forever.

This reader returns it for exactly ONE thing: a document whose version it cannot classify. That is the contract's own first question about this document, asked before any other member is read, and a layout no reader can classify is not a disappointment about one member — it is the whole document being unreadable for what a registration owes.

The other two ways a manifest can disappoint it stay deliberate non-errors: a member carrying a type the contract does not admit reads as ABSENT, because the projection is shared with the endpoint and key faces and one off-spec member must not fail a document the other two would have read; and a document that does not decode at all is a transport failure, because a proxy serving an error page under a 200 may well not be serving one on the next try.

It is ALSO the word for a reader stricter than this one, which is why the seam admits it at all. This seam is injectable, and a reader that validates the whole manifest against a schema — or applies a version rule narrower than this one — holds a refusal that is final and would otherwise have no way to declare it, and a refusal a caller cannot tell from an outage is one it retries against a third party's origin until something else stops it.

It is deliberately NOT the endpoint seam's ErrManifestVersionRefused. The two seams' vocabularies are disjoint: that one answers whether an endpoint may be dialled, this one whether a document can be read for what a registration owes. A version refusal reached through this seam therefore wears this word.

View Source
var ErrManifestVersionRefused = helpers.ErrManifestVersionRefused

ErrManifestVersionRefused re-exports helpers.ErrManifestVersionRefused — the verdict CheckWellKnownManifestVersion returns for a /.well-known/fora.json whose WellKnownManifest.ver this reader does not accept — so the endpoint resolver's refusal is checkable from this package beside ErrNoEndpoint and ErrEndpointRefused without a caller importing helpers directly. One sentinel under two names: an injected resolver that wraps either is classified the same way by the client tier.

Like ErrEndpointRefused it is a VERDICT — final, not a transport failure to retry — and it is never cached. The rule, and why the gate runs before any other member is read, are stated once on WellKnownManifest.ver in the proto.

View Source
var ErrNoEndpoint = errors.New("resolvers: well-known manifest has no endpoint")

ErrNoEndpoint signals that an Exchange's /.well-known/fora.json was fetched and decoded successfully but advertises no endpoint (WellKnownManifest.endpoint, proto field 12, absent). It is deliberately distinct from a transport or decode failure: the manifest exists, the Exchange simply has not closed the "defined-but-inert endpoint" gap. Callers can tell "Exchange unreachable" from "Exchange reachable but not self-advertising an endpoint".

Functions

func ActiveEd25519Key

func ActiveEd25519Key(directory *forav1.WBAFile, now time.Time, opts ...ActiveKeyScanOptions) (ed25519.PublicKey, error)

ActiveEd25519Key selects an identity's window-active Ed25519 signing key from a WBA directory BY DOCUMENT ORDER, complementing WBAKeyResolver (which matches a KNOWN thumbprint). It iterates directory.GetKeys() in document order and returns the FIRST key that passes ALL of: window-active ([not_before, not_after) half-open covers now, both bounds RFC 3339-parseable — a missing/unparseable bound makes the key inactive); kty=="OKP" && crv=="Ed25519" (matched CASE-INSENSITIVELY — a deliberate lenient SDK convention: RFC 7517/8037 specify the exact-case "OKP" / "Ed25519", but all three SDKs accept any case IDENTICALLY so a case-varying directory resolves the SAME key everywhere); and a present x that base64url-decodes to exactly 32 bytes. Any key failing any check is skipped and iteration continues.

SELECTION IS NOT NORMATIVE: the result is the first window-active key in document order, which is this SDK's deterministic tie-break — NOT "the current key". The protocol permits several simultaneously-active keys during overlap rotation and defines no "first", so callers must not read a normative meaning into the choice.

By default the WHOLE directory is scanned; pass ActiveKeyScanOptions.MaxScan to bound it. It returns (nil, ErrUnknownKey) when NO well-formed candidate key exists in the scanned range (empty / nil / all-malformed / bound scans none), or (nil, ErrKeyExpired) when a well-formed candidate existed but none was selectable (all out of window / all revoked). Byte-parity with the Python active_ed25519_key / TS activeEd25519Key oracles.

REVOCATION: this selector screens ONLY validity windows and key well-formedness — it does NOT consult any revocation channel. A key that was emergency-revoked but is still window-active in a (possibly CDN-cached) directory WILL be selected. A caller on a VERIFICATION path MUST NOT trust the result until it has screened the selected key's RFC 7638 thumbprint against the resolver's revoked-thumbprint set (WBAKeyResolver.Revoked / a revocation snapshot); otherwise adopting this selector defeats emergency revocation. Prefer ActiveEd25519KeyScreened, which folds that screen into selection. This bare form is for non-verification callers only.

func ActiveEd25519KeyScreened

func ActiveEd25519KeyScreened(directory *forav1.WBAFile, now time.Time, revoked func(thumbprint string) bool, opts ...ActiveKeyScanOptions) (ed25519.PublicKey, error)

ActiveEd25519KeyScreened is ActiveEd25519Key made REVOCATION-AWARE: it runs the same document-order window + well-formedness selection but ALSO skips any key whose RFC 7638 thumbprint `revoked` reports true, so a window-active-but-revoked key is never returned. It is the selector a VERIFICATION path adopts — folding the revoked-set screen the bare ActiveEd25519Key leaves to the caller into selection itself, so an emergency-revoked key still listed in a CDN-cached directory is passed over for the next active, non-revoked key. `revoked` is REQUIRED: a nil predicate screens nothing (equivalent to the bare selector and unsafe on a verification path). Pass one over the resolver's revoked-thumbprint set (e.g. WBAKeyResolver.Revoked) or, for a caller with no revocation channel, an explicit func(string) bool { return false } to make the waiver visible. The thumbprint is computed with helpers.Thumbprint (RFC 7638) — the SAME primitive WBAKeyResolver.Resolve keys on. Returns (nil, ErrUnknownKey) when no well-formed candidate existed, else (nil, ErrKeyExpired) when every candidate was out of window or revoked.

func ActiveEd25519KeyWithExpiry

func ActiveEd25519KeyWithExpiry(directory *forav1.WBAFile, now time.Time, opts ...ActiveKeyScanOptions) (ed25519.PublicKey, time.Time, error)

ActiveEd25519KeyWithExpiry runs the IDENTICAL document-order selection as ActiveEd25519Key but ALSO returns the selected key's not_after (its [not_before, not_after) upper bound). A downstream caller — e.g. an offer-key cache — clamps its cache TTL to min(now+ttl, not_after) with the returned time.Time so a cached key never outlives its validity window; the plain ActiveEd25519Key drops it. The not_after is guaranteed parseable because selection required it (wbaKeyActiveAt rejects a key whose window bounds do not parse). It returns the same (ErrUnknownKey / ErrKeyExpired) not-found sentinels ActiveEd25519Key surfaces when no key qualifies. Byte-parity with the Python active_ed25519_key_with_expiry / TS activeEd25519KeyWithExpiry oracles.

REVOCATION: like ActiveEd25519Key, this bare form does NOT consult revocation — it can return a window-active-but-revoked key. A VERIFICATION path MUST screen the result against the revoked-thumbprint set, or use the revocation-aware ActiveEd25519KeyWithExpiryScreened instead.

func ActiveEd25519KeyWithExpiryScreened

func ActiveEd25519KeyWithExpiryScreened(directory *forav1.WBAFile, now time.Time, revoked func(thumbprint string) bool, opts ...ActiveKeyScanOptions) (ed25519.PublicKey, time.Time, error)

ActiveEd25519KeyWithExpiryScreened is ActiveEd25519KeyWithExpiry made REVOCATION-AWARE (see ActiveEd25519KeyScreened): the same document-order selection, plus a skip of any key whose RFC 7638 thumbprint `revoked` reports true, returned with the selected key's not_after for cache-TTL clamping. This is the selector CachedOfferKeyResolver composes so a cached offer-signing key is both window-active AND not revoked. `revoked` is REQUIRED (see the screened plain face). Returns the same (ErrUnknownKey / ErrKeyExpired) not-found sentinels when no examined, non-revoked key qualifies.

func NewGuardedClientFromEnv

func NewGuardedClientFromEnv() *http.Client

NewGuardedClientFromEnv builds the single env-driven best-effort guarded HTTP client. SKIP_SSRF toggles the dial-time address pin; ALLOW_INSECURE toggles the https-only scheme guard. Both default to the guarded posture. The guarded (non-skip) path dials through a no-proxy transport, so a set HTTP(S)_PROXY cannot tunnel a private target past the dial-time address pin.

func NewGuardedTransport

func NewGuardedTransport(base *http.Transport) http.RoundTripper

NewGuardedTransport returns the guarded round-tripper — the scheme guard over the dial-time address pin — with base underneath it, honouring the same two env flags NewGuardedClientFromEnv reads. A nil base gets a fresh transport.

base exists so a caller can carry its OWN transport settings (a tuned connection pool, client certificates) UNDER the guard rather than instead of it. That distinction is the whole point: every consumer of this transport dials a host some other party named, so the guard is not a default a caller may replace. Handing over a base is the supported way to customise the dial; the only way to drop the guard is the deliberate, deployment-level SKIP_SSRF / ALLOW_INSECURE opt-out.

SSRFGuard clones what it is given, so the caller's value is never mutated, and drops the two settings that would route a dial around the address pin: a proxy, which would tunnel a private target past it, and a custom TLS dialer, which net/http prefers over the pinned dialer on https. TLSClientConfig is kept, so client certificates and a pinned root set are carried the way the pin allows.

func SSRFCheckRedirect

func SSRFCheckRedirect(req *http.Request, via []*http.Request) error

SSRFCheckRedirect is an http.Client.CheckRedirect that bounds redirect depth (redirectChainRefused, the shared cap) and re-vets every redirect target's scheme against the deny-by-default http/https allowlist (a scheme denylist is unwinnable — ftp, file, data, …). Redirect ADDRESS re-vetting is automatic when the client's transport is SSRFGuard-wrapped: each redirect makes a fresh guarded dial.

func SSRFGuard

func SSRFGuard(base *http.Transport) *http.Transport

SSRFGuard returns base with an SSRF-guarded dialer installed: it resolves every candidate address for the target host, refuses OUTRIGHT if ANY of them is a reserved / non-public address (anyAddrBlocked — fail-closed on a mixed set), and otherwise dials PINNED to a checked address literal. No re-resolution happens at connect, so a rebinding DNS cannot steer the connect onto a reserved address after the check. It is the INJECTABLE form of the WBA default client's dial-seam guard — drop it into any *http.Client so a fetch of a caller-supplied host cannot reach an internal target.

base==nil yields a fresh, minimal transport; a non-nil base is cloned so the caller's other transport settings are kept. In BOTH cases the guard forces Proxy=nil and clears any custom TLS dialer: each would route the dial around the address check, so each is mutually exclusive with the pin by construction. Pair it with SSRFCheckRedirect on the *http.Client to also vet redirect schemes and bound redirect depth.

A caller's TLSClientConfig — client certificates, a pinned root set — is kept and still applies; only the dialer itself is dropped.

func WBADirectoryURL

func WBADirectoryURL(scheme, host string) string

WBADirectoryURL builds the full WBA identity-directory URL from a scheme and an already-joined host: scheme://host + WBADirectoryPath. An empty scheme defaults to https. It is a PURE string function — the host arrives pre-formed (any port-join / IPv6 bracketing is the caller's concern, e.g. net.JoinHostPort at NewWBADirectoryFetcher), there is no env read, and no scheme-in-host detection. It is the cross-language oracle for the wba-url-vectors.json parity corpus that sdk/python wba_directory_url and sdk/ts wbaDirectoryURL replay; the base-carrying fetch path (fetchWBAFile) keeps appending the shared WBADirectoryPath const directly, so it is deliberately left untouched to preserve the single fetch+decode path and avoid a double-append.

Types

type ActiveKeyScanOptions

type ActiveKeyScanOptions struct {
	// MaxScan optionally bounds how many keys are examined in document order. nil
	// (the zero value) scans EVERY key — unbounded. A non-nil bound caps the scan at
	// max(0, *MaxScan) keys: 0 or negative scans none. When a non-nil positive bound
	// is exhausted without selecting a key AND the directory holds MORE keys than the
	// bound, the exhaustion is LOGGED (a valid key beyond the cap is unreachable), so a
	// bounded miss is never silently indistinguishable from a genuine "no active key".
	MaxScan *int
	// Logger receives the explicit-bound exhaustion warning (nil → slog.Default()).
	// Consulted ONLY on the bounded-exhaustion path; the unbounded default never logs.
	Logger *slog.Logger
}

ActiveKeyScanOptions tunes the document-order scan the ActiveEd25519Key* faces perform. The zero value — and omitting it entirely — scans the WHOLE directory, UNBOUNDED, which is the safe default: a silent cap makes a valid key at a high document position permanently unselectable AND indistinguishable from "no active key" (a DoS-by-directory-padding footgun), so a bound is now opt-IN, never the default.

It follows the file's options-struct convention (cf. WBAKeyResolverOptions), replacing the former variadic maxScan ...int that silently honored only its first argument. The faces still accept it variadically (opts ...ActiveKeyScanOptions) so the common unbounded call omits it entirely; at most ONE options value is honored (the first).

type CachedOfferKeyResolver

type CachedOfferKeyResolver struct {
	// contains filtered or unexported fields
}

CachedOfferKeyResolver resolves an exchange DOMAIN to that exchange's active Ed25519 offer-signing key, caching per domain with a TTL and folding revocation screening into selection. It is the SDK home of the per-domain-cache + active-key selection + not_after clamp that the Broker (offerkeys.Resolver) and the MCP shim (ExchangeOfferKeyCache) each re-implemented — so the clamp, the off-by-one-prone min(now+ttl, not_after), lives and is fixed once. It implements helpers.KeyResolver keyed by domain (Offer.exchange), the same shape core.Verifier resolves offer keys through, so a Broker or MCP client Verifier can inject the SAME cache.

func NewCachedOfferKeyResolver

func NewCachedOfferKeyResolver(cfg CachedOfferKeyResolverConfig) *CachedOfferKeyResolver

NewCachedOfferKeyResolver builds a resolver with defaults applied. It panics when cfg.Fetch is nil: a resolver with no directory source can never resolve anything, so the misconfiguration is caught at construction, not on the first Resolve.

func (*CachedOfferKeyResolver) Resolve

func (r *CachedOfferKeyResolver) Resolve(ctx context.Context, domain string) (ed25519.PublicKey, error)

Resolve implements helpers.KeyResolver: domain -> the exchange's active offer-signing key. A fresh cache entry short-circuits; a miss or TTL expiry fetches the domain's WBA directory, selects the first window-active, non-revoked key (ActiveEd25519KeyWithExpiryScreened), and caches it with an expiry clamped to min(now+ttl, not_after). Any fetch or selection failure propagates so the Verifier rejects the offer fail-closed.

type CachedOfferKeyResolverConfig

type CachedOfferKeyResolverConfig struct {
	// Fetch resolves a domain to its WBA directory. REQUIRED —
	// NewCachedOfferKeyResolver panics when it is nil.
	Fetch OfferDirectoryFetcher
	// TTL bounds the per-domain cache; <=0 uses defaultOfferKeyTTL. The stored
	// entry's expiry is clamped to min(now+TTL, key.not_after) so a key is never
	// served past its validity window.
	TTL time.Duration
	// Now is the cache-freshness + selection clock; nil uses time.Now. Tests inject.
	Now func() time.Time
	// Revoked screens a candidate offer-signing key by its RFC 7638 thumbprint: a
	// revoked key is skipped during selection so the resolver never serves a
	// window-active-but-revoked key on the verification path. nil screens nothing —
	// inject WBAKeyResolver.Revoked (or an equivalent revoked-set predicate) on any
	// verification path; leaving it nil is a visible waiver of emergency revocation.
	Revoked func(thumbprint string) bool
}

CachedOfferKeyResolverConfig wires a CachedOfferKeyResolver.

type Content

type Content struct {
	// URL is the signed delivery URL that was fetched, echoed back so a caller
	// correlating a batch does not have to keep its own map.
	URL string
	// MIMEType is the media type the edge served, parameters stripped.
	MIMEType string
	// Body is the fetched bytes.
	Body []byte
}

Content is one fetched resource.

type ContentFetchOptions

type ContentFetchOptions struct {
	// BaseTransport carries the caller's own transport settings — a tuned
	// connection pool, client certificates via TLSClientConfig — UNDERNEATH the
	// SSRF guard. It is never a replacement for the guard: a delivery URL names a
	// host chosen by another party, so the address pin and the https-only scheme
	// check are applied in every case. Nil means a fresh transport under the guard.
	//
	// A custom TLS dialer on this base is dropped rather than honoured: net/http
	// would prefer it over the pinned dialer on https and the address check would
	// never run. Configure TLS through TLSClientConfig, which is kept.
	//
	// The redirect policy is likewise a property of this profile rather than a
	// detail a caller supplies. A caller that could inject a whole client would be
	// asserting against its own policy instead of the one production runs.
	//
	// The only way to reach a private or plaintext endpoint is the deliberate,
	// deployment-level SKIP_SSRF / ALLOW_INSECURE opt-out, which is one decision
	// recorded in one place instead of a per-caller copy of it.
	BaseTransport *http.Transport
	// Timeout bounds one fetch, proof minting included. Defaults to
	// DefaultContentTimeout.
	Timeout time.Duration
	// MaxBytes caps one fetched body. Defaults to DefaultMaxContentBytes.
	MaxBytes int64
	// RequestID mints the value of the X-Request-ID correlation header stamped on
	// each delivery GET. Nil sends no header.
	//
	// It matters on THIS leg in particular. The RPC legs correlate through an
	// interceptor, which a plain GET never traverses, so without a hook here the
	// delivery fetch is the one leg carrying no id — and a delivery edge that mints
	// its own when the header is absent then logs a refusal under an id nothing
	// else knows. That is the leg where delivery failures are diagnosed.
	//
	// A func rather than a string because one fetcher serves many requests, and the
	// point of the header is that each carries its own id. It is `func() string`
	// rather than the transport tier's named RequestIDFunc so this package needs no
	// dependency on that tier for one alias; a named type is assignable here.
	RequestID func() string
}

ContentFetchOptions configures a ContentFetcher. Every field has a safe default.

type ContentFetcher

type ContentFetcher struct {
	// contains filtered or unexported fields
}

ContentFetcher fetches licensed content from a signed delivery URL. Build it with NewContentFetcher; it is safe for concurrent use.

func NewContentFetcher

func NewContentFetcher(opts ContentFetchOptions) *ContentFetcher

NewContentFetcher returns a fetcher whose zero-value options are safe defaults: the SSRF-guarded transport, a 30-second bound, an 8 MiB body cap, and redirects refused.

func (*ContentFetcher) Fetch

func (f *ContentFetcher) Fetch(ctx context.Context, signedURL string, signer ProofSigner) (Content, error)

Fetch retrieves the content at signedURL, presenting the proof of possession signer mints for it.

type FetchError

type FetchError struct {
	Failure FetchFailure
	Op      string
	Status  int    // HTTP status when the edge answered; 0 otherwise
	Reason  string // the edge's refusal token when it sent one
	Err     error
}

FetchError is this tier's canonical content-fetch error.

Reason exists so the edge's own refusal token survives as a value rather than being flattened into a sentence here. Those tokens are the difference between "the publisher refused us" and "our own key wiring is broken", and the layer that decides how a refusal reads can only tell them apart if the token arrives intact.

func (*FetchError) Error

func (e *FetchError) Error() string

func (*FetchError) ReasonOf

func (e *FetchError) ReasonOf() string

ReasonOf returns the most specific machine-readable reason available: the edge's own token when it sent one, otherwise the failure class.

func (*FetchError) Unwrap

func (e *FetchError) Unwrap() error

Unwrap keeps the cause matchable, so a caller can still reach a custody sentinel through errors.Is after the failure has been classified here.

type FetchFailure

type FetchFailure int

FetchFailure classifies why a content fetch failed, so a caller can branch on the class without reading the message.

const (
	// FetchUnknown is the zero value; it carries no classification.
	FetchUnknown FetchFailure = iota
	// FetchRefused is an edge that answered and said no. Reason carries the
	// edge's own token when it sent one.
	FetchRefused
	// FetchUnreachable is an edge that did not answer: dial failure, timeout, or
	// a refused redirect.
	FetchUnreachable
	// FetchTooLarge is a body past the configured cap. Deliberately distinct from
	// FetchRefused: the edge did nothing wrong and the URL is still good, so the
	// caller can retry with a larger budget.
	FetchTooLarge
	// FetchNotSignable is the proof failing to be produced. No request leaves on
	// this path — a custody backend that hangs lands here too, as a deadline,
	// because the timeout covers proof minting.
	FetchNotSignable
	// FetchMalformed is a delivery URL this client cannot sign faithfully.
	FetchMalformed
)

func (FetchFailure) String

func (f FetchFailure) String() string

String renders the failure class for logging and for the reason a caller sees when the edge supplied none.

type OfferDirectoryFetcher

type OfferDirectoryFetcher func(ctx context.Context, domain string) (*forav1.WBAFile, error)

OfferDirectoryFetcher fetches a domain's WBA identity directory. It is the injected IO seam of CachedOfferKeyResolver: the default (NewWBADirectoryFetcher) GETs scheme://domain[:port]/.well-known/http-message-signatures-directory through an SSRF-guarded client, but an application injects its own (wrapping its shared well-known fetch) and a test injects a directory table with no network.

func NewWBADirectoryFetcher

func NewWBADirectoryFetcher(client *http.Client, scheme, port string) OfferDirectoryFetcher

NewWBADirectoryFetcher returns the default OfferDirectoryFetcher: it GETs scheme://domain[:port]/.well-known/http-message-signatures-directory through client and protojson-decodes the WBAFile (any transport/status/decode failure wraps ErrDirectoryUnavailable). client nil installs the same SSRF-guarded default WBAKeyResolver uses — the exchange domain is signature-covered but attacker-influenceable, so the guard costs nothing. scheme empty → https; port empty → the scheme default.

type ProofSigner

type ProofSigner interface {
	// SignFetch returns the agent binding for a GET of targetURL. The URL is
	// passed verbatim because the proof covers it as an exact string.
	SignFetch(ctx context.Context, targetURL string) (helpers.AgentBinding, error)
}

ProofSigner mints the proof of possession for one bound fetch. It is an injected seam so this tier never holds key material: the caller composes it over whatever custody it uses, and decides the proof window.

type RegistrationRequirements

type RegistrationRequirements struct {
	// TermsDigest is the manifest's terms_digest, nil when the Exchange publishes
	// none. Copy it onto RegisterRequest.terms_digest unchanged: the request
	// signature covers the echo, and that echo is the durable record of which
	// terms revision the operator accepted.
	//
	// A pointer rather than a string because absent and empty are different
	// answers on the wire, and only the pointer can carry the difference.
	TermsDigest *string

	// Schema validates registration_data before anything is signed. It is nil in
	// TWO cases — the Exchange publishes none, and the Exchange publishes one this
	// SDK refuses — and Verdict is what tells them apart. Both are deliberately
	// the same VALUE, because a nil *helpers.RegistrationSchema reports no
	// failures and that is the behaviour the contract requires of a client in both
	// cases: a local check that cannot run must not become a local veto.
	Schema *helpers.RegistrationSchema

	// Verdict is the SDK's answer for the published schema. SchemaNotPublished is
	// the ordinary absent case; SchemaAccepted means Schema is usable; anything
	// else names why a published schema was refused, which is worth logging and is
	// never worth refusing the registration over.
	Verdict helpers.SchemaVerdict
}

RegistrationRequirements is what one Exchange asks of a registration: the terms revision that submitting one accepts, and the schema its registration_data must match.

Both members are optional in the contract, and their absence is a normal answer rather than a failure — an Exchange that publishes neither accepts registration data uninspected and records no terms acceptance.

type WBAKeyResolver

type WBAKeyResolver struct {
	// contains filtered or unexported fields
}

WBAKeyResolver resolves signing keys from WBA identity directories (WBADirectoryPath), matching by RFC 7638 thumbprint (the RFC 9421 keyid) — never by kid — and enforcing each key's [not_before, not_after) validity window plus the host's revocation snapshot. The directory host comes from the verified request's Signature-Agent value, threaded into ctx by the resolved verify entrypoints (SignatureAgentFromContext). Directories are cached per host with a TTL; revocation snapshots are primed on directory fetch and kept fresh by the Run poller. See the sentinel var block for the authority contract: revoked/expired/unavailable verdicts surface raw.

func NewWBAKeyResolver

func NewWBAKeyResolver(opts WBAKeyResolverOptions) *WBAKeyResolver

NewWBAKeyResolver constructs a WBAKeyResolver with defaults applied.

func (*WBAKeyResolver) Resolve

func (r *WBAKeyResolver) Resolve(ctx context.Context, keyID string) (ed25519.PublicKey, error)

Resolve implements KeyResolver: keyID is an RFC 7638 thumbprint; the directory to resolve it against comes from SignatureAgentFromContext. On an unknown thumbprint it performs one bounded directory re-fetch (rotation self-heal) before giving up. A thumbprint merely ABSENT from the directory (e.g. dropped during rotation) yields ErrUnknownKey, never ErrKeyRevoked: removal is not revocation — the authoritative revocation channel is the revocation list, so a composite resolver falls through on a removed key.

func (*WBAKeyResolver) Revoked

func (r *WBAKeyResolver) Revoked(keyID string) bool

Revoked reports whether keyID (an RFC 7638 thumbprint) is present in ANY host's fetched revocation snapshot, INDEPENDENT of whether that thumbprint appears in the corresponding WBA directory. Resolve gates a key only when the directory lists it (removal is not revocation); a key resolved from a source OTHER than the directory — e.g. a static bootstrap file — is invisible to that path, so a composite resolver can still admit a broker-revoked, directory- absent thumbprint. Revoked closes that gap: a caller that resolved a key elsewhere consults it to fail closed against the revocation channel. It returns false when no snapshot has been fetched (the snapshot is unavailable — the caller decides whether an unavailable revocation channel is itself fail-closed; this accessor reports membership only, never an outage).

func (*WBAKeyResolver) Run

func (r *WBAKeyResolver) Run(ctx context.Context)

Run drives the revocation poller until ctx is cancelled. Each tick refreshes every known host's revocation snapshot on a ±10%-jittered PollInterval. The caller owns the goroutine: `go r.Run(ctx)`; cancelling ctx is the stop.

type WBAKeyResolverOptions

type WBAKeyResolverOptions struct {
	// HTTP overrides the client used for directory and revocation GETs. When nil,
	// the resolver installs a safe-by-default SSRF-guarded client (see
	// newGuardedWBAClient): the directory host is derived from request input (the
	// Signature-Agent header) and fetched before the ed25519 check, so the default
	// refuses private/link-local/loopback targets. Inject a client only to REACH a
	// private directory (tests, on-prem) or to apply a custom dialer/timeout.
	HTTP *http.Client
	// TTL bounds how long a fetched directory is reused (≤0 → 1 hour).
	TTL time.Duration
	// PollInterval is the base revocation-poll cadence for Run (≤0 → 300s).
	PollInterval time.Duration
	// SyncDebounce bounds how often the unknown-thumbprint force-refresh may fire
	// per directory host (≤0 → 5s). It caps the outbound directory fetches an
	// unauthenticated caller can drive by presenting unknown thumbprints — the
	// resolver runs before the ed25519 check and the host is the caller-supplied
	// Signature-Agent. The TTL-cache refresh path is NOT gated by it; only the
	// self-heal force-fetch on an unknown thumbprint is.
	SyncDebounce time.Duration
	// Now overrides the clock for TTL and validity-window comparisons (nil →
	// time.Now). Tests inject.
	Now func() time.Time
	// After overrides the poll-tick timer source (nil → time.After). Tests
	// inject a deterministic clock.
	After func(time.Duration) <-chan time.Time
	// Scheme is applied when the Signature-Agent value carries no scheme
	// (empty → "https"). Tests inject "http" to drive an httptest server.
	Scheme string
	// RequireRevocation makes Resolve fail closed with ErrRevocationUnevaluated
	// when a key's directory declares a revocation_url but no snapshot has been
	// fetched (unreachable or not host-anchored) — i.e. revocation could not be
	// evaluated. Default false keeps the best-effort behavior (a declared-but-
	// unreachable revocation channel does not block resolution). Set it where a
	// revoked-key must never resolve even if the revocation channel is down.
	RequireRevocation bool
	// Logger receives best-effort revocation-refresh diagnostics (nil →
	// slog.Default()).
	Logger *slog.Logger
	// OnPollArmed and OnPollCycle are optional determinism seams for the Run
	// poller (nil in production). OnPollArmed fires each time the poller has
	// registered its next tick timer and is about to block; OnPollCycle fires
	// after each completed refresh. A deterministic-clock test waits for
	// OnPollArmed, advances the clock to fire the tick, then waits for
	// OnPollCycle — proving a poll boundary was crossed without sleeping.
	OnPollArmed func()
	OnPollCycle func()
}

WBAKeyResolverOptions tune a WBAKeyResolver. Zero values are safe defaults.

type WellKnownEndpointResolver

type WellKnownEndpointResolver struct {
	// contains filtered or unexported fields
}

WellKnownEndpointResolver resolves an Exchange domain to its self-advertised ExchangeService endpoint by fetching https://{host}/.well-known/fora.json and reading WellKnownManifest.endpoint. Unlike WellKnownKeyResolver (one fixed URL), it is HOST-KEYED: a Broker resolves an arbitrary, signature-covered Offer.exchange host per request, so the cache, TTL freshness, and single-flight coalescing are all per host. The pre-seeded registry is a TRUST overlay (the Allow hook), never the source of the endpoint — that is the Offer.exchange routing invariant: the endpoint always comes from the exchange's own manifest.

Because that host space is caller-influenced, neither per-host structure may grow without limit. The cache evicts least-recently-used at a fixed cap; coalescing is a singleflight.Group, which drops a host's entry as soon as its fetch completes and so holds nothing between calls.

func NewWellKnownEndpointResolver

func NewWellKnownEndpointResolver(opts WellKnownOptions) *WellKnownEndpointResolver

NewWellKnownEndpointResolver returns a host-keyed resolver. Zero-value options are safe defaults (https scheme, 5-minute TTL, real clock, SSRF-GUARDED client).

The endpoint host is REQUEST-DERIVED: a Broker resolves a per-request, signature-covered Offer.exchange host and this resolver fetches that host's /.well-known/fora.json. That is the same threat shape as the WBA directory fetch — a caller-influenced host reached over the network — so the default client is SSRF-guarded (NewGuardedClientFromEnv), NOT the unguarded http.DefaultClient. (The fixed-URL WellKnownKeyResolver stays on http.DefaultClient: its URL is an operator-chosen constant, not request-derived.) A deployment that must reach a private/loopback exchange (tests, on-prem) injects its own client via opts.HTTP or opts out via the SKIP_SSRF / ALLOW_INSECURE env flags.

func (*WellKnownEndpointResolver) ResolveEndpoint

func (r *WellKnownEndpointResolver) ResolveEndpoint(ctx context.Context, host string) (string, error)

ResolveEndpoint returns the ExchangeService endpoint host advertises in its well-known manifest. A fresh cache entry short-circuits; a miss or TTL expiry triggers a single coalesced per-host fetch. A host the Allow overlay rejects never reaches the network.

host must be a BARE hostname — no scheme, path, query or userinfo, though a port is fine. That is checked here rather than assumed, for the same reason vetAdvertisedEndpoint runs here: it is a property of building this URL, not of any one caller's plans for it.

type WellKnownKeyResolver

type WellKnownKeyResolver struct {
	// contains filtered or unexported fields
}

WellKnownKeyResolver fetches a JWKS-shaped document from one fixed, operator-chosen URL and caches resolved keys with a TTL. The document is a plain RFC 7517 JWK Set, not a WellKnownManifest — a manifest carries no keys member, and a JWK Set carries no manifest version, so the endpoint face's version gate does not apply here. The wire form is the RFC 7517 subset:

{"keys":[{"kid":"agent1.v1","kty":"OKP","crv":"Ed25519","x":"<base64url>"}]}

NOTE: kid-matched keys in fora.json are the pre-WBA-split identity shape. For party IDENTITY keys use WBAKeyResolver, which resolves the WBA directory (WBADirectoryPath) by RFC 7638 thumbprint with validity windows and revocation; this resolver remains for fixed-URL JWKS documents.

func NewWellKnownKeyResolver

func NewWellKnownKeyResolver(url string, opts WellKnownOptions) *WellKnownKeyResolver

NewWellKnownKeyResolver returns a resolver that lazily fetches the JWKS at url.

func (*WellKnownKeyResolver) Resolve

func (r *WellKnownKeyResolver) Resolve(ctx context.Context, keyID string) (ed25519.PublicKey, error)

Resolve implements helpers.KeyResolver. A cache hit short-circuits; a miss or TTL expiry triggers a single coalesced JWKS refresh.

type WellKnownOptions

type WellKnownOptions struct {
	// HTTP overrides the client used to fetch. The nil-default differs by resolver
	// because the URL's provenance differs: WellKnownKeyResolver fetches a
	// fixed, operator-chosen JWKS URL, so it defaults to the unguarded
	// http.DefaultClient; WellKnownEndpointResolver fetches a REQUEST-DERIVED
	// Offer.exchange host, so it defaults to the SSRF-guarded
	// NewGuardedClientFromEnv (same threat shape as the WBA directory fetch).
	HTTP *http.Client
	// TTL is how long a successful fetch is cached (≤0 → 5 minutes).
	TTL time.Duration
	// Now overrides the clock for cache freshness (nil → time.Now). Tests inject.
	Now func() time.Time
	// Allow, when set, gates which keyids (WellKnownKeyResolver) or hosts
	// (WellKnownEndpointResolver) may resolve (nil → allow all known). It is the
	// trust-allowlist seam: an Exchange host the application does not trust can be
	// rejected here before any fetch.
	Allow func(id string) bool
	// Scheme is the URL scheme the WellKnownEndpointResolver uses to build
	// {scheme}://{host}/.well-known/fora.json (empty → "https"). Tests inject
	// "http" to drive an httptest server. Unused by WellKnownKeyResolver, which
	// is constructed with a full URL.
	Scheme string
}

WellKnownOptions tune the resolver. Zero values are safe defaults.

type WellKnownRequirementsReader

type WellKnownRequirementsReader struct {
	// contains filtered or unexported fields
}

WellKnownRequirementsReader reads registration requirements out of an Exchange's own /.well-known/fora.json.

Every read is a fresh fetch, and that is the whole design

The protocol requires it in as many words: a registering client MUST read the terms digest from a freshly fetched manifest rather than a cached copy. A cached ENDPOINT is fine — a wrong one fails loudly — but a cached DIGEST is not, because a client cannot detect staleness locally, so a warm cache would make it echo a value the Exchange has already stopped accepting and retry the same refusal until the cache expired.

That is why this is a SEPARATE reader rather than a third face on WellKnownEndpointResolver. That resolver is built out of exactly the mechanism this value may not touch — a per-host LRU with a TTL, and single-flight coalescing on top of it — and it exposes no bypass. Adding a member to the document it decodes would satisfy the rule's letter at the one point it fails in practice: the first caller to reuse the SDK's own manifest path would be handed a digest minutes old with nothing in the API to warn them. A reader that holds no document cache leaves no cache slot to reuse, which is the same structural argument the report leg makes about configuration — the rule holds because there is nowhere for the wrong answer to come from, not because callers remember.

Reading the schema out of the same fresh bytes then costs nothing extra and removes a failure mode of its own: a stale local schema cannot refuse a payload the Exchange would have taken, because there is no stale local schema.

What is deliberately NOT held

Not even the compiled validator. Compiling an attacker-authored schema is bounded but not free, and an application registering repeatedly at the same Exchange may reasonably want to pay that once — but memoising it is a cache, which this tier does not do by default, and the useful key for it (the digest of the served bytes) is a property of a deployment's threat model rather than of the protocol. An application that wants it wraps this reader.

func NewWellKnownRequirementsReader

func NewWellKnownRequirementsReader(opts WellKnownOptions) *WellKnownRequirementsReader

NewWellKnownRequirementsReader returns a reader over WellKnownOptions. Zero-value options are safe defaults: https scheme and the SSRF-GUARDED client.

The domain is CALLER-NAMED — an agent registers at whichever Exchange it means to transact with, and that domain routinely arrives at runtime rather than from configuration — so this is the same threat shape as the endpoint resolver's fetch and takes the same guarded default, NOT http.DefaultClient. A deployment that must reach a private or loopback Exchange injects its own client via opts.HTTP or opts out through the SKIP_SSRF / ALLOW_INSECURE env flags.

opts.TTL and opts.Now are accepted and ignored: this reader caches nothing, so it has no freshness to compute. They are not errors, so one WellKnownOptions value can build every face in this package.

func (*WellKnownRequirementsReader) ResolveRegistrationRequirements

func (r *WellKnownRequirementsReader) ResolveRegistrationRequirements(
	ctx context.Context, exchange string,
) (RegistrationRequirements, error)

ResolveRegistrationRequirements fetches exchange's manifest and reports what it asks of a registration. The answer is never served from a cache; see the type doc for why that is the point rather than an omission.

Two refusals come before anything is dialled, and both are here rather than at the call site for the reason the endpoint resolver gives for its own: they are properties of building this URL and of this deployment's policy, not of any one caller's plans, and a second caller — or one that forgot — would otherwise fetch whatever domain an authenticated agent named.

The SHAPE predicate is IsBareDomain, the contract's rule, and not the routing tier's IsBareHost. The two are kept apart deliberately and this leg is where the difference bites: nothing upstream of here has run the contract's rule, and the URL below is built by concatenation, so a value carrying a path or userinfo would choose WHAT is fetched rather than merely where from. A trailing root dot, a leading or trailing hyphen, an underscore and a bracketed IPv6 literal are all usable hosts that the wire rule refuses.

The document's own version is read BEFORE any other member, which is the rule the contract states for every consumer of this document and not only for the face that reads an endpoint out of it. A layout this reader cannot classify does not get to supply a terms digest that a request signature then covers.

A refused schema is never an error. The verdict is returned alongside a nil Schema, because the contract requires a client that cannot check locally to send anyway and let the Exchange decide.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL