salmon: refactor to work as a federator publishing module
[akkoma] / lib / pleroma / web / salmon / salmon.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.Salmon do
6 @behaviour Pleroma.Web.Federator.Publisher
7
8 @httpoison Application.get_env(:pleroma, :httpoison)
9
10 use Bitwise
11
12 alias Pleroma.Activity
13 alias Pleroma.Instances
14 alias Pleroma.User
15 alias Pleroma.Web.ActivityPub.Visibility
16 alias Pleroma.Web.Federator.Publisher
17 alias Pleroma.Web.OStatus.ActivityRepresenter
18 alias Pleroma.Web.XML
19
20 require Logger
21
22 def decode(salmon) do
23 doc = XML.parse_document(salmon)
24
25 {:xmlObj, :string, data} = :xmerl_xpath.string('string(//me:data[1])', doc)
26 {:xmlObj, :string, sig} = :xmerl_xpath.string('string(//me:sig[1])', doc)
27 {:xmlObj, :string, alg} = :xmerl_xpath.string('string(//me:alg[1])', doc)
28 {:xmlObj, :string, encoding} = :xmerl_xpath.string('string(//me:encoding[1])', doc)
29 {:xmlObj, :string, type} = :xmerl_xpath.string('string(//me:data[1]/@type)', doc)
30
31 {:ok, data} = Base.url_decode64(to_string(data), ignore: :whitespace)
32 {:ok, sig} = Base.url_decode64(to_string(sig), ignore: :whitespace)
33 alg = to_string(alg)
34 encoding = to_string(encoding)
35 type = to_string(type)
36
37 [data, type, encoding, alg, sig]
38 end
39
40 def fetch_magic_key(salmon) do
41 with [data, _, _, _, _] <- decode(salmon),
42 doc <- XML.parse_document(data),
43 uri when not is_nil(uri) <- XML.string_from_xpath("/entry/author[1]/uri", doc),
44 {:ok, public_key} <- User.get_public_key_for_ap_id(uri),
45 magic_key <- encode_key(public_key) do
46 {:ok, magic_key}
47 end
48 end
49
50 def decode_and_validate(magickey, salmon) do
51 [data, type, encoding, alg, sig] = decode(salmon)
52
53 signed_text =
54 [data, type, encoding, alg]
55 |> Enum.map(&Base.url_encode64/1)
56 |> Enum.join(".")
57
58 key = decode_key(magickey)
59
60 verify = :public_key.verify(signed_text, :sha256, sig, key)
61
62 if verify do
63 {:ok, data}
64 else
65 :error
66 end
67 end
68
69 def decode_key("RSA." <> magickey) do
70 make_integer = fn bin ->
71 list = :erlang.binary_to_list(bin)
72 Enum.reduce(list, 0, fn el, acc -> acc <<< 8 ||| el end)
73 end
74
75 [modulus, exponent] =
76 magickey
77 |> String.split(".")
78 |> Enum.map(fn n -> Base.url_decode64!(n, padding: false) end)
79 |> Enum.map(make_integer)
80
81 {:RSAPublicKey, modulus, exponent}
82 end
83
84 def encode_key({:RSAPublicKey, modulus, exponent}) do
85 modulus_enc = :binary.encode_unsigned(modulus) |> Base.url_encode64()
86 exponent_enc = :binary.encode_unsigned(exponent) |> Base.url_encode64()
87
88 "RSA.#{modulus_enc}.#{exponent_enc}"
89 end
90
91 # Native generation of RSA keys is only available since OTP 20+ and in default build conditions
92 # We try at compile time to generate natively an RSA key otherwise we fallback on the old way.
93 try do
94 _ = :public_key.generate_key({:rsa, 2048, 65_537})
95
96 def generate_rsa_pem do
97 key = :public_key.generate_key({:rsa, 2048, 65_537})
98 entry = :public_key.pem_entry_encode(:RSAPrivateKey, key)
99 pem = :public_key.pem_encode([entry]) |> String.trim_trailing()
100 {:ok, pem}
101 end
102 rescue
103 _ ->
104 def generate_rsa_pem do
105 port = Port.open({:spawn, "openssl genrsa"}, [:binary])
106
107 {:ok, pem} =
108 receive do
109 {^port, {:data, pem}} -> {:ok, pem}
110 end
111
112 Port.close(port)
113
114 if Regex.match?(~r/RSA PRIVATE KEY/, pem) do
115 {:ok, pem}
116 else
117 :error
118 end
119 end
120 end
121
122 def keys_from_pem(pem) do
123 [private_key_code] = :public_key.pem_decode(pem)
124 private_key = :public_key.pem_entry_decode(private_key_code)
125 {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key
126 public_key = {:RSAPublicKey, modulus, exponent}
127 {:ok, private_key, public_key}
128 end
129
130 def encode(private_key, doc) do
131 type = "application/atom+xml"
132 encoding = "base64url"
133 alg = "RSA-SHA256"
134
135 signed_text =
136 [doc, type, encoding, alg]
137 |> Enum.map(&Base.url_encode64/1)
138 |> Enum.join(".")
139
140 signature =
141 signed_text
142 |> :public_key.sign(:sha256, private_key)
143 |> to_string
144 |> Base.url_encode64()
145
146 doc_base64 =
147 doc
148 |> Base.url_encode64()
149
150 # Don't need proper xml building, these strings are safe to leave unescaped
151 salmon = """
152 <?xml version="1.0" encoding="UTF-8"?>
153 <me:env xmlns:me="http://salmon-protocol.org/ns/magic-env">
154 <me:data type="application/atom+xml">#{doc_base64}</me:data>
155 <me:encoding>#{encoding}</me:encoding>
156 <me:alg>#{alg}</me:alg>
157 <me:sig>#{signature}</me:sig>
158 </me:env>
159 """
160
161 {:ok, salmon}
162 end
163
164 def remote_users(%{data: %{"to" => to} = data}) do
165 to = to ++ (data["cc"] || [])
166
167 to
168 |> Enum.map(fn id -> User.get_cached_by_ap_id(id) end)
169 |> Enum.filter(fn user -> user && !user.local end)
170 end
171
172 @doc "Pushes an activity to remote account."
173 def publish_one(%{recipient: %{info: %{salmon: salmon}}} = params),
174 do: publish_one(Map.put(params, :recipient, salmon))
175
176 def publish_one(%{recipient: url, feed: feed} = params) when is_binary(url) do
177 with {:ok, %{status: code}} when code in 200..299 <-
178 @httpoison.post(
179 url,
180 feed,
181 [{"Content-Type", "application/magic-envelope+xml"}]
182 ) do
183 if !Map.has_key?(params, :unreachable_since) || params[:unreachable_since],
184 do: Instances.set_reachable(url)
185
186 Logger.debug(fn -> "Pushed to #{url}, code #{code}" end)
187 :ok
188 else
189 e ->
190 unless params[:unreachable_since], do: Instances.set_reachable(url)
191 Logger.debug(fn -> "Pushing Salmon to #{url} failed, #{inspect(e)}" end)
192 {:error, "Unreachable instance"}
193 end
194 end
195
196 def publish_one(_), do: :noop
197
198 @supported_activities [
199 "Create",
200 "Follow",
201 "Like",
202 "Announce",
203 "Undo",
204 "Delete"
205 ]
206
207 def is_representable?(%Activity{data: %{"type" => type}} = activity)
208 when type in @supported_activities,
209 do: Visibility.is_public?(activity)
210
211 def is_representable?(_), do: false
212
213 @doc """
214 Publishes an activity to remote accounts
215 """
216 @spec publish(User.t(), Pleroma.Activity.t()) :: none
217 def publish(user, activity)
218
219 def publish(%{info: %{keys: keys}} = user, %{data: %{"type" => type}} = activity)
220 when type in @supported_activities do
221 feed = ActivityRepresenter.to_simple_form(activity, user, true)
222
223 if feed do
224 feed =
225 ActivityRepresenter.wrap_with_entry(feed)
226 |> :xmerl.export_simple(:xmerl_xml)
227 |> to_string
228
229 {:ok, private, _} = keys_from_pem(keys)
230 {:ok, feed} = encode(private, feed)
231
232 remote_users = remote_users(activity)
233
234 salmon_urls = Enum.map(remote_users, & &1.info.salmon)
235 reachable_urls_metadata = Instances.filter_reachable(salmon_urls)
236 reachable_urls = Map.keys(reachable_urls_metadata)
237
238 remote_users
239 |> Enum.filter(&(&1.info.salmon in reachable_urls))
240 |> Enum.each(fn remote_user ->
241 Logger.debug(fn -> "Sending Salmon to #{remote_user.ap_id}" end)
242
243 Publisher.enqueue_one(__MODULE__, %{
244 recipient: remote_user,
245 feed: feed,
246 unreachable_since: reachable_urls_metadata[remote_user.info.salmon]
247 })
248 end)
249 end
250 end
251
252 def publish(%{id: id}, _), do: Logger.debug(fn -> "Keys missing for user #{id}" end)
253 end