diff --git a/.changeset/update-handle.md b/.changeset/update-handle.md new file mode 100644 index 0000000..8358f48 --- /dev/null +++ b/.changeset/update-handle.md @@ -0,0 +1,5 @@ +--- +"@getcirrus/pds": minor +--- + +Support changing your handle from apps like Bluesky. `com.atproto.identity.updateHandle` was not implemented, so the request was forwarded to the AppView and failed with "Failed to change handle". The PDS now checks that the new handle resolves to your DID, updates your PLC record for did:plc accounts when its signing key is a rotation key, stores the handle, and emits an identity event so relays pick it up. Changing the `HANDLE` variable still works and takes precedence over a handle set this way. diff --git a/packages/pds/README.md b/packages/pds/README.md index be0465c..9558dc8 100644 --- a/packages/pds/README.md +++ b/packages/pds/README.md @@ -379,6 +379,14 @@ _atproto.alice.example.com TXT "did=did:web:pds.example.com" dig TXT _atproto.alice.example.com ``` +### Changing Handle + +Change your handle from the handle settings in Bluesky or any other app that uses `com.atproto.identity.updateHandle`. Set up verification for the new handle first (a DNS TXT record, or the PDS hostname itself); the PDS rejects a handle that does not resolve to your DID. + +For did:plc accounts the handle is also listed in the PLC directory (`alsoKnownAs`). The PDS updates it for you if its signing key is one of the DID's [rotation keys](#rotation-and-recovery-keys). Accounts migrated with an earlier version of `pds identity` may not have it: run `pds rotation-keys` once to fix that. Alternatively, update `alsoKnownAs` with a rotation key you control (such as a recovery key), then change the handle in the app. The PDS sees the PLC directory already has the new handle and skips that step. + +The new handle is stored by the PDS. If you later change the `HANDLE` variable and redeploy, the variable takes precedence again. + ## Configuration The PDS uses environment variables for configuration. Public values go in `wrangler.jsonc`, secrets are stored via Wrangler or in `.dev.vars` for local development. diff --git a/packages/pds/src/account/store.ts b/packages/pds/src/account/store.ts index 7fd2570..a306e9a 100644 --- a/packages/pds/src/account/store.ts +++ b/packages/pds/src/account/store.ts @@ -16,9 +16,9 @@ function sqliteDatetimeToIso(value: string): string { /** * SQLite-backed storage for person-account data: preferences, email, - * passkeys, and app passwords. Shares the Durable Object's SQLite database - * with SqliteRepoStorage; the email accessors read the email column of the - * repo_state table, whose schema SqliteRepoStorage owns. + * handle, passkeys, and app passwords. Shares the Durable Object's SQLite + * database with SqliteRepoStorage; the email accessors read the email column + * of the repo_state table, whose schema SqliteRepoStorage owns. * * Extends RpcTarget so the Durable Object can hand it to the Worker as a * stub (via account()) — callers invoke store methods directly over RPC. @@ -63,9 +63,47 @@ export class AccountStore extends RpcTarget { password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + -- Handle set via com.atproto.identity.updateHandle (single row). + -- env_handle records the HANDLE var it replaced, so a later + -- change to HANDLE takes precedence again. + CREATE TABLE IF NOT EXISTS account_handle ( + id INTEGER PRIMARY KEY CHECK (id = 1), + handle TEXT NOT NULL, + env_handle TEXT NOT NULL + ); `); } + // ============================================ + // Handle Methods + // ============================================ + + /** + * Get the account's current handle. Returns the handle stored by + * updateHandle, unless the HANDLE var has changed since it was stored, + * in which case the var wins. + */ + getHandle(envHandle: string): string { + const rows = this.sql + .exec("SELECT handle, env_handle FROM account_handle WHERE id = 1") + .toArray(); + const row = rows[0]; + if (!row || row.env_handle !== envHandle) return envHandle; + return row.handle as string; + } + + /** + * Store a new handle, overriding the HANDLE var. + */ + setHandle(handle: string, envHandle: string): void { + this.sql.exec( + `INSERT OR REPLACE INTO account_handle (id, handle, env_handle) VALUES (1, ?, ?)`, + handle, + envHandle, + ); + } + /** * Get user preferences. */ diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index e949842..98137eb 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -116,8 +116,9 @@ function getAccountDO(env: PDSEnv) { } // DID document for did:web resolution -app.get("/.well-known/did.json", (c) => { - return c.json(identity.buildDidDocument(c.env)); +app.get("/.well-known/did.json", async (c) => { + const handle = await identity.getHandle(c.env, getAccountDO(c.env)); + return c.json(identity.buildDidDocument(c.env, handle)); }); // Handle verification for AT Protocol @@ -139,7 +140,8 @@ app.get("/xrpc/_health", async (c) => { }); // Homepage -app.get("/", (c) => { +app.get("/", async (c) => { + const handle = await identity.getHandle(c.env, getAccountDO(c.env)); const html = ` @@ -172,7 +174,7 @@ body {
☁️
CIRRUS
a personal data server for the atmosphere
-
@${c.env.HANDLE}
+
@${handle}
v${version}
`; @@ -180,11 +182,11 @@ body { }); // Status dashboard -app.get("/status", (c) => { +app.get("/status", async (c) => { return c.html( renderDashboard({ hostname: c.env.PDS_HOSTNAME, - handle: c.env.HANDLE, + handle: await identity.getHandle(c.env, getAccountDO(c.env)), did: c.env.DID, version, spacesEnabled: c.env.SPACES_ENABLED === "true", @@ -292,7 +294,7 @@ app.get( // Handle resolution - return our DID for our handle, let others fall through to proxy app.use("/xrpc/com.atproto.identity.resolveHandle", async (c, next) => { const handle = c.req.query("handle"); - if (handle === c.env.HANDLE) { + if (handle === (await identity.getHandle(c.env, getAccountDO(c.env)))) { return c.json({ did: c.env.DID }); } await next(); @@ -302,7 +304,11 @@ app.use("/xrpc/com.atproto.identity.resolveHandle", async (c, next) => { app.get( "/xrpc/com.atproto.identity.getRecommendedDidCredentials", requireAuth, - identity.getRecommendedDidCredentials, + (c) => identity.getRecommendedDidCredentials(c, getAccountDO(c.env)), +); + +app.post("/xrpc/com.atproto.identity.updateHandle", requireAuth, (c) => + identity.updateHandle(c, getAccountDO(c.env)), ); // Identity management for outbound migration @@ -418,7 +424,8 @@ app.post( requireAuth, async (c) => { const accountDO = getAccountDO(c.env); - const result = await accountDO.repo().emitIdentityEvent(c.env.HANDLE); + const handle = await identity.getHandle(c.env, accountDO); + const result = await accountDO.repo().emitIdentityEvent(handle); return c.json(result); }, ); @@ -496,7 +503,7 @@ app.get("/passkey/register", async (c) => { renderPasskeyRegistrationPage({ options, token, - handle: c.env.HANDLE, + handle: await identity.getHandle(c.env, accountDO), }), 200, { "Content-Security-Policy": csp }, diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts index 7605f4a..ef6c78c 100644 --- a/packages/pds/src/oauth.ts +++ b/packages/pds/src/oauth.ts @@ -25,6 +25,7 @@ import { import { compare } from "bcryptjs"; import type { PDSEnv } from "./types"; import type { AccountDurableObject } from "./account-do"; +import { getHandle } from "./xrpc/identity"; import { getAuthenticationOptions, verifyPasskeyAuthentication, @@ -141,7 +142,7 @@ export function getProvider(env: PDSEnv): ATProtoOAuthProvider { if (!valid) return null; return { sub: env.DID, - handle: env.HANDLE, + handle: await getHandle(env, accountDO), }; }, // Passkey authentication options @@ -163,7 +164,7 @@ export function getProvider(env: PDSEnv): ATProtoOAuthProvider { if (!result.success) return null; return { sub: env.DID, - handle: env.HANDLE, + handle: await getHandle(env, accountDO), }; }, // DO-SQLite-cached permission-set resolver for `include:` scopes. @@ -298,7 +299,7 @@ export function createOAuthApp( // sub is required, we also include preferred_username (handle) return c.json({ sub: tokenData.sub, - preferred_username: c.env.HANDLE, + preferred_username: await getHandle(c.env, getAccountDO(c.env)), }); }); diff --git a/packages/pds/src/xrpc/identity.ts b/packages/pds/src/xrpc/identity.ts index fb04a8b..d434f20 100644 --- a/packages/pds/src/xrpc/identity.ts +++ b/packages/pds/src/xrpc/identity.ts @@ -14,8 +14,15 @@ */ import type { Context } from "hono"; import { Secp256k1Keypair } from "@atproto/crypto"; +import { + CompositeHandleResolver, + DohJsonHandleResolver, + WellKnownHandleResolver, +} from "@atcute/identity-resolver"; +import { isHandle } from "@atcute/lexicons/syntax"; +import type { AccountDurableObject } from "../account-do"; import type { AuthedAppEnv, PDSEnv } from "../types"; -import { requireIdentityControl } from "../middleware/auth"; +import { requireIdentityControl, requireScope } from "../middleware/auth"; import { createMigrationToken, validateMigrationToken, @@ -29,12 +36,57 @@ import { type UnsignedPlcOperation, } from "../plc"; +/** + * TLDs that can never be used in a handle. + * See https://atproto.com/specs/handle#additional-non-syntax-restrictions + */ +const DISALLOWED_TLDS = [ + ".local", + ".arpa", + ".invalid", + ".localhost", + ".internal", + ".example", + ".alt", + ".onion", +]; + +/** + * Wrapper that always uses globalThis.fetch so it can be mocked in tests. + * @atcute resolvers capture the fetch reference at construction time. + */ +const stubbableFetch: typeof fetch = (input, init) => + globalThis.fetch(input, init); + +// DNS first, then /.well-known/atproto-did, matching the reference PDS. +const handleResolver = new CompositeHandleResolver({ + strategy: "dns-first", + methods: { + dns: new DohJsonHandleResolver({ + dohUrl: "https://cloudflare-dns.com/dns-query", + fetch: stubbableFetch, + }), + http: new WellKnownHandleResolver({ fetch: stubbableFetch }), + }, +}); + +/** + * Get the account's current handle: the one set by updateHandle, or the + * HANDLE var if it has not been changed. + */ +export function getHandle( + env: PDSEnv, + accountDO: DurableObjectStub, +): Promise { + return accountDO.account().getHandle(env.HANDLE); +} + /** * Build the DID document for the local account. * * Served by /.well-known/did.json. */ -export function buildDidDocument(env: PDSEnv) { +export function buildDidDocument(env: PDSEnv, handle: string) { return { "@context": [ "https://www.w3.org/ns/did/v1", @@ -42,7 +94,7 @@ export function buildDidDocument(env: PDSEnv) { "https://w3id.org/security/suites/secp256k1-2019/v1", ], id: env.DID, - alsoKnownAs: [`at://${env.HANDLE}`], + alsoKnownAs: [`at://${handle}`], verificationMethod: [ { id: `${env.DID}#atproto`, @@ -84,13 +136,15 @@ export function buildDidDocument(env: PDSEnv) { */ export async function getRecommendedDidCredentials( c: Context, + accountDO: DurableObjectStub, ): Promise { const keypair = await Secp256k1Keypair.import(c.env.SIGNING_KEY); const signingKey = keypair.did(); + const handle = await getHandle(c.env, accountDO); return c.json({ rotationKeys: [signingKey], - alsoKnownAs: [`at://${c.env.HANDLE}`], + alsoKnownAs: [`at://${handle}`], verificationMethods: { atproto: signingKey }, services: { atproto_pds: { @@ -255,6 +309,180 @@ export async function submitPlcOperation( return new Response(null, { status: 200 }); } +/** + * Change the account's handle. + * + * The new handle must already resolve to this DID (via DNS TXT or + * /.well-known/atproto-did). For did:plc accounts the handle is written to + * alsoKnownAs in the PLC directory, which needs our signing key to be a + * rotation key unless the directory already has it; for did:web accounts + * the served DID document reads the stored handle. An identity event then + * tells relays and AppViews to re-verify it. + * + * Endpoint: POST com.atproto.identity.updateHandle + */ +export async function updateHandle( + c: Context, + accountDO: DurableObjectStub, +): Promise { + // getServiceAuth mints these for any caller, so a self-addressed one would + // skip the identity:handle check below. The reference PDS refuses them too. + if (c.get("auth").method === "service") { + return c.json( + { + error: "InsufficientScope", + message: "Service auth tokens can't change the handle.", + }, + 403, + ); + } + + const scopeError = requireScope(c, (perms) => + perms.assertIdentity({ attr: "handle" }), + ); + if (scopeError) return scopeError; + + const body = await c.req + .json<{ handle?: unknown }>() + .catch(() => ({}) as { handle?: unknown }); + + if (typeof body.handle !== "string") { + return c.json( + { + error: "InvalidRequest", + message: "Missing required parameter: handle", + }, + 400, + ); + } + + const handle = body.handle.toLowerCase(); + if (!isHandle(handle)) { + return c.json( + { error: "InvalidHandle", message: `Invalid handle: ${handle}` }, + 400, + ); + } + if (DISALLOWED_TLDS.some((tld) => handle.endsWith(tld))) { + return c.json( + { + error: "InvalidHandle", + message: "Handle TLD is invalid or disallowed", + }, + 400, + ); + } + + // /.well-known/atproto-did on our own hostname always serves our DID, so + // there is nothing to look up for it. + if (handle !== c.env.PDS_HOSTNAME) { + const resolved = await handleResolver + .resolve(handle, { signal: AbortSignal.timeout(10000) }) + .catch(() => null); + if (resolved !== c.env.DID) { + return c.json( + { + error: "InvalidRequest", + message: `Handle ${handle} does not resolve to ${c.env.DID}. Add a DNS TXT record for _atproto.${handle} with the value "did=${c.env.DID}", or serve the DID at https://${handle}/.well-known/atproto-did.`, + }, + 400, + ); + } + } + + if (c.env.DID.startsWith("did:plc:")) { + const error = await updatePlcHandle(c, handle); + if (error) return error; + } + + await accountDO.account().setHandle(handle, c.env.HANDLE); + await accountDO.repo().emitIdentityEvent(handle); + + return new Response(null, { status: 200 }); +} + +/** + * Write the handle to alsoKnownAs in the PLC directory, replacing the + * existing at:// entry. Does nothing if the directory already has it. + * Returns an error response on failure, or null on success. + */ +async function updatePlcHandle( + c: Context, + handle: string, +): Promise { + const current = await getLatestPlcOperation(c.env.DID); + if (!current || current.operation.type !== "plc_operation") { + return c.json( + { + error: "InternalServerError", + message: "Could not fetch current PLC state", + }, + 500, + ); + } + + const { operation } = current; + const aka = `at://${handle}`; + const akaIndex = operation.alsoKnownAs.findIndex((uri) => + uri.startsWith("at://"), + ); + if (operation.alsoKnownAs[akaIndex] === aka) return null; + + const keypair = await Secp256k1Keypair.import(c.env.SIGNING_KEY); + if (!operation.rotationKeys.includes(keypair.did())) { + return c.json( + { + error: "InvalidRequest", + message: `This PDS's signing key is not a rotation key for ${c.env.DID}, so it cannot update the handle in the PLC directory. Run "pds rotation-keys" to give it control, or set alsoKnownAs to ${aka} with a rotation key you hold, then try again.`, + }, + 400, + ); + } + + // Replace the first at:// entry, which is the one handles are verified + // against, and drop any other copy of the new handle. + const alsoKnownAs = + akaIndex < 0 + ? [aka, ...operation.alsoKnownAs] + : operation.alsoKnownAs + .map((uri, i) => (i === akaIndex ? aka : uri)) + .filter((uri, i) => uri !== aka || i === akaIndex); + + const signedOp = await signOperation( + { + type: "plc_operation", + prev: current.cid, + rotationKeys: operation.rotationKeys, + verificationMethods: operation.verificationMethods, + alsoKnownAs, + services: operation.services, + }, + keypair, + ); + + const res = await fetch(`${PLC_DIRECTORY}/${c.env.DID}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(signedOp), + }); + + if (!res.ok) { + const message = await res.text(); + return new Response( + JSON.stringify({ + error: "PlcDirectoryError", + message: message || `PLC directory responded with ${res.status}`, + }), + { + status: res.status, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return null; +} + /** * Generate a migration token for the CLI. * diff --git a/packages/pds/src/xrpc/repo.ts b/packages/pds/src/xrpc/repo.ts index c34ddda..2dc9b7b 100644 --- a/packages/pds/src/xrpc/repo.ts +++ b/packages/pds/src/xrpc/repo.ts @@ -13,6 +13,7 @@ import { detectContentType } from "../format.js"; import { BlobStore, extractJsonBlobCids } from "../repo/blobs.js"; import { buildScopeChecker, requireScope } from "../middleware/auth.js"; import { pokeRelaysIfUnheard } from "../relay.js"; +import { getHandle } from "./identity.js"; /** * Promote any staged blobs referenced by the given records to their public @@ -151,14 +152,15 @@ export async function describeRepo( } const data = await accountDO.repo().describeRepo(); + const handle = await getHandle(c.env, accountDO); return c.json({ did: c.env.DID, - handle: c.env.HANDLE, + handle, didDoc: { "@context": ["https://www.w3.org/ns/did/v1"], id: c.env.DID, - alsoKnownAs: [`at://${c.env.HANDLE}`], + alsoKnownAs: [`at://${handle}`], verificationMethod: [ { id: `${c.env.DID}#atproto`, diff --git a/packages/pds/src/xrpc/server.ts b/packages/pds/src/xrpc/server.ts index 42f50d0..2f2deea 100644 --- a/packages/pds/src/xrpc/server.ts +++ b/packages/pds/src/xrpc/server.ts @@ -1,6 +1,7 @@ import type { Context } from "hono"; import { hash as bcryptHash } from "bcryptjs"; import type { AccountDurableObject } from "../account-do"; +import { getHandle } from "./identity"; import { createServiceJwt, getSigningKeypair } from "../service-auth"; import { getProvider } from "../oauth"; import { @@ -71,7 +72,8 @@ export async function createSession( } // Check identifier matches handle or DID - if (identifier !== c.env.HANDLE && identifier !== c.env.DID) { + const handle = await getHandle(c.env, accountDO); + if (identifier !== handle && identifier !== c.env.DID) { return c.json( { error: "AuthenticationRequired", @@ -139,7 +141,7 @@ export async function createSession( return c.json({ accessJwt, refreshJwt, - handle: c.env.HANDLE, + handle, did: c.env.DID, ...(email ? { email } : {}), emailConfirmed: true, @@ -208,7 +210,7 @@ export async function refreshSession( return c.json({ accessJwt, refreshJwt, - handle: c.env.HANDLE, + handle: await getHandle(c.env, accountDO), did: c.env.DID, ...(email ? { email } : {}), emailConfirmed: true, @@ -258,7 +260,7 @@ export async function getSession( const storedEmail = await accountDO.account().getEmail(); const email = storedEmail || c.env.EMAIL; return c.json({ - handle: c.env.HANDLE, + handle: await getHandle(c.env, accountDO), did: c.env.DID, ...(email ? { email } : {}), emailConfirmed: true, diff --git a/packages/pds/test/identity.test.ts b/packages/pds/test/identity.test.ts index df57dcb..12f8719 100644 --- a/packages/pds/test/identity.test.ts +++ b/packages/pds/test/identity.test.ts @@ -1,8 +1,11 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { Secp256k1Keypair, verifySignature } from "@atproto/crypto"; -import { encode } from "@atcute/cbor"; -import { base64url } from "jose"; -import { env, worker } from "./helpers"; +import { decode, encode } from "@atcute/cbor"; +import { base64url, calculateJwkThumbprint, SignJWT, type JWK } from "jose"; +import { env, runInDurableObject, worker } from "./helpers"; +import app from "../src/index"; +import type { AccountDurableObject } from "../src/account-do"; +import type { PDSEnv } from "../src/types"; import { createMigrationToken } from "../src/migration-token"; import { createAccessToken } from "../src/session"; import { createServiceJwt, getSigningKeypair } from "../src/service-auth"; @@ -454,3 +457,556 @@ describe("Identity Endpoints", () => { }); }); }); + +describe("com.atproto.identity.updateHandle", () => { + const NEW_HANDLE = "new-handle.example.com"; + const PLC_DID = "did:plc:ewvi7nxzyoun6zhxrhs64oiz"; + const accountStub = () => env.ACCOUNT.get(env.ACCOUNT.idFromName("account")); + + afterEach(async () => { + vi.unstubAllGlobals(); + // The account DO is shared across test files; drop any stored handle. + await runInDurableObject(accountStub(), (_instance, state) => { + state.storage.sql.exec("DELETE FROM account_handle"); + }); + }); + + /** + * Stub the network: DNS-over-HTTPS TXT lookups, /.well-known/atproto-did, + * and plc.directory. Any other request fails the test. + */ + function mockNetwork(opts: { + dns?: Record; + wellKnown?: Record; + plcOperation?: Record; + plcPostResponse?: Response; + }) { + const posted: Array> = []; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.origin === "https://cloudflare-dns.com") { + const name = url.searchParams.get("name")!; + const did = opts.dns?.[name.replace(/^_atproto\./, "")]; + return Response.json( + { + Status: did ? 0 : 3, + TC: false, + RD: true, + RA: true, + AD: false, + CD: false, + Question: [{ name, type: 16 }], + ...(did && { + Answer: [{ name, type: 16, TTL: 300, data: `"did=${did}"` }], + }), + }, + { headers: { "Content-Type": "application/dns-json" } }, + ); + } + if (url.pathname === "/.well-known/atproto-did") { + const did = opts.wellKnown?.[url.hostname]; + return did + ? new Response(did) + : new Response("Not Found", { status: 404 }); + } + if (url.origin === "https://plc.directory") { + if (init?.method === "POST") { + posted.push(JSON.parse(init.body as string)); + return opts.plcPostResponse ?? new Response(null, { status: 200 }); + } + if (url.pathname === `/${PLC_DID}/log/audit` && opts.plcOperation) { + return Response.json([ + { + did: PLC_DID, + operation: opts.plcOperation, + cid: "bafyreicurrentop", + nullified: false, + createdAt: new Date().toISOString(), + }, + ]); + } + return new Response("Not Found", { status: 404 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + ); + vi.stubGlobal("fetch", fetchMock); + return { fetchMock, posted }; + } + + /** + * POST updateHandle. With a custom `env` the Hono app is called directly, + * since the worker export always runs with the configured bindings. + */ + function updateHandle( + body: unknown, + opts: { env?: PDSEnv; headers?: Record } = {}, + ) { + const request = new Request( + "http://pds.test/xrpc/com.atproto.identity.updateHandle", + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(opts.headers ?? { Authorization: `Bearer ${env.AUTH_TOKEN}` }), + }, + body: JSON.stringify(body), + }, + ); + return opts.env ? app.fetch(request, opts.env) : worker.fetch(request, env); + } + + async function latestIdentityEvent(): Promise<{ + did: string; + handle?: string; + }> { + return runInDurableObject(accountStub(), (_instance, state) => { + const row = state.storage.sql + .exec( + "SELECT payload FROM firehose_events WHERE event_type = 'identity' ORDER BY seq DESC LIMIT 1", + ) + .one(); + return decode(new Uint8Array(row.payload as ArrayBuffer)) as { + did: string; + handle?: string; + }; + }); + } + + async function signingDid(): Promise { + return (await Secp256k1Keypair.import(env.SIGNING_KEY)).did(); + } + + it("requires authentication", async () => { + const response = await updateHandle( + { handle: NEW_HANDLE }, + { headers: {} }, + ); + expect(response.status).toBe(401); + }); + + it("refuses service JWTs, even when bound to the method", async () => { + mockNetwork({ dns: { [NEW_HANDLE]: env.DID } }); + const jwt = await createServiceJwt({ + iss: env.DID, + aud: `did:web:${env.PDS_HOSTNAME}`, + lxm: "com.atproto.identity.updateHandle", + keypair: await getSigningKeypair(env.SIGNING_KEY), + }); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { headers: { Authorization: `Bearer ${jwt}` } }, + ); + expect(response.status).toBe(403); + + await runInDurableObject(accountStub(), async (instance) => { + expect((await instance.account()).getHandle(env.HANDLE)).toBe(env.HANDLE); + }); + }); + + it("accepts app passwords, like the reference PDS", async () => { + mockNetwork({ dns: { [NEW_HANDLE]: env.DID } }); + const token = await createAccessToken( + env.JWT_SECRET, + env.DID, + `did:web:${env.PDS_HOSTNAME}`, + { appPassword: true }, + ); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { headers: { Authorization: `Bearer ${token}` } }, + ); + expect(response.status).toBe(200); + }); + + it("rejects a missing handle", async () => { + const response = await updateHandle({}); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("InvalidRequest"); + }); + + it("rejects an invalid handle", async () => { + const response = await updateHandle({ handle: "not a handle" }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("InvalidHandle"); + }); + + it("rejects a disallowed TLD without resolving it", async () => { + const { fetchMock } = mockNetwork({}); + const response = await updateHandle({ handle: "alice.local" }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("InvalidHandle"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects a handle that does not resolve to this DID", async () => { + mockNetwork({ dns: { [NEW_HANDLE]: "did:plc:someoneelse234567abcdefg" } }); + const response = await updateHandle({ handle: NEW_HANDLE }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string; message: string }; + expect(body.error).toBe("InvalidRequest"); + expect(body.message).toContain(`_atproto.${NEW_HANDLE}`); + + await runInDurableObject(accountStub(), async (instance) => { + expect((await instance.account()).getHandle(env.HANDLE)).toBe(env.HANDLE); + }); + }); + + it("updates the handle everywhere for a did:web account", async () => { + mockNetwork({ dns: { [NEW_HANDLE]: env.DID } }); + + // Clients may send mixed case; handles are stored lowercased. + const response = await updateHandle({ handle: "New-Handle.Example.com" }); + expect(response.status).toBe(200); + + const didDoc = (await ( + await worker.fetch( + new Request("http://pds.test/.well-known/did.json"), + env, + ) + ).json()) as { alsoKnownAs: string[] }; + expect(didDoc.alsoKnownAs).toEqual([`at://${NEW_HANDLE}`]); + + const described = (await ( + await worker.fetch( + new Request( + `http://pds.test/xrpc/com.atproto.repo.describeRepo?repo=${env.DID}`, + ), + env, + ) + ).json()) as { handle: string }; + expect(described.handle).toBe(NEW_HANDLE); + + const resolved = (await ( + await worker.fetch( + new Request( + `http://pds.test/xrpc/com.atproto.identity.resolveHandle?handle=${NEW_HANDLE}`, + ), + env, + ) + ).json()) as { did: string }; + expect(resolved.did).toBe(env.DID); + + const session = await worker.fetch( + new Request("http://pds.test/xrpc/com.atproto.server.createSession", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: NEW_HANDLE, + password: "test-password", + }), + }), + env, + ); + expect(session.status).toBe(200); + expect(((await session.json()) as { handle: string }).handle).toBe( + NEW_HANDLE, + ); + + const oldSession = await worker.fetch( + new Request("http://pds.test/xrpc/com.atproto.server.createSession", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: env.HANDLE, + password: "test-password", + }), + }), + env, + ); + expect(oldSession.status).toBe(401); + + expect(await latestIdentityEvent()).toMatchObject({ + did: env.DID, + handle: NEW_HANDLE, + }); + }); + + it("accepts a handle verified by /.well-known/atproto-did", async () => { + mockNetwork({ wellKnown: { [NEW_HANDLE]: env.DID } }); + const response = await updateHandle({ handle: NEW_HANDLE }); + expect(response.status).toBe(200); + }); + + it("accepts the PDS hostname without looking it up", async () => { + const { fetchMock } = mockNetwork({}); + const response = await updateHandle({ handle: env.PDS_HOSTNAME }); + expect(response.status).toBe(200); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("lets a changed HANDLE var take precedence over a stored handle", async () => { + await runInDurableObject(accountStub(), async (instance) => { + const store = await instance.account(); + store.setHandle(NEW_HANDLE, env.HANDLE); + expect(store.getHandle(env.HANDLE)).toBe(NEW_HANDLE); + expect(store.getHandle("redeployed.example.com")).toBe( + "redeployed.example.com", + ); + }); + }); + + describe("did:plc accounts", () => { + const plcEnv = { ...env, DID: PLC_DID } as PDSEnv; + + async function currentOperation( + overrides: Record = {}, + ): Promise> { + const key = await signingDid(); + return { + type: "plc_operation", + prev: null, + rotationKeys: ["did:key:zQ3shRecoveryKeyExample", key], + verificationMethods: { atproto: key }, + alsoKnownAs: ["at://old-handle.example.com", "https://example.com/me"], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: `https://${env.PDS_HOSTNAME}`, + }, + }, + sig: "c2ln", + ...overrides, + }; + } + + it("writes the new handle to the PLC directory, signed with the signing key", async () => { + const current = await currentOperation(); + const { posted } = mockNetwork({ + dns: { [NEW_HANDLE]: PLC_DID }, + plcOperation: current, + }); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { env: plcEnv }, + ); + expect(response.status).toBe(200); + expect(posted).toHaveLength(1); + + const { sig, ...unsigned } = posted[0]!; + expect(unsigned).toEqual({ + type: "plc_operation", + prev: "bafyreicurrentop", + rotationKeys: current.rotationKeys, + verificationMethods: current.verificationMethods, + alsoKnownAs: [`at://${NEW_HANDLE}`, "https://example.com/me"], + services: current.services, + }); + expect( + await verifySignature( + await signingDid(), + encode(unsigned), + base64url.decode(sig as string), + ), + ).toBe(true); + + await runInDurableObject(accountStub(), async (instance) => { + expect((await instance.account()).getHandle(env.HANDLE)).toBe( + NEW_HANDLE, + ); + }); + }); + + it("skips the PLC update when the directory already has the handle", async () => { + const { posted } = mockNetwork({ + dns: { [NEW_HANDLE]: PLC_DID }, + plcOperation: await currentOperation({ + alsoKnownAs: [`at://${NEW_HANDLE}`], + }), + }); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { env: plcEnv }, + ); + expect(response.status).toBe(200); + expect(posted).toHaveLength(0); + }); + + it("adds the handle when alsoKnownAs has no at:// entry", async () => { + const { posted } = mockNetwork({ + dns: { [NEW_HANDLE]: PLC_DID }, + plcOperation: await currentOperation({ + alsoKnownAs: ["https://example.com/me"], + }), + }); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { env: plcEnv }, + ); + expect(response.status).toBe(200); + expect(posted[0]?.alsoKnownAs).toEqual([ + `at://${NEW_HANDLE}`, + "https://example.com/me", + ]); + }); + + it("does not duplicate the handle when alsoKnownAs already lists it later", async () => { + const { posted } = mockNetwork({ + dns: { [NEW_HANDLE]: PLC_DID }, + plcOperation: await currentOperation({ + alsoKnownAs: ["at://old-handle.example.com", `at://${NEW_HANDLE}`], + }), + }); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { env: plcEnv }, + ); + expect(response.status).toBe(200); + expect(posted[0]?.alsoKnownAs).toEqual([`at://${NEW_HANDLE}`]); + }); + + it("does not store the handle when the PLC state cannot be fetched", async () => { + const { posted } = mockNetwork({ dns: { [NEW_HANDLE]: PLC_DID } }); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { env: plcEnv }, + ); + expect(response.status).toBe(500); + expect(posted).toHaveLength(0); + + await runInDurableObject(accountStub(), async (instance) => { + expect((await instance.account()).getHandle(env.HANDLE)).toBe( + env.HANDLE, + ); + }); + }); + + it("refuses when the signing key is not a rotation key", async () => { + const { posted } = mockNetwork({ + dns: { [NEW_HANDLE]: PLC_DID }, + plcOperation: await currentOperation({ + rotationKeys: ["did:key:zQ3shSomeoneElsesKeyExample"], + }), + }); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { env: plcEnv }, + ); + expect(response.status).toBe(400); + const body = (await response.json()) as { message: string }; + expect(body.message).toContain("not a rotation key"); + expect(body.message).toContain("pds rotation-keys"); + expect(posted).toHaveLength(0); + + await runInDurableObject(accountStub(), async (instance) => { + expect((await instance.account()).getHandle(env.HANDLE)).toBe( + env.HANDLE, + ); + }); + }); + + it("does not store the handle when the PLC directory rejects the update", async () => { + mockNetwork({ + dns: { [NEW_HANDLE]: PLC_DID }, + plcOperation: await currentOperation(), + plcPostResponse: new Response("invalid signature", { status: 400 }), + }); + + const response = await updateHandle( + { handle: NEW_HANDLE }, + { env: plcEnv }, + ); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("PlcDirectoryError"); + + await runInDurableObject(accountStub(), async (instance) => { + expect((await instance.account()).getHandle(env.HANDLE)).toBe( + env.HANDLE, + ); + }); + }); + }); + + describe("OAuth scopes", () => { + async function oauthRequest(scope: string, accessToken: string) { + const kp = (await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const publicJwk = (await crypto.subtle.exportKey( + "jwk", + kp.publicKey, + )) as JWK; + delete publicJwk.key_ops; + delete publicJwk.ext; + const dpopJkt = await calculateJwkThumbprint(publicJwk, "sha256"); + + await runInDurableObject( + accountStub(), + async (instance: AccountDurableObject) => { + await ( + await instance.authStore() + ).saveTokens({ + accessToken, + refreshToken: `refresh-${accessToken}`, + clientId: "did:web:client.example.com", + sub: env.DID, + scope, + dpopJkt, + issuedAt: Date.now(), + accessExpiresAt: Date.now() + 3600_000, + refreshExpiresAt: Date.now() + 90 * 24 * 3600_000, + }); + }, + ); + + const url = "http://pds.test/xrpc/com.atproto.identity.updateHandle"; + const ath = base64url.encode( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken), + ), + ), + ); + const dpop = await new SignJWT({ htm: "POST", htu: url, ath }) + .setProtectedHeader({ + typ: "dpop+jwt", + alg: "ES256", + jwk: publicJwk, + }) + .setIssuedAt() + .setJti(base64url.encode(crypto.getRandomValues(new Uint8Array(16)))) + .sign(kp.privateKey); + + return updateHandle( + { handle: NEW_HANDLE }, + { headers: { Authorization: `DPoP ${accessToken}`, DPoP: dpop } }, + ); + } + + it("rejects a token without identity:handle", async () => { + mockNetwork({ dns: { [NEW_HANDLE]: env.DID } }); + const response = await oauthRequest( + "atproto transition:generic", + "tok-update-handle-generic", + ); + expect(response.status).toBe(403); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("InsufficientScope"); + }); + + it("accepts a token with identity:handle", async () => { + mockNetwork({ dns: { [NEW_HANDLE]: env.DID } }); + const response = await oauthRequest( + "atproto identity:handle", + "tok-update-handle-identity", + ); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/plans/todo/endpoint-implementation.md b/plans/todo/endpoint-implementation.md index 59b9706..b4bf4ae 100644 --- a/plans/todo/endpoint-implementation.md +++ b/plans/todo/endpoint-implementation.md @@ -60,11 +60,12 @@ This document tracks the implementation status of all AT Protocol XRPC endpoints | `getSession` | ✅ Complete | Current session info | | `refreshSession` | ✅ Complete | Token refresh with validation | -### com.atproto.identity (1/6 - 17%) +### com.atproto.identity (2/6 - 33%) -| Endpoint | Status | Notes | -| --------------- | ---------- | ---------------------------------------------------- | -| `resolveHandle` | ⚠️ Partial | Complete implementation (DNS + HTTPS for any handle) | +| Endpoint | Status | Notes | +| --------------- | ----------- | ----------------------------------------------------- | +| `resolveHandle` | ⚠️ Partial | Complete implementation (DNS + HTTPS for any handle) | +| `updateHandle` | ✅ Complete | Verifies handle, updates PLC for did:plc, emits event | ### app.bsky.\* (3 endpoints)