Add signature verification and lossless event tag storage
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
63
test/parrhesia/protocol/event_validator_signature_test.exs
Normal file
63
test/parrhesia/protocol/event_validator_signature_test.exs
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user