Implement full NIP-43 relay access flow

This commit is contained in:
2026-03-18 15:28:15 +01:00
parent f2856d000e
commit f732d9cf24
12 changed files with 1226 additions and 74 deletions

View File

@@ -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 |

View File

@@ -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,

View File

@@ -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"

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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 =

View File

@@ -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

View 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

View 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

View 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