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:
parent
d54ff52691
commit
f57e97bf18
5 changed files with 234 additions and 0 deletions
|
|
@ -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: ""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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") ==
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue