akkoma/lib/pleroma/http/middleware/httpsignature.ex
Oneric 2b4b68eba7 Ensure private keys are not logged
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.
2025-09-07 00:00:00 +00:00

135 lines
4 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 doesnt 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