Sign final buffered response bytes in a Plug before_send callback.
The signing callback runs after other registered callbacks, including callbacks
registered earlier that mutate signed facts. Their changes are included before
signing. Plug merges resp_cookies into headers afterward, so covering
set-cookie while cookies are pending fails closed; this also applies to
cookies set by session callbacks. Explicit final set-cookie response
headers can be signed without pending cookies. Transformations in the server
after Plug's callbacks (such as Bandit compression) must be disabled when
covering bytes; no no-transform field is injected. Request fields selected
with req retain their captured values and do not establish response
content integrity.
Summary
Functions
Wraps the transport and registers a final before_send callback that signs the response.
Validates response signing options once, at plug initialization.
Functions
Wraps the transport and registers a final before_send callback that signs the response.
The callback signs the status, final response headers, and, when the
connection state is :set, the buffered body, bound to the request in
RequestSeal.Plug.capture/1 at the time the callback runs. A covered
missing date header is added from the same sampled :clock value as
created and expires. Required body coverage needs conn.state == :set:
file and chunked delivery cannot authenticate unretained bytes.
Status-only streaming signatures are supported without body coverage. The
transport wrapper is request-local, redacted, and starts no process.
Parameters
conn- aPlug.Conn, normally afterRequestSeal.Plug.Capture.opts- options returned byinit/1.
Returns
Plug.Conn- with the wrapped adapter and the signing callback appended after all registered callbacks. Absent or nil callback state means no callbacks.
When the response is sent, success appends signature-input and
signature (and any generated content-digest) to the response headers.
On failure the :on_failure status and an empty unsigned body replace any
delivery, including file and chunked modes; signature, digest,
content-length, content-encoding, transfer-encoding, and trailer
headers are removed, and later chunk writes return {:error, :closed}.
Private state's :error holds a
%RequestSeal.Adapter.Error{stage: :sign, attempt: 1} with reason:
:unsupported_delivery (malformed callback state, which replaces delivery
at once; pending resp_cookies with set-cookie covered; or body
coverage without conn.state == :set), :unsupported_component (request
@request-target, @target-uri, or @query selected with req, because
Plug lacks exact target evidence, or a component the message cannot
supply), :not_captured (no capture), :invalid_options (a clock value
outside 0..253,402,300,799, or a signer whose algorithm differs),
:body_unavailable, :digest_conflict, :invalid_request, :limit, and
:signing_failed (signer or custody failure, or an unexpected exception),
with the meanings of RequestSeal.Finch.sign/4. Meanings:
RequestSeal.Adapter.Error.
Examples
iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> sign = %{
...> label: "res",
...> algorithm: "ed25519",
...> components: ~s[("@status" "content-type" "content-digest" "@method";req)],
...> parameters: %{created: true, expires_in: 60, nonce: :random, alg: true, keyid: nil, tag: nil},
...> digest: ["sha-256"],
...> field_schemas: %{}
...> }
iex> opts =
...> RequestSeal.Plug.SignResponse.init(
...> sign: sign,
...> signer: handle,
...> signing_timeout: 5_000,
...> clock: fn -> System.system_time(:second) end,
...> on_failure: {:respond, 500}
...> )
iex> capture =
...> RequestSeal.Plug.Capture.init(origin: :connection, max_body_bytes: 1_024, read_timeout: 5_000)
iex> conn =
...> Plug.Test.conn(:get, "https://docs.example.com/guides/1")
...> |> RequestSeal.Plug.Capture.call(capture)
...> |> RequestSeal.Plug.SignResponse.call(opts)
...> |> Plug.Conn.put_resp_content_type("text/plain")
...> |> Plug.Conn.send_resp(200, "guide text")
iex> {conn.status, Enum.map(conn.resp_headers, &elem(&1, 0)) -- ["cache-control"]}
{200, ["content-type", "content-digest", "signature-input", "signature"]}
iex> uncaptured =
...> Plug.Test.conn(:get, "https://docs.example.com/guides/1")
...> |> RequestSeal.Plug.SignResponse.call(opts)
...> |> Plug.Conn.send_resp(200, "guide text")
iex> {uncaptured.status, uncaptured.resp_body, uncaptured.private.request_seal.error.reason}
{500, "", :not_captured}
iex> RequestSeal.Custody.Local.release(handle)
:ok
Validates response signing options once, at plug initialization.
Parameters
opts- keyword list of the options below. All five are required; unknown or duplicate options reject.
Options
:sign- the signing map. It contains exactly:label(signature label),:components(serialized component Inner List without metadata parameters),:algorithm(exact supported HTTP signature algorithm),:parameters,:digest, and:field_schemas(explicit field schema map).:parameterscontains exactly:createdand:alg(Booleans),:expires_in(positive seconds or nil, requiring created when present),:nonce(:randomor nil), and:keyidand:tag(bounded strings or nil). The adapter generates selected metadata for each signing operation.:digestis nil or a nonempty unique selection of"sha-256"and"sha-512"; present digests must match those algorithms and the retained response body.reqcomponents bind to the captured request;hostandtrcomponents are unsupported. The shortRequestSeal.signing_spec/0form is not accepted here.:signer- aRequestSeal.KeyHandleor an arity-two function signer(algorithm, base).:signing_timeout- 1..300,000 milliseconds.:clock- arity-zero function returning Unix seconds.:on_failure-{:respond, status}with a final status in 200..599.
Returns
- keyword list - the validated options.
Raises RequestSeal.Adapter.Error with stage :sign and reason
:invalid_options (a missing, unknown, or invalid option),
:unsupported_component (host or tr components), or :limit (the
component list exceeds its bound).
Examples
iex> try do
...> RequestSeal.Plug.SignResponse.init(
...> sign: %{label: "res", algorithm: "ed25519", components: ~s[("@status")], expires_in: 60},
...> signer: fn _algorithm, _base -> {:error, :unused} end,
...> signing_timeout: 5_000,
...> clock: fn -> 0 end,
...> on_failure: {:respond, 500}
...> )
...> rescue
...> error in RequestSeal.Adapter.Error -> {error.reason, error.stage}
...> end
{:invalid_options, :sign}