diff --git a/config/config.exs b/config/config.exs index af1ff9e..9349c9b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -43,6 +43,7 @@ config :parrhesia, management_auth_required: true ], features: [ + verify_event_signatures: true, nip_45_count: true, nip_50_search: true, nip_77_negentropy: true, diff --git a/config/test.exs b/config/test.exs index d9e1c4a..e949673 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,7 +14,8 @@ config :parrhesia, Parrhesia.Web.Endpoint, config :parrhesia, enable_expiration_worker: false, - moderation_cache_enabled: false + moderation_cache_enabled: false, + features: [verify_event_signatures: false] pg_host = System.get_env("PGHOST") diff --git a/lib/parrhesia/protocol/event_validator.ex b/lib/parrhesia/protocol/event_validator.ex index 5aa472c..43942f8 100644 --- a/lib/parrhesia/protocol/event_validator.ex +++ b/lib/parrhesia/protocol/event_validator.ex @@ -21,6 +21,7 @@ defmodule Parrhesia.Protocol.EventValidator do | :invalid_content | :invalid_sig | :invalid_id_hash + | :invalid_signature | :invalid_marmot_keypackage_content | :missing_marmot_encoding_tag | :invalid_marmot_encoding_tag @@ -54,7 +55,8 @@ defmodule Parrhesia.Protocol.EventValidator do :ok <- validate_tags(event["tags"]), :ok <- validate_content(event["content"]), :ok <- validate_sig(event["sig"]), - :ok <- validate_id_hash(event) do + :ok <- validate_id_hash(event), + :ok <- validate_signature(event) do validate_kind_specific(event) end end @@ -89,6 +91,7 @@ defmodule Parrhesia.Protocol.EventValidator do 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", + invalid_signature: "invalid: event signature is invalid", invalid_marmot_keypackage_content: "invalid: kind 443 content must be non-empty base64", missing_marmot_encoding_tag: "invalid: kind 443 must include [\"encoding\", \"base64\"]", invalid_marmot_encoding_tag: "invalid: kind 443 must include [\"encoding\", \"base64\"]", @@ -193,6 +196,29 @@ defmodule Parrhesia.Protocol.EventValidator do end end + defp validate_signature(event) do + if verify_event_signatures?() do + verify_signature(event) + else + :ok + end + end + + defp verify_signature(%{"id" => id, "pubkey" => pubkey, "sig" => sig}) do + with {:ok, id_bin} <- Base.decode16(id, case: :lower), + {:ok, pubkey_bin} <- Base.decode16(pubkey, case: :lower), + {:ok, sig_bin} <- Base.decode16(sig, case: :lower), + true <- Secp256k1.schnorr_valid?(sig_bin, id_bin, pubkey_bin) do + :ok + else + _other -> {:error, :invalid_signature} + end + rescue + _error -> {:error, :invalid_signature} + end + + defp verify_signature(_event), do: {:error, :invalid_signature} + defp valid_tag?(tag) when is_list(tag) do tag != [] and Enum.all?(tag, &is_binary/1) end @@ -473,6 +499,12 @@ defmodule Parrhesia.Protocol.EventValidator do match?({:ok, _decoded}, Base.decode16(value, case: :lower)) end + defp verify_event_signatures? do + :parrhesia + |> Application.get_env(:features, []) + |> Keyword.get(:verify_event_signatures, true) + end + defp max_event_future_skew_seconds do :parrhesia |> Application.get_env(:limits, []) diff --git a/lib/parrhesia/storage/adapters/postgres/events.ex b/lib/parrhesia/storage/adapters/postgres/events.ex index ad1add8..3b4622b 100644 --- a/lib/parrhesia/storage/adapters/postgres/events.ex +++ b/lib/parrhesia/storage/adapters/postgres/events.ex @@ -56,6 +56,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do pubkey: event.pubkey, created_at: event.created_at, kind: event.kind, + tags: event.tags, content: event.content, sig: event.sig } @@ -66,13 +67,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do {:ok, nil} persisted_event -> - tags = load_tags([{persisted_event.created_at, persisted_event.id}]) - - {:ok, - to_nostr_event( - persisted_event, - Map.get(tags, {persisted_event.created_at, persisted_event.id}, []) - )} + {:ok, to_nostr_event(persisted_event)} end end end @@ -93,15 +88,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do |> sort_persisted_events() |> maybe_apply_query_limit(opts) - event_keys = Enum.map(persisted_events, fn event -> {event.created_at, event.id} end) - tags_by_event = load_tags(event_keys) - - nostr_events = - Enum.map(persisted_events, fn event -> - to_nostr_event(event, Map.get(tags_by_event, {event.created_at, event.id}, [])) - end) - - {:ok, nostr_events} + {:ok, Enum.map(persisted_events, &to_nostr_event/1)} end end @@ -609,6 +596,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do pubkey: normalized_event.pubkey, created_at: normalized_event.created_at, kind: normalized_event.kind, + tags: normalized_event.tags, content: normalized_event.content, sig: normalized_event.sig, d_tag: normalized_event.d_tag, @@ -628,6 +616,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do pubkey: event.pubkey, created_at: event.created_at, kind: event.kind, + tags: event.tags, content: event.content, sig: event.sig } @@ -820,44 +809,21 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do end end - defp load_tags([]), do: %{} - - defp load_tags(event_keys) when is_list(event_keys) do - created_at_values = Enum.map(event_keys, fn {created_at, _event_id} -> created_at end) - event_id_values = Enum.map(event_keys, fn {_created_at, event_id} -> event_id end) - - query = - from(tag in "event_tags", - where: tag.event_created_at in ^created_at_values and tag.event_id in ^event_id_values, - order_by: [asc: tag.idx], - select: %{ - event_created_at: tag.event_created_at, - event_id: tag.event_id, - name: tag.name, - value: tag.value - } - ) - - query - |> Repo.all() - |> Enum.group_by( - fn tag -> {tag.event_created_at, tag.event_id} end, - fn tag -> [tag.name, tag.value] end - ) - end - - defp to_nostr_event(persisted_event, tags) do + defp to_nostr_event(persisted_event) do %{ "id" => Base.encode16(persisted_event.id, case: :lower), "pubkey" => Base.encode16(persisted_event.pubkey, case: :lower), "created_at" => persisted_event.created_at, "kind" => persisted_event.kind, - "tags" => tags, + "tags" => normalize_persisted_tags(persisted_event.tags), "content" => persisted_event.content, "sig" => Base.encode16(persisted_event.sig, case: :lower) } end + defp normalize_persisted_tags(tags) when is_list(tags), do: tags + defp normalize_persisted_tags(_tags), do: [] + defp decode_hex(value, bytes, reason) when is_binary(value) do if byte_size(value) == bytes * 2 do case Base.decode16(value, case: :mixed) do diff --git a/priv/repo/migrations/20260314031753_add_events_tags_jsonb.exs b/priv/repo/migrations/20260314031753_add_events_tags_jsonb.exs new file mode 100644 index 0000000..3aba3f9 --- /dev/null +++ b/priv/repo/migrations/20260314031753_add_events_tags_jsonb.exs @@ -0,0 +1,24 @@ +defmodule Parrhesia.Repo.Migrations.AddEventsTagsJsonb do + use Ecto.Migration + + def up do + execute("ALTER TABLE events ADD COLUMN tags jsonb NOT NULL DEFAULT '[]'::jsonb") + + execute(""" + UPDATE events AS event + SET tags = COALESCE( + ( + SELECT jsonb_agg(jsonb_build_array(tag.name, tag.value) ORDER BY tag.idx) + FROM event_tags AS tag + WHERE tag.event_created_at = event.created_at + AND tag.event_id = event.id + ), + '[]'::jsonb + ) + """) + end + + def down do + execute("ALTER TABLE events DROP COLUMN tags") + end +end diff --git a/test/parrhesia/config_test.exs b/test/parrhesia/config_test.exs index f4e649a..3fb5ef5 100644 --- a/test/parrhesia/config_test.exs +++ b/test/parrhesia/config_test.exs @@ -15,6 +15,7 @@ defmodule Parrhesia.ConfigTest do assert Parrhesia.Config.get([:policies, :marmot_media_max_imeta_tags_per_event]) == 8 assert Parrhesia.Config.get([:policies, :marmot_media_reject_mip04_v1]) == true assert Parrhesia.Config.get([:policies, :marmot_push_max_trigger_age_seconds]) == 120 + assert Parrhesia.Config.get([:features, :verify_event_signatures]) == false assert Parrhesia.Config.get([:features, :nip_50_search]) == true assert Parrhesia.Config.get([:features, :marmot_push_notifications]) == false end diff --git a/test/parrhesia/protocol/event_validator_signature_test.exs b/test/parrhesia/protocol/event_validator_signature_test.exs new file mode 100644 index 0000000..86e4e6f --- /dev/null +++ b/test/parrhesia/protocol/event_validator_signature_test.exs @@ -0,0 +1,63 @@ +defmodule Parrhesia.Protocol.EventValidatorSignatureTest do + use ExUnit.Case, async: true + + alias Parrhesia.Protocol.EventValidator + + test "accepts valid Schnorr signatures when verification is enabled" do + previous_features = Application.get_env(:parrhesia, :features, []) + + Application.put_env( + :parrhesia, + :features, + Keyword.put(previous_features, :verify_event_signatures, true) + ) + + on_exit(fn -> + Application.put_env(:parrhesia, :features, previous_features) + end) + + event = signed_event() + + assert :ok = EventValidator.validate(event) + end + + test "rejects invalid Schnorr signatures when verification is enabled" do + previous_features = Application.get_env(:parrhesia, :features, []) + + Application.put_env( + :parrhesia, + :features, + Keyword.put(previous_features, :verify_event_signatures, true) + ) + + on_exit(fn -> + Application.put_env(:parrhesia, :features, previous_features) + end) + + event = + signed_event() + |> Map.put("sig", String.duplicate("0", 128)) + + assert {:error, :invalid_signature} = EventValidator.validate(event) + end + + defp signed_event do + {seckey, pubkey} = Secp256k1.keypair(:xonly) + + event = %{ + "pubkey" => Base.encode16(pubkey, case: :lower), + "created_at" => System.system_time(:second), + "kind" => 1, + "tags" => [["e", String.duplicate("a", 64), "wss://relay.example", "reply"]], + "content" => "signed" + } + + id = EventValidator.compute_id(event) + {:ok, id_bin} = Base.decode16(id, case: :lower) + sig = Secp256k1.schnorr_sign(id_bin, seckey) + + event + |> Map.put("id", id) + |> Map.put("sig", Base.encode16(sig, case: :lower)) + end +end diff --git a/test/parrhesia/storage/adapters/postgres/events_lifecycle_test.exs b/test/parrhesia/storage/adapters/postgres/events_lifecycle_test.exs index 2ebf640..ee16b1e 100644 --- a/test/parrhesia/storage/adapters/postgres/events_lifecycle_test.exs +++ b/test/parrhesia/storage/adapters/postgres/events_lifecycle_test.exs @@ -11,6 +11,24 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsLifecycleTest do :ok end + test "event tags round-trip without truncation" do + tagged_event = + event(%{ + "kind" => 1, + "tags" => [ + ["e", String.duplicate("a", 64), "wss://relay.example", "reply"], + ["-"], + ["p", String.duplicate("b", 64), "wss://hint.example"] + ], + "content" => "tag-roundtrip" + }) + + assert {:ok, _event} = Events.put_event(%{}, tagged_event) + assert {:ok, persisted_tagged_event} = Events.get_event(%{}, tagged_event["id"]) + + assert persisted_tagged_event["tags"] == tagged_event["tags"] + end + test "delete_by_request tombstones owned target events" do target = event(%{"kind" => 1, "content" => "target"}) assert {:ok, _event} = Events.put_event(%{}, target)