Optional finalized Finch translation and signing. No pool is started or owned.
Requests preserve same-name header order and Finch's actual request target.
No stream is silently read: a streaming body stays unavailable unless the
caller explicitly retains it under a byte bound. Signing refuses unavailable
body coverage, host, request req/tr components, conflicting digests, or
unfaithful required fields. Cover @authority instead of host.
Response helpers keep trailers separate from headers and associate the exact
supplied request. Streaming callers withhold their own effects until the
returned verification succeeds. Unknown or duplicate options reject. Errors use
RequestSeal.Adapter.Error with adapter :finch and attempt 1; no framework
exception text is retained.
Summary
Functions
Translates a built Finch request into a lossless RequestSeal.Message without signing it.
Translates a Finch response and the request that produced it into a response RequestSeal.Message.
Signs a built Finch request and returns it with signature headers appended.
Verifies one labeled signature on a Finch response, bound to the exact request supplied.
Types
@type spec() :: RequestSeal.signing_spec() | %{ label: binary(), components: binary(), algorithm: RequestSeal.Crypto.algorithm(), parameters: %{ created: boolean(), expires_in: pos_integer() | nil, nonce: :random | nil, keyid: binary() | nil, tag: binary() | nil, alg: boolean() }, digest: [binary()] | nil, field_schemas: map() }
Functions
@spec request_message(Finch.Request.t(), keyword()) :: {:ok, RequestSeal.Message.t()} | {:error, RequestSeal.Adapter.Error.t()}
Translates a built Finch request into a lossless RequestSeal.Message without signing it.
The message uses the request's method and scheme, Finch's request path as an
origin-form target, and an authority that lowercases the host and omits the
default port (80 for HTTP, 443 for HTTPS). Headers keep their order and
spelling. Trailers are :unavailable. The request is not sent.
Parameters
request- aFinch.RequestfromFinch.build/4. Its scheme must be:httpor:https, with a binary host and a port in 1..65,535.opts- keyword list of the options below.
Options
:body- how the request body becomes message bytes. Default:as_is.:as_is- nil becomes empty retained bytes; iodata becomes retained bytes of at most 16 MiB (16,777,216 bytes); a{:stream, enumerable}body is not read and the message body is:unavailable.{:retain, max}-maxis a nonnegative integer byte bound. A stream is enumerated once and its bytes are retained; iodata is retained. A body longer thanmaxrejects. A stream given as a bare function is not enumerated and rejects.
Returns
{:ok, %RequestSeal.Message{kind: :request}}- the translated message.{:error, %RequestSeal.Adapter.Error{stage: :sign}}- reasons::invalid_options(unknown, duplicate, or malformed option),:invalid_request(not a Finch request; unsupported scheme, host, or port; non-list headers; unsupported body shape; or a valueRequestSeal.Message.new/1rejects, kept assource),:body_unavailable(a bare function stream under{:retain, max}),:limit(body over the bound), and:signing_failed(an exception while enumerating the body or translating). Meanings:RequestSeal.Adapter.Error.
Examples
iex> request =
...> Finch.build(
...> :post,
...> "https://telemetry.example.com:8443/ingest?batch=7",
...> [{"content-type", "application/json"}],
...> ~s({"cpu":0.42})
...> )
iex> {:ok, message} = RequestSeal.Finch.request_message(request)
iex> {message.method, message.authority, message.raw_target, message.body.state}
{"POST", "telemetry.example.com:8443", "/ingest?batch=7", :retained}
iex> streamed =
...> Finch.build(:post, "https://telemetry.example.com/ingest", [], {:stream, ["a", "b"]})
iex> {:ok, message} = RequestSeal.Finch.request_message(streamed)
iex> message.body.state
:unavailable
iex> {:ok, message} = RequestSeal.Finch.request_message(streamed, body: {:retain, 16})
iex> message.body.bytes
"ab"
iex> {:error, error} = RequestSeal.Finch.request_message(streamed, body: {:retain, 1})
iex> error.reason
:limit
@spec response_message(Finch.Response.t(), Finch.Request.t(), keyword()) :: {:ok, RequestSeal.Message.t()} | {:error, RequestSeal.Adapter.Error.t()}
Translates a Finch response and the request that produced it into a response RequestSeal.Message.
Status, headers, and trailers keep their order; trailers stay in their own
section. The related request is translated as by request_message/2 with
body: :as_is, so req components resolve against exactly that request.
Parameters
response- aFinch.Responsewith integer status and list headers and trailers.request- theFinch.Requestthat produced the response, with the same requirements as inrequest_message/2.opts- keyword list of the options below.
Options
:body-:retained(default) uses the response's binary body as retained bytes.:consumedmarks the body as consumed, for callers that feed a streaming body into their ownRequestSeal.Digeststate.
Returns
{:ok, %RequestSeal.Message{kind: :response}}- the translated message.{:error, %RequestSeal.Adapter.Error{stage: :verify}}- reasons::invalid_options(unknown, duplicate, or unsupported option value),:invalid_request(not a Finch response, a request that cannot be translated, or a valueRequestSeal.Message.new/1rejects),:body_unavailable(:retainedwith a non-binary response body),:limit(a related request body over 16 MiB), and:response_rejected(an unexpected exception). Meanings:RequestSeal.Adapter.Error.
Examples
iex> request = Finch.build(:get, "https://docs.example.com/guides/1")
iex> response = %Finch.Response{
...> status: 200,
...> headers: [{"content-type", "text/plain"}],
...> body: "guide text"
...> }
iex> {:ok, message} = RequestSeal.Finch.response_message(response, request)
iex> {message.kind, message.status, message.body.bytes, message.related_request.raw_target}
{:response, 200, "guide text", "/guides/1"}
iex> {:ok, message} = RequestSeal.Finch.response_message(response, request, body: :consumed)
iex> message.body.state
:consumed
iex> {:error, error} = RequestSeal.Finch.response_message(response, request, body: :streamed)
iex> error.reason
:invalid_options
@spec sign( Finch.Request.t(), spec(), RequestSeal.Policy.signer() | RequestSeal.KeyHandle.t(), keyword() ) :: {:ok, Finch.Request.t()} | {:error, RequestSeal.Adapter.Error.t()}
Signs a built Finch request and returns it with signature headers appended.
The request is translated as in request_message/2, signed once with fresh
metadata, and returned with its headers replaced by the signed field list:
the original headers in order, then any generated content-digest and
content-length, then Signature-Input and Signature. Iodata bodies become
a binary; a retained stream is replaced by its bytes. Nothing is sent.
Parameters
request- aFinch.Request, with the same requirements as inrequest_message/2.spec- aRequestSeal.signing_spec/0or a fullspec/0::label(required) - signature label.:algorithm(required) - exact supported HTTP signature algorithm or a{:jws, name}selection.:components(required) - serialized Structured Fields Inner List without metadata parameters."host",tr, andreqcomponents reject; cover"@authority"instead of"host".:expires_in- positive integer seconds (at most 999,999,999,999,999), required in the short form. It requirescreated.:created(defaulttrue),:nonce(:randomor nil, default:random),:alg(defaulttrue,falsefor JWS algorithm tuples, which requirefalse),:keyidand:tag(nil or printable ASCII of at most 1,024 bytes, default nil). These may also sit in a nested:parametersmap. A full spec carrying all six parameter keys,:digest, and:field_schemasis used unchanged, includingexpires_in: nil.:digest- nil (default) or a unique list of"sha-256"and"sha-512". Without acontent-digestheader one is computed from the retained bytes (at most 16 MiB) and appended; an existing one must match exactly those algorithms and the bytes.:field_schemas- explicit field schema map, default%{}.
signer- aRequestSeal.KeyHandlewhose algorithm equals the spec's, or an arity-two function(algorithm, base)returning{:ok, signature}or{:error, term}. Both run in custody's monitored workers; the deadline or the caller's exit terminates their work.opts- keyword list of the options below.
Parameters are emitted in the order created, expires, nonce, alg, keyid,
tag. A selected nonce is 32 CSPRNG bytes, unpadded Base64url, fresh on every
call. Covered content-length is supplied from the retained bytes; an
existing different value rejects.
Options
:signing_timeout- 1..300,000 ms for either signer form. Default 5,000.:clock- arity-zero function returning integer Unix seconds from 0 through 253,402,300,799. Default system seconds. Used only whencreatedis selected.:body-:as_is(default) or{:retain, max}, as inrequest_message/2. Covering content (:digest,content-digest,repr-digest, orcontent-length) needs retained bytes.
Returns
{:ok, %Finch.Request{}}- the signed request.{:error, %RequestSeal.Adapter.Error{stage: :sign}}- reasons::invalid_options(option, spec, signer, or clock value; for a handle bound to another algorithm,sourceis a:signer_algorithm_mismatchcore error),:invalid_request(request translation as inrequest_message/2, or a coveredcontent-lengththat disagrees with the bytes),:unsupported_component(host,tr, orreqcomponents, or a component the message cannot supply),:body_unavailable(content coverage without retained bytes),:digest_conflict(an existingcontent-digestdisagrees),:limit(body, component, digest, or signature base bound), and:signing_failed(signer or custody failure, with a boundedsourcewhen available, or an unexpected exception). Meanings:RequestSeal.Adapter.Error.
Examples
iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> spec = %{
...> label: "sig",
...> algorithm: "ed25519",
...> components: ~s[("@method" "@authority" "@path" "content-digest")],
...> expires_in: 60,
...> digest: ["sha-256"]
...> }
iex> request =
...> Finch.build(
...> :put,
...> "https://builds.example.org/artifacts/42",
...> [{"content-type", "application/octet-stream"}],
...> "artifact bytes"
...> )
iex> {:ok, signed} = RequestSeal.Finch.sign(request, spec, handle)
iex> Enum.map(signed.headers, &elem(&1, 0))
["content-type", "content-digest", "Signature-Input", "Signature"]
iex> {:ok, key} = RequestSeal.Custody.public_key(handle)
iex> {:ok, policy} =
...> RequestSeal.Policy.new(%{
...> algorithms: ["ed25519"],
...> components: spec.components,
...> key_resolver: fn _ -> {:ok, %{algorithm: "ed25519", key: key}} end,
...> freshness: :not_evaluated,
...> content: %{kind: :content, algorithms: ["sha-256"], section: :headers},
...> replay: :not_required
...> })
iex> {:ok, message} = RequestSeal.Finch.request_message(signed)
iex> {:ok, verification} = RequestSeal.verify(message, policy, label: "sig")
iex> verification.label
"sig"
iex> streamed = Finch.build(:put, "https://builds.example.org/artifacts/43", [], {:stream, ["a"]})
iex> {:error, error} = RequestSeal.Finch.sign(streamed, spec, handle)
iex> error.reason
:body_unavailable
iex> {:error, error} = RequestSeal.Finch.sign(request, %{spec | components: ~s[("host")]}, handle)
iex> error.reason
:unsupported_component
iex> RequestSeal.Custody.Local.release(handle)
:ok
@spec verify(Finch.Response.t(), Finch.Request.t(), RequestSeal.Policy.t(), keyword()) :: {:ok, RequestSeal.Verification.t()} | {:error, RequestSeal.Adapter.Error.t()}
Verifies one labeled signature on a Finch response, bound to the exact request supplied.
The response is translated as by response_message/3 and passed to
RequestSeal.verify/3 with policy and the options below. A successful
result grants no authorization.
Parameters
response- aFinch.Response. Its body must be a binary unless:digest_stateis given.request- theFinch.Requestthat produced the response.policy- aRequestSeal.Policybuilt withRequestSeal.Policy.new/1.opts- keyword list of the options below.
Options
:label- the signature label to verify. Required by the core verifier; omitting it returns:response_rejected.:digest_state- aRequestSeal.Digeststate the caller fed with a streamed body and finished at its own end of stream. When present the response body is treated as consumed and is not read.
Returns
{:ok, %RequestSeal.Verification{}}- the verified facts.{:error, %RequestSeal.Adapter.Error{stage: :verify}}- reasons::invalid_options(unknown or duplicate option),:invalid_request(as inresponse_message/3),:body_unavailable(non-binary body without:digest_state),:limit(a related request body over 16 MiB), and:response_rejected(the core verifier rejected; its boundedRequestSeal.Erroris kept assource, or an unexpected exception occurred). Meanings:RequestSeal.Adapter.Error.
Examples
iex> {_public, 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> request = Finch.build(:get, "https://docs.example.com/guides/1")
iex> response = %Finch.Response{
...> status: 200,
...> headers: [{"content-type", "text/plain"}],
...> body: "guide text"
...> }
iex> components = ~s[("@status" "content-type" "@path";req)]
iex> {:ok, unsigned} = RequestSeal.Finch.response_message(response, request)
iex> {:ok, signed} =
...> RequestSeal.sign(unsigned, %{
...> label: "res",
...> algorithm: "ed25519",
...> components: components,
...> expires_in: 60
...> }, handle)
iex> response = %{response | headers: Enum.map(signed.fields, &{&1.name, &1.value})}
iex> {:ok, policy} =
...> RequestSeal.Policy.new(%{
...> algorithms: ["ed25519"],
...> components: components,
...> key_resolver: fn _ -> {:ok, %{algorithm: "ed25519", key: key}} end,
...> freshness: :not_evaluated,
...> content: :not_required,
...> replay: :not_required
...> })
iex> {:ok, verification} = RequestSeal.Finch.verify(response, request, policy, label: "res")
iex> verification.label
"res"
iex> other = Finch.build(:get, "https://docs.example.com/guides/2")
iex> {:error, error} = RequestSeal.Finch.verify(response, other, policy, label: "res")
iex> {error.reason, error.source.reason}
{:response_rejected, :invalid_signature}
iex> RequestSeal.Custody.Local.release(handle)
:ok