diff --git a/docs/reference/account-migration.md b/docs/reference/account-migration.md index dbee22b..cf00519 100644 --- a/docs/reference/account-migration.md +++ b/docs/reference/account-migration.md @@ -53,10 +53,16 @@ curl "$OLD_PDS/xrpc/com.atproto.sync.getRepo?did=$DID" -o repo.car Ask the source PDS for service auth scoped to account creation on Tempest: ```bash +TEMPEST_SERVICE_DID=did:web:tempest.example.com + curl -H "Authorization: Bearer $OLD_ACCESS" \ - "$OLD_PDS/xrpc/com.atproto.server.getServiceAuth?aud=$TEMPEST_PUBLIC_URL&lxm=com.atproto.server.createAccount" + "$OLD_PDS/xrpc/com.atproto.server.getServiceAuth?aud=$TEMPEST_SERVICE_DID&lxm=com.atproto.server.createAccount" ``` +`aud` is the target service DID, not the HTTPS service endpoint. The HTTPS +endpoint still belongs in the public DID document's `#atproto_pds` +`serviceEndpoint`. + Create the account on Tempest with the existing DID: ```bash diff --git a/docs/reference/tokens.md b/docs/reference/tokens.md new file mode 100644 index 0000000..d3004e7 --- /dev/null +++ b/docs/reference/tokens.md @@ -0,0 +1,120 @@ +--- +title: Tokens +updated: 2026-06-13 +--- + +This page covers operational token handling for Tempest deployment and account +migration work. Keep raw tokens out of Git, logs, tickets, screenshots, and chat. +Use `.sandbox/` or a local password manager for temporary working files. + +## Token Types + +Common tokens in deployment and migration: + +- `ADMIN_TOKEN`: raw operator token for Tempest admin routes. Railway stores only + `TEMPEST_ADMIN_TOKEN_HASH`. +- account `accessJwt`: short-lived bearer token returned by + `com.atproto.server.createSession`. +- account `refreshJwt`: refresh token returned by session creation and refresh. +- `serviceAuth`: scoped proof from one PDS to another, returned by + `com.atproto.server.getServiceAuth`. +- 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. + +## Source Account Access Token + +To migrate an account from its current PDS, first create a normal session on the +current authoritative PDS. For the current `tempestpds.bsky.social` migration +example: + +```bash +export OLD_PDS="https://jellybaby.us-east.host.bsky.network" +export HANDLE="tempestpds.bsky.social" +export TEMPEST="https://tempest.desertthunder.dev" +export TEMPEST_SERVICE_DID="did:web:tempest.desertthunder.dev" + +read -s OLD_PASSWORD +``` + +Use the account password or a Bluesky app password: + +```bash +curl -fsS -X POST "$OLD_PDS/xrpc/com.atproto.server.createSession" \ + -H "Content-Type: application/json" \ + --data "$(jq -n \ + --arg identifier "$HANDLE" \ + --arg password "$OLD_PASSWORD" \ + '{identifier: $identifier, password: $password}')" \ + > .sandbox/old_session.json +``` + +Extract the source access token: + +```bash +export OLD_ACCESS="$(jq -r .accessJwt .sandbox/old_session.json)" +``` + +Check that extraction worked without printing the token: + +```bash +jq '{did, handle, has_access: (.accessJwt != null), has_refresh: (.refreshJwt != null)}' \ + .sandbox/old_session.json +``` + +## Service Auth for Migration + +Ask the old PDS for service auth scoped to account creation on Tempest: + +```bash +curl -fsS -G "$OLD_PDS/xrpc/com.atproto.server.getServiceAuth" \ + -H "Authorization: Bearer $OLD_ACCESS" \ + --data-urlencode "aud=$TEMPEST_SERVICE_DID" \ + --data-urlencode "lxm=com.atproto.server.createAccount" \ + > .sandbox/service_auth_create_account.json +``` + +`aud` must be a DID. Do not use `https://tempest.desertthunder.dev` as the +`getServiceAuth` audience; current PDS implementations reject URL audiences with +`InvalidRequest`. + +Check that a token exists without printing it: + +```bash +jq '{has_token: (.token != null)}' .sandbox/service_auth_create_account.json +``` + +Export the token for the next Tempest request: + +```bash +export SERVICE_AUTH="$(jq -r .token .sandbox/service_auth_create_account.json)" +``` + +Use that value as `serviceAuth` when calling Tempest +`com.atproto.server.createAccount` with an existing DID. The service-auth token +must have: + +- issuer and subject equal to the account DID; +- audience equal to the target service DID, such as + `did:web:tempest.desertthunder.dev`; +- method (`lxm`) equal to `com.atproto.server.createAccount`. + +## Safety Notes + +- Do not commit `.sandbox/old_session.json`, + `.sandbox/service_auth_create_account.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. +- Keep the old PDS account active until Tempest passes repo, blob, firehose, + crawler, DID, and real-client checks. + +## Related Runbooks + +- [Account Migration](./account-migration.md) +- [Deployment Guide](./deployment.md) +- [Deployment and Observability](./deployment-observability.md) +- [Security, OAuth, and Delegated Access](./security-oauth.md) diff --git a/lib/tempest/accounts.ex b/lib/tempest/accounts.ex index 7b4c608..042b469 100644 --- a/lib/tempest/accounts.ex +++ b/lib/tempest/accounts.ex @@ -561,8 +561,9 @@ defmodule Tempest.Accounts do token = Map.get(attrs, "serviceAuth") || Map.get(attrs, "serviceAuthToken") with token when is_binary(token) and token != "" <- token, - {:ok, %{"iss" => ^did, "sub" => ^did, "aud" => aud, "lxm" => "com.atproto.server.createAccount"}} <- + {:ok, %{"iss" => ^did, "aud" => aud, "lxm" => "com.atproto.server.createAccount"} = claims} <- Tokens.verify_service_auth(token), + ^did <- Map.get(claims, "sub", did), :ok <- validate_service_audience(aud) do :ok else diff --git a/lib/tempest/accounts/tokens.ex b/lib/tempest/accounts/tokens.ex index e8ace56..ff86bb5 100644 --- a/lib/tempest/accounts/tokens.ex +++ b/lib/tempest/accounts/tokens.ex @@ -77,7 +77,7 @@ defmodule Tempest.Accounts.Tokens do _error -> {:error, :invalid} end - defp validate_service_auth_header(%{"alg" => "ES256K", "kid" => kid} = header) when is_binary(kid) do + defp validate_service_auth_header(%{"alg" => "ES256K"} = header) do case Map.get(header, "typ") do nil -> :ok typ when is_binary(typ) -> if String.upcase(typ) == "JWT", do: :ok, else: {:error, :invalid} @@ -96,18 +96,20 @@ defmodule Tempest.Accounts.Tokens do _error -> {:error, :invalid} end - defp validate_service_auth_claim_shape(%{"iss" => did, "sub" => did, "aud" => aud, "lxm" => lxm}) + defp validate_service_auth_claim_shape(%{"iss" => did, "aud" => aud, "lxm" => lxm} = claims) when is_binary(did) and did != "" and is_binary(aud) and aud != "" and is_binary(lxm) and lxm != "" do - :ok + case Map.get(claims, "sub", did) do + ^did -> :ok + _other -> {:error, :invalid} + end end defp validate_service_auth_claim_shape(_claims), do: {:error, :invalid} - defp service_auth_public_jwk(did, kid) do + defp service_auth_public_jwk(did, kid) when kid in [nil, did <> "#atproto"] do expected_kid = did <> "#atproto" - with ^expected_kid <- kid, - {:ok, document} <- Identity.did_document_for_did(did), + with {:ok, document} <- Identity.did_document_for_did(did), {:ok, public_key_multibase} <- find_atproto_public_key(document, expected_kid), {:ok, public_key} <- decode_multibase64(public_key_multibase), {:ok, jwk} <- public_jwk_from_raw_secp256k1(public_key) do @@ -118,6 +120,8 @@ defmodule Tempest.Accounts.Tokens do end end + defp service_auth_public_jwk(_did, _kid), do: {:error, :invalid} + defp find_atproto_public_key(%{"verificationMethod" => methods}, expected_kid) when is_list(methods) do methods |> Enum.find(fn @@ -141,8 +145,17 @@ defmodule Tempest.Accounts.Tokens do _error -> {:error, :invalid} end - defp validate_service_auth_claims(%{"iss" => did, "sub" => did, "iat" => iat, "exp" => exp}) + defp validate_service_auth_claims(%{"iss" => did, "iat" => iat, "exp" => exp} = claims) when is_integer(iat) and is_integer(exp) do + case Map.get(claims, "sub", did) do + ^did -> validate_service_auth_times(iat, exp) + _other -> {:error, :invalid} + end + end + + defp validate_service_auth_claims(_claims), do: {:error, :invalid} + + defp validate_service_auth_times(iat, exp) do now = DateTime.utc_now() |> DateTime.to_unix() cond do @@ -153,8 +166,6 @@ defmodule Tempest.Accounts.Tokens do end end - defp validate_service_auth_claims(_claims), do: {:error, :invalid} - defp service_auth_private_jwk!(key) do with {:ok, private_key} <- KeyStore.decrypt_private_key(key), {:ok, public_key} <- decode_multibase64(key.public_key_multibase), diff --git a/test/tempest_web/xrpc/accounts_sessions_test.exs b/test/tempest_web/xrpc/accounts_sessions_test.exs index 82e0fe1..4982957 100644 --- a/test/tempest_web/xrpc/accounts_sessions_test.exs +++ b/test/tempest_web/xrpc/accounts_sessions_test.exs @@ -239,6 +239,38 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest do assert %{"error" => "AccountTakedown"} = json_response(login_conn, 403) end + test "createAccount accepts Bluesky-style service auth without kid or sub", %{conn: conn} do + did = "did:plc:" <> (:crypto.strong_rand_bytes(16) |> Base.encode32(case: :lower, padding: false)) + + {service_auth, did_document} = + remote_service_auth(did, "did:web:tempest.test", "com.atproto.server.createAccount", + include_kid?: false, + include_sub?: false, + lifetime_seconds: 60 + ) + + Req.Test.expect(__MODULE__, fn req_conn -> + assert req_conn.request_path == "/#{did}" + Req.Test.json(req_conn, did_document) + end) + + migrated = + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.createAccount", %{ + "did" => did, + "handle" => "bluesky-service-auth.test", + "email" => "bluesky-service-auth@example.com", + "password" => @password, + "serviceAuth" => service_auth + }) + |> json_response(200) + + assert migrated["did"] == did + assert migrated["active"] == false + assert migrated["status"] == "deactivated" + end + test "migrated did:web account stays private until activation emits ordered events", %{conn: conn} do did = "did:web:migrated-#{System.unique_integer([:positive])}.example.com" @@ -451,22 +483,25 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest do events end - defp remote_service_auth(did, audience, method_nsid) do + defp remote_service_auth(did, audience, method_nsid, opts \\ []) do key = JOSE.JWK.generate_key({:ec, "secp256k1"}) {_kty, private_jwk} = JOSE.JWK.to_map(key) public_key_multibase = public_key_multibase(private_jwk) now = DateTime.utc_now() |> DateTime.to_unix() - headers = %{"typ" => "JWT", "alg" => "ES256K", "kid" => did <> "#atproto"} + headers = + %{"typ" => "JWT", "alg" => "ES256K"} + |> maybe_put(Keyword.get(opts, :include_kid?, true), "kid", did <> "#atproto") - claims = %{ - "iss" => did, - "sub" => did, - "aud" => audience, - "lxm" => method_nsid, - "iat" => now, - "exp" => now + 600 - } + claims = + %{ + "iss" => did, + "aud" => audience, + "lxm" => method_nsid, + "iat" => now, + "exp" => now + Keyword.get(opts, :lifetime_seconds, 600) + } + |> maybe_put(Keyword.get(opts, :include_sub?, true), "sub", did) {_jws, token} = JOSE.JWT.sign(key, headers, claims) |> JOSE.JWS.compact() @@ -491,4 +526,7 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest do y = Base.url_decode64!(encoded_y, padding: false) "u" <> Base.url_encode64(<<4, x::binary, y::binary>>, padding: false) end + + defp maybe_put(map, true, key, value), do: Map.put(map, key, value) + defp maybe_put(map, false, _key, _value), do: map end