From 41c207e1db29147a91a4b7e208db56f90f3d5e89 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 13 Jun 2026 17:20:58 -0500 Subject: [PATCH] docs: PLC migration CLI steps and docs --- docs/reference/account-migration.md | 59 ++++++++++ docs/reference/tokens.md | 56 +++++++++- lib/tempest_web/plugs/xrpc_auth.ex | 4 + scripts/src/tempest_py/main.py | 116 ++++++++++++++++++++ test/tempest_web/xrpc/plc_identity_test.exs | 22 ++++ 5 files changed, 255 insertions(+), 2 deletions(-) diff --git a/docs/reference/account-migration.md b/docs/reference/account-migration.md index df96a6d..32ba70e 100644 --- a/docs/reference/account-migration.md +++ b/docs/reference/account-migration.md @@ -58,6 +58,11 @@ export EMAIL="operator@example.com" read -s OLD_PASSWORD read -s TEMPEST_PASSWORD +# Optional, only if the source PDS requires an auth-factor token/code during +# createSession. +read -s OLD_AUTH_FACTOR_TOKEN +export OLD_AUTH_FACTOR_TOKEN + UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest ``` @@ -75,6 +80,11 @@ UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest import-repo UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest status UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest missing-blobs UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest upload-missing-blobs +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-recommended +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-request-token +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-sign +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-submit +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest activate ``` The same project also exposes the admin-token Argon2 helper as @@ -151,8 +161,57 @@ Update identity so the account DID document points `#atproto_pds` at `signPlcOperation`, and `submitPlcOperation` to build, sign, and submit the PLC operation through Tempest's PLC client boundary. +For the current `did:plc` migration, use the CLI helpers: + +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest refresh-session +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-recommended +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest login-source +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-request-token +``` + +`plc-request-token` writes `.sandbox/plc_token.json` when the source PDS returns +a token directly. Some PDS implementations email a one-time code instead; in +that case export it before signing: + +```bash +export PLC_TOKEN="code-from-email" +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-sign +``` + +If `plc-request-token` returns `Bad token scope`, refresh the source session with +the main account password rather than an app password. If the source PDS requires +an auth-factor token/code for high-risk account operations, set +`OLD_AUTH_FACTOR_TOKEN` and rerun `login-source`, then rerun `plc-request-token`. + +Before submitting, inspect the signed operation. The PDS service endpoint must +be Tempest: + +```bash +jq '.operation.services.atproto_pds.endpoint' .sandbox/plc_signed_operation.json +``` + +Then submit the signed PLC operation through Tempest: + +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-submit +curl -fsS "https://plc.directory/$DID" | jq '.service' +``` + +The resolved DID document should include `#atproto_pds` with +`serviceEndpoint` equal to `https://tempest.desertthunder.dev` for the current +deployment. + Activate the account: +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest refresh-session +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest activate +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest status +``` + +The equivalent curl call is: + ```bash curl -X POST "$TEMPEST/xrpc/com.atproto.server.activateAccount" \ -H "Authorization: Bearer $TEMPEST_ACCESS" \ diff --git a/docs/reference/tokens.md b/docs/reference/tokens.md index 8b1d6bb..c395427 100644 --- a/docs/reference/tokens.md +++ b/docs/reference/tokens.md @@ -18,6 +18,9 @@ Common tokens in deployment and migration: - account `refreshJwt`: refresh token returned by session creation and refresh. - `serviceAuth`: scoped proof from one PDS to another, returned by `com.atproto.server.getServiceAuth`. +- PLC operation token/code: short-lived one-time authorization for + `com.atproto.identity.signPlcOperation`, requested from the current PDS during + `did:plc` identity migration. - app password: Bluesky-compatible account password substitute for client and bot login. Prefer this over the main account password for migration commands when the source PDS accepts it. @@ -37,6 +40,18 @@ export TEMPEST_SERVICE_DID="did:web:tempest.desertthunder.dev" read -s OLD_PASSWORD ``` +Use the main account password for PLC operation signing. App passwords can be +useful for ordinary source-PDS access, but they may produce a session that cannot +request a PLC operation signature. + +If the source PDS asks for an auth-factor token/code during login, pass it to +the CLI as `OLD_AUTH_FACTOR_TOKEN`: + +```bash +read -s OLD_AUTH_FACTOR_TOKEN +export OLD_AUTH_FACTOR_TOKEN +``` + The migration CLI reads the same environment variables as the curl examples and writes the same artifacts: @@ -126,16 +141,53 @@ The `ar` and `arg2` aliases run the same helper: UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest ar --only-hash ``` +## PLC Operation Token + +After repo import and blob upload, the `did:plc` document must be updated so +`#atproto_pds` points at Tempest. The source PDS signs that PLC operation after +issuing a short-lived token or emailing a one-time code: + +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-recommended +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-request-token +``` + +If `.sandbox/plc_token.json` contains a `token`, the CLI will read it. If the +source PDS emails a code instead, keep it out of shell history when possible and +export it only for the signing step: + +```bash +read -s PLC_TOKEN +export PLC_TOKEN +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest plc-sign +``` + +The signed operation is written to `.sandbox/plc_signed_operation.json`. +Inspect its service endpoint before submitting it: + +```bash +jq '.operation.services.atproto_pds.endpoint' .sandbox/plc_signed_operation.json +``` + +For this deployment the value must be `https://tempest.desertthunder.dev`. + +If `plc-request-token` returns `Bad token scope`, the source session is not +authorized for PLC signing. Re-run `login-source` with the main account password +and any required `OLD_AUTH_FACTOR_TOKEN`, then retry `plc-request-token`. + ## Safety Notes - Do not commit `.sandbox/old_session.json`, - `.sandbox/service_auth_create_account.json`, or shell history containing raw - tokens. + `.sandbox/service_auth_create_account.json`, + `.sandbox/plc_token.json`, `.sandbox/plc_signed_operation.json`, or shell + history containing raw tokens. - Prefer app passwords over the main account password for source-PDS session creation. - Revoke or rotate the app password after migration. - Treat `serviceAuth` as short-lived migration material. Regenerate it if the migration attempt is delayed. +- Treat PLC operation tokens/codes as single-use, short-lived migration + material. Regenerate the token/code if signing fails or the token expires. - Treat Tempest `accessJwt` as short-lived. If migration commands return `Bearer token is invalid` or `Bearer token is expired`, refresh the saved Tempest session: diff --git a/lib/tempest_web/plugs/xrpc_auth.ex b/lib/tempest_web/plugs/xrpc_auth.ex index 88fe63e..dfa902b 100644 --- a/lib/tempest_web/plugs/xrpc_auth.ex +++ b/lib/tempest_web/plugs/xrpc_auth.ex @@ -81,6 +81,10 @@ defmodule TempestWeb.Plugs.XrpcAuth do "com.atproto.repo.importRepo", "com.atproto.repo.listMissingBlobs", "com.atproto.repo.uploadBlob", + "com.atproto.identity.getRecommendedDidCredentials", + "com.atproto.identity.requestPlcOperationSignature", + "com.atproto.identity.signPlcOperation", + "com.atproto.identity.submitPlcOperation", "com.atproto.server.activateAccount", "com.atproto.server.deactivateAccount", "com.atproto.server.requestAccountDelete", diff --git a/scripts/src/tempest_py/main.py b/scripts/src/tempest_py/main.py index 1aca0b6..2ad1828 100644 --- a/scripts/src/tempest_py/main.py +++ b/scripts/src/tempest_py/main.py @@ -45,6 +45,11 @@ class Command(StrEnum): STATUS = "status" MISSING_BLOBS = "missing-blobs" UPLOAD_MISSING_BLOBS = "upload-missing-blobs" + PLC_RECOMMENDED = "plc-recommended" + PLC_REQUEST_TOKEN = "plc-request-token" + PLC_SIGN = "plc-sign" + PLC_SUBMIT = "plc-submit" + ACTIVATE = "activate" @dataclass(frozen=True) @@ -66,6 +71,11 @@ class Settings: import_repo_path: Path status_path: Path missing_blobs_path: Path + plc_recommended_path: Path + plc_token_path: Path + plc_signed_path: Path + plc_submit_path: Path + activate_path: Path def env(name: str, default: str | None = None) -> str | None: @@ -105,6 +115,11 @@ def settings_from_env(args: argparse.Namespace) -> Settings: import_repo_path=path_from_env("TEMPEST_IMPORT_REPO_JSON", artifact_dir / "tempest_import_repo.json"), status_path=path_from_env("TEMPEST_STATUS_JSON", artifact_dir / "tempest_account_status.json"), missing_blobs_path=path_from_env("TEMPEST_MISSING_BLOBS_JSON", artifact_dir / "tempest_missing_blobs.json"), + plc_recommended_path=path_from_env("PLC_RECOMMENDED_JSON", artifact_dir / "plc_recommended.json"), + plc_token_path=path_from_env("PLC_TOKEN_JSON", artifact_dir / "plc_token.json"), + plc_signed_path=path_from_env("PLC_SIGNED_OPERATION_JSON", artifact_dir / "plc_signed_operation.json"), + plc_submit_path=path_from_env("PLC_SUBMIT_JSON", artifact_dir / "plc_submit.json"), + activate_path=path_from_env("TEMPEST_ACTIVATE_JSON", artifact_dir / "tempest_activate_account.json"), ) @@ -253,11 +268,26 @@ def tempest_refresh_token(settings: Settings) -> str: return token +def plc_operation_token(settings: Settings) -> str: + explicit = env("PLC_TOKEN") + if explicit: + return explicit + + data = read_json(settings.plc_token_path) + token = data.get("token") + if not isinstance(token, str) or not token: + raise CliError(f"{settings.plc_token_path} does not contain token; set PLC_TOKEN from the emailed code/token if needed") + return token + + def login_source(settings: Settings) -> None: step("source session") password = require_env(settings, "OLD_PASSWORD", settings.old_password) url = f"{settings.old_pds}/xrpc/com.atproto.server.createSession" payload = {"identifier": settings.handle, "password": password} + auth_factor_token = env("OLD_AUTH_FACTOR_TOKEN") + if auth_factor_token: + payload["authFactorToken"] = auth_factor_token status, _headers, raw = request("POST", url, json_body=payload) data = expect_json(status, raw, url) write_json(settings.old_session_path, data) @@ -435,6 +465,82 @@ def upload_missing_blobs(settings: Settings) -> None: print_json_summary(f"uploaded cid={cid}", result) +def plc_recommended(settings: Settings) -> None: + step("recommended PLC credentials from Tempest") + url = f"{settings.tempest}/xrpc/com.atproto.identity.getRecommendedDidCredentials" + status, _headers, raw = request("GET", url, headers=bearer(tempest_access_token(settings))) + data = expect_json(status, raw, url) + write_json(settings.plc_recommended_path, data) + print_json_summary("saved recommended PLC credentials", data) + log(f"wrote {settings.plc_recommended_path}") + + +def plc_request_token(settings: Settings) -> None: + step("request PLC operation token from old PDS") + url = f"{settings.old_pds}/xrpc/com.atproto.identity.requestPlcOperationSignature" + status, _headers, raw = request("POST", url, headers=bearer(access_from_session(settings))) + + if status == 400 and settings.old_password: + status, _headers, raw = request( + "POST", + url, + headers=bearer(access_from_session(settings)), + json_body={"password": settings.old_password}, + ) + + data = expect_json(status, raw, url) + write_json(settings.plc_token_path, data) + print_json_summary("saved PLC operation token response", data) + log(f"wrote {settings.plc_token_path}") + if "token" not in data: + log("No token field was returned. If the old PDS emails a token/code, export it as PLC_TOKEN before plc-sign.") + + +def plc_sign(settings: Settings) -> None: + step("sign PLC operation on old PDS") + recommended = read_json(settings.plc_recommended_path) + payload = { + key: recommended[key] + for key in ("rotationKeys", "alsoKnownAs", "verificationMethods", "services") + if key in recommended + } + payload["token"] = plc_operation_token(settings) + + url = f"{settings.old_pds}/xrpc/com.atproto.identity.signPlcOperation" + status, _headers, raw = request("POST", url, headers=bearer(access_from_session(settings)), json_body=payload) + data = expect_json(status, raw, url) + write_json(settings.plc_signed_path, data) + operation = data.get("operation") if isinstance(data.get("operation"), dict) else {} + service = operation.get("services", {}).get("atproto_pds", {}) if isinstance(operation.get("services"), dict) else {} + log(f"signed operation service_endpoint={service.get('endpoint', '')}") + log(f"wrote {settings.plc_signed_path}") + + +def plc_submit(settings: Settings) -> None: + step("submit PLC operation through Tempest") + data = read_json(settings.plc_signed_path) + operation = data.get("operation") + if not isinstance(operation, dict): + raise CliError(f"{settings.plc_signed_path} does not contain operation") + + url = f"{settings.tempest}/xrpc/com.atproto.identity.submitPlcOperation" + status, _headers, raw = request("POST", url, headers=bearer(tempest_access_token(settings)), json_body={"operation": operation}) + result = expect_json(status, raw, url) + write_json(settings.plc_submit_path, result) + print_json_summary("saved PLC submit result", result) + log(f"wrote {settings.plc_submit_path}") + + +def activate_account(settings: Settings) -> None: + step("activate Tempest account") + url = f"{settings.tempest}/xrpc/com.atproto.server.activateAccount" + status, _headers, raw = request("POST", url, headers=bearer(tempest_access_token(settings)), json_body={}) + data = expect_json(status, raw, url) + write_json(settings.activate_path, data) + print_json_summary("saved activation result", data) + log(f"wrote {settings.activate_path}") + + def full(settings: Settings) -> None: started = time.monotonic() log("Tempest migration CLI") @@ -504,6 +610,16 @@ def run_command(command: Command, settings: Settings) -> None: list_missing_blobs(settings) case Command.UPLOAD_MISSING_BLOBS: upload_missing_blobs(settings) + case Command.PLC_RECOMMENDED: + plc_recommended(settings) + case Command.PLC_REQUEST_TOKEN: + plc_request_token(settings) + case Command.PLC_SIGN: + plc_sign(settings) + case Command.PLC_SUBMIT: + plc_submit(settings) + case Command.ACTIVATE: + activate_account(settings) def main(argv: list[str] | None = None) -> int: diff --git a/test/tempest_web/xrpc/plc_identity_test.exs b/test/tempest_web/xrpc/plc_identity_test.exs index 9cfea6e..876d598 100644 --- a/test/tempest_web/xrpc/plc_identity_test.exs +++ b/test/tempest_web/xrpc/plc_identity_test.exs @@ -74,6 +74,26 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do } end + test "getRecommendedDidCredentials allows deactivated migration accounts", %{conn: conn} do + account = create_account!(conn, "plc-inactive-creds.test", "plc-inactive-creds@example.com") + + account["did"] + |> account_by_did!() + |> Ecto.Changeset.change(active: false, status: "deactivated") + |> Repo.update!() + + conn = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> get(~p"/xrpc/com.atproto.identity.getRecommendedDidCredentials") + + response = json_response(conn, 200) + + assert response["did"] == account["did"] + assert get_in(response, ["services", "atproto_pds", "endpoint"]) == "http://localhost:4002" + end + test "getRecommendedDidCredentials is consistent with fake PLC publication boundary", %{conn: conn} do put_identity_test_config() @@ -527,6 +547,8 @@ defmodule TempestWeb.Xrpc.PlcIdentityTest do |> json_response(200) end + defp account_by_did!(did), do: Repo.get_by!(Account, did: did) + defp create_app_password!(conn, access_jwt) do conn |> recycle() -- 2.51.2