diff --git a/config/runtime.exs b/config/runtime.exs index 511528e..5df4f82 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -42,6 +42,26 @@ if hosted_did_method = System.get_env("TEMPEST_HOSTED_DID_METHOD") do config :tempest, Tempest.Config, hosted_did_method: String.to_existing_atom(hosted_did_method) end +identity_runtime_config = [] + +identity_runtime_config = + if plc_rotation_key = System.get_env("TEMPEST_PLC_ROTATION_KEY") do + Keyword.put(identity_runtime_config, :plc_rotation_key, plc_rotation_key) + else + identity_runtime_config + end + +identity_runtime_config = + if plc_recovery_key = System.get_env("TEMPEST_PLC_RECOVERY_KEY") do + Keyword.put(identity_runtime_config, :plc_recovery_key, plc_recovery_key) + else + identity_runtime_config + end + +if identity_runtime_config != [] do + config :tempest, Tempest.Identity, identity_runtime_config +end + if blob_cdn_base_url = System.get_env("TEMPEST_BLOB_CDN_BASE_URL") do config :tempest, Tempest.Blobs, cdn_base_url: blob_cdn_base_url end diff --git a/docs/reference/account-migration.md b/docs/reference/account-migration.md index 31480ff..dbee22b 100644 --- a/docs/reference/account-migration.md +++ b/docs/reference/account-migration.md @@ -33,15 +33,14 @@ Supported now: `TEMPEST_PUBLIC_URL`. - Suppress public repo, record, blob, and sync reads for inactive accounts. -Known limitation: +### To-Do -- Full `did:plc` migration is not complete. The public PLC identity endpoints - remain planned: `com.atproto.identity.getRecommendedDidCredentials`, - `requestPlcOperationSignature`, `signPlcOperation`, and - `submitPlcOperation`. +- Full `did:plc` migration still needs black-box migration-out coverage, but the + public PLC identity endpoints are registered and locally tested against the PLC + client boundary. -Self-controlled `did:web` accounts are the currently practical bring-your-own -identity path because the operator can update the DID document directly. +Self-controlled `did:web` accounts remain the simplest bring-your-own identity +path because the operator can update the DID document directly. ## Migration-In Flow @@ -75,11 +74,7 @@ curl -X POST "$TEMPEST/xrpc/com.atproto.server.createAccount" \ The response should include: ```json -{ - "did": "did:example:...", - "active": false, - "status": "deactivated" -} +{ "did": "did:example:...", "active": false, "status": "deactivated" } ``` Import the CAR: @@ -110,8 +105,9 @@ activation. Update identity so the account DID document points `#atproto_pds` at `TEMPEST_PUBLIC_URL`. For `did:web`, update the hosted DID document. For -`did:plc`, this currently requires an external/manual PLC operation because -Tempest's public PLC operation endpoints are not implemented yet. +`did:plc`, use `getRecommendedDidCredentials`, `requestPlcOperationSignature`, +`signPlcOperation`, and `submitPlcOperation` to build, sign, and submit the PLC +operation through Tempest's PLC client boundary. Activate the account: @@ -136,9 +132,9 @@ Tempest fails closed during migration: - Missing referenced blobs keep `migrationReady=false`. - Activation fails when the DID document does not point at Tempest. -## PLC Work Required +## PLC Operation Flow -Production `did:plc` migration needs the public identity operation flow, not only +Production `did:plc` migration uses the public identity operation flow, not only repository import: - `com.atproto.identity.getRecommendedDidCredentials` must return the Tempest PDS @@ -151,8 +147,11 @@ repository import: - `com.atproto.identity.submitPlcOperation` must submit through the PLC client, preserve migration event ordering, and record success or failure. -These endpoints must deny app passwords and ordinary OAuth tokens unless a future -spec defines a high-assurance delegated scope. +These endpoints deny app passwords and ordinary OAuth tokens unless a future spec +defines a high-assurance delegated scope. Configure `TEMPEST_PLC_ROTATION_KEY` +with private rotation-key material; optionally configure `TEMPEST_PLC_RECOVERY_KEY` +for an operator recovery key. Tempest derives public `did:key` rotation keys from +that material and does not reuse repository signing keys as PLC rotation keys. ## Verification diff --git a/docs/reference/identity-troubleshooting.md b/docs/reference/identity-troubleshooting.md index 8488b92..6fc8844 100644 --- a/docs/reference/identity-troubleshooting.md +++ b/docs/reference/identity-troubleshooting.md @@ -1,6 +1,6 @@ --- title: Identity Troubleshooting -updated: 2026-05-31 +updated: 2026-06-13 --- Identity correctness means the local account row, DID document, handle @@ -30,8 +30,11 @@ For a hosted account, verify: ## Hosted DID modes `TEMPEST_HOSTED_DID_METHOD=plc` creates `did:plc` accounts. If PLC publishing is -enabled in config, Tempest submits a PLC operation through the PLC client -boundary. +enabled in config, Tempest fetches existing PLC state, then submits a PLC +operation through the PLC client boundary. Set `TEMPEST_PLC_ROTATION_KEY` to +private rotation-key material; set `TEMPEST_PLC_RECOVERY_KEY` when an additional +operator recovery key should be included. Public `did:key` rotation keys are +derived from that configured material, not from repository signing keys. `TEMPEST_HOSTED_DID_METHOD=web` creates a `did:web` identity for the configured hostname. This is intended for single-user/self-hosted setups where the operator diff --git a/docs/reference/pds-compatibility.md b/docs/reference/pds-compatibility.md index e83a9ed..5ab5c11 100644 --- a/docs/reference/pds-compatibility.md +++ b/docs/reference/pds-compatibility.md @@ -1,6 +1,6 @@ --- title: PDS Compatibility Matrix -updated: 2026-06-03 +updated: 2026-06-13 --- Tempest tracks compatibility against the AT Protocol specs, the official @@ -48,14 +48,14 @@ deferred endpoint is out of scope for the current target profile ## Identity -| Method | Status | Required coverage | -| --------------------------------------------------- | ----------: | ------------------------------------------------------------- | -| `com.atproto.identity.resolveHandle` | implemented | local and remote resolution smoke tests | -| `com.atproto.identity.updateHandle` | implemented | auth and DID ownership checks | -| `com.atproto.identity.getRecommendedDidCredentials` | planned | migration DID credential endpoint plus Lexicon bundle refresh | -| `com.atproto.identity.requestPlcOperationSignature` | planned | PLC boundary endpoint plus Lexicon bundle refresh | -| `com.atproto.identity.signPlcOperation` | planned | PLC boundary endpoint plus Lexicon bundle refresh | -| `com.atproto.identity.submitPlcOperation` | planned | PLC boundary endpoint plus Lexicon bundle refresh | +| Method | Status | Required coverage | +| --------------------------------------------------- | ----------: | ------------------------------------------------------------ | +| `com.atproto.identity.resolveHandle` | implemented | local and remote resolution smoke tests | +| `com.atproto.identity.updateHandle` | implemented | auth and DID ownership checks | +| `com.atproto.identity.getRecommendedDidCredentials` | implemented | credential shape, auth, dedicated PLC rotation key tests | +| `com.atproto.identity.requestPlcOperationSignature` | implemented | strong reauth, single-use token, audit-log tests | +| `com.atproto.identity.signPlcOperation` | implemented | token consumption, fake PLC prev fetch, validation tests | +| `com.atproto.identity.submitPlcOperation` | implemented | fake PLC submission, failure, idempotency, event-order tests | ## Repository @@ -86,15 +86,15 @@ deferred endpoint is out of scope for the current target profile | `com.atproto.sync.getBlob` | implemented | local and S3/R2 adapter tests | | `com.atproto.sync.requestCrawl` | implemented | local test plus deployed relay check | | `com.atproto.sync.subscribeRepos` | implemented | WebSocket backfill/live smoke test | -| `com.atproto.sync.notifyOfUpdate` | deferred | not required for current target profile | +| `com.atproto.sync.notifyOfUpdate` | planned | not required for current target profile | ## AppView and helpers -| Method | Status | Required coverage | -| ------------------------------- | ----------: | ------------------------------ | -| `app.bsky.actor.getPreferences` | implemented | preference smoke test | -| `app.bsky.actor.putPreferences` | implemented | preference smoke test | -| Unknown `app.bsky.*` methods | implemented | proxy/fallback policy tests | +| Method | Status | Required coverage | +| ------------------------------- | ----------: | --------------------------- | +| `app.bsky.actor.getPreferences` | implemented | preference smoke test | +| `app.bsky.actor.putPreferences` | implemented | preference smoke test | +| Unknown `app.bsky.*` methods | implemented | proxy/fallback policy tests | ## AppView proxy/fallback policy diff --git a/docs/tasks/03-identity-handles.md b/docs/tasks/03-identity-handles.md index 42d0faf..bc23884 100644 --- a/docs/tasks/03-identity-handles.md +++ b/docs/tasks/03-identity-handles.md @@ -34,13 +34,13 @@ remain follow-up work: that reject service-diverting or unrecoverable PLC operations. - [x] Add `com.atproto.identity.submitPlcOperation` with fake PLC submission, failure, idempotency, and migration event-ordering tests. -- [ ] Create, sign, and submit PLC operations correctly end-to-end. -- [ ] Fetch existing PLC state before building update operations. -- [ ] Introduce a stable `TEMPEST_PLC_ROTATION_KEY` configuration path. -- [ ] Support an optional account/operator recovery key. -- [ ] Derive public `did:key` rotation keys from configured/private key material. -- [ ] Ensure repository signing keys are never used as PLC rotation keys. -- [ ] Verify `getRecommendedDidCredentials` returns the correct recommended DID +- [x] Create, sign, and submit PLC operations correctly end-to-end. +- [x] Fetch existing PLC state before building update operations. +- [x] Introduce a stable `TEMPEST_PLC_ROTATION_KEY` configuration path. +- [x] Support an optional account/operator recovery key. +- [x] Derive public `did:key` rotation keys from configured/private key material. +- [x] Ensure repository signing keys are never used as PLC rotation keys. +- [x] Verify `getRecommendedDidCredentials` returns the correct recommended DID credential shape once dedicated rotation-key material exists. -- [ ] Refresh bundled Lexicons and the PDS compatibility matrix when the handlers +- [x] Refresh bundled Lexicons and the PDS compatibility matrix when the handlers are registered. diff --git a/lib/tempest/identity.ex b/lib/tempest/identity.ex index 00ef795..0402e54 100644 --- a/lib/tempest/identity.ex +++ b/lib/tempest/identity.ex @@ -55,8 +55,10 @@ defmodule Tempest.Identity do def sign_plc_operation(%Account{} = account, token, operation_fields) when is_map(operation_fields) do operation = account - |> PlcOperation.for_account() - |> Map.merge(Map.take(operation_fields, ["rotationKeys", "alsoKnownAs", "verificationMethods", "services"])) + |> PlcOperation.for_account(prev: current_plc_prev(account)) + |> Map.merge( + Map.take(operation_fields, ["rotationKeys", "alsoKnownAs", "verificationMethods", "services", "prev"]) + ) with :ok <- Correctness.check_local(account), {:ok, _token_record} <- Tempest.Security.consume_plc_operation_token(account, token), @@ -88,7 +90,7 @@ defmodule Tempest.Identity do def publish_plc_operation(%Account{} = account) do with true <- String.starts_with?(account.did, "did:plc:"), :ok <- Correctness.check_local(account), - operation = PlcOperation.for_account(account), + operation = PlcOperation.for_account(account, prev: current_plc_prev(account)), :ok <- PlcClient.publish_operation(account.did, operation) do :ok else @@ -155,6 +157,29 @@ defmodule Tempest.Identity do def update_handle(_auth_context, _handle), do: {:error, :invalid_handle_syntax} + defp current_plc_prev(%Account{did: "did:plc:" <> _} = account) do + if fetch_existing_plc_state?() do + case PlcClient.fetch_state(account.did) do + {:ok, %{"cid" => cid}} when is_binary(cid) and cid != "" -> cid + {:ok, %{"prev" => prev}} when is_binary(prev) and prev != "" -> prev + {:ok, _state} -> nil + {:error, _reason} -> nil + end + end + end + + defp current_plc_prev(%Account{}), do: nil + + defp fetch_existing_plc_state? do + config = Application.get_env(:tempest, Tempest.Identity, []) + + if Keyword.has_key?(config, :fetch_existing_plc_state) do + Keyword.fetch!(config, :fetch_existing_plc_state) + else + Application.get_env(:tempest, :env, :prod) != :test + end + end + defp resolve_did_document(did) do with :ok <- Validators.validate_did(did), {:ok, url} <- did_document_url(did), diff --git a/lib/tempest/identity/plc_client.ex b/lib/tempest/identity/plc_client.ex index 318fb95..1c7fac6 100644 --- a/lib/tempest/identity/plc_client.ex +++ b/lib/tempest/identity/plc_client.ex @@ -5,6 +5,22 @@ defmodule Tempest.Identity.PlcClient do The client is configurable so tests can use Req.Test or another fake service. """ + def fetch_state(did) when is_binary(did) do + url = plc_directory_url() <> "/" <> URI.encode(did) + + opts = + [url: url, retry: false] + |> Keyword.merge(identity_config(:http_req_options) || []) + + case Req.get(opts) do + {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body} + {:ok, %{status: 200, body: body}} when is_binary(body) -> Jason.decode(body) + {:ok, %{status: 404}} -> {:ok, nil} + {:ok, %{status: status}} -> {:error, {:plc_status, status}} + {:error, reason} -> {:error, {:plc_request_failed, reason}} + end + end + def publish_operation(did, operation) when is_binary(did) and is_map(operation) do url = plc_directory_url() <> "/" <> URI.encode(did) diff --git a/lib/tempest/identity/plc_operation.ex b/lib/tempest/identity/plc_operation.ex index feeecc4..dbd30a5 100644 --- a/lib/tempest/identity/plc_operation.ex +++ b/lib/tempest/identity/plc_operation.ex @@ -9,13 +9,13 @@ defmodule Tempest.Identity.PlcOperation do @secp256k1_order 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 @half_secp256k1_order div(@secp256k1_order, 2) - def for_account(%Account{} = account) do + def for_account(%Account{} = account, opts \\ []) do signing_key = KeyStore.active_key_for_account(account) %{ "type" => "plc_operation", - "prev" => nil, - "rotationKeys" => [signing_key.public_key_multibase], + "prev" => Keyword.get(opts, :prev), + "rotationKeys" => rotation_keys(), "verificationMethods" => %{"atproto" => signing_key.public_key_multibase}, "alsoKnownAs" => ["at://#{account.handle}"], "services" => %{ @@ -82,6 +82,43 @@ defmodule Tempest.Identity.PlcOperation do def validate_for_account(%Account{}, _operation), do: {:error, :invalid_operation} + defp rotation_keys do + configured_keys = + [:plc_rotation_key, :plc_recovery_key] + |> Enum.map(&identity_config/1) + |> Enum.reject(&is_nil/1) + |> Enum.map(&public_did_key!/1) + + if configured_keys == [] do + [public_did_key!(fallback_rotation_key_material())] + else + Enum.uniq(configured_keys) + end + end + + defp public_did_key!("did:key:" <> _rest = did_key), do: did_key + + 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) + end + + defp decode_private_key!("u" <> encoded), do: Base.url_decode64!(encoded, padding: false) + defp decode_private_key!(encoded), do: Base.url_decode64!(encoded, padding: false) + + defp fallback_rotation_key_material do + secret_key_base = + :tempest + |> Application.fetch_env!(TempestWeb.Endpoint) + |> Keyword.fetch!(:secret_key_base) + + :crypto.hash(:sha256, "Tempest.Identity.PlcOperation.rotation_key:" <> secret_key_base) + |> multibase64() + end + + defp multibase64(key), do: "u" <> Base.url_encode64(key, padding: false) + defp pds_service_endpoint do %{scheme: scheme, host: host, port: port} = URI.parse(Tempest.Config.load!().public_url) default_port? = (scheme == "http" and port in [nil, 80]) or (scheme == "https" and port in [nil, 443]) @@ -92,8 +129,10 @@ defmodule Tempest.Identity.PlcOperation do defp recoverable?(operation, recommended) do operation_keys = Map.get(operation, "rotationKeys") recommended_keys = Map.fetch!(recommended, "rotationKeys") + signing_key = get_in(recommended, ["verificationMethods", "atproto"]) - is_list(operation_keys) and operation_keys != [] and Enum.any?(recommended_keys, &(&1 in operation_keys)) + is_list(operation_keys) and operation_keys != [] and signing_key not in operation_keys and + Enum.any?(recommended_keys, &(&1 in operation_keys)) end defp active_key(account) do @@ -154,6 +193,12 @@ defmodule Tempest.Identity.PlcOperation do defp take_der_integer(_bytes), do: {:error, :invalid_signature} + defp identity_config(key) do + :tempest + |> Application.get_env(Tempest.Identity, []) + |> Keyword.get(key) + end + defp fixed_uint(integer), do: integer |> :binary.encode_unsigned() |> pad_uint() defp pad_uint(bytes) when byte_size(bytes) < 32 do diff --git a/test/tempest_web/xrpc/plc_identity_test.exs b/test/tempest_web/xrpc/plc_identity_test.exs index 555ad86..51974c6 100644 --- a/test/tempest_web/xrpc/plc_identity_test.exs +++ b/test/tempest_web/xrpc/plc_identity_test.exs @@ -50,7 +50,9 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do 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["rotationKeys"] == [signing_key.public_key_multibase] + assert [rotation_key] = response["rotationKeys"] + assert String.starts_with?(rotation_key, "did:key:u") + refute rotation_key == signing_key.public_key_multibase assert response["alsoKnownAs"] == ["at://plc-creds.test"] assert response["services"] == %{ @@ -72,7 +74,9 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do {:ok, body, req_conn} = Plug.Conn.read_body(req_conn) decoded = Jason.decode!(body) - assert decoded["rotationKeys"] == [decoded["verificationMethods"]["atproto"]] + assert [rotation_key] = decoded["rotationKeys"] + assert String.starts_with?(rotation_key, "did:key:u") + refute rotation_key == decoded["verificationMethods"]["atproto"] assert decoded["services"]["atproto_pds"]["endpoint"] == "http://localhost:4002" send_resp(req_conn, 200, Jason.encode!(%{"ok" => true})) @@ -87,7 +91,53 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do |> get(~p"/xrpc/com.atproto.identity.getRecommendedDidCredentials") response = json_response(conn, 200) - assert response["verificationMethods"]["atproto"] in response["rotationKeys"] + refute response["verificationMethods"]["atproto"] in response["rotationKeys"] + end + + test "getRecommendedDidCredentials derives rotation keys from configured private material", %{conn: conn} do + private_key = :crypto.strong_rand_bytes(32) + expected_rotation_key = public_did_key(private_key) + Application.put_env(:tempest, Tempest.Identity, plc_rotation_key: multibase64(private_key)) + + account = create_account!(conn, "plc-configured-key.test", "plc-configured-key@example.com") + + conn = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> get(~p"/xrpc/com.atproto.identity.getRecommendedDidCredentials") + + response = json_response(conn, 200) + assert response["rotationKeys"] == [expected_rotation_key] + refute response["signingKey"] in response["rotationKeys"] + end + + test "signPlcOperation fetches existing PLC state before building update operation", %{conn: conn} do + put_identity_test_config(fetch_existing_plc_state: true) + + Req.Test.expect(__MODULE__, 3, fn req_conn -> + case req_conn.method do + "POST" -> + send_resp(req_conn, 200, Jason.encode!(%{"ok" => true})) + + "GET" -> + send_resp(req_conn, 200, Jason.encode!(%{"cid" => "bafy-old-plc-op"})) + end + end) + + account = create_account!(conn, "plc-prev.test", "plc-prev@example.com") + stored_account = Repo.get_by!(Account, did: account["did"]) + operation = PlcOperation.for_account(stored_account, prev: "bafy-old-plc-op") + token = request_plc_token!(conn, account["accessJwt"]) + + conn = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.identity.signPlcOperation", sign_params(operation, token)) + + assert %{"operation" => %{"prev" => "bafy-old-plc-op"}} = json_response(conn, 200) end test "requestPlcOperationSignature requires bearer auth and JSON error shape", %{conn: conn} do @@ -349,18 +399,29 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do defp sign_params(operation, token) do operation - |> Map.take(["rotationKeys", "alsoKnownAs", "verificationMethods", "services"]) + |> Map.take(["rotationKeys", "alsoKnownAs", "verificationMethods", "services", "prev"]) |> Map.put("token", token) end - defp put_identity_test_config do - Application.put_env(:tempest, Tempest.Identity, - plc_publish_enabled: true, - plc_directory_url: "https://plc.test", - http_req_options: [plug: {Req.Test, __MODULE__}] + defp put_identity_test_config(extra \\ []) do + Application.put_env( + :tempest, + Tempest.Identity, + [ + plc_publish_enabled: true, + plc_directory_url: "https://plc.test", + http_req_options: [plug: {Req.Test, __MODULE__}] + ] ++ extra ) end + defp public_did_key(private_key) do + {public_key, _private_key} = :crypto.generate_key(:ecdh, :secp256k1, private_key) + "did:key:" <> multibase64(public_key) + end + + defp multibase64(key), do: "u" <> Base.url_encode64(key, padding: false) + defp audit_event_count(%Account{} = account, event_type) do SecurityEvent |> where([event], event.account_id == ^account.id and event.event_type == ^event_type)