Implement strict NIP-01 event validation and canonical reply prefixes

This commit is contained in:
2026-03-13 19:56:51 +01:00
parent 86b7156429
commit eb4fbcc2c9
8 changed files with 279 additions and 76 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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