feat: Handle HTTP signatures

This commit is contained in:
Ro 2024-09-24 08:16:54 -05:00
parent 5529d5f979
commit 30d6284518
15 changed files with 269 additions and 128 deletions

View file

@ -1,18 +1,8 @@
# Postland
To start your Phoenix server:
* Run `mix setup` to install and setup dependencies
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
## Learn more
* Official website: https://www.phoenixframework.org/
* Guides: https://hexdocs.pm/phoenix/overview.html
* Docs: https://hexdocs.pm/phoenix
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix
- Posting
- Deleting posts
- Following
- Unfollowing
- Liking
- Unliking

112
lib/activity_pub/headers.ex Normal file
View file

@ -0,0 +1,112 @@
defmodule ActivityPub.Headers do
alias ActivityPub.Headers.SignatureSplitter
@http_date_format "%a, %d %b %Y %H:%M:%S GMT"
@signing_headers [
"(request-target)",
"host",
"date",
"digest"
]
def signing_headers(method, url, body, actor_url, private_key) do
method = String.downcase("#{method}")
date = DateTime.utc_now() |> Calendar.strftime(@http_date_format)
host = url.host
digest = :crypto.hash(:sha256, body)
digest_header = "SHA-256=#{Base.encode64(digest)}"
headers =
[
{"host", host},
{"date", date},
{"digest", digest_header}
]
to_sign =
signing_text(@signing_headers, [request_target_pseudoheader(method, url.path) | headers])
signature = to_sign |> :public_key.sign(:sha256, private_key) |> Base.encode64()
signature_header =
~s|keyId="#{actor_url}#main-key",headers="(request-target) host date digest",signature="#{signature}"|
[{"signature", signature_header} | headers]
end
def verify(method, path, headers, actor_fetcher \\ &fetch_actor_key/1) do
{_key, signature_header} = Enum.find(headers, fn {key, _} -> key == "signature" end)
signature_kv = SignatureSplitter.split(signature_header)
key_id = find_value(signature_kv, "keyId")
signature = signature_kv |> find_value("signature") |> Base.decode64!()
signing_text_headers = signature_kv |> find_value("headers") |> String.split(" ")
to_verify =
signing_text(signing_text_headers, [request_target_pseudoheader(method, path) | headers])
case actor_fetcher.(key_id) do
{:ok, public_key} ->
:public_key.verify(to_verify, :sha256, signature, public_key)
error ->
error
end
end
def request_target_pseudoheader(method, path) do
formatted_method = String.downcase("#{method}")
{"(request-target)", "#{formatted_method} #{path}"}
end
def signing_text(signing_text_headers, header_pairs) do
signing_text_headers
|> Enum.map_join("", fn header_name ->
value = find_value(header_pairs, header_name)
"#{header_name}: #{value}\n"
end)
end
def split_signature_header(signature_header) do
signature_header
|> String.split(",")
|> Enum.map(fn pair_string ->
[key, value] = String.split(pair_string, "=")
value = String.trim(value, "\"")
{key, value}
end)
end
defp find_value(headers, key) do
Enum.find_value(headers, fn {key_candidate, value} ->
String.downcase(key_candidate) == key && value
end)
end
def fetch_actor_key(key_id) do
request =
Req.new(url: key_id)
|> Req.Request.put_header("accept", "application/json")
case Req.get(request) do
{:ok, result} ->
dbg(result)
key_map = result.body["publicKey"]
if key_map["id"] == key_id do
[public_key | _] =
:public_key.pem_decode(key_map["publicKeyPem"])
|> hd()
|> :public_key.pem_entry_decode()
{:ok, public_key}
else
{:error, :id_mismatch}
end
error ->
error
end
end
end

View file

@ -0,0 +1,30 @@
defmodule ActivityPub.Headers.SignatureSplitter do
def split(""), do: []
def split(value) do
{key_value, rest} = scan_until(value, ",")
{key, value} = split_kv(key_value)
[{key, value} | split(rest)]
end
defp split_kv(kv_string) do
{key, wrapped_value} = scan_until(kv_string, "=")
value = String.trim(wrapped_value, ~s("))
{key, value}
end
def scan_until(buffer, terminator, acc \\ "")
def scan_until("", _terminator, acc), do: {acc, ""}
def scan_until(<<ch::utf8, buffer::binary>>, terminator, acc) do
ch = <<ch>>
if ch == terminator do
{acc, buffer}
else
scan_until(buffer, terminator, acc <> ch)
end
end
end

View file

@ -30,6 +30,7 @@ defmodule Postland do
def on_boot_setup() do
if !setup?() do
# TODO: Require the DB have restrictive permissions
%{public: public_key, private: private_key} = generate_keys()
attrs = %{

View file

@ -1,8 +1,6 @@
defmodule PostlandWeb.WebfingerController do
use PostlandWeb, :controller
alias Postland.Accounts
def get(conn, _params) do
render(conn, :webfinger, %{})
end

View file

@ -6,13 +6,6 @@ defmodule PostlandWeb.UserLoginLive do
<div class="mx-auto max-w-sm">
<.header class="text-center">
Log in to account
<:subtitle>
Don't have an account?
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
Sign up
</.link>
for an account now.
</:subtitle>
</.header>
<.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">

View file

@ -57,7 +57,9 @@ defmodule Postland.MixProject do
{:gettext, "~> 0.20"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.2"}
{:bandit, "~> 1.2"},
{:req, "~> 0.5.6"},
{:stream_data, "~> 1.1.1", only: [:test]}
]
end

View file

@ -35,6 +35,8 @@
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"},
"stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"},
"swoosh": {:hex, :swoosh, "1.17.1", "01295a82bddd2c6cac1e65856e29444d7c23c4501e0ebc69cea8a82018227e25", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3b20d25e580cb79af631335a1bdcfbffd835c08ebcdc16e98577223a241a18a1"},
"tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},

View file

@ -0,0 +1,36 @@
defmodule ActivityPub.HeadersTest do
use Postland.DataCase, async: true
use ExUnitProperties
import ActivityPub.Headers
property "can verify what it signs" do
private =
Postland.KeyFixtures.private()
|> :public_key.pem_decode()
|> hd()
|> :public_key.pem_entry_decode()
actor_fetcher = fn _ ->
Postland.KeyFixtures.public()
|> :public_key.pem_decode()
|> hd()
|> :public_key.pem_entry_decode()
end
check all(
method <- member_of([:get, :post]),
body <- StreamData.binary(),
host1 <- StreamData.string([?a..?z, ?0..?9, ?-]),
host2 <- StreamData.string([?a..?z, ?0..?9, ?-])
) do
url = URI.parse("#{host1}/inbox")
actor_url = "#{host2}/actor"
headers = signing_headers(method, url, body, actor_url, private)
verify(method, "/inbox", headers, actor_fetcher)
end
end
end

View file

@ -57,18 +57,4 @@ defmodule PostlandWeb.UserLoginLiveTest do
assert redirected_to(conn) == "/users/log_in"
end
end
describe "login navigation" do
test "redirects to registration page when the Register button is clicked", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/log_in")
{:ok, _login_live, login_html} =
lv
|> element(~s|main a:fl-contains("Sign up")|)
|> render_click()
|> follow_redirect(conn, ~p"/users/register")
assert login_html =~ "Register"
end
end
end

View file

@ -1,87 +0,0 @@
defmodule PostlandWeb.UserRegistrationLiveTest do
use PostlandWeb.ConnCase
import Phoenix.LiveViewTest
import Postland.AccountsFixtures
describe "Registration page" do
test "renders registration page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/register")
assert html =~ "Register"
assert html =~ "Log in"
end
test "redirects if already logged in", %{conn: conn} do
result =
conn
|> log_in_user(user_fixture())
|> live(~p"/users/register")
|> follow_redirect(conn, "/")
assert {:ok, _conn} = result
end
test "renders errors for invalid data", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
result =
lv
|> element("#registration_form")
|> render_change(user: %{"username" => "with spaces", "password" => "too short"})
assert result =~ "Register"
assert result =~ "must contain only letters, numbers, and underscores"
assert result =~ "should be at least 12 character"
end
end
describe "register user" do
test "creates account and logs the user in", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
username = unique_user_username()
form = form(lv, "#registration_form", user: valid_user_attributes(username: username))
render_submit(form)
conn = follow_trigger_action(form, conn)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, "/")
response = html_response(conn, 200)
assert response =~ username
assert response =~ "Settings"
assert response =~ "Log out"
end
test "renders errors for duplicated username", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
user = user_fixture(%{username: "test"})
result =
lv
|> form("#registration_form",
user: %{"username" => user.username, "password" => "valid_password"}
)
|> render_submit()
assert result =~ "has already been taken"
end
end
describe "registration navigation" do
test "redirects to login page when the Log in button is clicked", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
{:ok, _login_live, login_html} =
lv
|> element(~s|main a:fl-contains("Log in")|)
|> render_click()
|> follow_redirect(conn, ~p"/users/log_in")
assert login_html =~ "Log in"
end
end
end

View file

@ -10,7 +10,9 @@ defmodule Postland.AccountsFixtures do
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
username: unique_user_username(),
password: valid_user_password()
password: valid_user_password(),
private_key: "test",
public_key: "test"
})
end

View file

@ -0,0 +1,9 @@
defmodule Postland.KeyFixtures do
def public do
File.read!("test/support/fixtures/public.pem")
end
def private do
File.read!("test/support/fixtures/private.pem")
end
end

View file

@ -0,0 +1,52 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----

View file

@ -0,0 +1,15 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA5wphw7hrp6UrKZm6Ops3
JZmxoAelTH+gWLXGt4+W8j9WzpRU5BuadHqqevgiGEo599bk3ecFlzNY7i7UtnkW
jxtykOq6miMbQObW2WhwE/+wj8S9wcH9AjAa34Pz+6YedP6XZ3RbsAj3ON7xo/a8
XnDeSwm6hfiYJbkXW83UGeMyzYC4vilM0Ex1zZCGTbJY/FJJyLCikZWxEBtUHuTI
J4vwdnfZ6GraQN3l9fLkDAAdyzpG/0EjjhPkEcniHFB8CUVuWXCfaSnRAt3YU2BH
yMsqV/mWqHUM5LMvE0q2brN1azT+0sKHBiOAc7VOCDd92603h3MyjmqC3tu82jHQ
Wc8wtmEMzwBM6hU+ytKdl/PSud/hiNDATKsogcZLu0NRrrVyvmx4ykmT1DBhyoZy
UcEv46iRMY3LturxsBSA65OCr/1ftGbpuaZ3Ill/mWUedQsm1sqPGPX1voNyaC+J
5XLhwkqw5McHpmAY7YSx9bXiE06cj/KCDJcE5ZsIjIxu9hGxkWb0OInlWivMXjXu
qpFfspEdjl34RFyzb+aX4tnHVz1f4HkeDj94tH1v9njnJsPElJYhwHX8VlGp13rh
Z8lpzhqCD5zADby8es2V0RiXgCPKCWt+hyddiJt9hiBQjSFDVYYfeeBrrJu3UV0O
KTzdT4N7FjGF4AtYEpJiXCsCAwEAAQ==
-----END PUBLIC KEY-----