diff --git a/PROGRESS.md b/PROGRESS.md index 1f67189..704c1f1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/config/config.exs b/config/config.exs index 13861e0..8074a59 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/lib/parrhesia/protocol.ex b/lib/parrhesia/protocol.ex index 8c6db80..bbcd766 100644 --- a/lib/parrhesia/protocol.ex +++ b/lib/parrhesia/protocol.ex @@ -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 - {:ok, {:close, subscription_id}} + 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 diff --git a/lib/parrhesia/protocol/event_validator.ex b/lib/parrhesia/protocol/event_validator.ex new file mode 100644 index 0000000..825e7e4 --- /dev/null +++ b/lib/parrhesia/protocol/event_validator.ex @@ -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 diff --git a/lib/parrhesia/web/connection.ex b/lib/parrhesia/web/connection.ex index cfd0fd5..d450fd8 100644 --- a/lib/parrhesia/web/connection.ex +++ b/lib/parrhesia/web/connection.ex @@ -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 diff --git a/test/parrhesia/config_test.exs b/test/parrhesia/config_test.exs index 7a57a2e..955ad1f 100644 --- a/test/parrhesia/config_test.exs +++ b/test/parrhesia/config_test.exs @@ -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 diff --git a/test/parrhesia/protocol_test.exs b/test/parrhesia/protocol_test.exs index 2481b95..7a14303 100644 --- a/test/parrhesia/protocol_test.exs +++ b/test/parrhesia/protocol_test.exs @@ -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 diff --git a/test/parrhesia/web/connection_test.exs b/test/parrhesia/web/connection_test.exs index 39172e0..240289d 100644 --- a/test/parrhesia/web/connection_test.exs +++ b/test/parrhesia/web/connection_test.exs @@ -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