Implement M6 push notification policy guards and replay tests
This commit is contained in:
@@ -10,8 +10,10 @@ defmodule Parrhesia.ConfigTest do
|
||||
assert Parrhesia.Config.get([:policies, :auth_required_for_writes]) == false
|
||||
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, :nip_50_search]) == true
|
||||
assert Parrhesia.Config.get([:features, :nip_ee_mls]) == false
|
||||
assert Parrhesia.Config.get([:features, :marmot_push_notifications]) == false
|
||||
end
|
||||
|
||||
test "returns default for unknown keys" do
|
||||
|
||||
@@ -223,6 +223,207 @@ defmodule Parrhesia.Policy.EventPolicyTest do
|
||||
)
|
||||
end
|
||||
|
||||
test "accepts push coordination events when push feature is enabled" do
|
||||
server_pubkey = String.duplicate("f", 64)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:features,
|
||||
nip_ee_mls: false,
|
||||
marmot_push_notifications: true
|
||||
)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:policies,
|
||||
marmot_push_server_pubkeys: [server_pubkey],
|
||||
marmot_push_max_relay_tags: 16,
|
||||
marmot_push_max_payload_bytes: 65_536,
|
||||
marmot_push_max_trigger_age_seconds: 300,
|
||||
marmot_push_require_expiration: true,
|
||||
marmot_push_max_expiration_window_seconds: 120,
|
||||
marmot_push_max_server_recipients: 1
|
||||
)
|
||||
|
||||
relay_list_event = %{
|
||||
"kind" => 10_050,
|
||||
"tags" => [["relay", "wss://notify.example"], ["relay", "wss://notify2.example"]],
|
||||
"pubkey" => String.duplicate("d", 64),
|
||||
"id" => "",
|
||||
"created_at" => System.system_time(:second),
|
||||
"content" => ""
|
||||
}
|
||||
|
||||
now = System.system_time(:second)
|
||||
|
||||
trigger_event = %{
|
||||
"kind" => 1059,
|
||||
"tags" => [["p", server_pubkey], ["expiration", Integer.to_string(now + 60)]],
|
||||
"pubkey" => String.duplicate("d", 64),
|
||||
"id" => "",
|
||||
"created_at" => now,
|
||||
"content" => "encrypted-push"
|
||||
}
|
||||
|
||||
assert :ok =
|
||||
EventPolicy.authorize_write(
|
||||
relay_list_event,
|
||||
MapSet.new([String.duplicate("d", 64)])
|
||||
)
|
||||
|
||||
assert :ok =
|
||||
EventPolicy.authorize_write(trigger_event, MapSet.new([String.duplicate("d", 64)]))
|
||||
end
|
||||
|
||||
test "enforces push policy limits for relay-list and trigger payloads" do
|
||||
server_pubkey = String.duplicate("e", 64)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:features,
|
||||
nip_ee_mls: false,
|
||||
marmot_push_notifications: true
|
||||
)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:policies,
|
||||
marmot_push_server_pubkeys: [server_pubkey],
|
||||
marmot_push_max_relay_tags: 1,
|
||||
marmot_push_max_payload_bytes: 8,
|
||||
marmot_push_max_trigger_age_seconds: 300,
|
||||
marmot_push_require_expiration: true,
|
||||
marmot_push_max_expiration_window_seconds: 120,
|
||||
marmot_push_max_server_recipients: 1
|
||||
)
|
||||
|
||||
relay_list_event = %{
|
||||
"kind" => 10_050,
|
||||
"tags" => [["relay", "wss://notify.example"], ["relay", "wss://notify2.example"]],
|
||||
"pubkey" => String.duplicate("d", 64),
|
||||
"id" => "",
|
||||
"created_at" => System.system_time(:second),
|
||||
"content" => ""
|
||||
}
|
||||
|
||||
now = System.system_time(:second)
|
||||
|
||||
oversized_trigger = %{
|
||||
"kind" => 1059,
|
||||
"tags" => [["p", server_pubkey], ["expiration", Integer.to_string(now + 60)]],
|
||||
"pubkey" => String.duplicate("d", 64),
|
||||
"id" => "",
|
||||
"created_at" => now,
|
||||
"content" => "encrypted-push-too-large"
|
||||
}
|
||||
|
||||
assert {:error, :push_notification_relay_tags_exceeded} =
|
||||
EventPolicy.authorize_write(
|
||||
relay_list_event,
|
||||
MapSet.new([String.duplicate("d", 64)])
|
||||
)
|
||||
|
||||
assert {:error, :push_notification_payload_too_large} =
|
||||
EventPolicy.authorize_write(
|
||||
oversized_trigger,
|
||||
MapSet.new([String.duplicate("d", 64)])
|
||||
)
|
||||
end
|
||||
|
||||
test "enforces push replay and expiration protection" do
|
||||
server_pubkey = String.duplicate("c", 64)
|
||||
now = System.system_time(:second)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:features,
|
||||
nip_ee_mls: false,
|
||||
marmot_push_notifications: true
|
||||
)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:policies,
|
||||
marmot_push_server_pubkeys: [server_pubkey],
|
||||
marmot_push_max_relay_tags: 16,
|
||||
marmot_push_max_payload_bytes: 65_536,
|
||||
marmot_push_max_trigger_age_seconds: 5,
|
||||
marmot_push_require_expiration: true,
|
||||
marmot_push_max_expiration_window_seconds: 30,
|
||||
marmot_push_max_server_recipients: 1
|
||||
)
|
||||
|
||||
stale_trigger = %{
|
||||
"kind" => 1059,
|
||||
"tags" => [["p", server_pubkey], ["expiration", Integer.to_string(now - 50)]],
|
||||
"pubkey" => String.duplicate("d", 64),
|
||||
"id" => "",
|
||||
"created_at" => now - 20,
|
||||
"content" => "encrypted"
|
||||
}
|
||||
|
||||
missing_expiration = %{
|
||||
"kind" => 1059,
|
||||
"tags" => [["p", server_pubkey]],
|
||||
"pubkey" => String.duplicate("d", 64),
|
||||
"id" => "",
|
||||
"created_at" => now,
|
||||
"content" => "encrypted"
|
||||
}
|
||||
|
||||
far_expiration = %{
|
||||
"kind" => 1059,
|
||||
"tags" => [["p", server_pubkey], ["expiration", Integer.to_string(now + 120)]],
|
||||
"pubkey" => String.duplicate("d", 64),
|
||||
"id" => "",
|
||||
"created_at" => now,
|
||||
"content" => "encrypted"
|
||||
}
|
||||
|
||||
multi_server_target = %{
|
||||
"kind" => 1059,
|
||||
"tags" => [
|
||||
["p", server_pubkey],
|
||||
["p", String.duplicate("b", 64)],
|
||||
["expiration", Integer.to_string(now + 20)]
|
||||
],
|
||||
"pubkey" => String.duplicate("d", 64),
|
||||
"id" => "",
|
||||
"created_at" => now,
|
||||
"content" => "encrypted"
|
||||
}
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:policies,
|
||||
marmot_push_server_pubkeys: [server_pubkey, String.duplicate("b", 64)],
|
||||
marmot_push_max_relay_tags: 16,
|
||||
marmot_push_max_payload_bytes: 65_536,
|
||||
marmot_push_max_trigger_age_seconds: 5,
|
||||
marmot_push_require_expiration: true,
|
||||
marmot_push_max_expiration_window_seconds: 30,
|
||||
marmot_push_max_server_recipients: 1
|
||||
)
|
||||
|
||||
assert {:error, :push_notification_replay_window_exceeded} =
|
||||
EventPolicy.authorize_write(stale_trigger, MapSet.new([String.duplicate("d", 64)]))
|
||||
|
||||
assert {:error, :push_notification_missing_expiration} =
|
||||
EventPolicy.authorize_write(
|
||||
missing_expiration,
|
||||
MapSet.new([String.duplicate("d", 64)])
|
||||
)
|
||||
|
||||
assert {:error, :push_notification_expiration_too_far} =
|
||||
EventPolicy.authorize_write(far_expiration, MapSet.new([String.duplicate("d", 64)]))
|
||||
|
||||
assert {:error, :push_notification_server_recipients_exceeded} =
|
||||
EventPolicy.authorize_write(
|
||||
multi_server_target,
|
||||
MapSet.new([String.duplicate("d", 64)])
|
||||
)
|
||||
end
|
||||
|
||||
test "rejects mls kinds when feature is disabled" do
|
||||
Application.put_env(:parrhesia, :features, nip_ee_mls: false)
|
||||
|
||||
|
||||
@@ -149,6 +149,82 @@ defmodule Parrhesia.Web.ConformanceTest do
|
||||
assert persisted_welcome["id"] == wrapped_welcome["id"]
|
||||
end
|
||||
|
||||
test "push coordination events are accepted and stored when feature is enabled" do
|
||||
previous_features = Application.get_env(:parrhesia, :features, [])
|
||||
previous_policies = Application.get_env(:parrhesia, :policies, [])
|
||||
|
||||
server_pubkey = String.duplicate("f", 64)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:features,
|
||||
previous_features
|
||||
|> Keyword.put(:marmot_push_notifications, true)
|
||||
|> Keyword.put(:nip_ee_mls, false)
|
||||
)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:policies,
|
||||
previous_policies
|
||||
|> Keyword.put(:marmot_push_server_pubkeys, [server_pubkey])
|
||||
|> Keyword.put(:marmot_push_max_trigger_age_seconds, 300)
|
||||
|> Keyword.put(:marmot_push_require_expiration, true)
|
||||
|> Keyword.put(:marmot_push_max_expiration_window_seconds, 120)
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :features, previous_features)
|
||||
Application.put_env(:parrhesia, :policies, previous_policies)
|
||||
end)
|
||||
|
||||
{:ok, state} = Connection.init(subscription_index: nil)
|
||||
|
||||
relay_list_event =
|
||||
valid_event(%{
|
||||
"kind" => 10_050,
|
||||
"tags" => [["relay", "wss://notify.example"], ["relay", "wss://notify2.example"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
now = System.system_time(:second)
|
||||
|
||||
push_trigger =
|
||||
valid_event(%{
|
||||
"kind" => 1059,
|
||||
"created_at" => now,
|
||||
"tags" => [["p", server_pubkey], ["expiration", Integer.to_string(now + 60)]],
|
||||
"content" => "encrypted-push"
|
||||
})
|
||||
|
||||
assert {:push, {:text, relay_ok_frame}, ^state} =
|
||||
Connection.handle_in(
|
||||
{Jason.encode!(["EVENT", relay_list_event]), [opcode: :text]},
|
||||
state
|
||||
)
|
||||
|
||||
assert Jason.decode!(relay_ok_frame) == [
|
||||
"OK",
|
||||
relay_list_event["id"],
|
||||
true,
|
||||
"ok: event stored"
|
||||
]
|
||||
|
||||
assert {:push, {:text, trigger_ok_frame}, ^state} =
|
||||
Connection.handle_in(
|
||||
{Jason.encode!(["EVENT", push_trigger]), [opcode: :text]},
|
||||
state
|
||||
)
|
||||
|
||||
assert Jason.decode!(trigger_ok_frame) == ["OK", push_trigger["id"], true, "ok: event stored"]
|
||||
|
||||
assert {:ok, persisted_relay_list} = Storage.events().get_event(%{}, relay_list_event["id"])
|
||||
assert persisted_relay_list["id"] == relay_list_event["id"]
|
||||
|
||||
assert {:ok, persisted_trigger} = Storage.events().get_event(%{}, push_trigger["id"])
|
||||
assert persisted_trigger["id"] == push_trigger["id"]
|
||||
end
|
||||
|
||||
defp valid_event(overrides \\ %{}) do
|
||||
now = System.system_time(:second)
|
||||
|
||||
|
||||
@@ -223,6 +223,117 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
]
|
||||
end
|
||||
|
||||
test "push trigger EVENT outside replay window is rejected" do
|
||||
previous_features = Application.get_env(:parrhesia, :features, [])
|
||||
previous_policies = Application.get_env(:parrhesia, :policies, [])
|
||||
|
||||
server_pubkey = String.duplicate("e", 64)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:features,
|
||||
previous_features
|
||||
|> Keyword.put(:marmot_push_notifications, true)
|
||||
|> Keyword.put(:nip_ee_mls, false)
|
||||
)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:policies,
|
||||
previous_policies
|
||||
|> Keyword.put(:marmot_push_server_pubkeys, [server_pubkey])
|
||||
|> Keyword.put(:marmot_push_max_trigger_age_seconds, 5)
|
||||
|> Keyword.put(:marmot_push_require_expiration, true)
|
||||
|> Keyword.put(:marmot_push_max_expiration_window_seconds, 30)
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :features, previous_features)
|
||||
Application.put_env(:parrhesia, :policies, previous_policies)
|
||||
end)
|
||||
|
||||
state = connection_state()
|
||||
now = System.system_time(:second)
|
||||
|
||||
event =
|
||||
valid_event()
|
||||
|> Map.put("kind", 1059)
|
||||
|> Map.put("created_at", now - 20)
|
||||
|> Map.put("tags", [["p", server_pubkey], ["expiration", Integer.to_string(now - 5)]])
|
||||
|> Map.put("content", "encrypted")
|
||||
|> 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,
|
||||
"restricted: push notification trigger is outside replay window"
|
||||
]
|
||||
end
|
||||
|
||||
test "duplicate push trigger EVENT is rejected" do
|
||||
previous_features = Application.get_env(:parrhesia, :features, [])
|
||||
previous_policies = Application.get_env(:parrhesia, :policies, [])
|
||||
|
||||
server_pubkey = String.duplicate("f", 64)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:features,
|
||||
previous_features
|
||||
|> Keyword.put(:marmot_push_notifications, true)
|
||||
|> Keyword.put(:nip_ee_mls, false)
|
||||
)
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:policies,
|
||||
previous_policies
|
||||
|> Keyword.put(:marmot_push_server_pubkeys, [server_pubkey])
|
||||
|> Keyword.put(:marmot_push_max_trigger_age_seconds, 300)
|
||||
|> Keyword.put(:marmot_push_require_expiration, true)
|
||||
|> Keyword.put(:marmot_push_max_expiration_window_seconds, 120)
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :features, previous_features)
|
||||
Application.put_env(:parrhesia, :policies, previous_policies)
|
||||
end)
|
||||
|
||||
state = connection_state()
|
||||
now = System.system_time(:second)
|
||||
|
||||
event =
|
||||
valid_event()
|
||||
|> Map.put("kind", 1059)
|
||||
|> Map.put("created_at", now)
|
||||
|> Map.put("tags", [["p", server_pubkey], ["expiration", Integer.to_string(now + 60)]])
|
||||
|> Map.put("content", "encrypted")
|
||||
|> then(&Map.put(&1, "id", EventValidator.compute_id(&1)))
|
||||
|
||||
payload = Jason.encode!(["EVENT", event])
|
||||
|
||||
assert {:push, {:text, first_response}, ^state} =
|
||||
Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(first_response) == ["OK", event["id"], true, "ok: event stored"]
|
||||
|
||||
assert {:push, {:text, second_response}, ^state} =
|
||||
Connection.handle_in({payload, [opcode: :text]}, state)
|
||||
|
||||
assert Jason.decode!(second_response) == [
|
||||
"OK",
|
||||
event["id"],
|
||||
false,
|
||||
"duplicate: event already stored"
|
||||
]
|
||||
end
|
||||
|
||||
test "NEG sessions open and close" do
|
||||
state = connection_state()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user