From c2fa16b95c8e36df33b872c4c19732b39463c06c Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 13 Jun 2026 16:24:37 -0500 Subject: [PATCH] feat: support publicKeyMultibase 'z' * migration guide with token gen --- .gitignore | 5 + docs/reference/account-migration.md | 35 ++ docs/reference/deployment.md | 2 +- docs/reference/tokens.md | 27 + lib/tempest/accounts/tokens.ex | 91 +++- scripts/pyproject.toml | 21 + scripts/src/tempest_py/__init__.py | 1 + scripts/{ => src/tempest_py}/ar.py | 27 +- scripts/src/tempest_py/main.py | 509 ++++++++++++++++++ scripts/uv.lock | 221 ++++++++ .../xrpc/accounts_sessions_test.exs | 41 +- 11 files changed, 949 insertions(+), 31 deletions(-) create mode 100644 scripts/pyproject.toml create mode 100644 scripts/src/tempest_py/__init__.py rename scripts/{ => src/tempest_py}/ar.py (75%) mode change 100755 => 100644 create mode 100644 scripts/src/tempest_py/main.py create mode 100644 scripts/uv.lock diff --git a/.gitignore b/.gitignore index afb0270..022162a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,9 @@ tempest-*.tar npm-debug.log /assets/node_modules/ +# Python helper tooling. +__pycache__/ +*.py[cod] +.venv/ + .sandbox/ diff --git a/docs/reference/account-migration.md b/docs/reference/account-migration.md index cf00519..89b2900 100644 --- a/docs/reference/account-migration.md +++ b/docs/reference/account-migration.md @@ -44,6 +44,41 @@ path because the operator can update the DID document directly. ## Migration-In Flow +The Python CLI mirrors the manual curl flow below. It reads configuration from +environment variables, writes artifacts into `.sandbox/` by default, and defaults +to the full migration-in sequence: + +```bash +export OLD_PDS="https://jellybaby.us-east.host.bsky.network" +export HANDLE="tempestpds.bsky.social" +export DID="did:plc:oga6ppys7zwxlheuqmcm7dac" +export TEMPEST="https://tempest.desertthunder.dev" +export TEMPEST_SERVICE_DID="did:web:tempest.desertthunder.dev" +export EMAIL="operator@example.com" +read -s OLD_PASSWORD +read -s TEMPEST_PASSWORD + +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest +``` + +Run individual steps when resuming or inspecting a failure: + +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest login-source +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest service-auth +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 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 +``` + +The same project also exposes the admin-token Argon2 helper as +`tempest argon`, with `tempest ar` and `tempest arg2` aliases. + From the source PDS, export the account repository: ```bash diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 7d79020..a4a5d2b 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -38,7 +38,7 @@ Generate secrets from a trusted local machine: ```bash mix phx.gen.secret -scripts/ar.py +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest argon ``` Store the raw `ADMIN_TOKEN` in a password manager. Railway gets only diff --git a/docs/reference/tokens.md b/docs/reference/tokens.md index d3004e7..fe7c588 100644 --- a/docs/reference/tokens.md +++ b/docs/reference/tokens.md @@ -37,6 +37,13 @@ export TEMPEST_SERVICE_DID="did:web:tempest.desertthunder.dev" read -s OLD_PASSWORD ``` +The migration CLI reads the same environment variables as the curl examples and +writes the same artifacts: + +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest login-source +``` + Use the account password or a Bluesky app password: ```bash @@ -66,6 +73,12 @@ jq '{did, handle, has_access: (.accessJwt != null), has_refresh: (.refreshJwt != Ask the old PDS for service auth scoped to account creation on Tempest: +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest service-auth +``` + +The equivalent curl call is: + ```bash curl -fsS -G "$OLD_PDS/xrpc/com.atproto.server.getServiceAuth" \ -H "Authorization: Bearer $OLD_ACCESS" \ @@ -99,6 +112,20 @@ must have: `did:web:tempest.desertthunder.dev`; - method (`lxm`) equal to `com.atproto.server.createAccount`. +## Admin Token Hash + +Generate `TEMPEST_ADMIN_TOKEN_HASH` through the same uv project: + +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest argon +``` + +The `ar` and `arg2` aliases run the same helper: + +```bash +UV_CACHE_DIR=.sandbox/uv-cache uv run --project scripts tempest ar --only-hash +``` + ## Safety Notes - Do not commit `.sandbox/old_session.json`, diff --git a/lib/tempest/accounts/tokens.ex b/lib/tempest/accounts/tokens.ex index ff86bb5..19bdc1f 100644 --- a/lib/tempest/accounts/tokens.ex +++ b/lib/tempest/accounts/tokens.ex @@ -13,6 +13,9 @@ defmodule Tempest.Accounts.Tokens do @service_auth_lifetime_seconds 10 * 60 @refresh_lifetime_seconds 60 * 60 * 24 * 30 @refresh_prefix "tempest-refresh-v1." + @base58btc_alphabet ~c"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + @secp256k1_pub_multicodec <<0xE7, 0x01>> + @secp256k1_p 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F def sign_access_token(%Account{} = account, %Session{} = session) do Phoenix.Token.sign(Endpoint, @access_salt, %{ @@ -111,7 +114,7 @@ defmodule Tempest.Accounts.Tokens do 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, public_key} <- decode_public_key_multibase(public_key_multibase), {:ok, jwk} <- public_jwk_from_raw_secp256k1(public_key) do {:ok, jwk} else @@ -168,7 +171,7 @@ defmodule Tempest.Accounts.Tokens do 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), + {:ok, public_key} <- decode_public_key_multibase(key.public_key_multibase), {:ok, public_jwk} <- public_jwk_from_raw_secp256k1(public_key) do public_jwk |> JOSE.JWK.to_map() |> elem(1) |> Map.put("d", base64url(private_key)) |> JOSE.JWK.from_map() else @@ -182,8 +185,88 @@ defmodule Tempest.Accounts.Tokens do defp public_jwk_from_raw_secp256k1(_public_key), do: {:error, :invalid} - defp decode_multibase64("u" <> encoded), do: Base.url_decode64(encoded, padding: false) - defp decode_multibase64(_value), do: {:error, :invalid} + defp decode_public_key_multibase("u" <> encoded), do: Base.url_decode64(encoded, padding: false) + + defp decode_public_key_multibase("z" <> encoded) do + with {:ok, bytes} <- base58btc_decode(encoded), + {:ok, key_bytes} <- unwrap_secp256k1_multikey(bytes), + {:ok, public_key} <- normalize_secp256k1_public_key(key_bytes) do + {:ok, public_key} + end + end + + defp decode_public_key_multibase(_value), do: {:error, :invalid} + + defp unwrap_secp256k1_multikey(@secp256k1_pub_multicodec <> key), do: {:ok, key} + defp unwrap_secp256k1_multikey(_bytes), do: {:error, :invalid} + + defp normalize_secp256k1_public_key(<<4, _rest::binary-size(64)>> = public_key), do: {:ok, public_key} + + defp normalize_secp256k1_public_key(<>) when prefix in [2, 3] do + x_int = :binary.decode_unsigned(x) + y2 = rem(modular_pow(x_int, 3, @secp256k1_p) + 7, @secp256k1_p) + y_root = modular_pow(y2, div(@secp256k1_p + 1, 4), @secp256k1_p) + y_int = if rem(y_root, 2) == rem(prefix, 2), do: y_root, else: @secp256k1_p - y_root + + {:ok, <<4, x::binary, unsigned_256(y_int)::binary>>} + end + + defp normalize_secp256k1_public_key(_key), do: {:error, :invalid} + + defp base58btc_decode(encoded) when is_binary(encoded) do + encoded + |> String.to_charlist() + |> Enum.reduce_while({:ok, 0}, fn char, {:ok, acc} -> + case base58_value(char) do + {:ok, value} -> {:cont, {:ok, acc * 58 + value}} + :error -> {:halt, {:error, :invalid}} + end + end) + |> case do + {:ok, value} -> + leading_zero_count = + encoded + |> String.to_charlist() + |> Enum.take_while(&(&1 == ?1)) + |> length() + + decoded = :binary.copy(<<0>>, leading_zero_count) <> unsigned_bytes(value) + {:ok, decoded} + + {:error, reason} -> + {:error, reason} + end + end + + defp base58_value(char) do + case Enum.find_index(@base58btc_alphabet, &(&1 == char)) do + nil -> :error + index -> {:ok, index} + end + 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) + + defp modular_pow(_base, 0, _modulus, result), do: result + + defp modular_pow(base, exponent, modulus, result) do + result = if rem(exponent, 2) == 1, do: rem(result * base, modulus), else: result + modular_pow(rem(base * base, modulus), div(exponent, 2), modulus, result) + end + + defp unsigned_256(value) do + value + |> unsigned_bytes() + |> pad_left(32) + end + + defp unsigned_bytes(0), do: <<>> + defp unsigned_bytes(value), do: :binary.encode_unsigned(value) + + defp pad_left(bytes, size) when byte_size(bytes) <= size do + :binary.copy(<<0>>, size - byte_size(bytes)) <> bytes + end def new_refresh_token do @refresh_prefix <> random_url_token(48) diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml new file mode 100644 index 0000000..437452e --- /dev/null +++ b/scripts/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "tempest-scripts" +version = "0.1.0" +description = "Operational scripts for Tempest" +requires-python = ">=3.11" +dependencies = [ + "argon2-cffi>=23.1.0", + "httpx>=0.27.0", +] + +[project.scripts] +tempest = "tempest_py.main:main" +tempest-admin-hash = "tempest_py.ar:main" +tempest-migrate = "tempest_py.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/tempest_py"] diff --git a/scripts/src/tempest_py/__init__.py b/scripts/src/tempest_py/__init__.py new file mode 100644 index 0000000..0c35419 --- /dev/null +++ b/scripts/src/tempest_py/__init__.py @@ -0,0 +1 @@ +"""Operational scripts for Tempest.""" diff --git a/scripts/ar.py b/scripts/src/tempest_py/ar.py old mode 100755 new mode 100644 similarity index 75% rename from scripts/ar.py rename to scripts/src/tempest_py/ar.py index 9880800..a35c346 --- a/scripts/ar.py +++ b/scripts/src/tempest_py/ar.py @@ -1,27 +1,10 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "argon2-cffi>=23.1.0", -# ] -# /// -"""Generate a TEMPEST_ADMIN_TOKEN_HASH value. - -Usage: - scripts/ar.py - scripts/ar.py 'my-long-admin-token' - ADMIN_TOKEN='my-long-admin-token' scripts/ar.py --from-env - -By default, the script generates a new random admin token and prints the token -once, along with the Argon2 hash to put in Railway or another secret manager. -""" +"""Generate a TEMPEST_ADMIN_TOKEN_HASH value.""" from __future__ import annotations import argparse import os import secrets -import sys from argon2 import PasswordHasher, Type @@ -66,8 +49,8 @@ def token_from_args(args: argparse.Namespace) -> tuple[str, bool]: return secrets.token_urlsafe(48), True -def main() -> int: - args = parser().parse_args() +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) token, generated = token_from_args(args) if len(token) < 32 and not args.allow_short: @@ -88,7 +71,3 @@ def main() -> int: print("# Store this value in Railway or your secret manager.") print(f"TEMPEST_ADMIN_TOKEN_HASH={token_hash}") return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/src/tempest_py/main.py b/scripts/src/tempest_py/main.py new file mode 100644 index 0000000..f8590e5 --- /dev/null +++ b/scripts/src/tempest_py/main.py @@ -0,0 +1,509 @@ +"""Tempest account migration CLI.""" + +from __future__ import annotations + +import argparse +import json +import mimetypes +import os +import sys +import time +import urllib.parse +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Any + +import httpx + +from tempest_py import ar + + +DEFAULT_DID = "did:plc:oga6ppys7zwxlheuqmcm7dac" +DEFAULT_HANDLE = "tempestpds.bsky.social" +DEFAULT_OLD_PDS = "https://jellybaby.us-east.host.bsky.network" +DEFAULT_TEMPEST = "https://tempest.desertthunder.dev" +DEFAULT_TEMPEST_SERVICE_DID = "did:web:tempest.desertthunder.dev" +CREATE_ACCOUNT_LXM = "com.atproto.server.createAccount" +ARGON_COMMANDS = {"ar", "arg2", "argon"} + + +class CliError(RuntimeError): + pass + + +class Command(StrEnum): + FULL = "full" + LOGIN_SOURCE = "login-source" + SERVICE_AUTH = "service-auth" + EXPORT_CAR = "export-car" + LIST_SOURCE_BLOBS = "list-source-blobs" + DOWNLOAD_SOURCE_BLOBS = "download-source-blobs" + CREATE_ACCOUNT = "create-account" + IMPORT_REPO = "import-repo" + STATUS = "status" + MISSING_BLOBS = "missing-blobs" + UPLOAD_MISSING_BLOBS = "upload-missing-blobs" + + +@dataclass(frozen=True) +class Settings: + artifact_dir: Path + old_pds: str + tempest: str + tempest_service_did: str + did: str + handle: str + email: str | None + old_password: str | None + tempest_password: str | None + old_session_path: Path + service_auth_path: Path + car_path: Path + source_blobs_path: Path + create_account_path: Path + import_repo_path: Path + status_path: Path + missing_blobs_path: Path + + +def env(name: str, default: str | None = None) -> str | None: + value = os.environ.get(name) + return value if value not in (None, "") else default + + +def require_env(settings: Settings, name: str, value: str | None) -> str: + if value: + return value + raise CliError(f"{name} is required for this command") + + +def path_from_env(name: str, default: Path) -> Path: + value = env(name) + return Path(value) if value else default + + +def settings_from_env(args: argparse.Namespace) -> Settings: + artifact_dir = Path(args.artifact_dir or env("ARTIFACT_DIR", ".sandbox")) + + return Settings( + artifact_dir=artifact_dir, + old_pds=env("OLD_PDS", DEFAULT_OLD_PDS).rstrip("/"), + tempest=env("TEMPEST", DEFAULT_TEMPEST).rstrip("/"), + tempest_service_did=env("TEMPEST_SERVICE_DID", DEFAULT_TEMPEST_SERVICE_DID), + did=env("DID", DEFAULT_DID), + handle=env("HANDLE", DEFAULT_HANDLE), + email=env("EMAIL"), + old_password=env("OLD_PASSWORD"), + tempest_password=env("TEMPEST_PASSWORD"), + old_session_path=path_from_env("OLD_SESSION_JSON", artifact_dir / "old_session.json"), + service_auth_path=path_from_env("SERVICE_AUTH_JSON", artifact_dir / "service_auth_create_account.json"), + car_path=path_from_env("REPO_CAR", artifact_dir / "tempestpds.repo.car"), + source_blobs_path=path_from_env("SOURCE_BLOBS_JSON", artifact_dir / "source_blobs.json"), + create_account_path=path_from_env("TEMPEST_CREATE_ACCOUNT_JSON", artifact_dir / "tempest_create_account.json"), + 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"), + ) + + +def log(message: str) -> None: + print(message, flush=True) + + +def step(name: str) -> None: + log(f"\n==> {name}") + + +def ensure_artifact_dir(settings: Settings) -> None: + settings.artifact_dir.mkdir(parents=True, exist_ok=True) + + +def read_json(path: Path) -> dict[str, Any]: + try: + with path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + except FileNotFoundError as exc: + raise CliError(f"missing artifact: {path}") from exc + except json.JSONDecodeError as exc: + raise CliError(f"invalid JSON artifact: {path}") from exc + + if not isinstance(data, dict): + raise CliError(f"expected JSON object in {path}") + return data + + +def write_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, sort_keys=True) + fh.write("\n") + + +def write_bytes(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def safe_summary(data: dict[str, Any]) -> dict[str, Any]: + summary: dict[str, Any] = {} + for key, value in data.items(): + if key.lower() in {"accessjwt", "refreshjwt", "token", "serviceauth", "password"}: + summary[f"has_{key}"] = isinstance(value, str) and value != "" + elif isinstance(value, (str, int, bool)) or value is None: + summary[key] = value + return summary + + +def print_json_summary(label: str, data: dict[str, Any]) -> None: + log(f"{label}: {json.dumps(safe_summary(data), sort_keys=True)}") + + +def request( + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + json_body: dict[str, Any] | None = None, + body: bytes | None = None, + timeout: int = 30, +) -> tuple[int, dict[str, str], bytes]: + headers = dict(headers or {}) + payload = body + + if json_body is not None: + payload = json.dumps(json_body, separators=(",", ":")).encode("utf-8") + headers.setdefault("Content-Type", "application/json") + + try: + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + response = client.request(method, url, headers=headers, content=payload) + except httpx.HTTPError as exc: + raise CliError(f"request failed for {url}: {exc}") from exc + + return response.status_code, {key.lower(): value for key, value in response.headers.items()}, response.content + + +def expect_json(status: int, raw: bytes, url: str) -> dict[str, Any]: + try: + data = json.loads(raw.decode("utf-8")) + except json.JSONDecodeError as exc: + raise CliError(f"{url} returned HTTP {status} with non-JSON body") from exc + + if not isinstance(data, dict): + raise CliError(f"{url} returned HTTP {status} with non-object JSON") + + if not 200 <= status <= 299: + message = data.get("message") or data.get("error") or raw.decode("utf-8", errors="replace") + raise CliError(f"{url} returned HTTP {status}: {message}") + + return data + + +def bearer(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def access_from_session(settings: Settings) -> str: + explicit = env("OLD_ACCESS") + if explicit: + return explicit + + data = read_json(settings.old_session_path) + token = data.get("accessJwt") + if not isinstance(token, str) or not token: + raise CliError(f"{settings.old_session_path} does not contain accessJwt") + return token + + +def service_auth_token(settings: Settings) -> str: + explicit = env("SERVICE_AUTH") + if explicit: + return explicit + + data = read_json(settings.service_auth_path) + token = data.get("token") + if not isinstance(token, str) or not token: + raise CliError(f"{settings.service_auth_path} does not contain token") + return token + + +def tempest_access_token(settings: Settings) -> str: + explicit = env("TEMPEST_ACCESS") + if explicit: + return explicit + + data = read_json(settings.create_account_path) + token = data.get("accessJwt") + if not isinstance(token, str) or not token: + raise CliError(f"{settings.create_account_path} does not contain accessJwt") + 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} + status, _headers, raw = request("POST", url, json_body=payload) + data = expect_json(status, raw, url) + write_json(settings.old_session_path, data) + print_json_summary("saved source session", data) + log(f"wrote {settings.old_session_path}") + + +def get_service_auth(settings: Settings) -> None: + step("service auth") + query = urllib.parse.urlencode({"aud": settings.tempest_service_did, "lxm": CREATE_ACCOUNT_LXM}) + url = f"{settings.old_pds}/xrpc/com.atproto.server.getServiceAuth?{query}" + status, _headers, raw = request("GET", url, headers=bearer(access_from_session(settings))) + data = expect_json(status, raw, url) + write_json(settings.service_auth_path, data) + print_json_summary("saved service auth", data) + log(f"aud={settings.tempest_service_did} lxm={CREATE_ACCOUNT_LXM}") + log(f"wrote {settings.service_auth_path}") + + +def export_car(settings: Settings) -> None: + step("export repo CAR") + query = urllib.parse.urlencode({"did": settings.did}) + url = f"{settings.old_pds}/xrpc/com.atproto.sync.getRepo?{query}" + status, headers, raw = request("GET", url, timeout=60) + if not 200 <= status <= 299: + data = expect_json(status, raw, url) + raise CliError(str(data)) + write_bytes(settings.car_path, raw) + log(f"content-type={headers.get('content-type', '')} bytes={len(raw)}") + log(f"wrote {settings.car_path}") + + +def list_source_blobs(settings: Settings) -> None: + step("source blob inventory") + query = urllib.parse.urlencode({"did": settings.did}) + url = f"{settings.old_pds}/xrpc/com.atproto.sync.listBlobs?{query}" + status, _headers, raw = request("GET", url) + data = expect_json(status, raw, url) + write_json(settings.source_blobs_path, data) + cids = data.get("cids") if isinstance(data.get("cids"), list) else [] + log(f"blob_count={len(cids)}") + log(f"wrote {settings.source_blobs_path}") + + +def blob_path(settings: Settings, cid: str) -> Path: + return settings.artifact_dir / f"tempestpds.blob.{cid}" + + +def download_source_blobs(settings: Settings) -> None: + step("download source blobs") + data = read_json(settings.source_blobs_path) + cids = data.get("cids") + if not isinstance(cids, list): + raise CliError(f"{settings.source_blobs_path} does not contain cids") + + if not cids: + log("no source blobs to download") + return + + for cid in cids: + if not isinstance(cid, str) or not cid: + raise CliError(f"invalid blob CID in {settings.source_blobs_path}") + query = urllib.parse.urlencode({"did": settings.did, "cid": cid}) + url = f"{settings.old_pds}/xrpc/com.atproto.sync.getBlob?{query}" + status, headers, raw = request("GET", url, timeout=60) + if not 200 <= status <= 299: + expect_json(status, raw, url) + path = blob_path(settings, cid) + write_bytes(path, raw) + log(f"downloaded cid={cid} content-type={headers.get('content-type', '')} bytes={len(raw)} path={path}") + + +def create_account(settings: Settings) -> None: + step("create inactive Tempest account") + email = require_env(settings, "EMAIL", settings.email) + password = require_env(settings, "TEMPEST_PASSWORD", settings.tempest_password) + payload = { + "did": settings.did, + "handle": settings.handle, + "email": email, + "password": password, + "serviceAuth": service_auth_token(settings), + } + url = f"{settings.tempest}/xrpc/com.atproto.server.createAccount" + status, _headers, raw = request("POST", url, json_body=payload) + data = expect_json(status, raw, url) + write_json(settings.create_account_path, data) + print_json_summary("saved 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(): + raise CliError(f"missing CAR: {settings.car_path}") + url = f"{settings.tempest}/xrpc/com.atproto.repo.importRepo" + status, _headers, raw = request( + "POST", + url, + headers={**bearer(tempest_access_token(settings)), "Content-Type": "application/vnd.ipld.car"}, + body=settings.car_path.read_bytes(), + timeout=120, + ) + data = expect_json(status, raw, url) + write_json(settings.import_repo_path, data) + print_json_summary("saved import result", data) + log(f"wrote {settings.import_repo_path}") + + +def check_status(settings: Settings) -> None: + step("Tempest account status") + url = f"{settings.tempest}/xrpc/com.atproto.server.checkAccountStatus" + status, _headers, raw = request("GET", url, headers=bearer(tempest_access_token(settings))) + data = expect_json(status, raw, url) + write_json(settings.status_path, data) + print_json_summary("saved account status", data) + log(f"wrote {settings.status_path}") + + +def list_missing_blobs(settings: Settings) -> None: + step("Tempest missing blobs") + url = f"{settings.tempest}/xrpc/com.atproto.repo.listMissingBlobs" + status, _headers, raw = request("GET", url, headers=bearer(tempest_access_token(settings))) + data = expect_json(status, raw, url) + write_json(settings.missing_blobs_path, data) + blobs = data.get("blobs") if isinstance(data.get("blobs"), list) else [] + log(f"missing_blob_count={len(blobs)}") + log(f"wrote {settings.missing_blobs_path}") + + +def upload_missing_blobs(settings: Settings) -> None: + step("upload missing blobs to Tempest") + data = read_json(settings.missing_blobs_path) + blobs = data.get("blobs") + if not isinstance(blobs, list): + raise CliError(f"{settings.missing_blobs_path} does not contain blobs") + if not blobs: + log("no missing blobs to upload") + return + + for blob in blobs: + cid = blob.get("cid") if isinstance(blob, dict) else None + if not isinstance(cid, str) or not cid: + raise CliError(f"invalid missing blob entry in {settings.missing_blobs_path}") + + path = blob_path(settings, cid) + if not path.exists(): + raise CliError(f"missing downloaded blob file: {path}") + + mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + if path.name.endswith(f".{cid}"): + mime_type = "application/octet-stream" + if cid == "bafkreifodmypic3zbjtevk7rbftxvjxgpgegt5njaxn57lamxracv2a3he": + mime_type = "image/png" + + url = f"{settings.tempest}/xrpc/com.atproto.repo.uploadBlob" + status, _headers, raw = request( + "POST", + url, + headers={**bearer(tempest_access_token(settings)), "Content-Type": mime_type}, + body=path.read_bytes(), + timeout=60, + ) + result = expect_json(status, raw, url) + print_json_summary(f"uploaded cid={cid}", result) + + +def full(settings: Settings) -> None: + started = time.monotonic() + log("Tempest migration CLI") + log(f"old_pds={settings.old_pds}") + log(f"tempest={settings.tempest}") + log(f"tempest_service_did={settings.tempest_service_did}") + log(f"did={settings.did}") + log(f"handle={settings.handle}") + ensure_artifact_dir(settings) + login_source(settings) + get_service_auth(settings) + export_car(settings) + list_source_blobs(settings) + download_source_blobs(settings) + create_account(settings) + import_repo(settings) + check_status(settings) + list_missing_blobs(settings) + log(f"\ncomplete in {time.monotonic() - started:.1f}s") + log("Do not activate until the DID document #atproto_pds serviceEndpoint points at Tempest.") + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="tempest", + description="Run Tempest account migration steps from environment variables.", + epilog="Admin token helper aliases: tempest ar, tempest arg2, tempest argon.", + ) + p.add_argument( + "command", + nargs="?", + choices=[command.value for command in Command], + default=Command.FULL.value, + help="Command to run. Defaults to full.", + ) + p.add_argument( + "--artifact-dir", + default=None, + help="Directory for JSON/CAR/blob artifacts. Defaults to ARTIFACT_DIR or .sandbox.", + ) + return p + + +def run_command(command: Command, settings: Settings) -> None: + match command: + case Command.FULL: + full(settings) + case Command.LOGIN_SOURCE: + login_source(settings) + case Command.SERVICE_AUTH: + get_service_auth(settings) + case Command.EXPORT_CAR: + export_car(settings) + case Command.LIST_SOURCE_BLOBS: + list_source_blobs(settings) + case Command.DOWNLOAD_SOURCE_BLOBS: + download_source_blobs(settings) + case Command.CREATE_ACCOUNT: + create_account(settings) + case Command.IMPORT_REPO: + import_repo(settings) + case Command.STATUS: + check_status(settings) + case Command.MISSING_BLOBS: + list_missing_blobs(settings) + case Command.UPLOAD_MISSING_BLOBS: + upload_missing_blobs(settings) + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + + if argv and argv[0] in ARGON_COMMANDS: + return ar.main(argv[1:]) + + args = parser().parse_args(argv) + + settings = settings_from_env(args) + command = Command(args.command) + + try: + ensure_artifact_dir(settings) + run_command(command, settings) + except CliError as exc: + log(f"\nERROR: {exc}") + return 1 + except KeyboardInterrupt: + log("\ninterrupted") + return 130 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/uv.lock b/scripts/uv.lock new file mode 100644 index 0000000..4aaa747 --- /dev/null +++ b/scripts/uv.lock @@ -0,0 +1,221 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "tempest-scripts" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "argon2-cffi" }, + { name = "httpx" }, +] + +[package.metadata] +requires-dist = [ + { name = "argon2-cffi", specifier = ">=23.1.0" }, + { name = "httpx", specifier = ">=0.27.0" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/test/tempest_web/xrpc/accounts_sessions_test.exs b/test/tempest_web/xrpc/accounts_sessions_test.exs index 4982957..28d071f 100644 --- a/test/tempest_web/xrpc/accounts_sessions_test.exs +++ b/test/tempest_web/xrpc/accounts_sessions_test.exs @@ -246,6 +246,7 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest do 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 ) @@ -486,7 +487,7 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest 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) + public_key_multibase = public_key_multibase(private_jwk, opts) now = DateTime.utc_now() |> DateTime.to_unix() headers = @@ -521,12 +522,48 @@ defmodule TempestWeb.Xrpc.AccountsSessionsTest do {token, document} end - defp public_key_multibase(%{"x" => encoded_x, "y" => encoded_y}) do + defp public_key_multibase(private_jwk, opts) do + case Keyword.get(opts, :public_key_encoding, :base64url_uncompressed) do + :base58btc_compressed_multikey -> compressed_base58btc_multikey(private_jwk) + :base64url_uncompressed -> base64url_uncompressed_multibase(private_jwk) + end + end + + defp base64url_uncompressed_multibase(%{"x" => encoded_x, "y" => encoded_y}) do x = Base.url_decode64!(encoded_x, padding: false) y = Base.url_decode64!(encoded_y, padding: false) "u" <> Base.url_encode64(<<4, x::binary, y::binary>>, padding: false) end + defp compressed_base58btc_multikey(%{"x" => encoded_x, "y" => encoded_y}) do + x = Base.url_decode64!(encoded_x, padding: false) + y = Base.url_decode64!(encoded_y, padding: false) + prefix = if rem(:binary.decode_unsigned(y), 2) == 0, do: 2, else: 3 + multikey = <<0xE7, 0x01, prefix, x::binary>> + "z" <> base58btc_encode(multikey) + end + + defp base58btc_encode(bytes) do + alphabet = ~c"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + value = :binary.decode_unsigned(bytes) + encoded = encode_base58_value(value, alphabet, []) + + leading_zeroes = + bytes + |> :binary.bin_to_list() + |> Enum.take_while(&(&1 == 0)) + |> length() + + List.to_string(List.duplicate(?1, leading_zeroes) ++ encoded) + end + + defp encode_base58_value(0, _alphabet, []), do: [?1] + defp encode_base58_value(0, _alphabet, acc), do: acc + + defp encode_base58_value(value, alphabet, acc) do + encode_base58_value(div(value, 58), alphabet, [Enum.at(alphabet, rem(value, 58)) | acc]) + end + defp maybe_put(map, true, key, value), do: Map.put(map, key, value) defp maybe_put(map, false, _key, _value), do: map end -- 2.51.2