HTTPDigest

CIHex pmHexDocsLicense: MIT

RFC 9530 Digest Fields for Elixir: build, parse, and verify Content-Digest, Repr-Digest, and Want-* headers.

Why RFC 9530, not RFC 3230

RFC 3230 defined a single Digest field, but did not clearly separate the bytes on the wire from the selected representation. That distinction matters: under content codings such as gzip or br, and under range responses, the HTTP message content and the representation are different byte strings.

RFC 9530 obsoletes that ambiguity by splitting the field into Content-Digest and Repr-Digest. Content-Digest covers message content bytes. Repr-Digest covers selected representation bytes.

HTTPDigest implements the RFC 9530 fields and Structured Fields syntax. It does not implement RFC 3230 compatibility mode. If you are migrating from a package that emits the old Digest header, treat this as the newer layer.

Installation

Add http_digest to the dependencies in mix.exs:

def deps do
[
{:http_digest, "~> 0.1"}
]
end

Requires OTP 25 or later because verification uses :crypto.hash_equals/2 for constant-time comparison.

Quick Start

Build and verify a Content-Digest field:

body = ~s({"hello": "world"})
{:ok, header} = HTTPDigest.content_digest(body)
#=> {:ok, "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"}
HTTPDigest.verify_content(body, header)
#=> {:ok, :sha256}

Build and parse a Repr-Digest field:

representation = ~s({"hello": "world"}) <> "\n"
{:ok, header} = HTTPDigest.repr_digest(representation, algorithms: [:sha256])
{:ok, %{sha256: digest_bytes}} = HTTPDigest.parse_repr_digest(header)

Negotiate a response digest from Want-* fields:

{:ok, want} = HTTPDigest.build_want_content_digest(sha512: 10, sha256: 5)
#=> {:ok, "sha-512=10, sha-256=5"}
HTTPDigest.select_from_want(want, supported: [:sha256, :sha512])
#=> {:ok, :sha512}

Hash a large body incrementally:

{:ok, stream} = HTTPDigest.Stream.init(algorithms: [:sha256, :sha512])
stream =
chunks
|> Enum.reduce(stream, &HTTPDigest.Stream.update(&2, &1))
HTTPDigest.Stream.content_digest(stream)

Verify incoming Plug requests by hashing bytes as Plug.Parsers reads them:

plug Plug.Parsers,
parsers: [:json],
json_decoder: JSON,
body_reader: {HTTPDigest.Plug, :read_body, []}
plug HTTPDigest.Plug, required: true

Attach outgoing Content-Digest fields in Req:

Req.new(base_url: "https://api.example.com")
|> HTTPDigest.Req.attach(digest_algorithms: [:sha256])
|> Req.post!(url: "/payments", body: encoded_body)

Content vs Representation

This library never guesses what the representation is. Callers supply bytes.

For Content-Digest, pass the exact message content bytes. For Repr-Digest, pass the selected representation bytes as your application defines them. With Content-Encoding: br, for example, the content digest is computed over the encoded bytes, while the representation digest is computed over the selected representation associated with the response metadata.

The RFC 9530 examples make this visible: the JSON representation {"hello": "world"}\n has a different digest than the byte range "world"}\n, and an empty HEAD response can still carry a Repr-Digest for the selected representation.

Verification Policy

HTTPDigest.verify_content/3, verify_repr/3, and verify_digests/3 default to policy: :strongest. Among supported algorithms present in the field, the strongest one is checked. A mismatch there fails verification even if a weaker digest still matches.

Header containssupportedpolicyallow_insecureOutcome
valid sha-512 + valid sha-256[:sha256, :sha512]:strongestfalse{:ok, :sha512}
tampered sha-512 + valid sha-256[:sha256, :sha512]:strongestfalse{:error, :digest_mismatch}
tampered sha-512 + valid sha-256[:sha256, :sha512]:anyfalse{:ok, :sha256} (downgrade-vulnerable)
valid sha-256 only[:sha256, :sha512]:strongestfalse{:ok, :sha256}
valid md5 only[:md5, :sha256]anyfalse{:error, :insecure_algorithm_refused}
valid md5 only[:md5, :sha256]anytrue{:ok, :md5}
unknown algorithm only, such as blake2defaultanyany{:error, :no_supported_algorithm}
sha-512 only[:sha256]anyany{:error, :no_supported_algorithm}
empty dictionaryanyanyany{:error, :empty_header}
invalid Structured Fields syntaxanyanyany{:error, :malformed_header}

Downgrade Defense

RFC 9530 Section 6.6 describes a downgrade shape where an attacker tampers with a stronger digest but leaves a weaker digest valid. A verifier that accepts "any matching digest" can be tricked into accepting the message through the weaker digest.

The default :strongest policy prevents that: once sha-512 and sha-256 are both present and supported, sha-512 decides the result. Use policy: :any only when interoperability requires it and the downgrade risk is acceptable for the application.

API

Relationship to HTTP Message Signatures

RFC 9421 HTTP Message Signatures can sign content-digest and repr-digest fields. HTTPDigest provides that digest layer: build, parse, and verify the field values first, then sign or verify them with a message-signatures implementation.

Development

mix deps.get
mix format --check-formatted
mix compile --warnings-as-errors
mix test
mix docs
mix hex.build

scripts/differential_check.exs is a dev-only cross-check against Python hashlib and a Structured Fields parser when one is installed. It is not part of the Hex package.

License

MIT © Ivan Podgurskiy. See LICENSE.