RFC 9421 HTTP Message Signatures: sign and verify requests and responses.
Implements the profile the x402 http-message-signatures extension
relies on: the derived components @method, @target-uri,
@authority, @scheme, @request-target, @path, @query,
@query-param;name and @status, plain HTTP fields (with the req
flag for request components covered by a response signature, RFC 9421
§2.4), the created, expires, nonce, alg, keyid and tag
parameters, and the ed25519, ecdsa-p256-sha256 and
rsa-pss-sha512 algorithms (X402.HTTPSignature.Key).
Messages
A message is a plain map:
:method— the request method ("GET"or:get);:url— the absolute target URI of the request;:headers— a list of{name, value}pairs (any case, repeated names allowed) or a map of name to value or list of values;:status— the status code, which marks the message as a response;:request— for a response, the request it answers, used by components carrying thereqflag.
Signing a request
{:ok, key} = X402.HTTPSignature.Key.generate("ed25519")
message = %{
method: "GET",
url: "https://api.example.com/premium?limit=10",
headers: [{"payment-signature", encoded_payload}]
}
{:ok, headers} = X402.HTTPSignature.sign(message, key, tag: "web-bot-auth")
# [{"signature-input", ~S|sig1=("@method" "@authority" "@path" "payment-signature");created=...|},
# {"signature", "sig1=:...:"}]Signing a response
Response signatures cover @status, the x402 header and, bound to the
request through ;req, the request authority and path — the shape the
extension specification recommends:
response = %{status: 200, headers: [{"payment-response", encoded}], request: message}
{:ok, headers} = X402.HTTPSignature.sign(response, key, tag: "x402-response")Verifying
verify/2 takes the message (with its Signature-Input and
Signature headers) and resolves the key from the keyid parameter:
X402.HTTPSignature.verify(message,
keys: fn keyid -> MyApp.Keys.fetch(keyid) end,
required_components: ["@method", "@authority", "@path"],
max_age: 300
)
# {:ok, %{label: "sig1", key: key, params: %{"created" => ..., "keyid" => ...}, components: [...]}}Verification trusts nothing the signature says about itself: the key
comes from :keys (the keyid parameter is only a lookup hint and
must match the key's kid), the algorithm is the key's and must be in
:algorithms (an alg parameter that disagrees is rejected), and the
caller states which components and parameters must be covered. A
message carrying two candidate signatures, or a Signature-Input
with a repeated label or parameter, is rejected as ambiguous rather
than picking one.
Profile
This is a bounded profile of RFC 9421, not a complete implementation. Out of scope, and rejected when encountered:
- the
hmac-sha256,rsa-v1_5-sha256andecdsa-p384-sha384algorithms, and thesf,key,bsandtrcomponent parameters (RFC 9421 §2.1.1–§2.1.4); Accept-Signaturenegotiation (§5) andContent-Digest(RFC 9530), which callers cover as a plain field when they compute it;- key discovery:
verify/2never fetches a directory, the caller's:keysdoes the lookup.
Summary
Types
A component identifier: the lowercased name and its ordered parameters.
A component as given to sign/3: a name, or a name with parameters
such as {"@authority", req: true} or {"@query-param", name: "Pet"}.
A request or response message.
Signature parameters in serialization order.
A successful verification.
Functions
Returns the default covered components for a message (see sign/3).
Builds the /.well-known/http-message-signatures-directory document
(draft-meunier-http-message-signatures-directory §3): a JWK Set of the
keys' public parts.
Percent-encodes a query parameter name or value as RFC 9421 §2.2.8
requires (the form-urlencoded percent-encode set, space as %20).
Merges the headers produced by several sign/3 calls (with distinct
labels) into one signature-input and one signature header, as a
directory response signed with every key requires.
Normalizes component specs (as accepted by sign/3) to component
identifiers with string parameter keys.
Signs a message, returning the signature-input and signature
headers to attach to it.
Builds the signature base (RFC 9421 §2.5) for a message, covered components and signature parameters.
Verifies a signature on a message.
Types
A component identifier: the lowercased name and its ordered parameters.
A component as given to sign/3: a name, or a name with parameters
such as {"@authority", req: true} or {"@query-param", name: "Pet"}.
@type message() :: %{ optional(:method) => String.t() | atom(), optional(:url) => String.t(), optional(:headers) => [{String.t(), String.t()}] | %{optional(String.t()) => term()}, optional(:status) => 100..599, optional(:request) => map() }
A request or response message.
@type params() :: [{String.t(), X402.HTTPSignature.StructuredField.bare()}]
Signature parameters in serialization order.
@type sign_error() :: base_error() | X402.HTTPSignature.Key.error()
@type verified() :: %{ label: String.t(), key: X402.HTTPSignature.Key.t(), params: %{optional(String.t()) => X402.HTTPSignature.StructuredField.bare()}, components: [component()] }
A successful verification.
@type verify_error() :: base_error() | :missing_signature | :malformed_signature_input | :malformed_signature | :signature_not_found | :ambiguous_signature | :unknown_key | :invalid_key | :algorithm_mismatch | {:unsupported_algorithm, term()} | {:missing_parameter, String.t()} | :signature_expired | :signature_too_old | :signature_not_yet_valid | :invalid_signature
Functions
@spec default_components(message()) :: [component_spec()]
Returns the default covered components for a message (see sign/3).
Examples
iex> X402.HTTPSignature.default_components(%{method: "GET", url: "https://example.com/"})
["@method", "@authority", "@path"]
iex> X402.HTTPSignature.default_components(%{method: "GET", url: "https://example.com/", headers: [{"PAYMENT-SIGNATURE", "abc"}]})
["@method", "@authority", "@path", "payment-signature"]
iex> X402.HTTPSignature.default_components(%{status: 402, headers: %{"payment-required" => "abc"}, request: %{}})
["@status", "payment-required", {"@authority", [req: true]}, {"@path", [req: true]}]
iex> X402.HTTPSignature.default_components(%{status: 200})
["@status"]
@spec directory([ X402.HTTPSignature.Key.t() | {X402.HTTPSignature.Key.t(), keyword() | map()} ]) :: map()
Builds the /.well-known/http-message-signatures-directory document
(draft-meunier-http-message-signatures-directory §3): a JWK Set of the
keys' public parts.
Each entry is a X402.HTTPSignature.Key or {key, extra} where
extra is a keyword list or map of additional JWK members such as
nbf and exp.
Examples
iex> jwk = %{"kty" => "OKP", "crv" => "Ed25519", "x" => "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"}
iex> {:ok, key} = X402.HTTPSignature.Key.from_jwk(jwk)
iex> X402.HTTPSignature.directory([{key, nbf: 1_712_793_600, exp: 1_715_385_600}])
%{
"keys" => [
%{
"kty" => "OKP",
"crv" => "Ed25519",
"x" => "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs",
"kid" => "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U",
"alg" => "ed25519",
"use" => "sig",
"nbf" => 1712793600,
"exp" => 1715385600
}
]
}
Percent-encodes a query parameter name or value as RFC 9421 §2.2.8
requires (the form-urlencoded percent-encode set, space as %20).
Examples
iex> X402.HTTPSignature.form_encode("with plus whitespace")
"with%20plus%20whitespace"
iex> X402.HTTPSignature.form_encode("façade\": ")
"fa%C3%A7ade%22%3A%20"
iex> X402.HTTPSignature.form_encode("a-b_c.d*e")
"a-b_c.d*e"
Merges the headers produced by several sign/3 calls (with distinct
labels) into one signature-input and one signature header, as a
directory response signed with every key requires.
Examples
iex> X402.HTTPSignature.merge_headers([
...> [{"signature-input", "a=();created=1"}, {"signature", "a=:AQ==:"}],
...> [{"signature-input", "b=();created=2"}, {"signature", "b=:Ag==:"}]
...> ])
[{"signature-input", "a=();created=1, b=();created=2"}, {"signature", "a=:AQ==:, b=:Ag==:"}]
@spec normalize_components([component_spec() | component()]) :: {:ok, [component()]} | {:error, {:invalid_component, String.t()}}
Normalizes component specs (as accepted by sign/3) to component
identifiers with string parameter keys.
Examples
iex> X402.HTTPSignature.normalize_components(["@Method", {"@authority", req: true}, {"@query-param", name: "Pet"}])
{:ok, [{"@method", []}, {"@authority", [{"req", true}]}, {"@query-param", [{"name", "Pet"}]}]}
iex> X402.HTTPSignature.normalize_components([{"@path", req: "yes"}])
{:error, {:invalid_component, "@path"}}
iex> X402.HTTPSignature.normalize_components([:method])
{:error, {:invalid_component, ":method"}}
@spec sign(message(), X402.HTTPSignature.Key.t(), keyword()) :: {:ok, [{String.t(), String.t()}]} | {:error, sign_error()}
Signs a message, returning the signature-input and signature
headers to attach to it.
The key must carry private material. Parameters are serialized in the
order created, expires, keyid, alg, nonce, tag.
Options
:components(list ofterm/0) - Covered components, in order. Defaults to"@method","@authority"and"@path"plus"payment-signature"when present for a request, and to"@status","payment-required"/"payment-response"when present, and"@authority";req/"@path";reqwhen:requestis given for a response.:label(String.t/0) - The signature label. The default value is"sig1".:created- Thecreatedtimestamp; defaults to now.falseomits it.:expires(non_neg_integer/0) - Absoluteexpirestimestamp.:ttl(pos_integer/0) - Setsexpirestocreatedplus this many seconds.:nonce- Anoncevalue;truegenerates a random one. The default value isfalse.:tag(String.t/0) - The applicationtag.:keyid- Overrides the key'skid;falseomits the parameter.:alg(boolean/0) - Include thealgparameter. The default value isfalse.
Examples
iex> seed = Base.url_decode64!("n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU", padding: false)
iex> {:ok, key} = X402.HTTPSignature.Key.new(alg: "ed25519", private_key: seed, kid: "test-key-ed25519")
iex> message = %{
...> method: "POST",
...> url: "https://example.com/foo?param=Value&Pet=dog",
...> headers: [
...> {"Date", "Tue, 20 Apr 2021 02:07:55 GMT"},
...> {"Content-Type", "application/json"},
...> {"Content-Length", "18"}
...> ]
...> }
iex> X402.HTTPSignature.sign(message, key,
...> label: "sig-b26",
...> components: ["date", "@method", "@path", "@authority", "content-type", "content-length"],
...> created: 1_618_884_473
...> )
{:ok, [
{"signature-input", ~S|sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"|},
{"signature", "sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:"}
]}
iex> {:ok, key} = X402.HTTPSignature.Key.generate("ed25519")
iex> X402.HTTPSignature.sign(%{method: "GET", url: "https://example.com/"}, key, components: ["content-type"])
{:error, {:missing_component, ~S|"content-type"|}}
@spec signature_base(message(), [component_spec()], params()) :: {:ok, String.t()} | {:error, base_error()}
Builds the signature base (RFC 9421 §2.5) for a message, covered components and signature parameters.
Examples
iex> message = %{method: "POST", url: "https://example.com/foo?param=Value&Pet=dog",
...> headers: [{"Content-Type", "application/json"}]}
iex> {:ok, base} = X402.HTTPSignature.signature_base(message,
...> ["@method", "@authority", "@path", "content-type"],
...> [{"created", 1_618_884_473}, {"keyid", "test-key-rsa-pss"}])
iex> String.split(base, "\n")
[
~S|"@method": POST|,
~S|"@authority": example.com|,
~S|"@path": /foo|,
~S|"content-type": application/json|,
~S|"@signature-params": ("@method" "@authority" "@path" "content-type");created=1618884473;keyid="test-key-rsa-pss"|
]
iex> X402.HTTPSignature.signature_base(%{method: "GET", url: "https://example.com/"}, ["@status"], [])
{:error, {:invalid_component, ~S|"@status"|}}
iex> X402.HTTPSignature.signature_base(%{method: "GET", url: "https://example.com/"}, ["@path", "@path"], [])
{:error, {:duplicate_component, ~S|"@path"|}}
@spec verify( message(), keyword() ) :: {:ok, verified()} | {:error, verify_error()}
Verifies a signature on a message.
Exactly one signature is verified: the one selected by :label and/or
:tag, or the only signature present; several candidates are
:ambiguous_signature. The key is resolved from the keyid parameter
through :keys; a key with a kid different from the parameter is
rejected, as is a key whose algorithm differs from an explicit alg
parameter or is not in :algorithms.
Time checks use the created/expires parameters when present:
expires in the past, created in the future, or created older
than :max_age fail the verification (:clock_skew widens each check).
Nothing is bounded by default: set :max_age (which also demands
created) or required_params: ["expires"] to enforce freshness.
Options
:keys(term/0) - Required. The verification keys: aX402.HTTPSignature.Key, a list of them (matched bykid), or a function receiving thekeyidparameter (ornil) and returning a key, a JWK map,{:ok, key}, ornil/:error.:label(String.t/0) - Verify the signature with this label only.:tag(String.t/0) - Verify the signature carrying thistagonly.:algorithms(list ofString.t/0) - Accepted algorithms. The default value is["ed25519", "ecdsa-p256-sha256", "rsa-pss-sha512"].:required_components(list ofterm/0) - Components that must be covered (same forms assign/3). The default value is[].:required_params(list ofString.t/0) - Signature parameters that must be present, for example["created"]. The default value is[].:max_age(pos_integer/0) - Maximum seconds sincecreated.:clock_skew(non_neg_integer/0) - Tolerance for time checks. The default value is0.:now(non_neg_integer/0) - The current UNIX time; defaults to the system clock.
Examples
iex> jwk = %{"kty" => "OKP", "crv" => "Ed25519", "kid" => "test-key-ed25519", "x" => "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"}
iex> {:ok, key} = X402.HTTPSignature.Key.from_jwk(jwk)
iex> message = %{
...> method: "POST",
...> url: "https://example.com/foo?param=Value&Pet=dog",
...> headers: [
...> {"date", "Tue, 20 Apr 2021 02:07:55 GMT"},
...> {"content-type", "application/json"},
...> {"content-length", "18"},
...> {"signature-input", ~S|sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"|},
...> {"signature", "sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:"}
...> ]
...> }
iex> {:ok, verified} = X402.HTTPSignature.verify(message, keys: [key])
iex> {verified.label, verified.params["created"], length(verified.components)}
{"sig-b26", 1618884473, 6}
iex> X402.HTTPSignature.verify(%{method: "GET", url: "https://example.com/"}, keys: [])
{:error, :missing_signature}