diff --git a/lib/parrhesia/auth/nip98.ex b/lib/parrhesia/auth/nip98.ex index b860048..ce1811a 100644 --- a/lib/parrhesia/auth/nip98.ex +++ b/lib/parrhesia/auth/nip98.ex @@ -3,6 +3,7 @@ defmodule Parrhesia.Auth.Nip98 do Minimal NIP-98 HTTP auth validation. """ + alias Parrhesia.Auth.Nip98ReplayCache alias Parrhesia.Protocol.EventValidator @max_age_seconds 60 @@ -23,7 +24,8 @@ defmodule Parrhesia.Auth.Nip98 do with {:ok, event_json} <- decode_base64(encoded_event), {:ok, event} <- JSON.decode(event_json), :ok <- validate_event_shape(event, opts), - :ok <- validate_http_binding(event, method, url) do + :ok <- validate_http_binding(event, method, url), + :ok <- consume_replay_token(event, opts) do {:ok, event} else {:error, reason} -> {:error, reason} @@ -95,4 +97,14 @@ defmodule Parrhesia.Auth.Nip98 do true -> :ok end end + + defp consume_replay_token(%{"id" => event_id, "created_at" => created_at}, opts) + when is_binary(event_id) and is_integer(created_at) do + case Keyword.get(opts, :replay_cache, Nip98ReplayCache) do + nil -> :ok + replay_cache -> Nip98ReplayCache.consume(replay_cache, event_id, created_at, opts) + end + end + + defp consume_replay_token(_event, _opts), do: {:error, :invalid_event} end diff --git a/lib/parrhesia/auth/nip98_replay_cache.ex b/lib/parrhesia/auth/nip98_replay_cache.ex new file mode 100644 index 0000000..eb60f7f --- /dev/null +++ b/lib/parrhesia/auth/nip98_replay_cache.ex @@ -0,0 +1,56 @@ +defmodule Parrhesia.Auth.Nip98ReplayCache do + @moduledoc """ + Tracks recently accepted NIP-98 auth event ids to prevent replay. + """ + + use GenServer + + @default_max_age_seconds 60 + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) do + case Keyword.get(opts, :name, __MODULE__) do + nil -> GenServer.start_link(__MODULE__, opts) + name -> GenServer.start_link(__MODULE__, opts, name: name) + end + end + + @spec consume(GenServer.server(), String.t(), integer(), keyword()) :: + :ok | {:error, :replayed_auth_event} + def consume(server \\ __MODULE__, event_id, created_at, opts \\ []) + when is_binary(event_id) and is_integer(created_at) and is_list(opts) do + GenServer.call(server, {:consume, event_id, created_at, opts}) + end + + @impl true + def init(_opts) do + {:ok, %{entries: %{}}} + end + + @impl true + def handle_call({:consume, event_id, created_at, opts}, _from, state) do + now_ms = System.monotonic_time(:millisecond) + entries = prune_expired(state.entries, now_ms) + + case Map.has_key?(entries, event_id) do + true -> + {:reply, {:error, :replayed_auth_event}, %{state | entries: entries}} + + false -> + expires_at_ms = replay_expiration_ms(now_ms, created_at, opts) + next_entries = Map.put(entries, event_id, expires_at_ms) + {:reply, :ok, %{state | entries: next_entries}} + end + end + + defp prune_expired(entries, now_ms) do + Map.reject(entries, fn {_event_id, expires_at_ms} -> expires_at_ms <= now_ms end) + end + + defp replay_expiration_ms(now_ms, created_at, opts) do + max_age_seconds = Keyword.get(opts, :max_age_seconds, max_age_seconds()) + max(now_ms, created_at * 1000) + max_age_seconds * 1000 + end + + defp max_age_seconds, do: @default_max_age_seconds +end diff --git a/lib/parrhesia/auth/supervisor.ex b/lib/parrhesia/auth/supervisor.ex index 68bd0b1..eabf5e7 100644 --- a/lib/parrhesia/auth/supervisor.ex +++ b/lib/parrhesia/auth/supervisor.ex @@ -13,6 +13,7 @@ defmodule Parrhesia.Auth.Supervisor do def init(_init_arg) do children = [ {Parrhesia.Auth.Challenges, name: Parrhesia.Auth.Challenges}, + {Parrhesia.Auth.Nip98ReplayCache, name: Parrhesia.Auth.Nip98ReplayCache}, {Parrhesia.API.Identity.Manager, []} ] diff --git a/lib/parrhesia/web/management.ex b/lib/parrhesia/web/management.ex index c637376..df7a68e 100644 --- a/lib/parrhesia/web/management.ex +++ b/lib/parrhesia/web/management.ex @@ -35,6 +35,9 @@ defmodule Parrhesia.Web.Management do {:error, :stale_event} -> send_json(conn, 401, %{"ok" => false, "error" => "stale-auth-event"}) + {:error, :replayed_auth_event} -> + send_json(conn, 401, %{"ok" => false, "error" => "replayed-auth-event"}) + {:error, :invalid_method_tag} -> send_json(conn, 401, %{"ok" => false, "error" => "auth-method-tag-mismatch"}) diff --git a/test/parrhesia/api/auth_test.exs b/test/parrhesia/api/auth_test.exs index 0aa8ed8..8daced9 100644 --- a/test/parrhesia/api/auth_test.exs +++ b/test/parrhesia/api/auth_test.exs @@ -43,6 +43,21 @@ defmodule Parrhesia.API.AuthTest do assert {:ok, _context} = Auth.validate_nip98(header, "POST", url, max_age_seconds: 180) end + test "validate_nip98 rejects replayed auth events" do + url = "http://example.com/management" + event = nip98_event("POST", url) + header = "Nostr " <> Base.encode64(JSON.encode!(event)) + + replay_cache = + start_supervised!({Parrhesia.Auth.Nip98ReplayCache, name: nil}) + + assert {:ok, _context} = + Auth.validate_nip98(header, "POST", url, replay_cache: replay_cache) + + assert {:error, :replayed_auth_event} = + Auth.validate_nip98(header, "POST", url, replay_cache: replay_cache) + end + defp nip98_event(method, url, overrides \\ %{}) do now = System.system_time(:second) @@ -51,7 +66,7 @@ defmodule Parrhesia.API.AuthTest do "created_at" => now, "kind" => 27_235, "tags" => [["method", method], ["u", url]], - "content" => "", + "content" => "token-#{System.unique_integer([:positive, :monotonic])}", "sig" => String.duplicate("b", 128) } diff --git a/test/parrhesia/application_test.exs b/test/parrhesia/application_test.exs index 55bb135..20faf54 100644 --- a/test/parrhesia/application_test.exs +++ b/test/parrhesia/application_test.exs @@ -20,6 +20,7 @@ defmodule Parrhesia.ApplicationTest do end) assert is_pid(Process.whereis(Parrhesia.Auth.Challenges)) + assert is_pid(Process.whereis(Parrhesia.Auth.Nip98ReplayCache)) assert is_pid(Process.whereis(Parrhesia.API.Identity.Manager)) assert is_pid(Process.whereis(Parrhesia.API.Sync.Manager)) diff --git a/test/parrhesia/auth/nip98_test.exs b/test/parrhesia/auth/nip98_test.exs index a4a4e16..cfccc3d 100644 --- a/test/parrhesia/auth/nip98_test.exs +++ b/test/parrhesia/auth/nip98_test.exs @@ -36,6 +36,31 @@ defmodule Parrhesia.Auth.Nip98Test do Nip98.validate_authorization_header(header, "POST", url, max_age_seconds: 180) end + test "rejects replayed authorization headers" do + url = "http://example.com/management" + event = nip98_event("POST", url) + header = "Nostr " <> Base.encode64(JSON.encode!(event)) + + replay_cache = + start_supervised!({Parrhesia.Auth.Nip98ReplayCache, name: nil}) + + assert {:ok, _event} = + Nip98.validate_authorization_header( + header, + "POST", + url, + replay_cache: replay_cache + ) + + assert {:error, :replayed_auth_event} = + Nip98.validate_authorization_header( + header, + "POST", + url, + replay_cache: replay_cache + ) + end + defp nip98_event(method, url, overrides \\ %{}) do now = System.system_time(:second) @@ -44,7 +69,7 @@ defmodule Parrhesia.Auth.Nip98Test do "created_at" => now, "kind" => 27_235, "tags" => [["method", method], ["u", url]], - "content" => "", + "content" => "token-#{System.unique_integer([:positive, :monotonic])}", "sig" => String.duplicate("b", 128) } diff --git a/test/parrhesia/web/router_test.exs b/test/parrhesia/web/router_test.exs index 7996897..d577ff8 100644 --- a/test/parrhesia/web/router_test.exs +++ b/test/parrhesia/web/router_test.exs @@ -198,14 +198,11 @@ defmodule Parrhesia.Web.RouterTest do test "POST /management accepts valid NIP-98 header" do management_url = "http://www.example.com/management" - auth_event = nip98_event("POST", management_url) - - authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event)) conn = conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}})) |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", authorization) + |> put_req_header("authorization", nip98_authorization("POST", management_url)) |> Router.call([]) assert conn.status == 200 @@ -244,8 +241,6 @@ defmodule Parrhesia.Web.RouterTest do test "POST /management supports ACL methods" do management_url = "http://www.example.com/management" - auth_event = nip98_event("POST", management_url) - authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event)) grant_conn = conn( @@ -262,7 +257,7 @@ defmodule Parrhesia.Web.RouterTest do }) ) |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", authorization) + |> put_req_header("authorization", nip98_authorization("POST", management_url)) |> Router.call([]) assert grant_conn.status == 200 @@ -277,7 +272,7 @@ defmodule Parrhesia.Web.RouterTest do }) ) |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", authorization) + |> put_req_header("authorization", nip98_authorization("POST", management_url)) |> Router.call([]) assert list_conn.status == 200 @@ -299,8 +294,6 @@ defmodule Parrhesia.Web.RouterTest do test "POST /management supports identity methods" do management_url = "http://www.example.com/management" - auth_event = nip98_event("POST", management_url) - authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event)) conn = conn( @@ -312,7 +305,7 @@ defmodule Parrhesia.Web.RouterTest do }) ) |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", authorization) + |> put_req_header("authorization", nip98_authorization("POST", management_url)) |> Router.call([]) assert conn.status == 200 @@ -330,8 +323,6 @@ defmodule Parrhesia.Web.RouterTest do test "POST /management stats and health include sync summary" do management_url = "http://www.example.com/management" - auth_event = nip98_event("POST", management_url) - authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event)) initial_total = Sync.sync_stats() |> elem(1) |> Map.fetch!("servers_total") server_id = "router-sync-#{System.unique_integer([:positive, :monotonic])}" @@ -366,7 +357,7 @@ defmodule Parrhesia.Web.RouterTest do }) ) |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", authorization) + |> put_req_header("authorization", nip98_authorization("POST", management_url)) |> Router.call([]) assert stats_conn.status == 200 @@ -390,7 +381,7 @@ defmodule Parrhesia.Web.RouterTest do }) ) |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", authorization) + |> put_req_header("authorization", nip98_authorization("POST", management_url)) |> Router.call([]) assert health_conn.status == 200 @@ -415,6 +406,32 @@ defmodule Parrhesia.Web.RouterTest do assert conn.status == 404 end + test "POST /management rejects replayed NIP-98 headers" do + management_url = "http://www.example.com/management" + authorization = nip98_authorization("POST", management_url) + + first_conn = + conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}})) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", authorization) + |> Router.call([]) + + assert first_conn.status == 200 + + second_conn = + conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}})) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", authorization) + |> Router.call([]) + + assert second_conn.status == 401 + + assert JSON.decode!(second_conn.resp_body) == %{ + "ok" => false, + "error" => "replayed-auth-event" + } + end + defp nip98_event(method, url) do now = System.system_time(:second) @@ -423,13 +440,17 @@ defmodule Parrhesia.Web.RouterTest do "created_at" => now, "kind" => 27_235, "tags" => [["method", method], ["u", url]], - "content" => "", + "content" => "token-#{System.unique_integer([:positive, :monotonic])}", "sig" => String.duplicate("b", 128) } Map.put(base, "id", EventValidator.compute_id(base)) end + defp nip98_authorization(method, url) do + "Nostr " <> Base.encode64(JSON.encode!(nip98_event(method, url))) + end + defp listener(overrides) do deep_merge( %{