fix: implement Gaiia webhook signature verification per their spec

- Header: X-Gaiia-Webhook-Signature with t=timestamp,v1=signature format
- Signed payload: timestamp.body (not just body)
- 5-minute timestamp tolerance to prevent replay attacks
- Updated tests to match new 4-arity verify_signature/4
This commit is contained in:
Graham McIntire 2026-02-14 13:37:22 -06:00
parent 44be203e73
commit 950f4d9ae0
3 changed files with 76 additions and 17 deletions

View file

@ -12,14 +12,15 @@ defmodule Towerops.Gaiia.Webhooks do
require Logger
@doc """
Verify an HMAC-SHA256 signature against the raw request body.
Verify a Gaiia webhook signature using the `timestamp.body` signed payload format.
Returns `true` if the signature matches, `false` otherwise.
Uses constant-time comparison to prevent timing attacks.
Computes HMAC-SHA256 of `"<timestamp>.<body>"` with the secret key and compares
against the provided signature using constant-time comparison.
"""
@spec verify_signature(binary(), binary(), binary()) :: boolean()
def verify_signature(body, signature, secret) do
expected = :hmac |> :crypto.mac(:sha256, secret, body) |> Base.encode16(case: :lower)
@spec verify_signature(binary(), binary(), binary(), binary()) :: boolean()
def verify_signature(body, timestamp, signature, secret) do
signed_payload = timestamp <> "." <> body
expected = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower)
Plug.Crypto.secure_compare(expected, signature)
end

View file

@ -53,16 +53,60 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
end
defp verify_signature(conn, raw_body, secret) do
case get_req_header(conn, "x-gaiia-signature") do
[signature] ->
if Webhooks.verify_signature(raw_body, signature, secret) do
case get_req_header(conn, "x-gaiia-webhook-signature") do
[header] ->
with {:ok, timestamp, signature} <- parse_signature_header(header),
:ok <- check_timestamp(timestamp),
:ok <- check_signature(timestamp, raw_body, secret, signature) do
:ok
end
_ ->
{:error, :missing_signature}
end
end
defp parse_signature_header(header) do
parts =
header
|> String.split(",")
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Map.new(fn [k, v] -> {k, v} end)
case {Map.get(parts, "t"), Map.get(parts, "v1")} do
{t, v1} when is_binary(t) and is_binary(v1) -> {:ok, t, v1}
_ -> {:error, :missing_signature}
end
rescue
_ -> {:error, :missing_signature}
end
@max_age_seconds 300
defp check_timestamp(timestamp) do
case Integer.parse(timestamp) do
{ts, ""} ->
now = System.system_time(:second)
if abs(now - ts) <= @max_age_seconds do
:ok
else
{:error, :invalid_signature}
end
_ ->
{:error, :missing_signature}
{:error, :invalid_signature}
end
end
defp check_signature(timestamp, raw_body, secret, expected) do
signed_payload = timestamp <> "." <> raw_body
computed = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower)
if Plug.Crypto.secure_compare(computed, expected) do
:ok
else
{:error, :invalid_signature}
end
end

View file

@ -246,29 +246,43 @@ defmodule Towerops.Gaiia.WebhooksTest do
end
end
describe "verify_signature/2" do
test "returns true for valid HMAC-SHA256 signature" do
describe "verify_signature/4" do
test "returns true for valid HMAC-SHA256 signature with timestamp.body format" do
secret = "test-webhook-secret"
body = ~s({"event":"account.created"})
signature = :hmac |> :crypto.mac(:sha256, secret, body) |> Base.encode16(case: :lower)
timestamp = "1492774577"
signed_payload = timestamp <> "." <> body
signature = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower)
assert Webhooks.verify_signature(body, signature, secret) == true
assert Webhooks.verify_signature(body, timestamp, signature, secret) == true
end
test "returns false for invalid signature" do
secret = "test-webhook-secret"
body = ~s({"event":"account.created"})
assert Webhooks.verify_signature(body, "invalid-signature", secret) == false
assert Webhooks.verify_signature(body, "1492774577", "invalid-signature", secret) == false
end
test "returns false for tampered body" do
secret = "test-webhook-secret"
body = ~s({"event":"account.created"})
signature = :hmac |> :crypto.mac(:sha256, secret, body) |> Base.encode16(case: :lower)
timestamp = "1492774577"
signed_payload = timestamp <> "." <> body
signature = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower)
tampered_body = ~s({"event":"account.deleted"})
assert Webhooks.verify_signature(tampered_body, signature, secret) == false
assert Webhooks.verify_signature(tampered_body, timestamp, signature, secret) == false
end
test "returns false for wrong timestamp" do
secret = "test-webhook-secret"
body = ~s({"event":"account.created"})
timestamp = "1492774577"
signed_payload = timestamp <> "." <> body
signature = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower)
assert Webhooks.verify_signature(body, "9999999999", signature, secret) == false
end
end
end