X402.HTTPSignature (X402 v0.9.0)

Copy Markdown View Source

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 the req flag.

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-sha256 and ecdsa-p384-sha384 algorithms, and the sf, key, bs and tr component parameters (RFC 9421 §2.1.1–§2.1.4);
  • Accept-Signature negotiation (§5) and Content-Digest (RFC 9530), which callers cover as a plain field when they compute it;
  • key discovery: verify/2 never fetches a directory, the caller's :keys does 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

base_error()

@type base_error() ::
  {:missing_component, String.t()}
  | {:duplicate_component, String.t()}
  | {:invalid_component, String.t()}
  | {:unknown_component, String.t()}
  | {:unsupported_component_parameter, String.t()}
  | {:invalid_field_value, String.t()}
  | :non_ascii
  | :invalid_structured_field

component()

@type component() :: {String.t(), [{String.t(), true | String.t()}]}

A component identifier: the lowercased name and its ordered parameters.

component_spec()

@type component_spec() :: String.t() | {String.t(), keyword()}

A component as given to sign/3: a name, or a name with parameters such as {"@authority", req: true} or {"@query-param", name: "Pet"}.

message()

@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.

params()

Signature parameters in serialization order.

sign_error()

@type sign_error() :: base_error() | X402.HTTPSignature.Key.error()

verified()

@type verified() :: %{
  label: String.t(),
  key: X402.HTTPSignature.Key.t(),
  params: %{optional(String.t()) => X402.HTTPSignature.StructuredField.bare()},
  components: [component()]
}

A successful verification.

verify_error()

@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

default_components(response)

(since 0.9.0)
@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"]

directory(keys)

(since 0.9.0)
@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
    }
  ]
}

form_encode(value)

(since 0.9.0)
@spec form_encode(String.t()) :: String.t()

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"

merge_headers(header_lists)

(since 0.9.0)
@spec merge_headers([[{String.t(), String.t()}]]) :: [{String.t(), String.t()}]

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==:"}]

normalize_components(components)

(since 0.9.0)
@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"}}

sign(message, key, opts \\ [])

(since 0.9.0)
@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 of term/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";req when :request is given for a response.

  • :label (String.t/0) - The signature label. The default value is "sig1".

  • :created - The created timestamp; defaults to now. false omits it.

  • :expires (non_neg_integer/0) - Absolute expires timestamp.

  • :ttl (pos_integer/0) - Sets expires to created plus this many seconds.

  • :nonce - A nonce value; true generates a random one. The default value is false.

  • :tag (String.t/0) - The application tag.

  • :keyid - Overrides the key's kid; false omits the parameter.

  • :alg (boolean/0) - Include the alg parameter. The default value is false.

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"|}}

signature_base(message, components, params)

(since 0.9.0)
@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"|}}

verify(message, opts)

(since 0.9.0)
@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: a X402.HTTPSignature.Key, a list of them (matched by kid), or a function receiving the keyid parameter (or nil) and returning a key, a JWK map, {:ok, key}, or nil/:error.

  • :label (String.t/0) - Verify the signature with this label only.

  • :tag (String.t/0) - Verify the signature carrying this tag only.

  • :algorithms (list of String.t/0) - Accepted algorithms. The default value is ["ed25519", "ecdsa-p256-sha256", "rsa-pss-sha512"].

  • :required_components (list of term/0) - Components that must be covered (same forms as sign/3). The default value is [].

  • :required_params (list of String.t/0) - Signature parameters that must be present, for example ["created"]. The default value is [].

  • :max_age (pos_integer/0) - Maximum seconds since created.

  • :clock_skew (non_neg_integer/0) - Tolerance for time checks. The default value is 0.

  • :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}