RequestSeal.WebBotAuth (RequestSeal v0.5.2)

Copy Markdown View Source

Source-bound HTTP request signing and verification for draft-ietf-webbotauth-httpsig-protocol-00.

Caller clocks range from 0 through 253,402,300,799 Unix seconds.

verify/3 validates every web-bot-auth signature independently. Legacy string Signature-Agent fields reject. Caller trust resolves the (URL, key) pair before agent attribution. Nested signature coverage includes the inner input and every inner component. One replay claim follows validation of the entire envelope. Unresolved associations return :agent_unresolved with a bounded detail; they never imply invalid cryptography or populate a URL principal. Policy explicitly permits held keys when desired. No network or store is started.

sign/4 keeps private material behind the caller's arity-two signer and adds a dictionary Signature-Agent member without replacing other agent members. Authorization always remains unevaluated. See RequestSeal.Error for reasons.

Summary

Functions

Sign an explicit protocol-00 request with caller-owned signing authority.

Verify all selected request signatures, then commit replay once.

Functions

sign(message, spec, signer, opts \\ [])

Sign an explicit protocol-00 request with caller-owned signing authority.

Signing appends a Signature-Agent dictionary member for the label (or reuses an identical existing member), then Signature-Input and Signature members. The signature always covers @authority and "signature-agent";key="<label>", plus any components. Parameters are created, keyid (the key's SHA-256 JWK thumbprint), alg (HTTP algorithm names only), expires, nonce (when not nil), and tag="web-bot-auth".

Parameters

  • message - a RequestSeal.Message of kind :request.
  • spec - a map with exactly these required keys, plus optional :components:
    • :label - signature label (Structured Fields key).
    • :agent - %{location: url, type: type} with exactly those keys. type is :directory, :jwks_uri, or :cimd; location is an HTTPS URL of 1 to 2,048 printable ASCII bytes, without userinfo. A :directory location must be a bare lowercase origin, such as "https://crawler.example". If the message already has a member for the label, it must be identical.
    • :key - the signer's RequestSeal.PublicKey, compatible with :algorithm.
    • :algorithm - a RequestSeal.Crypto algorithm; "hmac-sha256" and {:jws, "HS256"} reject.
    • :created, :expires - integer Unix seconds, 0 to 999,999,999,999,999; expires is after created by at most 86,400 seconds.
    • :nonce - binary of 1 to 1,024 bytes, or nil.
    • :components - optional serialized Inner List of additional component identifiers, without parameters. Default "()".
  • signer - arity-two function receiving (algorithm, signature_base) and returning {:ok, signature} (1 to 1,024 bytes) or {:error, term}.
  • opts - keyword list.

Options

  • :field_schemas - Structured Fields schemas for covered fields, as in RequestSeal.sign/4. Default %{}.

Returns

  • {:ok, message} - the message with the appended fields.
  • {:error, %RequestSeal.Error{}} - established reasons: :invalid_options (option, spec, key, lifetime, signer form, or, from 0.5.0, an invalid :agent location), :invalid_message, :invalid_signature_agent (an invalid existing Signature-Agent field), :limit, :nested_coverage_incomplete (components name a signature that is absent or not covered completely), existing-dictionary reasons :missing_signature_input, :missing_signature, :label_mismatch, :duplicate_label, :duplicate_parameter, :invalid_signature_input, :invalid_signature_field, and from RequestSeal.sign/4 :label_in_use, :signature_base_failed, and :signer_failed. Other reasons: see RequestSeal.Error.

Examples

iex> {_, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> {:ok, key} = RequestSeal.Custody.public_key(handle)
iex> signer = fn "ed25519", bytes -> RequestSeal.Custody.sign(handle, bytes) end
iex> {:ok, message} = RequestSeal.Message.request("GET", "https://docs.example.org/guides/index.html", [], nil)
iex> spec = %{label: "crawler", agent: %{location: "https://crawler.example", type: :directory}, key: key, algorithm: "ed25519", created: 1_760_000_000, expires: 1_760_000_060, nonce: "crawl-0001"}
iex> {:ok, signed} = RequestSeal.WebBotAuth.sign(message, spec, signer)
iex> Enum.map(signed.fields, &{&1.name, &1.value}) |> hd()
{"Signature-Agent", ~s(crawler="https://crawler.example";type=directory)}
iex> Enum.map(signed.fields, & &1.name)
["Signature-Agent", "Signature-Input", "Signature"]
iex> {:error, error} = RequestSeal.WebBotAuth.sign(message, %{spec | expires: 1_760_090_000}, signer)
iex> error.reason
:invalid_options
iex> {:error, error} = RequestSeal.WebBotAuth.sign(message, %{spec | agent: %{location: "http://crawler.example", type: :directory}}, signer)
iex> {error.reason, error.layer}
{:invalid_options, :input}
iex> RequestSeal.Custody.Local.release(handle)
:ok

verify(message, policy, opts \\ [])

Verify all selected request signatures, then commit replay once.

A signature is selected when its tag parameter is "web-bot-auth". Each selected signature needs created and expires (lifetime positive and at most the policy max_lifetime), a keyid that is a SHA-256 JWK thumbprint, and coverage of @authority or @target-uri plus the policy components. When a Signature-Agent field is present, including an empty dictionary, it must have a member for the signature's label (otherwise :agent_unresolved with detail :missing_member), and the signature must cover "signature-agent";key="<label>". Published RFC 9421 test keys reject unless the policy sets test_keys: :allow. Each signature is then checked by RequestSeal.verify/3 with the resolved key, and nested signatures must be covered completely. A required replay claim runs once, after every selected signature passes; a derived replay retention end above 253,402,300,799 rejects with :retention_exceeded at :replay before commitment or storage.

Parameters

Options

Returns

  • {:ok, %RequestSeal.WebBotAuth.Verification{}} - signatures maps each selected label to a RequestSeal.Verification whose principal is %{kind: :agent, ...} with source provenance, or %{kind: :key, thumbprint: keyid} for a held key.
  • {:error, %RequestSeal.Error{}} - established reasons: :invalid_policy, :invalid_options, :invalid_message, :limit; signature fields: :missing_signature_input, :missing_signature, :label_mismatch, :duplicate_label, :duplicate_parameter, :invalid_signature_input, :invalid_signature_field, :no_web_bot_auth_signature, :invalid_signature_agent (detail :legacy_string, :invalid_dictionary, :duplicate_member, or :invalid_member); profile rules: :unexpected_signature, :invalid_keyid, :test_key_rejected, :missing_required_component, :nested_coverage_incomplete; freshness: :invalid_clock, :missing_created, :missing_expires, :lifetime_exceeded, :created_in_future, :expired, :too_old; key association: :agent_unresolved (detail :missing_member, :unsupported_type, :not_an_origin, :untrusted_agent, :source_mismatch, :unknown_key, :revoked_key, :source_unavailable, or :timeout); generic verification: :algorithm_not_permitted, :algorithm_mismatch, :signature_base_failed, :invalid_signature, :verifier_failed; content: :digest_not_covered, :digest_unsupported, :digest_mismatch, :body_unavailable; replay: :missing_replay_identifier, :retention_exceeded, :commitment_failed, :replayed, :store_unavailable, :store_timeout, :store_failed. Other reasons: see RequestSeal.Error.

Examples

A held public key, trusted by its thumbprint (no network):

iex> {_, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> {:ok, key} = RequestSeal.Custody.public_key(handle)
iex> {:ok, thumbprint} = RequestSeal.PublicKey.thumbprint(key)
iex> signer = fn "ed25519", bytes -> RequestSeal.Custody.sign(handle, bytes) end
iex> {:ok, message} = RequestSeal.Message.request("GET", "https://docs.example.org/guides/index.html", [], nil)
iex> spec = %{label: "crawler", agent: %{location: "https://crawler.example", type: :directory}, key: key, algorithm: "ed25519", created: 1_760_000_000, expires: 1_760_000_060, nonce: "crawl-0001"}
iex> {:ok, request} = RequestSeal.WebBotAuth.sign(message, spec, signer)
iex> held = fn %{keyid: ^thumbprint} -> {:ok, %{algorithm: "ed25519", key: key}}; _ -> :error end
iex> attrs = %{algorithms: ["ed25519"], agents: fn _ -> :error end, cache: nil, unresolved: {:held_keys, held}, freshness: %{clock: fn -> 1_760_000_010 end, skew: 5, max_age: 60}, content: :not_required, replay: :not_required}
iex> {:ok, policy} = RequestSeal.WebBotAuth.Policy.new(attrs)
iex> {:ok, envelope} = RequestSeal.WebBotAuth.verify(request, policy)
iex> envelope.signatures["crawler"].principal == %{kind: :key, thumbprint: thumbprint}
true
iex> {envelope.ignored, envelope.replay, envelope.authorization}
{[], :not_required, :not_evaluated}
iex> {:ok, untrusting} = RequestSeal.WebBotAuth.Policy.new(Map.delete(attrs, :unresolved))
iex> {:error, error} = RequestSeal.WebBotAuth.verify(request, untrusting)
iex> {error.reason, error.detail}
{:agent_unresolved, :untrusted_agent}
iex> {:error, error} = RequestSeal.WebBotAuth.verify(%{request | authority: "other.example"}, policy)
iex> error.reason
:invalid_signature
iex> RequestSeal.Custody.Local.release(handle)
:ok

Directory discovery: the trust callback maps the configured agent URL to a caller-configured RequestSeal.Discovery.Source, which a caller-started cache resolves.

Fragment (requires an HTTPS key directory peer at https://crawler.example; not executed here):

{:ok, cache} = RequestSeal.Discovery.Cache.start_link()

{:ok, source} =
  RequestSeal.Discovery.Source.new(%{type: :directory, location: "https://crawler.example"})

{:ok, policy} =
  RequestSeal.WebBotAuth.Policy.new(%{
    algorithms: ["ed25519"],
    cache: cache,
    agents: fn
      %{location: "https://crawler.example", type: :directory} -> {:ok, source}
      _ -> :error
    end,
    freshness: %{clock: fn -> System.system_time(:second) end, skew: 5, max_age: 60},
    content: :not_required,
    replay: :not_required
  })

{:ok, envelope} = RequestSeal.WebBotAuth.verify(request, policy, timeout: 5_000)
envelope.signatures["crawler"].principal.kind
#=> :agent

GenServer.stop(cache)