diff --git a/lib/towerops/gaiia/webhooks.ex b/lib/towerops/gaiia/webhooks.ex index 29e5d499..495a2615 100644 --- a/lib/towerops/gaiia/webhooks.ex +++ b/lib/towerops/gaiia/webhooks.ex @@ -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 `"."` 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 diff --git a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex index d2caa899..423e4bbd 100644 --- a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex +++ b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex @@ -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 diff --git a/test/towerops/gaiia/webhooks_test.exs b/test/towerops/gaiia/webhooks_test.exs index 982b93b3..7538cbca 100644 --- a/test/towerops/gaiia/webhooks_test.exs +++ b/test/towerops/gaiia/webhooks_test.exs @@ -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