defmodule ActivityPub.Headers do alias ActivityPub.Headers.SignatureSplitter @http_date_format "%a, %d %b %Y %H:%M:%S GMT" @signing_headers [ "(request-target)", "host", "date", "digest" ] def signing_headers(method, url, body, actor_url, private_key) do url = case url do url when is_struct(url, URI) -> url url when is_binary(url) -> URI.parse(url) end private_key = case private_key do "-----BEGIN" <> _ = key_pem -> key_pem |> :public_key.pem_decode() |> hd() |> :public_key.pem_entry_decode() private_key -> private_key end method = String.downcase("#{method}") date = DateTime.utc_now() |> Calendar.strftime(@http_date_format) host = url.host digest = :crypto.hash(:sha256, body) digest_header = "SHA-256=#{Base.encode64(digest)}" headers = [ {"host", host}, {"date", date}, {"digest", digest_header} ] to_sign = signing_text(@signing_headers, [request_target_pseudoheader(method, url.path) | headers]) signature = to_sign |> :public_key.sign(:sha256, private_key) |> Base.encode64() signature_header = ~s|keyId="#{actor_url}#main-key",headers="(request-target) host date digest",signature="#{signature}"| [{"signature", signature_header} | headers] end def verify(method, path, headers, body, actor_fetcher \\ &fetch_actor_key/1) do {_key, signature_header} = Enum.find(headers, fn {key, _} -> key == "signature" end) signature_kv = SignatureSplitter.split(signature_header) key_id = find_value(signature_kv, "keyId") signature = signature_kv |> find_value("signature") |> Base.decode64!() signing_text_headers = signature_kv |> find_value("headers") |> String.split(" ") digest = :crypto.hash(:sha256, body) expected_digest_header = "SHA-256=#{Base.encode64(digest)}" actual_digest_header = find_value(headers, "digest") if expected_digest_header != actual_digest_header do {:error, :digest} else to_verify = signing_text(signing_text_headers, [request_target_pseudoheader(method, path) | headers]) case actor_fetcher.(key_id) do {:ok, public_key} -> :public_key.verify(to_verify, :sha256, signature, public_key) error -> error end end end def request_target_pseudoheader(method, path) do formatted_method = String.downcase("#{method}") {"(request-target)", "#{formatted_method} #{path}"} end def signing_text(signing_text_headers, header_pairs) do signing_text_headers |> Enum.map_join("", fn header_name -> value = find_value(header_pairs, header_name) "#{header_name}: #{value}\n" end) |> String.trim() end def split_signature_header(signature_header) do signature_header |> String.split(",") |> Enum.map(fn pair_string -> [key, value] = String.split(pair_string, "=") value = String.trim(value, "\"") {key, value} end) end defp find_value(headers, key) do key = String.downcase(key) Enum.find_value(headers, fn {key_candidate, value} -> String.downcase(key_candidate) == String.downcase(key) && value end) end def fetch_actor_key(key_id) do case ActivityPub.fetch_actor(key_id) do {:ok, body} -> key_map = body["publicKey"] if key_map["id"] == key_id do public_key = :public_key.pem_decode(key_map["publicKeyPem"]) |> hd() |> :public_key.pem_entry_decode() {:ok, public_key} else {:error, :id_mismatch} end error -> error end end end