Implement MIP-01 #h query guardrails and ordering tests
This commit is contained in:
@@ -21,10 +21,10 @@ Spec source: `~/marmot/README.md` + MIP-00..05.
|
|||||||
|
|
||||||
## M2 — MIP-01 (group construction data expectations)
|
## M2 — MIP-01 (group construction data expectations)
|
||||||
|
|
||||||
- [ ] Enforce relay-side routing prerequisites for Marmot groups (`#h` query path)
|
- [x] Enforce relay-side routing prerequisites for Marmot groups (`#h` query path)
|
||||||
- [ ] Keep deterministic ordering for group-event catch-up (`created_at` + `id` tie-break)
|
- [x] Keep deterministic ordering for group-event catch-up (`created_at` + `id` tie-break)
|
||||||
- [ ] Add guardrails for group metadata traffic volume and filter windows
|
- [x] Add guardrails for group metadata traffic volume and filter windows
|
||||||
- [ ] Add tests for `#h` routing and ordering invariants
|
- [x] Add tests for `#h` routing and ordering invariants
|
||||||
|
|
||||||
## M3 — MIP-02 (welcome events)
|
## M3 — MIP-02 (welcome events)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ config :parrhesia,
|
|||||||
min_pow_difficulty: 0,
|
min_pow_difficulty: 0,
|
||||||
accept_ephemeral_events: true,
|
accept_ephemeral_events: true,
|
||||||
mls_group_event_ttl_seconds: 300,
|
mls_group_event_ttl_seconds: 300,
|
||||||
|
marmot_require_h_for_group_queries: true,
|
||||||
|
marmot_group_max_h_values_per_filter: 32,
|
||||||
|
marmot_group_max_query_window_seconds: 2_592_000,
|
||||||
management_auth_required: true
|
management_auth_required: true
|
||||||
],
|
],
|
||||||
features: [
|
features: [
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ defmodule Parrhesia.Policy.EventPolicy do
|
|||||||
@type policy_error ::
|
@type policy_error ::
|
||||||
:auth_required
|
:auth_required
|
||||||
| :restricted_giftwrap
|
| :restricted_giftwrap
|
||||||
|
| :marmot_group_h_tag_required
|
||||||
|
| :marmot_group_h_values_exceeded
|
||||||
|
| :marmot_group_filter_window_too_wide
|
||||||
| :protected_event_requires_auth
|
| :protected_event_requires_auth
|
||||||
| :protected_event_pubkey_mismatch
|
| :protected_event_pubkey_mismatch
|
||||||
| :pow_below_minimum
|
| :pow_below_minimum
|
||||||
@@ -27,7 +30,7 @@ defmodule Parrhesia.Policy.EventPolicy do
|
|||||||
{:error, :restricted_giftwrap}
|
{:error, :restricted_giftwrap}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
:ok
|
enforce_marmot_group_read_guardrails(filters)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -56,6 +59,15 @@ defmodule Parrhesia.Policy.EventPolicy do
|
|||||||
def error_message(:restricted_giftwrap),
|
def error_message(:restricted_giftwrap),
|
||||||
do: "restricted: giftwrap access requires recipient authentication"
|
do: "restricted: giftwrap access requires recipient authentication"
|
||||||
|
|
||||||
|
def error_message(:marmot_group_h_tag_required),
|
||||||
|
do: "restricted: kind 445 queries must include a #h tag"
|
||||||
|
|
||||||
|
def error_message(:marmot_group_h_values_exceeded),
|
||||||
|
do: "rate-limited: kind 445 queries exceed maximum #h values"
|
||||||
|
|
||||||
|
def error_message(:marmot_group_filter_window_too_wide),
|
||||||
|
do: "rate-limited: kind 445 query window exceeds configured maximum"
|
||||||
|
|
||||||
def error_message(:protected_event_requires_auth),
|
def error_message(:protected_event_requires_auth),
|
||||||
do: "auth-required: protected events require authenticated pubkey"
|
do: "auth-required: protected events require authenticated pubkey"
|
||||||
|
|
||||||
@@ -111,6 +123,90 @@ defmodule Parrhesia.Policy.EventPolicy do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp enforce_marmot_group_read_guardrails(filters) do
|
||||||
|
cond do
|
||||||
|
marmot_group_h_tag_required_violation?(filters) ->
|
||||||
|
{:error, :marmot_group_h_tag_required}
|
||||||
|
|
||||||
|
marmot_group_h_values_exceeded?(filters) ->
|
||||||
|
{:error, :marmot_group_h_values_exceeded}
|
||||||
|
|
||||||
|
marmot_group_query_window_too_wide?(filters) ->
|
||||||
|
{:error, :marmot_group_filter_window_too_wide}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp marmot_group_h_tag_required_violation?(filters) do
|
||||||
|
config_bool([:policies, :marmot_require_h_for_group_queries], true) and
|
||||||
|
Enum.any?(filters, fn filter ->
|
||||||
|
targets_marmot_group_events?(filter) and not valid_h_tag_values?(Map.get(filter, "#h"))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp marmot_group_h_values_exceeded?(filters) do
|
||||||
|
max_h_values = config_int([:policies, :marmot_group_max_h_values_per_filter], 32)
|
||||||
|
|
||||||
|
max_h_values > 0 and
|
||||||
|
Enum.any?(filters, fn filter ->
|
||||||
|
targets_marmot_group_events?(filter) and h_tag_values_count(filter) > max_h_values
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp marmot_group_query_window_too_wide?(filters) do
|
||||||
|
max_window = config_int([:policies, :marmot_group_max_query_window_seconds], 2_592_000)
|
||||||
|
|
||||||
|
max_window > 0 and
|
||||||
|
Enum.any?(filters, fn filter ->
|
||||||
|
if targets_marmot_group_events?(filter) do
|
||||||
|
query_window_exceeds?(filter, max_window)
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp targets_marmot_group_events?(filter) do
|
||||||
|
case Map.get(filter, "kinds") do
|
||||||
|
kinds when is_list(kinds) -> 445 in kinds
|
||||||
|
_other -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp h_tag_values_count(filter) do
|
||||||
|
case Map.get(filter, "#h") do
|
||||||
|
values when is_list(values) -> length(values)
|
||||||
|
_other -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_h_tag_values?(values) when is_list(values) do
|
||||||
|
values != [] and Enum.all?(values, &lowercase_hex?(&1, 32))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_h_tag_values?(_values), do: false
|
||||||
|
|
||||||
|
defp query_window_exceeds?(filter, max_window) do
|
||||||
|
case {Map.get(filter, "since"), Map.get(filter, "until")} do
|
||||||
|
{since, until}
|
||||||
|
when is_integer(since) and since >= 0 and is_integer(until) and until >= 0 and
|
||||||
|
until >= since ->
|
||||||
|
until - since > max_window
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lowercase_hex?(value, bytes) when is_binary(value) do
|
||||||
|
byte_size(value) == bytes * 2 and
|
||||||
|
match?({:ok, _decoded}, Base.decode16(value, case: :lower))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lowercase_hex?(_value, _bytes), do: false
|
||||||
|
|
||||||
defp reject_if_pubkey_banned(event) do
|
defp reject_if_pubkey_banned(event) do
|
||||||
with pubkey when is_binary(pubkey) <- Map.get(event, "pubkey"),
|
with pubkey when is_binary(pubkey) <- Map.get(event, "pubkey"),
|
||||||
{:ok, true} <- Storage.moderation().pubkey_banned?(%{}, pubkey) do
|
{:ok, true} <- Storage.moderation().pubkey_banned?(%{}, pubkey) do
|
||||||
|
|||||||
@@ -221,6 +221,27 @@ defmodule Parrhesia.Web.Connection do
|
|||||||
{:error, :restricted_giftwrap} ->
|
{:error, :restricted_giftwrap} ->
|
||||||
restricted_close(state, subscription_id, EventPolicy.error_message(:restricted_giftwrap))
|
restricted_close(state, subscription_id, EventPolicy.error_message(:restricted_giftwrap))
|
||||||
|
|
||||||
|
{:error, :marmot_group_h_tag_required} ->
|
||||||
|
restricted_close(
|
||||||
|
state,
|
||||||
|
subscription_id,
|
||||||
|
EventPolicy.error_message(:marmot_group_h_tag_required)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, :marmot_group_h_values_exceeded} ->
|
||||||
|
restricted_close(
|
||||||
|
state,
|
||||||
|
subscription_id,
|
||||||
|
EventPolicy.error_message(:marmot_group_h_values_exceeded)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, :marmot_group_filter_window_too_wide} ->
|
||||||
|
restricted_close(
|
||||||
|
state,
|
||||||
|
subscription_id,
|
||||||
|
EventPolicy.error_message(:marmot_group_filter_window_too_wide)
|
||||||
|
)
|
||||||
|
|
||||||
{:error, :subscription_limit_reached} ->
|
{:error, :subscription_limit_reached} ->
|
||||||
response =
|
response =
|
||||||
Protocol.encode_relay({
|
Protocol.encode_relay({
|
||||||
@@ -287,6 +308,27 @@ defmodule Parrhesia.Web.Connection do
|
|||||||
EventPolicy.error_message(:restricted_giftwrap)
|
EventPolicy.error_message(:restricted_giftwrap)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
{:error, :marmot_group_h_tag_required} ->
|
||||||
|
restricted_count_notice(
|
||||||
|
state,
|
||||||
|
subscription_id,
|
||||||
|
EventPolicy.error_message(:marmot_group_h_tag_required)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, :marmot_group_h_values_exceeded} ->
|
||||||
|
restricted_count_notice(
|
||||||
|
state,
|
||||||
|
subscription_id,
|
||||||
|
EventPolicy.error_message(:marmot_group_h_values_exceeded)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, :marmot_group_filter_window_too_wide} ->
|
||||||
|
restricted_count_notice(
|
||||||
|
state,
|
||||||
|
subscription_id,
|
||||||
|
EventPolicy.error_message(:marmot_group_filter_window_too_wide)
|
||||||
|
)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
response = Protocol.encode_relay({:closed, subscription_id, inspect(reason)})
|
response = Protocol.encode_relay({:closed, subscription_id, inspect(reason)})
|
||||||
{:push, {:text, response}, state}
|
{:push, {:text, response}, state}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
defmodule Parrhesia.Repo.Migrations.AddEventTagsHLookupIndex do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
execute(
|
||||||
|
"CREATE INDEX event_tags_h_value_created_at_idx ON event_tags (value, event_created_at DESC) WHERE name = 'h'"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute("DROP INDEX event_tags_h_value_created_at_idx")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -57,6 +57,47 @@ defmodule Parrhesia.Policy.EventPolicyTest do
|
|||||||
EventPolicy.authorize_write(event, MapSet.new([String.duplicate("c", 64)]))
|
EventPolicy.authorize_write(event, MapSet.new([String.duplicate("c", 64)]))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "requires #h when querying kind 445" do
|
||||||
|
filter = %{"kinds" => [445]}
|
||||||
|
|
||||||
|
assert {:error, :marmot_group_h_tag_required} =
|
||||||
|
EventPolicy.authorize_read([filter], MapSet.new())
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
EventPolicy.authorize_read(
|
||||||
|
[%{"kinds" => [445], "#h" => [String.duplicate("a", 64)]}],
|
||||||
|
MapSet.new()
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "enforces max #h values and query window for kind 445 filters" do
|
||||||
|
Application.put_env(
|
||||||
|
:parrhesia,
|
||||||
|
:policies,
|
||||||
|
marmot_group_max_h_values_per_filter: 1,
|
||||||
|
marmot_group_max_query_window_seconds: 10,
|
||||||
|
marmot_require_h_for_group_queries: true
|
||||||
|
)
|
||||||
|
|
||||||
|
too_many_groups = %{
|
||||||
|
"kinds" => [445],
|
||||||
|
"#h" => [String.duplicate("a", 64), String.duplicate("b", 64)]
|
||||||
|
}
|
||||||
|
|
||||||
|
wide_window = %{
|
||||||
|
"kinds" => [445],
|
||||||
|
"#h" => [String.duplicate("c", 64)],
|
||||||
|
"since" => 1,
|
||||||
|
"until" => 100
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, :marmot_group_h_values_exceeded} =
|
||||||
|
EventPolicy.authorize_read([too_many_groups], MapSet.new())
|
||||||
|
|
||||||
|
assert {:error, :marmot_group_filter_window_too_wide} =
|
||||||
|
EventPolicy.authorize_read([wide_window], MapSet.new())
|
||||||
|
end
|
||||||
|
|
||||||
test "rejects mls kinds when feature is disabled" do
|
test "rejects mls kinds when feature is disabled" do
|
||||||
Application.put_env(:parrhesia, :features, nip_ee_mls: false)
|
Application.put_env(:parrhesia, :features, nip_ee_mls: false)
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,51 @@ defmodule Parrhesia.Storage.Adapters.Postgres.EventsQueryCountTest do
|
|||||||
assert result["id"] == matching["id"]
|
assert result["id"] == matching["id"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "query/3 routes Marmot group events by #h and keeps deterministic order" do
|
||||||
|
group_id = String.duplicate("a", 64)
|
||||||
|
other_group_id = String.duplicate("b", 64)
|
||||||
|
|
||||||
|
older =
|
||||||
|
persist_event(%{
|
||||||
|
"kind" => 445,
|
||||||
|
"created_at" => 1_700_000_600,
|
||||||
|
"tags" => [["h", group_id]],
|
||||||
|
"content" => Base.encode64("older")
|
||||||
|
})
|
||||||
|
|
||||||
|
tie_a =
|
||||||
|
persist_event(%{
|
||||||
|
"kind" => 445,
|
||||||
|
"created_at" => 1_700_000_601,
|
||||||
|
"tags" => [["h", group_id]],
|
||||||
|
"content" => Base.encode64("tie-a")
|
||||||
|
})
|
||||||
|
|
||||||
|
tie_b =
|
||||||
|
persist_event(%{
|
||||||
|
"kind" => 445,
|
||||||
|
"created_at" => 1_700_000_601,
|
||||||
|
"tags" => [["h", group_id]],
|
||||||
|
"content" => Base.encode64("tie-b")
|
||||||
|
})
|
||||||
|
|
||||||
|
_other_group =
|
||||||
|
persist_event(%{
|
||||||
|
"kind" => 445,
|
||||||
|
"created_at" => 1_700_000_602,
|
||||||
|
"tags" => [["h", other_group_id]],
|
||||||
|
"content" => Base.encode64("other-group")
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, results} =
|
||||||
|
Events.query(%{}, [%{"kinds" => [445], "#h" => [group_id]}], [])
|
||||||
|
|
||||||
|
tie_winner_id = Enum.min([tie_a["id"], tie_b["id"]])
|
||||||
|
tie_loser_id = Enum.max([tie_a["id"], tie_b["id"]])
|
||||||
|
|
||||||
|
assert Enum.map(results, & &1["id"]) == [tie_winner_id, tie_loser_id, older["id"]]
|
||||||
|
end
|
||||||
|
|
||||||
test "mls keypackage relay list kind 10051 follows replaceable conflict semantics" do
|
test "mls keypackage relay list kind 10051 follows replaceable conflict semantics" do
|
||||||
author = String.duplicate("c", 64)
|
author = String.duplicate("c", 64)
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,19 @@ defmodule Parrhesia.Web.ConnectionTest do
|
|||||||
assert Enum.any?(decoded, fn frame -> frame == ["AUTH", state.auth_challenge] end)
|
assert Enum.any?(decoded, fn frame -> frame == ["AUTH", state.auth_challenge] end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "kind 445 REQ without #h is rejected" do
|
||||||
|
state = connection_state()
|
||||||
|
|
||||||
|
req_payload = Jason.encode!(["REQ", "sub-445", %{"kinds" => [445]}])
|
||||||
|
|
||||||
|
assert {:push, frames, ^state} = Connection.handle_in({req_payload, [opcode: :text]}, state)
|
||||||
|
|
||||||
|
decoded = Enum.map(frames, fn {:text, frame} -> Jason.decode!(frame) end)
|
||||||
|
|
||||||
|
assert ["CLOSED", "sub-445", "restricted: kind 445 queries must include a #h tag"] =
|
||||||
|
Enum.find(decoded, fn frame -> List.first(frame) == "CLOSED" end)
|
||||||
|
end
|
||||||
|
|
||||||
test "valid EVENT stores event and returns accepted OK" do
|
test "valid EVENT stores event and returns accepted OK" do
|
||||||
state = connection_state()
|
state = connection_state()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user