Explicit bounded HTTPS discovery of HTTP Message Signatures Directories, JWKS
and Client ID Metadata Documents. Construct a Discovery.Source from trusted
caller configuration. A message/key ID never defines a URL to fetch.
The caller starts SSL; the library starts no application, listener, store, timer or background refresh. A monitored worker enforces a single absolute deadline across DNS, TLS, redirects and CIMD's optional JWKS subresource, and terminates socket owners on caller death or timeout.
Source policy controls exact private-address exceptions, redirects and resource
limits. Every address in a DNS response is vetted; TLS connects by tuple and
checks its connected peer. Responses require HTTP 200 and the selected media
type; framing rejects ambiguous Content-Length/Transfer-Encoding. Trailer
signatures cannot establish a proof. Gzip is expanded under a separate bound.
Content-coding tokens are case-insensitive; x-gzip is accepted as gzip per
RFC 9110 Section 8.4.1.3. Unknown or stacked codings reject.
Required public JWK members produce RFC 7638/8037 SHA-256 key IDs. A supplied
kid must equal that value by default. Explicit Source.key_id: :directory
requires unique printable directory-assigned IDs while retaining thumbprints
for identity, revocation and cache removal; private material and malformed keys reject the
entire set. HTTP alg tokens map to the JOSE restriction held by PublicKey.
Keys whose use/operation restrictions forbid verification, and not-yet-valid,
expired or revoked keys, are not resolvable. Duplicate IDs reject among eligible
entries only. Directory sources require thumbprint mode. Directory
signatures use the existing RequestSeal.verify/3 contract to bind @authority;req, digest, created/expires, key ID and tag.
Directory proof means cryptographic possession, never principal attribution.
Proof selection uses the directory label or a directory-tagged member whose
key ID names a published directory key. Unrelated labels are ignored; missing
or invalid selected members reject.
resolver/2 bridges a snapshot or {cache, source} to Policy.key_resolver.
A key ID never selects a URL. RequestSeal.Discovery.Cache is an optional
caller-started bounded cache with explicit refresh/removal/invalidation; it
never serves stale keys. Named profiles enforce their own key/proof validity
clock independently of request-freshness policy.
Web Bot Auth protocol-00 Appendix C.4 states: "A verifier should not fetch the
directory for every request." Freshness follows Cache-Control: max-age before
Expires, accounting for Date and Age under RFC 9111. Without either
freshness field, the default is 300 seconds, caller-configurable via min_ttl.
Invalid or past Expires is immediately stale. max_ttl, key/signature expiry,
and no-cache/no-store still bound freshness.
Appendix C.5 states: "Negative cache entries should expire after no more than
five minutes." Accordingly, negative_ttl cannot exceed 300 seconds.
It also states: "Network failures, TLS failures, and 5xx responses should be
treated as transient unless local policy says otherwise." This adapter marks
5xx :unexpected_status retryable and 4xx non-retryable; retry scheduling and
decisions remain caller-owned.
Sources: Web Bot Auth protocol-00, CIMD-02, RFC 7638, RFC 8037, RFC 9110 Section 8.4.1.3, RFC 9111 Section 4.2.1, RFC 9112.
Summary
Functions
Fetch a configured source once under a cancellable absolute deadline, with no cache.
Build a RequestSeal.Policy key_resolver over a key set snapshot or a cache.
Functions
@spec fetch(RequestSeal.Discovery.Source.t(), keyword()) :: {:ok, RequestSeal.Discovery.KeySet.t()} | {:error, RequestSeal.Discovery.Error.t()}
Fetch a configured source once under a cancellable absolute deadline, with no cache.
Accepted response media types are
application/http-message-signatures-directory+json for :directory,
application/jwk-set+json or application/json for :jwks_uri, and
application/json for :cimd (and for a CIMD document's same-origin jwks_uri,
application/jwk-set+json or application/json).
Parameters
source- aRequestSeal.Discovery.SourcefromRequestSeal.Discovery.Source.new/1; a modified struct is revalidated.opts- keyword list without unknown or duplicate keys; see Options.
Options
:timeout- milliseconds, 1 through 300,000, capped by the source's:timeout. Default: the source's:timeout.:clock- arity-zero function returning Unix seconds from 0 through 253,402,300,799, used for freshness, key validity and directory signature times. Default: system time.
Returns
{:ok, %RequestSeal.Discovery.KeySet{}}- the eligible keys indexed by the source's key ID mode, withfetched_at,expires_at,revisionandproof(:signedor:unsignedfor directories,:not_applicableotherwise).{:error, %RequestSeal.Discovery.Error{}}- reasons this function can return::invalid_source,:invalid_options,:transport_unavailable,:resolution_failed,:address_denied,:connect_failed,:tls_failed,:deadline_exceeded,:redirect_denied(also a CIMDjwks_urion another origin),:redirect_limit,:unexpected_status,:unexpected_media_type,:limit,:invalid_response,:invalid_key_set,:client_id_mismatch,:ambiguous_key_source,:directory_unsigned,:directory_signature_invalid, and:source_unavailableif the fetch worker is killed. Meanings:RequestSeal.Discovery.Error.
Examples
iex> {:ok, source} =
...> RequestSeal.Discovery.Source.new(%{
...> type: :jwks_uri,
...> location: "https://keys.example/jwks.json"
...> })
iex> RequestSeal.Discovery.fetch(source, retries: 2)
{:error, %RequestSeal.Discovery.Error{reason: :invalid_options, retryable: false}}
iex> RequestSeal.Discovery.fetch(source, timeout: 0)
{:error, %RequestSeal.Discovery.Error{reason: :invalid_options, retryable: false}}Fragment (requires a started :ssl application and an HTTPS peer at
signer.example; not executed here):
{:ok, _} = Application.ensure_all_started(:ssl)
{:ok, source} =
RequestSeal.Discovery.Source.new(%{type: :directory, location: "https://signer.example"})
{:ok, %RequestSeal.Discovery.KeySet{proof: :signed} = set} =
RequestSeal.Discovery.fetch(source, timeout: 2_000)
@spec resolver( RequestSeal.Discovery.KeySet.t() | {GenServer.server(), RequestSeal.Discovery.Source.t()}, keyword() ) :: (map() -> term())
Build a RequestSeal.Policy key_resolver over a key set snapshot or a cache.
The returned function takes the resolver request map, uses only its :keyid
(labels and tags are ignored), and returns {:ok, %{key: key, algorithm: algorithm}}
or :error. A RequestSeal.Discovery.KeySet
snapshot is resolved with RequestSeal.Discovery.KeySet.lookup/3 at system
wall time. A {cache, source} pair is resolved with
RequestSeal.Discovery.Cache.resolve/4, whose freshness and key expiry use the
cache's configured clock, preserving the caller's explicit clock policy; a
miss or expiry fetches under the source's timeout.
Parameters
input- aRequestSeal.Discovery.KeySet, or{cache, source}with a runningRequestSeal.Discovery.Cacheand aRequestSeal.Discovery.Source.opts- keyword list; see Options.
Options
:algorithms- required, and the only accepted option: 1 to 64 algorithm identifiers accepted by a policy (the names inRequestSeal.Crypto.algorithms/0or{:jws, name}tuples). The first entry compatible with the key is selected.
Returns
- A function of arity one. Invalid options do not raise: the function then
returns
:errorfor every request. It also returns:errorfor an unknown, stale, revoked or incompatible key, a cache error, or a request without:keyid.
Examples
A key set normally comes from fetch/2 or RequestSeal.Discovery.Cache.refresh/2;
this example builds one from a locally generated Ed25519 key so it runs offline.
iex> {public, _private} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, key} =
...> RequestSeal.PublicKey.import(
...> %{"kty" => "OKP", "crv" => "Ed25519", "x" => Base.url_encode64(public, padding: false)},
...> :jwk
...> )
iex> {:ok, thumbprint} = RequestSeal.PublicKey.thumbprint(key)
iex> {:ok, source} =
...> RequestSeal.Discovery.Source.new(%{type: :jwks_uri, location: "https://keys.example/jwks.json"})
iex> entry = %RequestSeal.Discovery.Resolution{
...> key: key,
...> key_id: thumbprint,
...> thumbprint: thumbprint,
...> algorithm: "ed25519",
...> origin: "https://keys.example",
...> source_type: :jwks_uri,
...> location: source.location,
...> fetched_at: 0,
...> expires_at: 253_402_300_799,
...> proof: :not_applicable
...> }
iex> set = %RequestSeal.Discovery.KeySet{
...> source: source,
...> origin: "https://keys.example",
...> keys: %{thumbprint => entry},
...> fetched_at: 0,
...> expires_at: 253_402_300_799,
...> revision: "example-revision",
...> proof: :not_applicable
...> }
iex> resolve = RequestSeal.Discovery.resolver(set, algorithms: ["ed25519"])
iex> {:ok, %{algorithm: "ed25519", key: ^key}} = resolve.(%{keyid: thumbprint, label: "sig1"})
iex> resolve.(%{keyid: "unknown"})
:error
iex> RequestSeal.Discovery.resolver(set, algorithms: []).(%{keyid: thumbprint})
:error