Add signature verification and lossless event tag storage
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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, [])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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_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
|
||||||
|
|||||||
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
|
: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)
|
||||||
|
|||||||
Reference in New Issue
Block a user