Ideally we’d use a single common HTTP request error format handling for _all_ HTTP requests (including non-ActivityPub requests, e.g. NodeInfo). But for the purpose of this commit this would create too much noise and it is significant effort to go through all error pattern matches etc too ensure it is still all correct or update as needed.
135 lines
4 KiB
Elixir
135 lines
4 KiB
Elixir
# Akkoma: Magically expressive social media
|
||
# Copyright © 2025 Akkoma Authors <https://akkoma.dev/>
|
||
# SPDX-License-Identifier: AGPL-3.0-only
|
||
|
||
defmodule Pleroma.HTTP.Middleware.HTTPSignature do
|
||
alias Pleroma.User.SigningKey
|
||
alias Pleroma.Signature
|
||
|
||
require Logger
|
||
|
||
@behaviour Tesla.Middleware
|
||
|
||
@moduledoc """
|
||
Adds a HTTP signature and related headers to requests, if a signing key is set in the request env.
|
||
If any other middleware can update the target location (e.g. redirects) this MUST be placed after all of them!
|
||
|
||
(Note: the third argument holds static middleware options from client creation)
|
||
"""
|
||
|
||
@doc """
|
||
If logging raw Tesla.Env use this if you wish to redact signing key details
|
||
"""
|
||
def redact_keys(env) do
|
||
case get_in(env, [:opts, :httpsig, :signing_key]) do
|
||
nil -> env
|
||
key -> put_in(env, [:opts, :httpsig, :signing_key], redact_key_details(key))
|
||
end
|
||
end
|
||
|
||
defp redact_key_details(%SigningKey{key_id: id}), do: id
|
||
defp redact_key_details(key), do: key
|
||
|
||
@impl true
|
||
def call(env, next, _options) do
|
||
env = maybe_sign(env)
|
||
Tesla.run(env, next)
|
||
end
|
||
|
||
defp maybe_sign(env) do
|
||
case Keyword.get(env.opts, :httpsig) do
|
||
%{signing_key: %SigningKey{} = key} ->
|
||
set_signature_headers(env, key)
|
||
|
||
_ ->
|
||
env
|
||
end
|
||
end
|
||
|
||
defp set_signature_headers(env, key) do
|
||
Logger.debug("Signing request to: #{env.url}")
|
||
{http_headers, signing_headers} = collect_headers_for_signature(env)
|
||
signature = Signature.sign(key, signing_headers, has_body: has_body(env))
|
||
set_headers(env, [{"signature", signature} | http_headers])
|
||
end
|
||
|
||
defp has_body(%{body: body}) when body in [nil, ""], do: false
|
||
defp has_body(_), do: true
|
||
|
||
defp set_headers(env, []), do: env
|
||
|
||
defp set_headers(env, [{key, val} | rest]) do
|
||
headers = :proplists.delete(key, env.headers)
|
||
headers = [{key, val} | headers]
|
||
set_headers(%{env | headers: headers}, rest)
|
||
end
|
||
|
||
# Returns tuple.
|
||
# First element is headers+values which need to be added to the HTTP request.
|
||
# Second element are all headers to be used for signing, including already existing and pseudo headers.
|
||
defp collect_headers_for_signature(env) do
|
||
{request_target, host} = get_request_target_and_host(env)
|
||
date = http_date()
|
||
|
||
# content-length is always automatically set later on
|
||
# since they are needed to establish working connection.
|
||
# Similarly host will always be set for HTTP/1, and technically may be omitted for HTTP/2+
|
||
# but Tesla doesn’t handle it well if we preset it ourselves (and seems to set it even for HTTP/2 anyway)
|
||
http_headers = [{"date", date}]
|
||
|
||
signing_headers = %{
|
||
"(request-target)" => request_target,
|
||
"host" => host,
|
||
"date" => date
|
||
}
|
||
|
||
if has_body(env) do
|
||
append_body_headers(env, http_headers, signing_headers)
|
||
else
|
||
{http_headers, signing_headers}
|
||
end
|
||
end
|
||
|
||
defp append_body_headers(env, http_headers, signing_headers) do
|
||
content_length = byte_size(env.body)
|
||
digest = digest_value(env)
|
||
|
||
http_headers = [{"digest", digest} | http_headers]
|
||
|
||
signing_headers =
|
||
Map.merge(signing_headers, %{
|
||
"digest" => digest,
|
||
"content-length" => content_length
|
||
})
|
||
|
||
{http_headers, signing_headers}
|
||
end
|
||
|
||
defp get_request_target_and_host(env) do
|
||
uri = URI.parse(env.url)
|
||
rt = "#{env.method} #{uri.path}"
|
||
host = host_from_uri(uri)
|
||
{rt, host}
|
||
end
|
||
|
||
defp digest_value(env) do
|
||
# case Tesla.get_header(env, "digest")
|
||
encoded_hash = :crypto.hash(:sha256, env.body) |> Base.encode64()
|
||
"SHA-256=" <> encoded_hash
|
||
end
|
||
|
||
defp host_from_uri(%URI{port: port, scheme: scheme, host: host}) do
|
||
# https://httpwg.org/specs/rfc9110.html#field.host
|
||
# https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.3
|
||
if port == URI.default_port(scheme) do
|
||
host
|
||
else
|
||
"#{host}:#{port}"
|
||
end
|
||
end
|
||
|
||
defp http_date() do
|
||
now = NaiveDateTime.utc_now()
|
||
Timex.lformat!(now, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT", "en")
|
||
end
|
||
end
|