Packages

Elixir client for Mercado Pago API

Current section

Files

Jump to
meli lib meli webhook signature.ex
Raw

lib/meli/webhook/signature.ex

defmodule Meli.Webhook.Signature do
@moduledoc """
Handles webhook signature verification for Mercado Pago notifications.
Mercado Pago signs webhook notifications with HMAC-SHA256 to ensure
authenticity. This module provides functions to verify these signatures.
## Signature Header Format
Mercado Pago sends the signature in the `x-signature` header in the format:
ts=TIMESTAMP,v1=SIGNATURE
Where:
* `ts` - Unix timestamp when the signature was generated
* `v1` - HMAC-SHA256 signature
## Verification Process
The signature is computed over a manifest string in the format:
id:[data.id];request-id:[x-request-id];ts:[ts];
Where:
* `data.id` - The ID from query params (lowercased if alphanumeric)
* `x-request-id` - The value from the `x-request-id` header
* `ts` - The timestamp extracted from the `x-signature` header
If any value is missing, that section should be omitted from the manifest.
## Examples
# In your Phoenix/LiveView endpoint
def handle_webhook(conn, params) do
signature_header = Plug.Conn.get_req_header(conn, "x-signature") |> List.first()
x_request_id = Plug.Conn.get_req_header(conn, "x-request-id") |> List.first()
data_id = conn.query_params["data.id"]
secret = Application.get_env(:meli, :webhook_secret)
if Meli.Webhook.Signature.verify?(data_id, x_request_id, signature_header, secret) do
# Process webhook
else
# Reject invalid signature
end
end
"""
import Bitwise
@doc """
Verifies a webhook signature.
## Parameters
* `data_id` - The ID from query params (will be lowercased if alphanumeric)
* `x_request_id` - Value of the `x-request-id` header
* `signature_header` - Value of the `x-signature` header
* `secret` - Your webhook secret from Mercado Pago dashboard
## Examples
iex> Meli.Webhook.Signature.verify?("123", "req-456", "ts=123,v1=abc", "secret")
true
"""
@spec verify?(String.t() | nil, String.t() | nil, String.t(), String.t()) :: boolean()
def verify?(data_id, x_request_id, signature_header, secret)
when is_binary(signature_header) and is_binary(secret) do
with {:ok, timestamp, signature} <- parse_signature_header(signature_header),
:ok <- validate_timestamp(timestamp),
{:ok, expected} <- compute_signature(data_id, x_request_id, timestamp, secret) do
secure_compare(signature, expected)
else
_ -> false
end
end
@doc """
Parses the signature header into timestamp and signature components.
## Examples
iex> Meli.Webhook.Signature.parse_signature_header("ts=1234567890,v1=abc123")
{:ok, "1234567890", "abc123"}
"""
@spec parse_signature_header(String.t()) ::
{:ok, String.t(), String.t()} | {:error, :invalid_format}
def parse_signature_header(header) when is_binary(header) do
parts =
header
|> String.split(",")
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Enum.filter(fn
[_, _] -> true
_ -> false
end)
|> Map.new(fn [k, v] -> {String.trim(k), String.trim(v)} end)
with {:ok, timestamp} <- Map.fetch(parts, "ts"),
{:ok, signature} <- Map.fetch(parts, "v1") do
{:ok, timestamp, signature}
else
:error -> {:error, :invalid_format}
end
end
@doc """
Validates that the timestamp is within acceptable range.
Prevents replay attacks by rejecting timestamps that are too old
or too far in the future.
## Options
* `:max_age` - Maximum age in seconds (default: 300, i.e., 5 minutes)
"""
@spec validate_timestamp(String.t(), keyword()) :: :ok | {:error, :timestamp_expired}
def validate_timestamp(timestamp, opts \\ []) do
max_age = Keyword.get(opts, :max_age, 300)
case Integer.parse(timestamp) do
{ts, ""} ->
ts = normalize_timestamp(ts)
now = System.system_time(:second)
if abs(now - ts) <= max_age, do: :ok, else: {:error, :timestamp_expired}
_ ->
{:error, :timestamp_expired}
end
end
@spec normalize_timestamp(integer()) :: integer()
defp normalize_timestamp(ts) when ts >= 1_000_000_000_000, do: div(ts, 1000)
defp normalize_timestamp(ts), do: ts
@doc """
Computes expected signature for a webhook payload.
The signature is computed as HMAC-SHA256 of the manifest string:
`id:[data_id];request-id:[x_request_id];ts:[ts];`
If data_id or x_request_id are nil or empty, those sections are omitted.
## Parameters
* `data_id` - The ID from query params (will be lowercased if alphanumeric)
* `x_request_id` - Value of the `x-request-id` header
* `timestamp` - The timestamp from the signature header
* `secret` - Your webhook secret
## Examples
iex> Meli.Webhook.Signature.compute_signature("123", "req-456", "1704908010", "secret")
{:ok, "a1b2c3..."}
"""
@spec compute_signature(String.t() | nil, String.t() | nil, String.t(), String.t()) ::
{:ok, String.t()} | {:error, term()}
def compute_signature(data_id, x_request_id, timestamp, secret) do
manifest = build_manifest(data_id, x_request_id, timestamp)
signature =
:crypto.mac(:hmac, :sha256, secret, manifest)
|> Base.encode16(case: :lower)
{:ok, signature}
end
@doc false
@spec build_manifest(String.t() | nil, String.t() | nil, String.t()) :: String.t()
def build_manifest(data_id, x_request_id, timestamp) do
data_id = normalize_data_id(data_id)
parts = []
parts =
if is_binary(data_id) and data_id != "" do
["id:#{data_id}" | parts]
else
parts
end
parts =
if is_binary(x_request_id) and x_request_id != "" do
["request-id:#{x_request_id}" | parts]
else
parts
end
parts = ["ts:#{timestamp}" | parts]
parts
|> Enum.reverse()
|> Enum.join(";")
|> Kernel.<>(";")
end
@doc false
@spec normalize_data_id(String.t() | nil) :: String.t() | nil
def normalize_data_id(nil), do: nil
def normalize_data_id(""), do: ""
def normalize_data_id(data_id) when is_binary(data_id) do
if alphanumeric?(data_id) do
String.downcase(data_id)
else
data_id
end
end
@spec alphanumeric?(String.t()) :: boolean()
defp alphanumeric?(string) do
String.match?(string, ~r/^[a-zA-Z0-9]+$/)
end
defp secure_compare(left, right) when byte_size(left) != byte_size(right), do: false
defp secure_compare(left, right) do
left_bytes = :binary.bin_to_list(left)
right_bytes = :binary.bin_to_list(right)
result =
Enum.zip(left_bytes, right_bytes)
|> Enum.reduce(0, fn {a, b}, acc -> Bitwise.bxor(a, b) ||| acc end)
result == 0
end
end