Rename repository

This commit is contained in:
Pim Kunis 2021-09-01 14:43:55 +02:00
parent 4aeb2d2cd8
commit 232df26b85
71 changed files with 348 additions and 345 deletions

View file

@ -0,0 +1,98 @@
defmodule ArchitexWeb.Federation.AuthenticateServer do
import ArchitexWeb.Error
alias Architex.{SigningKey, ServerKeyInfo}
@auth_header_regex ~r/^X-Matrix origin=(?<origin>.*),key="(?<key>.*)",sig="(?<sig>.*)"$/
def authenticate(%Plug.Conn{
body_params: body_params,
req_headers: headers,
request_path: path,
method: method,
query_string: query_string
}) do
# TODO: This will break if request ends with '?'.
uri = URI.decode_www_form(path)
uri =
if String.length(query_string) > 0 do
uri <> "?" <> URI.decode_www_form(query_string)
else
uri
end
object_to_sign = %{
uri: uri,
method: method,
destination: Architex.server_name()
}
object_to_sign =
if method != "GET", do: Map.put(object_to_sign, :content, body_params), else: object_to_sign
object_fun = &Map.put(object_to_sign, :origin, &1)
authenticate_with_headers(headers, object_fun)
end
defp authenticate_with_headers(headers, object_fun) do
# TODO: Only query once per origin.
headers
|> parse_authorization_headers()
|> Enum.find(:error, fn {origin, _, sig} ->
object = object_fun.(origin)
with {:ok, raw_sig} <- Architex.decode_base64(sig),
{:ok, encoded_object} <- Architex.encode_canonical_json(object),
{:ok, %ServerKeyInfo{signing_keys: keys}} <-
ServerKeyInfo.with_fresh_signing_keys(origin) do
Enum.find_value(keys, false, fn %SigningKey{signing_key: signing_key} ->
with {:ok, decoded_key} <- Architex.decode_base64(signing_key) do
Architex.sign_verify(raw_sig, encoded_object, decoded_key)
else
_ -> false
end
end)
else
_ -> false
end
end)
end
def parse_authorization_headers(headers) do
headers
|> Enum.filter(&(elem(&1, 0) == "authorization"))
|> Enum.map(fn {_, auth_header} ->
Regex.named_captures(@auth_header_regex, auth_header)
end)
|> Enum.reject(&Kernel.is_nil/1)
|> Enum.map(fn %{"origin" => origin, "key" => key, "sig" => sig} ->
{origin, key, sig}
end)
|> Enum.filter(fn {_, key, _} -> String.starts_with?(key, "ed25519:") end)
end
defmacro __using__(opts) do
except = Keyword.get(opts, :except) || []
quote do
def action(conn, _) do
action = action_name(conn)
if action not in unquote(except) do
case ArchitexWeb.Federation.AuthenticateServer.authenticate(conn) do
{origin, _key, _sig} ->
conn = Plug.Conn.assign(conn, :origin, origin)
apply(__MODULE__, action, [conn, conn.params])
:error ->
put_error(conn, :unauthorized, "Signature verification failed.")
end
else
apply(__MODULE__, action, [conn, conn.params])
end
end
end
end
end

View file

@ -0,0 +1,119 @@
defmodule ArchitexWeb.Federation.EventController do
use ArchitexWeb, :controller
use ArchitexWeb.Federation.AuthenticateServer
import ArchitexWeb.Error
import Ecto.Query
alias Architex.{Repo, Event, RoomServer}
alias ArchitexWeb.Federation.Transaction
@doc """
Retrieves a single event.
Action for GET /_matrix/federation/v1/event/{eventId}.
"""
def event(%Plug.Conn{assigns: %{origin: origin}} = conn, %{"event_id" => event_id}) do
query =
Event
|> where([e], e.event_id == ^event_id)
|> preload(:room)
case Repo.one(query) do
%Event{room: room} = event ->
case RoomServer.get_room_server(room) do
{:ok, pid} ->
if RoomServer.server_in_room?(pid, origin) do
data = Transaction.new([event])
conn
|> put_status(200)
|> json(data)
else
put_error(conn, :unauthorized, "Origin server is not participating in room.")
end
_ ->
put_error(conn, :unknown)
end
nil ->
put_error(conn, :not_found, "Event or room not found.")
end
end
def event(conn, _), do: put_error(conn, :missing_param)
@doc """
Retrieves a snapshot of a room's state at a given event.
Action for GET /_matrix/federation/v1/state/{roomId}.
"""
def state(%Plug.Conn{assigns: %{origin: origin}} = conn, %{
"event_id" => event_id,
"room_id" => room_id
}) do
get_state_or_state_ids(conn, :state, origin, event_id, room_id)
end
def state(conn, _), do: put_error(conn, :missing_param)
@doc """
Retrieves a snapshot of a room's state at a given event, in the form of event IDs.
Action for GET /_matrix/federation/v1/state_ids/{roomId}.
"""
def state_ids(%Plug.Conn{assigns: %{origin: origin}} = conn, %{
"event_id" => event_id,
"room_id" => room_id
}) do
get_state_or_state_ids(conn, :state_ids, origin, event_id, room_id)
end
def state_ids(conn, _), do: put_error(conn, :missing_param)
@spec get_state_or_state_ids(
Plug.Conn.t(),
:state | :state_ids,
String.t(),
String.t(),
String.t()
) :: Plug.Conn.t()
defp get_state_or_state_ids(conn, state_or_state_ids, origin, event_id, room_id) do
query =
Event
|> where([e], e.event_id == ^event_id and e.room_id == ^room_id)
|> preload(:room)
case Repo.one(query) do
%Event{room: room} = event ->
case RoomServer.get_room_server(room) do
{:ok, pid} ->
if RoomServer.server_in_room?(pid, origin) do
{state_events, auth_chain} =
case state_or_state_ids do
:state -> RoomServer.get_state_at_event(pid, event)
:state_ids -> RoomServer.get_state_ids_at_event(pid, event)
end
data = %{
auth_chain: auth_chain,
pdus: state_events
}
conn
|> put_status(200)
|> json(data)
else
put_error(conn, :unauthorized, "Origin server is not participating in room.")
end
_ ->
put_error(conn, :unknown)
end
nil ->
put_error(conn, :not_found, "Event or room not found.")
end
end
end

View file

@ -0,0 +1,43 @@
defmodule ArchitexWeb.Federation.KeyController do
use ArchitexWeb, :controller
import ArchitexWeb.Error
alias Architex.KeyServer
@doc """
Gets the homeserver's published signing keys.
Action for GET /_matrix/key/v2/server/{keyId}.
"""
def get_signing_keys(conn, _params) do
keys =
KeyServer.get_own_signing_keys()
|> Enum.into(%{}, fn {key_id, key} ->
{key_id, %{"key" => key}}
end)
# TODO: Consider using TimeX.
# Valid for one month.
valid_until = DateTime.utc_now() |> DateTime.add(60 * 60 * 24 * 30, :second)
data = %{
server_name: Architex.server_name(),
verify_keys: keys,
old_verify_keys: %{},
valid_until_ts: DateTime.to_unix(valid_until, :millisecond)
}
case KeyServer.sign_object(data) do
{:ok, sig, key_id} ->
signed_data = Architex.add_signature(data, key_id, sig)
conn
|> put_status(200)
|> json(signed_data)
:error ->
put_error(conn, :unknown, "Error signing object.")
end
end
end

View file

@ -0,0 +1,61 @@
defmodule ArchitexWeb.Federation.QueryController do
use ArchitexWeb, :controller
use ArchitexWeb.Federation.AuthenticateServer
import ArchitexWeb.Error
import Ecto.Query
alias Architex.{Repo, Account}
alias Architex.Types.UserId
defmodule ProfileRequest do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :user_id, UserId
field :field, :string
end
def validate(params) do
%__MODULE__{}
|> cast(params, [:user_id, :field])
|> validate_required([:user_id])
|> validate_inclusion(:field, ["displayname", "avatar_url"])
|> then(fn
%Ecto.Changeset{valid?: true} = cs -> {:ok, apply_changes(cs)}
_ -> :error
end)
end
end
@doc """
Performs a query to get profile information, such as a display name or avatar,
for a given user.
Action for GET /_matrix/federation/v1/query/profile.
"""
def profile(conn, params) do
with {:ok, %ProfileRequest{user_id: %UserId{localpart: localpart, domain: domain}}} <-
ProfileRequest.validate(params) do
if domain == Architex.server_name() do
case Repo.one(from a in Account, where: a.localpart == ^localpart) do
%Account{} ->
# TODO: Return displayname and avatar_url when we implement them.
conn
|> put_status(200)
|> json(%{})
nil ->
put_error(conn, :not_found, "User does not exist.")
end
else
put_error(conn, :not_found, "Wrong server name.")
end
else
_ -> put_error(conn, :bad_json)
end
end
end

View file

@ -0,0 +1,92 @@
defmodule ArchitexWeb.Federation.HTTPClient do
use Tesla
alias ArchitexWeb.Endpoint
alias ArchitexWeb.Federation.Request.GetSigningKeys
alias ArchitexWeb.Federation.Middleware.SignRequest
alias ArchitexWeb.Router.Helpers, as: RouteHelpers
# TODO: Maybe create database-backed homeserver struct to pass to client function.
# TODO: Fix error propagation.
@adapter {Tesla.Adapter.Finch, name: ArchitexWeb.HTTPClient}
def client(server_name) do
Tesla.client(
[
{Tesla.Middleware.Opts, [server_name: server_name]},
SignRequest,
{Tesla.Middleware.BaseUrl, "http://" <> server_name},
Tesla.Middleware.JSON
],
@adapter
)
end
def get_signing_keys(client) do
path = RouteHelpers.key_path(Endpoint, :get_signing_keys)
with {:ok,
%GetSigningKeys{server_name: server_name, verify_keys: verify_keys, signatures: sigs} =
response} <- tesla_request(:get, client, path, GetSigningKeys),
serializable_response <- Architex.to_serializable_map(response),
serializable_response <- Map.drop(serializable_response, [:signatures]),
{:ok, encoded_body} <- Architex.encode_canonical_json(serializable_response),
server_sigs when not is_nil(server_sigs) <- sigs[server_name] do
# For each verify key, check if there is a matching signature.
# If not, invalidate the whole response.
Enum.all?(verify_keys, fn {key_id, %{"key" => key}} ->
with true <- Map.has_key?(server_sigs, key_id),
{:ok, decoded_key} <- Architex.decode_base64(key),
{:ok, decoded_sig} <- Architex.decode_base64(server_sigs[key_id]) do
Architex.sign_verify(decoded_sig, encoded_body, decoded_key)
else
_ -> false
end
end)
|> then(fn
true -> {:ok, response}
false -> :error
end)
else
_ -> :error
end
end
def query_profile(client, user_id, field \\ nil) do
path = RouteHelpers.query_path(Endpoint, :profile) |> Tesla.build_url(user_id: user_id)
path = if field, do: Tesla.build_url(path, field: field), else: path
Tesla.get(client, path)
end
def get_event(client, event_id) do
path = RouteHelpers.event_path(Endpoint, :event, event_id)
Tesla.get(client, path)
end
def get_state(client, room_id, event_id) do
path =
RouteHelpers.event_path(Endpoint, :state, room_id) |> Tesla.build_url(event_id: event_id)
Tesla.get(client, path)
end
def get_state_ids(client, room_id, event_id) do
path =
RouteHelpers.event_path(Endpoint, :state_ids, room_id)
|> Tesla.build_url(event_id: event_id)
Tesla.get(client, path)
end
defp tesla_request(method, client, path, request_schema) do
with {:ok, %Tesla.Env{body: body}} <- Tesla.request(client, url: path, method: method),
%Ecto.Changeset{valid?: true} = cs <- apply(request_schema, :changeset, [body]) do
{:ok, Ecto.Changeset.apply_changes(cs)}
else
_ -> :error
end
end
end

View file

@ -0,0 +1,36 @@
defmodule ArchitexWeb.Federation.Request.GetSigningKeys do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :server_name, :string
field :verify_keys, {:map, {:map, :string}}
field :old_verify_keys, {:map, :map}
field :signatures, {:map, {:map, :string}}
field :valid_until_ts, :integer
end
def changeset(params) do
# TODO: There must be a better way to validate embedded maps?
%__MODULE__{}
|> cast(params, [:server_name, :verify_keys, :old_verify_keys, :signatures, :valid_until_ts])
|> validate_required([:server_name, :verify_keys, :valid_until_ts])
|> Architex.validate_change_simple(:verify_keys, fn map ->
Enum.all?(map, fn {_, map} ->
is_map_key(map, "key")
end)
end)
|> Architex.validate_change_simple(:old_verify_keys, fn map ->
Enum.all?(map, fn
{_, %{"key" => key, "expired_ts" => expired_ts}}
when is_binary(key) and is_integer(expired_ts) ->
true
_ ->
false
end)
end)
end
end

View file

@ -0,0 +1,41 @@
defmodule ArchitexWeb.Federation.Middleware.SignRequest do
@behaviour Tesla.Middleware
def call(%Tesla.Env{opts: opts} = env, next, _opts) do
sign = Keyword.get(opts, :sign, true)
case sign_request(env, sign) do
%Tesla.Env{} = env -> Tesla.run(env, next)
:error -> {:error, :sign_request}
end
end
defp sign_request(env, false), do: env
defp sign_request(%Tesla.Env{method: method, url: path, opts: opts, body: body} = env, true) do
origin = Architex.server_name()
object_to_sign = %{
method: Atom.to_string(method) |> String.upcase(),
origin: origin,
uri: URI.decode_www_form(path),
destination: Keyword.fetch!(opts, :server_name)
}
object_to_sign =
if not is_nil(body), do: Map.put(object_to_sign, :content, body), else: object_to_sign
with {:ok, sig, key_id} <- Architex.KeyServer.sign_object(object_to_sign) do
sigs = %{origin => %{key_id => sig}}
auth_headers = create_signature_authorization_headers(sigs, origin)
Tesla.put_headers(env, auth_headers)
end
end
defp create_signature_authorization_headers(signatures, origin) do
Enum.map(signatures[origin], fn {key, sig} ->
{"Authorization", "X-Matrix origin=#{origin},key=\"#{key}\",sig=\"#{sig}\""}
end)
end
end

View file

@ -0,0 +1,36 @@
defmodule ArchitexWeb.Federation.Transaction do
alias Architex.Event
alias ArchitexWeb.Federation.Transaction
# TODO
@type edu :: any()
@type t :: %__MODULE__{
origin: String.t(),
origin_server_ts: integer(),
pdus: [Event.t()],
edus: [edu()] | nil
}
defstruct [:origin, :origin_server_ts, :pdus, :edus]
defimpl Jason.Encoder, for: Transaction do
@fields [:origin, :origin_server_ts, :pdus, :edus]
def encode(transaction, opts) do
transaction
|> Map.take(@fields)
|> Jason.Encode.map(opts)
end
end
@spec new([Event.t()], [edu()] | nil) :: t()
def new(pdu_events, edus \\ nil) do
%Transaction{
origin: Architex.server_name(),
origin_server_ts: System.os_time(:millisecond),
pdus: Enum.map(pdu_events, &Architex.to_serializable_map/1),
edus: edus
}
end
end