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.
|
Minimal NIP-98 HTTP auth validation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Parrhesia.Auth.Nip98ReplayCache
|
||||||
alias Parrhesia.Protocol.EventValidator
|
alias Parrhesia.Protocol.EventValidator
|
||||||
|
|
||||||
@max_age_seconds 60
|
@max_age_seconds 60
|
||||||
@@ -23,7 +24,8 @@ defmodule Parrhesia.Auth.Nip98 do
|
|||||||
with {:ok, event_json} <- decode_base64(encoded_event),
|
with {:ok, event_json} <- decode_base64(encoded_event),
|
||||||
{:ok, event} <- JSON.decode(event_json),
|
{:ok, event} <- JSON.decode(event_json),
|
||||||
:ok <- validate_event_shape(event, opts),
|
: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}
|
{:ok, event}
|
||||||
else
|
else
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
@@ -95,4 +97,14 @@ defmodule Parrhesia.Auth.Nip98 do
|
|||||||
true -> :ok
|
true -> :ok
|
||||||
end
|
end
|
||||||
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
|
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
|
def init(_init_arg) do
|
||||||
children = [
|
children = [
|
||||||
{Parrhesia.Auth.Challenges, name: Parrhesia.Auth.Challenges},
|
{Parrhesia.Auth.Challenges, name: Parrhesia.Auth.Challenges},
|
||||||
|
{Parrhesia.Auth.Nip98ReplayCache, name: Parrhesia.Auth.Nip98ReplayCache},
|
||||||
{Parrhesia.API.Identity.Manager, []}
|
{Parrhesia.API.Identity.Manager, []}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ defmodule Parrhesia.Web.Management do
|
|||||||
{:error, :stale_event} ->
|
{:error, :stale_event} ->
|
||||||
send_json(conn, 401, %{"ok" => false, "error" => "stale-auth-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} ->
|
{:error, :invalid_method_tag} ->
|
||||||
send_json(conn, 401, %{"ok" => false, "error" => "auth-method-tag-mismatch"})
|
send_json(conn, 401, %{"ok" => false, "error" => "auth-method-tag-mismatch"})
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ defmodule Parrhesia.API.AuthTest do
|
|||||||
assert {:ok, _context} = Auth.validate_nip98(header, "POST", url, max_age_seconds: 180)
|
assert {:ok, _context} = Auth.validate_nip98(header, "POST", url, max_age_seconds: 180)
|
||||||
end
|
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
|
defp nip98_event(method, url, overrides \\ %{}) do
|
||||||
now = System.system_time(:second)
|
now = System.system_time(:second)
|
||||||
|
|
||||||
@@ -51,7 +66,7 @@ defmodule Parrhesia.API.AuthTest do
|
|||||||
"created_at" => now,
|
"created_at" => now,
|
||||||
"kind" => 27_235,
|
"kind" => 27_235,
|
||||||
"tags" => [["method", method], ["u", url]],
|
"tags" => [["method", method], ["u", url]],
|
||||||
"content" => "",
|
"content" => "token-#{System.unique_integer([:positive, :monotonic])}",
|
||||||
"sig" => String.duplicate("b", 128)
|
"sig" => String.duplicate("b", 128)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ defmodule Parrhesia.ApplicationTest do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
assert is_pid(Process.whereis(Parrhesia.Auth.Challenges))
|
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.Identity.Manager))
|
||||||
assert is_pid(Process.whereis(Parrhesia.API.Sync.Manager))
|
assert is_pid(Process.whereis(Parrhesia.API.Sync.Manager))
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,31 @@ defmodule Parrhesia.Auth.Nip98Test do
|
|||||||
Nip98.validate_authorization_header(header, "POST", url, max_age_seconds: 180)
|
Nip98.validate_authorization_header(header, "POST", url, max_age_seconds: 180)
|
||||||
end
|
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
|
defp nip98_event(method, url, overrides \\ %{}) do
|
||||||
now = System.system_time(:second)
|
now = System.system_time(:second)
|
||||||
|
|
||||||
@@ -44,7 +69,7 @@ defmodule Parrhesia.Auth.Nip98Test do
|
|||||||
"created_at" => now,
|
"created_at" => now,
|
||||||
"kind" => 27_235,
|
"kind" => 27_235,
|
||||||
"tags" => [["method", method], ["u", url]],
|
"tags" => [["method", method], ["u", url]],
|
||||||
"content" => "",
|
"content" => "token-#{System.unique_integer([:positive, :monotonic])}",
|
||||||
"sig" => String.duplicate("b", 128)
|
"sig" => String.duplicate("b", 128)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,14 +198,11 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
|
|
||||||
test "POST /management accepts valid NIP-98 header" do
|
test "POST /management accepts valid NIP-98 header" do
|
||||||
management_url = "http://www.example.com/management"
|
management_url = "http://www.example.com/management"
|
||||||
auth_event = nip98_event("POST", management_url)
|
|
||||||
|
|
||||||
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
|
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
||||||
|> put_req_header("content-type", "application/json")
|
|> put_req_header("content-type", "application/json")
|
||||||
|> put_req_header("authorization", authorization)
|
|> put_req_header("authorization", nip98_authorization("POST", management_url))
|
||||||
|> Router.call([])
|
|> Router.call([])
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
@@ -244,8 +241,6 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
|
|
||||||
test "POST /management supports ACL methods" do
|
test "POST /management supports ACL methods" do
|
||||||
management_url = "http://www.example.com/management"
|
management_url = "http://www.example.com/management"
|
||||||
auth_event = nip98_event("POST", management_url)
|
|
||||||
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
|
|
||||||
|
|
||||||
grant_conn =
|
grant_conn =
|
||||||
conn(
|
conn(
|
||||||
@@ -262,7 +257,7 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> put_req_header("content-type", "application/json")
|
|> put_req_header("content-type", "application/json")
|
||||||
|> put_req_header("authorization", authorization)
|
|> put_req_header("authorization", nip98_authorization("POST", management_url))
|
||||||
|> Router.call([])
|
|> Router.call([])
|
||||||
|
|
||||||
assert grant_conn.status == 200
|
assert grant_conn.status == 200
|
||||||
@@ -277,7 +272,7 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> put_req_header("content-type", "application/json")
|
|> put_req_header("content-type", "application/json")
|
||||||
|> put_req_header("authorization", authorization)
|
|> put_req_header("authorization", nip98_authorization("POST", management_url))
|
||||||
|> Router.call([])
|
|> Router.call([])
|
||||||
|
|
||||||
assert list_conn.status == 200
|
assert list_conn.status == 200
|
||||||
@@ -299,8 +294,6 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
|
|
||||||
test "POST /management supports identity methods" do
|
test "POST /management supports identity methods" do
|
||||||
management_url = "http://www.example.com/management"
|
management_url = "http://www.example.com/management"
|
||||||
auth_event = nip98_event("POST", management_url)
|
|
||||||
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
|
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
conn(
|
conn(
|
||||||
@@ -312,7 +305,7 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> put_req_header("content-type", "application/json")
|
|> put_req_header("content-type", "application/json")
|
||||||
|> put_req_header("authorization", authorization)
|
|> put_req_header("authorization", nip98_authorization("POST", management_url))
|
||||||
|> Router.call([])
|
|> Router.call([])
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
@@ -330,8 +323,6 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
|
|
||||||
test "POST /management stats and health include sync summary" do
|
test "POST /management stats and health include sync summary" do
|
||||||
management_url = "http://www.example.com/management"
|
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")
|
initial_total = Sync.sync_stats() |> elem(1) |> Map.fetch!("servers_total")
|
||||||
server_id = "router-sync-#{System.unique_integer([:positive, :monotonic])}"
|
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("content-type", "application/json")
|
||||||
|> put_req_header("authorization", authorization)
|
|> put_req_header("authorization", nip98_authorization("POST", management_url))
|
||||||
|> Router.call([])
|
|> Router.call([])
|
||||||
|
|
||||||
assert stats_conn.status == 200
|
assert stats_conn.status == 200
|
||||||
@@ -390,7 +381,7 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> put_req_header("content-type", "application/json")
|
|> put_req_header("content-type", "application/json")
|
||||||
|> put_req_header("authorization", authorization)
|
|> put_req_header("authorization", nip98_authorization("POST", management_url))
|
||||||
|> Router.call([])
|
|> Router.call([])
|
||||||
|
|
||||||
assert health_conn.status == 200
|
assert health_conn.status == 200
|
||||||
@@ -415,6 +406,32 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
assert conn.status == 404
|
assert conn.status == 404
|
||||||
end
|
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
|
defp nip98_event(method, url) do
|
||||||
now = System.system_time(:second)
|
now = System.system_time(:second)
|
||||||
|
|
||||||
@@ -423,13 +440,17 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
"created_at" => now,
|
"created_at" => now,
|
||||||
"kind" => 27_235,
|
"kind" => 27_235,
|
||||||
"tags" => [["method", method], ["u", url]],
|
"tags" => [["method", method], ["u", url]],
|
||||||
"content" => "",
|
"content" => "token-#{System.unique_integer([:positive, :monotonic])}",
|
||||||
"sig" => String.duplicate("b", 128)
|
"sig" => String.duplicate("b", 128)
|
||||||
}
|
}
|
||||||
|
|
||||||
Map.put(base, "id", EventValidator.compute_id(base))
|
Map.put(base, "id", EventValidator.compute_id(base))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp nip98_authorization(method, url) do
|
||||||
|
"Nostr " <> Base.encode64(JSON.encode!(nip98_event(method, url)))
|
||||||
|
end
|
||||||
|
|
||||||
defp listener(overrides) do
|
defp listener(overrides) do
|
||||||
deep_merge(
|
deep_merge(
|
||||||
%{
|
%{
|
||||||
|
|||||||
Reference in New Issue
Block a user