mobilizon/lib/federation/activity_pub/utils.ex
Thomas Citharel 5b337f952a
refactor(activitypub): handle failure finding public key in actor keys
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
2023-12-06 08:25:02 +01:00

808 lines
24 KiB
Elixir
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Portions of this file are derived from Pleroma:
# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/activity_pub/utils.ex
defmodule Mobilizon.Federation.ActivityPub.Utils do
@moduledoc """
Various ActivityPub related utils.
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Medias.Media
alias Mobilizon.Federation.ActivityPub.{Actions, Activity, Federator}
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.Federation.ActivityStream.Converter
alias Mobilizon.Federation.HTTPSignatures
alias Mobilizon.Web.Endpoint
require Logger
@actor_types ["Group", "Person", "Application"]
@all_actor_types @actor_types ++ ["Organization", "Service"]
@ap_public_audience "https://www.w3.org/ns/activitystreams#Public"
# Wraps an object into an activity
@spec create_activity(map(), boolean()) :: {:ok, Activity.t()}
def create_activity(map, local) when is_map(map) do
with map <- lazy_put_activity_defaults(map) do
{:ok,
%Activity{
data: map,
local: local,
actor: map["actor"],
recipients: get_recipients(map)
}}
end
end
# Get recipients for an activity or object
@spec get_recipients(map()) :: list()
defp get_recipients(data) do
Map.get(data, "to", []) ++ Map.get(data, "cc", [])
end
# Some implementations send the actor URI as the actor field, others send the entire actor object,
# so figure out what the actor's URI is based on what we have.
@spec get_url(map() | String.t() | list(String.t()) | any()) :: String.t() | nil
def get_url(%{"id" => id}), do: id
def get_url(id) when is_binary(id), do: id
def get_url(ids) when is_list(ids), do: get_url(hd(ids))
def get_url(_), do: nil
@spec make_json_ld_header :: map()
def make_json_ld_header do
%{
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
%{
"@language" => "und",
"sc" => "http://schema.org#",
"ical" => "http://www.w3.org/2002/12/cal/ical#",
"pt" => "https://joinpeertube.org/ns#",
"toot" => "http://joinmastodon.org/ns#",
"discoverable" => "toot:discoverable",
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"Hashtag" => "as:Hashtag",
"category" => "sc:category",
"uuid" => "sc:identifier",
"maximumAttendeeCapacity" => "sc:maximumAttendeeCapacity",
"remainingAttendeeCapacity" => "sc:remainingAttendeeCapacity",
"location" => %{
"@id" => "sc:location",
"@type" => "sc:Place"
},
"PostalAddress" => "sc:PostalAddress",
"address" => %{
"@id" => "sc:address",
"@type" => "sc:PostalAddress"
},
"addressCountry" => "sc:addressCountry",
"addressRegion" => "sc:addressRegion",
"postalCode" => "sc:postalCode",
"addressLocality" => "sc:addressLocality",
"streetAddress" => "sc:streetAddress",
"mz" => "https://joinmobilizon.org/ns#",
"repliesModerationOptionType" => %{
"@id" => "mz:repliesModerationOptionType",
"@type" => "rdfs:Class"
},
"repliesModerationOption" => %{
"@id" => "mz:repliesModerationOption",
"@type" => "mz:repliesModerationOptionType"
},
"commentsEnabled" => %{
"@type" => "sc:Boolean",
"@id" => "pt:commentsEnabled"
},
"joinModeType" => %{
"@id" => "mz:joinModeType",
"@type" => "rdfs:Class"
},
"joinMode" => %{
"@id" => "mz:joinMode",
"@type" => "mz:joinModeType"
},
"anonymousParticipationEnabled" => %{
"@id" => "mz:anonymousParticipationEnabled",
"@type" => "sc:Boolean"
},
"participationMessage" => %{
"@id" => "mz:participationMessage",
"@type" => "sc:Text"
},
"participantCount" => %{
"@id" => "mz:participantCount",
"@type" => "sc:Integer"
},
"memberCount" => %{
"@id" => "mz:memberCount",
"@type" => "sc:Integer"
},
"isOnline" => %{
"@type" => "sc:Boolean",
"@id" => "mz:isOnline"
},
"externalParticipationUrl" => %{
"@id" => "mz:externalParticipationUrl",
"@type" => "sc:URL"
},
"PropertyValue" => "sc:PropertyValue",
"value" => "sc:value",
"propertyID" => "sc:propertyID",
"inLanguage" => "sc:inLanguage",
"timezone" => %{
"@id" => "mz:timezone",
"@type" => "sc:Text"
},
"discussions" => %{
"@id" => "mz:discussions",
"@type" => "@id"
},
"events" => %{
"@id" => "mz:events",
"@type" => "@id"
},
"members" => %{
"@id" => "mz:members",
"@type" => "@id"
},
"openness" => %{
"@id" => "mz:openness",
"@type" => "@id"
},
"posts" => %{
"@id" => "mz:posts",
"@type" => "@id"
},
"resources" => %{
"@id" => "mz:resources",
"@type" => "@id"
},
"todos" => %{
"@id" => "mz:todos",
"@type" => "@id"
},
"status" => %{
"@id" => "ical:status",
"@type" => "ical:status"
}
}
]
}
end
@spec make_date :: String.t()
def make_date do
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
end
@doc """
Enqueues an activity for federation if it's local
"""
@spec maybe_federate(activity :: Activity.t()) :: :ok
def maybe_federate(%Activity{local: true} = activity) do
Logger.debug("Maybe federate an activity")
if Mobilizon.Config.get!([:instance, :federating]) do
priority =
case activity.data["type"] do
"Delete" -> 10
"Create" -> 1
_ -> 5
end
Federator.enqueue(:publish, activity, priority)
end
:ok
end
def maybe_federate(_), do: :ok
@doc """
Applies to activities sent by group members from outside this instance to a group of this instance,
we then need to relay (`Announce`) the object to other members on other instances.
"""
@spec maybe_relay_if_group_activity(Activity.t(), Actor.t() | nil | list(Actor.t())) :: :ok
def maybe_relay_if_group_activity(activity, attributed_to \\ nil)
def maybe_relay_if_group_activity(
%Activity{data: %{"object" => object}},
_attributed_to
)
when is_map(object) do
Logger.debug("Maybe relay if group activity (object is map)")
Logger.debug(inspect(object))
do_maybe_relay_if_group_activity(object, object["attributedTo"])
end
# When doing a delete the object is just an AP ID string, so we pass the attributed_to actor as well
def maybe_relay_if_group_activity(
%Activity{data: %{"object" => object}},
%Actor{url: attributed_to_url}
)
when is_binary(object) and is_binary(attributed_to_url) do
Logger.debug("Maybe relay if group activity (object is binary)")
do_maybe_relay_if_group_activity(object, attributed_to_url)
end
def maybe_relay_if_group_activity(_activity, _attributedTo) do
Logger.debug("Will not replay : not a group activity")
:ok
end
# TODO: Is this a map or a String?
@spec do_maybe_relay_if_group_activity(map() | String.t(), list(String.t()) | String.t()) :: :ok
defp do_maybe_relay_if_group_activity(object, attributed_to) when is_list(attributed_to),
do: do_maybe_relay_if_group_activity(object, hd(attributed_to))
defp do_maybe_relay_if_group_activity(object, attributed_to) when is_binary(attributed_to) do
Logger.debug("Let's try to relay group activity")
id = "#{Endpoint.url()}/announces/#{Ecto.UUID.generate()}"
case Actors.get_local_group_by_url(attributed_to) do
%Actor{} = group ->
case Actions.Announce.announce(group, object, id, true, false) do
{:ok, _activity, _object} ->
Logger.info("Forwarded activity to external members of the group")
:ok
{:error, err} ->
Logger.info("Failed to forward activity to external members of the group")
Logger.debug(inspect(err))
:error
end
nil ->
:ok
end
end
defp do_maybe_relay_if_group_activity(_, attributed_to) do
Logger.debug("Will not relay group activity, attributed to is : #{inspect(attributed_to)}")
end
@spec remote_actors(list(String.t())) :: list(Actor.t())
def remote_actors(recipients) do
recipients
|> Enum.map(fn url -> ActivityPubActor.get_or_fetch_actor_by_url(url) end)
|> Enum.map(fn {status, actor} ->
case status do
:ok ->
actor
_ ->
nil
end
end)
|> Enum.map(& &1)
|> Enum.filter(fn actor -> actor && !is_nil(actor.domain) end)
end
@doc """
Adds an id and a published data if they aren't there,
also adds it to an included object
"""
@spec lazy_put_activity_defaults(map()) :: map()
def lazy_put_activity_defaults(%{"object" => _object} = map) do
if is_map(map["object"]) do
object = lazy_put_object_defaults(map["object"])
%{map | "object" => object}
else
map
end
end
@doc """
Adds an id and published date if they aren't there.
"""
def lazy_put_object_defaults(map) when is_map(map) do
Map.put_new_lazy(map, "published", &make_date/0)
end
@spec get_actor(map()) :: String.t() | nil
def get_actor(%{"actor" => actor}) when is_binary(actor) do
actor
end
def get_actor(%{"actor" => [actor | tail] = actor_list} = object)
when is_list(actor_list) and length(actor_list) > 0 do
res =
try do
object
|> Map.put("actor", actor)
|> get_actor()
rescue
ArgumentError -> nil
end
case res do
id when is_binary(id) ->
id
_ ->
object
|> Map.put("actor", tail)
|> get_actor()
end
end
def get_actor(%{"actor" => %{"id" => id, "type" => type}})
when is_binary(id) and type in @all_actor_types do
id
end
def get_actor(%{"actor" => _, "attributedTo" => actor}) when not is_nil(actor) do
get_actor(%{"actor" => actor})
end
def get_actor(%{"actor" => %{"id" => id, "type" => type}})
when is_binary(id) do
raise ArgumentError,
message: "Object contains an actor object with invalid type: #{inspect(type)}"
end
def get_actor(%{"actor" => nil, "attributedTo" => nil} = object) do
raise ArgumentError,
message: "Object contains both actor and attributedTo fields being null: #{inspect(object)}"
end
def get_actor(%{"actor" => _}) do
raise ArgumentError, message: "Object contains not actor information"
end
@doc """
Checks that an incoming AP object's actor matches the domain it came from.
Takes the actor or attributedTo attributes (considers only the first elem if they're an array)
"""
@spec origin_check?(String.t(), map()) :: boolean()
def origin_check?(id, %{"type" => "Tombstone", "id" => tombstone_id}), do: id == tombstone_id
def origin_check?(id, %{"actor" => actor, "attributedTo" => _attributed_to} = params)
when not is_nil(actor) and actor != "" do
params = Map.delete(params, "attributedTo")
origin_check?(id, params)
end
def origin_check?(id, %{"attributedTo" => actor} = params) do
params = params |> Map.put("actor", actor) |> Map.delete("attributedTo")
origin_check?(id, params)
end
def origin_check?(id, %{"actor" => actor} = params)
when not is_nil(actor) and is_list(actor) and length(actor) > 0 do
origin_check?(id, Map.put(params, "actor", hd(actor)))
end
def origin_check?(id, %{"actor" => actor} = params)
when not is_nil(actor) do
actor = get_actor(params)
Logger.debug("Performing origin check on #{id} and #{actor} URIs")
are_same_origin?(id, actor)
end
def origin_check?(id, %{"type" => type, "id" => actor_id} = _params)
when type in ["Actor", "Person", "Group", "Application"],
do: id == actor_id
def origin_check?(_id, %{"actor" => nil} = _args), do: false
def origin_check?(_id, _args), do: false
@spec are_same_origin?(String.t(), String.t()) :: boolean()
def are_same_origin?(url_1, url_2) when is_binary(url_1) and is_binary(url_2) do
uri_1 = URI.parse(url_1)
uri_2 = URI.parse(url_2)
compare_uris?(uri_1, uri_2)
end
@spec compare_uris?(URI.t(), URI.t()) :: boolean()
defp compare_uris?(%URI{} = id_uri, %URI{} = other_uri),
do: id_uri.host == other_uri.host && id_uri.port == other_uri.port
@spec origin_check_from_id?(String.t(), String.t()) :: boolean()
def origin_check_from_id?(id, other_id) when is_binary(other_id) do
id_uri = URI.parse(id)
other_uri = URI.parse(other_id)
compare_uris?(id_uri, other_uri)
end
@spec origin_check_from_id?(String.t(), map()) :: boolean()
def origin_check_from_id?(id, %{"id" => other_id} = _params) when is_binary(other_id),
do: origin_check_from_id?(id, other_id)
@doc """
Return AS Link data from
* a `Plug.Upload` struct, stored an returned
* a `Media`, directly returned
* a map containing media information, stored, saved and returned
Save media data from %Plug.Upload{} and return AS Link data.
"""
def make_media_data(%Plug.Upload{} = media, opts) do
case Mobilizon.Web.Upload.store(media, opts) do
{:ok, media} ->
media
{:error, _err} ->
nil
end
end
def make_media_data(media) when is_map(media) do
with {:ok, %{url: url} = uploaded} <-
Mobilizon.Web.Upload.store(media.file),
{:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)},
{:ok, %Media{file: _file} = media} <-
Mobilizon.Medias.create_media(%{
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
actor_id: media.actor_id
}) do
Converter.Media.model_to_as(media)
else
{:media_exists, %Media{file: _file} = media} ->
Converter.Media.model_to_as(media)
err ->
err
end
end
def make_media_data(nil), do: nil
@doc """
Make announce activity data for the given actor and object
"""
def make_announce_data(actor, object, activity_id, public \\ true)
def make_announce_data(
%Actor{} = actor,
%{"id" => url, "type" => type} = _object,
activity_id,
public
)
when type in @actor_types do
do_make_announce_data(actor, url, url, activity_id, public)
end
def make_announce_data(
%Actor{} = actor,
%{"actor" => object_actor_url} = object,
activity_id,
public
) do
do_make_announce_data(
actor,
object_actor_url,
object,
activity_id,
public
)
end
defp do_make_announce_data(
%Actor{type: actor_type} = actor,
object_actor_url,
object,
activity_id,
public
) do
{to, cc} =
if public do
Logger.debug("Making announce data for a public object")
{[actor.followers_url, object_actor_url], [@ap_public_audience]}
else
Logger.debug("Making announce data for a private object")
if actor_type == :Group do
Logger.debug("Making announce data for a group private object")
to =
Map.get(object, "to", []) ++
Map.get(object, "cc", []) ++ [actor.followers_url, actor.members_url]
{to, []}
else
Logger.debug("Making announce data for a private object")
{[actor.followers_url], []}
end
end
data = %{
"type" => "Announce",
"actor" => actor.url,
"object" => object,
"to" => to,
"cc" => cc
}
data =
if object["attributedTo"],
do: Map.put(data, "attributedTo", object["attributedTo"]),
else: data
if activity_id, do: Map.put(data, "id", activity_id), else: data
end
@doc """
Make unannounce activity data for the given actor and object
"""
def make_unannounce_data(
%Actor{url: url} = actor,
activity,
activity_id
) do
data = %{
"type" => "Undo",
"actor" => url,
"object" => activity,
"to" => [actor.followers_url, actor.url],
"cc" => [@ap_public_audience]
}
if activity_id, do: Map.put(data, "id", activity_id), else: data
end
#### Unfollow-related helpers
@spec make_unfollow_data(Actor.t(), Actor.t(), map(), String.t()) :: map()
def make_unfollow_data(
%Actor{url: follower_url},
%Actor{url: followed_url},
follow_activity,
activity_id
) do
data = %{
"type" => "Undo",
"actor" => follower_url,
"to" => [followed_url],
"object" => follow_activity.data
}
if activity_id, do: Map.put(data, "id", activity_id), else: data
end
#### Create-related helpers
@doc """
Make create activity data
"""
@spec make_create_data(map(), map()) :: map()
def make_create_data(object, additional \\ %{}) do
Logger.debug("Making create data")
Logger.debug(inspect(object))
Logger.debug(inspect(additional))
%{
"type" => "Create",
"to" => object["to"],
"cc" => object["cc"],
"attributedTo" => object["attributedTo"] || object["actor"],
"actor" => object["actor"],
"object" => object,
"published" => make_date(),
"id" => object["id"] <> "/activity"
}
|> Map.merge(additional)
end
@doc """
Make update activity data
"""
@spec make_update_data(map(), map()) :: map()
def make_update_data(object, additional \\ %{}) do
Logger.debug("Making update data")
Logger.debug(inspect(object))
Logger.debug(inspect(additional))
%{
"type" => "Update",
"to" => object["to"],
"cc" => object["cc"],
"attributedTo" => object["attributedTo"] || object["actor"],
"actor" => object["actor"],
"object" => object,
"id" => object["id"] <> "/activity"
}
|> Map.merge(additional)
end
@doc """
Make accept join activity data
"""
@spec make_accept_join_data(map(), map()) :: map()
def make_accept_join_data(object, additional \\ %{}) do
%{
"type" => "Accept",
"to" => object["to"],
"cc" => object["cc"],
"object" => object,
"id" => object["id"] <> "/activity"
}
|> Map.merge(additional)
end
@doc """
Make add activity data
"""
@spec make_add_data(map(), map(), map()) :: map()
def make_add_data(object, target, additional \\ %{}) do
Logger.debug("Making add data")
Logger.debug(inspect(object))
Logger.debug(inspect(additional))
%{
"type" => "Add",
"to" => object["to"],
"cc" => object["cc"],
"actor" => object["actor"],
"object" => object,
"target" => Map.get(target, :url, target),
"id" => object["id"] <> "/add"
}
|> Map.merge(additional)
end
@doc """
Make move activity data
"""
@spec make_move_data(map(), map(), map(), map()) :: map()
def make_move_data(object, origin, target, additional \\ %{}) do
Logger.debug("Making move data")
Logger.debug(inspect(object))
Logger.debug(inspect(origin))
Logger.debug(inspect(target))
Logger.debug(inspect(additional))
%{
"type" => "Move",
"to" => object["to"],
"cc" => object["cc"],
"actor" => object["actor"],
"object" => object,
"origin" => if(is_nil(origin), do: origin, else: Map.get(origin, :url, origin)),
"target" => if(is_nil(target), do: target, else: Map.get(target, :url, target)),
"id" => object["id"] <> "/move"
}
|> Map.merge(additional)
end
@doc """
Converts PEM encoded keys to a public key representation
"""
@spec pem_to_public_key_pem(String.t()) :: String.t() | {:error, :no_publickey_found}
def pem_to_public_key_pem(pem) do
case :public_key.pem_decode(pem) do
[key_code] ->
public_key = pem_to_public_key(key_code)
public_key = :public_key.pem_entry_encode(:RSAPublicKey, public_key)
:public_key.pem_encode([public_key])
_ ->
{:error, :no_publickey_found}
end
end
@spec pem_to_public_key(String.t()) :: {:RSAPublicKey, any(), any()}
defp pem_to_public_key(key_code) do
case :public_key.pem_entry_decode(key_code) do
{:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} ->
{:RSAPublicKey, modulus, exponent}
{:RSAPublicKey, modulus, exponent} ->
{:RSAPublicKey, modulus, exponent}
end
end
@spec make_signature(Actor.t(), String.t(), DateTime.t()) :: list({atom(), String.t()})
defp make_signature(actor, id, date) do
uri = URI.parse(id)
signature =
actor
|> HTTPSignatures.Signature.sign(%{
"(request-target)": "get #{uri.path}",
host: uri.host,
date: date
})
[{:Signature, signature}]
end
@doc """
Sign a request with an actor.
"""
@spec sign_fetch(Enum.t(), Actor.t(), String.t(), String.t(), Keyword.t()) :: Enum.t()
def sign_fetch(headers, actor, id, date, options \\ []) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) and
Keyword.get(options, :ignore_sign_object_fetches, false) == false do
headers ++ make_signature(actor, id, date)
else
headers
end
end
@doc """
Add the Date header to the request if we sign object fetches
"""
@spec maybe_date_fetch(Enum.t(), String.t()) :: Enum.t()
def maybe_date_fetch(headers, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ [{:Date, date}]
else
headers
end
end
def 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
@spec label_in_collection?(any(), any()) :: boolean()
defp label_in_collection?(url, coll) when is_binary(coll), do: url == coll
defp label_in_collection?(url, coll) when is_list(coll), do: url in coll
defp label_in_collection?(_, _), do: false
@spec label_in_message?(String.t(), map()) :: boolean()
def label_in_message?(label, params),
do:
[params["to"], params["cc"], params["bto"], params["bcc"]]
|> Enum.any?(&label_in_collection?(label, &1))
@spec unaddressed_message?(map()) :: boolean()
def unaddressed_message?(params),
do:
[params["to"], params["cc"], params["bto"], params["bcc"]]
|> Enum.all?(&is_nil(&1))
@spec recipient_in_message(Actor.t(), Actor.t(), map()) :: boolean()
def recipient_in_message(%Actor{url: url} = _recipient, %Actor{} = _actor, params),
do: label_in_message?(url, params) || unaddressed_message?(params)
defp extract_list(target) when is_binary(target), do: [target]
defp extract_list(lst) when is_list(lst), do: lst
defp extract_list(_), do: []
def maybe_splice_recipient(url, params) do
need_splice? =
!label_in_collection?(url, params["to"]) &&
!label_in_collection?(url, params["cc"])
if need_splice? do
cc_list = extract_list(params["cc"])
Map.put(params, "cc", [url | cc_list])
else
params
end
end
@schemes_with_no_port ["http", "https"]
def create_full_domain_string(%URI{host: host, port: nil}), do: host
def create_full_domain_string(%URI{host: host, port: port}) do
if port in Enum.map(@schemes_with_no_port, &URI.default_port/1) do
host
else
"#{host}:#{port}"
end
end
end