feat(signature): add a freshness and coverage policy for incoming signatures

Signature.check_signature_policy/2 requires date or (created) to be
signed and within signature_max_age (2 h) / signature_max_clock_skew
(1 h), and requires a signed, hashed digest for requests with a body.

Based on Akkoma's http_signatures fork (required headers, max_sig_age)
and Mastodon's SignedRequest (clock skew margin).
This commit is contained in:
Lain Soykaf 2026-10-27 21:46:40 +04:00
commit f57e97bf18
No known key found for this signature in database
5 changed files with 234 additions and 0 deletions

View file

@ -373,6 +373,9 @@ config :pleroma, :activitypub,
note_replies_output_limit: 5, note_replies_output_limit: 5,
sign_object_fetches: true, sign_object_fetches: true,
authorized_fetch_mode: false, authorized_fetch_mode: false,
signature_policy: :enforce,
signature_max_age: 7200,
signature_max_clock_skew: 3600,
client_api_enabled: false, client_api_enabled: false,
anonymize_reporter: false, anonymize_reporter: false,
anonymize_reporter_local_nickname: "" anonymize_reporter_local_nickname: ""

View file

@ -1788,6 +1788,27 @@ config :pleroma, :config_description, [
description: description:
"List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this)" "List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this)"
}, },
%{
key: :signature_policy,
type: {:dropdown, :atom},
description:
"What to do with incoming HTTP signatures that do not sign the date (or `(created)`), are outside the allowed time window, or do not sign the digest of a request body. `:enforce` treats them as invalid, `:log` only logs them",
suggestions: [:enforce, :log]
},
%{
key: :signature_max_age,
type: :integer,
description:
"How old, in seconds, the signed date of an incoming HTTP signature can be. Default: 7200 (2 hours)",
suggestions: [7200]
},
%{
key: :signature_max_clock_skew,
type: :integer,
description:
"How far, in seconds, the signed date of an incoming HTTP signature can be in the future. Default: 3600 (1 hour). A `(created)` time in the future is always rejected",
suggestions: [3600]
},
%{ %{
key: :note_replies_output_limit, key: :note_replies_output_limit,
type: :integer, type: :integer,

View file

@ -286,6 +286,9 @@ Notes:
* `sign_object_fetches`: Sign object fetches with HTTP signatures * `sign_object_fetches`: Sign object fetches with HTTP signatures
* `authorized_fetch_mode`: Require HTTP signatures for AP fetches * `authorized_fetch_mode`: Require HTTP signatures for AP fetches
* `authorized_fetch_mode_exceptions`: List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this) * `authorized_fetch_mode_exceptions`: List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this)
* `signature_policy`: What to do with incoming HTTP signatures that do not sign the `date` header (or `(created)`), whose signed date is outside the window below, or that do not sign the `digest` header of a request with a body (or whose body Pleroma did not hash). `:enforce` (default) treats them as invalid, `:log` only logs a warning.
* `signature_max_age`: How old, in seconds, the signed date of an incoming HTTP signature can be. Default: `7200` (2 hours).
* `signature_max_clock_skew`: How far, in seconds, the signed `date` of an incoming HTTP signature can be in the future. Default: `3600` (1 hour). A `(created)` time in the future is always rejected.
## Pleroma.User ## Pleroma.User

View file

@ -95,6 +95,84 @@ defmodule Pleroma.Signature do
Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT")
end end
@doc """
Checks the parts of an incoming signature that the cryptographic check does
not cover, so that a captured request cannot be replayed later or with a
different body.
The signature must cover `date` or `(created)`, and the signed time must be
within the configured window. Requests other than GET and HEAD must also
sign `digest`, and DigestPlug must have hashed their body. Based on Akkoma's http_signatures fork and Mastodon's
SignedRequest.
"""
@spec check_signature_policy(Plug.Conn.t(), DateTime.t()) :: :ok | {:error, atom()}
def check_signature_policy(%Plug.Conn{} = conn, now \\ DateTime.utc_now()) do
signature = HTTPSignatures.signature_for_conn(conn)
signed = Map.get(signature, "headers", [])
cond do
"date" not in signed and "(created)" not in signed ->
{:error, :date_not_signed}
conn.method not in ["GET", "HEAD"] and "digest" not in signed ->
{:error, :digest_not_signed}
# The plug puts the digest computed by DigestPlug into the header. With
# no computed digest (e.g. multipart), nothing checked the body.
conn.method not in ["GET", "HEAD"] and is_nil(conn.assigns[:digest]) ->
{:error, :body_not_hashed}
true ->
with :ok <- check_signed_date(conn, signed, now) do
check_signed_created(signature, signed, now)
end
end
end
defp check_signed_date(conn, signed, now) do
if "date" in signed do
# Exactly one header: a second one could make this check and the
# signature check see different dates.
with [date] <- Plug.Conn.get_req_header(conn, "date"),
{:ok, %DateTime{} = date} <- Timex.parse(date, "{RFC1123}") do
check_time_window(date, now)
else
_ -> {:error, :invalid_date}
end
else
:ok
end
end
defp check_signed_created(signature, signed, now) do
if "(created)" in signed do
with created when is_binary(created) <- signature["created"],
{created, ""} <- Integer.parse(created),
{:ok, created} <- DateTime.from_unix(created) do
check_time_window(created, now)
else
_ -> {:error, :invalid_date}
end
else
:ok
end
end
defp check_time_window(signed_at, now) do
age = DateTime.diff(now, signed_at)
cond do
age > Pleroma.Config.get([:activitypub, :signature_max_age], 7200) ->
{:error, :date_too_old}
-age > Pleroma.Config.get([:activitypub, :signature_max_clock_skew], 3600) ->
{:error, :date_in_future}
true ->
:ok
end
end
@spec validate_signature(Plug.Conn.t(), String.t()) :: boolean() @spec validate_signature(Plug.Conn.t(), String.t()) :: boolean()
def validate_signature(%Plug.Conn{} = conn, request_target) do def validate_signature(%Plug.Conn{} = conn, request_target) do
# Newer drafts for HTTP signatures now use @request-target instead of the # Newer drafts for HTTP signatures now use @request-target instead of the

View file

@ -116,6 +116,135 @@ defmodule Pleroma.SignatureTest do
end end
end end
describe "check_signature_policy/2" do
@now ~U[2026-10-27 12:00:00Z]
defp policy_conn(method, signed_headers, extra_headers, extra_params \\ "") do
signature =
"keyId=\"https://remote.example/users/a#main-key\",headers=\"#{signed_headers}\"" <>
extra_params <> ",signature=\"fake\""
%Plug.Conn{
method: method,
req_headers: [{"signature", signature}, {"host", "pleroma.example"} | extra_headers],
assigns: %{digest: "SHA-256=abc"}
}
end
defp date_header(seconds_ago) do
date = DateTime.add(@now, -seconds_ago) |> DateTime.to_naive()
{"date", Signature.signed_date(date)}
end
test "accepts a fresh signed GET (Pleroma, Mastodon, Misskey style)" do
for headers <- ["(request-target) host date", "(request-target) host date accept"] do
conn = policy_conn("GET", headers, [date_header(10)])
assert Signature.check_signature_policy(conn, @now) == :ok
end
end
test "accepts a fresh signed POST covering the digest" do
conn =
policy_conn("POST", "(request-target) host content-length digest date", [
date_header(10),
{"digest", "SHA-256=abc"}
])
assert Signature.check_signature_policy(conn, @now) == :ok
end
test "accepts (created) instead of date (GoToSocial style)" do
created = DateTime.to_unix(@now) - 10
conn =
policy_conn(
"POST",
"(request-target) (created) host digest",
[{"digest", "SHA-256=abc"}],
",algorithm=\"hs2019\",created=#{created}"
)
assert Signature.check_signature_policy(conn, @now) == :ok
end
test "rejects signatures that cover neither date nor (created)" do
conn = policy_conn("GET", "(request-target) host", [date_header(10)])
assert Signature.check_signature_policy(conn, @now) == {:error, :date_not_signed}
end
test "rejects a body request whose digest is not signed" do
conn =
policy_conn("POST", "(request-target) host date", [
date_header(10),
{"digest", "SHA-256=abc"}
])
assert Signature.check_signature_policy(conn, @now) == {:error, :digest_not_signed}
end
test "rejects a signed date that is missing or unparsable" do
conn = policy_conn("GET", "(request-target) host date", [])
assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date}
conn = policy_conn("GET", "(request-target) host date", [{"date", "yesterday"}])
assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date}
end
test "rejects more than one date header" do
conn =
policy_conn("GET", "(request-target) host date", [date_header(10), date_header(9000)])
assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date}
end
test "rejects a signed digest when the body was not hashed" do
conn =
policy_conn("POST", "(request-target) host date digest", [
date_header(10),
{"digest", "SHA-256=abc"}
])
|> Map.put(:assigns, %{})
assert Signature.check_signature_policy(conn, @now) == {:error, :body_not_hashed}
end
test "rejects a signed date that is too old or too far in the future" do
clear_config([:activitypub, :signature_max_age], 7200)
clear_config([:activitypub, :signature_max_clock_skew], 3600)
conn = policy_conn("GET", "(request-target) host date", [date_header(7200)])
assert Signature.check_signature_policy(conn, @now) == :ok
conn = policy_conn("GET", "(request-target) host date", [date_header(7201)])
assert Signature.check_signature_policy(conn, @now) == {:error, :date_too_old}
conn = policy_conn("GET", "(request-target) host date", [date_header(-3600)])
assert Signature.check_signature_policy(conn, @now) == :ok
conn = policy_conn("GET", "(request-target) host date", [date_header(-3601)])
assert Signature.check_signature_policy(conn, @now) == {:error, :date_in_future}
end
test "rejects a (created) timestamp that is too old" do
clear_config([:activitypub, :signature_max_age], 60)
created = DateTime.to_unix(@now) - 61
conn =
policy_conn("GET", "(request-target) (created) host", [], ",created=#{created}")
assert Signature.check_signature_policy(conn, @now) == {:error, :date_too_old}
end
test "uses the cavage default of a signed date when headers are not given" do
conn = %Plug.Conn{
method: "GET",
req_headers: [{"signature", "keyId=\"https://remote.example/users/a#main-key\""}]
}
assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date}
end
end
describe "key_id_to_actor_id/1" do describe "key_id_to_actor_id/1" do
test "it properly deduces the actor id for misskey" do test "it properly deduces the actor id for misskey" do
assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") == assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") ==