diff --git a/lib/aether/chat.ex b/lib/aether/chat.ex index 143aa3d..3ff143a 100644 --- a/lib/aether/chat.ex +++ b/lib/aether/chat.ex @@ -81,12 +81,17 @@ defmodule Aether.Chat do def ensure_direct_conversation(sender_pubkey, recipient_pubkey, attrs, opts) when is_binary(sender_pubkey) and is_binary(recipient_pubkey) and is_map(attrs) do + participants = Enum.sort([sender_pubkey, recipient_pubkey]) + attrs = attrs + |> Map.update(:metadata, %{"participants" => participants}, fn metadata -> + Map.put(metadata || %{}, "participants", participants) + end) |> Map.put_new(:slug, direct_slug(sender_pubkey, recipient_pubkey)) |> Map.put_new(:title, "Direct Message") - |> Map.put(:conversation_kind, :dm) - |> Map.put_new(:backend, :public_sync) + |> Map.put_new(:conversation_kind, :dm) + |> Map.put_new(:backend, :nostr_nip17) with {:ok, %Channel{} = channel} <- ensure_conversation(attrs, opts), {:ok, _sender} <- ensure_participant(channel, sender_pubkey, %{role: :member}, opts), diff --git a/lib/aether/chat/backends/nostr_nip04_read_only.ex b/lib/aether/chat/backends/nostr_nip04_read_only.ex index 46a3905..07d1bd4 100644 --- a/lib/aether/chat/backends/nostr_nip04_read_only.ex +++ b/lib/aether/chat/backends/nostr_nip04_read_only.ex @@ -1,14 +1,23 @@ defmodule Aether.Chat.Backends.NostrNip04ReadOnly do @moduledoc """ - Legacy NIP-04 DM read-only fallback scaffold. + Legacy NIP-04 DM read-only fallback. - NIP-04 is kept for compatibility/import views. New outbound DMs should prefer - NIP-17 once that backend is implemented. + This backend queries canonical Parrhesia kind-4 events and decrypts messages for + local display when the viewer has an unlocked session private key. Outbound + messages intentionally remain disabled; new DMs should use NIP-17. """ @behaviour Aether.Chat.Backend - alias Aether.Chat.Channel + require Ash.Query + + alias Aether.Chat + alias Aether.Chat.{Channel, Message, Participant} + alias Aether.Chat.Nostr.Nak + alias Parrhesia.API.Events + alias Parrhesia.API.RequestContext + + @kind_nip04 4 @impl true def capabilities do @@ -18,19 +27,143 @@ defmodule Aether.Chat.Backends.NostrNip04ReadOnly do non_custodial_signing?: true, conversation_kinds: [:legacy_dm], read_only?: true, - required_protocols: [:nip04] + required_protocols: [:nip04], + signer_modes: [:session_privkey, :future_external_signer] } end @impl true - def ensure_conversation(_attrs, _opts), do: {:error, :not_implemented} + def ensure_conversation(attrs, opts) when is_map(attrs) do + attrs + |> Map.put(:backend, :nostr_nip04) + |> Map.put_new(:conversation_kind, :legacy_dm) + |> Chat.ensure_channel(opts) + end @impl true - def list_messages(%Channel{}, _opts), do: {:error, :not_implemented} + def list_messages(%Channel{} = channel, opts) do + with {:ok, privkey} <- fetch_privkey(opts), + {:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey), + {:ok, participant_pubkeys} <- participant_pubkeys(channel), + true <- MapSet.member?(participant_pubkeys, local_pubkey), + {:ok, events} <- query_legacy_dms(local_pubkey, opts) do + messages = + events + |> Enum.flat_map(&decrypt_message(&1, privkey, channel, participant_pubkeys)) + |> Enum.sort_by(&message_sort_key/1) + |> maybe_take_latest(Keyword.get(opts, :limit, 100)) + + {:ok, messages} + else + false -> {:error, :not_a_participant} + {:error, _reason} = error -> error + end + end @impl true def send_message(%Channel{}, _attrs, _opts), do: {:error, :read_only_backend} @impl true - def subscribe(%Channel{}, _pid, _opts), do: {:error, :not_implemented} + def subscribe(%Channel{}, _pid, _opts), do: :ok + + defp fetch_privkey(opts) do + case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do + privkey when is_binary(privkey) -> {:ok, privkey} + _other -> {:error, :missing_session_privkey} + end + end + + defp participant_pubkeys(%Channel{id: channel_id}) do + Participant + |> Ash.Query.filter(channel_id == ^channel_id) + |> Ash.read(Chat.ash_opts()) + |> case do + {:ok, participants} -> + pubkeys = + participants + |> Enum.map(& &1.pubkey) + |> Enum.filter(&valid_pubkey?/1) + |> MapSet.new() + + {:ok, pubkeys} + + {:error, _reason} = error -> + error + end + end + + defp query_legacy_dms(local_pubkey, opts) do + filters = [ + %{ + "kinds" => [@kind_nip04], + "#p" => [local_pubkey], + "limit" => Keyword.get(opts, :event_limit, 200) + } + ] + + Events.query(filters, context: request_context([local_pubkey])) + end + + defp decrypt_message(event, privkey, channel, participant_pubkeys) do + with %{"kind" => @kind_nip04, "pubkey" => sender_pubkey, "content" => ciphertext} <- event, + true <- MapSet.member?(participant_pubkeys, sender_pubkey), + true <- Enum.any?(p_tags(event), &MapSet.member?(participant_pubkeys, &1)), + {:ok, body} <- Nak.nip04_decrypt(privkey, sender_pubkey, ciphertext) do + [ + %Message{ + id: event["id"], + channel_id: channel.id, + author_pubkey: sender_pubkey, + body: body, + client_message_id: event["id"], + metadata: %{"nostr_kind" => @kind_nip04, "nostr_event_id" => event["id"]}, + inserted_at: unix_to_datetime(event["created_at"]) + } + ] + else + _other -> [] + end + end + + defp p_tags(event) do + event + |> Map.get("tags", []) + |> Enum.flat_map(fn + ["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey] + _tag -> [] + end) + end + + defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do + {DateTime.to_unix(inserted_at, :microsecond), id || ""} + end + + defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do + if length(messages) > limit do + messages |> Enum.take(-limit) + else + messages + end + end + + defp maybe_take_latest(messages, _limit), do: messages + + defp unix_to_datetime(timestamp) when is_integer(timestamp) do + case DateTime.from_unix(timestamp, :second) do + {:ok, datetime} -> datetime + {:error, _reason} -> DateTime.utc_now() + end + end + + defp unix_to_datetime(_timestamp), do: DateTime.utc_now() + + defp request_context(pubkeys) do + %RequestContext{ + caller: :local, + authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1)) + } + end + + defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/ + defp valid_pubkey?(_pubkey), do: false end diff --git a/lib/aether/chat/backends/nostr_nip17.ex b/lib/aether/chat/backends/nostr_nip17.ex index 4762299..cb31cd4 100644 --- a/lib/aether/chat/backends/nostr_nip17.ex +++ b/lib/aether/chat/backends/nostr_nip17.ex @@ -1,15 +1,26 @@ defmodule Aether.Chat.Backends.NostrNip17 do @moduledoc """ - NIP-17 private DM backend scaffold. + NIP-17 private DM backend. - This backend is intended to use Parrhesia/Nostr events as canonical message - storage and a local Aether projection for UI state. It is not active until the - signer, gift-wrap, subscription, and projection pipeline is implemented. + Gift-wrapped Nostr events in Parrhesia are the canonical message store. The + Ash chat channel/participant records are local UI projections used to find the + conversation participants; decrypted message structs returned to the UI are not + persisted through the Ash synced message resource. """ @behaviour Aether.Chat.Backend - alias Aether.Chat.Channel + require Ash.Query + + alias Aether.Chat + alias Aether.Chat.{Channel, Message, Participant} + alias Aether.Chat.Nostr.Nak + alias Parrhesia.API.Events + alias Parrhesia.API.RequestContext + alias Parrhesia.API.Stream + + @kind_giftwrap 1059 + @kind_dm 14 @impl true def capabilities do @@ -19,19 +30,265 @@ defmodule Aether.Chat.Backends.NostrNip17 do non_custodial_signing?: true, conversation_kinds: [:dm], read_only?: false, - required_protocols: [:nip17, :nip44, :nip59] + required_protocols: [:nip17, :nip44, :nip59], + signer_modes: [:session_privkey, :future_external_signer], + plaintext_projection?: false } end @impl true - def ensure_conversation(_attrs, _opts), do: {:error, :not_implemented} + def ensure_conversation(attrs, opts) when is_map(attrs) do + attrs + |> Map.put(:backend, :nostr_nip17) + |> Map.put_new(:conversation_kind, :dm) + |> Chat.ensure_channel(opts) + end @impl true - def list_messages(%Channel{}, _opts), do: {:error, :not_implemented} + def list_messages(%Channel{} = channel, opts) do + with {:ok, privkey} <- fetch_privkey(opts), + {:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey), + {:ok, participant_pubkeys} <- participant_pubkeys(channel), + true <- MapSet.member?(participant_pubkeys, local_pubkey), + {:ok, events} <- query_giftwraps(local_pubkey, opts) do + messages = + events + |> Enum.flat_map(&unwrap_message(&1, privkey, channel, participant_pubkeys)) + |> dedupe_messages() + |> Enum.sort_by(&message_sort_key/1) + |> maybe_take_latest(Keyword.get(opts, :limit, 100)) + + {:ok, messages} + else + false -> {:error, :not_a_participant} + {:error, _reason} = error -> error + end + end @impl true - def send_message(%Channel{}, _attrs, _opts), do: {:error, :not_implemented} + def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do + body = attrs |> Map.get(:body) || Map.get(attrs, "body") || "" + body = String.trim(body) + + with false <- body == "", + {:ok, privkey} <- fetch_privkey(opts), + {:ok, sender_pubkey} <- Nak.pubkey_from_private_key(privkey), + :ok <- validate_author(attrs, sender_pubkey), + {:ok, participant_pubkeys} <- participant_pubkeys(channel), + true <- MapSet.member?(participant_pubkeys, sender_pubkey), + {:ok, recipient_pubkey} <- recipient_pubkey(participant_pubkeys, sender_pubkey), + {:ok, rumor} <- Nak.nip17_rumor(privkey, recipient_pubkey, body), + {:ok, giftwraps} <- publish_giftwraps(privkey, rumor, [recipient_pubkey, sender_pubkey]) do + {:ok, message_from_rumor(rumor, channel, attrs, giftwraps)} + else + true -> {:error, :empty_message} + false -> {:error, :not_a_participant} + {:error, _reason} = error -> error + end + end @impl true - def subscribe(%Channel{}, _pid, _opts), do: {:error, :not_implemented} + def subscribe(%Channel{} = channel, pid, opts) when is_pid(pid) do + with {:ok, privkey} <- fetch_privkey(opts), + {:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey) do + filters = [%{"kinds" => [@kind_giftwrap], "#p" => [local_pubkey]}] + + Stream.subscribe(pid, "aether-chat-#{channel.id}", filters, + context: request_context([local_pubkey]) + ) + else + {:error, :missing_session_privkey} -> :ok + {:error, _reason} = error -> error + end + end + + def unwrap_event(%Channel{} = channel, giftwrap, opts) when is_map(giftwrap) do + with {:ok, privkey} <- fetch_privkey(opts), + {:ok, participant_pubkeys} <- participant_pubkeys(channel) do + case unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do + [message] -> {:ok, message} + [] -> {:error, :not_conversation_message} + end + end + end + + defp fetch_privkey(opts) do + case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do + privkey when is_binary(privkey) -> {:ok, privkey} + _other -> {:error, :missing_session_privkey} + end + end + + defp validate_author(attrs, sender_pubkey) do + case Map.get(attrs, :author_pubkey) || Map.get(attrs, "author_pubkey") do + nil -> :ok + ^sender_pubkey -> :ok + _other -> {:error, :author_pubkey_mismatch} + end + end + + defp participant_pubkeys(%Channel{id: channel_id}) do + Participant + |> Ash.Query.filter(channel_id == ^channel_id) + |> Ash.read(Chat.ash_opts()) + |> case do + {:ok, participants} -> + pubkeys = + participants + |> Enum.map(& &1.pubkey) + |> Enum.filter(&valid_pubkey?/1) + |> MapSet.new() + + {:ok, pubkeys} + + {:error, _reason} = error -> + error + end + end + + defp recipient_pubkey(participant_pubkeys, sender_pubkey) do + case participant_pubkeys |> MapSet.delete(sender_pubkey) |> MapSet.to_list() do + [recipient_pubkey] -> {:ok, recipient_pubkey} + [] -> {:error, :missing_recipient} + _many -> {:error, :unsupported_multi_recipient_dm} + end + end + + defp publish_giftwraps(privkey, rumor, delivery_pubkeys) do + delivery_pubkeys + |> Enum.uniq() + |> Enum.reduce_while({:ok, []}, fn delivery_pubkey, {:ok, acc} -> + with {:ok, giftwrap} <- Nak.nip17_wrap(privkey, delivery_pubkey, rumor), + :ok <- publish_event(giftwrap) do + {:cont, {:ok, [giftwrap | acc]}} + else + {:error, _reason} = error -> {:halt, error} + end + end) + |> case do + {:ok, giftwraps} -> {:ok, Enum.reverse(giftwraps)} + {:error, _reason} = error -> error + end + end + + defp publish_event(event) do + with {:ok, result} <- Events.publish(event, context: request_context([event["pubkey"]])), + true <- result.accepted do + :ok + else + false -> {:error, :publish_rejected} + {:ok, %{reason: reason}} when not is_nil(reason) -> {:error, reason} + {:error, _reason} = error -> error + other -> {:error, other} + end + end + + defp query_giftwraps(local_pubkey, opts) do + filters = [ + %{ + "kinds" => [@kind_giftwrap], + "#p" => [local_pubkey], + "limit" => Keyword.get(opts, :event_limit, 200) + } + ] + + Events.query(filters, context: request_context([local_pubkey])) + end + + defp unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do + with {:ok, rumor} <- Nak.nip17_unwrap(privkey, giftwrap), + true <- conversation_rumor?(rumor, participant_pubkeys) do + [message_from_rumor(rumor, channel, %{}, [giftwrap])] + else + _other -> [] + end + end + + defp conversation_rumor?(%{"kind" => @kind_dm, "pubkey" => sender_pubkey} = rumor, pubkeys) do + recipient_pubkeys = p_tags(rumor) + + MapSet.member?(pubkeys, sender_pubkey) and + Enum.any?(recipient_pubkeys, &MapSet.member?(pubkeys, &1)) + end + + defp conversation_rumor?(_rumor, _pubkeys), do: false + + defp message_from_rumor(rumor, channel, attrs, giftwraps) do + %Message{ + id: rumor["id"] || (List.first(giftwraps || []) && List.first(giftwraps)["id"]), + channel_id: channel.id, + author_id: Map.get(attrs, :author_id) || Map.get(attrs, "author_id"), + author_pubkey: rumor["pubkey"], + body: rumor["content"] || "", + client_message_id: rumor["id"], + metadata: message_metadata(rumor, attrs, giftwraps), + inserted_at: unix_to_datetime(rumor["created_at"]) + } + end + + defp message_metadata(rumor, attrs, giftwraps) do + attrs_metadata = Map.get(attrs, :metadata) || Map.get(attrs, "metadata") || %{} + + attrs_metadata + |> Map.put_new("author_name", get_in(attrs_metadata, ["author_name"])) + |> Map.put("nostr_kind", @kind_dm) + |> Map.put("nostr_rumor_id", rumor["id"]) + |> Map.put("nostr_giftwrap_ids", Enum.map(giftwraps || [], & &1["id"])) + end + + defp p_tags(event) do + event + |> Map.get("tags", []) + |> Enum.flat_map(fn + ["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey] + _tag -> [] + end) + end + + defp dedupe_messages(messages) do + messages + |> Enum.reduce({MapSet.new(), []}, fn message, {seen, acc} -> + key = message.client_message_id || message.id + + if MapSet.member?(seen, key) do + {seen, acc} + else + {MapSet.put(seen, key), [message | acc]} + end + end) + |> elem(1) + end + + defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do + {DateTime.to_unix(inserted_at, :microsecond), id || ""} + end + + defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do + if length(messages) > limit do + messages |> Enum.take(-limit) + else + messages + end + end + + defp maybe_take_latest(messages, _limit), do: messages + + defp unix_to_datetime(timestamp) when is_integer(timestamp) do + case DateTime.from_unix(timestamp, :second) do + {:ok, datetime} -> datetime + {:error, _reason} -> DateTime.utc_now() + end + end + + defp unix_to_datetime(_timestamp), do: DateTime.utc_now() + + defp request_context(pubkeys) do + %RequestContext{ + caller: :local, + authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1)) + } + end + + defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/ + defp valid_pubkey?(_pubkey), do: false end diff --git a/lib/aether/chat/nostr/nak.ex b/lib/aether/chat/nostr/nak.ex new file mode 100644 index 0000000..364371d --- /dev/null +++ b/lib/aether/chat/nostr/nak.ex @@ -0,0 +1,157 @@ +defmodule Aether.Chat.Nostr.Nak do + @moduledoc false + + @timeout_ms 30_000 + + def available?, do: System.find_executable("nak") != nil + + def pubkey_from_private_key(privkey) do + with {:ok, privkey_hex} <- normalize_private_key(privkey) do + run_script( + ~S''' + set -euo pipefail + nak key public "$1" + ''', + [privkey_hex] + ) + |> trim_result() + end + end + + def nip17_rumor(sender_privkey, recipient_pubkey, body) + when is_binary(recipient_pubkey) and is_binary(body) do + with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey), + :ok <- validate_pubkey(recipient_pubkey) do + run_script( + ~S''' + set -euo pipefail + nak event --sec "$1" -k 14 -p "$2" -c "$3" /dev/null + ''', + [sender_privkey_hex, recipient_pubkey, body] + ) + |> decode_json_result() + end + end + + def nip17_wrap(sender_privkey, recipient_pubkey, rumor) when is_map(rumor) do + with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey), + :ok <- validate_pubkey(recipient_pubkey), + {:ok, rumor_json} <- encode_json(rumor) do + run_script( + ~S''' + set -euo pipefail + printf '%s\n' "$3" | nak gift wrap --sec "$1" -p "$2" 2>/dev/null + ''', + [sender_privkey_hex, recipient_pubkey, rumor_json] + ) + |> decode_json_result() + end + end + + def nip17_unwrap(recipient_privkey, giftwrap) when is_map(giftwrap) do + with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey), + {:ok, giftwrap_json} <- encode_json(giftwrap) do + run_script( + ~S''' + set -euo pipefail + printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null + ''', + [recipient_privkey_hex, giftwrap_json] + ) + |> decode_json_result() + end + end + + def nip04_encrypt(sender_privkey, recipient_pubkey, plaintext) + when is_binary(recipient_pubkey) and is_binary(plaintext) do + with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey), + :ok <- validate_pubkey(recipient_pubkey) do + run_script( + ~S''' + set -euo pipefail + nak encrypt --nip04 --sec "$1" -p "$2" "$3" /dev/null + ''', + [sender_privkey_hex, recipient_pubkey, plaintext] + ) + |> trim_result() + end + end + + def nip04_decrypt(recipient_privkey, sender_pubkey, ciphertext) + when is_binary(sender_pubkey) and is_binary(ciphertext) do + with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey), + :ok <- validate_pubkey(sender_pubkey) do + run_script( + ~S''' + set -euo pipefail + nak decrypt --nip04 --sec "$1" -p "$2" "$3" 2>/dev/null + ''', + [recipient_privkey_hex, sender_pubkey, ciphertext] + ) + |> trim_result() + end + end + + def normalize_private_key(<<_::binary-size(32)>> = privkey), + do: {:ok, Base.encode16(privkey, case: :lower)} + + def normalize_private_key(privkey) when is_binary(privkey) do + if privkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do + {:ok, String.downcase(privkey)} + else + {:error, :invalid_private_key} + end + end + + def normalize_private_key(_privkey), do: {:error, :invalid_private_key} + + defp validate_pubkey(pubkey) when is_binary(pubkey) do + if pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do + :ok + else + {:error, :invalid_pubkey} + end + end + + defp validate_pubkey(_pubkey), do: {:error, :invalid_pubkey} + + defp run_script(script, args) do + if available?() do + do_run_script(script, args) + else + {:error, :nak_not_found} + end + end + + defp do_run_script(script, args) do + task = + Task.async(fn -> + System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true) + end) + + case Task.yield(task, @timeout_ms) || Task.shutdown(task, :brutal_kill) do + {:ok, {output, 0}} -> {:ok, output} + {:ok, {output, status}} -> {:error, {:nak_failed, status, output}} + nil -> {:error, :nak_timeout} + end + end + + defp trim_result({:ok, output}), do: {:ok, String.trim(output)} + defp trim_result({:error, _reason} = error), do: error + + defp decode_json_result({:ok, output}) do + output + |> String.trim() + |> JSON.decode() + rescue + _error -> {:error, {:invalid_json, output}} + end + + defp decode_json_result({:error, _reason} = error), do: error + + defp encode_json(value) do + {:ok, JSON.encode!(value)} + rescue + _error -> {:error, :invalid_json} + end +end diff --git a/lib/aether_web/live/chat_live.ex b/lib/aether_web/live/chat_live.ex index 04fa2b8..bc78bd9 100644 --- a/lib/aether_web/live/chat_live.ex +++ b/lib/aether_web/live/chat_live.ex @@ -35,7 +35,9 @@ defmodule AetherWeb.ChatLive do metadata: %{"recipient_username" => recipient.username} } - case Chat.ensure_direct_conversation(current_user.pubkey_hex, recipient.pubkey_hex, attrs) do + case Chat.ensure_direct_conversation(current_user.pubkey_hex, recipient.pubkey_hex, attrs, + session_privkey: socket.assigns[:session_privkey] + ) do {:ok, channel} -> {:noreply, push_navigate(socket, to: Chat.standalone_path(channel))} @@ -47,6 +49,19 @@ defmodule AetherWeb.ChatLive do end end + def handle_info({:parrhesia, :events, _ref, _subscription_id, events}, socket) + when is_list(events) do + Enum.each(events, &send_unwrapped_message(socket, &1)) + {:noreply, socket} + end + + def handle_info({:parrhesia, :event, _ref, _subscription_id, event}, socket) do + send_unwrapped_message(socket, event) + {:noreply, socket} + end + + def handle_info({:parrhesia, :eose, _ref, _subscription_id}, socket), do: {:noreply, socket} + @impl true def render(%{chat_request: %{mode: :embedded}} = assigns) do ~H""" @@ -58,6 +73,7 @@ defmodule AetherWeb.ChatLive do backend={@chat_request.backend} embedded?={true} current_user={@current_user} + session_privkey={@session_privkey} /> """ @@ -77,7 +93,7 @@ defmodule AetherWeb.ChatLive do {channel_title(@chat_request.slug)}

- Public synced group chat. Marmot-backed channels will come later without changing the chat surface. + Public synced rooms and NIP-17 direct messages share this chat surface.

<.live_component @@ -96,6 +112,7 @@ defmodule AetherWeb.ChatLive do backend={@chat_request.backend} embedded?={false} current_user={@current_user} + session_privkey={@session_privkey} /> @@ -132,6 +149,19 @@ defmodule AetherWeb.ChatLive do defp request(mode, slug, backend), do: %{mode: mode, slug: slug, backend: backend} + defp send_unwrapped_message(socket, event) do + request = socket.assigns.chat_request + + with {:ok, %Aether.Chat.Channel{} = channel} <- + Chat.get_channel_by_slug(request.slug, Chat.ash_opts()), + backend <- Chat.backend_module(channel.backend), + true <- function_exported?(backend, :unwrap_event, 3), + {:ok, message} <- + backend.unwrap_event(channel, event, session_privkey: socket.assigns[:session_privkey]) do + send_update(ChatPanelComponent, id: @component_id, incoming_message: message) + end + end + defp recipient_label(%{display_name: display_name}) when is_binary(display_name) and display_name != "" do display_name diff --git a/lib/aether_web/live/chat_panel_component.ex b/lib/aether_web/live/chat_panel_component.ex index 1cf0d34..bfe35d0 100644 --- a/lib/aether_web/live/chat_panel_component.ex +++ b/lib/aether_web/live/chat_panel_component.ex @@ -163,7 +163,7 @@ defmodule AetherWeb.ChatPanelComponent do defp load_channel(socket, slug, backend) do case Chat.ensure_conversation(%{slug: slug, title: channel_title(slug), backend: backend}) do {:ok, %Channel{} = channel} -> - messages = load_messages(channel) + messages = load_messages(channel, socket.assigns) message_ids = messages |> Enum.map(& &1.id) |> MapSet.new() socket @@ -192,7 +192,7 @@ defmodule AetherWeb.ChatPanelComponent do defp maybe_subscribe(%{assigns: %{channel: %Channel{} = channel}} = socket) do if connected?(socket) do - _ = Chat.subscribe_conversation(channel) + _ = Chat.subscribe_conversation(channel, self(), chat_opts(socket.assigns)) assign(socket, :subscribed_channel_id, channel.id) else socket @@ -211,7 +211,7 @@ defmodule AetherWeb.ChatPanelComponent do metadata: %{"author_name" => user_attr(user, :username)} } - case Chat.send_message(socket.assigns.channel, attrs) do + case Chat.send_message(socket.assigns.channel, attrs, chat_opts(socket.assigns)) do {:ok, message} -> {:noreply, put_message(socket, message)} @@ -241,13 +241,20 @@ defmodule AetherWeb.ChatPanelComponent do end end - defp load_messages(%Channel{} = channel) do - case Chat.list_conversation_messages(channel) do + defp load_messages(%Channel{} = channel, assigns) do + case Chat.list_conversation_messages(channel, chat_opts(assigns)) do {:ok, messages} -> messages {:error, _reason} -> [] end end + defp chat_opts(assigns) do + case Map.get(assigns, :session_privkey) do + privkey when is_binary(privkey) -> [session_privkey: privkey] + _other -> [] + end + end + defp channel_title("general"), do: "General Chat" defp channel_title(slug) do diff --git a/test/aether/chat_backend_test.exs b/test/aether/chat_backend_test.exs index aa7663f..f3287be 100644 --- a/test/aether/chat_backend_test.exs +++ b/test/aether/chat_backend_test.exs @@ -2,8 +2,11 @@ defmodule Aether.ChatBackendTest do use Tribes.PluginTest.PageCase, plugin: Tribes.Plugins.Aether.Plugin alias Aether.Chat + alias Aether.Chat.Nostr.Nak + alias Parrhesia.API.Events + alias Parrhesia.API.RequestContext - test "public sync DM flow sends a user-to-user message", %{ + test "NIP-17 DM flow sends a user-to-user message", %{ signed_in_conn: sender_conn, current_user: sender, conn: conn @@ -50,6 +53,92 @@ defmodule Aether.ChatBackendTest do assert has_element?(recipient_view, "#chat-messages", "hello recipient") end + test "Aether publishes real NIP-17 giftwraps that nak can unwrap" do + require_executable!("nak") + + {sender_pubkey, sender_privkey} = Tribes.Keyring.generate_keypair() + recipient_privkey = nak_key_generate!() + recipient_pubkey = nak_key_public!(recipient_privkey) + + {:ok, channel} = + Chat.ensure_direct_conversation(sender_pubkey, recipient_pubkey, %{ + title: "NIP-17 interop" + }) + + assert {:ok, message} = + Chat.send_message( + channel, + %{body: "hello nak recipient", author_pubkey: sender_pubkey}, + session_privkey: sender_privkey + ) + + assert message.body == "hello nak recipient" + + [giftwrap] = giftwraps_for(recipient_pubkey) + rumor = nak_gift_unwrap!(recipient_privkey, giftwrap) + + assert giftwrap["kind"] == 1059 + assert ["p", recipient_pubkey] in Enum.map(giftwrap["tags"], &Enum.take(&1, 2)) + assert rumor["kind"] == 14 + assert rumor["pubkey"] == sender_pubkey + assert rumor["content"] == "hello nak recipient" + assert ["p", recipient_pubkey] in Enum.map(rumor["tags"], &Enum.take(&1, 2)) + end + + test "Aether reads external NIP-17 giftwraps produced by nak" do + require_executable!("nak") + + external_sender_privkey = nak_key_generate!() + external_sender_pubkey = nak_key_public!(external_sender_privkey) + {recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair() + + {:ok, channel} = + Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{ + title: "External NIP-17" + }) + + giftwrap = + nak_nip17_giftwrap!(external_sender_privkey, recipient_pubkey, "hello from external nak") + + :ok = publish_event!(giftwrap) + + assert {:ok, [message]} = + Chat.list_conversation_messages(channel, session_privkey: recipient_privkey) + + assert message.author_pubkey == external_sender_pubkey + assert message.body == "hello from external nak" + end + + test "NIP-04 backend imports decryptable legacy DMs as read-only" do + require_executable!("nak") + + external_sender_privkey = nak_key_generate!() + external_sender_pubkey = nak_key_public!(external_sender_privkey) + {recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair() + + {:ok, channel} = + Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{ + title: "Legacy NIP-04", + backend: :nostr_nip04, + conversation_kind: :legacy_dm + }) + + {:ok, ciphertext} = + Nak.nip04_encrypt(external_sender_privkey, recipient_pubkey, "legacy hello from nak") + + legacy_event = nak_event!(external_sender_privkey, 4, recipient_pubkey, ciphertext) + :ok = publish_event!(legacy_event) + + assert {:ok, [message]} = + Chat.list_conversation_messages(channel, session_privkey: recipient_privkey) + + assert message.author_pubkey == external_sender_pubkey + assert message.body == "legacy hello from nak" + + assert {:error, :read_only_backend} = + Chat.send_message(channel, %{body: "nope"}, session_privkey: recipient_privkey) + end + test "backend capabilities prepare Nostr-compatible non-custodial backends" do assert %{canonical_store: :parrhesia_events, non_custodial_signing?: true} = Chat.backend_capabilities(:nostr_nip17) @@ -71,4 +160,85 @@ defmodule Aether.ChatBackendTest do Tribes.Plugin.User.from_host(user) end + + defp publish_event!(event) do + assert {:ok, result} = + Events.publish(event, + context: %RequestContext{ + caller: :local, + authenticated_pubkeys: MapSet.new([event["pubkey"]]) + } + ) + + assert result.accepted + :ok + end + + defp giftwraps_for(recipient_pubkey) do + assert {:ok, events} = + Events.query( + [%{"kinds" => [1059], "#p" => [recipient_pubkey], "limit" => 10}], + context: %RequestContext{ + caller: :local, + authenticated_pubkeys: MapSet.new([recipient_pubkey]) + } + ) + + events + end + + defp nak_nip17_giftwrap!(sender_privkey, recipient_pubkey, body) do + script = ~S''' + set -euo pipefail + nak event --sec "$1" -k 14 -p "$2" -c "$3" /dev/null | + nak gift wrap --sec "$1" -p "$2" 2>/dev/null + ''' + + script |> run_script!([sender_privkey, recipient_pubkey, body]) |> JSON.decode!() + end + + defp nak_event!(sender_privkey, kind, recipient_pubkey, content) do + script = ~S''' + set -euo pipefail + nak event --sec "$1" -k "$2" -p "$3" -c "$4" /dev/null + ''' + + script + |> run_script!([sender_privkey, to_string(kind), recipient_pubkey, content]) + |> JSON.decode!() + end + + defp nak_gift_unwrap!(recipient_privkey, giftwrap) do + script = ~S''' + set -euo pipefail + printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null + ''' + + script + |> run_script!([recipient_privkey, JSON.encode!(giftwrap)]) + |> JSON.decode!() + end + + defp nak_key_generate!, do: run_script!("nak key generate", []) + + defp nak_key_public!(privkey), do: run_script!("nak key public \"$1\"", [privkey]) + + defp require_executable!(name) do + unless System.find_executable(name) do + flunk("expected #{name} in PATH") + end + end + + defp run_script!(script, args) do + task = + Task.async(fn -> + System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true) + end) + + case Task.yield(task, 30_000) || Task.shutdown(task, :brutal_kill) do + {:ok, {output, 0}} -> String.trim(output) + {:ok, {output, status}} -> flunk("script failed with status #{status}:\n#{output}") + nil -> flunk("script timed out") + end + end end