Packages
message_signatures
0.1.0
RFC 9421 HTTP Message Signatures for Elixir: create and verify signatures over HTTP message components with strict signature-base canonicalization.
Current section
Files
Jump to
Current section
Files
message_signatures
README.md
README.md
# MessageSignatures
[](https://github.com/ivan-podgurskiy/message_signatures/actions/workflows/ci.yml)
[](LICENSE)
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](https://www.rfc-editor.org/rfc/rfc9421) 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](https://docs.joinmastodon.org/spec/security/#http-message-signatures-rfc9421)
from the older Cavage draft, while Xsolla's
[payment-provider implementation guide](https://developers.xsolla.com/payment-providers/implementation/)
uses RFC 9421 together with `Content-Digest`.
## Installation
Add `message_signatures` to the dependencies in `mix.exs`:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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.
| Policy | Default | Cost of relaxing it |
| --- | --- | --- |
| `required_components` | Requests 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-checked | The default can accept a valid but out-of-profile algorithm. Restrict the list to the algorithms allowed by your protocol. |
| `max_age` | `300` seconds | A larger value or `:infinity` enlarges the replay window for a captured signature. |
| `clock_skew` | `30` seconds | A larger value accepts more future-dated signatures and widens the effective validity window. |
| `require_created` | `true` | Without `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:
```elixir
{: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:
- `Accept-Signature`
- Full multi-signature workflows
- Trailers
- JWKS or key distribution
- Nonce storage
- Cavage draft compatibility
`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](https://docs.joinmastodon.org/spec/security/#http-message-signatures-rfc9421)
rather than mixing the two wire formats in one verifier.
## License
MIT. See [LICENSE](LICENSE).