Implement strict NIP-01 event validation and canonical reply prefixes
This commit is contained in:
@@ -13,10 +13,10 @@ Implementation checklist for Parrhesia relay.
|
||||
|
||||
- [x] Implement websocket endpoint + per-connection process
|
||||
- [x] Implement message decode/encode for `EVENT`, `REQ`, `CLOSE`
|
||||
- [ ] Implement strict event validation (`id`, `sig`, shape, timestamps)
|
||||
- [x] Implement strict event validation (`id`, `sig`, shape, timestamps)
|
||||
- [ ] Implement filter evaluation engine (AND/OR semantics)
|
||||
- [ ] Implement subscription lifecycle + `EOSE` behavior
|
||||
- [ ] Implement canonical `OK`, `NOTICE`, `CLOSED` responses + prefixes
|
||||
- [x] Implement canonical `OK`, `NOTICE`, `CLOSED` responses + prefixes
|
||||
|
||||
## Phase 2 — storage boundary + postgres adapter
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ config :parrhesia,
|
||||
max_frame_bytes: 1_048_576,
|
||||
max_event_bytes: 262_144,
|
||||
max_filters_per_req: 16,
|
||||
max_subscriptions_per_connection: 32
|
||||
max_subscriptions_per_connection: 32,
|
||||
max_event_future_skew_seconds: 900
|
||||
],
|
||||
policies: [
|
||||
auth_required_for_writes: false,
|
||||
|
||||
@@ -3,6 +3,8 @@ defmodule Parrhesia.Protocol do
|
||||
Nostr protocol message decode/encode helpers.
|
||||
"""
|
||||
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
|
||||
@type event :: map()
|
||||
@type filter :: map()
|
||||
|
||||
@@ -32,6 +34,14 @@ defmodule Parrhesia.Protocol do
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_event(event()) :: :ok | {:error, String.t()}
|
||||
def validate_event(event) do
|
||||
case EventValidator.validate(event) do
|
||||
:ok -> :ok
|
||||
{:error, reason} -> {:error, EventValidator.error_message(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec encode_relay(relay_message()) :: binary()
|
||||
def encode_relay(message) do
|
||||
message
|
||||
@@ -42,11 +52,11 @@ defmodule Parrhesia.Protocol do
|
||||
@spec decode_error_notice(decode_error()) :: String.t()
|
||||
def decode_error_notice(reason) do
|
||||
case reason do
|
||||
:invalid_json -> "error:invalid: malformed JSON"
|
||||
:invalid_message -> "error:invalid: unsupported message shape"
|
||||
:invalid_event -> "error:invalid: invalid EVENT shape"
|
||||
:invalid_subscription_id -> "error:invalid: invalid subscription id"
|
||||
:invalid_filters -> "error:invalid: invalid filters"
|
||||
:invalid_json -> "invalid: malformed JSON"
|
||||
:invalid_message -> "invalid: unsupported message shape"
|
||||
:invalid_event -> "invalid: invalid EVENT shape"
|
||||
:invalid_subscription_id -> "invalid: invalid subscription id"
|
||||
:invalid_filters -> "invalid: invalid filters"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,15 +67,14 @@ defmodule Parrhesia.Protocol do
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_message(["EVENT", event]) do
|
||||
case valid_event?(event) do
|
||||
true -> {:ok, {:event, event}}
|
||||
false -> {:error, :invalid_event}
|
||||
end
|
||||
end
|
||||
defp decode_message(["EVENT", event]) when is_map(event), do: {:ok, {:event, event}}
|
||||
defp decode_message(["EVENT", _event]), do: {:error, :invalid_event}
|
||||
|
||||
defp decode_message(["REQ", subscription_id | filters]) when is_binary(subscription_id) do
|
||||
cond do
|
||||
not valid_subscription_id?(subscription_id) ->
|
||||
{:error, :invalid_subscription_id}
|
||||
|
||||
filters == [] ->
|
||||
{:error, :invalid_filters}
|
||||
|
||||
@@ -81,33 +90,23 @@ defmodule Parrhesia.Protocol do
|
||||
do: {:error, :invalid_subscription_id}
|
||||
|
||||
defp decode_message(["CLOSE", subscription_id]) when is_binary(subscription_id) do
|
||||
if valid_subscription_id?(subscription_id) do
|
||||
{:ok, {:close, subscription_id}}
|
||||
else
|
||||
{:error, :invalid_subscription_id}
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_message(["CLOSE", _subscription_id]), do: {:error, :invalid_subscription_id}
|
||||
defp decode_message(_other), do: {:error, :invalid_message}
|
||||
|
||||
defp valid_event?(%{
|
||||
"id" => id,
|
||||
"pubkey" => pubkey,
|
||||
"created_at" => created_at,
|
||||
"kind" => kind,
|
||||
"tags" => tags,
|
||||
"content" => content,
|
||||
"sig" => sig
|
||||
}) do
|
||||
is_binary(id) and is_binary(pubkey) and is_integer(created_at) and is_integer(kind) and
|
||||
is_list(tags) and Enum.all?(tags, &valid_tag?/1) and is_binary(content) and is_binary(sig)
|
||||
end
|
||||
|
||||
defp valid_event?(_other), do: false
|
||||
|
||||
defp valid_tag?(tag) when is_list(tag), do: Enum.all?(tag, &is_binary/1)
|
||||
defp valid_tag?(_other), do: false
|
||||
|
||||
defp relay_frame({:notice, message}), do: ["NOTICE", message]
|
||||
defp relay_frame({:ok, event_id, accepted, message}), do: ["OK", event_id, accepted, message]
|
||||
defp relay_frame({:closed, subscription_id, message}), do: ["CLOSED", subscription_id, message]
|
||||
defp relay_frame({:eose, subscription_id}), do: ["EOSE", subscription_id]
|
||||
defp relay_frame({:event, subscription_id, event}), do: ["EVENT", subscription_id, event]
|
||||
|
||||
defp valid_subscription_id?(subscription_id) do
|
||||
subscription_id != "" and String.length(subscription_id) <= 64
|
||||
end
|
||||
end
|
||||
|
||||
150
lib/parrhesia/protocol/event_validator.ex
Normal file
150
lib/parrhesia/protocol/event_validator.ex
Normal file
@@ -0,0 +1,150 @@
|
||||
defmodule Parrhesia.Protocol.EventValidator do
|
||||
@moduledoc """
|
||||
Strict NIP-01 event validation helpers.
|
||||
"""
|
||||
|
||||
@required_fields ~w[id pubkey created_at kind tags content sig]
|
||||
@max_kind 65_535
|
||||
@default_max_event_future_skew_seconds 900
|
||||
|
||||
@type error_reason ::
|
||||
:invalid_shape
|
||||
| :invalid_id
|
||||
| :invalid_pubkey
|
||||
| :invalid_created_at
|
||||
| :created_at_too_far_in_future
|
||||
| :invalid_kind
|
||||
| :invalid_tags
|
||||
| :invalid_content
|
||||
| :invalid_sig
|
||||
| :invalid_id_hash
|
||||
|
||||
@spec validate(map()) :: :ok | {:error, error_reason()}
|
||||
def validate(event) when is_map(event) do
|
||||
with :ok <- validate_required_fields(event),
|
||||
:ok <- validate_id_shape(event["id"]),
|
||||
:ok <- validate_pubkey(event["pubkey"]),
|
||||
:ok <- validate_created_at(event["created_at"]),
|
||||
:ok <- validate_kind(event["kind"]),
|
||||
:ok <- validate_tags(event["tags"]),
|
||||
:ok <- validate_content(event["content"]),
|
||||
:ok <- validate_sig(event["sig"]) do
|
||||
validate_id_hash(event)
|
||||
end
|
||||
end
|
||||
|
||||
def validate(_event), do: {:error, :invalid_shape}
|
||||
|
||||
@spec compute_id(map()) :: String.t()
|
||||
def compute_id(event) do
|
||||
[
|
||||
0,
|
||||
event["pubkey"],
|
||||
event["created_at"],
|
||||
event["kind"],
|
||||
event["tags"],
|
||||
event["content"]
|
||||
]
|
||||
|> Jason.encode!()
|
||||
|> then(&:crypto.hash(:sha256, &1))
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
@error_messages %{
|
||||
invalid_shape:
|
||||
"invalid: event must include id, pubkey, created_at, kind, tags, content, and sig",
|
||||
invalid_id: "invalid: id must be 32-byte lowercase hex",
|
||||
invalid_pubkey: "invalid: pubkey must be 32-byte lowercase hex",
|
||||
invalid_created_at: "invalid: created_at must be a non-negative integer unix timestamp",
|
||||
created_at_too_far_in_future:
|
||||
"invalid: event creation date is too far off from the current time",
|
||||
invalid_kind: "invalid: kind must be an integer between 0 and 65535",
|
||||
invalid_tags: "invalid: tags must be an array of non-empty string arrays",
|
||||
invalid_content: "invalid: content must be a string",
|
||||
invalid_sig: "invalid: sig must be 64-byte lowercase hex",
|
||||
invalid_id_hash: "invalid: event id does not match serialized event"
|
||||
}
|
||||
|
||||
@spec error_message(error_reason()) :: String.t()
|
||||
def error_message(reason), do: Map.fetch!(@error_messages, reason)
|
||||
|
||||
defp validate_required_fields(event) do
|
||||
if Enum.all?(@required_fields, &Map.has_key?(event, &1)) do
|
||||
:ok
|
||||
else
|
||||
{:error, :invalid_shape}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_id_shape(id) when is_binary(id) do
|
||||
if lowercase_hex?(id, 32), do: :ok, else: {:error, :invalid_id}
|
||||
end
|
||||
|
||||
defp validate_id_shape(_id), do: {:error, :invalid_id}
|
||||
|
||||
defp validate_pubkey(pubkey) when is_binary(pubkey) do
|
||||
if lowercase_hex?(pubkey, 32), do: :ok, else: {:error, :invalid_pubkey}
|
||||
end
|
||||
|
||||
defp validate_pubkey(_pubkey), do: {:error, :invalid_pubkey}
|
||||
|
||||
defp validate_created_at(created_at) when is_integer(created_at) and created_at >= 0 do
|
||||
now = System.system_time(:second)
|
||||
max_future_skew = max_event_future_skew_seconds()
|
||||
|
||||
if created_at <= now + max_future_skew do
|
||||
:ok
|
||||
else
|
||||
{:error, :created_at_too_far_in_future}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_created_at(_created_at), do: {:error, :invalid_created_at}
|
||||
|
||||
defp validate_kind(kind) when is_integer(kind) and kind >= 0 and kind <= @max_kind, do: :ok
|
||||
defp validate_kind(_kind), do: {:error, :invalid_kind}
|
||||
|
||||
defp validate_tags(tags) when is_list(tags) do
|
||||
if Enum.all?(tags, &valid_tag?/1) do
|
||||
:ok
|
||||
else
|
||||
{:error, :invalid_tags}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_tags(_tags), do: {:error, :invalid_tags}
|
||||
|
||||
defp validate_content(content) when is_binary(content), do: :ok
|
||||
defp validate_content(_content), do: {:error, :invalid_content}
|
||||
|
||||
defp validate_sig(sig) when is_binary(sig) do
|
||||
if lowercase_hex?(sig, 64), do: :ok, else: {:error, :invalid_sig}
|
||||
end
|
||||
|
||||
defp validate_sig(_sig), do: {:error, :invalid_sig}
|
||||
|
||||
defp validate_id_hash(event) do
|
||||
if event["id"] == compute_id(event) do
|
||||
:ok
|
||||
else
|
||||
{:error, :invalid_id_hash}
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_tag?(tag) when is_list(tag) do
|
||||
tag != [] and Enum.all?(tag, &is_binary/1)
|
||||
end
|
||||
|
||||
defp valid_tag?(_tag), do: false
|
||||
|
||||
defp lowercase_hex?(value, bytes) do
|
||||
byte_size(value) == bytes * 2 and
|
||||
match?({:ok, _decoded}, Base.decode16(value, case: :lower))
|
||||
end
|
||||
|
||||
defp max_event_future_skew_seconds do
|
||||
:parrhesia
|
||||
|> Application.get_env(:limits, [])
|
||||
|> Keyword.get(:max_event_future_skew_seconds, @default_max_event_future_skew_seconds)
|
||||
end
|
||||
end
|
||||
@@ -26,12 +26,13 @@ defmodule Parrhesia.Web.Connection do
|
||||
event_id = Map.get(event, "id", "")
|
||||
|
||||
response =
|
||||
Protocol.encode_relay({
|
||||
:ok,
|
||||
event_id,
|
||||
false,
|
||||
"error:unsupported: EVENT ingest not implemented"
|
||||
})
|
||||
case Protocol.validate_event(event) do
|
||||
:ok ->
|
||||
Protocol.encode_relay({:ok, event_id, false, "error: EVENT ingest not implemented"})
|
||||
|
||||
{:error, message} ->
|
||||
Protocol.encode_relay({:ok, event_id, false, message})
|
||||
end
|
||||
|
||||
{:push, {:text, response}, state}
|
||||
|
||||
@@ -45,7 +46,7 @@ defmodule Parrhesia.Web.Connection do
|
||||
next_state = drop_subscription(state, subscription_id)
|
||||
|
||||
response =
|
||||
Protocol.encode_relay({:closed, subscription_id, "closed: subscription closed"})
|
||||
Protocol.encode_relay({:closed, subscription_id, "error: subscription closed"})
|
||||
|
||||
{:push, {:text, response}, next_state}
|
||||
|
||||
@@ -58,7 +59,7 @@ defmodule Parrhesia.Web.Connection do
|
||||
@impl true
|
||||
def handle_in({_payload, [opcode: :binary]}, %__MODULE__{} = state) do
|
||||
response =
|
||||
Protocol.encode_relay({:notice, "error:invalid: binary websocket frames are not supported"})
|
||||
Protocol.encode_relay({:notice, "invalid: binary websocket frames are not supported"})
|
||||
|
||||
{:push, {:text, response}, state}
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule Parrhesia.ConfigTest do
|
||||
test "returns configured relay limits/policies/features" do
|
||||
assert Parrhesia.Config.get([:limits, :max_frame_bytes]) == 1_048_576
|
||||
assert Parrhesia.Config.get([:limits, :max_event_bytes]) == 262_144
|
||||
assert Parrhesia.Config.get([:limits, :max_event_future_skew_seconds]) == 900
|
||||
assert Parrhesia.Config.get([:policies, :auth_required_for_writes]) == false
|
||||
assert Parrhesia.Config.get([:features, :nip_ee_mls]) == false
|
||||
end
|
||||
|
||||
@@ -2,21 +2,10 @@ defmodule Parrhesia.ProtocolTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Parrhesia.Protocol
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
|
||||
test "decodes valid EVENT frame" do
|
||||
payload =
|
||||
Jason.encode!([
|
||||
"EVENT",
|
||||
%{
|
||||
"id" => String.duplicate("0", 64),
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => 1_715_000_000,
|
||||
"kind" => 1,
|
||||
"tags" => [["p", String.duplicate("2", 64)]],
|
||||
"content" => "hello",
|
||||
"sig" => String.duplicate("3", 128)
|
||||
}
|
||||
])
|
||||
test "decodes EVENT frame shape" do
|
||||
payload = Jason.encode!(["EVENT", valid_event()])
|
||||
|
||||
assert {:ok, {:event, event}} = Protocol.decode_client(payload)
|
||||
assert event["kind"] == 1
|
||||
@@ -33,16 +22,58 @@ defmodule Parrhesia.ProtocolTest do
|
||||
assert {:ok, {:close, "sub-1"}} = Protocol.decode_client(close_payload)
|
||||
end
|
||||
|
||||
test "rejects invalid subscription ids" do
|
||||
empty_sub_payload = Jason.encode!(["REQ", "", %{"kinds" => [1]}])
|
||||
long_sub_payload = Jason.encode!(["CLOSE", String.duplicate("x", 65)])
|
||||
|
||||
assert {:error, :invalid_subscription_id} = Protocol.decode_client(empty_sub_payload)
|
||||
assert {:error, :invalid_subscription_id} = Protocol.decode_client(long_sub_payload)
|
||||
end
|
||||
|
||||
test "returns decode errors for malformed messages" do
|
||||
assert {:error, :invalid_json} = Protocol.decode_client("not-json")
|
||||
assert {:error, :invalid_filters} = Protocol.decode_client(Jason.encode!(["REQ", "sub-1"]))
|
||||
|
||||
assert {:error, :invalid_event} =
|
||||
Protocol.decode_client(Jason.encode!(["EVENT", %{"id" => "nope"}]))
|
||||
Protocol.decode_client(Jason.encode!(["EVENT", "not-a-map"]))
|
||||
end
|
||||
|
||||
test "validates strict NIP-01 event fields" do
|
||||
event = valid_event()
|
||||
assert :ok = Protocol.validate_event(event)
|
||||
|
||||
future_event = Map.put(event, "created_at", System.system_time(:second) + 3_600)
|
||||
future_event = Map.put(future_event, "id", EventValidator.compute_id(future_event))
|
||||
|
||||
assert {:error, "invalid: event creation date is too far off from the current time"} =
|
||||
Protocol.validate_event(future_event)
|
||||
|
||||
mismatched_id_event = Map.put(event, "id", String.duplicate("f", 64))
|
||||
|
||||
assert {:error, "invalid: event id does not match serialized event"} =
|
||||
Protocol.validate_event(mismatched_id_event)
|
||||
|
||||
bad_sig_event = Map.put(event, "sig", "abc")
|
||||
|
||||
assert {:error, "invalid: sig must be 64-byte lowercase hex"} =
|
||||
Protocol.validate_event(bad_sig_event)
|
||||
end
|
||||
|
||||
test "encodes relay messages" do
|
||||
frame = Protocol.encode_relay({:closed, "sub-1", "closed: subscription closed"})
|
||||
assert Jason.decode!(frame) == ["CLOSED", "sub-1", "closed: subscription closed"]
|
||||
frame = Protocol.encode_relay({:closed, "sub-1", "error: subscription closed"})
|
||||
assert Jason.decode!(frame) == ["CLOSED", "sub-1", "error: subscription closed"]
|
||||
end
|
||||
|
||||
defp valid_event do
|
||||
base_event = %{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => 1,
|
||||
"tags" => [["p", String.duplicate("2", 64)]],
|
||||
"content" => "hello",
|
||||
"sig" => String.duplicate("3", 128)
|
||||
}
|
||||
|
||||
Map.put(base_event, "id", EventValidator.compute_id(base_event))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule Parrhesia.Web.ConnectionTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
alias Parrhesia.Web.Connection
|
||||
|
||||
test "REQ registers subscription and replies with EOSE" do
|
||||
@@ -27,7 +28,7 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
Connection.handle_in({close_payload, [opcode: :text]}, subscribed_state)
|
||||
|
||||
refute MapSet.member?(next_state.subscriptions, "sub-123")
|
||||
assert Jason.decode!(response) == ["CLOSED", "sub-123", "closed: subscription closed"]
|
||||
assert Jason.decode!(response) == ["CLOSED", "sub-123", "error: subscription closed"]
|
||||
end
|
||||
|
||||
test "invalid input returns NOTICE" do
|
||||
@@ -36,34 +37,53 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({"not-json", [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(response) == ["NOTICE", "error:invalid: malformed JSON"]
|
||||
assert Jason.decode!(response) == ["NOTICE", "invalid: malformed JSON"]
|
||||
end
|
||||
|
||||
test "EVENT currently replies with unsupported OK" do
|
||||
test "valid EVENT currently replies with unsupported OK" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
|
||||
payload =
|
||||
Jason.encode!([
|
||||
"EVENT",
|
||||
%{
|
||||
"id" => String.duplicate("0", 64),
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => 1_715_000_000,
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "hello",
|
||||
"sig" => String.duplicate("3", 128)
|
||||
}
|
||||
])
|
||||
event = valid_event()
|
||||
payload = Jason.encode!(["EVENT", event])
|
||||
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(response) == [
|
||||
"OK",
|
||||
String.duplicate("0", 64),
|
||||
event["id"],
|
||||
false,
|
||||
"error:unsupported: EVENT ingest not implemented"
|
||||
"error: EVENT ingest not implemented"
|
||||
]
|
||||
end
|
||||
|
||||
test "invalid EVENT replies with OK false invalid prefix" do
|
||||
{:ok, state} = Connection.init(%{})
|
||||
|
||||
event = valid_event() |> Map.put("sig", "nope")
|
||||
payload = Jason.encode!(["EVENT", event])
|
||||
|
||||
assert {:push, {:text, response}, ^state} =
|
||||
Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(response) == [
|
||||
"OK",
|
||||
event["id"],
|
||||
false,
|
||||
"invalid: sig must be 64-byte lowercase hex"
|
||||
]
|
||||
end
|
||||
|
||||
defp valid_event do
|
||||
base_event = %{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "hello",
|
||||
"sig" => String.duplicate("3", 128)
|
||||
}
|
||||
|
||||
Map.put(base_event, "id", EventValidator.compute_id(base_event))
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user