defmodule TribeOne.TribesPlugin.Trust.Plugin do @moduledoc """ Tribes plugin entry point. """ use Tribes.Plugin.Base, otp_app: :tribe_one_trust import Plug.Conn import Phoenix.Controller, only: [json: 2] alias Parrhesia.API.Auth alias Parrhesia.Web.Listener alias TribeOne.TribesPlugin.Trust @impl true def register(context) do super(context) |> Map.merge(%{ nav_items: [ %{ label: "Trust", path: "/trust", icon: nil, requires: [], order: 50 } ], pages: [ %{ path: "/trust", live_view: TribeOne.TribesPlugin.TrustWeb.HomeLive, layout: nil } ], api_routes: [], management_methods: [ %{ name: "trust.hello.receive", version: "1", module: __MODULE__, action: :management_receive_hello, auth: :admin, description: "Record a Trust federation hello" }, %{ name: "trust.relationships.update", version: "1", module: __MODULE__, action: :management_update_relationship, auth: :admin, description: "Update a remote tribe trust relationship" } ], metrics: [], plugs: [], hooks: %{}, ash_domains: [TribeOne.TribesPlugin.Trust.Domain], config_schema: %{ title: "Trust", description: "Tribe-to-tribe federation and trust settings.", groups: [ %{ id: "federation", label: "Federation", settings: [ %{ key: "federation.hidden", label: "Hide unauthenticated federation identity", type: :boolean, default: false, description: "When enabled, /.well-known/tribes/identity returns 404 unless a signed federation call is used." } ] } ] } }) end def access_trust_facts(subject), do: Trust.access_trust_facts(subject) def management_receive_hello(_context, params) do with {:ok, result} <- Trust.receive_hello(params) do {:ok, relationship_payload(result.relationship)} end end def management_update_relationship(_context, %{"remote_tribe_pubkey" => pubkey} = params) do with {:ok, relationship} <- Trust.update_relationship(pubkey, params) do {:ok, relationship_payload(relationship)} end end def management_update_relationship(_context, _params), do: {:error, :invalid_remote_tribe_pubkey} def federation_identity(conn) do cond do Trust.federation_hidden?() -> conn |> put_status(:not_found) |> json(%{"ok" => false, "error" => "not-found"}) true -> case local_identity(api_url(conn)) do {:ok, identity} -> json(conn, identity) {:error, :local_tribe_not_found} -> conn |> put_status(404) |> json(%{"ok" => false, "error" => "local-tribe-not-found"}) {:error, reason} -> conn |> put_status(503) |> json(%{"ok" => false, "error" => inspect(reason)}) end end end def federation_hello(conn) do authorization = get_req_header(conn, "authorization") |> List.first() full_url = full_request_url(conn) with {:ok, auth_context} <- Auth.validate_nip98(authorization, conn.method, full_url), {:ok, attrs} <- parse_hello(conn.body_params), :ok <- verify_sender(auth_context.pubkey, attrs), {:ok, result} <- Trust.receive_hello(attrs) do json(conn, %{ "ok" => true, "relationship" => %{ "remote_tribe_pubkey" => result.relationship.remote_tribe_pubkey, "status" => to_string(result.relationship.status), "trust_score" => result.relationship.trust_score, "granted_capabilities" => result.relationship.granted_capabilities || [] } }) else {:error, :missing_authorization} -> conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-required"}) {:error, :invalid_authorization} -> conn |> put_status(401) |> json(%{"ok" => false, "error" => "invalid-authorization"}) {:error, :invalid_event} -> conn |> put_status(401) |> json(%{"ok" => false, "error" => "invalid-auth-event"}) {:error, :stale_event} -> conn |> put_status(401) |> json(%{"ok" => false, "error" => "stale-auth-event"}) {:error, :replayed_auth_event} -> conn |> put_status(401) |> json(%{"ok" => false, "error" => "replayed-auth-event"}) {:error, :invalid_method_tag} -> conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-method-tag-mismatch"}) {:error, :invalid_url_tag} -> conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-url-tag-mismatch"}) {:error, :sender_mismatch} -> conn |> put_status(403) |> json(%{"ok" => false, "error" => "sender-mismatch"}) {:error, :invalid_payload} -> conn |> put_status(400) |> json(%{"ok" => false, "error" => "invalid-payload"}) {:error, reason} -> conn |> put_status(400) |> json(%{"ok" => false, "error" => inspect(reason)}) end end defp local_identity(api_url) when is_binary(api_url) do with {:ok, tribe} when not is_nil(tribe) <- Tribes.Alliance.local_tribe(authorize?: false), {:ok, relay_urls} <- Tribes.Cluster.list_public_relay_urls() do {:ok, %{ "protocol" => "org.tribes.federation@1", "tribe" => %{ "pubkey" => tribe.pubkey, "name" => tribe.name, "description" => tribe.description, "visibility" => to_string(tribe.visibility) }, "api_url" => api_url, "relay_urls" => relay_urls, "capabilities" => Trust.federation_capabilities() }} else {:ok, nil} -> {:error, :local_tribe_not_found} {:error, _reason} = error -> error end end defp parse_hello(%{"from_tribe" => from_tribe} = params) when is_binary(from_tribe) do if is_list(Map.get(params, "relay_urls", [])) and is_list(Map.get(params, "capabilities", [])) and is_list(Map.get(params, "requested_capabilities", [])) do {:ok, params} else {:error, :invalid_payload} end end defp parse_hello(_params), do: {:error, :invalid_payload} defp verify_sender(pubkey, %{"from_tribe" => from_tribe}) when is_binary(pubkey) do if String.downcase(pubkey) == String.downcase(from_tribe) do :ok else {:error, :sender_mismatch} end end defp verify_sender(_pubkey, _attrs), do: {:error, :sender_mismatch} defp relationship_payload(relationship) do %{ "remote_tribe_pubkey" => relationship.remote_tribe_pubkey, "status" => to_string(relationship.status), "trust_score" => relationship.trust_score, "granted_capabilities" => relationship.granted_capabilities || [] } end defp api_url(conn) do base_url(conn) <> "/api/tribes/v1" end defp full_request_url(conn) do query_suffix = if conn.query_string == "", do: "", else: "?#{conn.query_string}" base_url(conn) <> Listener.visible_request_path(conn) <> query_suffix end defp base_url(conn) do scheme = forwarded_scheme(conn) {host, host_port} = forwarded_host(conn) port = forwarded_port(conn, scheme, host_port) port_suffix = cond do scheme == :http and port == 80 -> "" scheme == :https and port == 443 -> "" true -> ":#{port}" end "#{scheme}://#{host}#{port_suffix}" end defp forwarded_scheme(conn) do case first_forwarded_header(conn, "x-forwarded-proto") do "https" -> :https "http" -> :http _other -> conn.scheme end end defp forwarded_host(conn) do case first_forwarded_header(conn, "x-forwarded-host") do nil -> {conn.host, nil} forwarded_host -> parse_forwarded_host(forwarded_host) end end defp forwarded_port(conn, scheme, host_port) do forwarded? = Enum.any?(["x-forwarded-proto", "x-forwarded-host", "x-forwarded-port"], fn header -> is_binary(first_forwarded_header(conn, header)) end) case first_forwarded_header(conn, "x-forwarded-port") do nil when is_integer(host_port) -> host_port nil when forwarded? -> default_port(scheme) nil -> conn.port port -> parse_port(port, default_port(scheme)) end end defp first_forwarded_header(conn, header) do conn |> get_req_header(header) |> List.first() |> case do nil -> nil value -> value |> String.split(",", parts: 2) |> List.first() |> String.trim() end end defp parse_forwarded_host(host) when is_binary(host) do case URI.parse("//" <> host) do %URI{host: parsed_host, port: port} when is_binary(parsed_host) and parsed_host != "" -> {parsed_host, port} _other -> {host, nil} end end defp parse_port(value, fallback) when is_binary(value) do case Integer.parse(value) do {port, ""} when port > 0 -> port _other -> fallback end end defp default_port(:https), do: 443 defp default_port(_scheme), do: 80 end