lib/audio_proxy/signature.ex
Rama main · réplica de lectura del repositorio original.
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