Add signature verification and lossless event tag storage

This commit is contained in:
2026-03-14 04:20:42 +01:00
parent 18e429e05a
commit e12085af2f
8 changed files with 152 additions and 46 deletions

View File

@@ -43,6 +43,7 @@ config :parrhesia,
management_auth_required: true management_auth_required: true
], ],
features: [ features: [
verify_event_signatures: true,
nip_45_count: true, nip_45_count: true,
nip_50_search: true, nip_50_search: true,
nip_77_negentropy: true, nip_77_negentropy: true,

View File

@@ -14,7 +14,8 @@ config :parrhesia, Parrhesia.Web.Endpoint,
config :parrhesia, config :parrhesia,
enable_expiration_worker: false, enable_expiration_worker: false,
moderation_cache_enabled: false moderation_cache_enabled: false,
features: [verify_event_signatures: false]
pg_host = System.get_env("PGHOST") pg_host = System.get_env("PGHOST")

View File

@@ -21,6 +21,7 @@ defmodule Parrhesia.Protocol.EventValidator do
| :invalid_content | :invalid_content
| :invalid_sig | :invalid_sig
| :invalid_id_hash | :invalid_id_hash
| :invalid_signature
| :invalid_marmot_keypackage_content | :invalid_marmot_keypackage_content
| :missing_marmot_encoding_tag | :missing_marmot_encoding_tag
| :invalid_marmot_encoding_tag | :invalid_marmot_encoding_tag
@@ -54,7 +55,8 @@ defmodule Parrhesia.Protocol.EventValidator do
:ok <- validate_tags(event["tags"]), :ok <- validate_tags(event["tags"]),
:ok <- validate_content(event["content"]), :ok <- validate_content(event["content"]),
:ok <- validate_sig(event["sig"]), :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) validate_kind_specific(event)
end end
end end
@@ -89,6 +91,7 @@ defmodule Parrhesia.Protocol.EventValidator do
invalid_content: "invalid: content must be a string", invalid_content: "invalid: content must be a string",
invalid_sig: "invalid: sig must be 64-byte lowercase hex", invalid_sig: "invalid: sig must be 64-byte lowercase hex",
invalid_id_hash: "invalid: event id does not match serialized event", 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", invalid_marmot_keypackage_content: "invalid: kind 443 content must be non-empty base64",
missing_marmot_encoding_tag: "invalid: kind 443 must include [\"encoding\", \"base64\"]", missing_marmot_encoding_tag: "invalid: kind 443 must include [\"encoding\", \"base64\"]",
invalid_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
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 defp valid_tag?(tag) when is_list(tag) do
tag != [] and Enum.all?(tag, &is_binary/1) tag != [] and Enum.all?(tag, &is_binary/1)
end end
@@ -473,6 +499,12 @@ defmodule Parrhesia.Protocol.EventValidator do
match?({:ok, _decoded}, Base.decode16(value, case: :lower)) match?({:ok, _decoded}, Base.decode16(value, case: :lower))
end 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 defp max_event_future_skew_seconds do
:parrhesia :parrhesia
|> Application.get_env(:limits, []) |> Application.get_env(:limits, [])

View File

@@ -56,6 +56,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do
pubkey: event.pubkey, pubkey: event.pubkey,
created_at: event.created_at, created_at: event.created_at,
kind: event.kind, kind: event.kind,
tags: event.tags,
content: event.content, content: event.content,
sig: event.sig sig: event.sig
} }
@@ -66,13 +67,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do
{:ok, nil} {:ok, nil}
persisted_event -> persisted_event ->
tags = load_tags([{persisted_event.created_at, persisted_event.id}]) {:ok, to_nostr_event(persisted_event)}
{:ok,
to_nostr_event(
persisted_event,
Map.get(tags, {persisted_event.created_at, persisted_event.id}, [])
)}
end end
end end
end end
@@ -93,15 +88,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do
|> sort_persisted_events() |> sort_persisted_events()
|> maybe_apply_query_limit(opts) |> maybe_apply_query_limit(opts)
event_keys = Enum.map(persisted_events, fn event -> {event.created_at, event.id} end) {:ok, Enum.map(persisted_events, &to_nostr_event/1)}
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}
end end
end end
@@ -609,6 +596,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do
pubkey: normalized_event.pubkey, pubkey: normalized_event.pubkey,
created_at: normalized_event.created_at, created_at: normalized_event.created_at,
kind: normalized_event.kind, kind: normalized_event.kind,
tags: normalized_event.tags,
content: normalized_event.content, content: normalized_event.content,
sig: normalized_event.sig, sig: normalized_event.sig,
d_tag: normalized_event.d_tag, d_tag: normalized_event.d_tag,
@@ -628,6 +616,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do
pubkey: event.pubkey, pubkey: event.pubkey,
created_at: event.created_at, created_at: event.created_at,
kind: event.kind, kind: event.kind,
tags: event.tags,
content: event.content, content: event.content,
sig: event.sig sig: event.sig
} }
@@ -820,44 +809,21 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Events do
end end
end end
defp load_tags([]), do: %{} defp to_nostr_event(persisted_event) 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
%{ %{
"id" => Base.encode16(persisted_event.id, case: :lower), "id" => Base.encode16(persisted_event.id, case: :lower),
"pubkey" => Base.encode16(persisted_event.pubkey, case: :lower), "pubkey" => Base.encode16(persisted_event.pubkey, case: :lower),
"created_at" => persisted_event.created_at, "created_at" => persisted_event.created_at,
"kind" => persisted_event.kind, "kind" => persisted_event.kind,
"tags" => tags, "tags" => normalize_persisted_tags(persisted_event.tags),
"content" => persisted_event.content, "content" => persisted_event.content,
"sig" => Base.encode16(persisted_event.sig, case: :lower) "sig" => Base.encode16(persisted_event.sig, case: :lower)
} }
end 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 defp decode_hex(value, bytes, reason) when is_binary(value) do
if byte_size(value) == bytes * 2 do if byte_size(value) == bytes * 2 do
case Base.decode16(value, case: :mixed) do case Base.decode16(value, case: :mixed) do

View File

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

View File

@@ -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_max_imeta_tags_per_event]) == 8
assert Parrhesia.Config.get([:policies, :marmot_media_reject_mip04_v1]) == true 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([: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, :nip_50_search]) == true
assert Parrhesia.Config.get([:features, :marmot_push_notifications]) == false assert Parrhesia.Config.get([:features, :marmot_push_notifications]) == false
end end

View File

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

View File

@@ -11,6 +11,24 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsLifecycleTest do
:ok :ok
end 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 test "delete_by_request tombstones owned target events" do
target = event(%{"kind" => 1, "content" => "target"}) target = event(%{"kind" => 1, "content" => "target"})
assert {:ok, _event} = Events.put_event(%{}, target) assert {:ok, _event} = Events.put_event(%{}, target)