MessageSignatures

CILicense: MIT

RFC 9421 HTTP Message Signatures for Elixir: create and verify signatures over HTTP message components with strict signature-base canonicalization.

Why RFC 9421

RFC 9421 is the IETF standard for creating, encoding, and verifying signatures or message authentication codes over HTTP message components. It defines the signature base, cryptographic algorithms, and the Signature-Input and Signature fields.

Deployment profiles are emerging around the standard. Mastodon documents its RFC 9421 support and migration from the older Cavage draft, while Xsolla's payment-provider implementation guide uses RFC 9421 together with Content-Digest.

Installation

Add message_signatures to the dependencies in mix.exs:

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

Quick Start

Build a request, sign it, append the returned Signature-Input and Signature headers, then verify it through an application key resolver:

defmodule MyApp.KeyResolver do
@behaviour MessageSignatures.KeyResolver
@secret "replace-with-a-strong-shared-secret"
def secret, do: @secret
@impl true
def resolve_verify_key("demo-key", _context), do: {:ok, {:hmac_sha256, @secret}}
def resolve_verify_key(_key_id, _context), do: {:error, :unknown_key}
end
message =
MessageSignatures.Message.from_request(
method: :post,
url: "https://api.example.com/payments?attempt=1",
headers: [{"content-type", "application/json"}]
)
{:ok, signature_headers} =
MessageSignatures.sign(message,
key: {:hmac_sha256, MyApp.KeyResolver.secret()},
key_id: "demo-key",
params: [created: :now, alg: true]
)
signed_message = %{message | headers: message.headers ++ signature_headers}
MessageSignatures.verify(signed_message,
key_resolver: MyApp.KeyResolver,
policy: [
required_components: ["@method", "@target-uri"],
algorithms: [:hmac_sha256],
max_age: 300,
clock_skew: 30,
require_created: true
]
)
#=> {:ok, %MessageSignatures.VerifyResult{algorithm: :hmac_sha256, ...}}

The http_digest Stack

RFC 9421 does not directly sign message content. With http_digest (RFC 9530), generate a Content-Digest field for the body, then cover that field in the signature. MessageSignatures verifies the signature over the content-digest header; HTTPDigest verifies that digest against the body. Both checks are needed. Add http_digest as an application dependency to enable this composition.

For outgoing Req requests, MessageSignatures.Req.attach/2 defaults digest: true. When http_digest is available, it attaches HTTPDigest.Req before its signing step so the signature can cover the resulting Content-Digest field:

Req.new(base_url: "https://api.example.com")
|> MessageSignatures.Req.attach(
key: {:ed25519, private_key},
key_id: "my-key-2026",
components: ["@method", "@authority", "@path", "content-digest"]
)
|> Req.post!(url: "/payments", body: payload)

For inbound Plug requests, retain the body while Plug.Parsers reads it, verify the digest against that body, then verify the message signature over the content-digest header:

plug Plug.Parsers,
parsers: [:json],
json_decoder: JSON,
body_reader: {HTTPDigest.Plug, :read_body, []}
plug HTTPDigest.Plug, required: true
plug MessageSignatures.Plug,
key_resolver: MyApp.KeyResolver,
policy: [required_components: ["@method", "@target-uri", "content-digest"]]

Verification Policy

verify/2 fails closed. Set an explicit policy for every protocol profile; the defaults are a conservative baseline, not a replacement for one.

PolicyDefaultCost of relaxing it
required_componentsRequests require ["@method", "@target-uri"]; responses require ["@status"]A signature can stop binding the request method or target, or the response status, to its protected meaning.
algorithms:all: every implemented algorithm is allowed; the resolver selects the algorithm from the resolved key, and a supplied alg parameter is only cross-checkedThe default can accept a valid but out-of-profile algorithm. Restrict the list to the algorithms allowed by your protocol.
max_age300 secondsA larger value or :infinity enlarges the replay window for a captured signature.
clock_skew30 secondsA larger value accepts more future-dated signatures and widens the effective validity window.
require_createdtrueWithout created, freshness checks lose their normal issuance-time anchor and replay defenses become weaker.

The registered algorithms are hmac-sha256, ed25519, ecdsa-p256-sha256, ecdsa-p384-sha384, rsa-pss-sha512, and rsa-v1_5-sha256. A key resolver owns key lookup and returns {:ok, {algorithm, material}}; it is the authority for verification key selection.

Interoperability Debugging

When another implementation disagrees, compare the exact signature base before comparing cryptography:

{:ok, base} =
MessageSignatures.signature_base(message, ["@method", "@target-uri"],
created: created,
keyid: "demo-key",
alg: "hmac-sha256"
)
IO.puts(base)

Diff those bytes against the peer's base, including component order, derived component values, and the serialized @signature-params line.

ECDSA is a frequent wire-format trap: RFC 9421 uses fixed-width raw r || s bytes, while many crypto APIs produce and consume ASN.1 DER signatures. RSA-PSS is another: both peers must match the salt length and MGF1 digest; this implementation uses SHA-512 with a 64-byte salt for rsa-pss-sha512.

Scope

This package deliberately does not implement:

MessageSignatures.KeyResolver leaves key fetching, caching, rotation, and outages to the application. MessageSignatures.NonceChecker leaves replay storage to the application as well. For Cavage deployments, use a migration boundary informed by the Mastodon RFC 9421 migration notes rather than mixing the two wire formats in one verifier.

License

MIT. See LICENSE.