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

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

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