Ctrl K
ComunidadGuías…

lib/audio_proxy/signature.ex

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
defmodule AudioProxy.Signature do
  @moduledoc """
  URL signature generation and verification (API doc §1).

  A signed URL carries its signature as the first path segment:

      /{signature}/{options}/{source}

  The signature is `base64url(HMAC-SHA256(key, salt ‖ rest-of-path))`, where
  rest-of-path is the exact byte sequence after the signature segment, leading
  `/` included. Output is unpadded base64url; verification accepts padded and
  unpadded input. Key and salt are the hex-decoded `AP_KEY`/`AP_SALT` values
  from `AudioProxy.Config`.

  `sign/3` is the reference signer — tests use it, and clients can mirror it.
  `verify/2` is the request-path gate: constant-time comparison, and the
  literal `insecure` segment accepted only when `AP_ALLOW_INSECURE` is set.
  """

  alias AudioProxy.Config

  @insecure_segment "insecure"

  # An HMAC-SHA256 is 32 bytes → 43 unpadded base64url characters, or 44 with
  # the single canonical trailing "=".
  @sig_chars_unpadded 43

  @typedoc "Verification failure reason. The only one callers ever get."
  @type error_reason :: :invalid_signature

  @doc """
  Signs `rest_of_path` with `key` and `salt`, returning unpadded base64url.

  `rest_of_path` is everything after the signature segment, leading `/`
  required — exactly what `verify/2` expects back. A path without the leading
  slash can never verify (the request gate always prepends it), so it is
  rejected here with a `FunctionClauseError` instead of producing an unusable
  signature.
  """
  @spec sign(binary(), binary(), binary()) :: String.t()
  def sign("/" <> _ = rest_of_path, key, salt)
      when is_binary(key) and is_binary(salt) do
    key |> mac(salt, rest_of_path) |> Base.url_encode64(padding: false)
  end

  @doc """
  Verifies `sig_segment` against `rest_of_path` using the configured key/salt.

  Returns `:ok` or `{:error, :invalid_signature}` — callers get no finer
  distinction, so a missing key, garbage base64url, and a wrong MAC are
  indistinguishable from the outside.

  The literal `insecure` segment passes only when `AP_ALLOW_INSECURE` is
  enabled; otherwise it is just another invalid signature. Comparison runs on
  the decoded MAC bytes via `Plug.Crypto.secure_compare/2` (constant-time).

  Verification is strict about canonicality: a signature is 43 unpadded
  base64url characters or the same 43 with one trailing `=`, and the decoded
  bytes must re-encode to exactly the supplied characters. Over-padded input
  and the four final-character variants that decode to the same bytes are
  rejected — a signature is non-malleable, with exactly two accepted
  spellings. (Canonicalization of the *path* itself — percent-encoding,
  option order — belongs to the options parser, not this layer.)
  """
  @spec verify(binary(), binary()) :: :ok | {:error, error_reason()}
  def verify(sig_segment, rest_of_path)
      when is_binary(sig_segment) and is_binary(rest_of_path) do
    config = Config.all()

    cond do
      sig_segment == @insecure_segment ->
        if config.allow_insecure, do: :ok, else: {:error, :invalid_signature}

      is_nil(config.key) or is_nil(config.salt) ->
        {:error, :invalid_signature}

      true ->
        expected = mac(config.key, config.salt, rest_of_path)

        case decode(sig_segment) do
          {:ok, provided} -> compare(provided, expected)
          :error -> {:error, :invalid_signature}
        end
    end
  end

  defp mac(key, salt, data), do: :crypto.mac(:hmac, :sha256, key, salt <> data)

  # Strict decode: 43 unpadded chars, or 44 with exactly one trailing "=";
  # the decoded MAC must re-encode to the supplied characters, which rejects
  # over-padding and non-canonical final characters (4 variants share the
  # same 32 bytes — see the tamper test). Anything else is not a signature.
  defp decode(segment) do
    with true <- byte_size(segment) in [@sig_chars_unpadded, @sig_chars_unpadded + 1],
         trimmed <- String.trim_trailing(segment, "="),
         true <- byte_size(trimmed) == @sig_chars_unpadded,
         {:ok, mac} <- Base.url_decode64(trimmed, padding: false),
         true <- Base.url_encode64(mac, padding: false) == trimmed do
      {:ok, mac}
    else
      _ -> :error
    end
  end

  # secure_compare/2 is constant-time over equal-length inputs and returns
  # false immediately on a length mismatch — which also rejects decoded
  # segments that aren't the 32 bytes of an HMAC-SHA256.
  defp compare(provided, expected) do
    if Plug.Crypto.secure_compare(provided, expected) do
      :ok
    else
      {:error, :invalid_signature}
    end
  end
end