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:
parent
44be203e73
commit
950f4d9ae0
3 changed files with 76 additions and 17 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue