Implement full NIP-43 relay access flow
This commit is contained in:
13
README.md
13
README.md
@@ -32,6 +32,8 @@ Current `supported_nips` list:
|
||||
|
||||
`1, 9, 11, 13, 17, 40, 42, 43, 44, 45, 50, 59, 62, 66, 70, 77, 86, 98`
|
||||
|
||||
`43` is advertised when the built-in NIP-43 relay access flow is enabled. Parrhesia generates relay-signed `28935` invite responses on `REQ`, validates join and leave requests locally, and publishes the resulting signed `8000`, `8001`, and `13534` relay membership events into its own local event store.
|
||||
|
||||
`66` is advertised when the built-in NIP-66 publisher is enabled and has at least one relay target. The default config enables it for the `public` relay URL. Parrhesia probes those target relays, collects the resulting NIP-11 / websocket liveness data, and then publishes the signed `10166` and `30166` events locally on this relay.
|
||||
|
||||
## Requirements
|
||||
@@ -183,6 +185,7 @@ CSV env vars use comma-separated values. Boolean env vars accept `1/0`, `true/fa
|
||||
| `:identity.private_key` | `PARRHESIA_IDENTITY_PRIVATE_KEY` | `nil` | Optional inline relay private key |
|
||||
| `:moderation_cache_enabled` | `PARRHESIA_MODERATION_CACHE_ENABLED` | `true` | Toggle moderation cache |
|
||||
| `:enable_expiration_worker` | `PARRHESIA_ENABLE_EXPIRATION_WORKER` | `true` | Toggle background expiration worker |
|
||||
| `:nip43` | config-file driven | see table below | Built-in NIP-43 relay access invite / membership flow |
|
||||
| `:nip66` | config-file driven | see table below | Built-in NIP-66 discovery / monitor publisher |
|
||||
| `:sync.path` | `PARRHESIA_SYNC_PATH` | `nil` | Optional path to sync peer config |
|
||||
| `:sync.start_workers?` | `PARRHESIA_SYNC_START_WORKERS` | `true` | Start outbound sync workers on boot |
|
||||
@@ -266,6 +269,16 @@ Every listener supports this config-file schema:
|
||||
|
||||
NIP-66 targets are probe sources, not publish destinations. Parrhesia connects to each target relay, collects the configured liveness / discovery data, and stores the resulting signed `10166` / `30166` events in its own local event store so clients can query them here.
|
||||
|
||||
#### `:nip43`
|
||||
|
||||
| Atom key | ENV | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `:enabled` | `-` | `true` | Enables the built-in NIP-43 relay access flow and advertises `43` in NIP-11 |
|
||||
| `:invite_ttl_seconds` | `-` | `900` | Expiration window for generated invite claim strings returned by `REQ` filters targeting kind `28935` |
|
||||
| `:request_max_age_seconds` | `-` | `300` | Maximum allowed age for inbound join (`28934`) and leave (`28936`) requests |
|
||||
|
||||
Parrhesia treats NIP-43 invite requests as synthetic relay output, not stored client input. A `REQ` for kind `28935` causes the relay to generate a fresh relay-signed invite event on the fly. Clients then submit that claim back in a protected kind `28934` join request. When a join or leave request is accepted, Parrhesia updates its local relay membership state and publishes the corresponding relay-signed `8000` / `8001` delta plus the latest `13534` membership snapshot locally.
|
||||
|
||||
#### `:limits`
|
||||
|
||||
| Atom key | ENV | Default |
|
||||
|
||||
@@ -5,6 +5,11 @@ config :postgrex, :json_library, JSON
|
||||
config :parrhesia,
|
||||
moderation_cache_enabled: true,
|
||||
relay_url: "ws://localhost:4413/relay",
|
||||
nip43: [
|
||||
enabled: true,
|
||||
invite_ttl_seconds: 900,
|
||||
request_max_age_seconds: 300
|
||||
],
|
||||
nip66: [
|
||||
enabled: true,
|
||||
publish_interval_seconds: 900,
|
||||
|
||||
@@ -7,7 +7,7 @@ defmodule Parrhesia.API.Events do
|
||||
alias Parrhesia.API.RequestContext
|
||||
alias Parrhesia.Fanout.Dispatcher
|
||||
alias Parrhesia.Fanout.MultiNode
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.NIP43
|
||||
alias Parrhesia.Policy.EventPolicy
|
||||
alias Parrhesia.Protocol
|
||||
alias Parrhesia.Protocol.Filter
|
||||
@@ -40,7 +40,7 @@ defmodule Parrhesia.API.Events do
|
||||
:ok <- validate_event_payload_size(event, max_event_bytes(opts)),
|
||||
:ok <- Protocol.validate_event(event),
|
||||
:ok <- EventPolicy.authorize_write(event, context.authenticated_pubkeys, context),
|
||||
:ok <- maybe_process_group_event(event),
|
||||
{:ok, publish_state} <- NIP43.prepare_publish(event, nip43_opts(opts, context)),
|
||||
{:ok, _stored, message} <- persist_event(event) do
|
||||
Telemetry.emit(
|
||||
[:parrhesia, :ingest, :stop],
|
||||
@@ -48,6 +48,12 @@ defmodule Parrhesia.API.Events do
|
||||
telemetry_metadata_for_event(event)
|
||||
)
|
||||
|
||||
message =
|
||||
case NIP43.finalize_publish(event, publish_state, nip43_opts(opts, context)) do
|
||||
{:ok, override} when is_binary(override) -> override
|
||||
:ok -> message
|
||||
end
|
||||
|
||||
Dispatcher.dispatch(event)
|
||||
maybe_publish_multi_node(event)
|
||||
|
||||
@@ -85,6 +91,8 @@ defmodule Parrhesia.API.Events do
|
||||
:ok <- maybe_validate_filters(filters, opts),
|
||||
:ok <- maybe_authorize_read(filters, context, opts),
|
||||
{:ok, events} <- Storage.events().query(%{}, filters, storage_query_opts(context, opts)) do
|
||||
events = NIP43.dynamic_events(filters, nip43_opts(opts, context)) ++ events
|
||||
|
||||
Telemetry.emit(
|
||||
[:parrhesia, :query, :stop],
|
||||
%{duration: System.monotonic_time() - started_at},
|
||||
@@ -108,6 +116,7 @@ defmodule Parrhesia.API.Events do
|
||||
:ok <- maybe_authorize_read(filters, context, opts),
|
||||
{:ok, count} <-
|
||||
Storage.events().count(%{}, filters, requester_pubkeys: requester_pubkeys(context)),
|
||||
count <- count + NIP43.dynamic_count(filters, nip43_opts(opts, context)),
|
||||
{:ok, result} <- maybe_build_count_result(filters, count, Keyword.get(opts, :options)) do
|
||||
Telemetry.emit(
|
||||
[:parrhesia, :query, :stop],
|
||||
@@ -184,14 +193,6 @@ defmodule Parrhesia.API.Events do
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
defp maybe_process_group_event(event) do
|
||||
if Flow.group_related_kind?(Map.get(event, "kind")) do
|
||||
Flow.handle_event(event)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp persist_event(event) do
|
||||
kind = Map.get(event, "kind")
|
||||
|
||||
@@ -282,6 +283,11 @@ defmodule Parrhesia.API.Events do
|
||||
end
|
||||
end
|
||||
|
||||
defp nip43_opts(opts, %RequestContext{} = context) do
|
||||
[context: context, relay_url: Application.get_env(:parrhesia, :relay_url)]
|
||||
|> Kernel.++(Keyword.take(opts, [:path, :private_key, :configured_private_key]))
|
||||
end
|
||||
|
||||
defp error_message_for_publish_failure(:duplicate_event),
|
||||
do: "duplicate: event already stored"
|
||||
|
||||
|
||||
@@ -1,52 +1,62 @@
|
||||
defmodule Parrhesia.Groups.Flow do
|
||||
@moduledoc """
|
||||
Minimal group and membership flow handling for NIP-29/NIP-43 related kinds.
|
||||
Relay access membership projection backed by the shared group storage adapter.
|
||||
"""
|
||||
|
||||
alias Parrhesia.Storage
|
||||
|
||||
@membership_request_kind 8_000
|
||||
@membership_approval_kind 8_001
|
||||
@relay_metadata_kind 28_934
|
||||
@relay_admins_kind 28_935
|
||||
@relay_rules_kind 28_936
|
||||
@membership_event_kind 13_534
|
||||
@relay_access_group_id "__relay_access__"
|
||||
@add_user_kind 8_000
|
||||
@remove_user_kind 8_001
|
||||
@join_request_kind 28_934
|
||||
@invite_request_kind 28_935
|
||||
@leave_request_kind 28_936
|
||||
@membership_list_kind 13_534
|
||||
|
||||
@spec handle_event(map()) :: :ok | {:error, term()}
|
||||
def handle_event(event) when is_map(event) do
|
||||
case Map.get(event, "kind") do
|
||||
@membership_request_kind -> upsert_membership(event, "requested")
|
||||
@membership_approval_kind -> upsert_membership(event, "member")
|
||||
@membership_event_kind -> upsert_membership(event, "member")
|
||||
@relay_metadata_kind -> :ok
|
||||
@relay_admins_kind -> :ok
|
||||
@relay_rules_kind -> :ok
|
||||
@join_request_kind -> put_member(event, membership_pubkey_from_event(event))
|
||||
@leave_request_kind -> delete_member(event, membership_pubkey_from_event(event))
|
||||
@add_user_kind -> put_member(event, tagged_pubkey(event, "p"))
|
||||
@remove_user_kind -> delete_member(event, tagged_pubkey(event, "p"))
|
||||
@membership_list_kind -> replace_membership_snapshot(event)
|
||||
@invite_request_kind -> :ok
|
||||
_other -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
@spec group_related_kind?(non_neg_integer()) :: boolean()
|
||||
def group_related_kind?(kind)
|
||||
@spec relay_access_kind?(non_neg_integer()) :: boolean()
|
||||
def relay_access_kind?(kind)
|
||||
when kind in [
|
||||
@membership_request_kind,
|
||||
@membership_approval_kind,
|
||||
@relay_metadata_kind,
|
||||
@relay_admins_kind,
|
||||
@relay_rules_kind,
|
||||
@membership_event_kind
|
||||
@add_user_kind,
|
||||
@remove_user_kind,
|
||||
@join_request_kind,
|
||||
@invite_request_kind,
|
||||
@leave_request_kind,
|
||||
@membership_list_kind
|
||||
],
|
||||
do: true
|
||||
|
||||
def group_related_kind?(_kind), do: false
|
||||
def relay_access_kind?(_kind), do: false
|
||||
|
||||
defp upsert_membership(event, role) do
|
||||
with {:ok, group_id} <- group_id_from_event(event),
|
||||
{:ok, pubkey} <- pubkey_from_event(event) do
|
||||
@spec get_membership(binary()) :: {:ok, map() | nil} | {:error, term()}
|
||||
def get_membership(pubkey) when is_binary(pubkey) do
|
||||
Storage.groups().get_membership(%{}, @relay_access_group_id, pubkey)
|
||||
end
|
||||
|
||||
@spec list_memberships() :: {:ok, [map()]} | {:error, term()}
|
||||
def list_memberships do
|
||||
Storage.groups().list_memberships(%{}, @relay_access_group_id)
|
||||
end
|
||||
|
||||
defp put_member(event, {:ok, pubkey}) do
|
||||
with {:ok, metadata} <- membership_metadata(event) do
|
||||
Storage.groups().put_membership(%{}, %{
|
||||
group_id: group_id,
|
||||
group_id: @relay_access_group_id,
|
||||
pubkey: pubkey,
|
||||
role: role,
|
||||
metadata: %{"source_kind" => Map.get(event, "kind")}
|
||||
role: "member",
|
||||
metadata: metadata
|
||||
})
|
||||
|> case do
|
||||
{:ok, _membership} -> :ok
|
||||
@@ -55,21 +65,85 @@ defmodule Parrhesia.Groups.Flow do
|
||||
end
|
||||
end
|
||||
|
||||
defp group_id_from_event(event) do
|
||||
group_id =
|
||||
event
|
||||
|> Map.get("tags", [])
|
||||
|> Enum.find_value(fn
|
||||
["h", value | _rest] when is_binary(value) and value != "" -> value
|
||||
_tag -> nil
|
||||
end)
|
||||
defp put_member(_event, {:error, reason}), do: {:error, reason}
|
||||
|
||||
case group_id do
|
||||
nil -> {:error, :missing_group_id}
|
||||
value -> {:ok, value}
|
||||
defp delete_member(_event, {:ok, pubkey}) do
|
||||
Storage.groups().delete_membership(%{}, @relay_access_group_id, pubkey)
|
||||
end
|
||||
|
||||
defp delete_member(_event, {:error, reason}), do: {:error, reason}
|
||||
|
||||
defp replace_membership_snapshot(event) do
|
||||
with {:ok, tagged_members} <- tagged_pubkeys(event, "member"),
|
||||
{:ok, existing_memberships} <- list_memberships() do
|
||||
incoming_pubkeys = MapSet.new(tagged_members)
|
||||
existing_pubkeys = MapSet.new(Enum.map(existing_memberships, & &1.pubkey))
|
||||
|
||||
remove_members =
|
||||
existing_pubkeys
|
||||
|> MapSet.difference(incoming_pubkeys)
|
||||
|> MapSet.to_list()
|
||||
|
||||
add_members =
|
||||
incoming_pubkeys
|
||||
|> MapSet.to_list()
|
||||
|
||||
:ok = remove_memberships(remove_members)
|
||||
add_memberships(event, add_members)
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp pubkey_from_event(%{"pubkey" => pubkey}) when is_binary(pubkey), do: {:ok, pubkey}
|
||||
defp pubkey_from_event(_event), do: {:error, :missing_pubkey}
|
||||
defp membership_pubkey_from_event(%{"pubkey" => pubkey}) when is_binary(pubkey),
|
||||
do: {:ok, pubkey}
|
||||
|
||||
defp membership_pubkey_from_event(_event), do: {:error, :missing_pubkey}
|
||||
|
||||
defp tagged_pubkey(event, tag_name) do
|
||||
event
|
||||
|> tagged_pubkeys(tag_name)
|
||||
|> case do
|
||||
{:ok, [pubkey]} -> {:ok, pubkey}
|
||||
{:ok, []} -> {:error, :missing_pubkey}
|
||||
{:ok, _pubkeys} -> {:error, :invalid_pubkey}
|
||||
end
|
||||
end
|
||||
|
||||
defp tagged_pubkeys(event, tag_name) do
|
||||
pubkeys =
|
||||
event
|
||||
|> Map.get("tags", [])
|
||||
|> Enum.flat_map(fn
|
||||
[^tag_name, pubkey | _rest] when is_binary(pubkey) and pubkey != "" -> [pubkey]
|
||||
_tag -> []
|
||||
end)
|
||||
|
||||
{:ok, Enum.uniq(pubkeys)}
|
||||
end
|
||||
|
||||
defp membership_metadata(event) do
|
||||
{:ok,
|
||||
%{
|
||||
"source_kind" => Map.get(event, "kind"),
|
||||
"source_event_id" => Map.get(event, "id")
|
||||
}}
|
||||
end
|
||||
|
||||
defp remove_memberships(pubkeys) when is_list(pubkeys) do
|
||||
Enum.each(pubkeys, fn pubkey ->
|
||||
:ok = Storage.groups().delete_membership(%{}, @relay_access_group_id, pubkey)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp add_memberships(event, pubkeys) when is_list(pubkeys) do
|
||||
Enum.reduce_while(pubkeys, :ok, fn pubkey, :ok ->
|
||||
case put_member(event, {:ok, pubkey}) do
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, _reason} = error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
389
lib/parrhesia/nip43.ex
Normal file
389
lib/parrhesia/nip43.ex
Normal file
@@ -0,0 +1,389 @@
|
||||
defmodule Parrhesia.NIP43 do
|
||||
@moduledoc false
|
||||
|
||||
alias Parrhesia.API.Events
|
||||
alias Parrhesia.API.Identity
|
||||
alias Parrhesia.API.RequestContext
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.Protocol
|
||||
alias Parrhesia.Protocol.Filter
|
||||
|
||||
@join_request_kind 28_934
|
||||
@invite_request_kind 28_935
|
||||
@leave_request_kind 28_936
|
||||
@add_user_kind 8_000
|
||||
@remove_user_kind 8_001
|
||||
@membership_list_kind 13_534
|
||||
@claim_token_kind 31_943
|
||||
@default_invite_ttl_seconds 900
|
||||
|
||||
@type publish_state ::
|
||||
:ok
|
||||
| %{action: :join, duplicate?: boolean(), message: String.t()}
|
||||
| %{action: :leave, duplicate?: boolean(), message: String.t()}
|
||||
|
||||
@spec enabled?(keyword()) :: boolean()
|
||||
def enabled?(opts \\ []) do
|
||||
config(opts)
|
||||
|> Keyword.get(:enabled, true)
|
||||
|> Kernel.==(true)
|
||||
end
|
||||
|
||||
@spec prepare_publish(map(), keyword()) :: {:ok, publish_state()} | {:error, term()}
|
||||
def prepare_publish(event, opts \\ []) when is_map(event) and is_list(opts) do
|
||||
if enabled?(opts) do
|
||||
prepare_enabled_publish(event, opts)
|
||||
else
|
||||
prepare_disabled_publish(event)
|
||||
end
|
||||
end
|
||||
|
||||
@spec finalize_publish(map(), publish_state(), keyword()) :: :ok | {:ok, String.t()}
|
||||
def finalize_publish(event, publish_state, opts \\ [])
|
||||
|
||||
def finalize_publish(event, :ok, _opts) when is_map(event) do
|
||||
case Map.get(event, "kind") do
|
||||
kind when kind in [@add_user_kind, @remove_user_kind, @membership_list_kind] ->
|
||||
Flow.handle_event(event)
|
||||
|
||||
_other ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def finalize_publish(event, %{action: :join, duplicate?: true, message: message}, _opts)
|
||||
when is_map(event) do
|
||||
{:ok, message}
|
||||
end
|
||||
|
||||
def finalize_publish(event, %{action: :join, duplicate?: false, message: message}, opts)
|
||||
when is_map(event) do
|
||||
opts = Keyword.put_new(opts, :now, Map.get(event, "created_at"))
|
||||
:ok = Flow.handle_event(event)
|
||||
publish_membership_events(Map.get(event, "pubkey"), :add, opts)
|
||||
{:ok, message}
|
||||
end
|
||||
|
||||
def finalize_publish(event, %{action: :leave, duplicate?: true, message: message}, _opts)
|
||||
when is_map(event) do
|
||||
{:ok, message}
|
||||
end
|
||||
|
||||
def finalize_publish(event, %{action: :leave, duplicate?: false, message: message}, opts)
|
||||
when is_map(event) do
|
||||
opts = Keyword.put_new(opts, :now, Map.get(event, "created_at"))
|
||||
:ok = Flow.handle_event(event)
|
||||
publish_membership_events(Map.get(event, "pubkey"), :remove, opts)
|
||||
{:ok, message}
|
||||
end
|
||||
|
||||
@spec dynamic_events([map()], keyword()) :: [map()]
|
||||
def dynamic_events(filters, opts \\ []) when is_list(filters) and is_list(opts) do
|
||||
if enabled?(opts) and requests_invite?(filters) do
|
||||
filters
|
||||
|> build_invite_event(opts)
|
||||
|> maybe_wrap_event()
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
@spec dynamic_count([map()], keyword()) :: non_neg_integer()
|
||||
def dynamic_count(filters, opts \\ []) do
|
||||
filters
|
||||
|> dynamic_events(opts)
|
||||
|> length()
|
||||
end
|
||||
|
||||
defp prepare_enabled_publish(%{"kind" => @join_request_kind, "pubkey" => pubkey} = event, opts)
|
||||
when is_binary(pubkey) do
|
||||
with {:ok, _claim} <- validate_claim_from_event(event),
|
||||
{:ok, membership} <- Flow.get_membership(pubkey) do
|
||||
if membership_active?(membership) do
|
||||
{:ok,
|
||||
%{
|
||||
action: :join,
|
||||
duplicate?: true,
|
||||
message: "duplicate: you are already a member of this relay."
|
||||
}}
|
||||
else
|
||||
{:ok,
|
||||
%{
|
||||
action: :join,
|
||||
duplicate?: false,
|
||||
message: "info: welcome to #{relay_url(opts)}!"
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_enabled_publish(%{"kind" => @leave_request_kind, "pubkey" => pubkey}, _opts)
|
||||
when is_binary(pubkey) do
|
||||
with {:ok, membership} <- Flow.get_membership(pubkey) do
|
||||
if membership_active?(membership) do
|
||||
{:ok, %{action: :leave, duplicate?: false, message: "info: membership revoked."}}
|
||||
else
|
||||
{:ok,
|
||||
%{
|
||||
action: :leave,
|
||||
duplicate?: true,
|
||||
message: "duplicate: you are not a member of this relay."
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_enabled_publish(%{"kind" => @invite_request_kind}, _opts) do
|
||||
{:error, "restricted: kind 28935 invite claims are generated via REQ"}
|
||||
end
|
||||
|
||||
defp prepare_enabled_publish(%{"kind" => kind, "pubkey" => pubkey}, _opts)
|
||||
when kind in [@add_user_kind, @remove_user_kind, @membership_list_kind] and
|
||||
is_binary(pubkey) do
|
||||
case relay_pubkey() do
|
||||
{:ok, ^pubkey} -> {:ok, :ok}
|
||||
{:ok, _other} -> {:error, "restricted: relay access metadata must be relay-signed"}
|
||||
{:error, _reason} -> {:error, "error: relay identity unavailable"}
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_enabled_publish(_event, _opts), do: {:ok, :ok}
|
||||
|
||||
defp prepare_disabled_publish(%{"kind" => kind})
|
||||
when kind in [
|
||||
@join_request_kind,
|
||||
@invite_request_kind,
|
||||
@leave_request_kind,
|
||||
@add_user_kind,
|
||||
@remove_user_kind,
|
||||
@membership_list_kind
|
||||
] do
|
||||
{:error, "blocked: NIP-43 relay access requests are disabled"}
|
||||
end
|
||||
|
||||
defp prepare_disabled_publish(_event), do: {:ok, :ok}
|
||||
|
||||
defp build_invite_event(filters, opts) do
|
||||
now = Keyword.get(opts, :now, System.system_time(:second))
|
||||
identity_opts = identity_opts(opts)
|
||||
|
||||
with {:ok, claim} <- issue_claim(now, opts),
|
||||
{:ok, signed_event} <-
|
||||
%{
|
||||
"created_at" => now,
|
||||
"kind" => @invite_request_kind,
|
||||
"tags" => [["-"], ["claim", claim]],
|
||||
"content" => ""
|
||||
}
|
||||
|> Identity.sign_event(identity_opts),
|
||||
true <- Filter.matches_any?(signed_event, filters) do
|
||||
{:ok, signed_event}
|
||||
else
|
||||
_other -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_wrap_event({:ok, event}), do: [event]
|
||||
defp maybe_wrap_event(_other), do: []
|
||||
|
||||
defp requests_invite?(filters) do
|
||||
Enum.any?(filters, fn filter ->
|
||||
case Map.get(filter, "kinds") do
|
||||
kinds when is_list(kinds) -> @invite_request_kind in kinds
|
||||
_other -> false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp issue_claim(now, opts) do
|
||||
ttl_seconds =
|
||||
config(opts)
|
||||
|> Keyword.get(:invite_ttl_seconds, @default_invite_ttl_seconds)
|
||||
|> normalize_positive_integer(@default_invite_ttl_seconds)
|
||||
|
||||
identity_opts = identity_opts(opts)
|
||||
|
||||
token_event = %{
|
||||
"created_at" => now,
|
||||
"kind" => @claim_token_kind,
|
||||
"tags" => [["exp", Integer.to_string(now + ttl_seconds)]],
|
||||
"content" => Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)
|
||||
}
|
||||
|
||||
with {:ok, signed_token} <- Identity.sign_event(token_event, identity_opts) do
|
||||
signed_token
|
||||
|> JSON.encode!()
|
||||
|> Base.url_encode64(padding: false)
|
||||
|> then(&{:ok, &1})
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_claim_from_event(event) do
|
||||
claim =
|
||||
event
|
||||
|> Map.get("tags", [])
|
||||
|> Enum.find_value(fn
|
||||
["claim", value | _rest] when is_binary(value) and value != "" -> value
|
||||
_tag -> nil
|
||||
end)
|
||||
|
||||
case claim do
|
||||
nil -> {:error, "restricted: that is an invalid invite code."}
|
||||
value -> validate_claim(value)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_claim(claim) when is_binary(claim) do
|
||||
with {:ok, payload} <- Base.url_decode64(claim, padding: false),
|
||||
{:ok, decoded} <- JSON.decode(payload),
|
||||
:ok <- Protocol.validate_event(decoded),
|
||||
:ok <- validate_claim_token(decoded) do
|
||||
{:ok, decoded}
|
||||
else
|
||||
{:error, :expired_claim} ->
|
||||
{:error, "restricted: that invite code is expired."}
|
||||
|
||||
_other ->
|
||||
{:error, "restricted: that is an invalid invite code."}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_claim(_claim), do: {:error, "restricted: that is an invalid invite code."}
|
||||
|
||||
defp validate_claim_token(%{
|
||||
"kind" => @claim_token_kind,
|
||||
"pubkey" => pubkey,
|
||||
"tags" => tags
|
||||
}) do
|
||||
with {:ok, relay_pubkey} <- relay_pubkey(),
|
||||
true <- pubkey == relay_pubkey,
|
||||
{:ok, expires_at} <- fetch_expiration(tags),
|
||||
true <- expires_at >= System.system_time(:second) do
|
||||
:ok
|
||||
else
|
||||
false -> {:error, :invalid_claim}
|
||||
{:error, _reason} -> {:error, :invalid_claim}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_claim_token(_event), do: {:error, :invalid_claim}
|
||||
|
||||
defp fetch_expiration(tags) when is_list(tags) do
|
||||
case Enum.find(tags, &match?(["exp", _value | _rest], &1)) do
|
||||
["exp", value | _rest] ->
|
||||
parse_expiration(value)
|
||||
|
||||
_other ->
|
||||
{:error, :invalid_claim}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_expiration(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{expires_at, ""} when expires_at > 0 -> validate_expiration(expires_at)
|
||||
_other -> {:error, :invalid_claim}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_expiration(_value), do: {:error, :invalid_claim}
|
||||
|
||||
defp validate_expiration(expires_at) when is_integer(expires_at) do
|
||||
if expires_at >= System.system_time(:second) do
|
||||
{:ok, expires_at}
|
||||
else
|
||||
{:error, :expired_claim}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_expiration(_expires_at), do: {:error, :expired_claim}
|
||||
|
||||
defp publish_membership_events(member_pubkey, action, opts) when is_binary(member_pubkey) do
|
||||
now = Keyword.get(opts, :now, System.system_time(:second))
|
||||
identity_opts = identity_opts(opts)
|
||||
context = Keyword.get(opts, :context, %RequestContext{})
|
||||
|
||||
action
|
||||
|> build_membership_delta_event(member_pubkey, now)
|
||||
|> sign_and_publish(context, identity_opts)
|
||||
|
||||
current_membership_snapshot(now)
|
||||
|> sign_and_publish(context, identity_opts)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp build_membership_delta_event(:add, member_pubkey, now) do
|
||||
%{
|
||||
"created_at" => now,
|
||||
"kind" => @add_user_kind,
|
||||
"tags" => [["-"], ["p", member_pubkey]],
|
||||
"content" => ""
|
||||
}
|
||||
end
|
||||
|
||||
defp build_membership_delta_event(:remove, member_pubkey, now) do
|
||||
%{
|
||||
"created_at" => now,
|
||||
"kind" => @remove_user_kind,
|
||||
"tags" => [["-"], ["p", member_pubkey]],
|
||||
"content" => ""
|
||||
}
|
||||
end
|
||||
|
||||
defp current_membership_snapshot(now) do
|
||||
tags =
|
||||
case Flow.list_memberships() do
|
||||
{:ok, memberships} ->
|
||||
[["-"] | Enum.map(memberships, &["member", &1.pubkey])]
|
||||
|
||||
{:error, _reason} ->
|
||||
[["-"]]
|
||||
end
|
||||
|
||||
%{
|
||||
"created_at" => now,
|
||||
"kind" => @membership_list_kind,
|
||||
"tags" => tags,
|
||||
"content" => ""
|
||||
}
|
||||
end
|
||||
|
||||
defp sign_and_publish(unsigned_event, context, identity_opts) do
|
||||
with {:ok, signed_event} <- Identity.sign_event(unsigned_event, identity_opts),
|
||||
{:ok, %{accepted: true}} <- Events.publish(signed_event, context: context) do
|
||||
:ok
|
||||
else
|
||||
_other -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp membership_active?(nil), do: false
|
||||
defp membership_active?(%{role: "member"}), do: true
|
||||
defp membership_active?(_membership), do: false
|
||||
|
||||
defp relay_pubkey do
|
||||
case Identity.get() do
|
||||
{:ok, %{pubkey: pubkey}} when is_binary(pubkey) -> {:ok, pubkey}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp relay_url(opts) do
|
||||
Keyword.get(opts, :relay_url, Application.get_env(:parrhesia, :relay_url))
|
||||
end
|
||||
|
||||
defp identity_opts(opts) do
|
||||
opts
|
||||
|> Keyword.take([:path, :private_key, :configured_private_key])
|
||||
end
|
||||
|
||||
defp config(opts) do
|
||||
case Keyword.get(opts, :config) do
|
||||
config when is_list(config) -> config
|
||||
_other -> Application.get_env(:parrhesia, :nip43, [])
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value
|
||||
defp normalize_positive_integer(_value, default), do: default
|
||||
end
|
||||
@@ -686,19 +686,29 @@ defmodule Parrhesia.Policy.EventPolicy do
|
||||
_tag -> false
|
||||
end)
|
||||
|
||||
if protected? do
|
||||
pubkey = Map.get(event, "pubkey")
|
||||
cond do
|
||||
not protected? ->
|
||||
:ok
|
||||
|
||||
cond do
|
||||
MapSet.size(authenticated_pubkeys) == 0 -> {:error, :protected_event_requires_auth}
|
||||
MapSet.member?(authenticated_pubkeys, pubkey) -> :ok
|
||||
true -> {:error, :protected_event_pubkey_mismatch}
|
||||
end
|
||||
else
|
||||
:ok
|
||||
nip43_relay_access_kind?(Map.get(event, "kind")) ->
|
||||
:ok
|
||||
|
||||
true ->
|
||||
pubkey = Map.get(event, "pubkey")
|
||||
|
||||
cond do
|
||||
MapSet.size(authenticated_pubkeys) == 0 -> {:error, :protected_event_requires_auth}
|
||||
MapSet.member?(authenticated_pubkeys, pubkey) -> :ok
|
||||
true -> {:error, :protected_event_pubkey_mismatch}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp nip43_relay_access_kind?(kind) when kind in [8_000, 8_001, 13_534, 28_934, 28_935, 28_936],
|
||||
do: true
|
||||
|
||||
defp nip43_relay_access_kind?(_kind), do: false
|
||||
|
||||
defp config_bool([scope, key], default) do
|
||||
case Application.get_env(:parrhesia, scope, []) |> Keyword.get(key, default) do
|
||||
true -> true
|
||||
|
||||
@@ -7,6 +7,7 @@ defmodule Parrhesia.Protocol.EventValidator do
|
||||
@max_kind 65_535
|
||||
@default_max_event_future_skew_seconds 900
|
||||
@default_max_tags_per_event 256
|
||||
@default_nip43_request_max_age_seconds 300
|
||||
@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]
|
||||
@@ -53,6 +54,15 @@ defmodule Parrhesia.Protocol.EventValidator do
|
||||
| :invalid_nip66_frequency_tag
|
||||
| :invalid_nip66_timeout_tag
|
||||
| :invalid_nip66_check_tag
|
||||
| :missing_nip43_protected_tag
|
||||
| :missing_nip43_claim_tag
|
||||
| :invalid_nip43_claim_tag
|
||||
| :missing_nip43_member_tag
|
||||
| :invalid_nip43_member_tag
|
||||
| :missing_nip43_pubkey_tag
|
||||
| :invalid_nip43_pubkey_tag
|
||||
| :stale_nip43_join_request
|
||||
| :stale_nip43_leave_request
|
||||
|
||||
@spec validate(map()) :: :ok | {:error, error_reason()}
|
||||
def validate(event) when is_map(event) do
|
||||
@@ -149,7 +159,23 @@ defmodule Parrhesia.Protocol.EventValidator do
|
||||
"invalid: kind 10166 must include a single [\"frequency\", <seconds>] tag",
|
||||
invalid_nip66_timeout_tag:
|
||||
"invalid: kind 10166 timeout tags must be [\"timeout\", <check>, <ms>]",
|
||||
invalid_nip66_check_tag: "invalid: kind 10166 c tags must contain lowercase check names"
|
||||
invalid_nip66_check_tag: "invalid: kind 10166 c tags must contain lowercase check names",
|
||||
missing_nip43_protected_tag:
|
||||
"invalid: NIP-43 events must include a NIP-70 protected [\"-\"] tag",
|
||||
missing_nip43_claim_tag:
|
||||
"invalid: kinds 28934 and 28935 must include a single [\"claim\", <invite code>] tag",
|
||||
invalid_nip43_claim_tag:
|
||||
"invalid: kinds 28934 and 28935 must include a single [\"claim\", <invite code>] tag",
|
||||
missing_nip43_member_tag:
|
||||
"invalid: kind 13534 must include at least one [\"member\", <hex pubkey>] tag",
|
||||
invalid_nip43_member_tag:
|
||||
"invalid: kind 13534 member tags must contain lowercase hex pubkeys",
|
||||
missing_nip43_pubkey_tag:
|
||||
"invalid: kinds 8000 and 8001 must include a single [\"p\", <hex pubkey>] tag",
|
||||
invalid_nip43_pubkey_tag:
|
||||
"invalid: kinds 8000 and 8001 must include a single [\"p\", <hex pubkey>] tag",
|
||||
stale_nip43_join_request: "invalid: kind 28934 created_at must be recent",
|
||||
stale_nip43_leave_request: "invalid: kind 28936 created_at must be recent"
|
||||
}
|
||||
|
||||
@spec error_message(error_reason()) :: String.t()
|
||||
@@ -277,6 +303,21 @@ defmodule Parrhesia.Protocol.EventValidator do
|
||||
defp validate_kind_specific(%{"kind" => 10_166} = event),
|
||||
do: validate_nip66_monitor_announcement(event)
|
||||
|
||||
defp validate_kind_specific(%{"kind" => 13_534} = event),
|
||||
do: validate_nip43_membership_list(event)
|
||||
|
||||
defp validate_kind_specific(%{"kind" => kind} = event) when kind in [8_000, 8_001],
|
||||
do: validate_nip43_membership_delta(event)
|
||||
|
||||
defp validate_kind_specific(%{"kind" => 28_934} = event),
|
||||
do: validate_nip43_join_request(event)
|
||||
|
||||
defp validate_kind_specific(%{"kind" => 28_935} = event),
|
||||
do: validate_nip43_invite_response(event)
|
||||
|
||||
defp validate_kind_specific(%{"kind" => 28_936} = event),
|
||||
do: validate_nip43_leave_request(event)
|
||||
|
||||
defp validate_kind_specific(_event), do: :ok
|
||||
|
||||
defp validate_marmot_keypackage_event(event) do
|
||||
@@ -454,6 +495,80 @@ defmodule Parrhesia.Protocol.EventValidator do
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_nip43_membership_list(event) do
|
||||
tags = Map.get(event, "tags", [])
|
||||
|
||||
case validate_protected_tag(tags) do
|
||||
:ok -> validate_optional_repeated_pubkey_tag(tags, "member", :invalid_nip43_member_tag)
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_nip43_membership_delta(event) do
|
||||
tags = Map.get(event, "tags", [])
|
||||
|
||||
case validate_protected_tag(tags) do
|
||||
:ok ->
|
||||
validate_single_pubkey_tag(
|
||||
tags,
|
||||
"p",
|
||||
:missing_nip43_pubkey_tag,
|
||||
:invalid_nip43_pubkey_tag
|
||||
)
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_nip43_join_request(event) do
|
||||
tags = Map.get(event, "tags", [])
|
||||
|
||||
case validate_protected_tag(tags) do
|
||||
:ok ->
|
||||
with :ok <-
|
||||
validate_single_string_tag_with_predicate(
|
||||
tags,
|
||||
"claim",
|
||||
:missing_nip43_claim_tag,
|
||||
:invalid_nip43_claim_tag,
|
||||
&non_empty_string?/1
|
||||
) do
|
||||
validate_recent_created_at(event, :stale_nip43_join_request)
|
||||
end
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_nip43_invite_response(event) do
|
||||
tags = Map.get(event, "tags", [])
|
||||
|
||||
case validate_protected_tag(tags) do
|
||||
:ok ->
|
||||
validate_single_string_tag_with_predicate(
|
||||
tags,
|
||||
"claim",
|
||||
:missing_nip43_claim_tag,
|
||||
:invalid_nip43_claim_tag,
|
||||
&non_empty_string?/1
|
||||
)
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_nip43_leave_request(event) do
|
||||
tags = Map.get(event, "tags", [])
|
||||
|
||||
case validate_protected_tag(tags) do
|
||||
:ok -> validate_recent_created_at(event, :stale_nip43_leave_request)
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_non_empty_base64_content(event),
|
||||
do: validate_non_empty_base64_content(event, :invalid_marmot_keypackage_content)
|
||||
|
||||
@@ -626,6 +741,55 @@ defmodule Parrhesia.Protocol.EventValidator do
|
||||
end)
|
||||
end
|
||||
|
||||
defp validate_protected_tag(tags) do
|
||||
if Enum.any?(tags, &match?(["-"], &1)) do
|
||||
:ok
|
||||
else
|
||||
{:error, :missing_nip43_protected_tag}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_single_pubkey_tag(tags, tag_name, missing_error, invalid_error) do
|
||||
case fetch_single_tag(tags, tag_name, missing_error) do
|
||||
{:ok, [^tag_name, value]} ->
|
||||
if lowercase_hex?(value, 32) do
|
||||
:ok
|
||||
else
|
||||
{:error, invalid_error}
|
||||
end
|
||||
|
||||
{:ok, _invalid_tag_shape} ->
|
||||
{:error, invalid_error}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_optional_repeated_pubkey_tag(tags, tag_name, invalid_error) do
|
||||
matching_tags = Enum.filter(tags, &match_tag_name?(&1, tag_name))
|
||||
|
||||
if Enum.all?(matching_tags, fn
|
||||
[^tag_name, pubkey | _rest] -> lowercase_hex?(pubkey, 32)
|
||||
_other -> false
|
||||
end) do
|
||||
:ok
|
||||
else
|
||||
{:error, invalid_error}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_recent_created_at(%{"created_at" => created_at}, error_reason)
|
||||
when is_integer(created_at) do
|
||||
if created_at >= System.system_time(:second) - nip43_request_max_age_seconds() do
|
||||
:ok
|
||||
else
|
||||
{:error, error_reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_recent_created_at(_event, error_reason), do: {:error, error_reason}
|
||||
|
||||
defp fetch_single_tag(tags, tag_name, missing_error) do
|
||||
case Enum.filter(tags, &match_tag_name?(&1, tag_name)) do
|
||||
[tag] -> {:ok, tag}
|
||||
@@ -754,4 +918,10 @@ defmodule Parrhesia.Protocol.EventValidator do
|
||||
_other -> @default_max_tags_per_event
|
||||
end
|
||||
end
|
||||
|
||||
defp nip43_request_max_age_seconds do
|
||||
:parrhesia
|
||||
|> Application.get_env(:nip43, [])
|
||||
|> Keyword.get(:request_max_age_seconds, @default_nip43_request_max_age_seconds)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule Parrhesia.Web.RelayInfo do
|
||||
"""
|
||||
|
||||
alias Parrhesia.API.Identity
|
||||
alias Parrhesia.NIP43
|
||||
alias Parrhesia.Web.Listener
|
||||
|
||||
@spec document(Listener.t()) :: map()
|
||||
@@ -21,13 +22,20 @@ defmodule Parrhesia.Web.RelayInfo do
|
||||
end
|
||||
|
||||
defp supported_nips do
|
||||
base = [1, 9, 11, 13, 17, 40, 42, 43, 44, 45, 50, 59, 62, 70]
|
||||
base = [1, 9, 11, 13, 17, 40, 42, 44, 45, 50, 59, 62, 70]
|
||||
|
||||
with_nip43 =
|
||||
if NIP43.enabled?() do
|
||||
base ++ [43]
|
||||
else
|
||||
base
|
||||
end
|
||||
|
||||
with_nip66 =
|
||||
if Parrhesia.NIP66.enabled?() do
|
||||
base ++ [66]
|
||||
with_nip43 ++ [66]
|
||||
else
|
||||
base
|
||||
with_nip43
|
||||
end
|
||||
|
||||
with_negentropy =
|
||||
|
||||
@@ -4,24 +4,45 @@ defmodule Parrhesia.Groups.FlowTest do
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.Storage
|
||||
|
||||
test "handles membership request kinds by upserting group memberships" do
|
||||
test "handles join requests by upserting relay memberships" do
|
||||
event = %{
|
||||
"kind" => 8_000,
|
||||
"kind" => 28_934,
|
||||
"pubkey" => String.duplicate("a", 64),
|
||||
"tags" => [["h", "group-1"]]
|
||||
"tags" => [["-"], ["claim", "invite-code"]],
|
||||
"id" => "join-1"
|
||||
}
|
||||
|
||||
assert :ok = Flow.handle_event(event)
|
||||
|
||||
assert {:ok, membership} =
|
||||
Storage.groups().get_membership(%{}, "group-1", String.duplicate("a", 64))
|
||||
assert {:ok, membership} = Flow.get_membership(String.duplicate("a", 64))
|
||||
|
||||
assert membership.role == "requested"
|
||||
assert membership.role == "member"
|
||||
assert membership.metadata["source_kind"] == 28_934
|
||||
end
|
||||
|
||||
test "marks configured membership and relay kinds as group related" do
|
||||
assert Flow.group_related_kind?(8_000)
|
||||
assert Flow.group_related_kind?(13_534)
|
||||
refute Flow.group_related_kind?(1)
|
||||
test "membership snapshot replaces the stored relay memberships" do
|
||||
assert {:ok, _membership} =
|
||||
Storage.groups().put_membership(%{}, %{
|
||||
group_id: "__relay_access__",
|
||||
pubkey: String.duplicate("a", 64),
|
||||
role: "member"
|
||||
})
|
||||
|
||||
snapshot = %{
|
||||
"kind" => 13_534,
|
||||
"tags" => [["-"], ["member", String.duplicate("b", 64)]],
|
||||
"id" => "snapshot-1"
|
||||
}
|
||||
|
||||
assert :ok = Flow.handle_event(snapshot)
|
||||
|
||||
assert {:ok, memberships} = Flow.list_memberships()
|
||||
assert Enum.map(memberships, & &1.pubkey) == [String.duplicate("b", 64)]
|
||||
end
|
||||
|
||||
test "marks configured relay access kinds as handled" do
|
||||
assert Flow.relay_access_kind?(28_934)
|
||||
assert Flow.relay_access_kind?(13_534)
|
||||
refute Flow.relay_access_kind?(1)
|
||||
end
|
||||
end
|
||||
|
||||
63
test/parrhesia/nip43_test.exs
Normal file
63
test/parrhesia/nip43_test.exs
Normal file
@@ -0,0 +1,63 @@
|
||||
defmodule Parrhesia.NIP43Test do
|
||||
use Parrhesia.IntegrationCase, async: false, sandbox: true
|
||||
|
||||
alias Parrhesia.API.Events
|
||||
alias Parrhesia.API.Identity
|
||||
alias Parrhesia.API.RequestContext
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.Web.Listener
|
||||
alias Parrhesia.Web.RelayInfo
|
||||
|
||||
test "relay-authored membership snapshots replace the stored access list" do
|
||||
first_snapshot =
|
||||
signed_relay_event(13_534, [
|
||||
["-"],
|
||||
["member", String.duplicate("a", 64)],
|
||||
["member", String.duplicate("b", 64)]
|
||||
])
|
||||
|
||||
second_snapshot =
|
||||
signed_relay_event(13_534, [
|
||||
["-"],
|
||||
["member", String.duplicate("b", 64)]
|
||||
])
|
||||
|
||||
assert {:ok, %{accepted: true}} = Events.publish(first_snapshot, context: %RequestContext{})
|
||||
assert {:ok, %{accepted: true}} = Events.publish(second_snapshot, context: %RequestContext{})
|
||||
|
||||
assert {:ok, memberships} = Flow.list_memberships()
|
||||
assert Enum.map(memberships, & &1.pubkey) == [String.duplicate("b", 64)]
|
||||
end
|
||||
|
||||
test "count includes generated invite responses for kind 28935 filters" do
|
||||
assert {:ok, 1} = Events.count([%{"kinds" => [28_935]}], context: %RequestContext{})
|
||||
end
|
||||
|
||||
test "relay info only advertises 43 when NIP-43 is enabled" do
|
||||
previous_config = Application.get_env(:parrhesia, :nip43, [])
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :nip43, previous_config)
|
||||
end)
|
||||
|
||||
listener = Listener.from_opts(id: :public)
|
||||
|
||||
Application.put_env(:parrhesia, :nip43, enabled: false)
|
||||
refute 43 in RelayInfo.document(listener)["supported_nips"]
|
||||
|
||||
Application.put_env(:parrhesia, :nip43, enabled: true, invite_ttl_seconds: 900)
|
||||
assert 43 in RelayInfo.document(listener)["supported_nips"]
|
||||
end
|
||||
|
||||
defp signed_relay_event(kind, tags) do
|
||||
{:ok, event} =
|
||||
Identity.sign_event(%{
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => kind,
|
||||
"tags" => tags,
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
event
|
||||
end
|
||||
end
|
||||
123
test/parrhesia/protocol/event_validator_nip43_test.exs
Normal file
123
test/parrhesia/protocol/event_validator_nip43_test.exs
Normal file
@@ -0,0 +1,123 @@
|
||||
defmodule Parrhesia.Protocol.EventValidatorNIP43Test do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
|
||||
test "accepts valid NIP-43 relay access events" do
|
||||
join_request =
|
||||
build_event(%{
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", "invite-code"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
invite_response =
|
||||
build_event(%{
|
||||
"kind" => 28_935,
|
||||
"tags" => [["-"], ["claim", "invite-code"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
leave_request =
|
||||
build_event(%{
|
||||
"kind" => 28_936,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
add_user =
|
||||
build_event(%{
|
||||
"kind" => 8_000,
|
||||
"tags" => [["-"], ["p", String.duplicate("a", 64)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
remove_user =
|
||||
build_event(%{
|
||||
"kind" => 8_001,
|
||||
"tags" => [["-"], ["p", String.duplicate("b", 64)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
membership_list =
|
||||
build_event(%{
|
||||
"kind" => 13_534,
|
||||
"tags" => [
|
||||
["-"],
|
||||
["member", String.duplicate("c", 64)],
|
||||
["member", String.duplicate("d", 64)]
|
||||
],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert :ok = EventValidator.validate(join_request)
|
||||
assert :ok = EventValidator.validate(invite_response)
|
||||
assert :ok = EventValidator.validate(leave_request)
|
||||
assert :ok = EventValidator.validate(add_user)
|
||||
assert :ok = EventValidator.validate(remove_user)
|
||||
assert :ok = EventValidator.validate(membership_list)
|
||||
end
|
||||
|
||||
test "rejects malformed NIP-43 relay access events" do
|
||||
stale_join =
|
||||
build_event(%{
|
||||
"kind" => 28_934,
|
||||
"created_at" => System.system_time(:second) - 301,
|
||||
"tags" => [["-"], ["claim", "invite-code"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
missing_claim =
|
||||
build_event(%{
|
||||
"kind" => 28_935,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
invalid_leave =
|
||||
build_event(%{
|
||||
"kind" => 28_936,
|
||||
"tags" => [],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
invalid_membership_delta =
|
||||
build_event(%{
|
||||
"kind" => 8_000,
|
||||
"tags" => [["-"], ["p", "not-a-pubkey"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
invalid_membership_list =
|
||||
build_event(%{
|
||||
"kind" => 13_534,
|
||||
"tags" => [["-"], ["member", "not-a-pubkey"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:error, :stale_nip43_join_request} = EventValidator.validate(stale_join)
|
||||
assert {:error, :missing_nip43_claim_tag} = EventValidator.validate(missing_claim)
|
||||
assert {:error, :missing_nip43_protected_tag} = EventValidator.validate(invalid_leave)
|
||||
|
||||
assert {:error, :invalid_nip43_pubkey_tag} =
|
||||
EventValidator.validate(invalid_membership_delta)
|
||||
|
||||
assert {:error, :invalid_nip43_member_tag} =
|
||||
EventValidator.validate(invalid_membership_list)
|
||||
end
|
||||
|
||||
defp build_event(overrides) do
|
||||
event =
|
||||
%{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "",
|
||||
"sig" => String.duplicate("2", 128)
|
||||
}
|
||||
|> Map.merge(overrides)
|
||||
|
||||
Map.put(event, "id", EventValidator.compute_id(event))
|
||||
end
|
||||
end
|
||||
270
test/parrhesia/web/connection_nip43_test.exs
Normal file
270
test/parrhesia/web/connection_nip43_test.exs
Normal file
@@ -0,0 +1,270 @@
|
||||
defmodule Parrhesia.Web.ConnectionNIP43Test do
|
||||
use Parrhesia.IntegrationCase, async: false, sandbox: true
|
||||
|
||||
alias Parrhesia.Groups.Flow
|
||||
alias Parrhesia.Protocol
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
alias Parrhesia.Storage
|
||||
alias Parrhesia.Web.Connection
|
||||
|
||||
test "REQ for kind 28935 returns a relay-signed invite response" do
|
||||
state = connection_state()
|
||||
|
||||
assert {:push, frames, next_state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["REQ", "sub-invite", %{"kinds" => [28_935]}]), [opcode: :text]},
|
||||
state
|
||||
)
|
||||
|
||||
assert next_state.subscriptions["sub-invite"].eose_sent?
|
||||
|
||||
decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end)
|
||||
assert ["EOSE", "sub-invite"] = List.last(decoded)
|
||||
|
||||
assert ["EVENT", "sub-invite", invite_event] =
|
||||
Enum.find(decoded, fn frame -> List.first(frame) == "EVENT" end)
|
||||
|
||||
assert invite_event["kind"] == 28_935
|
||||
assert :ok = Protocol.validate_event(invite_event)
|
||||
assert is_binary(claim_from_event(invite_event))
|
||||
end
|
||||
|
||||
test "join request accepts valid claims, stores membership, and publishes membership events" do
|
||||
invite_event = request_invite_event()
|
||||
join_pubkey = String.duplicate("9", 64)
|
||||
|
||||
join_request =
|
||||
valid_event(%{
|
||||
"pubkey" => join_pubkey,
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", claim_from_event(invite_event)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", join_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(response) == [
|
||||
"OK",
|
||||
join_request["id"],
|
||||
true,
|
||||
"info: welcome to ws://localhost:4413/relay!"
|
||||
]
|
||||
|
||||
assert {:ok, membership} = Flow.get_membership(join_pubkey)
|
||||
assert membership.role == "member"
|
||||
|
||||
assert {:ok, add_events} =
|
||||
Storage.events().query(%{}, [%{"kinds" => [8_000], "#p" => [join_pubkey]}], [])
|
||||
|
||||
assert length(add_events) == 1
|
||||
|
||||
assert {:ok, membership_list_events} =
|
||||
Storage.events().query(%{}, [%{"kinds" => [13_534]}], [])
|
||||
|
||||
assert Enum.any?(membership_list_events, fn event ->
|
||||
["member", join_pubkey] in event["tags"]
|
||||
end)
|
||||
end
|
||||
|
||||
test "join request rejects invalid claims" do
|
||||
join_request =
|
||||
valid_event(%{
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", "invalid-claim"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", join_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(response) == [
|
||||
"OK",
|
||||
join_request["id"],
|
||||
false,
|
||||
"restricted: that is an invalid invite code."
|
||||
]
|
||||
end
|
||||
|
||||
test "duplicate join and leave requests return duplicate messages" do
|
||||
invite_event = request_invite_event()
|
||||
member_pubkey = String.duplicate("8", 64)
|
||||
|
||||
first_join =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", claim_from_event(invite_event)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
second_join =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"created_at" => System.system_time(:second) + 1,
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", claim_from_event(invite_event)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, _response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", first_join]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert {:push, {:text, duplicate_join_response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", second_join]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(duplicate_join_response) == [
|
||||
"OK",
|
||||
second_join["id"],
|
||||
true,
|
||||
"duplicate: you are already a member of this relay."
|
||||
]
|
||||
|
||||
leave_request =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"kind" => 28_936,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
duplicate_leave =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"created_at" => System.system_time(:second) + 2,
|
||||
"kind" => 28_936,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, leave_response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", leave_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(leave_response) == [
|
||||
"OK",
|
||||
leave_request["id"],
|
||||
true,
|
||||
"info: membership revoked."
|
||||
]
|
||||
|
||||
assert {:push, {:text, duplicate_leave_response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", duplicate_leave]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert JSON.decode!(duplicate_leave_response) == [
|
||||
"OK",
|
||||
duplicate_leave["id"],
|
||||
true,
|
||||
"duplicate: you are not a member of this relay."
|
||||
]
|
||||
end
|
||||
|
||||
test "leave request publishes a remove event and prunes the membership list" do
|
||||
invite_event = request_invite_event()
|
||||
member_pubkey = String.duplicate("7", 64)
|
||||
|
||||
join_request =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"kind" => 28_934,
|
||||
"tags" => [["-"], ["claim", claim_from_event(invite_event)]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
leave_request =
|
||||
valid_event(%{
|
||||
"pubkey" => member_pubkey,
|
||||
"created_at" => System.system_time(:second) + 1,
|
||||
"kind" => 28_936,
|
||||
"tags" => [["-"]],
|
||||
"content" => ""
|
||||
})
|
||||
|
||||
assert {:push, {:text, _response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", join_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert {:push, {:text, _response}, _state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["EVENT", leave_request]), [opcode: :text]},
|
||||
connection_state()
|
||||
)
|
||||
|
||||
assert {:ok, nil} = Flow.get_membership(member_pubkey)
|
||||
|
||||
assert {:ok, remove_events} =
|
||||
Storage.events().query(%{}, [%{"kinds" => [8_001], "#p" => [member_pubkey]}], [])
|
||||
|
||||
assert length(remove_events) == 1
|
||||
|
||||
assert {:ok, membership_list_events} =
|
||||
Storage.events().query(%{}, [%{"kinds" => [13_534]}], [])
|
||||
|
||||
assert Enum.any?(membership_list_events, fn event ->
|
||||
["member", member_pubkey] not in event["tags"]
|
||||
end)
|
||||
end
|
||||
|
||||
defp request_invite_event do
|
||||
state = connection_state()
|
||||
|
||||
assert {:push, frames, _next_state} =
|
||||
Connection.handle_in(
|
||||
{JSON.encode!(["REQ", "sub-invite", %{"kinds" => [28_935]}]), [opcode: :text]},
|
||||
state
|
||||
)
|
||||
|
||||
frames
|
||||
|> Enum.map(fn {:text, frame} -> JSON.decode!(frame) end)
|
||||
|> Enum.find_value(fn
|
||||
["EVENT", "sub-invite", invite_event] -> invite_event
|
||||
_frame -> nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp claim_from_event(event) do
|
||||
event
|
||||
|> Map.get("tags", [])
|
||||
|> Enum.find_value(fn
|
||||
["claim", claim | _rest] -> claim
|
||||
_tag -> nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp connection_state(opts \\ []) do
|
||||
{:ok, state} = Connection.init(Keyword.put_new(opts, :subscription_index, nil))
|
||||
state
|
||||
end
|
||||
|
||||
defp valid_event(overrides) do
|
||||
%{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "",
|
||||
"sig" => String.duplicate("3", 128)
|
||||
}
|
||||
|> Map.merge(overrides)
|
||||
|> then(fn event -> Map.put(event, "id", EventValidator.compute_id(event)) end)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user