defmodule GitGud.Federation.HttpSignaturesTest do
use ExUnit.Case, async: true
alias GitGud.Federation
alias GitGud.Federation.HttpSignatures
setup do
{pub_pem, priv_pem} = Federation.generate_keypair()
{:ok, pub: pub_pem, priv: priv_pem, key_id: "https://x.example/users/alice#main-key"}
end
test "parse_header handles a typical AP signature header", _ctx do
header =
"keyId=\"https://x.example/u/a#main-key\"" <>
",algorithm=\"rsa-sha256\"" <>
",headers=\"(request-target) host date digest\"" <>
",signature=\"ZmFrZQ==\""
assert {:ok, params} = HttpSignatures.parse_header(header)
assert params["keyId"] == "https://x.example/u/a#main-key"
assert params["algorithm"] == "rsa-sha256"
assert params["headers"] == "(request-target) host date digest"
assert params["signature"] == "ZmFrZQ=="
end
test "rejects malformed signature headers", _ctx do
assert {:error, _} = HttpSignatures.parse_header("totally not a sig")
end
test "signing_string composes (request-target) and headers", _ctx do
lookup = fn
"host" -> "x.example"
"date" -> "Wed, 09 Mar 2026 12:00:00 GMT"
"digest" -> "SHA-256=AAA"
_ -> nil
end
s = HttpSignatures.signing_string("(request-target) host date digest", lookup, "post /inbox")
assert s =~ "(request-target): post /inbox"
assert s =~ "host: x.example"
assert s =~ "digest: SHA-256=AAA"
end
test "sign_request → verify round-trip", %{pub: pub, priv: priv, key_id: key_id} do
body = ~s({"type":"Follow"})
headers = HttpSignatures.sign_request("post", "/inbox", "x.example", body, priv, key_id)
assert {_, sig_value} = Enum.find(headers, fn {h, _} -> h == "signature" end)
{_, host} = Enum.find(headers, fn {h, _} -> h == "host" end)
{_, date} = Enum.find(headers, fn {h, _} -> h == "date" end)
{_, digest} = Enum.find(headers, fn {h, _} -> h == "digest" end)
assert :ok = HttpSignatures.verify_digest(digest, body)
{:ok, params} = HttpSignatures.parse_header(sig_value)
assert params["headers"] == "(request-target) host date digest"
lookup = fn
"host" -> host
"date" -> date
"digest" -> digest
_ -> nil
end
signing = HttpSignatures.signing_string(params["headers"], lookup, "post /inbox")
assert :ok = HttpSignatures.verify(pub, signing, params["signature"])
end
test "verify rejects a tampered signing string", %{pub: pub, priv: priv, key_id: key_id} do
body = "{}"
sig_headers = HttpSignatures.sign_request("post", "/inbox", "x.example", body, priv, key_id)
sig =
Enum.find_value(sig_headers, fn
{"signature", v} -> v
_ -> nil
end)
{:ok, params} = HttpSignatures.parse_header(sig)
assert {:error, :bad_signature} =
HttpSignatures.verify(pub, "post /TAMPERED\nhost: nope", params["signature"])
end
test "verify_digest detects body mismatch", _ctx do
body = "hello"
digest = "SHA-256=" <> Base.encode64(:crypto.hash(:sha256, body))
assert :ok = HttpSignatures.verify_digest(digest, body)
assert {:error, :digest_mismatch} = HttpSignatures.verify_digest(digest, "tampered")
end
test "verify_clock_skew enforces ±5 min window", _ctx do
now = DateTime.utc_now() |> DateTime.truncate(:second)
fresh = Calendar.strftime(now, "%a, %d %b %Y %H:%M:%S GMT")
assert :ok = HttpSignatures.verify_clock_skew(fresh)
far_past = DateTime.add(now, -3600) |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT")
assert {:error, :clock_skew} = HttpSignatures.verify_clock_skew(far_past, now)
end
end
3.6 KiB · text
5af55d9