RequestSeal.Plug.SignResponse (RequestSeal v0.5.2)

Copy Markdown View Source

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

call(conn, opts)

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

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

init(opts)

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). :parameters contains exactly :created and :alg (Booleans), :expires_in (positive seconds or nil, requiring created when present), :nonce (:random or nil), and :keyid and :tag (bounded strings or nil). The adapter generates selected metadata for each signing operation. :digest is nil or a nonempty unique selection of "sha-256" and "sha-512"; present digests must match those algorithms and the retained response body. req components bind to the captured request; host and tr components are unsupported. The short RequestSeal.signing_spec/0 form is not accepted here.
  • :signer - a RequestSeal.KeyHandle or 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}