Packages

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
message_signatures lib message_signatures.ex
Raw

lib/message_signatures.ex

defmodule MessageSignatures do
@moduledoc """
RFC 9421 HTTP Message Signatures: create and verify signatures over HTTP
message components.
Composition with `http_digest` (RFC 9530): this package signs and
verifies the `content-digest` header as a covered component. Validating
that the digest matches the body is `HTTPDigest.verify_content/3`'s job.
"""
alias MessageSignatures.{
Algorithms,
Component,
Error,
Message,
SignatureBase,
SignatureParams,
StructuredField,
VerifyResult
}
@sign_opts [:key, :key_id, :label, :components, :params, :sf_types]
@verify_opts [:key_resolver, :label, :tag, :policy, :nonce_checker, :sf_types, :now]
@policy_keys [:required_components, :algorithms, :max_age, :clock_skew, :require_created]
@param_order [:created, :expires, :nonce, :alg, :keyid, :tag]
@doc """
Signs a message, returning `[{"signature-input", value}, {"signature", value}]`.
Options: `:key` (`{algorithm, material}`, see `MessageSignatures.Key`),
`:key_id`, `:label` (default `"sig1"`), `:components` (defaults:
`["@method", "@target-uri"]` for requests, `["@status"]` for responses),
`:params` (default `[created: :now]`), and `:sf_types`.
"""
@spec sign(Message.t(), keyword()) :: {:ok, [{String.t(), String.t()}]} | {:error, Error.t()}
def sign(%Message{} = message, opts) do
opts = Keyword.validate!(opts, @sign_opts)
sf_types = fetch_sf_types!(opts)
with {:ok, {alg, material}} <- fetch_key(opts),
{:ok, label} <- fetch_label(opts),
{:ok, signature_params} <- build_sign_params(message, alg, opts),
{:ok, base} <- SignatureBase.build(message, signature_params, sf_types: sf_types),
{:ok, signature} <- do_sign(alg, material, base),
{:ok, signature_input} <- SignatureParams.serialize(signature_params),
{:ok, signature_value} <- serialize_signature_dict(label, signature) do
{:ok,
[
{"signature-input", label <> "=" <> signature_input},
{"signature", signature_value}
]}
end
end
@doc """
Verifies one signature on a message.
Verification fails closed by default: requests must cover `@method` and
`@target-uri`, responses must cover `@status`, `created` is required, and
signatures older than 300 seconds are rejected with 30 seconds of clock
skew. Relaxations, including `required_components: []`, must be explicit.
"""
@spec verify(Message.t(), keyword()) :: {:ok, VerifyResult.t()} | {:error, Error.t()}
def verify(%Message{} = message, opts) do
opts = validate_verify_opts!(opts)
policy = build_policy!(message, opts)
sf_types = opts |> explicit_option(:sf_types, %{}) |> validate_sf_types!()
validate_selector!(:label, opts[:label])
validate_selector!(:tag, opts[:tag])
validate_callback_option!(:key_resolver, opts[:key_resolver], :resolve_verify_key)
validate_callback_option!(:nonce_checker, opts[:nonce_checker], :check_nonce)
verification_now = verify_now!(opts)
with {:ok, entries} <- parse_signatures(message),
{:ok, entry} <- select(entries, opts),
context = %{
label: entry.label,
tag: SignatureParams.tag(entry.params),
message: message,
params: entry.params
},
{:ok, {algorithm, material}} <- resolve_key(opts[:key_resolver], entry, context),
:ok <- check_algorithm(algorithm, entry, policy),
:ok <- check_required_components(entry, policy),
:ok <- check_freshness(entry, policy, verification_now),
{:ok, base} <- SignatureBase.build(message, entry.params, sf_types: sf_types),
:ok <- check_signature(algorithm, material, base, entry),
{:ok, result} <- verification_result(entry, algorithm),
:ok <- check_nonce(opts[:nonce_checker], entry, context) do
{:ok, result}
end
end
@doc """
Builds the signature base without signing.
Parameters use the same expansion as `sign/2`; when supplied here, `:alg`
must be its IANA string because no key tuple is available to derive it.
"""
@spec signature_base(Message.t(), [String.t()], keyword()) ::
{:ok, String.t()} | {:error, Error.t()}
def signature_base(%Message{} = message, components, params \\ []) do
validate_params_keyword!(params)
{sf_types, params} = Keyword.pop(params, :sf_types, %{})
sf_types = validate_sf_types!(sf_types)
with :ok <- validate_components(components),
{:ok, expanded} <- expand_params(params, nil),
{:ok, signature_params} <- SignatureParams.new(components, expanded) do
SignatureBase.build(message, signature_params, sf_types: sf_types)
end
end
@doc """
Parses the `Signature-Input` / `Signature` headers into labeled entries
(in `Signature-Input` order) without verifying anything. Useful for
inspecting multi-signature messages before selecting one to verify.
"""
@spec parse_signatures(Message.t()) ::
{:ok, [%{label: String.t(), params: SignatureParams.t(), signature: binary()}]}
| {:error, Error.t()}
def parse_signatures(%Message{} = message) do
with {:ok, si_raw} <- fetch_header(message, "signature-input"),
{:ok, sig_raw} <- fetch_header(message, "signature"),
{:ok, si_members} <- parse_dict(si_raw, :malformed_signature_input),
{:ok, sig_members} <- parse_dict(sig_raw, :malformed_signature),
:ok <- same_labels(si_members, sig_members) do
build_entries(si_members, Map.new(sig_members))
end
end
@doc false
def select(entries, opts) do
cond do
label = opts[:label] ->
case Enum.find(entries, &(&1.label == label)) do
nil -> {:error, %Error{reason: :label_not_found, label: label}}
entry -> {:ok, entry}
end
tag = opts[:tag] ->
case Enum.filter(entries, &(SignatureParams.tag(&1.params) == tag)) do
[entry] -> {:ok, entry}
[] -> {:error, %Error{reason: :missing_signature, detail: {:tag, tag}}}
_many -> {:error, %Error{reason: :multiple_signatures, detail: {:tag, tag}}}
end
true ->
case entries do
[entry] -> {:ok, entry}
[] -> {:error, %Error{reason: :missing_signature}}
_many -> {:error, %Error{reason: :multiple_signatures}}
end
end
end
defp fetch_header(message, name) do
case Message.field_value(message, name) do
{:ok, value} -> {:ok, value}
:error -> {:error, %Error{reason: :missing_signature, detail: name}}
end
end
defp parse_dict(raw, error_reason) do
case StructuredField.parse_dictionary(raw, duplicates: :error) do
{:ok, members} -> {:ok, members}
{:error, :malformed} -> {:error, %Error{reason: error_reason}}
end
end
defp same_labels(si_members, sig_members) do
si_labels = Enum.map(si_members, &elem(&1, 0))
sig_labels = Enum.map(sig_members, &elem(&1, 0))
case Enum.sort(si_labels) == Enum.sort(sig_labels) do
true -> :ok
false -> {:error, %Error{reason: :malformed_signature, label: List.first(si_labels)}}
end
end
defp build_entries(si_members, sig_by_label) do
si_members
|> Enum.reduce_while({:ok, []}, fn {label, member}, {:ok, acc} ->
with {:ok, sp} <- SignatureParams.from_member(member),
{:item, {:binary, signature}, []} <- Map.fetch!(sig_by_label, label) do
{:cont, {:ok, [%{label: label, params: sp, signature: signature} | acc]}}
else
{:error, %Error{} = e} -> {:halt, {:error, %{e | label: label}}}
_bad_sig_member -> {:halt, {:error, %Error{reason: :malformed_signature, label: label}}}
end
end)
|> case do
{:ok, acc} -> {:ok, Enum.reverse(acc)}
error -> error
end
end
defp fetch_key(opts) do
case opts[:key] do
{alg, material} ->
if Algorithms.known?(alg) do
{:ok, {alg, material}}
else
{:error, %Error{reason: :unknown_algorithm, algorithm: alg}}
end
_ ->
raise ArgumentError, "sign/2 requires key: {algorithm, material}"
end
end
defp validate_verify_opts!(opts) when is_list(opts) do
if Keyword.keyword?(opts) do
Keyword.validate!(opts, @verify_opts)
else
raise ArgumentError, "verify/2 options must be a keyword list"
end
end
defp validate_verify_opts!(_opts),
do: raise(ArgumentError, "verify/2 options must be a keyword list")
defp build_policy!(message, opts) do
supplied = explicit_option(opts, :policy, [])
unless Keyword.keyword?(supplied) do
raise ArgumentError, "verify/2 :policy must be a keyword list"
end
default_policy(message)
|> Keyword.merge(Keyword.validate!(supplied, @policy_keys))
|> validate_policy!()
end
defp validate_policy!(policy) do
required_components = validate_required_components!(policy[:required_components])
algorithms = policy[:algorithms]
max_age = policy[:max_age]
clock_skew = policy[:clock_skew]
require_created = policy[:require_created]
unless algorithms == :all or
(is_list(algorithms) and Enum.all?(algorithms, &Algorithms.known?/1)) do
raise ArgumentError, "verify/2 :algorithms must be :all or a list of known algorithms"
end
unless max_age == :infinity or (is_integer(max_age) and max_age >= 0) do
raise ArgumentError, "verify/2 :max_age must be a non-negative integer or :infinity"
end
unless is_integer(clock_skew) and clock_skew >= 0 do
raise ArgumentError, "verify/2 :clock_skew must be a non-negative integer"
end
unless is_boolean(require_created) do
raise ArgumentError, "verify/2 :require_created must be a boolean"
end
Keyword.put(policy, :required_components, required_components)
end
defp validate_required_components!(components) when is_list(components) do
unless Enum.all?(components, &is_binary/1) do
raise ArgumentError, "verify/2 :required_components must be a list of strings"
end
Enum.map(components, fn component ->
case Component.parse(component) do
{:ok, parsed} ->
Component.serialize(parsed)
{:error, %Error{reason: reason}} ->
raise ArgumentError,
"verify/2 invalid :required_components component #{inspect(component)}: #{reason}"
end
end)
end
defp validate_required_components!(_components),
do: raise(ArgumentError, "verify/2 :required_components must be a list of strings")
defp validate_selector!(_name, nil), do: :ok
defp validate_selector!(_name, value) when is_binary(value), do: :ok
defp validate_selector!(name, _value),
do: raise(ArgumentError, "verify/2 :#{name} must be a string")
defp validate_callback_option!(_option, nil, _function), do: :ok
defp validate_callback_option!(_option, callback, _function) when is_function(callback, 2),
do: :ok
defp validate_callback_option!(option, module, function) when is_atom(module) do
unless Code.ensure_loaded?(module) and function_exported?(module, function, 2) do
raise ArgumentError,
"verify/2 :#{option} module #{inspect(module)} must export #{function}/2"
end
end
defp validate_callback_option!(option, _callback, _function),
do: raise(ArgumentError, "verify/2 :#{option} must be a module or arity-2 function")
defp verify_now!(opts) do
case Keyword.fetch(opts, :now) do
:error ->
System.os_time(:second)
{:ok, now} when is_integer(now) ->
now
{:ok, _now} ->
raise ArgumentError, "verify/2 :now must be an integer Unix timestamp"
end
end
defp explicit_option(opts, key, default) do
case Keyword.fetch(opts, key) do
{:ok, value} -> value
:error -> default
end
end
defp resolve_key(nil, _entry, _context),
do: raise(ArgumentError, "verify/2 requires :key_resolver")
defp resolve_key(resolver, entry, context) do
key_id = SignatureParams.keyid(entry.params)
result = invoke_callback!(resolver, :resolve_verify_key, [key_id, context], :key_resolver)
case result do
{:ok, {algorithm, material}} when is_atom(algorithm) ->
if Algorithms.known?(algorithm) do
{:ok, {algorithm, material}}
else
{:error, %Error{reason: :unknown_algorithm, algorithm: algorithm, label: entry.label}}
end
{:error, detail} ->
{:error,
%Error{
reason: :unknown_key,
key_id: key_id,
label: entry.label,
detail: detail
}}
_other ->
raise ArgumentError,
"verify/2 key resolver must return {:ok, {algorithm, material}} or {:error, detail}"
end
end
defp check_algorithm(algorithm, entry, policy) do
with :ok <- check_alg_param(algorithm, SignatureParams.alg(entry.params), entry) do
case policy[:algorithms] do
:all ->
:ok
allowed ->
if algorithm in allowed do
:ok
else
{:error,
%Error{
reason: :algorithm_not_allowed,
algorithm: algorithm,
label: entry.label
}}
end
end
end
end
defp check_alg_param(_resolved, nil, _entry), do: :ok
defp check_alg_param(resolved, claimed_iana, entry) do
case Algorithms.from_iana(claimed_iana) do
{:ok, ^resolved} ->
:ok
{:ok, other} ->
{:error,
%Error{
reason: :algorithm_mismatch,
algorithm: other,
label: entry.label,
detail: {:resolved, resolved}
}}
:error ->
{:error, %Error{reason: :unknown_algorithm, algorithm: claimed_iana, label: entry.label}}
end
end
defp check_required_components(entry, policy) do
covered = entry.params.components |> Enum.map(&Component.serialize/1) |> MapSet.new()
Enum.reduce_while(policy[:required_components], :ok, fn required, :ok ->
if MapSet.member?(covered, required) do
{:cont, :ok}
else
{:halt,
{:error,
%Error{
reason: :missing_required_component,
component: required,
label: entry.label
}}}
end
end)
end
defp check_freshness(entry, policy, now) do
created = SignatureParams.created(entry.params)
expires = SignatureParams.expires(entry.params)
skew = policy[:clock_skew]
cond do
is_nil(created) and policy[:require_created] ->
{:error, %Error{reason: :missing_created, label: entry.label}}
is_integer(created) and created > now + skew ->
{:error, %Error{reason: :not_yet_valid, label: entry.label}}
is_integer(expires) and now > expires + skew ->
{:error, %Error{reason: :expired, label: entry.label}}
is_integer(created) and policy[:max_age] != :infinity and
now - created > policy[:max_age] ->
{:error, %Error{reason: :expired, label: entry.label}}
true ->
:ok
end
end
defp check_nonce(nil, _entry, _context), do: :ok
defp check_nonce(checker, entry, context) do
nonce = SignatureParams.nonce(entry.params)
result = invoke_callback!(checker, :check_nonce, [nonce, context], :nonce_checker)
case result do
:ok ->
:ok
{:error, detail} ->
{:error, %Error{reason: :nonce_rejected, label: entry.label, detail: detail}}
_other ->
raise ArgumentError, "verify/2 nonce checker must return :ok or {:error, detail}"
end
end
defp invoke_callback!(callback, _function, arguments, _option)
when is_function(callback, 2),
do: apply(callback, arguments)
defp invoke_callback!(module, function, arguments, _option) when is_atom(module) do
if Code.ensure_loaded?(module) and function_exported?(module, function, 2) do
apply(module, function, arguments)
else
raise ArgumentError,
"verify/2 callback module #{inspect(module)} must export #{function}/2"
end
end
defp invoke_callback!(_callback, _function, _arguments, option),
do: raise(ArgumentError, "verify/2 :#{option} must be a module or arity-2 function")
defp check_signature(algorithm, material, base, entry) do
case Algorithms.verify(algorithm, material, base, entry.signature) do
:ok ->
:ok
{:error, :signature_mismatch} ->
{:error, %Error{reason: :signature_mismatch, label: entry.label, algorithm: algorithm}}
end
end
defp verification_result(entry, algorithm) do
with {:ok, created} <- unix_datetime(SignatureParams.created(entry.params), :created, entry),
{:ok, expires} <- unix_datetime(SignatureParams.expires(entry.params), :expires, entry) do
{:ok,
%VerifyResult{
label: entry.label,
key_id: SignatureParams.keyid(entry.params),
algorithm: algorithm,
covered: Enum.map(entry.params.components, &Component.serialize/1),
created: created,
expires: expires,
nonce: SignatureParams.nonce(entry.params),
tag: SignatureParams.tag(entry.params)
}}
end
end
defp unix_datetime(nil, _name, _entry), do: {:ok, nil}
defp unix_datetime(seconds, name, entry) do
case DateTime.from_unix(seconds) do
{:ok, datetime} ->
{:ok, datetime}
{:error, :invalid_unix_time} ->
{:error,
%Error{
reason: :malformed_signature_input,
label: entry.label,
detail: {:timestamp_out_of_range, name, seconds}
}}
end
end
defp default_policy(message) do
[
required_components: default_components(message),
algorithms: :all,
max_age: 300,
clock_skew: 30,
require_created: true
]
end
defp fetch_label(opts) do
label = Keyword.get(opts, :label)
label = if is_nil(label), do: "sig1", else: label
if is_binary(label) do
case StructuredField.serialize_dictionary([{label, {:item, {:integer, 0}, []}}]) do
{:ok, _} -> {:ok, label}
{:error, _} -> {:error, %Error{reason: :malformed_signature_input, label: label}}
end
else
{:error, %Error{reason: :malformed_signature_input, label: label}}
end
end
defp build_sign_params(message, alg, opts) do
components = Keyword.get(opts, :components)
components = if is_nil(components), do: default_components(message), else: components
params = Keyword.get(opts, :params)
params = if is_nil(params), do: [created: :now], else: params
key_id = Keyword.get(opts, :key_id)
with :ok <- validate_components(components),
{:ok, expanded} <- expand_params(params, alg, key_id),
{:ok, signature_params} <- SignatureParams.new(components, expanded) do
{:ok, signature_params}
end
end
defp validate_components(components) when is_list(components) do
if Enum.all?(components, &is_binary/1), do: :ok, else: malformed_params(:invalid_components)
end
defp validate_components(_components), do: malformed_params(:invalid_components)
defp default_components(%Message{kind: :request}), do: ["@method", "@target-uri"]
defp default_components(%Message{kind: :response}), do: ["@status"]
defp expand_params(params, signing_alg, key_id \\ nil)
defp expand_params(params, signing_alg, key_id) when is_list(params) do
if Keyword.keyword?(params) do
do_expand_params(params, signing_alg, key_id)
else
malformed_params(:invalid_params)
end
end
defp expand_params(_params, _signing_alg, _key_id), do: malformed_params(:invalid_params)
defp do_expand_params(params, signing_alg, key_id) do
with {:ok, created} <- expand_created(params),
{:ok, expires} <- expand_expires(params, created),
{:ok, alg} <- expand_alg(Keyword.get(params, :alg), signing_alg) do
values = %{
created: created,
expires: expires,
nonce: Keyword.get(params, :nonce),
alg: alg,
keyid: if(is_nil(key_id), do: Keyword.get(params, :keyid), else: key_id),
tag: Keyword.get(params, :tag)
}
ordered = Enum.map(@param_order, &{&1, Map.fetch!(values, &1)})
{:ok, Enum.reject(ordered, fn {_key, value} -> is_nil(value) end)}
end
end
defp expand_created(params) do
case Keyword.get(params, :created, :now) do
:now -> {:ok, System.os_time(:second)}
false -> {:ok, nil}
integer when is_integer(integer) -> {:ok, integer}
_ -> malformed_params(:invalid_created)
end
end
defp expand_expires(params, created) do
case {Keyword.get(params, :expires), Keyword.get(params, :expires_in)} do
{nil, nil} -> {:ok, nil}
{expires, nil} -> {:ok, expires}
{nil, seconds} when is_integer(seconds) and is_integer(created) -> {:ok, created + seconds}
{nil, _seconds} -> malformed_params(:expires_in_without_created)
{_expires, _seconds} -> malformed_params(:expires_conflict)
end
end
defp expand_alg(nil, _signing_alg), do: {:ok, nil}
defp expand_alg(true, signing_alg) when not is_nil(signing_alg),
do: {:ok, Algorithms.to_iana(signing_alg)}
defp expand_alg(iana, _signing_alg) when is_binary(iana), do: {:ok, iana}
defp expand_alg(_alg, _signing_alg), do: malformed_params(:invalid_alg)
defp malformed_params(detail),
do: {:error, %Error{reason: :malformed_signature_input, detail: detail}}
defp validate_params_keyword!(params) do
unless Keyword.keyword?(params) do
raise ArgumentError, "signature_base/3 params must be a keyword list"
end
end
defp fetch_sf_types!(opts), do: opts |> Keyword.get(:sf_types, %{}) |> validate_sf_types!()
defp validate_sf_types!(sf_types) when is_map(sf_types) do
if Enum.all?(sf_types, fn {name, type} ->
is_binary(name) and type in [:dictionary, :list, :item]
end) do
sf_types
else
raise ArgumentError,
"sf_types must be a map of field-name strings to :dictionary, :list, or :item"
end
end
defp validate_sf_types!(_sf_types) do
raise ArgumentError,
"sf_types must be a map of field-name strings to :dictionary, :list, or :item"
end
defp do_sign(alg, material, base) do
case Algorithms.sign(alg, material, base) do
{:ok, signature} -> {:ok, signature}
{:error, :invalid_key} -> {:error, %Error{reason: :invalid_key, algorithm: alg}}
end
end
defp serialize_signature_dict(label, signature) do
case StructuredField.serialize_dictionary([{label, {:item, {:binary, signature}, []}}]) do
{:ok, serialized} -> {:ok, serialized}
{:error, reason} -> {:error, %Error{reason: :malformed_signature, detail: reason}}
end
end
end