diff --git a/PROGRESS_MARMOT.md b/PROGRESS_MARMOT.md index 4eff288..51bf5f3 100644 --- a/PROGRESS_MARMOT.md +++ b/PROGRESS_MARMOT.md @@ -29,9 +29,9 @@ Spec source: `~/marmot/README.md` + MIP-00..05. ## M3 — MIP-02 (welcome events) - [ ] Support wrapped Welcome delivery via NIP-59 (`1059`) recipient-gated reads -- [ ] Validate relay behavior for unsigned inner Welcome semantics (kind `444` envelope expectations) +- [x] Validate relay behavior for unsigned inner Welcome semantics (kind `444` envelope expectations) - [ ] Ensure durability/ack semantics support Commit-then-Welcome sequencing requirements -- [ ] Add negative tests for malformed wrapped Welcome payloads +- [x] Add negative tests for malformed wrapped Welcome payloads ## M4 — MIP-03 (group events) diff --git a/lib/parrhesia/protocol/event_validator.ex b/lib/parrhesia/protocol/event_validator.ex index 5f619c8..1ef5426 100644 --- a/lib/parrhesia/protocol/event_validator.ex +++ b/lib/parrhesia/protocol/event_validator.ex @@ -36,6 +36,10 @@ defmodule Parrhesia.Protocol.EventValidator do | :invalid_marmot_keypackage_ref_tag | :missing_marmot_relay_tag | :invalid_marmot_relay_tag + | :invalid_marmot_direct_welcome_event + | :invalid_giftwrap_content + | :missing_giftwrap_recipient_tag + | :invalid_giftwrap_recipient_tag @spec validate(map()) :: :ok | {:error, error_reason()} def validate(event) when is_map(event) do @@ -106,7 +110,14 @@ defmodule Parrhesia.Protocol.EventValidator do 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" + "invalid: kind 10051 must include at least one [\"relay\", ] tag", + invalid_marmot_direct_welcome_event: + "invalid: kind 444 welcome events must be wrapped in kind 1059 and remain unsigned", + invalid_giftwrap_content: "invalid: kind 1059 content must be a non-empty encrypted payload", + missing_giftwrap_recipient_tag: + "invalid: kind 1059 must include at least one recipient p tag", + invalid_giftwrap_recipient_tag: + "invalid: kind 1059 recipient p tags must contain lowercase hex pubkeys" } @spec error_message(error_reason()) :: String.t() @@ -187,6 +198,12 @@ defmodule Parrhesia.Protocol.EventValidator do defp validate_kind_specific(%{"kind" => 10_051} = event), do: validate_marmot_keypackage_relay_list(event) + defp validate_kind_specific(%{"kind" => 444}), + do: {:error, :invalid_marmot_direct_welcome_event} + + defp validate_kind_specific(%{"kind" => 1059} = event), + do: validate_giftwrap_event(event) + defp validate_kind_specific(_event), do: :ok defp validate_marmot_keypackage_event(event) do @@ -244,6 +261,14 @@ defmodule Parrhesia.Protocol.EventValidator do end end + defp validate_giftwrap_event(event) do + tags = Map.get(event, "tags", []) + + with :ok <- validate_non_empty_content(event, :invalid_giftwrap_content) do + validate_giftwrap_recipient_tags(tags) + 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 @@ -251,6 +276,28 @@ defmodule Parrhesia.Protocol.EventValidator do end end + defp validate_non_empty_content(event, error_reason) do + case Map.get(event, "content", "") do + content when is_binary(content) and content != "" -> :ok + _other -> {:error, error_reason} + end + end + + defp validate_giftwrap_recipient_tags(tags) do + recipient_tags = Enum.filter(tags, &match_tag_name?(&1, "p")) + + cond do + recipient_tags == [] -> + {:error, :missing_giftwrap_recipient_tag} + + Enum.all?(recipient_tags, &valid_giftwrap_recipient_tag?/1) -> + :ok + + true -> + {:error, :invalid_giftwrap_recipient_tag} + end + end + defp validate_single_string_tag(tags, tag_name, expected_value, missing_error, invalid_error) do validate_single_string_tag_with_predicate( tags, @@ -358,6 +405,11 @@ defmodule Parrhesia.Protocol.EventValidator do defp valid_single_relay_tag?(["relay", relay_url]), do: valid_websocket_url?(relay_url) defp valid_single_relay_tag?(_tag), do: false + defp valid_giftwrap_recipient_tag?(["p", recipient_pubkey | _rest]), + do: lowercase_hex?(recipient_pubkey, 32) + + defp valid_giftwrap_recipient_tag?(_tag), do: false + defp valid_websocket_url?(url) when is_binary(url) do case URI.parse(url) do %URI{scheme: scheme, host: host} diff --git a/test/parrhesia/protocol/event_validator_marmot_test.exs b/test/parrhesia/protocol/event_validator_marmot_test.exs index 097245b..53d8dce 100644 --- a/test/parrhesia/protocol/event_validator_marmot_test.exs +++ b/test/parrhesia/protocol/event_validator_marmot_test.exs @@ -42,6 +42,53 @@ defmodule Parrhesia.Protocol.EventValidatorMarmotTest do assert {:error, :missing_marmot_relay_tag} = EventValidator.validate(event) end + test "rejects direct kind 444 welcome events" do + event = + valid_keypackage_event(%{ + "kind" => 444, + "tags" => [["e", String.duplicate("a", 64)], ["encoding", "base64"]], + "content" => Base.encode64("welcome") + }) + + assert {:error, :invalid_marmot_direct_welcome_event} = EventValidator.validate(event) + + assert {:error, + "invalid: kind 444 welcome events must be wrapped in kind 1059 and remain unsigned"} = + Protocol.validate_event(event) + end + + test "rejects malformed kind 1059 wrapped welcome envelopes" do + invalid_missing_recipient = + valid_keypackage_event(%{ + "kind" => 1059, + "tags" => [["e", String.duplicate("a", 64)]], + "content" => "ciphertext" + }) + + invalid_recipient_pubkey = + valid_keypackage_event(%{ + "kind" => 1059, + "tags" => [["p", "not-hex"]], + "content" => "ciphertext" + }) + + invalid_empty_content = + valid_keypackage_event(%{ + "kind" => 1059, + "tags" => [["p", String.duplicate("b", 64)]], + "content" => "" + }) + + assert {:error, :missing_giftwrap_recipient_tag} = + EventValidator.validate(invalid_missing_recipient) + + assert {:error, :invalid_giftwrap_recipient_tag} = + EventValidator.validate(invalid_recipient_pubkey) + + assert {:error, :invalid_giftwrap_content} = + EventValidator.validate(invalid_empty_content) + end + defp valid_keypackage_event(overrides \\ %{}) do base_event = %{ "pubkey" => String.duplicate("1", 64), diff --git a/test/parrhesia/web/connection_test.exs b/test/parrhesia/web/connection_test.exs index da3e11d..f9ed209 100644 --- a/test/parrhesia/web/connection_test.exs +++ b/test/parrhesia/web/connection_test.exs @@ -135,6 +135,29 @@ defmodule Parrhesia.Web.ConnectionTest do ] end + test "malformed wrapped welcome EVENT is rejected" do + state = connection_state() + + event = + valid_event() + |> Map.put("kind", 1059) + |> Map.put("tags", [["e", String.duplicate("a", 64)]]) + |> Map.put("content", "ciphertext") + |> then(&Map.put(&1, "id", EventValidator.compute_id(&1))) + + payload = Jason.encode!(["EVENT", event]) + + assert {:push, {:text, response}, ^state} = + Connection.handle_in({payload, [opcode: :text]}, state) + + assert Jason.decode!(response) == [ + "OK", + event["id"], + false, + "invalid: kind 1059 must include at least one recipient p tag" + ] + end + test "NEG sessions open and close" do state = connection_state()