Fix server signature verification

This commit is contained in:
Pim Kunis 2021-08-10 18:02:53 +02:00
parent 33b64d80f5
commit e6b3c4752d
8 changed files with 138 additions and 110 deletions

View file

@ -41,6 +41,16 @@ defmodule MatrixServer do
end
end
# TODO Eventually move to regex with named captures.
def get_localpart(id) do
with [part, _] <- String.split(id, ":", parts: 2),
{_, localpart} <- String.split_at(part, 1) do
localpart
else
_ -> nil
end
end
# https://elixirforum.com/t/22709/9
def has_duplicates?(list) do
list

View file

@ -26,32 +26,11 @@ defmodule MatrixServer.SigningServer do
{:ok, %{public_key: public_key, private_key: private_key}}
end
# https://blog.swwomm.com/2020/09/elixir-ed25519-signatures-with-enacl.html
@impl true
def handle_call(
{:sign_object, object},
_from,
%{private_key: private_key} = state
) do
case MatrixServer.encode_canonical_json(object) do
{:ok, json} ->
signature =
json
|> :enacl.sign_detached(private_key)
|> MatrixServer.encode_unpadded_base64()
signature_map = %{@signing_key_id => signature}
servername = MatrixServer.server_name()
signed_object =
Map.update(object, :signatures, %{servername => signature_map}, fn signatures ->
Map.put(signatures, servername, signature_map)
end)
{:reply, {:ok, signed_object}, state}
{:error, _msg} ->
{:reply, {:error, :json_encode}, state}
def handle_call({:sign_object, object}, _from, %{private_key: private_key} = state) do
case sign_object(object, private_key) do
{:ok, signature} -> {:reply, {:ok, signature, @signing_key_id}, state}
{:error, _reason} -> {:reply, :error, state}
end
end
@ -61,6 +40,18 @@ defmodule MatrixServer.SigningServer do
{:reply, [{@signing_key_id, result}], state}
end
# https://blog.swwomm.com/2020/09/elixir-ed25519-signatures-with-enacl.html
defp sign_object(object, private_key) do
with {:ok, json} <- MatrixServer.encode_canonical_json(object) do
signature =
json
|> :enacl.sign_detached(private_key)
|> MatrixServer.encode_unpadded_base64()
{:ok, signature}
end
end
# TODO: not sure if there is a better way to do this...
defp get_keys do
raw_priv_key =

View file

@ -1,75 +1,50 @@
defmodule MatrixServerWeb.AuthenticateServer do
import Ecto.Changeset
import MatrixServerWeb.Plug.Error
alias MatrixServer.SigningServer
alias Ecto.Changeset
@auth_header_regex ~r/^X-Matrix origin=(?<origin>.*),key="(?<key>.*)",sig="(?<sig>.*)"$/
defmodule SignedJSON do
use Ecto.Schema
def authenticate(
%Plug.Conn{
body_params: body_params,
req_headers: headers,
request_path: path,
method: method,
query_string: query_string
}
) do
object_to_sign = %{
uri: path <> "?" <> URI.decode_www_form(query_string),
method: method,
destination: MatrixServer.server_name()
}
import Ecto.Changeset
object_to_sign =
if method != "GET", do: Map.put(object_to_sign, :content, body_params), else: object_to_sign
@primary_key false
embedded_schema do
field :method, :string
field :uri, :string
field :origin, :string
field :destination, :string
field :content, :map
field :signatures, :map
end
object_fun = &Map.put(object_to_sign, :origin, &1)
def changeset(params) do
%__MODULE__{}
|> cast(params, [:method, :uri, :origin, :destination, :content, :signatures])
|> validate_required([:method, :uri, :origin, :destination])
end
authenticate_with_headers(headers, object_fun)
end
def authenticated?(%Plug.Conn{body_params: params}) do
with %Changeset{valid?: true} = cs <- SignedJSON.changeset(params),
input <- apply_changes(cs) do
verify_signature(input)
else
_ -> false
end
end
defp authenticate_with_headers(headers, object_fun) do
headers
|> parse_authorization_headers()
|> Enum.find(:error, fn {origin, _, sig} ->
# TODO: fetch actual signing keys for origin from cache/key store.
{_, signing_key} = SigningServer.get_signing_keys() |> hd()
object = object_fun.(origin)
defp verify_signature(%SignedJSON{signatures: signatures, origin: origin} = input) do
if Map.has_key?(signatures, origin) do
# TODO: fetch actual signing keys from cache/key store.
signing_keys = SigningServer.get_signing_keys() |> Enum.into(%{})
found_signatures =
Enum.filter(signatures[origin], fn {key, _} ->
case String.split(key, ":", parts: 2) do
[algorithm, _] -> algorithm == "ed25519"
_ -> false
end
end)
|> Enum.map(fn {key_id, sig} ->
if Map.has_key?(signing_keys, key_id) do
{key_id, sig, signing_keys[key_id]}
end
end)
|> Enum.reject(&Kernel.is_nil/1)
with [{_, sig, signing_key} | _] <- found_signatures,
{:ok, raw_sig} <- MatrixServer.decode_base64(sig),
serializable_input <- MatrixServer.to_serializable_map(input),
{:ok, encoded_input} <- MatrixServer.encode_canonical_json(serializable_input) do
:enacl.sign_verify_detached(raw_sig, encoded_input, signing_key)
with {:ok, raw_sig} <- MatrixServer.decode_base64(sig),
{:ok, encoded_object} <- MatrixServer.encode_canonical_json(object) do
:enacl.sign_verify_detached(raw_sig, encoded_object, signing_key)
else
_ -> false
end
else
false
end
end)
end
# TODO: Not actually needed?
def parse_authorization_headers(headers) do
headers
|> Enum.filter(&(elem(&1, 0) == "authorization"))
@ -77,9 +52,10 @@ defmodule MatrixServerWeb.AuthenticateServer do
Regex.named_captures(@auth_header_regex, auth_header)
end)
|> Enum.reject(&Kernel.is_nil/1)
|> Enum.reduce(%{}, fn %{"origin" => origin, "key" => key, "sig" => sig}, acc ->
Map.update(acc, origin, %{key => sig}, &Map.put(&1, key, sig))
|> 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
@ -89,10 +65,14 @@ defmodule MatrixServerWeb.AuthenticateServer do
def action(conn, _) do
action = action_name(conn)
if action not in unquote(except) and
not MatrixServerWeb.AuthenticateServer.authenticated?(conn) do
IO.puts("Not authorized!")
apply(__MODULE__, action, [conn, conn.params])
if action not in unquote(except) do
case MatrixServerWeb.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

View file

@ -0,0 +1,55 @@
defmodule MatrixServerWeb.Federation.QueryController do
use MatrixServerWeb, :controller
use MatrixServerWeb.AuthenticateServer
import MatrixServerWeb.Plug.Error
import Ecto.Query
alias MatrixServer.{Repo, Account}
defmodule ProfileRequest do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :user_id, :string
field :field, :string
end
def validate(params) do
%__MODULE__{}
|> cast(params, [:user_id, :field])
|> validate_required([:user_id])
|> validate_inclusion(:field, ["displayname", "avatar_url"])
|> tap(fn
%Ecto.Changeset{valid?: true} = cs -> {:ok, apply_changes(cs)}
_ -> :error
end)
end
end
def profile(conn, params) do
with %ProfileRequest{user_id: user_id} <- ProfileRequest.validate(params) do
if MatrixServer.get_domain(user_id) == MatrixServer.server_name() do
localpart = MatrixServer.get_localpart(user_id)
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(:not_found, "User does not exist.")
end
else
put_error(:not_found, "Wrong server name.")
end
else
_ -> put_error(conn, :bad_json)
end
end
end

View file

@ -1,10 +0,0 @@
defmodule MatrixServerWeb.Federation.TestController do
use MatrixServerWeb, :controller
use MatrixServerWeb.AuthenticateServer
def test(conn, _params) do
conn
|> put_status(200)
|> json(%{})
end
end

View file

@ -20,26 +20,27 @@ defmodule MatrixServerWeb.FederationClient do
Tesla.get(client, RouteHelpers.key_path(Endpoint, :get_signing_keys))
end
def test_server_auth(client) do
origin = "localhost:4001"
destination = "localhost:4000"
path = RouteHelpers.test_path(Endpoint, :test)
# TODO: Create tesla middleware to add signature and headers.
def query_profile(client, server_name, user_id, field \\ nil) do
origin = MatrixServer.server_name()
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
params = %{
method: "POST",
object_to_sign = %{
method: "GET",
uri: path,
origin: origin,
destination: destination,
content: %{"hi" => "hello"}
destination: server_name
}
{:ok, signed_object} = MatrixServer.SigningServer.sign_object(params)
auth_headers = create_signature_authorization_headers(signed_object, origin)
{:ok, signature, key_id} = MatrixServer.SigningServer.sign_object(object_to_sign)
signatures = %{origin => %{key_id => signature}}
auth_headers = create_signature_authorization_headers(signatures, origin)
Tesla.post(client, path, signed_object, headers: auth_headers)
Tesla.get(client, path, headers: auth_headers)
end
defp create_signature_authorization_headers(%{signatures: signatures}, origin) do
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)

View file

@ -11,10 +11,11 @@ defmodule MatrixServerWeb.Plug.Error do
unknown: {400, "M_UNKNOWN", "An unknown error occurred."},
invalid_room_state:
{400, "M_INVALID_ROOM_STATE", "The request would leave the room in an invalid state."},
unauthorized: {400, "M_UNAUTHORIZED", "The request was unauthorized."},
unknown_token: {401, "M_UNKNOWN_TOKEN", "Invalid access token."},
missing_token: {401, "M_MISSING_TOKEN", "Access token required."},
not_found: {404, "M_NOT_FOUND", "The requested resource was not found."},
room_alias_exists: {409, "M.UNKNOWN", "The given room alias already exists."}
room_alias_exists: {409, "M_UNKNOWN", "The given room alias already exists."}
}
def put_error(conn, {error, msg}), do: put_error(conn, error, msg)

View file

@ -56,7 +56,7 @@ defmodule MatrixServerWeb.Router do
scope "/_matrix", MatrixServerWeb.Federation do
pipe_through :authenticate_server
post "/test", TestController, :test
get "/federation/v1/query/profile", QueryController, :profile
end
scope "/", MatrixServerWeb.Client do