Implement full NIP-43 relay access flow
This commit is contained in:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user