Prevent NIP-98 token replay
This commit is contained in:
@@ -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
|
||||
|
||||
56
lib/parrhesia/auth/nip98_replay_cache.ex
Normal file
56
lib/parrhesia/auth/nip98_replay_cache.ex
Normal file
@@ -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
|
||||
@@ -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, []}
|
||||
]
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user