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