RequestSeal.Digest (RequestSeal v0.5.2)

Copy Markdown View Source

RFC 9530 content/representation digests and preference fields, using OTP hashes.

compute/3 hashes a retained RequestSeal.Body without re-encoding its bytes. init/3, update/2, and finish/1 support caller-fed binary chunks without retaining content, reading handles, or starting processes. Results are Structured Fields dictionaries, serialized with serialize/1. check/3 and check_stream/3 compare computed checksums with a Message's Content-Digest or Repr-Digest field.

Retained content is bounded at 16,777,216 bytes (16 MiB): compute/3 and check/3 cannot raise that hard limit, bounding work on already retained content. Only caller-fed streams may explicitly opt in to a higher :max_bytes through init/3: incremental hash state uses constant memory and retains no chunks. The caller owns that larger work budget. State is caller-owned and immutable; use each returned state and finish only at EOF. Finishing does not prove transport completion; the caller supplies that boundary.

Hash HTTP content after transfer framing removal but before content decoding. Gzip bytes remain gzip bytes. A range's content is only the transmitted range; a HEAD response's content is empty. Representation hashing always uses the complete selected representation, including content encoding (Sections 2–3). The caller selects that representation using HTTP method, status, metadata, and resource semantics; this library never reconstructs it from partial content. No unavailable body substitutes for empty bytes.

Every supported checksum present must match. Before dictionary deduplication, checks reject a supported algorithm appearing more than once across the combined occurrences in the selected section with :conflicting_digest, even if its checksum values agree. Unknown and deprecated algorithms are parsed but never computed; success reports only their count. They cannot establish a match alone. A stream must have computed every supported algorithm in the field. A successful check proves checksum agreement only, with no signature coverage, authentication, identity, or authorization claim. Legacy Digest fields never substitute for these fields.

parse/1 and serialize/1 use the RFC 8941 dictionary/byte-sequence schema mandated by RFC 9530; known checksum lengths are checked. Preferences are hints, with 0 meaning unacceptable (Section 4); this module selects no algorithm from them. Parameters and duplicate keys follow Structured Fields rules (last value, first position), except supported checksum algorithms in check/3 and check_stream/3, which reject any repeat with :conflicting_digest. Inspection hides hash contexts.

Summary

Functions

Recompute the selected field from retained content or explicit representation.

Check hashes accumulated through caller EOF against a Message digest field.

Compute SHA-256/SHA-512 checksums from retained exact body bytes.

Finalize checksums as an ordered Structured Fields dictionary at caller EOF.

Initialize incremental hashes for an explicit semantic kind and algorithms.

Parse an RFC 9530 integrity dictionary; parameters retain RFC semantics.

Parse an RFC 9530 preference dictionary with integer weights in 0..10.

Read an explicit Want-Content-Digest or Want-Repr-Digest field/section.

Serialize a digest dictionary with known algorithm checksum-size validation.

Serialize an RFC 9530 preference dictionary with weight validation.

Hash one binary content chunk, rejecting overflow before cryptographic work.

Types

failure()

t()

@type t() :: %RequestSeal.Digest{
  bytes: non_neg_integer(),
  hashes: [{binary(), reference()}],
  kind: :content | :representation,
  max_bytes: non_neg_integer()
}

Functions

check(message, kind, opts \\ [])

@spec check(RequestSeal.Message.t(), :content | :representation, keyword()) ::
  {:ok, map()} | failure()

Recompute the selected field from retained content or explicit representation.

:content checks Content-Digest against the Message's retained body. :representation checks Repr-Digest against the :representation option, a retained Body containing the entire selected representation, even when it equals content. Only supported algorithms in the field are computed.

Parameters

Options

Unknown or duplicate options reject.

  • :section - :headers (default) or :trailers; sections never merge. A trailer must be observed (a list) before checking it.
  • :max_bytes - integer in 0..16,777,216 bounding the hashed bytes; default 16,777,216.
  • :representation - a retained RequestSeal.Body; required for :representation and rejected for :content.

Returns

  • {:ok, %{kind: kind, bytes: count, checked: names, unsupported: count}} - every supported checksum matched. :checked lists the supported algorithm names in field order; :unsupported counts other algorithm names.
  • {:error, %RequestSeal.Digest.Error{}} - reasons this function can return: :invalid_message, :invalid_kind, :invalid_options, :representation_required, :invalid_body, :body_unavailable, :conflicting_digest, :invalid_digest_length, :unsupported_algorithm (no supported algorithm in the field), :limit, and :mismatch. Meanings: RequestSeal.Digest.Error.
  • {:error, %RequestSeal.StructuredFields.Error{}} - :unavailable_section, :missing_field, :limit, :syntax, :revision_type, or :schema_type. Meanings: RequestSeal.StructuredFields.Error.

Examples

iex> {:ok, request} = RequestSeal.Message.request("POST", "https://docs.example/notes", [], ~s({"hello": "world"}), digest: ["sha-256"])
iex> RequestSeal.Digest.check(request, :content)
{:ok, %{kind: :content, bytes: 18, checked: ["sha-256"], unsupported: 0}}
iex> RequestSeal.Digest.check(%{request | body: %{request.body | bytes: ~s({"hello": "World"})}}, :content)
{:error, %RequestSeal.Digest.Error{reason: :mismatch}}
iex> RequestSeal.Digest.check(request, :representation)
{:error, %RequestSeal.Digest.Error{reason: :representation_required}}

A representation check reads Repr-Digest and the explicit representation body:

iex> {:ok, representation} = RequestSeal.Body.new(%{state: :retained, bytes: ~s({"hello": "world"})})
iex> {:ok, response} = RequestSeal.Message.response(200, [{"Repr-Digest", "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"}], nil)
iex> RequestSeal.Digest.check(response, :representation, representation: representation)
{:ok, %{kind: :representation, bytes: 18, checked: ["sha-256"], unsupported: 0}}

check_stream(state, message, opts \\ [])

@spec check_stream(t(), RequestSeal.Message.t(), keyword()) ::
  {:ok, map()} | failure()

Check hashes accumulated through caller EOF against a Message digest field.

The state's kind selects Content-Digest (:content) or Repr-Digest (:representation). The state must have computed every supported algorithm in the field; extra computed algorithms are ignored. The Message body is not read.

Parameters

Options

  • :section - :headers (default) or :trailers; the only option. A trailer must be observed (a list) before checking it.

Returns

  • {:ok, %{kind: kind, bytes: count, checked: names, unsupported: count}} - as in check/3.
  • {:error, %RequestSeal.Digest.Error{}} - reasons this function can return: :invalid_state, :invalid_options, :conflicting_digest, :invalid_digest_length, :unsupported_algorithm, :uncomputed_algorithm, and :mismatch. Meanings: RequestSeal.Digest.Error.
  • {:error, %RequestSeal.StructuredFields.Error{}} - :invalid_message, :unavailable_section, :missing_field, :limit, :syntax, :revision_type, or :schema_type. Meanings: RequestSeal.StructuredFields.Error.

Examples

iex> {:ok, body} = RequestSeal.Body.new(%{state: :streaming, source: make_ref()})
iex> {:ok, transport} = RequestSeal.TransportFacts.new(%{})
iex> {:ok, field} = RequestSeal.FieldOccurrence.new(%{name: "Content-Digest", value: "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", section: :headers})
iex> {:ok, response} = RequestSeal.Message.new(%{kind: :response, status: 200, fields: [field], trailers: :pending, body: body, transport: transport})
iex> {:ok, state} = RequestSeal.Digest.init(:content, ["sha-256"])
iex> {:ok, state} = RequestSeal.Digest.update(state, ~s({"hello": "world"}))
iex> RequestSeal.Digest.check_stream(state, response)
{:ok, %{kind: :content, bytes: 18, checked: ["sha-256"], unsupported: 0}}
iex> RequestSeal.Digest.check_stream(state, response, section: :trailers)
{:error, %RequestSeal.StructuredFields.Error{reason: :unavailable_section}}
iex> {:ok, other} = RequestSeal.Digest.init(:content, ["sha-512"])
iex> RequestSeal.Digest.check_stream(other, response)
{:error, %RequestSeal.Digest.Error{reason: :uncomputed_algorithm}}

compute(body, algorithms, opts \\ [])

Compute SHA-256/SHA-512 checksums from retained exact body bytes.

Parameters

  • body - a valid RequestSeal.Body in the :retained state. Its bytes are hashed as they are, without re-encoding.
  • algorithms - a nonempty list of unique names drawn from "sha-256" and "sha-512"; the result keeps this order.

Options

  • :max_bytes - integer in 0..16,777,216 bounding the hashed bytes; default 16,777,216. A larger value rejects; use init/3 for a larger streaming budget.

Returns

  • {:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}} - as in finish/1.
  • {:error, %RequestSeal.Digest.Error{}} - reasons this function can return: :invalid_body, :body_unavailable (streaming, unavailable, or consumed), :invalid_options, :unsupported_algorithm, and :limit. Meanings: RequestSeal.Digest.Error.

Examples

iex> {:ok, body} = RequestSeal.Body.new(%{state: :retained, bytes: ~s({"hello": "world"})})
iex> {:ok, dictionary} = RequestSeal.Digest.compute(body, ["sha-256"])
iex> RequestSeal.Digest.serialize(dictionary)
{:ok, "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"}
iex> RequestSeal.Digest.compute(body, ["sha-256"], max_bytes: 16_777_217)
{:error, %RequestSeal.Digest.Error{reason: :invalid_options}}
iex> {:ok, unavailable} = RequestSeal.Body.new(%{state: :unavailable})
iex> RequestSeal.Digest.compute(unavailable, ["sha-256"])
{:error, %RequestSeal.Digest.Error{reason: :body_unavailable}}

finish(state)

@spec finish(t()) :: {:ok, RequestSeal.StructuredFields.Value.t()} | failure()

Finalize checksums as an ordered Structured Fields dictionary at caller EOF.

Finishing does not prove transport completion. The state stays usable; finishing it again returns the same checksums.

Parameters

Returns

  • {:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}} - one {name, item} member per algorithm in init/3 order; each item value is {:bytes, checksum}.
  • {:error, %RequestSeal.Digest.Error{reason: :invalid_state}} - the only reason. Meanings: RequestSeal.Digest.Error.

Examples

iex> {:ok, state} = RequestSeal.Digest.init(:content, ["sha-256"])
iex> {:ok, state} = RequestSeal.Digest.update(state, ~s({"hello": ))
iex> {:ok, state} = RequestSeal.Digest.update(state, ~s("world"}))
iex> {:ok, dictionary} = RequestSeal.Digest.finish(state)
iex> RequestSeal.Digest.serialize(dictionary)
{:ok, "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"}
iex> RequestSeal.Digest.finish(%{state | bytes: -1})
{:error, %RequestSeal.Digest.Error{reason: :invalid_state}}

init(kind, algorithms, opts \\ [])

@spec init(:content | :representation, [binary()], keyword()) ::
  {:ok, t()} | failure()

Initialize incremental hashes for an explicit semantic kind and algorithms.

Feed chunks with update/2, then call finish/1 or check_stream/3 at the caller's EOF.

Parameters

  • kind - :content or :representation; selects the field check_stream/3 compares against (Content-Digest or Repr-Digest).
  • algorithms - a nonempty list of unique names drawn from "sha-256" and "sha-512"; the result keeps this order.

Options

  • :max_bytes - nonnegative integer bounding the total bytes accepted by update/2; default 16,777,216. It may exceed 16 MiB to explicitly opt in to a larger streaming work budget.

Returns

  • {:ok, %RequestSeal.Digest{}} - immutable hash state with bytes: 0.
  • {:error, %RequestSeal.Digest.Error{}} - reasons this function can return: :invalid_kind, :invalid_options (not a keyword list, unknown or duplicate keys, or a negative or non-integer :max_bytes), and :unsupported_algorithm. Meanings: RequestSeal.Digest.Error.

Examples

iex> {:ok, state} = RequestSeal.Digest.init(:content, ["sha-256", "sha-512"], max_bytes: 1024)
iex> {state.kind, state.bytes, state.max_bytes}
{:content, 0, 1024}
iex> RequestSeal.Digest.init(:content, ["md5"])
{:error, %RequestSeal.Digest.Error{reason: :unsupported_algorithm}}
iex> RequestSeal.Digest.init(:content, ["sha-256", "sha-256"])
{:error, %RequestSeal.Digest.Error{reason: :unsupported_algorithm}}

parse(wire)

@spec parse(binary()) :: {:ok, RequestSeal.StructuredFields.Value.t()} | failure()

Parse an RFC 9530 integrity dictionary; parameters retain RFC semantics.

Every member must be a byte sequence. Known algorithms ("sha-256", 32 bytes; "sha-512", 64 bytes) have their checksum length checked; other algorithm names are kept unchecked. Duplicate keys follow the Structured Fields last-value, first-position rule.

Parameters

  • wire - the field value bytes (at most 65,536).

Returns

  • {:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}}.
  • {:error, %RequestSeal.StructuredFields.Error{}} - :syntax, :limit, :revision_type, or :schema_type. Meanings: RequestSeal.StructuredFields.Error.
  • {:error, %RequestSeal.Digest.Error{reason: :invalid_digest_length}}. Meanings: RequestSeal.Digest.Error.

Examples

iex> {:ok, dictionary} = RequestSeal.Digest.parse("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:, md5=:AAAA:")
iex> Enum.map(dictionary.value, fn {name, _item} -> name end)
["sha-256", "md5"]
iex> RequestSeal.Digest.parse("sha-256=:AAAA:")
{:error, %RequestSeal.Digest.Error{reason: :invalid_digest_length}}
iex> RequestSeal.Digest.parse("sha-256=42")
{:error, %RequestSeal.StructuredFields.Error{reason: :schema_type}}

parse_preferences(wire)

@spec parse_preferences(binary()) ::
  {:ok, RequestSeal.StructuredFields.Value.t()} | failure()

Parse an RFC 9530 preference dictionary with integer weights in 0..10.

Weights are hints; 0 means unacceptable. Algorithm names are not restricted.

Parameters

  • wire - the Want-Content-Digest or Want-Repr-Digest field value bytes (at most 65,536).

Returns

  • {:ok, %RequestSeal.StructuredFields.Value{type: :dictionary}} - members carry {:integer, weight} items.
  • {:error, %RequestSeal.StructuredFields.Error{}} - :syntax, :limit, :revision_type, or :schema_type. Meanings: RequestSeal.StructuredFields.Error.
  • {:error, %RequestSeal.Digest.Error{reason: :invalid_preference}} - a weight outside 0..10. Meanings: RequestSeal.Digest.Error.

Examples

iex> {:ok, preferences} = RequestSeal.Digest.parse_preferences("sha-512=3, sha-256=10, md5=0")
iex> Enum.map(preferences.value, fn {name, item} -> {name, item.value} end)
[{"sha-512", {:integer, 3}}, {"sha-256", {:integer, 10}}, {"md5", {:integer, 0}}]
iex> RequestSeal.Digest.parse_preferences("sha-256=11")
{:error, %RequestSeal.Digest.Error{reason: :invalid_preference}}

preferences(message, name, section \\ :headers)

@spec preferences(RequestSeal.Message.t(), binary(), :headers | :trailers) ::
  {:ok, RequestSeal.StructuredFields.Value.t()} | failure()

Read an explicit Want-Content-Digest or Want-Repr-Digest field/section.

Matching occurrences in the selected section are combined in order, as in RequestSeal.StructuredFields.parse_field/5, then parsed as in parse_preferences/1.

Parameters

  • message - a valid RequestSeal.Message.
  • name - "Want-Content-Digest" or "Want-Repr-Digest", any case.
  • section - :headers (default) or :trailers.

Returns

Examples

iex> {:ok, request} = RequestSeal.Message.request("GET", "https://builds.example/artifacts/118", [{"Want-Content-Digest", "sha-512=3, sha-256=10"}], nil)
iex> {:ok, preferences} = RequestSeal.Digest.preferences(request, "want-content-digest")
iex> Enum.map(preferences.value, fn {name, item} -> {name, item.value} end)
[{"sha-512", {:integer, 3}}, {"sha-256", {:integer, 10}}]
iex> RequestSeal.Digest.preferences(request, "Want-Repr-Digest")
{:error, %RequestSeal.StructuredFields.Error{reason: :missing_field}}
iex> RequestSeal.Digest.preferences(request, "Accept")
{:error, %RequestSeal.Digest.Error{reason: :invalid_field}}

serialize(value)

@spec serialize(RequestSeal.StructuredFields.Value.t()) :: {:ok, binary()} | failure()

Serialize a digest dictionary with known algorithm checksum-size validation.

Parameters

Returns

  • {:ok, wire} - canonical field value bytes; "" for an empty dictionary (omit the field).
  • {:error, %RequestSeal.StructuredFields.Error{}} - :invalid_value, :schema_type, :revision_type, or :limit. Meanings: RequestSeal.StructuredFields.Error.
  • {:error, %RequestSeal.Digest.Error{reason: :invalid_digest_length}}. Meanings: RequestSeal.Digest.Error.

Examples

iex> alias RequestSeal.StructuredFields.Value
iex> checksum = :crypto.hash(:sha256, "invoice 2041")
iex> RequestSeal.Digest.serialize(%Value{type: :dictionary, value: [{"sha-256", %Value{type: :item, value: {:bytes, checksum}}}]})
{:ok, "sha-256=:" <> Base.encode64(checksum) <> ":"}
iex> RequestSeal.Digest.serialize(%Value{type: :dictionary, value: [{"sha-512", %Value{type: :item, value: {:bytes, checksum}}}]})
{:error, %RequestSeal.Digest.Error{reason: :invalid_digest_length}}

serialize_preferences(value)

@spec serialize_preferences(RequestSeal.StructuredFields.Value.t()) ::
  {:ok, binary()} | failure()

Serialize an RFC 9530 preference dictionary with weight validation.

Parameters

Returns

  • {:ok, wire} - canonical field value bytes; "" for an empty dictionary (omit the field).
  • {:error, %RequestSeal.StructuredFields.Error{}} - :invalid_value, :schema_type, :revision_type, or :limit. Meanings: RequestSeal.StructuredFields.Error.
  • {:error, %RequestSeal.Digest.Error{reason: :invalid_preference}}. Meanings: RequestSeal.Digest.Error.

Examples

iex> alias RequestSeal.StructuredFields.Value
iex> weight = fn n -> %Value{type: :item, value: {:integer, n}} end
iex> RequestSeal.Digest.serialize_preferences(%Value{type: :dictionary, value: [{"sha-256", weight.(10)}, {"sha-512", weight.(1)}]})
{:ok, "sha-256=10, sha-512=1"}
iex> RequestSeal.Digest.serialize_preferences(%Value{type: :dictionary, value: [{"sha-256", weight.(-1)}]})
{:error, %RequestSeal.Digest.Error{reason: :invalid_preference}}

update(state, chunk)

@spec update(t(), binary()) :: {:ok, t()} | failure()

Hash one binary content chunk, rejecting overflow before cryptographic work.

A chunk that would take the total past the state's :max_bytes rejects before it is hashed. The input state is not changed; continue with the returned state.

Parameters

Returns

  • {:ok, %RequestSeal.Digest{}} - the new state with :bytes increased.
  • {:error, %RequestSeal.Digest.Error{}} - reasons this function can return: :invalid_state, :invalid_chunk, and :limit. Meanings: RequestSeal.Digest.Error.

Examples

iex> {:ok, state} = RequestSeal.Digest.init(:content, ["sha-256"], max_bytes: 12)
iex> {:ok, state} = RequestSeal.Digest.update(state, "cpu=0.42;")
iex> state.bytes
9
iex> RequestSeal.Digest.update(state, "mem=0.91;")
{:error, %RequestSeal.Digest.Error{reason: :limit}}
iex> RequestSeal.Digest.update(state, [?a])
{:error, %RequestSeal.Digest.Error{reason: :invalid_chunk}}