fix: Verify body digest vs signing header

This commit is contained in:
Ro 2024-11-01 17:43:40 -05:00
parent 2549a8db2a
commit 1bd8ce9c6b
Signed by: ro
GPG key ID: 5B5AD5A568CDABF9
9 changed files with 66 additions and 30 deletions

View file

@ -1,5 +1,6 @@
## Backend
- [x] Check that signature header (digest) matches digest of body contents
- [ ] Posting
- [x] Making posts locally
- [x] Figuring out follower list
@ -7,24 +8,28 @@
- [x] Post formatting
- [ ] Sending posts w/ images / videos
- [ ] Receiving posts w/ images / videos
- [ ] Timeline
- [x] Timeline
- [x] My posts
- [x] Posts from accounts you follow
- [ ] Show the actor avatar and display name
- [x] Show the actor avatar and display name
- [ ] Deleting posts
- [ ] Profile
- [ ] Name field (for display name)
- [x] Profile
- [x] Name field (for display name)
- [x] Bust actor cache when you update your profile
- [x] Following
- [ ] Scrape public posts from the outbox when you follow
- [ ] Unfollowing
- [x] Being followed
- [x] Accepting follows
- [ ] Approving / declining follows and authorized instance list
- [ ] Blocking
- [ ] Approving / declining follows
- [ ] Manage authorized instance list
- [ ] Liking
- [ ] Unliking
- [ ] CW posts
- [ ] Polls
- [ ] DMs
- [ ] Support authenticated fetch of outbox (by allowed domains / servers)
- [ ] Followers-only posts (or maybe this is handled because we only send posts to followers? but we also include public in the TO field?)
## UX

View file

@ -10,21 +10,25 @@ defmodule ActivityPub.Headers do
]
def signing_headers(method, url, body, actor_url, private_key) do
url = case url do
url when is_struct(url, URI) ->
url
url when is_binary(url) ->
URI.parse(url)
url =
case url do
url when is_struct(url, URI) ->
url
url when is_binary(url) ->
URI.parse(url)
end
private_key = case private_key do
"-----BEGIN" <> _ = key_pem ->
key_pem
|> :public_key.pem_decode()
|> hd()
|> :public_key.pem_entry_decode()
private_key ->
private_key
private_key =
case private_key do
"-----BEGIN" <> _ = key_pem ->
key_pem
|> :public_key.pem_decode()
|> hd()
|> :public_key.pem_entry_decode()
private_key ->
private_key
end
method = String.downcase("#{method}")
@ -51,7 +55,7 @@ defmodule ActivityPub.Headers do
[{"signature", signature_header} | headers]
end
def verify(method, path, headers, actor_fetcher \\ &fetch_actor_key/1) do
def verify(method, path, headers, body, actor_fetcher \\ &fetch_actor_key/1) do
{_key, signature_header} = Enum.find(headers, fn {key, _} -> key == "signature" end)
signature_kv = SignatureSplitter.split(signature_header)
@ -59,15 +63,23 @@ defmodule ActivityPub.Headers do
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])
digest = :crypto.hash(:sha256, body)
expected_digest_header = "SHA-256=#{Base.encode64(digest)}"
actual_digest_header = find_value(headers, "digest")
case actor_fetcher.(key_id) do
{:ok, public_key} ->
:public_key.verify(to_verify, :sha256, signature, public_key)
if expected_digest_header != actual_digest_header do
{:error, :digest}
else
to_verify =
signing_text(signing_text_headers, [request_target_pseudoheader(method, path) | headers])
error ->
error
case actor_fetcher.(key_id) do
{:ok, public_key} ->
:public_key.verify(to_verify, :sha256, signature, public_key)
error ->
error
end
end
end

View file

@ -0,0 +1,18 @@
defmodule PostlandWeb.CacheBodyReader do
@moduledoc """
Inspired by https://hexdocs.pm/plug/1.6.0/Plug.Parsers.html#module-custom-body-reader
"""
alias Plug.Conn
@doc """
Read the raw body and store it for later use in the connection.
It ignores the updated connection returned by `Plug.Conn.read_body/2` to not break CSRF.
"""
@spec read_body(Conn.t(), Plug.opts()) :: {:ok, String.t(), Conn.t()}
def read_body(conn, opts) do
{:ok, body, _conn} = Conn.read_body(conn, opts)
conn = update_in(conn.assigns[:raw_body], &[body | &1 || []])
{:ok, body, conn}
end
end

View file

@ -7,7 +7,7 @@ defmodule PostlandWeb.InboxController do
alias Postland.Activities
def post(conn, params) do
if Headers.verify(conn.method, conn.request_path, conn.req_headers) do
if Headers.verify(conn.method, conn.request_path, conn.req_headers, conn.assigns.raw_body) do
case Activities.process_activity(params) do
{:ok, _activity} ->
send_resp(conn, 200, Jason.encode!(params))

View file

@ -14,7 +14,7 @@ defmodule PostlandWeb.OutboxController do
"orderedItems" => []
}
if Headers.verify(conn.method, conn.request_path, conn.req_headers) do
if Headers.verify(conn.method, conn.request_path, conn.req_headers, conn.assigns.raw_body) do
Plug.Conn.send_resp(conn, 200, Jason.encode!(json))
else
send_resp(conn, 403, "forbidden")

View file

@ -49,6 +49,7 @@ defmodule PostlandWeb.Endpoint do
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
body_reader: {PostlandWeb.CacheBodyReader, :read_body, []},
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()

View file

@ -88,6 +88,7 @@ defmodule PostlandWeb.ProfileLive do
case Repo.update(changeset) do
{:ok, account} ->
form = account |> User.profile_changeset(%{}) |> to_form()
Cachex.del(:main_cache, "actor:#{Postland.my_actor_id()}")
{:noreply, assign(socket, account: account, editing: false, form: form)}
{:error, changeset} ->

View file

@ -33,7 +33,7 @@ defmodule ActivityPub.HeadersTest do
headers = signing_headers(method, url, body, actor_url, private)
verify(method, "/inbox", headers, actor_fetcher)
verify(method, "/inbox", headers, body, actor_fetcher)
end
end
end

View file

@ -20,7 +20,6 @@ defmodule PostlandWeb.UserSessionControllerTest do
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ user.username
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log_out"
end