Blind key rotation and stale duration for profiles

See https://blog.dereferenced.org/the-case-for-blind-key-rotation

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-02-14 17:56:36 +01:00
parent 3a753312c1
commit 39b7afd1cd
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
10 changed files with 164 additions and 11 deletions

View file

@ -21,6 +21,11 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
- Possibility to change email address for the account
- Possibility to delete your account
### Changed
- Signature validation also now checks if `Date` header has acceptable values
- Actor profiles are now stale after two days and have to be refetched
- Actor keys are rotated some time after sending a `Delete` activity
### Fixed
- Fixed URL search
- Fixed content accessed through URL search being public

View file

@ -146,7 +146,11 @@ config :ex_cldr,
config :http_signatures,
adapter: Mobilizon.Federation.HTTPSignatures.Signature
config :mobilizon, :activitypub, sign_object_fetches: true
config :mobilizon, :activitypub,
# One day
actor_stale_period: 3_600 * 48,
actor_key_rotation_delay: 3_600 * 48,
sign_object_fetches: true
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Nominatim

View file

@ -73,8 +73,8 @@ defmodule Mobilizon.Federation.ActivityPub do
with {:not_http, true} <- {:not_http, String.starts_with?(url, "http")},
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(url)},
{:existing_comment, nil} <- {:existing_comment, Events.get_comment_from_url(url)},
{:existing_actor, {:error, :actor_not_found}} <-
{:existing_actor, Actors.get_actor_by_url(url)},
{:existing_actor, {:error, _err}} <-
{:existing_actor, get_or_fetch_actor_by_url(url)},
{:ok, %{body: body, status_code: code}} when code in 200..299 <-
HTTPoison.get(
url,
@ -126,7 +126,7 @@ defmodule Mobilizon.Federation.ActivityPub do
end
@doc """
Getting an actor from url, eventually creating it
Getting an actor from url, eventually creating it if we don't have it locally or if it needs an update
"""
@spec get_or_fetch_actor_by_url(String.t(), boolean) :: {:ok, Actor.t()} | {:error, String.t()}
def get_or_fetch_actor_by_url(url, preload \\ false)
@ -138,12 +138,13 @@ defmodule Mobilizon.Federation.ActivityPub do
end
def get_or_fetch_actor_by_url(url, preload) do
case Actors.get_actor_by_url(url, preload) do
{:ok, %Actor{} = actor} ->
{:ok, actor}
with {:ok, %Actor{} = cached_actor} <- Actors.get_actor_by_url(url, preload),
false <- Actors.needs_update?(cached_actor) do
{:ok, cached_actor}
else
_ ->
case make_actor_from_url(url, preload) do
# For tests, see https://github.com/jjh42/mock#not-supported---mocking-internal-function-calls and Mobilizon.Federation.ActivityPubTest
case __MODULE__.make_actor_from_url(url, preload) do
{:ok, %Actor{} = actor} ->
{:ok, actor}
@ -351,6 +352,7 @@ defmodule Mobilizon.Federation.ActivityPub do
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: event.url, actor_id: actor.id}),
Share.delete_all_by_uri(event.url),
:ok <- check_for_actor_key_rotation(actor),
{:ok, activity} <- create_activity(Map.merge(data, audience), local),
:ok <- maybe_federate(activity) do
{:ok, activity, event}
@ -374,6 +376,7 @@ defmodule Mobilizon.Federation.ActivityPub do
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}),
Share.delete_all_by_uri(comment.url),
:ok <- check_for_actor_key_rotation(actor),
{:ok, activity} <- create_activity(Map.merge(data, audience), local),
:ok <- maybe_federate(activity) do
{:ok, activity, comment}
@ -1012,4 +1015,15 @@ defmodule Mobilizon.Federation.ActivityPub do
})
end
end
defp check_for_actor_key_rotation(%Actor{} = actor) do
if Actors.should_rotate_actor_key(actor) do
Actors.schedule_key_rotation(
actor,
Application.get_env(:mobilizon, :activitypub)[:actor_key_rotation_delay]
)
end
:ok
end
end

View file

@ -47,6 +47,12 @@ defmodule Mobilizon do
ActivityPub.Federator,
cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1),
cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1),
cachex_spec(
:actor_key_rotation,
2500,
div(Application.get_env(:mobilizon, :activitypub)[:actor_key_rotation_delay], 60),
60 * 30
),
cachex_spec(:statistics, 10, 60, 60),
cachex_spec(:config, 10, 60, 60),
cachex_spec(:activity_pub, 2500, 3, 15)

View file

@ -50,7 +50,8 @@ defmodule Mobilizon.Actors.Actor do
mentions: [Mention.t()],
shares: [Share.t()],
owner_shares: [Share.t()],
memberships: [t]
memberships: [t],
last_refreshed_at: DateTime.t()
}
@required_attrs [:preferred_username, :keys, :suspended, :url]
@ -65,6 +66,7 @@ defmodule Mobilizon.Actors.Actor do
:domain,
:summary,
:manually_approves_followers,
:last_refreshed_at,
:user_id
]
@attrs @required_attrs ++ @optional_attrs
@ -118,6 +120,7 @@ defmodule Mobilizon.Actors.Actor do
field(:openness, ActorOpenness, default: :moderated)
field(:visibility, ActorVisibility, default: :private)
field(:suspended, :boolean, default: false)
field(:last_refreshed_at, :utc_datetime)
embeds_one(:avatar, File, on_replace: :update)
embeds_one(:banner, File, on_replace: :update)
@ -261,6 +264,7 @@ defmodule Mobilizon.Actors.Actor do
|> unique_constraint(:url, name: :actors_url_index)
|> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index)
|> validate_format(:preferred_username, ~r/[a-z0-9_]+/)
|> put_change(:last_refreshed_at, DateTime.utc_now() |> DateTime.truncate(:second))
end
@doc """

View file

@ -260,6 +260,13 @@ defmodule Mobilizon.Actors do
end
end
@spec actor_key_rotation(Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def actor_key_rotation(%Actor{} = actor) do
actor
|> Actor.changeset(%{keys: Crypto.generate_rsa_2048_private_key()})
|> Repo.update()
end
@doc """
Returns the list of actors.
"""
@ -746,6 +753,40 @@ defmodule Mobilizon.Actors do
get_follower_by_followed_and_following(followed_actor, follower_actor)
end
@doc """
Whether the actor needs to be updated.
Local actors obviously don't need to be updated
"""
@spec needs_update?(Actor.t()) :: boolean
def needs_update?(%Actor{domain: nil}), do: false
def needs_update?(%Actor{last_refreshed_at: nil, domain: domain}) when not is_nil(domain),
do: true
def needs_update?(%Actor{domain: domain} = actor) when not is_nil(domain) do
DateTime.diff(DateTime.utc_now(), actor.last_refreshed_at) >=
Application.get_env(:mobilizon, :activitypub)[:actor_stale_period]
end
def needs_update?(_), do: true
@spec should_rotate_actor_key(Actor.t()) :: boolean
def should_rotate_actor_key(%Actor{id: actor_id}) do
with {:ok, value} when is_boolean(value) <- Cachex.exists?(:actor_key_rotation, actor_id) do
value
end
end
@spec schedule_key_rotation(Actor.t(), integer()) :: nil
def schedule_key_rotation(%Actor{id: actor_id} = actor, delay) do
Cachex.put(:actor_key_rotation, actor_id, true)
Workers.Background.enqueue("actor_key_rotation", %{"actor_id" => actor.id}, schedule_in: delay)
:ok
end
@spec remove_banner(Actor.t()) :: {:ok, Actor.t()}
defp remove_banner(%Actor{banner: nil} = actor), do: {:ok, actor}

View file

@ -14,4 +14,10 @@ defmodule Mobilizon.Service.Workers.Background do
Actors.perform(:delete_actor, actor)
end
end
def perform(%{"op" => "actor_key_rotation", "actor_id" => actor_id}, _job) do
with %Actor{} = actor <- Actors.get_actor(actor_id) do
Actors.actor_key_rotation(actor)
end
end
end

View file

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddLastRefreshedAtToActors do
use Ecto.Migration
def change do
alter table(:actors) do
add(:last_refreshed_at, :utc_datetime, null: true)
end
end
end

View file

@ -10,6 +10,7 @@ defmodule Mobilizon.Federation.ActivityPubTest do
import Mock
import Mobilizon.Factory
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
@ -47,10 +48,72 @@ defmodule Mobilizon.Federation.ActivityPubTest do
end
end
@actor_url "https://framapiaf.org/users/tcit"
test "returns an actor from url" do
# Initial fetch
use_cassette "activity_pub/fetch_framapiaf.org_users_tcit" do
assert {:ok, %Actor{preferred_username: "tcit", domain: "framapiaf.org"}} =
ActivityPub.get_or_fetch_actor_by_url("https://framapiaf.org/users/tcit")
ActivityPub.get_or_fetch_actor_by_url(@actor_url)
end
# Fetch uses cache if Actors.needs_update? returns false
with_mocks([
{Actors, [:passthrough],
[
get_actor_by_url: fn @actor_url, false ->
{:ok,
%Actor{
preferred_username: "tcit",
domain: "framapiaf.org"
}}
end,
needs_update?: fn _ -> false end
]},
{ActivityPub, [:passthrough],
make_actor_from_url: fn @actor_url, false ->
{:ok,
%Actor{
preferred_username: "tcit",
domain: "framapiaf.org"
}}
end}
]) do
assert {:ok, %Actor{preferred_username: "tcit", domain: "framapiaf.org"}} =
ActivityPub.get_or_fetch_actor_by_url(@actor_url)
assert_called(Actors.needs_update?(:_))
refute called(ActivityPub.make_actor_from_url(@actor_url, false))
end
# Fetch doesn't use cache if Actors.needs_update? returns true
with_mocks([
{Actors, [:passthrough],
[
get_actor_by_url: fn @actor_url, false ->
{:ok,
%Actor{
preferred_username: "tcit",
domain: "framapiaf.org"
}}
end,
needs_update?: fn _ -> true end
]},
{ActivityPub, [:passthrough],
make_actor_from_url: fn @actor_url, false ->
{:ok,
%Actor{
preferred_username: "tcit",
domain: "framapiaf.org"
}}
end}
]) do
assert {:ok, %Actor{preferred_username: "tcit", domain: "framapiaf.org"}} =
ActivityPub.get_or_fetch_actor_by_url(@actor_url)
assert_called(ActivityPub.get_or_fetch_actor_by_url(@actor_url))
assert_called(Actors.get_actor_by_url(@actor_url, false))
assert_called(Actors.needs_update?(:_))
assert_called(ActivityPub.make_actor_from_url(@actor_url, false))
end
end
end

View file

@ -39,6 +39,7 @@ defmodule Mobilizon.Factory do
following_url: Actor.build_url(preferred_username, :following),
inbox_url: Actor.build_url(preferred_username, :inbox),
outbox_url: Actor.build_url(preferred_username, :outbox),
last_refreshed_at: DateTime.utc_now(),
user: build(:user)
}
end