From cf5ae772b27d7a855a8691cc575142b54deaa683 Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Fri, 13 Mar 2026 21:54:07 +0100 Subject: [PATCH] Implement MIP-00 keypackage envelope validation --- PROGRESS_MARMOT.md | 10 +- lib/parrhesia/protocol/event_validator.ex | 246 +++++++++++++++++- ...13205257_add_event_tags_i_lookup_index.exs | 13 + .../protocol/event_validator_marmot_test.exs | 83 ++++++ .../postgres/events_query_count_test.exs | 23 ++ 5 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 priv/repo/migrations/20260313205257_add_event_tags_i_lookup_index.exs create mode 100644 test/parrhesia/protocol/event_validator_marmot_test.exs diff --git a/PROGRESS_MARMOT.md b/PROGRESS_MARMOT.md index 9a0e3aa..94dcf77 100644 --- a/PROGRESS_MARMOT.md +++ b/PROGRESS_MARMOT.md @@ -13,11 +13,11 @@ Spec source: `~/marmot/README.md` + MIP-00..05. ## M1 — MIP-00 (credentials + keypackages) -- [ ] Enforce kind `443` required tags and encoding (`encoding=base64`) -- [ ] Validate `mls_protocol_version`, `mls_ciphersuite`, `mls_extensions`, `relays`, and `i` tag shape -- [ ] Add efficient `#i` query/index path for KeyPackageRef lookup -- [ ] Keep replaceable behavior for kind `10051` relay-list events -- [ ] Add conformance tests for valid/invalid KeyPackage envelopes +- [x] Enforce kind `443` required tags and encoding (`encoding=base64`) +- [x] Validate `mls_protocol_version`, `mls_ciphersuite`, `mls_extensions`, `relays`, and `i` tag shape +- [x] Add efficient `#i` query/index path for KeyPackageRef lookup +- [x] Keep replaceable behavior for kind `10051` relay-list events +- [x] Add conformance tests for valid/invalid KeyPackage envelopes ## M2 — MIP-01 (group construction data expectations) diff --git a/lib/parrhesia/protocol/event_validator.ex b/lib/parrhesia/protocol/event_validator.ex index 825e7e4..5f619c8 100644 --- a/lib/parrhesia/protocol/event_validator.ex +++ b/lib/parrhesia/protocol/event_validator.ex @@ -6,6 +6,9 @@ defmodule Parrhesia.Protocol.EventValidator do @required_fields ~w[id pubkey created_at kind tags content sig] @max_kind 65_535 @default_max_event_future_skew_seconds 900 + @supported_mls_ciphersuites MapSet.new(~w[0x0001 0x0002 0x0003 0x0004 0x0005 0x0006 0x0007]) + @required_mls_extensions MapSet.new(["0xf2ee", "0x000a"]) + @supported_keypackage_ref_sizes [32, 48, 64] @type error_reason :: :invalid_shape @@ -18,6 +21,21 @@ defmodule Parrhesia.Protocol.EventValidator do | :invalid_content | :invalid_sig | :invalid_id_hash + | :invalid_marmot_keypackage_content + | :missing_marmot_encoding_tag + | :invalid_marmot_encoding_tag + | :missing_marmot_protocol_version_tag + | :invalid_marmot_protocol_version_tag + | :missing_marmot_ciphersuite_tag + | :invalid_marmot_ciphersuite_tag + | :missing_marmot_extensions_tag + | :invalid_marmot_extensions_tag + | :missing_marmot_relays_tag + | :invalid_marmot_relays_tag + | :missing_marmot_keypackage_ref_tag + | :invalid_marmot_keypackage_ref_tag + | :missing_marmot_relay_tag + | :invalid_marmot_relay_tag @spec validate(map()) :: :ok | {:error, error_reason()} def validate(event) when is_map(event) do @@ -28,8 +46,9 @@ defmodule Parrhesia.Protocol.EventValidator do :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) + :ok <- validate_sig(event["sig"]), + :ok <- validate_id_hash(event) do + validate_kind_specific(event) end end @@ -62,7 +81,32 @@ defmodule Parrhesia.Protocol.EventValidator do 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" + invalid_id_hash: "invalid: event id does not match serialized event", + 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\"]", + missing_marmot_protocol_version_tag: + "invalid: kind 443 must include [\"mls_protocol_version\", \"1.0\"]", + invalid_marmot_protocol_version_tag: + "invalid: kind 443 must include [\"mls_protocol_version\", \"1.0\"]", + missing_marmot_ciphersuite_tag: + "invalid: kind 443 must include a supported [\"mls_ciphersuite\", \"0x....\"]", + invalid_marmot_ciphersuite_tag: + "invalid: kind 443 must include a supported [\"mls_ciphersuite\", \"0x....\"]", + missing_marmot_extensions_tag: + "invalid: kind 443 must include [\"mls_extensions\", ...] with 0xf2ee and 0x000a", + invalid_marmot_extensions_tag: + "invalid: kind 443 must include [\"mls_extensions\", ...] with 0xf2ee and 0x000a", + missing_marmot_relays_tag: "invalid: kind 443 must include [\"relays\", , ...]", + invalid_marmot_relays_tag: "invalid: kind 443 must include [\"relays\", , ...]", + missing_marmot_keypackage_ref_tag: + "invalid: kind 443 must include [\"i\", ]", + invalid_marmot_keypackage_ref_tag: + "invalid: kind 443 must include [\"i\", ]", + missing_marmot_relay_tag: + "invalid: kind 10051 must include at least one [\"relay\", ] tag", + invalid_marmot_relay_tag: + "invalid: kind 10051 must include at least one [\"relay\", ] tag" } @spec error_message(error_reason()) :: String.t() @@ -137,6 +181,202 @@ defmodule Parrhesia.Protocol.EventValidator do defp valid_tag?(_tag), do: false + defp validate_kind_specific(%{"kind" => 443} = event), + do: validate_marmot_keypackage_event(event) + + defp validate_kind_specific(%{"kind" => 10_051} = event), + do: validate_marmot_keypackage_relay_list(event) + + defp validate_kind_specific(_event), do: :ok + + defp validate_marmot_keypackage_event(event) do + tags = Map.get(event, "tags", []) + + with :ok <- validate_non_empty_base64_content(event), + {:ok, ["encoding", "base64"]} <- + fetch_single_tag(tags, "encoding", :missing_marmot_encoding_tag), + :ok <- + validate_single_string_tag( + tags, + "mls_protocol_version", + "1.0", + :missing_marmot_protocol_version_tag, + :invalid_marmot_protocol_version_tag + ), + :ok <- + validate_single_string_tag_with_predicate( + tags, + "mls_ciphersuite", + :missing_marmot_ciphersuite_tag, + :invalid_marmot_ciphersuite_tag, + &supported_mls_ciphersuite?/1 + ), + :ok <- validate_mls_extensions_tag(tags), + :ok <- + validate_relays_tag( + tags, + "relays", + :missing_marmot_relays_tag, + :invalid_marmot_relays_tag + ), + :ok <- validate_keypackage_ref_tag(tags) do + :ok + else + {:ok, _other_tag} -> {:error, :invalid_marmot_encoding_tag} + {:error, _reason} = error -> error + end + end + + defp validate_marmot_keypackage_relay_list(event) do + tags = Map.get(event, "tags", []) + + relay_tags = Enum.filter(tags, &match_tag_name?(&1, "relay")) + + cond do + relay_tags == [] -> + {:error, :missing_marmot_relay_tag} + + Enum.all?(relay_tags, &valid_single_relay_tag?/1) -> + :ok + + true -> + {:error, :invalid_marmot_relay_tag} + end + end + + defp validate_non_empty_base64_content(event) do + case Base.decode64(Map.get(event, "content", "")) do + {:ok, decoded} when byte_size(decoded) > 0 -> :ok + _other -> {:error, :invalid_marmot_keypackage_content} + end + end + + defp validate_single_string_tag(tags, tag_name, expected_value, missing_error, invalid_error) do + validate_single_string_tag_with_predicate( + tags, + tag_name, + missing_error, + invalid_error, + &(&1 == expected_value) + ) + end + + defp validate_single_string_tag_with_predicate( + tags, + tag_name, + missing_error, + invalid_error, + predicate + ) + when is_function(predicate, 1) do + case fetch_single_tag(tags, tag_name, missing_error) do + {:ok, [^tag_name, value]} -> + if predicate.(value) do + :ok + else + {:error, invalid_error} + end + + {:ok, _invalid_tag_shape} -> + {:error, invalid_error} + + {:error, _reason} = error -> + error + end + end + + defp validate_mls_extensions_tag(tags) do + with {:ok, ["mls_extensions" | extensions]} <- + fetch_single_tag(tags, "mls_extensions", :missing_marmot_extensions_tag), + true <- extensions != [], + true <- Enum.all?(extensions, &valid_mls_extension_id?/1), + true <- required_mls_extensions_present?(extensions) do + :ok + else + {:ok, _invalid_tag_shape} -> {:error, :invalid_marmot_extensions_tag} + false -> {:error, :invalid_marmot_extensions_tag} + {:error, _reason} = error -> error + end + end + + defp validate_relays_tag(tags, tag_name, missing_error, invalid_error) do + with {:ok, [^tag_name | relay_urls]} <- fetch_single_tag(tags, tag_name, missing_error), + true <- relay_urls != [], + true <- Enum.all?(relay_urls, &valid_websocket_url?/1) do + :ok + else + {:ok, _invalid_tag_shape} -> {:error, invalid_error} + false -> {:error, invalid_error} + {:error, _reason} = error -> error + end + end + + defp validate_keypackage_ref_tag(tags) do + with {:ok, ["i", keypackage_ref]} <- + fetch_single_tag(tags, "i", :missing_marmot_keypackage_ref_tag), + true <- valid_keypackage_ref?(keypackage_ref) do + :ok + else + {:ok, _invalid_tag_shape} -> {:error, :invalid_marmot_keypackage_ref_tag} + false -> {:error, :invalid_marmot_keypackage_ref_tag} + {:error, _reason} = error -> error + end + end + + defp fetch_single_tag(tags, tag_name, missing_error) do + case Enum.filter(tags, &match_tag_name?(&1, tag_name)) do + [tag] -> {:ok, tag} + [] -> {:error, missing_error} + _duplicates -> {:error, missing_error} + end + end + + defp match_tag_name?([tag_name | _rest], expected_tag_name) when is_binary(tag_name), + do: tag_name == expected_tag_name + + defp match_tag_name?(_tag, _expected_tag_name), do: false + + defp supported_mls_ciphersuite?(value) when is_binary(value) do + value + |> String.downcase() + |> then(&MapSet.member?(@supported_mls_ciphersuites, &1)) + end + + defp supported_mls_ciphersuite?(_value), do: false + + defp valid_mls_extension_id?(value) when is_binary(value) do + String.match?(value, ~r/^0x[0-9a-fA-F]{4}$/) + end + + defp valid_mls_extension_id?(_value), do: false + + defp required_mls_extensions_present?(extensions) do + normalized = MapSet.new(Enum.map(extensions, &String.downcase/1)) + MapSet.subset?(@required_mls_extensions, normalized) + end + + defp valid_single_relay_tag?(["relay", relay_url]), do: valid_websocket_url?(relay_url) + defp valid_single_relay_tag?(_tag), do: false + + defp valid_websocket_url?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} + when scheme in ["ws", "wss"] and is_binary(host) and host != "" -> + true + + _other -> + false + end + end + + defp valid_websocket_url?(_url), do: false + + defp valid_keypackage_ref?(value) when is_binary(value) do + Enum.any?(@supported_keypackage_ref_sizes, &lowercase_hex?(value, &1)) + end + + defp valid_keypackage_ref?(_value), do: false + defp lowercase_hex?(value, bytes) do byte_size(value) == bytes * 2 and match?({:ok, _decoded}, Base.decode16(value, case: :lower)) diff --git a/priv/repo/migrations/20260313205257_add_event_tags_i_lookup_index.exs b/priv/repo/migrations/20260313205257_add_event_tags_i_lookup_index.exs new file mode 100644 index 0000000..c7a056b --- /dev/null +++ b/priv/repo/migrations/20260313205257_add_event_tags_i_lookup_index.exs @@ -0,0 +1,13 @@ +defmodule Parrhesia.Repo.Migrations.AddEventTagsILookupIndex do + use Ecto.Migration + + def up do + execute( + "CREATE INDEX event_tags_i_value_created_at_idx ON event_tags (value, event_created_at DESC) WHERE name = 'i'" + ) + end + + def down do + execute("DROP INDEX event_tags_i_value_created_at_idx") + end +end diff --git a/test/parrhesia/protocol/event_validator_marmot_test.exs b/test/parrhesia/protocol/event_validator_marmot_test.exs new file mode 100644 index 0000000..097245b --- /dev/null +++ b/test/parrhesia/protocol/event_validator_marmot_test.exs @@ -0,0 +1,83 @@ +defmodule Parrhesia.Protocol.EventValidatorMarmotTest do + use ExUnit.Case, async: true + + alias Parrhesia.Protocol + alias Parrhesia.Protocol.EventValidator + + test "accepts valid MIP-00 keypackage envelope (kind 443)" do + event = valid_keypackage_event() + + assert :ok = EventValidator.validate(event) + assert :ok = Protocol.validate_event(event) + end + + test "rejects keypackage without required encoding tag" do + event = + valid_keypackage_event(%{ + "tags" => + Enum.reject(valid_keypackage_tags(), fn [name | _rest] -> name == "encoding" end) + }) + + assert {:error, :missing_marmot_encoding_tag} = EventValidator.validate(event) + + assert {:error, "invalid: kind 443 must include [\"encoding\", \"base64\"]"} = + Protocol.validate_event(event) + end + + test "rejects keypackage with non-base64 content" do + event = valid_keypackage_event(%{"content" => "%%%not-base64%%%"}) + + assert {:error, :invalid_marmot_keypackage_content} = EventValidator.validate(event) + end + + test "accepts keypackage relay list envelope (kind 10051)" do + event = valid_keypackage_relay_list_event() + + assert :ok = EventValidator.validate(event) + end + + test "rejects keypackage relay list without relay tags" do + event = valid_keypackage_relay_list_event(%{"tags" => [["p", String.duplicate("f", 64)]]}) + + assert {:error, :missing_marmot_relay_tag} = EventValidator.validate(event) + end + + defp valid_keypackage_event(overrides \\ %{}) do + base_event = %{ + "pubkey" => String.duplicate("1", 64), + "created_at" => System.system_time(:second), + "kind" => 443, + "tags" => valid_keypackage_tags(), + "content" => Base.encode64("fake-keypackage-bundle"), + "sig" => String.duplicate("2", 128) + } + + event = Map.merge(base_event, overrides) + Map.put(event, "id", EventValidator.compute_id(event)) + end + + defp valid_keypackage_tags do + [ + ["mls_protocol_version", "1.0"], + ["mls_ciphersuite", "0x0001"], + ["mls_extensions", "0xf2ee", "0x000a"], + ["encoding", "base64"], + ["i", String.duplicate("a", 64)], + ["relays", "wss://relay.example.com"] + ] + end + + defp valid_keypackage_relay_list_event(overrides \\ %{}) do + base_event = %{ + "pubkey" => String.duplicate("3", 64), + "created_at" => System.system_time(:second), + "kind" => 10_051, + "tags" => [["relay", "wss://relay.one"], ["relay", "wss://relay.two"]], + "content" => "", + "sig" => String.duplicate("4", 128) + } + + event = Map.merge(base_event, overrides) + Map.put(event, "id", EventValidator.compute_id(event)) + end +end diff --git a/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs b/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs index a05bb02..930b4c7 100644 --- a/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs +++ b/test/parrhesia/storage/adapters/postgres/events_query_count_test.exs @@ -245,6 +245,29 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsQueryCountTest do assert result["id"] == allowed["id"] end + test "query/3 supports #i keypackage reference lookups" do + keypackage_ref = String.duplicate("a", 64) + + matching = + persist_event(%{ + "kind" => 443, + "tags" => [["i", keypackage_ref], ["encoding", "base64"]], + "content" => Base.encode64("keypackage") + }) + + _non_matching = + persist_event(%{ + "kind" => 443, + "tags" => [["i", String.duplicate("b", 64)], ["encoding", "base64"]], + "content" => Base.encode64("other") + }) + + assert {:ok, [result]} = + Events.query(%{}, [%{"kinds" => [443], "#i" => [keypackage_ref]}], []) + + assert result["id"] == matching["id"] + end + test "mls keypackage relay list kind 10051 follows replaceable conflict semantics" do author = String.duplicate("c", 64)