From 1ab90f25ae815c430adf10d76fa8fcde27576294 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 13 Jun 2026 16:57:32 -0500 Subject: [PATCH] feat: refresh inactive account tokens * session refresh script --- docs/reference/account-migration.md | 2 + docs/reference/car-drisl.md | 120 ++++++++++++++++++ docs/reference/tokens.md | 8 ++ lib/tempest/accounts.ex | 6 +- scripts/src/tempest_py/main.py | 25 ++++ .../xrpc/accounts_sessions_test.exs | 42 ++++++ 6 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 docs/reference/car-drisl.md diff --git a/docs/reference/account-migration.md b/docs/reference/account-migration.md index 89b2900..df96a6d 100644 --- a/docs/reference/account-migration.md +++ b/docs/reference/account-migration.md @@ -70,6 +70,7 @@ UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest export-car UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest list-source-blobs UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest download-source-blobs UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest create-account +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest refresh-session 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 @@ -208,4 +209,5 @@ Compatibility tracking: - [Migration and Account Lifecycle](./migration-lifecycle.md) - [PDS Compatibility Matrix](./pds-compatibility.md) +- [CAR and DRISL](./car-drisl.md) - [Identity Troubleshooting](./identity-troubleshooting.md) diff --git a/docs/reference/car-drisl.md b/docs/reference/car-drisl.md new file mode 100644 index 0000000..66f851f --- /dev/null +++ b/docs/reference/car-drisl.md @@ -0,0 +1,120 @@ +--- +title: CAR and DRISL +updated: 2026-06-13 +--- + +This page summarizes how Tempest handles AT Protocol repository CAR exports and +DRISL-encoded repository blocks. + +## Repository Shape + +An AT Protocol account repository is a public, self-certifying key/value store. +Keys are repo paths such as `app.bsky.actor.profile/self`; values are records. +The repository root is a signed commit object. The commit points at the root of +a Merkle Search Tree (MST), and the MST points at record blocks by CID. + +The AT Protocol repository spec defines repo format version `3`. Commit objects +include: + +- `did`: account DID. +- `version`: fixed `3`. +- `data`: CID link to the MST root. +- `rev`: monotonically increasing TID revision. +- `prev`: nullable previous commit CID link, required in v3. +- `sig`: compact secp256k1 commit signature bytes. + +The signature is over the DRISL-encoded unsigned commit bytes. The commit itself +does not identify which public key verifies it; Tempest resolves the account DID +document and uses the `#atproto` signing key. + +## CAR Files + +CAR means Content Addressable aRchive. AT Protocol full repo export uses CAR v1 +with MIME type `application/vnd.ipld.car`. + +In an AT Protocol repo CAR: + +- The first CAR root should be the current commit CID. +- The CAR must include the commit block. +- Full exports must include every MST node and record block reachable from that + commit. +- Block order should not be trusted. Import code must tolerate arbitrary order. +- Extra unrelated blocks may exist and should not be treated as current records + unless reachable from the commit's MST. + +Tempest import flow: + +1. Decode CAR v1. +2. Read the first root as the commit CID. +3. Decode the commit block. +4. Check commit CID equals the hash of the commit bytes. +5. Check commit DID equals the authenticated account DID. +6. Walk the MST and collect current record path to CID mappings. +7. Check all referenced record blocks are present. +8. Resolve the correct DID document signing key. +9. Verify the commit signature. +10. Atomically replace local repo storage with reachable blocks and current records. + +For inactive migrated accounts, import must verify against the external +authoritative DID document, not the local inactive Tempest account document. The +local account has a newly generated signing key; the source CAR is signed by the +source/current PLC signing key. + +## DRISL + +DRISL is the deterministic CBOR profile used by atproto repositories. It is +similar in role to DAG-CBOR: it gives repository objects canonical bytes so CIDs +and signatures are reproducible. + +Tempest uses DRISL for: + +- commit objects; +- MST nodes; +- record blocks; +- firehose commit events. + +DRISL decoding returns typed internal values for binary constructs: + +- CID links decode as `%Tempest.RepoCore.Cid{}`. +- byte strings decode as `%Tempest.RepoCore.Drisl.Bytes{}`. + +Those typed values are correct while verifying repository structure. They are +not JSON-safe record storage shapes. Before Tempest stores imported records as +`record_json`, it must normalize decoded values back to AT Protocol JSON: + +```elixir +%Tempest.RepoCore.Cid{} -> %{"$link" => cid_string} +``` + +This matters for blob references. A profile avatar record may contain a DRISL CID +link under `avatar.ref`; after JSON normalization, the stored record should have: + +```json +{ "avatar": { "$type": "blob", "ref": { "$link": "bafk..." } } } +``` + +## Public Keys + +AT Protocol DID documents commonly expose the repo signing key as a Multikey: + +```json +{ "id": "did:plc:...#atproto", "type": "Multikey", "publicKeyMultibase": "zQ3..." } +``` + +The `z...` value is base58btc Multikey. For secp256k1 keys, Tempest must: + +1. base58btc-decode the value; +2. unwrap the secp256k1 public-key multicodec prefix; +3. accept compressed or uncompressed secp256k1 public-key bytes; +4. use that key for ES256K service-auth verification and repo commit signature + verification. + +Tempest also has older/internal `u...` base64url raw public-key values in local +test and generated DID documents. The shared decoder accepts both. + +## References + +- [AT Protocol Repository](https://atproto.com/specs/repository) +- [AT Protocol Data Model](https://atproto.com/specs/data-model) +- [AT Protocol Sync](https://atproto.com/specs/sync) +- [IPLD CAR v1 Specification](https://ipld.io/specs/transport/car/carv1/) diff --git a/docs/reference/tokens.md b/docs/reference/tokens.md index fe7c588..8b1d6bb 100644 --- a/docs/reference/tokens.md +++ b/docs/reference/tokens.md @@ -136,6 +136,14 @@ UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest ar --only-hash - Revoke or rotate the app password after migration. - Treat `serviceAuth` as short-lived migration material. Regenerate it if the migration attempt is delayed. +- Treat Tempest `accessJwt` as short-lived. If migration commands return + `Bearer token is invalid` or `Bearer token is expired`, refresh the saved + Tempest session: + + ```bash + UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest refresh-session + ``` + - Keep the old PDS account active until Tempest passes repo, blob, firehose, crawler, DID, and real-client checks. diff --git a/lib/tempest/accounts.ex b/lib/tempest/accounts.ex index 042b469..41ff25c 100644 --- a/lib/tempest/accounts.ex +++ b/lib/tempest/accounts.ex @@ -288,7 +288,7 @@ defmodule Tempest.Accounts do revoke_session_family!(session.family_id, now) {:error, :expired_token} - not session.account.active or session.account.status != "active" -> + not refresh_allowed_for_account?(session.account) -> {:error, :inactive_account} true -> @@ -298,6 +298,10 @@ defmodule Tempest.Accounts do def authenticate_refresh(_token), do: {:error, :invalid_token} + defp refresh_allowed_for_account?(%Account{active: true, status: "active"}), do: true + defp refresh_allowed_for_account?(%Account{active: false, status: "deactivated"}), do: true + defp refresh_allowed_for_account?(%Account{}), do: false + def list_app_passwords(%AuthContext{token_type: :access, account: account}) do {:ok, %{"passwords" => AppPasswords.list(account)}} end diff --git a/scripts/src/tempest_py/main.py b/scripts/src/tempest_py/main.py index f8590e5..1aca0b6 100644 --- a/scripts/src/tempest_py/main.py +++ b/scripts/src/tempest_py/main.py @@ -40,6 +40,7 @@ class Command(StrEnum): LIST_SOURCE_BLOBS = "list-source-blobs" DOWNLOAD_SOURCE_BLOBS = "download-source-blobs" CREATE_ACCOUNT = "create-account" + REFRESH_SESSION = "refresh-session" IMPORT_REPO = "import-repo" STATUS = "status" MISSING_BLOBS = "missing-blobs" @@ -240,6 +241,18 @@ def tempest_access_token(settings: Settings) -> str: return token +def tempest_refresh_token(settings: Settings) -> str: + explicit = env("TEMPEST_REFRESH") + if explicit: + return explicit + + data = read_json(settings.create_account_path) + token = data.get("refreshJwt") + if not isinstance(token, str) or not token: + raise CliError(f"{settings.create_account_path} does not contain refreshJwt") + return token + + def login_source(settings: Settings) -> None: step("source session") password = require_env(settings, "OLD_PASSWORD", settings.old_password) @@ -336,6 +349,16 @@ def create_account(settings: Settings) -> None: log(f"wrote {settings.create_account_path}") +def refresh_tempest_session(settings: Settings) -> None: + step("refresh Tempest session") + url = f"{settings.tempest}/xrpc/com.atproto.server.refreshSession" + status, _headers, raw = request("POST", url, headers=bearer(tempest_refresh_token(settings))) + data = expect_json(status, raw, url) + write_json(settings.create_account_path, data) + print_json_summary("saved refreshed Tempest account", data) + log(f"wrote {settings.create_account_path}") + + def import_repo(settings: Settings) -> None: step("import repo CAR into Tempest") if not settings.car_path.exists(): @@ -471,6 +494,8 @@ def run_command(command: Command, settings: Settings) -> None: download_source_blobs(settings) case Command.CREATE_ACCOUNT: create_account(settings) + case Command.REFRESH_SESSION: + refresh_tempest_session(settings) case Command.IMPORT_REPO: import_repo(settings) case Command.STATUS: diff --git a/test/tempest_web/xrpc/accounts_sessions_test.exs b/test/tempest_web/xrpc/accounts_sessions_test.exs index 28d071f..db5f1a7 100644 --- a/test/tempest_web/xrpc/accounts_sessions_test.exs +++ b/test/tempest_web/xrpc/accounts_sessions_test.exs @@ -272,6 +272,48 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest do assert migrated["status"] == "deactivated" end + test "refreshSession rotates tokens for deactivated migrated accounts", %{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, + public_key_encoding: :base58btc_compressed_multikey, + 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" => "migrated-refresh.test", + "email" => "migrated-refresh@example.com", + "password" => @password, + "serviceAuth" => service_auth + }) + |> json_response(200) + + refreshed = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{migrated["refreshJwt"]}") + |> post(~p"/xrpc/com.atproto.server.refreshSession") + |> json_response(200) + + assert refreshed["did"] == did + assert refreshed["active"] == false + assert refreshed["status"] == "deactivated" + assert refreshed["accessJwt"] != migrated["accessJwt"] + assert refreshed["refreshJwt"] != migrated["refreshJwt"] + 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" -- 2.51.2