diff --git a/conf/.env.example b/conf/.env.example index beaf140..34446e1 100644 --- a/conf/.env.example +++ b/conf/.env.example @@ -41,7 +41,9 @@ TEMPEST_BACKUP_S3_SECRET_ACCESS_KEY=replace-with-r2-secret-key # TEMPEST_BLOB_STORE=local # TEMPEST_BACKUP_STORE=local -# Optional PLC recovery material. +# Optional PLC rotation/recovery private key material. Set both in production and +# back them up outside the deploy platform. Generate each value with: +# mix run -e 'gen = fn gen -> key = :crypto.strong_rand_bytes(32); try do :crypto.generate_key(:ecdh, :secp256k1, key); "u" <> Base.url_encode64(key, padding: false) rescue _ -> gen.(gen) end end; IO.puts(gen.(gen))' # TEMPEST_PLC_ROTATION_KEY= # TEMPEST_PLC_RECOVERY_KEY= diff --git a/lib/tempest/identity/multikey.ex b/lib/tempest/identity/multikey.ex index a03bf12..e620971 100644 --- a/lib/tempest/identity/multikey.ex +++ b/lib/tempest/identity/multikey.ex @@ -7,6 +7,26 @@ defmodule Tempest.Identity.Multikey do @secp256k1_pub_multicodec <<0xE7, 0x01>> @secp256k1_p 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F + def encode_secp256k1_did_key(public_key) when is_binary(public_key) do + with {:ok, multikey} <- encode_secp256k1_public_key(public_key) do + {:ok, "did:key:" <> multikey} + end + end + + def encode_secp256k1_did_key!(public_key) do + case encode_secp256k1_did_key(public_key) do + {:ok, did_key} -> did_key + {:error, reason} -> raise ArgumentError, "invalid secp256k1 public key: #{inspect(reason)}" + end + end + + def encode_secp256k1_public_key(public_key) when is_binary(public_key) do + with {:ok, key} <- decode_or_normalize_public_key(public_key), + {:ok, compressed_key} <- compress_secp256k1_public_key(key) do + {:ok, "z" <> base58btc_encode(@secp256k1_pub_multicodec <> compressed_key)} + end + end + def decode_secp256k1_public_key(multibase, opts \\ []) def decode_secp256k1_public_key(multibase, opts) when is_binary(multibase) do @@ -24,6 +44,11 @@ defmodule Tempest.Identity.Multikey do def decode_secp256k1_public_key(_multibase, _opts), do: {:error, :invalid_public_key} + defp decode_or_normalize_public_key("did:key:" <> multibase), do: decode_multibase(multibase) + defp decode_or_normalize_public_key("u" <> _rest = multibase), do: decode_multibase(multibase) + defp decode_or_normalize_public_key("z" <> _rest = multibase), do: decode_multibase(multibase) + defp decode_or_normalize_public_key(public_key), do: normalize_secp256k1_public_key(public_key) + defp decode_multibase("u" <> encoded), do: Base.url_decode64(encoded, padding: false) defp decode_multibase("z" <> encoded) do @@ -42,6 +67,15 @@ defmodule Tempest.Identity.Multikey do defp normalize_secp256k1_public_key(<<3, _rest::binary-size(32)>> = public_key), do: {:ok, public_key} defp normalize_secp256k1_public_key(_key), do: {:error, :invalid_public_key} + defp compress_secp256k1_public_key(<> = public_key) when prefix in [2, 3] do + {:ok, public_key} + end + + defp compress_secp256k1_public_key(<<4, x::binary-size(32), y::binary-size(32)>>) do + prefix = if rem(:binary.decode_unsigned(y), 2) == 0, do: 2, else: 3 + {:ok, <>} + end + defp uncompress_secp256k1_public_key(<<4, _rest::binary-size(64)>> = public_key), do: {:ok, public_key} defp uncompress_secp256k1_public_key(<>) when prefix in [2, 3] do @@ -84,6 +118,28 @@ defmodule Tempest.Identity.Multikey do end end + defp base58btc_encode(bytes) when is_binary(bytes) do + leading_zero_count = + bytes + |> :binary.bin_to_list() + |> Enum.take_while(&(&1 == 0)) + |> length() + + encoded = + bytes + |> :binary.decode_unsigned() + |> do_base58btc_encode("") + + String.duplicate("1", leading_zero_count) <> encoded + end + + defp do_base58btc_encode(0, acc), do: acc + + defp do_base58btc_encode(value, acc) do + char = @base58btc_alphabet |> Enum.at(rem(value, 58)) |> then(&<<&1>>) + do_base58btc_encode(div(value, 58), char <> acc) + end + defp modular_pow(_base, 0, modulus), do: rem(1, modulus) defp modular_pow(base, exponent, modulus), do: modular_pow(rem(base, modulus), exponent, modulus, 1) diff --git a/lib/tempest/identity/plc_client.ex b/lib/tempest/identity/plc_client.ex index a4dcd8b..1a2e5c7 100644 --- a/lib/tempest/identity/plc_client.ex +++ b/lib/tempest/identity/plc_client.ex @@ -42,7 +42,7 @@ defmodule Tempest.Identity.PlcClient do case Req.post(opts) do {:ok, %{status: status}} when status in 200..299 -> - Logger.info("Published PLC operation", + Logger.info("Published PLC operation did=#{did} plc_url=#{url} plc_status=#{status}", did: did, plc_url: url, plc_status: status @@ -51,20 +51,25 @@ defmodule Tempest.Identity.PlcClient do :ok {:ok, %{status: status, body: body}} -> - Logger.error("PLC directory rejected operation", + response_body = response_body_snippet(body) + + Logger.error( + "PLC directory rejected operation did=#{did} plc_url=#{url} plc_status=#{status} plc_response_body=#{response_body}", did: did, plc_url: url, plc_status: status, - plc_response_body: response_body_snippet(body) + plc_response_body: response_body ) {:error, {:plc_status, status}} {:error, reason} -> - Logger.error("PLC directory request failed", + request_error = inspect(reason) + + Logger.error("PLC directory request failed did=#{did} plc_url=#{url} plc_request_error=#{request_error}", did: did, plc_url: url, - plc_request_error: inspect(reason) + plc_request_error: request_error ) {:error, {:plc_request_failed, reason}} diff --git a/lib/tempest/identity/plc_operation.ex b/lib/tempest/identity/plc_operation.ex index dbd30a5..98b03c8 100644 --- a/lib/tempest/identity/plc_operation.ex +++ b/lib/tempest/identity/plc_operation.ex @@ -4,7 +4,7 @@ defmodule Tempest.Identity.PlcOperation do """ alias Tempest.Accounts.Account - alias Tempest.Identity.KeyStore + alias Tempest.Identity.{KeyStore, Multikey} @secp256k1_order 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 @half_secp256k1_order div(@secp256k1_order, 2) @@ -16,7 +16,7 @@ defmodule Tempest.Identity.PlcOperation do "type" => "plc_operation", "prev" => Keyword.get(opts, :prev), "rotationKeys" => rotation_keys(), - "verificationMethods" => %{"atproto" => signing_key.public_key_multibase}, + "verificationMethods" => %{"atproto" => Multikey.encode_secp256k1_did_key!(signing_key.public_key_multibase)}, "alsoKnownAs" => ["at://#{account.handle}"], "services" => %{ "atproto_pds" => %{ @@ -101,7 +101,7 @@ defmodule Tempest.Identity.PlcOperation do defp public_did_key!(private_key_material) when is_binary(private_key_material) do private_key = decode_private_key!(private_key_material) {public_key, _private_key} = :crypto.generate_key(:ecdh, :secp256k1, private_key) - "did:key:" <> multibase64(public_key) + Multikey.encode_secp256k1_did_key!(public_key) end defp decode_private_key!("u" <> encoded), do: Base.url_decode64!(encoded, padding: false) diff --git a/test/tempest_web/xrpc/plc_identity_test.exs b/test/tempest_web/xrpc/plc_identity_test.exs index 876d598..f966f68 100644 --- a/test/tempest_web/xrpc/plc_identity_test.exs +++ b/test/tempest_web/xrpc/plc_identity_test.exs @@ -6,6 +6,7 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do alias Tempest.Accounts.Account alias Tempest.AdminAuth + alias Tempest.Identity.Multikey alias Tempest.Identity.PlcOperation alias Tempest.Identity.SigningKey alias Tempest.Repo @@ -56,14 +57,15 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do |> get(~p"/xrpc/com.atproto.identity.getRecommendedDidCredentials") response = json_response(conn, 200) + expected_signing_key = Multikey.encode_secp256k1_did_key!(signing_key.public_key_multibase) assert response["did"] == account["did"] assert response["handle"] == "plc-creds.test" - assert response["signingKey"] == signing_key.public_key_multibase - assert response["verificationMethods"] == %{"atproto" => signing_key.public_key_multibase} + assert response["signingKey"] == expected_signing_key + assert response["verificationMethods"] == %{"atproto" => expected_signing_key} assert [rotation_key] = response["rotationKeys"] - assert String.starts_with?(rotation_key, "did:key:u") - refute rotation_key == signing_key.public_key_multibase + assert String.starts_with?(rotation_key, "did:key:z") + refute rotation_key == expected_signing_key assert response["alsoKnownAs"] == ["at://plc-creds.test"] assert response["services"] == %{ @@ -106,7 +108,7 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do decoded = Jason.decode!(body) assert [rotation_key] = decoded["rotationKeys"] - assert String.starts_with?(rotation_key, "did:key:u") + assert String.starts_with?(rotation_key, "did:key:z") refute rotation_key == decoded["verificationMethods"]["atproto"] assert decoded["services"]["atproto_pds"]["endpoint"] == "http://localhost:4002" @@ -210,7 +212,7 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do assert submitted == signed_operation assert submitted["services"]["atproto_pds"]["endpoint"] == "http://localhost:4002" assert [rotation_key] = submitted["rotationKeys"] - assert String.starts_with?(rotation_key, "did:key:u") + assert String.starts_with?(rotation_key, "did:key:z") refute rotation_key == submitted["verificationMethods"]["atproto"] send_resp(req_conn, 200, Jason.encode!(%{"ok" => true})) @@ -643,7 +645,7 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do defp public_did_key(private_key) do {public_key, _private_key} = :crypto.generate_key(:ecdh, :secp256k1, private_key) - "did:key:" <> multibase64(public_key) + Multikey.encode_secp256k1_did_key!(public_key) end defp multibase64(key), do: "u" <> Base.url_encode64(key, padding: false)