From 71eea268f3fe3d080b6d12b8ccf73ecb84256a94 Mon Sep 17 00:00:00 2001 From: NatureDesk Date: Sat, 11 Jul 2026 10:33:38 +0200 Subject: [PATCH] fix: route key public keys safely --- sdk/python/src/tinyplace/api/keys.py | 30 +++++++++++-- sdk/python/tests/test_api_namespaces.py | 28 ++++++++++++ sdk/typescript/src/api/keys.ts | 33 +++++++++++--- sdk/typescript/src/errors.ts | 19 +++++++-- sdk/typescript/tests/keys.test.ts | 45 ++++++++++++++++++++ sdk/typescript/tests/tinyplace-error.test.ts | 7 +++ 6 files changed, 149 insertions(+), 13 deletions(-) diff --git a/sdk/python/src/tinyplace/api/keys.py b/sdk/python/src/tinyplace/api/keys.py index 9071ca40..9498f1b0 100644 --- a/sdk/python/src/tinyplace/api/keys.py +++ b/sdk/python/src/tinyplace/api/keys.py @@ -9,24 +9,46 @@ def __init__(self, http: HttpClient) -> None: self._http = http async def get_bundle(self, agent_id: str) -> Json: - return await self._http.get(f"/keys/{encode(agent_id)}/bundle") + return await self._http.get(_key_path(agent_id, "bundle")) async def health(self, agent_id: str) -> Json: return await self._http.get_directory_auth_as( - f"/keys/{encode(agent_id)}/health", + _key_path(agent_id, "health"), agent_id, ) async def upload_pre_keys(self, agent_id: str, request: JsonDict) -> None: await self._http.put_directory_auth_as( - f"/keys/{encode(agent_id)}/prekeys", + _key_path(agent_id, "prekeys"), agent_id, request, ) async def rotate_signed_pre_key(self, agent_id: str, request: JsonDict) -> None: await self._http.put_directory_auth_as( - f"/keys/{encode(agent_id)}/signed-prekey", + _key_path(agent_id, "signed-prekey"), agent_id, request, ) + + +def _key_path(agent_id: str, suffix: str) -> str: + return f"/keys/{_key_route_id(agent_id)}/{suffix}" + + +def _key_route_id(agent_id: str) -> str: + if _looks_like_base64_public_key(agent_id): + return f"by-public-key/{_base64_to_base64url(agent_id)}" + return encode(agent_id) + + +def _looks_like_base64_public_key(value: str) -> bool: + if len(value) < 40 or not any(marker in value for marker in "+/="): + return False + return all(char.isalnum() or char in "+/=" for char in value) and ( + value.rstrip("=").count("=") == 0 + ) + + +def _base64_to_base64url(value: str) -> str: + return value.replace("+", "-").replace("/", "_").rstrip("=") diff --git a/sdk/python/tests/test_api_namespaces.py b/sdk/python/tests/test_api_namespaces.py index d810c0e1..955d19bd 100644 --- a/sdk/python/tests/test_api_namespaces.py +++ b/sdk/python/tests/test_api_namespaces.py @@ -41,6 +41,34 @@ async def test_keys_docs_and_search_routes() -> None: assert session.requests[1]["headers"]["X-Agent-ID"] == "@agent" +async def test_keys_routes_base64_public_keys_as_url_safe_segments() -> None: + signer = LocalSigner.from_seed(bytes([25]) * 32) + session = FakeSession([FakeResponse(200, {"ok": True}) for _ in range(4)]) + client = TinyPlaceClient( + base_url="https://api.example.test", + signer=signer, + session=session, # type: ignore[arg-type] + ) + agent_id = "h71YEo+jgSQJ8bG0msq/ES3I4A0K9oKRA5Eqq3yY4Q8=" + + await client.keys.get_bundle(agent_id) + await client.keys.health(agent_id) + await client.keys.upload_pre_keys(agent_id, {"preKeys": []}) + await client.keys.rotate_signed_pre_key(agent_id, {"signedPreKey": {}}) + + paths = [ + request["url"].replace("https://api.example.test", "") + for request in session.requests + ] + assert paths == [ + "/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/bundle", + "/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/health", + "/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/prekeys", + "/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/signed-prekey", + ] + assert session.requests[1]["headers"]["X-Agent-ID"] == agent_id + + async def test_directory_routes() -> None: session = FakeSession([FakeResponse(200, {"ok": True}) for _ in range(9)]) signer = LocalSigner.from_seed(bytes([23]) * 32) diff --git a/sdk/typescript/src/api/keys.ts b/sdk/typescript/src/api/keys.ts index d6ded29f..07484c40 100644 --- a/sdk/typescript/src/api/keys.ts +++ b/sdk/typescript/src/api/keys.ts @@ -10,21 +10,19 @@ export class KeysApi { constructor(private readonly http: HttpClient) {} getBundle(agentId: string): Promise { - return this.http.get( - `/keys/${encodeURIComponent(agentId)}/bundle`, - ); + return this.http.get(keyPath(agentId, "bundle")); } health(agentId: string): Promise { return this.http.getDirectoryAuthAs( - `/keys/${encodeURIComponent(agentId)}/health`, + keyPath(agentId, "health"), agentId, ); } uploadPreKeys(agentId: string, request: PreKeysRequest): Promise { return this.http.putDirectoryAuthAs( - `/keys/${encodeURIComponent(agentId)}/prekeys`, + keyPath(agentId, "prekeys"), agentId, request, ); @@ -35,9 +33,32 @@ export class KeysApi { request: SignedPreKeyRequest, ): Promise { return this.http.putDirectoryAuthAs( - `/keys/${encodeURIComponent(agentId)}/signed-prekey`, + keyPath(agentId, "signed-prekey"), agentId, request, ); } } + +function keyPath(agentId: string, suffix: string): string { + return `/keys/${keyRouteId(agentId)}/${suffix}`; +} + +function keyRouteId(agentId: string): string { + if (looksLikeBase64PublicKey(agentId)) { + return `by-public-key/${base64ToBase64Url(agentId)}`; + } + return encodeURIComponent(agentId); +} + +function looksLikeBase64PublicKey(value: string): boolean { + return ( + value.length >= 40 && + /[+/=]/.test(value) && + /^[A-Za-z0-9+/]+={0,2}$/.test(value) + ); +} + +function base64ToBase64Url(value: string): string { + return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index a13fed9d..bfe1efbb 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -19,6 +19,7 @@ export type TinyPlaceErrorCode = | "payment_required" | "auth_invalid" + | "admin_not_configured" | "handle_taken" | "not_found" | "rate_limited" @@ -33,6 +34,7 @@ export type TinyPlaceErrorCode = export const TINYPLACE_ERROR_CODES: ReadonlyArray = [ "payment_required", "auth_invalid", + "admin_not_configured", "handle_taken", "not_found", "rate_limited", @@ -70,6 +72,8 @@ const HINTS: Record = { "Payment required (x402 challenge). Settle it (e.g. `tinyplace pay --data ''`), then retry the original call.", auth_invalid: "Authentication rejected. Confirm your signing key matches the registered identity (TINYPLACE_SECRET_KEY); if you have no identity yet, run `tinyplace init` then `tinyplace register`.", + admin_not_configured: + "Admin approval is not configured. Stop retrying and configure an admin signing route or ask platform support to approve/recover the bounty.", handle_taken: "That @handle is already claimed — choose a different handle.", not_found: "Not found. Re-check the id or @handle; resolve a handle first with `tinyplace resolve @name`.", @@ -90,6 +94,7 @@ const HINTS: Record = { const RETRYABLE: Record = { payment_required: false, auth_invalid: false, + admin_not_configured: false, handle_taken: false, not_found: false, rate_limited: true, @@ -104,6 +109,8 @@ const RETRYABLE: Record = { const WHEN: Record = { payment_required: "HTTP 402, or a response carrying an x402 payment challenge.", auth_invalid: "HTTP 401/403 — the request signature or session was rejected.", + admin_not_configured: + "The bounty approval endpoint reports that admin approval is not configured.", handle_taken: "HTTP 409 (or a body) indicating the @handle/username is already claimed.", not_found: "HTTP 404 — the id or @handle does not exist.", @@ -158,11 +165,14 @@ function deriveCode(error: unknown): TinyPlaceErrorCode { return "payment_required"; } - // 2. Local SDK-thrown errors (no HTTP status). + // 2. Configuration failures that need operator action, not blind retries. + if (ADMIN_APPROVAL_NOT_CONFIGURED.test(text)) return "admin_not_configured"; + + // 3. Local SDK-thrown errors (no HTTP status). if (name === "TinyPlaceValidationError") return "validation"; if (status === undefined && SIGNER_REQUIRED.test(text)) return "no_signer"; - // 3. Status-driven classification. + // 4. Status-driven classification. if (status !== undefined) { if (status === 0) return "transient"; if (status === 429) return "rate_limited"; @@ -180,7 +190,7 @@ function deriveCode(error: unknown): TinyPlaceErrorCode { if (status >= 400 && HANDLE_TAKEN.test(text)) return "handle_taken"; } - // 4. Non-HTTP fallbacks. + // 5. Non-HTTP fallbacks. if (HANDLE_TAKEN.test(text)) return "handle_taken"; return "unknown"; } @@ -188,6 +198,9 @@ function deriveCode(error: unknown): TinyPlaceErrorCode { const SIGNER_REQUIRED = /requires? (a )?(signing key|signer)|needs? a (wallet|signer)|secret_key|no signer/i; +const ADMIN_APPROVAL_NOT_CONFIGURED = + /admin approval is not configured|approval.*admin.*not configured/i; + const HANDLE_TAKEN = /(handle|username|name)[^.]*(taken|exists|already|registered|claimed|conflict)|(taken|exists|already|registered|claimed)[^.]*(handle|username)/i; diff --git a/sdk/typescript/tests/keys.test.ts b/sdk/typescript/tests/keys.test.ts index 82d81e4e..afd99c68 100644 --- a/sdk/typescript/tests/keys.test.ts +++ b/sdk/typescript/tests/keys.test.ts @@ -55,4 +55,49 @@ describe("KeysApi", () => { "https://example.test/keys/%40agent/signed-prekey", ); }); + + it("routes base64 public keys through the URL-safe key route", async () => { + const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(59)); + const agentId = "h71YEo+jgSQJ8bG0msq/ES3I4A0K9oKRA5Eqq3yY4Q8="; + const requests: Array = []; + const client = new TinyPlaceClient({ + baseUrl: "https://example.test", + signer, + fetch: async (input, init) => { + const request = new Request(input, init); + requests.push(request); + if (request.method === "GET") { + return Response.json({ + agentId, + oneTimePreKeyCount: 0, + lowOneTimePreKeys: true, + }); + } + return new Response(null, { status: 204 }); + }, + }); + + await client.keys.getBundle(agentId); + await client.keys.health(agentId); + await client.keys.uploadPreKeys(agentId, { + identityKey: signer.publicKeyBase64, + preKeys: [{ keyId: "opk_1", publicKey: "pub", signature: "sig" }], + }); + await client.keys.rotateSignedPreKey(agentId, { + identityKey: signer.publicKeyBase64, + signedPreKey: { keyId: "spk_1", publicKey: "pub", signature: "sig" }, + }); + + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + "/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/bundle", + "/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/health", + "/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/prekeys", + "/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/signed-prekey", + ]); + + for (const request of requests.slice(1)) { + expect(request.headers.get("X-Agent-ID")).toBe(agentId); + expect(request.headers.get("X-TinyPlace-Signature")).toBeTruthy(); + } + }); }); diff --git a/sdk/typescript/tests/tinyplace-error.test.ts b/sdk/typescript/tests/tinyplace-error.test.ts index 1b650b12..755a3549 100644 --- a/sdk/typescript/tests/tinyplace-error.test.ts +++ b/sdk/typescript/tests/tinyplace-error.test.ts @@ -25,6 +25,13 @@ describe("TinyPlaceError self-classification", () => { expect(error.paymentRequired?.payment.amount).toBe("1000"); }); + it("classifies missing admin approval configuration as operator action", () => { + const error = new TinyPlaceError(503, "admin approval is not configured"); + expect(error.code).toBe("admin_not_configured"); + expect(error.retryable).toBe(false); + expect(error.hint).toMatch(/configure an admin signing route/i); + }); + it("parses the challenge from the standard x402 v2 accepts[] array", () => { const error = new TinyPlaceError(402, { error: "payment required",