From ddfe19bb9d12a0b9a3e4bcd69e18b357522f627b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Oct 2026 06:35:38 +0100 Subject: [PATCH 1/8] docs(plans): plan PLC rotation and recovery keys --- plans/todo/plc-rotation-keys.md | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 plans/todo/plc-rotation-keys.md diff --git a/plans/todo/plc-rotation-keys.md b/plans/todo/plc-rotation-keys.md new file mode 100644 index 0000000..b1b63e5 --- /dev/null +++ b/plans/todo/plc-rotation-keys.md @@ -0,0 +1,138 @@ +# PLC Rotation Keys and Recovery Keys + +**Status:** ๐Ÿ“‹ Planning +**Priority:** P1 (affects every did:plc account migrated to Cirrus) + +## Problem + +`pds identity` asks the source PDS to sign a PLC operation that changes the PDS endpoint and the atproto verification method, but passes `rotationKeys: undefined` (`packages/pds/src/cli/utils/plc-client.ts`). The source PDS keeps the rotation keys unchanged, so after migration: + +- The Cirrus signing key is a verification method only. Cirrus cannot sign PLC operations for the account. +- The old PDS (for bsky.social, Bluesky's PLC key) still holds a rotation key and can rewrite the DID, including the PDS endpoint. +- Every later DID change has to go through the old PDS: handle changes (`updateHandle`, PR #250), migrating away from Cirrus, and rotating a leaked signing key. +- If the user deletes their old account and never added a recovery key, nobody they can reach can change the DID. +- Outbound migration from Cirrus is broken for these accounts. `signPlcOperation` (`packages/pds/src/xrpc/identity.ts`) signs with `SIGNING_KEY` without checking it is a rotation key, so plc.directory rejects the result. + +Cirrus already recommends its signing key as a rotation key in `getRecommendedDidCredentials`. The CLI never uses it. + +Nothing in the CLI or docs mentions rotation or recovery keys, although the root README's Key Safety section assumes users may have one. + +## Target state + +After migration, a did:plc account's rotation keys are, in priority order: + +1. User-held recovery key(s), kept offline, if any. +2. The Cirrus signing key, as reported by the deployed PDS. + +The old PDS's key is removed. Cirrus can make routine identity changes on its own. A recovery key ranked above it can override a Cirrus-signed operation for 72 hours, and can update the DID if Cirrus is lost. + +did:web accounts are unaffected; they have no rotation keys. + +## Decisions + +| Decision | Choice | Notes | +| --------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Old PDS rotation key | Removed | Keeping it leaves the old host able to redirect the DID. | +| Recovery key | Offered by default, skippable | Skipping still leaves Cirrus as a rotation key, which fixes the bugs above. | +| Where the Cirrus rotation key lives | `SIGNING_KEY` | Same key as the verification method. A separate secret would sit in the same Worker secrets, so the offline recovery key is the real protection. | +| Source of the Cirrus key | The deployed PDS's `getRecommendedDidCredentials` | Not the local `.dev.vars` copy, which may differ from what is deployed. | +| Recovery key type | secp256k1, generated locally by the CLI | Never sent to Cloudflare or written to `.dev.vars`. | +| Emergency override (nullifying a malicious op within 72h) | Documented, using `goat` | Building a nullification flow is a separate piece of work. | + +**Open:** the export format for the recovery key. It must be usable by `goat` for the emergency path. Check which private key encodings `goat plc` accepts (hex vs multibase) before implementing, and print the key in that form alongside the `did:key`. + +## Changes + +Each phase is one commit, building and passing tests on its own. + +### Phase 1: refuse unsignable operations on the server + +`packages/pds/src/xrpc/identity.ts` + +- `signPlcOperation`: after fetching the current operation, return `400 InvalidRequest` if `SIGNING_KEY`'s `did:key` is not in `currentOp.operation.rotationKeys`. The message says this PDS cannot sign identity changes for the DID and points to `pds rotation-keys`. +- Move `signOperation`, `getLatestPlcOperation` and the PLC operation types into a shared module (`packages/pds/src/plc.ts`) so the CLI can sign locally in phase 4. PR #250's `updatePlcHandle` uses the same helpers; whichever lands second rebases onto the shared module. + +Tests (`packages/pds/test/identity.test.ts`): rejects when the key is not a rotation key, signs when it is, preserves fields not in the request body. + +### Phase 2: CLI building blocks + +`packages/pds/src/cli/utils/plc-client.ts` + +- `SourcePdsPlcClient.signPlcOperation` takes `rotationKeys` and passes it through instead of `undefined`. +- `SourcePdsPlcClient.getRecommendedDidCredentials()`, used to identify which current rotation keys belong to the source PDS. +- `PlcDirectoryClient.getLatestOperation(did)`: last non-nullified entry from the audit log. + +`packages/pds/src/cli/utils/pds-client.ts` + +- `getRecommendedDidCredentials()`, `signPlcOperation(token, fields)` and `submitPlcOperation(op)` against the user's own PDS. With `getMigrationToken()` these let the deployed PDS sign with its own key, so the CLI never needs the signing key locally. + +`packages/pds/src/cli/utils/rotation-keys.ts` (new, pure functions) + +- `buildRotationKeys({ userKeys, pdsKey })`: user keys first, then the PDS key; deduplicated; rejects more than five keys or anything that is not a `did:key`. +- `labelRotationKeys(current, { pdsKey, sourcePdsKeys })`: tags each key as this PDS, the source PDS, or unknown, for display and for deciding what to keep. + +`packages/pds/src/cli/utils/secrets.ts` and `cli-helpers.ts` + +- Extract the signing key backup prompt in `init.ts` (1Password, clipboard, file, show, skip) into a helper that takes what is being backed up. Parameterise `saveTo1Password` and `saveKeyBackup` titles and warnings so they work for a recovery key. `init.ts` switches to the helper with no change in behaviour. +- `generateRecoveryKey()`: secp256k1 keypair, returned in the export format chosen above plus its `did:key`. +- `importRotationKey(input)`: parses a pasted private key (secp256k1, and P-256 if `goat` keys may be P-256) and returns a keypair. + +Tests in `packages/pds/test/cli/`: `rotation-keys.test.ts` (ordering, dedupe, limits, labelling), additions to `plc-client.test.ts` (rotation keys sent, recommended credentials parsed), and key generation/import round trips. + +### Phase 3: set rotation keys during migration + +`packages/pds/src/cli/commands/identity.ts` + +1. Fetch the Cirrus key from the deployed PDS's recommended credentials. If a local `SIGNING_KEY` exists and derives a different `did:key`, stop with an error. Use this key for both `verificationMethods.atproto` and the rotation key, replacing the local derivation. +2. Read the DID's current rotation keys from plc.directory. +3. When the command has a source session (no `--token`), fetch the source PDS's recommended credentials and label its keys. With `--token` there is no session, so every existing key is unknown. +4. For each key not belonging to the source PDS, ask whether the user holds it. Confirmed keys are kept, in their existing order. +5. If no user-held key remains, offer to generate a recovery key (default yes). Run the backup helper, then require the user to confirm it is saved before continuing. +6. Show the resulting rotation keys, with labels, in the existing "DID Update" note. +7. Pass the rotation keys to the source PDS's `signPlcOperation`. +8. After submitting, read the latest operation back and check the rotation keys and endpoint match. Warn with the expected and actual lists if not. + +### Phase 4: `pds rotation-keys` for existing accounts + +New command `packages/pds/src/cli/commands/rotation-keys.ts`, registered in `packages/pds/src/cli/index.ts`. did:web accounts exit with an explanation. + +1. Show the current rotation keys, labelled as this PDS or other. +2. **If this PDS is not a rotation key**, choose who signs the fix: + - The previous PDS: log in with its password, request an email token, and have it sign an operation with the new rotation keys. Works only while the old account exists. The source PDS URL is not in the DID document any more, so ask for it, defaulting to bsky.social. + - A rotation key the user holds: paste it, check its `did:key` is in the current list, and sign locally with the shared helper from phase 1. + Key selection, recovery key generation and backup follow phase 3 steps 4 to 6. +3. **If this PDS is a rotation key but there is no other key**, offer to generate and add a recovery key. The deployed PDS signs via `getMigrationToken`, `signPlcOperation` and `submitPlcOperation`. +4. Submit, then verify as in phase 3 step 8. + +Every operation keeps `services`, `verificationMethods` and `alsoKnownAs` from the current operation. + +### Phase 5: surface it in `pds status` + +`packages/pds/src/cli/commands/status.ts`, did:plc only: + +- โœ“ when this PDS's key is a rotation key, โœ— otherwise, with a hint to run `pds rotation-keys`. +- โš  when it is the only rotation key, suggesting a recovery key. + +### Phase 6: docs and release + +- `packages/pds/README.md`: update migration step 3 to describe the rotation key and recovery key prompts. Add a "Rotation and recovery keys" section: what they are, the 72-hour override, how to fix an already-migrated account, and how to use the recovery key with `goat` in an emergency. +- Root `README.md` Key Safety: the "If You've Lost Your Key" section should explain that the Cirrus signing key is also a rotation key, so losing it leaves the recovery key as the only way to update the DID. +- `plans/complete/migration-wizard.md`: note rotation key handling in the identity step. +- Changeset, `@getcirrus/pds` minor, describing the user-visible change: migration now gives your PDS control of your identity and offers a recovery key; existing accounts can run `pds rotation-keys`; leaving Cirrus reports a clear error instead of a PLC rejection. + +## Verification + +PLC operations are permanent, so manual testing uses throwaway did:plc accounts, never a real one. + +- Migrate a fresh bsky.social test account with the updated `pds identity`, with and without generating a recovery key. Check the audit log shows the expected rotation keys and Bluesky's key is gone. +- Run `pds rotation-keys` on an account migrated with the current CLI, signing through the old PDS. +- Repeat with the old account deleted and a pre-existing recovery key. +- Change the handle through PR #250's `updateHandle` on a fixed account; the PLC step should succeed. +- Migrate the fixed account out of Cirrus to another PDS. +- Use the generated recovery key with `goat` to sign an operation, confirming the export format works. + +## Risks + +- **Writing the wrong rotation keys can lock the user out.** Mitigations: the Cirrus key comes from the deployed PDS, user-held keys are only dropped when the user says they don't hold them, the full list is shown before signing, and the result is read back after submitting. +- **The fix for existing accounts depends on the old account.** Users who deleted it and have no recovery key cannot be fixed. The command should say so plainly. +- **bsky.social behaviour.** Confirm on a test account that bsky.social's `signPlcOperation` accepts replacement `rotationKeys` and that deactivated accounts can still request PLC tokens. From ecafdace918db7f686b9fff461a81221b40558f7 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Oct 2026 06:35:38 +0100 Subject: [PATCH 2/8] fix(pds): refuse to sign PLC operations without a rotation key signPlcOperation signed with SIGNING_KEY regardless of whether it was one of the DID's rotation keys. For accounts migrated with `pds identity` it usually is not, so plc.directory rejected the result and outbound migration failed with an opaque error. It now returns InvalidRequest explaining why. The PLC operation types, audit-log lookup and signing move to src/plc.ts so the CLI can sign operations with a locally held key. --- packages/pds/src/plc.ts | 71 ++++++++++++++++ packages/pds/src/xrpc/identity.ts | 104 ++++++------------------ packages/pds/test/identity.test.ts | 125 ++++++++++++++++++++++++++++- 3 files changed, 220 insertions(+), 80 deletions(-) create mode 100644 packages/pds/src/plc.ts diff --git a/packages/pds/src/plc.ts b/packages/pds/src/plc.ts new file mode 100644 index 0000000..1b37b14 --- /dev/null +++ b/packages/pds/src/plc.ts @@ -0,0 +1,71 @@ +/** + * PLC directory operations shared by the PDS and the CLI. + */ +import type { Keypair } from "@atproto/crypto"; +import { encode } from "@atcute/cbor"; +import { base64url } from "jose"; + +export const PLC_DIRECTORY = "https://plc.directory"; + +export interface UnsignedPlcOperation { + type: "plc_operation"; + prev: string | null; + rotationKeys: string[]; + verificationMethods: Record; + alsoKnownAs: string[]; + services: Record; +} + +export interface SignedPlcOperation extends UnsignedPlcOperation { + sig: string; +} + +/** + * Operation types other than plc_operation that can appear in a DID's log: + * tombstones, and the legacy "create" genesis format. + */ +export interface OtherPlcOperation { + type: "plc_tombstone" | "create"; + prev: string | null; + sig: string; +} + +export interface PlcAuditLog { + did: string; + operation: SignedPlcOperation | OtherPlcOperation; + cid: string; + nullified: boolean; + createdAt: string; +} + +/** + * Get the most recent non-nullified operation for a DID, or null if the + * log cannot be fetched. + */ +export async function getLatestPlcOperation( + did: string, + plcUrl = PLC_DIRECTORY, +): Promise { + try { + const res = await fetch(`${plcUrl}/${did}/log/audit`); + if (!res.ok) { + return null; + } + const log = (await res.json()) as PlcAuditLog[]; + return log.filter((op) => !op.nullified).pop() ?? null; + } catch { + return null; + } +} + +/** + * Sign a PLC operation: DAG-CBOR encode the unsigned operation, sign the + * bytes, and attach the signature as base64url. + */ +export async function signOperation( + op: UnsignedPlcOperation, + keypair: Keypair, +): Promise { + const sig = await keypair.sign(encode(op)); + return { ...op, sig: base64url.encode(sig) }; +} diff --git a/packages/pds/src/xrpc/identity.ts b/packages/pds/src/xrpc/identity.ts index 9c2a928..ec515a6 100644 --- a/packages/pds/src/xrpc/identity.ts +++ b/packages/pds/src/xrpc/identity.ts @@ -14,15 +14,18 @@ */ import type { Context } from "hono"; import { Secp256k1Keypair } from "@atproto/crypto"; -import { encode } from "@atcute/cbor"; -import { base64url } from "jose"; import type { AuthedAppEnv, PDSEnv } from "../types"; import { createMigrationToken, validateMigrationToken, } from "../migration-token"; - -const PLC_DIRECTORY = "https://plc.directory"; +import { + getLatestPlcOperation, + PLC_DIRECTORY, + signOperation, + type SignedPlcOperation, + type UnsignedPlcOperation, +} from "../plc"; /** * Build the DID document for the local account. @@ -96,33 +99,6 @@ export async function getRecommendedDidCredentials( }); } -/** - * PLC operation structure - */ -interface UnsignedPlcOperation { - type: "plc_operation"; - prev: string | null; - rotationKeys: string[]; - verificationMethods: Record; - alsoKnownAs: string[]; - services: Record; -} - -interface SignedPlcOperation extends UnsignedPlcOperation { - sig: string; -} - -/** - * Audit log entry from plc.directory - */ -interface PlcAuditLog { - did: string; - operation: SignedPlcOperation; - cid: string; - nullified: boolean; - createdAt: string; -} - /** * Request a PLC operation signature for outbound migration. * @@ -142,10 +118,13 @@ export async function requestPlcOperationSignature( } /** - * Sign a PLC operation for migrating to a new PDS. + * Sign a PLC operation with this PDS's signing key. * - * Validates the migration token and returns a signed PLC operation - * that updates the DID document to point to the new PDS. + * Validates the migration token and returns a signed PLC operation that + * applies the requested changes to the current PLC state. Used by a new PDS + * during outbound migration, and by `pds rotation-keys`. The signing key + * must be one of the DID's rotation keys, or plc.directory would reject + * the operation. * * Endpoint: POST com.atproto.identity.signPlcOperation */ @@ -191,7 +170,7 @@ export async function signPlcOperation( // Get current PLC state to build the update const currentOp = await getLatestPlcOperation(c.env.DID); - if (!currentOp) { + if (!currentOp || currentOp.operation.type !== "plc_operation") { return c.json( { error: "InternalServerError", @@ -201,6 +180,17 @@ export async function signPlcOperation( ); } + const keypair = await Secp256k1Keypair.import(c.env.SIGNING_KEY); + if (!currentOp.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 sign identity changes. Run "pds rotation-keys" to give it control, or sign the operation with a rotation key you hold.`, + }, + 400, + ); + } + // Build the new operation, merging current state with requested changes const newOp: UnsignedPlcOperation = { type: "plc_operation", @@ -212,55 +202,11 @@ export async function signPlcOperation( services: body.services ?? currentOp.operation.services, }; - // Sign the operation with our signing key - const keypair = await Secp256k1Keypair.import(c.env.SIGNING_KEY); const signedOp = await signOperation(newOp, keypair); return c.json({ operation: signedOp }); } -/** - * Get the latest PLC operation for a DID - */ -async function getLatestPlcOperation(did: string): Promise { - try { - const res = await fetch(`${PLC_DIRECTORY}/${did}/log/audit`); - if (!res.ok) { - return null; - } - const log = (await res.json()) as PlcAuditLog[]; - // Return the most recent non-nullified operation - return log.filter((op) => !op.nullified).pop() ?? null; - } catch { - return null; - } -} - -/** - * Sign a PLC operation with the given keypair - * - * PLC operations are signed by: - * 1. CBOR-encoding the unsigned operation - * 2. Signing the bytes with secp256k1 - * 3. Adding the signature as base64url - */ -async function signOperation( - op: UnsignedPlcOperation, - keypair: Secp256k1Keypair, -): Promise { - // CBOR-encode the operation (without sig field) - const bytes = encode(op); - - // Sign the bytes - const sig = await keypair.sign(bytes); - - // Convert signature to base64url - return { - ...op, - sig: base64url.encode(sig), - }; -} - /** * Submit a signed PLC operation to the PLC directory. * diff --git a/packages/pds/test/identity.test.ts b/packages/pds/test/identity.test.ts index 10ab4a3..8af9591 100644 --- a/packages/pds/test/identity.test.ts +++ b/packages/pds/test/identity.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { Secp256k1Keypair } from "@atproto/crypto"; +import { Secp256k1Keypair, verifySignature } from "@atproto/crypto"; +import { encode } from "@atcute/cbor"; +import { base64url } from "jose"; import { env, worker } from "./helpers"; +import { createMigrationToken } from "../src/migration-token"; describe("Identity Endpoints", () => { describe("com.atproto.identity.getRecommendedDidCredentials", () => { @@ -179,4 +182,124 @@ describe("Identity Endpoints", () => { expect(body.message).toContain("invalid signature"); }); }); + + describe("com.atproto.identity.signPlcOperation", () => { + const recoveryKey = "did:key:zQ3shRecoveryKeyExample"; + const services = { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: `https://${env.PDS_HOSTNAME}`, + }, + }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function signingDid(): Promise { + return (await Secp256k1Keypair.import(env.SIGNING_KEY)).did(); + } + + function mockAuditLog(operation: Record) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : input.toString(); + expect(url).toBe(`https://plc.directory/${env.DID}/log/audit`); + return Response.json([ + { + did: env.DID, + operation: { ...operation, prev: null, sig: "b2xk" }, + cid: "bafyreioldop", + nullified: true, + createdAt: "2025-01-01T00:00:00.000Z", + }, + { + did: env.DID, + operation: { ...operation, prev: "bafyreioldop", sig: "c2ln" }, + cid: "bafyreicurrentop", + nullified: false, + createdAt: "2025-01-02T00:00:00.000Z", + }, + ]); + }), + ); + } + + async function sign(body: Record) { + return worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.signPlcOperation", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ + token: await createMigrationToken(env.DID, env.JWT_SECRET), + ...body, + }), + }, + ), + env, + ); + } + + it("signs the requested changes on top of the current operation", async () => { + const key = await signingDid(); + mockAuditLog({ + type: "plc_operation", + rotationKeys: ["did:key:zQ3shOldPdsKeyExample", key], + verificationMethods: { atproto: key }, + alsoKnownAs: ["at://alice.example.com"], + services, + }); + + const response = await sign({ rotationKeys: [recoveryKey, key] }); + expect(response.status).toBe(200); + + const { operation } = (await response.json()) as { + operation: Record & { sig: string }; + }; + const { sig, ...unsigned } = operation; + expect(unsigned).toEqual({ + type: "plc_operation", + prev: "bafyreicurrentop", + rotationKeys: [recoveryKey, key], + verificationMethods: { atproto: key }, + alsoKnownAs: ["at://alice.example.com"], + services, + }); + expect( + await verifySignature(key, encode(unsigned), base64url.decode(sig)), + ).toBe(true); + }); + + it("refuses when the signing key is not a rotation key", async () => { + mockAuditLog({ + type: "plc_operation", + rotationKeys: ["did:key:zQ3shOldPdsKeyExample"], + verificationMethods: { atproto: await signingDid() }, + alsoKnownAs: ["at://alice.example.com"], + services, + }); + + const response = await sign({}); + expect(response.status).toBe(400); + const body = (await response.json()) as { + error: string; + message: string; + }; + expect(body.error).toBe("InvalidRequest"); + expect(body.message).toContain("not a rotation key"); + }); + + it("refuses when the DID has been tombstoned", async () => { + mockAuditLog({ type: "plc_tombstone" }); + + const response = await sign({}); + expect(response.status).toBe(500); + }); + }); }); From 05e7f34c4576f3f224db5e2c9c7c550b11853616 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Oct 2026 07:01:11 +0100 Subject: [PATCH 3/8] fix(pds): require full access to sign PLC operations getMigrationToken and signPlcOperation accepted any authenticated caller: app-password sessions, OAuth tokens of any scope, and service JWTs (which getServiceAuth mints for any caller). Together they let any of these get a PLC operation signed by the PDS. That was only harmful where the signing key is a rotation key, which the following commits make the norm. Both endpoints now require the static AUTH_TOKEN, a password session, or an OAuth token granted identity:*. AuthInfo records how the request authenticated so the check can tell app passwords and service JWTs apart from password sessions. --- .changeset/plc-identity-auth.md | 5 + packages/pds/src/middleware/auth.ts | 56 ++++++++++- packages/pds/src/xrpc/identity.ts | 7 ++ packages/pds/test/identity.test.ts | 151 ++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 .changeset/plc-identity-auth.md diff --git a/.changeset/plc-identity-auth.md b/.changeset/plc-identity-auth.md new file mode 100644 index 0000000..ac91d5f --- /dev/null +++ b/.changeset/plc-identity-auth.md @@ -0,0 +1,5 @@ +--- +"@getcirrus/pds": patch +--- + +Signing PLC operations (`com.atproto.identity.signPlcOperation`) and generating migration tokens now require your account password, the `AUTH_TOKEN`, or an OAuth grant that includes `identity:*`. App passwords, OAuth tokens without that permission, and service auth tokens are refused. Previously any signed-in app could request a migration token and have your PDS sign a change to your identity. diff --git a/packages/pds/src/middleware/auth.ts b/packages/pds/src/middleware/auth.ts index 4290bca..28b7c14 100644 --- a/packages/pds/src/middleware/auth.ts +++ b/packages/pds/src/middleware/auth.ts @@ -12,6 +12,8 @@ import type { PDSEnv } from "../types"; export interface AuthInfo { did: string; scope: string; + /** How the request authenticated */ + method: "static" | "session" | "app-password" | "service" | "oauth"; } export type AuthVariables = { @@ -86,6 +88,36 @@ export function requireScope( return checker(check); } +/** + * Require credentials allowed to change the account's identity, such as + * signing PLC operations: the static AUTH_TOKEN, a password session, or an + * OAuth token granted `identity:*`. App passwords and service JWTs are + * refused. Returns a 403 Response, or null when allowed. + */ +export function requireIdentityControl( + c: Context<{ Bindings: PDSEnv; Variables: AuthVariables }>, +): Response | null { + const auth = c.get("auth"); + switch (auth?.method) { + case "static": + case "session": + return null; + case "oauth": + return requireScope(c, (perms) => perms.assertIdentity({ attr: "*" })); + default: + return c.json( + { + error: "InsufficientScope", + message: + auth?.method === "app-password" + ? "App passwords can't change your identity. Sign in with your account password." + : "These credentials can't change your identity.", + }, + 403, + ); + } +} + export async function requireAuth( c: Context<{ Bindings: PDSEnv; Variables: AuthVariables }>, next: Next, @@ -122,7 +154,11 @@ export async function requireAuth( ); } - c.set("auth", { did: tokenData.sub, scope: tokenData.scope }); + c.set("auth", { + did: tokenData.sub, + scope: tokenData.scope, + method: "oauth", + }); return next(); } @@ -143,7 +179,11 @@ export async function requireAuth( // shared operator secret; requireScope() treats `com.atproto.access` as a // fully-trusted legacy scope, so this carries the same broad authority. if (token === c.env.AUTH_TOKEN) { - c.set("auth", { did: c.env.DID, scope: "com.atproto.access" }); + c.set("auth", { + did: c.env.DID, + scope: "com.atproto.access", + method: "static", + }); return next(); } @@ -170,7 +210,11 @@ export async function requireAuth( } // Store auth info in context for downstream use - c.set("auth", { did: payload.sub, scope: payload.scope as string }); + c.set("auth", { + did: payload.sub, + scope: payload.scope as string, + method: payload.apf === true ? "app-password" : "session", + }); return next(); } catch (err) { // Match official PDS: expired tokens return 400 with 'ExpiredToken' @@ -237,7 +281,11 @@ export async function requireAuth( // represent a fully-authenticated caller bound to one method (above); // mark with the legacy `com.atproto.access` scope so requireScope() // short-circuits at the resource layer. - c.set("auth", { did: payload.iss, scope: "com.atproto.access" }); + c.set("auth", { + did: payload.iss, + scope: "com.atproto.access", + method: "service", + }); return next(); } catch { // Service JWT verification also failed diff --git a/packages/pds/src/xrpc/identity.ts b/packages/pds/src/xrpc/identity.ts index ec515a6..54b22cd 100644 --- a/packages/pds/src/xrpc/identity.ts +++ b/packages/pds/src/xrpc/identity.ts @@ -15,6 +15,7 @@ import type { Context } from "hono"; import { Secp256k1Keypair } from "@atproto/crypto"; import type { AuthedAppEnv, PDSEnv } from "../types"; +import { requireIdentityControl } from "../middleware/auth"; import { createMigrationToken, validateMigrationToken, @@ -131,6 +132,9 @@ export async function requestPlcOperationSignature( export async function signPlcOperation( c: Context, ): Promise { + const forbidden = requireIdentityControl(c); + if (forbidden) return forbidden; + const body = await c.req.json<{ token?: string; rotationKeys?: string[]; @@ -268,6 +272,9 @@ export async function submitPlcOperation( export async function getMigrationToken( c: Context, ): Promise { + const forbidden = requireIdentityControl(c); + if (forbidden) return forbidden; + const token = await createMigrationToken(c.env.DID, c.env.JWT_SECRET); return c.json({ token }); } diff --git a/packages/pds/test/identity.test.ts b/packages/pds/test/identity.test.ts index 8af9591..df57dcb 100644 --- a/packages/pds/test/identity.test.ts +++ b/packages/pds/test/identity.test.ts @@ -4,6 +4,8 @@ import { encode } from "@atcute/cbor"; import { base64url } from "jose"; import { env, worker } from "./helpers"; import { createMigrationToken } from "../src/migration-token"; +import { createAccessToken } from "../src/session"; +import { createServiceJwt, getSigningKeypair } from "../src/service-auth"; describe("Identity Endpoints", () => { describe("com.atproto.identity.getRecommendedDidCredentials", () => { @@ -302,4 +304,153 @@ describe("Identity Endpoints", () => { expect(response.status).toBe(500); }); }); + + describe("credentials allowed to change the identity", () => { + const serviceDid = `did:web:${env.PDS_HOSTNAME}`; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function oauthToken(scope: string): Promise { + const accessToken = `oauth-identity-${crypto.randomUUID()}`; + const stub = env.ACCOUNT.get(env.ACCOUNT.idFromName("account")); + await ( + await stub.authStore() + ).saveTokens({ + accessToken, + refreshToken: `refresh-${accessToken}`, + clientId: "https://example.com/client-metadata.json", + sub: env.DID, + scope, + issuedAt: Date.now(), + accessExpiresAt: Date.now() + 60_000, + refreshExpiresAt: Date.now() + 3_600_000, + }); + return `DPoP ${accessToken}`; + } + + async function sessionToken(appPassword: boolean): Promise { + return `Bearer ${await createAccessToken(env.JWT_SECRET, env.DID, serviceDid, { appPassword })}`; + } + + async function serviceToken(lxm: string): Promise { + return `Bearer ${await createServiceJwt({ + iss: env.DID, + aud: serviceDid, + lxm, + keypair: await getSigningKeypair(env.SIGNING_KEY), + })}`; + } + + function getMigrationToken(authorization: string) { + return worker.fetch( + new Request( + "http://pds.test/xrpc/gg.mk.experimental.getMigrationToken", + { headers: { Authorization: authorization } }, + ), + env, + ); + } + + async function signPlcOperation(authorization: string) { + return worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.signPlcOperation", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authorization, + }, + body: JSON.stringify({ + token: await createMigrationToken(env.DID, env.JWT_SECRET), + }), + }, + ), + env, + ); + } + + it("allows the static token, a password session and OAuth with identity:*", async () => { + for (const authorization of [ + `Bearer ${env.AUTH_TOKEN}`, + await sessionToken(false), + await oauthToken("atproto identity:*"), + ]) { + const response = await getMigrationToken(authorization); + expect(response.status).toBe(200); + } + }); + + it("refuses app passwords", async () => { + const authorization = await sessionToken(true); + + const tokenResponse = await getMigrationToken(authorization); + expect(tokenResponse.status).toBe(403); + const body = (await tokenResponse.json()) as { message: string }; + expect(body.message).toContain("App passwords"); + + expect((await signPlcOperation(authorization)).status).toBe(403); + }); + + it("refuses OAuth tokens without identity:*", async () => { + for (const scope of [ + "atproto transition:generic", + "atproto identity:handle", + ]) { + const authorization = await oauthToken(scope); + expect((await getMigrationToken(authorization)).status).toBe(403); + expect((await signPlcOperation(authorization)).status).toBe(403); + } + }); + + it("refuses service JWTs, even when bound to the method", async () => { + expect( + ( + await getMigrationToken( + await serviceToken("gg.mk.experimental.getMigrationToken"), + ) + ).status, + ).toBe(403); + expect( + ( + await signPlcOperation( + await serviceToken("com.atproto.identity.signPlcOperation"), + ) + ).status, + ).toBe(403); + }); + + it("lets OAuth with identity:* sign a PLC operation", async () => { + const key = (await Secp256k1Keypair.import(env.SIGNING_KEY)).did(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json([ + { + did: env.DID, + operation: { + type: "plc_operation", + prev: null, + sig: "c2ln", + rotationKeys: [key], + verificationMethods: { atproto: key }, + alsoKnownAs: [], + services: {}, + }, + cid: "bafyreicurrentop", + nullified: false, + createdAt: "2025-01-01T00:00:00.000Z", + }, + ]), + ), + ); + + const response = await signPlcOperation( + await oauthToken("atproto identity:*"), + ); + expect(response.status).toBe(200); + }); + }); }); From 28a0f9c226f1748784e18ad3f365ba61e77651f2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Oct 2026 06:40:32 +0100 Subject: [PATCH 4/8] feat(pds): CLI helpers for PLC rotation and recovery keys - SourcePdsPlcClient.signPlcOperation takes the fields to change, so callers can set rotationKeys. Adds getRecommendedDidCredentials (to recognise the source PDS's keys) and PlcDirectoryClient.getLatestOperation. - PDSClient gains getRecommendedDidCredentials and signPlcOperation, so the CLI can have the deployed PDS sign for itself. - New rotation-keys utils: ordering (user keys first, PDS key last, max 5), labelling, recovery key generation, and importing pasted private keys. Keys use goat's multibase encoding; checked against goat in both directions for K-256 and P-256. - The signing key backup prompt moves out of init into promptKeyBackup, and the 1Password and file backups take a key kind so they work for recovery keys. init behaves as before. - The CLI's PLC types now come from src/plc.ts. --- packages/pds/src/cli/commands/identity.ts | 14 +- packages/pds/src/cli/commands/init.ts | 111 +---------- packages/pds/src/cli/utils/cli-helpers.ts | 195 +++++++++++++++++++- packages/pds/src/cli/utils/pds-client.ts | 37 ++++ packages/pds/src/cli/utils/plc-client.ts | 93 ++++------ packages/pds/src/cli/utils/rotation-keys.ts | 131 +++++++++++++ packages/pds/src/plc.ts | 11 ++ packages/pds/src/xrpc/identity.ts | 9 +- packages/pds/test/cli/cli-helpers.test.ts | 21 +++ packages/pds/test/cli/plc-client.test.ts | 103 +++++++++-- packages/pds/test/cli/rotation-keys.test.ts | 141 ++++++++++++++ 11 files changed, 665 insertions(+), 201 deletions(-) create mode 100644 packages/pds/src/cli/utils/rotation-keys.ts create mode 100644 packages/pds/test/cli/rotation-keys.test.ts diff --git a/packages/pds/src/cli/commands/identity.ts b/packages/pds/src/cli/commands/identity.ts index 21898b1..9f926a1 100644 --- a/packages/pds/src/cli/commands/identity.ts +++ b/packages/pds/src/cli/commands/identity.ts @@ -266,11 +266,15 @@ export const identityCommand = defineCommand({ // Sign the PLC operation via source PDS spinner.start("Signing identity update..."); - const signResult = await sourcePdsClient.signPlcOperation( - token.trim(), - targetUrl, - signingKeyDid, - ); + const signResult = await sourcePdsClient.signPlcOperation(token.trim(), { + verificationMethods: { atproto: signingKeyDid }, + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: targetUrl, + }, + }, + }); if (!signResult.success || !signResult.signedOperation) { spinner.stop("Failed to sign operation"); diff --git a/packages/pds/src/cli/commands/init.ts b/packages/pds/src/cli/commands/init.ts index 2f913d1..21a6979 100644 --- a/packages/pds/src/cli/commands/init.ts +++ b/packages/pds/src/cli/commands/init.ts @@ -21,10 +21,7 @@ import { promptSelect, detectPackageManager, formatCommand, - copyToClipboard, - saveKeyBackup, - is1PasswordAvailable, - saveTo1Password, + promptKeyBackup, runCommand, } from "../utils/cli-helpers.js"; @@ -521,108 +518,12 @@ export const initCommand = defineCommand({ "Critical: Back Up Your Signing Key", ); - // Check if 1Password CLI is available - const has1Password = await is1PasswordAvailable(); - - // Build backup options dynamically - type BackupOption = "1password" | "copy" | "file" | "show" | "skip"; - const backupOptions: Array<{ - value: BackupOption; - label: string; - hint: string; - }> = []; - - if (has1Password) { - backupOptions.push({ - value: "1password" as const, - label: "Save to 1Password", - hint: "recommended - uses op CLI", - }); - } - - backupOptions.push( - { - value: "copy" as const, - label: "Copy to clipboard", - hint: "paste into password manager", - }, - { - value: "file" as const, - label: "Save to file", - hint: "signing-key-backup.txt", - }, - { - value: "show" as const, - label: "Display it (I'll copy manually)", - hint: "shown in terminal", - }, - { - value: "skip" as const, - label: "Skip (I understand the risk)", - hint: "not recommended", - }, - ); - - const backupChoice = await promptSelect({ - message: "How would you like to back up your signing key?", - options: backupOptions, + await promptKeyBackup({ + key: signingKey, + kind: "signing", + handle, + allowSkip: true, }); - - if (backupChoice === "1password") { - spinner.start("Saving to 1Password..."); - const result = await saveTo1Password(signingKey, handle); - if (result.success) { - spinner.stop("Saved to 1Password"); - p.log.success(`Created: "${result.itemName}"`); - } else { - spinner.stop("Failed to save to 1Password"); - p.log.error(result.error || "Unknown error"); - p.log.info("Falling back to displaying the key..."); - p.note( - [ - "SIGNING KEY (keep this secret!):", - "", - signingKey, - "", - "Copy this to your password manager now.", - ].join("\n"), - "๐Ÿ”‘ Your Signing Key", - ); - } - } else if (backupChoice === "copy") { - await copyToClipboard(signingKey); - p.log.success("Signing key copied to clipboard"); - p.log.info("Paste it into your password manager now!"); - } else if (backupChoice === "file") { - const backupPath = await saveKeyBackup(signingKey, handle); - p.log.success(`Signing key saved to: ${backupPath}`); - p.log.warn( - "Move this file to a secure location and delete the local copy!", - ); - } else if (backupChoice === "show") { - p.note( - [ - "SIGNING KEY (keep this secret!):", - "", - signingKey, - "", - "Copy this to your password manager now.", - ].join("\n"), - "๐Ÿ”‘ Your Signing Key", - ); - } - - if (backupChoice !== "skip") { - const confirmed = await promptConfirm({ - message: "Have you saved your signing key securely?", - initialValue: true, - }); - if (!confirmed) { - p.log.warn("Please back up your key before continuing!"); - // Show it one more time - p.note(signingKey, "๐Ÿ”‘ Signing Key"); - } - } } const jwtSecret = await getOrGenerateSecret( diff --git a/packages/pds/src/cli/utils/cli-helpers.ts b/packages/pds/src/cli/utils/cli-helpers.ts index 1ba734d..e19e180 100644 --- a/packages/pds/src/cli/utils/cli-helpers.ts +++ b/packages/pds/src/cli/utils/cli-helpers.ts @@ -153,18 +153,69 @@ export async function is1PasswordAvailable(): Promise { }); } +export type BackupKeyKind = "signing" | "recovery"; + +const KEY_BACKUP_TEXT: Record< + BackupKeyKind, + { + name: string; + label: string; + title: string; + heading: string; + filePrefix: string; + tag: string; + warning: string; + encoding: string; + } +> = { + signing: { + name: "signing key", + label: "Signing Key", + title: "Cirrus PDS Signing Key", + heading: "CIRRUS PDS SIGNING KEY", + filePrefix: "signing-key-backup", + tag: "signing-key", + warning: "WARNING: This key controls your identity!", + encoding: "hex-encoded secp256k1 private key", + }, + recovery: { + name: "recovery key", + label: "Recovery Key", + title: "atproto Recovery Key", + heading: "ATPROTO RECOVERY KEY", + filePrefix: "recovery-key-backup", + tag: "recovery-key", + warning: "WARNING: This key can take control of your identity!", + encoding: "multibase private key, usable with goat", + }, +}; + /** * Save a key to 1Password using the CLI - * Creates a secure note with the signing key + * Creates a secure note with the key and any extra details */ export async function saveTo1Password( key: string, handle: string, + kind: BackupKeyKind = "signing", + details: string[] = [], ): Promise<{ success: boolean; itemName?: string; error?: string }> { - const itemName = `Cirrus PDS Signing Key - ${handle}`; + const text = KEY_BACKUP_TEXT[kind]; + const itemName = `${text.title} - ${handle}`; + const notes = [ + text.heading, + "", + `Handle: ${handle}`, + `Created: ${new Date().toISOString()}`, + ...details, + "", + text.warning, + "", + `${text.name.toUpperCase()}:`, + key, + ].join("\n"); return new Promise((resolve) => { - // Create a secure note with the signing key const child = spawn( "op", [ @@ -174,9 +225,9 @@ export async function saveTo1Password( "Secure Note", "--title", itemName, - `notesPlain=CIRRUS PDS SIGNING KEY\n\nHandle: ${handle}\nCreated: ${new Date().toISOString()}\n\nWARNING: This key controls your identity!\n\nSIGNING KEY:\n${key}`, + `notesPlain=${notes}`, "--tags", - "cirrus,pds,signing-key", + `cirrus,pds,${text.tag}`, ], { stdio: ["ignore", "pipe", "pipe"] }, ); @@ -304,26 +355,30 @@ export function runCommand( export async function saveKeyBackup( key: string, handle: string, + kind: BackupKeyKind = "signing", + details: string[] = [], ): Promise { - const filename = `signing-key-backup-${handle.replace(/[^a-z0-9]/gi, "-")}.txt`; + const text = KEY_BACKUP_TEXT[kind]; + const filename = `${text.filePrefix}-${handle.replace(/[^a-z0-9]/gi, "-")}.txt`; const filepath = join(process.cwd(), filename); const content = [ "=".repeat(60), - "CIRRUS PDS SIGNING KEY BACKUP", + `${text.heading} BACKUP`, "=".repeat(60), "", `Handle: ${handle}`, `Created: ${new Date().toISOString()}`, + ...details, "", - "WARNING: This key controls your identity!", + text.warning, "- Store this file in a secure location (password manager, encrypted drive)", "- Delete this file from your local disk after backing up", "- Never share this key with anyone", "- If compromised, your identity can be stolen", "", "=".repeat(60), - "SIGNING KEY (hex-encoded secp256k1 private key)", + `${text.name.toUpperCase()} (${text.encoding})`, "=".repeat(60), "", key, @@ -334,3 +389,125 @@ export async function saveKeyBackup( await writeFile(filepath, content, { mode: 0o600 }); // Read/write only for owner return filepath; } + +/** + * Ask how to back up a key (1Password, clipboard, file or on screen), do + * it, and ask the user to confirm it is saved. Returns whether they + * confirmed. With allowSkip, the user may decline to back it up. + */ +export async function promptKeyBackup(opts: { + key: string; + kind: BackupKeyKind; + handle: string; + details?: string[]; + allowSkip: boolean; +}): Promise { + const text = KEY_BACKUP_TEXT[opts.kind]; + const details = opts.details ?? []; + const capitalised = text.name[0]!.toUpperCase() + text.name.slice(1); + const showKey = () => + p.note( + [ + `${text.name.toUpperCase()} (keep this secret!):`, + "", + opts.key, + "", + "Copy this to your password manager now.", + ].join("\n"), + `๐Ÿ”‘ Your ${text.label}`, + ); + + type BackupOption = "1password" | "copy" | "file" | "show" | "skip"; + const options: Array<{ value: BackupOption; label: string; hint: string }> = + []; + + if (await is1PasswordAvailable()) { + options.push({ + value: "1password", + label: "Save to 1Password", + hint: "recommended - uses op CLI", + }); + } + + options.push( + { + value: "copy", + label: "Copy to clipboard", + hint: "paste into password manager", + }, + { + value: "file", + label: "Save to file", + hint: `${text.filePrefix}.txt`, + }, + { + value: "show", + label: "Display it (I'll copy manually)", + hint: "shown in terminal", + }, + ); + + if (opts.allowSkip) { + options.push({ + value: "skip", + label: "Skip (I understand the risk)", + hint: "not recommended", + }); + } + + const choice = await promptSelect({ + message: `How would you like to back up your ${text.name}?`, + options, + }); + + if (choice === "1password") { + const spinner = p.spinner(); + spinner.start("Saving to 1Password..."); + const result = await saveTo1Password( + opts.key, + opts.handle, + opts.kind, + details, + ); + if (result.success) { + spinner.stop("Saved to 1Password"); + p.log.success(`Created: "${result.itemName}"`); + } else { + spinner.stop("Failed to save to 1Password"); + p.log.error(result.error || "Unknown error"); + p.log.info("Falling back to displaying the key..."); + showKey(); + } + } else if (choice === "copy") { + await copyToClipboard(opts.key); + p.log.success(`${capitalised} copied to clipboard`); + p.log.info("Paste it into your password manager now!"); + } else if (choice === "file") { + const backupPath = await saveKeyBackup( + opts.key, + opts.handle, + opts.kind, + details, + ); + p.log.success(`${capitalised} saved to: ${backupPath}`); + p.log.warn( + "Move this file to a secure location and delete the local copy!", + ); + } else if (choice === "show") { + showKey(); + } + + if (choice === "skip") { + return false; + } + + const confirmed = await promptConfirm({ + message: `Have you saved your ${text.name} securely?`, + initialValue: true, + }); + if (!confirmed) { + p.log.warn("Please back up your key before continuing!"); + p.note(opts.key, `๐Ÿ”‘ ${text.label}`); + } + return confirmed; +} diff --git a/packages/pds/src/cli/utils/pds-client.ts b/packages/pds/src/cli/utils/pds-client.ts index cd3c0a6..dd8130a 100644 --- a/packages/pds/src/cli/utils/pds-client.ts +++ b/packages/pds/src/cli/utils/pds-client.ts @@ -18,6 +18,7 @@ import { // Without them, the client's .get() and .post() methods lack type information. import type {} from "@atcute/atproto"; import type {} from "@atcute/bluesky"; +import type { PlcOperationChanges, SignedPlcOperation } from "../../plc.js"; export interface Session { accessJwt: string; @@ -1004,6 +1005,42 @@ export class PDSClient { return { success: true, token: data.token }; } + /** + * Get this PDS's recommended PLC credentials. The rotation key and + * atproto verification method are both the deployed signing key. + */ + async getRecommendedDidCredentials(): Promise<{ + rotationKeys: string[]; + verificationMethods: Record; + }> { + const result = await ok( + this.client.get("com.atproto.identity.getRecommendedDidCredentials", {}), + ); + return { + rotationKeys: result.rotationKeys ?? [], + verificationMethods: + (result.verificationMethods as Record | undefined) ?? + {}, + }; + } + + /** + * Get this PDS to sign a PLC operation applying the given changes to the + * DID's current state. Needs a token from getMigrationToken, and fails + * unless the PDS's signing key is a rotation key. + */ + async signPlcOperation( + token: string, + changes: PlcOperationChanges, + ): Promise { + const result = await ok( + this.client.post("com.atproto.identity.signPlcOperation", { + input: { token, ...changes }, + }), + ); + return result.operation as unknown as SignedPlcOperation; + } + /** * List notifications (proxied through PDS to AppView) */ diff --git a/packages/pds/src/cli/utils/plc-client.ts b/packages/pds/src/cli/utils/plc-client.ts index a1caa1d..a452127 100644 --- a/packages/pds/src/cli/utils/plc-client.ts +++ b/packages/pds/src/cli/utils/plc-client.ts @@ -9,45 +9,12 @@ import { Client, ok, type FetchHandler } from "@atcute/client"; // Import for type augmentation - gives us typed XRPC method signatures import type {} from "@atcute/atproto"; - -const PLC_DIRECTORY = "https://plc.directory"; - -/** - * PLC operation structure (from plc.directory) - */ -export interface PlcOperation { - type: string; - prev: string | null; - sig: string; - rotationKeys: string[]; - verificationMethods: Record; - alsoKnownAs: string[]; - services: Record; -} - -/** - * Audit log entry from plc.directory - */ -export interface PlcAuditLog { - did: string; - operation: PlcOperation; - cid: string; - nullified: boolean; - createdAt: string; -} - -/** - * Signed PLC operation ready for submission - */ -export interface SignedPlcOperation { - type: "plc_operation"; - prev: string | null; - sig: string; - rotationKeys: string[]; - verificationMethods: Record; - alsoKnownAs: string[]; - services: Record; -} +import { + PLC_DIRECTORY, + type PlcAuditLog, + type PlcOperationChanges, + type SignedPlcOperation, +} from "../../plc.js"; export interface CredentialInfo { type: "email" | "passkey"; @@ -151,43 +118,41 @@ export class SourcePdsPlcClient { } /** - * Get a signed PLC operation from the source PDS. - * This builds and signs the operation to migrate to the new PDS. + * Get the source PDS's recommended credentials for this account. Its + * rotationKeys are the keys the source PDS holds. + * + * Uses: com.atproto.identity.getRecommendedDidCredentials + */ + async getRecommendedDidCredentials(): Promise<{ rotationKeys: string[] }> { + const result = await ok( + this.client.get("com.atproto.identity.getRecommendedDidCredentials", {}), + ); + return { rotationKeys: result.rotationKeys ?? [] }; + } + + /** + * Get the source PDS to sign a PLC operation applying the given changes + * to the DID's current state. * * Uses: com.atproto.identity.signPlcOperation * * @param token - The email token received from the source PDS - * @param newPdsEndpoint - The endpoint URL of the new PDS - * @param newSigningKey - The new signing key DID (did:key:...) + * @param changes - Fields to change; omitted fields keep their values */ async signPlcOperation( token: string, - newPdsEndpoint: string, - newSigningKey: string, + changes: PlcOperationChanges, ): Promise { try { const result = await ok( this.client.post("com.atproto.identity.signPlcOperation", { - input: { - token, - rotationKeys: undefined, // Keep existing rotation keys - alsoKnownAs: undefined, // Keep existing aliases - verificationMethods: { - atproto: newSigningKey, - }, - services: { - atproto_pds: { - type: "AtprotoPersonalDataServer", - endpoint: newPdsEndpoint, - }, - }, - }, + input: { token, ...changes }, }), ); return { success: true, - signedOperation: result.operation as SignedPlcOperation, + signedOperation: result.operation as unknown as SignedPlcOperation, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Network error"; @@ -240,6 +205,14 @@ export class PlcDirectoryClient { return res.json() as Promise; } + /** + * Get the most recent non-nullified operation for a DID + */ + async getLatestOperation(did: string): Promise { + const log = await this.getAuditLog(did); + return log.filter((entry) => !entry.nullified).pop() ?? null; + } + /** * Get the current DID document */ diff --git a/packages/pds/src/cli/utils/rotation-keys.ts b/packages/pds/src/cli/utils/rotation-keys.ts new file mode 100644 index 0000000..6be9724 --- /dev/null +++ b/packages/pds/src/cli/utils/rotation-keys.ts @@ -0,0 +1,131 @@ +/** + * PLC rotation key helpers: ordering, labelling, and recovery keys. + * + * Rotation keys are listed in priority order. A higher-priority key can + * override an operation signed by a lower one for 72 hours, so keys the + * user holds offline come before the PDS key. + */ +import { + bytesToMultibase, + multibaseToBytes, + P256Keypair, + Secp256k1Keypair, + type Keypair, +} from "@atproto/crypto"; + +/** plc.directory accepts at most this many rotation keys */ +export const MAX_ROTATION_KEYS = 5; + +// Varint-encoded multicodec prefixes for private keys, as used by goat +const SECP256K1_PRIV_PREFIX = [0x81, 0x26]; +const P256_PRIV_PREFIX = [0x86, 0x26]; + +export type RotationKeyOwner = "this-pds" | "source-pds" | "unknown"; + +export interface LabelledRotationKey { + key: string; + owner: RotationKeyOwner; +} + +/** + * Build the rotation key list: keys the user holds, in the order given, + * followed by the PDS key. + */ +export function buildRotationKeys(opts: { + userKeys: string[]; + pdsKey: string; +}): string[] { + const keys = [ + ...new Set([ + ...opts.userKeys.filter((k) => k !== opts.pdsKey), + opts.pdsKey, + ]), + ]; + const invalid = keys.find((k) => !k.startsWith("did:key:")); + if (invalid) { + throw new Error(`Not a did:key: ${invalid}`); + } + if (keys.length > MAX_ROTATION_KEYS) { + throw new Error( + `A DID can have at most ${MAX_ROTATION_KEYS} rotation keys (got ${keys.length})`, + ); + } + return keys; +} + +/** + * Tag each current rotation key with who holds it, where known. + */ +export function labelRotationKeys( + current: string[], + known: { pdsKey: string; sourcePdsKeys?: string[] }, +): LabelledRotationKey[] { + return current.map((key) => ({ + key, + owner: + key === known.pdsKey + ? "this-pds" + : known.sourcePdsKeys?.includes(key) + ? "source-pds" + : "unknown", + })); +} + +export interface RecoveryKey { + /** Private key in multibase form, as used by goat's --plc-signing-key */ + privateKey: string; + /** Public key as a did:key, for the rotationKeys list */ + did: string; +} + +/** + * Generate a secp256k1 recovery key. + */ +export async function generateRecoveryKey(): Promise { + const keypair = await Secp256k1Keypair.create({ exportable: true }); + const bytes = await keypair.export(); + return { + privateKey: bytesToMultibase( + new Uint8Array([...SECP256K1_PRIV_PREFIX, ...bytes]), + "base58btc", + ), + did: keypair.did(), + }; +} + +/** + * Parse a private rotation key pasted by the user: multibase (from goat or + * generateRecoveryKey, secp256k1 or P-256), or a hex secp256k1 key like + * the PDS signing key. + */ +export async function importRotationKey(input: string): Promise { + const value = input.trim(); + if (/^[0-9a-f]{64}$/i.test(value)) { + return Secp256k1Keypair.import(value.toLowerCase()); + } + if (value.startsWith("z")) { + let bytes: Uint8Array; + try { + bytes = multibaseToBytes(value); + } catch { + throw new Error("Not a valid multibase key"); + } + const prefix = [bytes[0], bytes[1]]; + const key = bytes.slice(2); + if (key.length === 32) { + if (samePrefix(prefix, SECP256K1_PRIV_PREFIX)) { + return Secp256k1Keypair.import(key); + } + if (samePrefix(prefix, P256_PRIV_PREFIX)) { + return P256Keypair.import(key); + } + } + } + throw new Error( + "Unrecognised key format. Expected a multibase private key (starting with z) or a 64-character hex key.", + ); +} + +function samePrefix(a: Array, b: number[]): boolean { + return a[0] === b[0] && a[1] === b[1]; +} diff --git a/packages/pds/src/plc.ts b/packages/pds/src/plc.ts index 1b37b14..87aaa2f 100644 --- a/packages/pds/src/plc.ts +++ b/packages/pds/src/plc.ts @@ -16,6 +16,17 @@ export interface UnsignedPlcOperation { services: Record; } +/** + * Fields a caller can change when asking a PDS to sign an operation. + * Omitted fields keep their current values. + */ +export type PlcOperationChanges = Partial< + Pick< + UnsignedPlcOperation, + "rotationKeys" | "verificationMethods" | "alsoKnownAs" | "services" + > +>; + export interface SignedPlcOperation extends UnsignedPlcOperation { sig: string; } diff --git a/packages/pds/src/xrpc/identity.ts b/packages/pds/src/xrpc/identity.ts index 54b22cd..fb04a8b 100644 --- a/packages/pds/src/xrpc/identity.ts +++ b/packages/pds/src/xrpc/identity.ts @@ -24,6 +24,7 @@ import { getLatestPlcOperation, PLC_DIRECTORY, signOperation, + type PlcOperationChanges, type SignedPlcOperation, type UnsignedPlcOperation, } from "../plc"; @@ -135,13 +136,7 @@ export async function signPlcOperation( const forbidden = requireIdentityControl(c); if (forbidden) return forbidden; - const body = await c.req.json<{ - token?: string; - rotationKeys?: string[]; - alsoKnownAs?: string[]; - verificationMethods?: Record; - services?: Record; - }>(); + const body = await c.req.json(); const { token } = body; diff --git a/packages/pds/test/cli/cli-helpers.test.ts b/packages/pds/test/cli/cli-helpers.test.ts index e689bbf..be267eb 100644 --- a/packages/pds/test/cli/cli-helpers.test.ts +++ b/packages/pds/test/cli/cli-helpers.test.ts @@ -357,6 +357,27 @@ describe("saveKeyBackup", () => { expect(stats.mode & 0o777).toBe(0o600); }); + it("labels a recovery key backup and includes its details", async () => { + const filepath = await saveKeyBackup( + "zRecoverySecret", + "alice.example.com", + "recovery", + ["DID: did:plc:abc123", "Public key: did:key:zQ3shRecovery"], + ); + + expect(filepath).toContain("recovery-key-backup-alice-example-com.txt"); + + const content = readFileSync(filepath, "utf-8"); + expect(content).toContain("ATPROTO RECOVERY KEY BACKUP"); + expect(content).toContain("DID: did:plc:abc123"); + expect(content).toContain("Public key: did:key:zQ3shRecovery"); + expect(content).toContain( + "RECOVERY KEY (multibase private key, usable with goat)", + ); + expect(content).toContain("zRecoverySecret"); + expect(content).not.toContain("SIGNING KEY"); + }); + it("includes timestamp in content", async () => { const before = new Date().toISOString().slice(0, 10); // YYYY-MM-DD const filepath = await saveKeyBackup("key", "test.com"); diff --git a/packages/pds/test/cli/plc-client.test.ts b/packages/pds/test/cli/plc-client.test.ts index 3cd0cd7..4d65d9d 100644 --- a/packages/pds/test/cli/plc-client.test.ts +++ b/packages/pds/test/cli/plc-client.test.ts @@ -102,14 +102,24 @@ describe("SourcePdsPlcClient", () => { "https://bsky.social", "test-token", ); - const result = await client.signPlcOperation( - "email-token", - "https://new-pds.com", - "did:key:new", - ); + const changes = { + rotationKeys: ["did:key:recovery", "did:key:new"], + verificationMethods: { atproto: "did:key:new" }, + services: mockOperation.services, + }; + const result = await client.signPlcOperation("email-token", changes); expect(result.success).toBe(true); expect(result.signedOperation).toEqual(mockOperation); + + const [url, init] = mockFetch.mock.calls[0] as [URL, RequestInit]; + expect(url.toString()).toBe( + "https://bsky.social/xrpc/com.atproto.identity.signPlcOperation", + ); + expect(JSON.parse(init.body as string)).toEqual({ + token: "email-token", + ...changes, + }); }); it("handles expired token error", async () => { @@ -119,11 +129,7 @@ describe("SourcePdsPlcClient", () => { "https://bsky.social", "test-token", ); - const result = await client.signPlcOperation( - "old-token", - "https://new-pds.com", - "did:key:new", - ); + const result = await client.signPlcOperation("old-token", {}); expect(result.success).toBe(false); expect(result.error).toContain("expired"); @@ -136,17 +142,48 @@ describe("SourcePdsPlcClient", () => { "https://bsky.social", "test-token", ); - const result = await client.signPlcOperation( - "bad-token", - "https://new-pds.com", - "did:key:new", - ); + const result = await client.signPlcOperation("bad-token", {}); expect(result.success).toBe(false); expect(result.error).toContain("Invalid token"); }); }); + describe("getRecommendedDidCredentials", () => { + it("returns the source PDS's rotation keys", async () => { + mockFetch.mockResolvedValueOnce( + mockXrpcResponse({ + rotationKeys: ["did:key:sourcePds"], + verificationMethods: { atproto: "did:key:sourceSigning" }, + }), + ); + + const client = new SourcePdsPlcClient( + "https://bsky.social", + "test-token", + ); + const result = await client.getRecommendedDidCredentials(); + + expect(result).toEqual({ rotationKeys: ["did:key:sourcePds"] }); + const [url] = mockFetch.mock.calls[0] as [URL]; + expect(url.toString()).toBe( + "https://bsky.social/xrpc/com.atproto.identity.getRecommendedDidCredentials", + ); + }); + + it("returns no keys when the response has none", async () => { + mockFetch.mockResolvedValueOnce(mockXrpcResponse({})); + + const client = new SourcePdsPlcClient( + "https://bsky.social", + "test-token", + ); + expect(await client.getRecommendedDidCredentials()).toEqual({ + rotationKeys: [], + }); + }); + }); + describe("setAuthToken", () => { it("updates auth token for subsequent requests", async () => { mockFetch.mockResolvedValue(mockXrpcResponse({})); @@ -202,6 +239,42 @@ describe("PlcDirectoryClient", () => { }); }); + describe("getLatestOperation", () => { + it("returns the last entry that has not been nullified", async () => { + const entry = (cid: string, nullified: boolean) => ({ + did: "did:plc:abc123", + operation: { type: "plc_operation", prev: null, sig: cid }, + cid, + nullified, + createdAt: "2024-01-01T00:00:00Z", + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + entry("cid1", false), + entry("cid2", false), + entry("cid3", true), + ]), + }); + + const client = new PlcDirectoryClient(); + const result = await client.getLatestOperation("did:plc:abc123"); + + expect(result?.cid).toBe("cid2"); + }); + + it("returns null for an empty log", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }); + + const client = new PlcDirectoryClient(); + expect(await client.getLatestOperation("did:plc:abc123")).toBeNull(); + }); + }); + describe("getDocument", () => { it("returns document on success", async () => { const mockDoc = { diff --git a/packages/pds/test/cli/rotation-keys.test.ts b/packages/pds/test/cli/rotation-keys.test.ts new file mode 100644 index 0000000..9dd0775 --- /dev/null +++ b/packages/pds/test/cli/rotation-keys.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { + P256Keypair, + Secp256k1Keypair, + bytesToMultibase, + verifySignature, +} from "@atproto/crypto"; +import { + buildRotationKeys, + generateRecoveryKey, + importRotationKey, + labelRotationKeys, +} from "../../src/cli/utils/rotation-keys.js"; + +const PDS = "did:key:zQ3shPdsKey"; +const RECOVERY = "did:key:zQ3shRecoveryKey"; +const OTHER = "did:key:zDnaeOtherKey"; + +describe("buildRotationKeys", () => { + it("puts user keys before the PDS key, in the order given", () => { + expect( + buildRotationKeys({ userKeys: [RECOVERY, OTHER], pdsKey: PDS }), + ).toEqual([RECOVERY, OTHER, PDS]); + }); + + it("returns just the PDS key when the user holds none", () => { + expect(buildRotationKeys({ userKeys: [], pdsKey: PDS })).toEqual([PDS]); + }); + + it("moves the PDS key to the end and drops duplicates", () => { + expect( + buildRotationKeys({ userKeys: [PDS, RECOVERY, RECOVERY], pdsKey: PDS }), + ).toEqual([RECOVERY, PDS]); + }); + + it("rejects keys that are not did:keys", () => { + expect(() => + buildRotationKeys({ userKeys: ["zQ3shNoPrefix"], pdsKey: PDS }), + ).toThrow("Not a did:key"); + }); + + it("rejects more than five keys", () => { + const userKeys = [1, 2, 3, 4, 5].map((n) => `did:key:zKey${n}`); + expect(() => buildRotationKeys({ userKeys, pdsKey: PDS })).toThrow( + "at most 5", + ); + }); +}); + +describe("labelRotationKeys", () => { + it("labels this PDS, the source PDS, and unknown keys", () => { + expect( + labelRotationKeys([RECOVERY, "did:key:zSource", PDS], { + pdsKey: PDS, + sourcePdsKeys: ["did:key:zSource"], + }), + ).toEqual([ + { key: RECOVERY, owner: "unknown" }, + { key: "did:key:zSource", owner: "source-pds" }, + { key: PDS, owner: "this-pds" }, + ]); + }); + + it("treats every other key as unknown without source PDS keys", () => { + expect(labelRotationKeys(["did:key:zSource"], { pdsKey: PDS })).toEqual([ + { key: "did:key:zSource", owner: "unknown" }, + ]); + }); +}); + +describe("generateRecoveryKey", () => { + it("generates a multibase secp256k1 key that imports to the same did:key", async () => { + const { privateKey, did } = await generateRecoveryKey(); + expect(privateKey).toMatch(/^z[1-9A-HJ-NP-Za-km-z]+$/); + expect(did).toMatch(/^did:key:zQ3s/); + + const keypair = await importRotationKey(privateKey); + expect(keypair.did()).toBe(did); + }); + + it("generates a different key each time", async () => { + const a = await generateRecoveryKey(); + const b = await generateRecoveryKey(); + expect(a.did).not.toBe(b.did); + }); +}); + +describe("importRotationKey", () => { + it("imports a multibase secp256k1 key", async () => { + const original = await Secp256k1Keypair.create({ exportable: true }); + const encoded = bytesToMultibase( + new Uint8Array([0x81, 0x26, ...(await original.export())]), + "base58btc", + ); + expect((await importRotationKey(encoded)).did()).toBe(original.did()); + }); + + it("imports a multibase P-256 key, goat's default", async () => { + const original = await P256Keypair.create({ exportable: true }); + const encoded = bytesToMultibase( + new Uint8Array([0x86, 0x26, ...(await original.export())]), + "base58btc", + ); + const imported = await importRotationKey(encoded); + expect(imported.did()).toBe(original.did()); + + const message = new TextEncoder().encode("plc operation"); + expect( + await verifySignature( + original.did(), + message, + await imported.sign(message), + ), + ).toBe(true); + }); + + it("imports a hex secp256k1 key, ignoring whitespace and case", async () => { + const original = await Secp256k1Keypair.create({ exportable: true }); + const hex = Buffer.from(await original.export()).toString("hex"); + expect((await importRotationKey(` ${hex.toUpperCase()}\n`)).did()).toBe( + original.did(), + ); + }); + + it("rejects a public key", async () => { + const keypair = await Secp256k1Keypair.create(); + const publicMultibase = keypair.did().replace("did:key:", ""); + await expect(importRotationKey(publicMultibase)).rejects.toThrow( + "Unrecognised key format", + ); + }); + + it("rejects garbage", async () => { + await expect(importRotationKey("not a key")).rejects.toThrow( + "Unrecognised key format", + ); + await expect(importRotationKey("z0OIl")).rejects.toThrow( + "Not a valid multibase key", + ); + }); +}); From e28949edfc028a1abdf85fd8b34834edd1770ce0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Oct 2026 06:43:29 +0100 Subject: [PATCH 5/8] feat(pds): give the PDS a rotation key when migrating identity `pds identity` asked the source PDS to sign an operation that left the rotation keys unchanged, so the source PDS kept control of the DID and Cirrus could not sign PLC operations. It now sets the rotation keys to the keys the user holds, then this PDS's signing key, and removes the source PDS's key. - The source PDS's recommended credentials identify its keys. The user is asked about each other key, and those they hold are kept on top. - If the user holds none, it offers to generate a recovery key, and only adds it after the user confirms it is backed up. - The PDS key comes from the deployed PDS's recommended credentials rather than .dev.vars, and a mismatch with .dev.vars stops the command. - The signed operation is checked for the requested rotation keys before submitting, and read back from plc.directory afterwards. - Key choices happen before requesting the email token, with a summary and a confirmation. - --token now logs in too. Signing needs a session, so that path could not have worked before. --- packages/pds/src/cli/commands/identity.ts | 240 +++++++++++++----- .../pds/src/cli/utils/rotation-key-prompts.ts | 171 +++++++++++++ .../pds/test/cli/rotation-key-prompts.test.ts | 235 +++++++++++++++++ 3 files changed, 585 insertions(+), 61 deletions(-) create mode 100644 packages/pds/src/cli/utils/rotation-key-prompts.ts create mode 100644 packages/pds/test/cli/rotation-key-prompts.test.ts diff --git a/packages/pds/src/cli/commands/identity.ts b/packages/pds/src/cli/commands/identity.ts index 9f926a1..b6857d4 100644 --- a/packages/pds/src/cli/commands/identity.ts +++ b/packages/pds/src/cli/commands/identity.ts @@ -3,9 +3,12 @@ * * This command handles the PLC operation flow for migrating identity * from source PDS to Cirrus. It: - * 1. Requests an email token from the source PDS - * 2. Gets the source PDS to sign a PLC operation with the new endpoint - * 3. Submits the signed operation to the PLC directory + * 1. Chooses the rotation keys: keys the user holds, an optional new + * recovery key, then this PDS's key. The source PDS's key is removed. + * 2. Requests an email token from the source PDS + * 3. Gets the source PDS to sign a PLC operation with the new endpoint, + * signing key and rotation keys + * 4. Submits the signed operation to the PLC directory */ import { defineCommand } from "citty"; import * as p from "@clack/prompts"; @@ -22,7 +25,14 @@ import { detectPackageManager, formatCommand, promptText, + promptConfirm, } from "../utils/cli-helpers.js"; +import { + checkRotationKeys, + chooseRotationKeys, + describeRotationKeys, + type RotationKeyChoice, +} from "../utils/rotation-key-prompts.js"; import { Secp256k1Keypair } from "@atproto/crypto"; // Helper to override clack's dim styling in notes @@ -42,7 +52,8 @@ export const identityCommand = defineCommand({ }, token: { type: "string", - description: "Email token (if you already have one)", + description: + "Confirmation code from your previous PDS's email, if you already have one", }, }, async run({ args }) { @@ -89,12 +100,6 @@ export const identityCommand = defineCommand({ process.exit(1); } - if (!signingKey) { - p.log.error("No SIGNING_KEY found. Run 'pds init' first."); - p.outro("Identity update cancelled."); - process.exit(1); - } - // Only did:plc is supported for now if (!did.startsWith("did:plc:")) { p.log.error("Only did:plc identities are supported for now."); @@ -139,17 +144,9 @@ export const identityCommand = defineCommand({ const sourceDomain = getDomain(sourcePdsUrl); spinner.stop(`Current PDS: ${sourceDomain}`); - // Check if already pointing to target const targetEndpoint = targetUrl.replace(/\/$/, ""); const currentEndpoint = sourcePdsUrl.replace(/\/$/, ""); - if (currentEndpoint === targetEndpoint) { - p.log.success("Your DID already points to your new PDS!"); - p.log.info(`Next step: ${formatCommand(pm, "pds", "activate")}`); - p.outro("All set!"); - return; - } - // Check target PDS is healthy spinner.start(`Checking ${targetDomain}...`); const targetClient = new PDSClient(targetUrl, authToken); @@ -170,21 +167,130 @@ export const identityCommand = defineCommand({ } spinner.stop(`New PDS is ready`); - // Get signing key in did:key format for the PLC operation - spinner.start("Preparing signing key..."); - const signingKeyDid = await getSigningKeyDid(signingKey); - if (!signingKeyDid) { - spinner.stop("Failed to derive signing key"); - p.log.error("Could not convert signing key to did:key format"); + // The DID must name the key the deployed PDS signs with, which may not + // be the one in .dev.vars + spinner.start("Fetching your PDS's signing key..."); + let pdsKey: string; + try { + const credentials = await targetClient.getRecommendedDidCredentials(); + const rotationKey = credentials.rotationKeys[0]; + if ( + !rotationKey || + credentials.verificationMethods.atproto !== rotationKey + ) { + throw new Error("PDS returned unexpected credentials"); + } + pdsKey = rotationKey; + } catch (err) { + spinner.stop("Failed to fetch signing key"); + p.log.error( + err instanceof Error ? err.message : "Could not fetch credentials", + ); + p.outro("Identity update cancelled."); + process.exit(1); + } + const localKey = signingKey ? await getSigningKeyDid(signingKey) : null; + if (localKey && localKey !== pdsKey) { + spinner.stop("Signing keys don't match"); + p.log.error( + `The signing key in .dev.vars (${localKey}) is not the one ${targetDomain} uses (${pdsKey}).`, + ); + p.log.info( + "Restore the deployed key to .dev.vars, or redeploy with the local one, then try again.", + ); + p.outro("Identity update cancelled."); + process.exit(1); + } + spinner.stop(`Signing key: ${pdsKey}`); + + const plcClient = new PlcDirectoryClient(); + spinner.start("Reading your PLC record..."); + const current = await plcClient.getLatestOperation(did).catch(() => null); + if (!current || current.operation.type !== "plc_operation") { + spinner.stop("Failed to read PLC record"); + p.log.error(`Could not read the current PLC operation for ${did}`); p.outro("Identity update cancelled."); process.exit(1); } - spinner.stop("Signing key ready"); + const currentKeys = current.operation.rotationKeys; + spinner.stop("PLC record loaded"); + + // Check if already pointing to target + if (currentEndpoint === targetEndpoint) { + p.log.success("Your DID already points to your new PDS!"); + if (!currentKeys.includes(pdsKey)) { + p.log.warn( + "Your PDS can't update your identity, because its key isn't one of your rotation keys.", + ); + p.log.info( + `Fix this with: ${formatCommand(pm, "pds", "rotation-keys")}`, + ); + } + p.log.info(`Next step: ${formatCommand(pm, "pds", "activate")}`); + p.outro("All set!"); + return; + } // Determine friendly names for display const isBlueskyPds = sourceDomain.endsWith(".bsky.network"); const sourceDisplayName = isBlueskyPds ? "bsky.social" : sourceDomain; + // Create source PDS client + const sourcePdsClient = new SourcePdsPlcClient(sourcePdsUrl); + + // Log in to the source PDS: signing needs a session, and its + // recommended credentials tell us which rotation keys it holds + let token = args.token; + let sourcePdsKeys: string[] | undefined; + + const password = await p.password({ + message: `Your password for ${sourceDisplayName}:`, + }); + + if (p.isCancel(password)) { + p.cancel("Identity update cancelled."); + process.exit(0); + } + + spinner.start(`Logging in to ${sourceDisplayName}...`); + const sourceClient = new PDSClient(sourcePdsUrl); + try { + const session = await sourceClient.createSession(did, password); + sourcePdsClient.setAuthToken(session.accessJwt); + spinner.stop("Authenticated"); + } catch (err) { + spinner.stop("Login failed"); + p.log.error(err instanceof Error ? err.message : "Authentication failed"); + p.outro("Identity update cancelled."); + process.exit(1); + } + + try { + sourcePdsKeys = (await sourcePdsClient.getRecommendedDidCredentials()) + .rotationKeys; + } catch { + p.log.warn( + `Couldn't ask ${sourceDisplayName} which rotation keys it holds.`, + ); + } + + let choice: RotationKeyChoice; + try { + choice = await chooseRotationKeys({ + did, + handle, + currentKeys, + pdsKey, + pdsName: targetDomain, + sourcePdsKeys, + sourceName: sourceDisplayName, + }); + } catch (err) { + p.log.error(err instanceof Error ? err.message : String(err)); + p.outro("Identity update cancelled."); + process.exit(1); + } + // Show what we're about to do p.note( brightNote([ @@ -193,44 +299,24 @@ export const identityCommand = defineCommand({ `${pc.dim("From:")} ${sourceDisplayName}`, `${pc.dim("To:")} ${targetDomain}`, "", - pc.dim("This tells the network where to find you."), + ...describeRotationKeys(choice), + "", + pc.dim("This tells the network where to find you, and gives"), + pc.dim("your new PDS control of your identity."), ]), "๐Ÿ”„ DID Update", ); - // Create source PDS client - const sourcePdsClient = new SourcePdsPlcClient(sourcePdsUrl); - - // If no token provided, need to get one - let token = args.token; + const proceed = await promptConfirm({ + message: "Update your identity?", + initialValue: true, + }); + if (!proceed) { + p.outro("Identity update cancelled."); + process.exit(0); + } if (!token) { - // Authenticate with source PDS to request token - const password = await p.password({ - message: `Your password for ${sourceDisplayName}:`, - }); - - if (p.isCancel(password)) { - p.cancel("Identity update cancelled."); - process.exit(0); - } - - // Login to source PDS - spinner.start(`Logging in to ${sourceDisplayName}...`); - const sourceClient = new PDSClient(sourcePdsUrl); - try { - const session = await sourceClient.createSession(did, password); - sourcePdsClient.setAuthToken(session.accessJwt); - spinner.stop("Authenticated"); - } catch (err) { - spinner.stop("Login failed"); - p.log.error( - err instanceof Error ? err.message : "Authentication failed", - ); - p.outro("Identity update cancelled."); - process.exit(1); - } - // Request PLC operation signature (sends email) spinner.start("Requesting identity update token..."); const signatureRequest = @@ -267,7 +353,8 @@ export const identityCommand = defineCommand({ // Sign the PLC operation via source PDS spinner.start("Signing identity update..."); const signResult = await sourcePdsClient.signPlcOperation(token.trim(), { - verificationMethods: { atproto: signingKeyDid }, + rotationKeys: choice.rotationKeys, + verificationMethods: { atproto: pdsKey }, services: { atproto_pds: { type: "AtprotoPersonalDataServer", @@ -289,9 +376,21 @@ export const identityCommand = defineCommand({ } spinner.stop("Operation signed"); - // Submit to PLC directory - const plcClient = new PlcDirectoryClient(); + // The source PDS builds the operation; refuse to submit it if it + // doesn't carry the rotation keys we asked for + const signedKeys = signResult.signedOperation.rotationKeys; + if ( + signedKeys.length !== choice.rotationKeys.length || + signedKeys.some((key, i) => key !== choice.rotationKeys[i]) + ) { + p.log.error( + `${sourceDisplayName} signed different rotation keys than requested: ${signedKeys.join(", ")}`, + ); + p.outro("Identity update cancelled. Nothing was submitted."); + process.exit(1); + } + // Submit to PLC directory spinner.start("Submitting to PLC directory..."); const submitResult = await plcClient.submitOperation( did, @@ -313,17 +412,36 @@ export const identityCommand = defineCommand({ const verifyResolver = new DidResolver(); const newDidDoc = await verifyResolver.resolve(did); const newPdsEndpoint = newDidDoc ? getPdsEndpoint(newDidDoc) : null; + const keyMismatch = await checkRotationKeys( + plcClient, + did, + choice.rotationKeys, + ).catch(() => undefined); - if (newPdsEndpoint?.replace(/\/$/, "") === targetEndpoint) { + if (newPdsEndpoint?.replace(/\/$/, "") === targetEndpoint && !keyMismatch) { spinner.stop("Verified! DID now points to new PDS"); } else { spinner.stop("Update submitted (verification pending)"); p.log.warn("It may take a moment for the update to propagate."); + if (keyMismatch) { + p.log.warn( + `plc.directory lists these rotation keys: ${keyMismatch.join(", ") || "none"}`, + ); + p.log.info( + `Check them with: ${formatCommand(pm, "pds", "rotation-keys")}`, + ); + } } // Success! p.log.success("Your identity now points to your new PDS!"); + if (choice.createdRecoveryKey) { + p.log.info( + "Keep your recovery key offline. If your PDS ever makes an identity change you didn't ask for, you have 72 hours to undo it with that key.", + ); + } + p.note( brightNote([ pc.bold("Final step:"), diff --git a/packages/pds/src/cli/utils/rotation-key-prompts.ts b/packages/pds/src/cli/utils/rotation-key-prompts.ts new file mode 100644 index 0000000..13983a4 --- /dev/null +++ b/packages/pds/src/cli/utils/rotation-key-prompts.ts @@ -0,0 +1,171 @@ +/** + * Interactive steps for choosing a DID's rotation keys, shared by + * `pds identity` and `pds rotation-keys`. + */ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import type { PlcDirectoryClient } from "./plc-client.js"; +import { promptConfirm, promptKeyBackup } from "./cli-helpers.js"; +import { + buildRotationKeys, + generateRecoveryKey, + labelRotationKeys, + MAX_ROTATION_KEYS, +} from "./rotation-keys.js"; + +export interface RotationKeyChoice { + /** The full rotation key list, in priority order */ + rotationKeys: string[]; + /** Description of each key, for display */ + descriptions: Map; + /** Current keys that will be removed */ + removed: string[]; + /** Whether a recovery key was generated */ + createdRecoveryKey: boolean; +} + +/** + * Decide the new rotation keys: keep the current keys the user holds, offer + * to generate a recovery key if there are none, and put this PDS's key last. + * + * @param sourcePdsKeys - Keys held by the previous PDS, if known. Without + * them, the user is asked about every key that isn't this PDS's. + * @param sourceName - Display name for the previous PDS + */ +export async function chooseRotationKeys(opts: { + did: string; + handle: string; + currentKeys: string[]; + pdsKey: string; + pdsName: string; + sourcePdsKeys?: string[]; + sourceName?: string; +}): Promise { + const descriptions = new Map([ + [opts.pdsKey, `${opts.pdsName} (this PDS)`], + ]); + const labelled = labelRotationKeys(opts.currentKeys, { + pdsKey: opts.pdsKey, + sourcePdsKeys: opts.sourcePdsKeys, + }); + const sourceKnown = opts.sourcePdsKeys !== undefined; + + const userKeys: string[] = []; + for (const { key, owner } of labelled) { + if (owner === "source-pds") { + descriptions.set(key, opts.sourceName ?? "previous PDS"); + continue; + } + if (owner === "this-pds") { + continue; + } + const keep = await promptConfirm({ + message: sourceKnown + ? `Keep ${pc.cyan(key)}? It isn't held by ${opts.sourceName ?? "your previous PDS"}, so it is probably a recovery key you added.` + : `Do you hold the private key for ${pc.cyan(key)}? Answer yes to keep it, such as a recovery key you added.`, + initialValue: sourceKnown, + }); + if (keep) { + userKeys.push(key); + descriptions.set(key, "your key"); + } else { + descriptions.set(key, "not held by you"); + } + } + + let createdRecoveryKey = false; + if (userKeys.length === 0) { + p.note( + [ + "A recovery key is a rotation key you keep offline.", + "It ranks above your PDS's key, so for 72 hours after any", + "identity change it can override it. If your PDS is lost or", + "compromised, it is how you take your identity back.", + ].join("\n"), + "Recovery key", + ); + const create = await promptConfirm({ + message: "Create a recovery key? (recommended)", + initialValue: true, + }); + if (create) { + const recoveryKey = await generateRecoveryKey(); + const backup = () => + promptKeyBackup({ + key: recoveryKey.privateKey, + kind: "recovery", + handle: opts.handle, + details: [`DID: ${opts.did}`, `Public key: ${recoveryKey.did}`], + allowSkip: false, + }); + let saved = await backup(); + while (!saved) { + const retry = await promptConfirm({ + message: + "Try saving it again? Choosing no continues without a recovery key.", + initialValue: true, + }); + if (!retry) break; + saved = await backup(); + } + if (saved) { + userKeys.push(recoveryKey.did); + descriptions.set(recoveryKey.did, "your new recovery key"); + createdRecoveryKey = true; + } + } + } + + if (userKeys.length > MAX_ROTATION_KEYS - 1) { + throw new Error( + `You chose ${userKeys.length} keys to keep, but a DID can have at most ${MAX_ROTATION_KEYS} rotation keys including this PDS's.`, + ); + } + + const rotationKeys = buildRotationKeys({ userKeys, pdsKey: opts.pdsKey }); + return { + rotationKeys, + descriptions, + removed: opts.currentKeys.filter((k) => !rotationKeys.includes(k)), + createdRecoveryKey, + }; +} + +/** + * Lines describing a rotation key change, for a note. + */ +export function describeRotationKeys(choice: RotationKeyChoice): string[] { + const lines = [pc.bold("Rotation keys, highest priority first:")]; + choice.rotationKeys.forEach((key, i) => { + lines.push(` ${i + 1}. ${key}`); + lines.push(` ${pc.dim(choice.descriptions.get(key) ?? "")}`); + }); + if (choice.removed.length > 0) { + lines.push("", pc.bold("Removed:")); + for (const key of choice.removed) { + lines.push(` ${key}`); + lines.push(` ${pc.dim(choice.descriptions.get(key) ?? "")}`); + } + } + return lines; +} + +/** + * Read the DID's latest operation back and check its rotation keys. + * Returns the keys plc.directory has, or null if they match. + */ +export async function checkRotationKeys( + plcClient: PlcDirectoryClient, + did: string, + expected: string[], +): Promise { + const latest = await plcClient.getLatestOperation(did); + const actual = + latest?.operation.type === "plc_operation" + ? latest.operation.rotationKeys + : []; + return actual.length === expected.length && + actual.every((key, i) => key === expected[i]) + ? null + : actual; +} diff --git a/packages/pds/test/cli/rotation-key-prompts.test.ts b/packages/pds/test/cli/rotation-key-prompts.test.ts new file mode 100644 index 0000000..1d73c71 --- /dev/null +++ b/packages/pds/test/cli/rotation-key-prompts.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@clack/prompts", () => ({ + note: vi.fn(), +})); + +vi.mock("../../src/cli/utils/cli-helpers.js", () => ({ + promptConfirm: vi.fn(), + promptKeyBackup: vi.fn(), +})); + +import { + chooseRotationKeys, + checkRotationKeys, + describeRotationKeys, +} from "../../src/cli/utils/rotation-key-prompts.js"; +import { + promptConfirm, + promptKeyBackup, +} from "../../src/cli/utils/cli-helpers.js"; +import type { PlcDirectoryClient } from "../../src/cli/utils/plc-client.js"; + +const confirm = vi.mocked(promptConfirm); +const backup = vi.mocked(promptKeyBackup); + +const PDS = "did:key:zQ3shPdsKey"; +const SOURCE = "did:key:zQ3shBlueskyKey"; +const RECOVERY = "did:key:zQ3shExistingRecovery"; + +const base = { + did: "did:plc:abc123", + handle: "alice.example.com", + pdsKey: PDS, + pdsName: "pds.example.com", + sourceName: "bsky.social", +}; + +/** Answer confirm prompts in order */ +function answers(...values: boolean[]) { + for (const value of values) { + confirm.mockResolvedValueOnce(value); + } +} + +beforeEach(() => { + confirm.mockReset(); + backup.mockReset(); +}); + +describe("chooseRotationKeys", () => { + it("keeps a recovery key the user holds above the PDS key and drops the source PDS key", async () => { + answers(true); + + const choice = await chooseRotationKeys({ + ...base, + currentKeys: [RECOVERY, SOURCE], + sourcePdsKeys: [SOURCE], + }); + + expect(choice.rotationKeys).toEqual([RECOVERY, PDS]); + expect(choice.removed).toEqual([SOURCE]); + expect(choice.createdRecoveryKey).toBe(false); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0]![0].initialValue).toBe(true); + }); + + it("never asks about the source PDS's own key", async () => { + answers(false); + + const choice = await chooseRotationKeys({ + ...base, + currentKeys: [SOURCE], + sourcePdsKeys: [SOURCE], + }); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0]![0].message).toContain( + "Create a recovery key", + ); + expect(choice.rotationKeys).toEqual([PDS]); + expect(choice.removed).toEqual([SOURCE]); + }); + + it("defaults to not keeping keys when the source PDS's keys are unknown", async () => { + answers(false, false); + + const choice = await chooseRotationKeys({ + ...base, + currentKeys: [SOURCE], + }); + + expect(confirm.mock.calls[0]![0].initialValue).toBe(false); + expect(choice.rotationKeys).toEqual([PDS]); + }); + + it("generates and adds a recovery key once the user has saved it", async () => { + answers(true); + backup.mockResolvedValueOnce(true); + + const choice = await chooseRotationKeys({ + ...base, + currentKeys: [SOURCE], + sourcePdsKeys: [SOURCE], + }); + + expect(choice.createdRecoveryKey).toBe(true); + expect(choice.rotationKeys).toHaveLength(2); + const [recovery, pds] = choice.rotationKeys; + expect(recovery).toMatch(/^did:key:zQ3s/); + expect(pds).toBe(PDS); + + const backupOptions = backup.mock.calls[0]![0]; + expect(backupOptions.kind).toBe("recovery"); + expect(backupOptions.allowSkip).toBe(false); + expect(backupOptions.key).toMatch(/^z/); + expect(backupOptions.details).toContain(`Public key: ${recovery}`); + }); + + it("retries the backup until the user confirms", async () => { + answers(true, true); + backup.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const choice = await chooseRotationKeys({ + ...base, + currentKeys: [SOURCE], + sourcePdsKeys: [SOURCE], + }); + + expect(backup).toHaveBeenCalledTimes(2); + expect(choice.createdRecoveryKey).toBe(true); + }); + + it("leaves the recovery key out if the user gives up saving it", async () => { + answers(true, false); + backup.mockResolvedValueOnce(false); + + const choice = await chooseRotationKeys({ + ...base, + currentKeys: [SOURCE], + sourcePdsKeys: [SOURCE], + }); + + expect(choice.createdRecoveryKey).toBe(false); + expect(choice.rotationKeys).toEqual([PDS]); + }); + + it("does not offer a recovery key when the user already holds one", async () => { + answers(true); + + await chooseRotationKeys({ + ...base, + currentKeys: [RECOVERY, SOURCE], + sourcePdsKeys: [SOURCE], + }); + + expect(backup).not.toHaveBeenCalled(); + }); + + it("keeps the PDS key last when it is already a rotation key", async () => { + answers(true); + + const choice = await chooseRotationKeys({ + ...base, + currentKeys: [PDS, RECOVERY], + sourcePdsKeys: [], + }); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(choice.rotationKeys).toEqual([RECOVERY, PDS]); + expect(choice.removed).toEqual([]); + }); + + it("refuses to keep more keys than leave room for the PDS key", async () => { + const keys = [1, 2, 3, 4, 5].map((n) => `did:key:zUser${n}`); + answers(true, true, true, true, true); + + await expect( + chooseRotationKeys({ ...base, currentKeys: keys, sourcePdsKeys: [] }), + ).rejects.toThrow("at most 5 rotation keys"); + }); +}); + +describe("describeRotationKeys", () => { + it("lists keys in priority order with descriptions, then removed keys", () => { + const lines = describeRotationKeys({ + rotationKeys: [RECOVERY, PDS], + descriptions: new Map([ + [RECOVERY, "your key"], + [PDS, "pds.example.com (this PDS)"], + [SOURCE, "bsky.social"], + ]), + removed: [SOURCE], + createdRecoveryKey: false, + }).join("\n"); + + expect(lines.indexOf(RECOVERY)).toBeLessThan(lines.indexOf(PDS)); + expect(lines).toContain("1. " + RECOVERY); + expect(lines).toContain("2. " + PDS); + expect(lines).toContain("Removed:"); + expect(lines.indexOf("Removed:")).toBeLessThan(lines.indexOf(SOURCE)); + expect(lines).toContain("bsky.social"); + }); +}); + +describe("checkRotationKeys", () => { + function plcWith(operation: Record | null) { + return { + getLatestOperation: vi.fn(async () => (operation ? { operation } : null)), + } as unknown as PlcDirectoryClient; + } + + it("returns null when the keys match in order", async () => { + const plc = plcWith({ + type: "plc_operation", + rotationKeys: [RECOVERY, PDS], + }); + expect(await checkRotationKeys(plc, base.did, [RECOVERY, PDS])).toBeNull(); + }); + + it("returns the actual keys when the order differs", async () => { + const plc = plcWith({ + type: "plc_operation", + rotationKeys: [PDS, RECOVERY], + }); + expect(await checkRotationKeys(plc, base.did, [RECOVERY, PDS])).toEqual([ + PDS, + RECOVERY, + ]); + }); + + it("returns no keys for a tombstoned DID", async () => { + const plc = plcWith({ type: "plc_tombstone" }); + expect(await checkRotationKeys(plc, base.did, [PDS])).toEqual([]); + }); +}); From 1d61a4bc81e2fd0da9c3405ba1132dbd78cf3dc9 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Oct 2026 06:46:08 +0100 Subject: [PATCH 6/8] feat(pds): add `pds rotation-keys` for already-migrated accounts Accounts migrated before `pds identity` set rotation keys still have the previous PDS's key and not Cirrus's. The new command shows the DID's rotation keys and rewrites them as the keys the user holds, then this PDS's key, offering a recovery key if the user holds none. The change is signed by whichever key can: - this PDS, when its key is already a rotation key (adding a recovery key, or dropping keys the user doesn't hold); - the previous PDS, found from the PLC audit log, with its password and an email code; - a rotation key the user pastes in (goat multibase or hex), signed locally. The signed operation is checked against the requested keys and endpoint before submitting, and read back afterwards. Key selection now defaults to keeping keys whose holder is unknown, since pressing enter could otherwise drop a user's recovery key. --- packages/pds/src/cli/commands/identity.ts | 40 +- .../pds/src/cli/commands/rotation-keys.ts | 388 ++++++++++++++++++ packages/pds/src/cli/index.ts | 2 + .../pds/src/cli/utils/rotation-key-prompts.ts | 41 +- packages/pds/src/cli/utils/rotation-keys.ts | 23 ++ .../pds/test/cli/rotation-key-prompts.test.ts | 21 +- packages/pds/test/cli/rotation-keys.test.ts | 67 +++ 7 files changed, 540 insertions(+), 42 deletions(-) create mode 100644 packages/pds/src/cli/commands/rotation-keys.ts diff --git a/packages/pds/src/cli/commands/identity.ts b/packages/pds/src/cli/commands/identity.ts index b6857d4..c656458 100644 --- a/packages/pds/src/cli/commands/identity.ts +++ b/packages/pds/src/cli/commands/identity.ts @@ -31,9 +31,9 @@ import { checkRotationKeys, chooseRotationKeys, describeRotationKeys, + getPdsRotationKey, type RotationKeyChoice, } from "../utils/rotation-key-prompts.js"; -import { Secp256k1Keypair } from "@atproto/crypto"; // Helper to override clack's dim styling in notes const brightNote = (lines: string[]) => @@ -167,20 +167,10 @@ export const identityCommand = defineCommand({ } spinner.stop(`New PDS is ready`); - // The DID must name the key the deployed PDS signs with, which may not - // be the one in .dev.vars spinner.start("Fetching your PDS's signing key..."); let pdsKey: string; try { - const credentials = await targetClient.getRecommendedDidCredentials(); - const rotationKey = credentials.rotationKeys[0]; - if ( - !rotationKey || - credentials.verificationMethods.atproto !== rotationKey - ) { - throw new Error("PDS returned unexpected credentials"); - } - pdsKey = rotationKey; + pdsKey = await getPdsRotationKey(targetClient, targetDomain, signingKey); } catch (err) { spinner.stop("Failed to fetch signing key"); p.log.error( @@ -189,18 +179,6 @@ export const identityCommand = defineCommand({ p.outro("Identity update cancelled."); process.exit(1); } - const localKey = signingKey ? await getSigningKeyDid(signingKey) : null; - if (localKey && localKey !== pdsKey) { - spinner.stop("Signing keys don't match"); - p.log.error( - `The signing key in .dev.vars (${localKey}) is not the one ${targetDomain} uses (${pdsKey}).`, - ); - p.log.info( - "Restore the deployed key to .dev.vars, or redeploy with the local one, then try again.", - ); - p.outro("Identity update cancelled."); - process.exit(1); - } spinner.stop(`Signing key: ${pdsKey}`); const plcClient = new PlcDirectoryClient(); @@ -456,17 +434,3 @@ export const identityCommand = defineCommand({ p.outro("Identity updated! ๐ŸŽ‰"); }, }); - -/** - * Convert a hex-encoded secp256k1 private key to a did:key - * - * This imports the private key and returns the did:key representation. - */ -async function getSigningKeyDid(hexPrivateKey: string): Promise { - try { - const keypair = await Secp256k1Keypair.import(hexPrivateKey); - return keypair.did(); - } catch { - return null; - } -} diff --git a/packages/pds/src/cli/commands/rotation-keys.ts b/packages/pds/src/cli/commands/rotation-keys.ts new file mode 100644 index 0000000..45b757f --- /dev/null +++ b/packages/pds/src/cli/commands/rotation-keys.ts @@ -0,0 +1,388 @@ +/** + * Rotation keys command - review and fix who can update a did:plc identity + * + * Accounts migrated before `pds identity` set rotation keys still have the + * previous PDS's key and not this PDS's. This command shows the current + * keys and rewrites them as: keys the user holds, then this PDS's key. The + * change is signed by whichever key can: this PDS (if it is already a + * rotation key), the previous PDS (with its password and an email code), or + * a rotation key the user pastes in. + */ +import { defineCommand } from "citty"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { getVars } from "../utils/wrangler.js"; +import { readDevVars } from "../utils/dotenv.js"; +import { PDSClient } from "../utils/pds-client.js"; +import { SourcePdsPlcClient, PlcDirectoryClient } from "../utils/plc-client.js"; +import { + getTargetUrl, + getDomain, + detectPackageManager, + formatCommand, + promptConfirm, + promptSelect, + promptText, +} from "../utils/cli-helpers.js"; +import { + checkRotationKeys, + chooseRotationKeys, + describeRotationKeys, + getPdsRotationKey, + type RotationKeyChoice, +} from "../utils/rotation-key-prompts.js"; +import { + findPreviousPdsEndpoint, + importRotationKey, + labelRotationKeys, +} from "../utils/rotation-keys.js"; +import { signOperation, type SignedPlcOperation } from "../../plc.js"; +import type { Keypair } from "@atproto/crypto"; + +const brightNote = (lines: string[]) => + lines.map((l) => `\x1b[0m${l}`).join("\n"); + +function cancel(message: string): never { + p.log.error(message); + p.outro("Rotation keys unchanged."); + process.exit(1); +} + +function displayName(url: string): string { + const domain = getDomain(url); + return domain.endsWith(".bsky.network") ? "bsky.social" : domain; +} + +export const rotationKeysCommand = defineCommand({ + meta: { + name: "rotation-keys", + description: + "Review your DID's rotation keys and make sure your PDS can update your identity", + }, + args: { + dev: { + type: "boolean", + description: "Target local development server instead of production", + default: false, + }, + }, + async run({ args }) { + const pm = detectPackageManager(); + const isDev = args.dev; + + p.intro("๐Ÿ”‘ Rotation Keys"); + + const spinner = p.spinner(); + + const config = { ...readDevVars(), ...getVars() }; + const did = config.DID; + const handle = config.HANDLE; + const authToken = config.AUTH_TOKEN; + + if (!did || !handle || !authToken) { + cancel( + "DID, HANDLE and AUTH_TOKEN must be configured. Run 'pds init' first.", + ); + } + + if (!did.startsWith("did:plc:")) { + p.log.info( + "did:web identities have no rotation keys. Whoever controls the domain controls the identity.", + ); + p.outro("Nothing to do."); + return; + } + + let targetUrl: string; + try { + targetUrl = getTargetUrl(isDev, config.PDS_HOSTNAME); + } catch (err) { + cancel(err instanceof Error ? err.message : "Configuration error"); + } + const targetDomain = getDomain(targetUrl); + const targetEndpoint = targetUrl.replace(/\/$/, ""); + const pdsClient = new PDSClient(targetUrl, authToken); + + spinner.start(`Checking ${targetDomain}...`); + let pdsKey: string; + try { + pdsKey = await getPdsRotationKey( + pdsClient, + targetDomain, + config.SIGNING_KEY, + ); + } catch (err) { + spinner.stop("Failed to fetch signing key"); + cancel( + err instanceof Error + ? err.message + : `Could not reach ${targetDomain}. Is it deployed?`, + ); + } + spinner.stop(`${targetDomain} signs with ${pdsKey}`); + + const plcClient = new PlcDirectoryClient(); + spinner.start("Reading your PLC record..."); + const log = await plcClient.getAuditLog(did).catch(() => null); + const current = log?.filter((entry) => !entry.nullified).pop(); + if (!log || !current || current.operation.type !== "plc_operation") { + spinner.stop("Failed to read PLC record"); + cancel(`Could not read the current PLC operation for ${did}`); + } + spinner.stop("PLC record loaded"); + const operation = current.operation; + + const currentEndpoint = operation.services.atproto_pds?.endpoint?.replace( + /\/$/, + "", + ); + if (currentEndpoint !== targetEndpoint) { + cancel( + `Your DID points to ${currentEndpoint ?? "no PDS"}, not ${targetDomain}. Run '${formatCommand(pm, "pds", "identity")}' to move your identity here first.`, + ); + } + + const labelled = labelRotationKeys(operation.rotationKeys, { pdsKey }); + const pdsCanSign = operation.rotationKeys.includes(pdsKey); + p.note( + brightNote([ + pc.bold("Current rotation keys, highest priority first:"), + ...labelled.map( + ({ key, owner }, i) => + ` ${i + 1}. ${key}${owner === "this-pds" ? pc.dim(` (${targetDomain})`) : ""}`, + ), + "", + pdsCanSign + ? `${pc.green("โœ“")} ${targetDomain} can update your identity.` + : `${pc.red("โœ—")} ${targetDomain} can't update your identity, because its key isn't listed.`, + ]), + "Your identity", + ); + + type Signer = "pds" | "previous" | "key"; + let signer: Signer; + if (pdsCanSign) { + signer = "pds"; + } else { + signer = await promptSelect({ + message: "Who should sign the change? It needs one of the keys above.", + options: [ + { + value: "previous", + label: "My previous PDS", + hint: "needs its password and an email code", + }, + { + value: "key", + label: "A rotation key I hold", + hint: "such as a recovery key", + }, + ], + }); + } + + let sourcePdsClient: SourcePdsPlcClient | undefined; + let sourceName: string | undefined; + let sourcePdsKeys: string[] | undefined; + let signingKeypair: Keypair | undefined; + + if (signer === "previous") { + const previous = findPreviousPdsEndpoint(log, targetEndpoint); + const sourceUrl = await promptText({ + message: "Your previous PDS:", + initialValue: previous ?? "https://bsky.social", + validate: (v) => + v && /^https?:\/\//.test(v.trim()) + ? undefined + : "Enter a URL, such as https://bsky.social", + }); + sourceName = displayName(sourceUrl.trim()); + sourcePdsClient = new SourcePdsPlcClient(sourceUrl.trim()); + + const password = await p.password({ + message: `Your password for ${sourceName}:`, + }); + if (p.isCancel(password)) { + p.cancel("Rotation keys unchanged."); + process.exit(0); + } + + spinner.start(`Logging in to ${sourceName}...`); + try { + const session = await new PDSClient(sourceUrl.trim()).createSession( + did, + password, + ); + sourcePdsClient.setAuthToken(session.accessJwt); + spinner.stop("Authenticated"); + } catch (err) { + spinner.stop("Login failed"); + cancel(err instanceof Error ? err.message : "Authentication failed"); + } + + try { + sourcePdsKeys = (await sourcePdsClient.getRecommendedDidCredentials()) + .rotationKeys; + } catch { + p.log.warn(`Couldn't ask ${sourceName} which rotation keys it holds.`); + } + if ( + sourcePdsKeys && + !sourcePdsKeys.some((key) => operation.rotationKeys.includes(key)) + ) { + cancel( + `${sourceName}'s key isn't one of your rotation keys, so it can't sign this change. Use a rotation key you hold instead.`, + ); + } + } else if (signer === "key") { + const secret = await p.password({ + message: "Paste the private key (multibase or hex):", + }); + if (p.isCancel(secret)) { + p.cancel("Rotation keys unchanged."); + process.exit(0); + } + try { + signingKeypair = await importRotationKey(secret); + } catch (err) { + cancel(err instanceof Error ? err.message : "Could not read key"); + } + if (!operation.rotationKeys.includes(signingKeypair.did())) { + cancel( + `That key (${signingKeypair.did()}) isn't one of your rotation keys, so it can't sign this change.`, + ); + } + p.log.success(`Signing with ${signingKeypair.did()}`); + } + + let choice: RotationKeyChoice; + try { + choice = await chooseRotationKeys({ + did, + handle, + currentKeys: operation.rotationKeys, + pdsKey, + pdsName: targetDomain, + sourcePdsKeys, + sourceName, + heldKeys: signingKeypair ? [signingKeypair.did()] : [], + }); + } catch (err) { + cancel(err instanceof Error ? err.message : String(err)); + } + + const unchanged = + choice.rotationKeys.length === operation.rotationKeys.length && + choice.rotationKeys.every((key, i) => key === operation.rotationKeys[i]); + if (unchanged) { + p.outro("Your rotation keys are already set up. Nothing to change."); + return; + } + + p.note(brightNote(describeRotationKeys(choice)), "๐Ÿ”„ New rotation keys"); + const proceed = await promptConfirm({ + message: "Update your rotation keys?", + initialValue: true, + }); + if (!proceed) { + p.outro("Rotation keys unchanged."); + process.exit(0); + } + + let signed: SignedPlcOperation; + if (signer === "pds") { + spinner.start(`Asking ${targetDomain} to sign...`); + try { + const { token, error } = await pdsClient.getMigrationToken(); + if (!token) throw new Error(error ?? "Could not get a token"); + signed = await pdsClient.signPlcOperation(token, { + rotationKeys: choice.rotationKeys, + }); + } catch (err) { + spinner.stop("Signing failed"); + cancel(err instanceof Error ? err.message : "Could not sign"); + } + spinner.stop("Operation signed"); + } else if (signer === "previous") { + spinner.start("Requesting a confirmation code..."); + const request = await sourcePdsClient!.requestPlcOperationSignature(); + if (!request.success) { + spinner.stop("Failed to request code"); + cancel(request.error ?? "Could not request a confirmation code"); + } + spinner.stop(`${sourceName} has emailed you a confirmation code`); + + const token = await promptText({ + message: "Enter the confirmation code from your email:", + placeholder: "XXXXX-XXXXX", + validate: (v) => + !v || v.trim().length < 5 + ? "Please enter the confirmation code" + : undefined, + }); + + spinner.start(`Asking ${sourceName} to sign...`); + const result = await sourcePdsClient!.signPlcOperation(token.trim(), { + rotationKeys: choice.rotationKeys, + }); + if (!result.success || !result.signedOperation) { + spinner.stop("Signing failed"); + cancel(result.error ?? "Could not sign PLC operation"); + } + signed = result.signedOperation; + spinner.stop("Operation signed"); + } else { + signed = await signOperation( + { + type: "plc_operation", + prev: current.cid, + rotationKeys: choice.rotationKeys, + verificationMethods: operation.verificationMethods, + alsoKnownAs: operation.alsoKnownAs, + services: operation.services, + }, + signingKeypair!, + ); + } + + if ( + signed.rotationKeys.length !== choice.rotationKeys.length || + signed.rotationKeys.some((key, i) => key !== choice.rotationKeys[i]) || + signed.services.atproto_pds?.endpoint?.replace(/\/$/, "") !== + targetEndpoint + ) { + cancel( + "The signed operation doesn't match what was requested, so it was not submitted.", + ); + } + + spinner.start("Submitting to PLC directory..."); + const submitted = await plcClient.submitOperation(did, signed); + if (!submitted.success) { + spinner.stop("Failed to submit operation"); + cancel(submitted.error ?? "PLC directory rejected the operation"); + } + spinner.stop("Submitted"); + + const mismatch = await checkRotationKeys( + plcClient, + did, + choice.rotationKeys, + ).catch(() => undefined); + if (mismatch) { + p.log.warn( + `plc.directory lists these rotation keys: ${mismatch.join(", ") || "none"}`, + ); + } else if (mismatch === null) { + p.log.success(`${targetDomain} can now update your identity.`); + } + + if (choice.createdRecoveryKey) { + p.log.info( + "Keep your recovery key offline. If your PDS ever makes an identity change you didn't ask for, you have 72 hours to undo it with that key.", + ); + } + + p.outro("Rotation keys updated! ๐Ÿ”‘"); + }, +}); diff --git a/packages/pds/src/cli/index.ts b/packages/pds/src/cli/index.ts index b1e4171..9389c06 100644 --- a/packages/pds/src/cli/index.ts +++ b/packages/pds/src/cli/index.ts @@ -10,6 +10,7 @@ import { initCommand } from "./commands/init.js"; import { migrateCommand } from "./commands/migrate.js"; import { migrateTokenCommand } from "./commands/migrate-token.js"; import { identityCommand } from "./commands/identity.js"; +import { rotationKeysCommand } from "./commands/rotation-keys.js"; import { activateCommand } from "./commands/activate.js"; import { deactivateCommand } from "./commands/deactivate.js"; import { statusCommand } from "./commands/status.js"; @@ -31,6 +32,7 @@ const main = defineCommand({ migrate: migrateCommand, "migrate-token": migrateTokenCommand, identity: identityCommand, + "rotation-keys": rotationKeysCommand, activate: activateCommand, deactivate: deactivateCommand, status: statusCommand, diff --git a/packages/pds/src/cli/utils/rotation-key-prompts.ts b/packages/pds/src/cli/utils/rotation-key-prompts.ts index 13983a4..115c7a1 100644 --- a/packages/pds/src/cli/utils/rotation-key-prompts.ts +++ b/packages/pds/src/cli/utils/rotation-key-prompts.ts @@ -4,7 +4,9 @@ */ import * as p from "@clack/prompts"; import pc from "picocolors"; +import { Secp256k1Keypair } from "@atproto/crypto"; import type { PlcDirectoryClient } from "./plc-client.js"; +import type { PDSClient } from "./pds-client.js"; import { promptConfirm, promptKeyBackup } from "./cli-helpers.js"; import { buildRotationKeys, @@ -13,6 +15,32 @@ import { MAX_ROTATION_KEYS, } from "./rotation-keys.js"; +/** + * Get the key the deployed PDS signs with, from its recommended + * credentials. The DID must name this key, which may not be the one in + * .dev.vars, so a different local key is an error. + */ +export async function getPdsRotationKey( + client: PDSClient, + pdsName: string, + localSigningKey?: string, +): Promise { + const credentials = await client.getRecommendedDidCredentials(); + const pdsKey = credentials.rotationKeys[0]; + if (!pdsKey || credentials.verificationMethods.atproto !== pdsKey) { + throw new Error(`${pdsName} returned unexpected credentials`); + } + if (localSigningKey) { + const localKey = (await Secp256k1Keypair.import(localSigningKey)).did(); + if (localKey !== pdsKey) { + throw new Error( + `The signing key in .dev.vars (${localKey}) is not the one ${pdsName} uses (${pdsKey}). Restore the deployed key to .dev.vars, or redeploy with the local one, then try again.`, + ); + } + } + return pdsKey; +} + export interface RotationKeyChoice { /** The full rotation key list, in priority order */ rotationKeys: string[]; @@ -31,6 +59,7 @@ export interface RotationKeyChoice { * @param sourcePdsKeys - Keys held by the previous PDS, if known. Without * them, the user is asked about every key that isn't this PDS's. * @param sourceName - Display name for the previous PDS + * @param heldKeys - Keys known to be the user's, kept without asking */ export async function chooseRotationKeys(opts: { did: string; @@ -40,6 +69,7 @@ export async function chooseRotationKeys(opts: { pdsName: string; sourcePdsKeys?: string[]; sourceName?: string; + heldKeys?: string[]; }): Promise { const descriptions = new Map([ [opts.pdsKey, `${opts.pdsName} (this PDS)`], @@ -59,11 +89,18 @@ export async function chooseRotationKeys(opts: { if (owner === "this-pds") { continue; } + if (opts.heldKeys?.includes(key)) { + userKeys.push(key); + descriptions.set(key, "your key"); + continue; + } + // Keeping a key nobody holds only preserves the status quo, while + // dropping one the user holds loses it, so the default is to keep const keep = await promptConfirm({ message: sourceKnown ? `Keep ${pc.cyan(key)}? It isn't held by ${opts.sourceName ?? "your previous PDS"}, so it is probably a recovery key you added.` - : `Do you hold the private key for ${pc.cyan(key)}? Answer yes to keep it, such as a recovery key you added.`, - initialValue: sourceKnown, + : `Keep ${pc.cyan(key)}? Answer no only if you don't hold this key, such as one belonging to your previous PDS.`, + initialValue: true, }); if (keep) { userKeys.push(key); diff --git a/packages/pds/src/cli/utils/rotation-keys.ts b/packages/pds/src/cli/utils/rotation-keys.ts index 6be9724..02ed3aa 100644 --- a/packages/pds/src/cli/utils/rotation-keys.ts +++ b/packages/pds/src/cli/utils/rotation-keys.ts @@ -12,6 +12,7 @@ import { Secp256k1Keypair, type Keypair, } from "@atproto/crypto"; +import type { PlcAuditLog } from "../../plc.js"; /** plc.directory accepts at most this many rotation keys */ export const MAX_ROTATION_KEYS = 5; @@ -71,6 +72,28 @@ export function labelRotationKeys( })); } +/** + * Find the PDS a DID pointed at before the current one, from its PLC audit + * log. Returns null if it has never pointed anywhere else. + */ +export function findPreviousPdsEndpoint( + log: PlcAuditLog[], + currentEndpoint: string, +): string | null { + const current = currentEndpoint.replace(/\/$/, ""); + for (const entry of [...log].reverse()) { + if (entry.nullified || entry.operation.type !== "plc_operation") continue; + const endpoint = entry.operation.services.atproto_pds?.endpoint?.replace( + /\/$/, + "", + ); + if (endpoint && endpoint !== current) { + return endpoint; + } + } + return null; +} + export interface RecoveryKey { /** Private key in multibase form, as used by goat's --plc-signing-key */ privateKey: string; diff --git a/packages/pds/test/cli/rotation-key-prompts.test.ts b/packages/pds/test/cli/rotation-key-prompts.test.ts index 1d73c71..f7859f6 100644 --- a/packages/pds/test/cli/rotation-key-prompts.test.ts +++ b/packages/pds/test/cli/rotation-key-prompts.test.ts @@ -81,7 +81,7 @@ describe("chooseRotationKeys", () => { expect(choice.removed).toEqual([SOURCE]); }); - it("defaults to not keeping keys when the source PDS's keys are unknown", async () => { + it("defaults to keeping keys when the source PDS's keys are unknown", async () => { answers(false, false); const choice = await chooseRotationKeys({ @@ -89,10 +89,27 @@ describe("chooseRotationKeys", () => { currentKeys: [SOURCE], }); - expect(confirm.mock.calls[0]![0].initialValue).toBe(false); + expect(confirm.mock.calls[0]![0].initialValue).toBe(true); + expect(confirm.mock.calls[0]![0].message).toContain("Answer no only if"); expect(choice.rotationKeys).toEqual([PDS]); }); + it("keeps held keys without asking", async () => { + answers(false); + + const choice = await chooseRotationKeys({ + ...base, + currentKeys: [SOURCE, RECOVERY], + heldKeys: [RECOVERY], + }); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0]![0].message).toContain(SOURCE); + expect(choice.rotationKeys).toEqual([RECOVERY, PDS]); + expect(choice.removed).toEqual([SOURCE]); + expect(backup).not.toHaveBeenCalled(); + }); + it("generates and adds a recovery key once the user has saved it", async () => { answers(true); backup.mockResolvedValueOnce(true); diff --git a/packages/pds/test/cli/rotation-keys.test.ts b/packages/pds/test/cli/rotation-keys.test.ts index 9dd0775..658e625 100644 --- a/packages/pds/test/cli/rotation-keys.test.ts +++ b/packages/pds/test/cli/rotation-keys.test.ts @@ -7,6 +7,7 @@ import { } from "@atproto/crypto"; import { buildRotationKeys, + findPreviousPdsEndpoint, generateRecoveryKey, importRotationKey, labelRotationKeys, @@ -68,6 +69,72 @@ describe("labelRotationKeys", () => { }); }); +describe("findPreviousPdsEndpoint", () => { + const entry = ( + endpoint: string | null, + opts: { nullified?: boolean; type?: string } = {}, + ) => ({ + did: "did:plc:abc123", + operation: { + type: opts.type ?? "plc_operation", + prev: null, + sig: "sig", + rotationKeys: [], + verificationMethods: {}, + alsoKnownAs: [], + services: endpoint + ? { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint, + }, + } + : {}, + }, + cid: "cid", + nullified: opts.nullified ?? false, + createdAt: "2025-01-01T00:00:00.000Z", + }); + const log = (...entries: ReturnType[]) => + entries as Parameters[0]; + + it("returns the most recent endpoint before the current one", () => { + expect( + findPreviousPdsEndpoint( + log( + entry("https://first.example"), + entry("https://morel.us-east.host.bsky.network"), + entry("https://pds.example.com/"), + ), + "https://pds.example.com", + ), + ).toBe("https://morel.us-east.host.bsky.network"); + }); + + it("skips nullified operations and operations without a PDS", () => { + expect( + findPreviousPdsEndpoint( + log( + entry("https://real-previous.example"), + entry("https://attacker.example", { nullified: true }), + entry(null), + entry("https://pds.example.com"), + ), + "https://pds.example.com/", + ), + ).toBe("https://real-previous.example"); + }); + + it("returns null when the DID has only pointed at the current PDS", () => { + expect( + findPreviousPdsEndpoint( + log(entry("https://pds.example.com"), entry("https://pds.example.com")), + "https://pds.example.com", + ), + ).toBeNull(); + }); +}); + describe("generateRecoveryKey", () => { it("generates a multibase secp256k1 key that imports to the same did:key", async () => { const { privateKey, did } = await generateRecoveryKey(); From c006ad4151f85881f88f040b25fc5383769d002f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Oct 2026 06:51:23 +0100 Subject: [PATCH 7/8] feat(pds): show rotation key status in `pds status` For did:plc accounts whose DID points at this PDS, status now reports whether the PDS's key is a rotation key (an error if not, since the PDS then can't update the identity) and warns when it is the only one, pointing to `pds rotation-keys` in both cases. --- packages/pds/src/cli/commands/status.ts | 25 +++++++++ packages/pds/src/cli/utils/checks.ts | 31 +++++++++++ packages/pds/test/cli/checks.test.ts | 73 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 packages/pds/test/cli/checks.test.ts diff --git a/packages/pds/src/cli/commands/status.ts b/packages/pds/src/cli/commands/status.ts index 40b98b7..ba5ed15 100644 --- a/packages/pds/src/cli/commands/status.ts +++ b/packages/pds/src/cli/commands/status.ts @@ -13,6 +13,7 @@ import { checkRepoInitialised, checkBlobsImported, checkAppViewIndexing, + checkPlcRotationKeys, } from "../utils/checks.js"; const CHECK = pc.green("โœ“"); @@ -167,6 +168,30 @@ export const statusCommand = defineCommand({ console.log( ` ${CHECK} DID resolves to this PDS (via ${didCheck.resolveMethod})`, ); + if (did.startsWith("did:plc:")) { + const keys = await checkPlcRotationKeys(client, did); + if (!keys) { + console.log(` ${WARN} Could not check rotation keys`); + hasWarnings = true; + } else if (!keys.pdsCanSign) { + console.log( + ` ${CROSS} This PDS can't update your identity (its key isn't a rotation key)`, + ); + console.log(pc.dim(" Fix with: pds rotation-keys")); + hasErrors = true; + } else if (keys.otherKeys === 0) { + console.log(` ${CHECK} This PDS can update your identity`); + console.log( + ` ${WARN} No recovery key: your PDS is your only rotation key`, + ); + console.log(pc.dim(" Add one with: pds rotation-keys")); + hasWarnings = true; + } else { + console.log( + ` ${CHECK} This PDS can update your identity, with ${keys.otherKeys} other rotation ${keys.otherKeys === 1 ? "key" : "keys"}`, + ); + } + } } else if (didCheck.pdsEndpoint) { console.log(` ${CROSS} DID resolves to different PDS`); console.log(pc.dim(` Resolved via: ${didCheck.resolveMethod}`)); diff --git a/packages/pds/src/cli/utils/checks.ts b/packages/pds/src/cli/utils/checks.ts index e5b3775..175554c 100644 --- a/packages/pds/src/cli/utils/checks.ts +++ b/packages/pds/src/cli/utils/checks.ts @@ -4,6 +4,7 @@ import { PDSClient, type MigrationStatus } from "./pds-client.js"; import { resolveHandleToDid } from "./handle-resolver.js"; import { DidResolver } from "../../did-resolver.js"; +import { PlcDirectoryClient } from "./plc-client.js"; export interface CheckResult { ok: boolean; @@ -120,6 +121,36 @@ export async function checkDidDocument( }; } +/** + * Check whether this PDS can update a did:plc identity: its signing key + * must be one of the DID's rotation keys. Also reports whether any other + * key is listed, such as a recovery key. Returns null if either side + * can't be read. + */ +export async function checkPlcRotationKeys( + client: PDSClient, + did: string, + plcClient = new PlcDirectoryClient(), +): Promise<{ pdsCanSign: boolean; otherKeys: number } | null> { + try { + const [credentials, latest] = await Promise.all([ + client.getRecommendedDidCredentials(), + plcClient.getLatestOperation(did), + ]); + const pdsKey = credentials.rotationKeys[0]; + if (!pdsKey || latest?.operation.type !== "plc_operation") { + return null; + } + const keys = latest.operation.rotationKeys; + return { + pdsCanSign: keys.includes(pdsKey), + otherKeys: keys.filter((key) => key !== pdsKey).length, + }; + } catch { + return null; + } +} + /** * Check that DID resolves and returns the PDS endpoint (simpler version using PDSClient) */ diff --git a/packages/pds/test/cli/checks.test.ts b/packages/pds/test/cli/checks.test.ts new file mode 100644 index 0000000..1dafbd9 --- /dev/null +++ b/packages/pds/test/cli/checks.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from "vitest"; +import { checkPlcRotationKeys } from "../../src/cli/utils/checks.js"; +import type { PDSClient } from "../../src/cli/utils/pds-client.js"; +import type { PlcDirectoryClient } from "../../src/cli/utils/plc-client.js"; + +const PDS = "did:key:zQ3shPdsKey"; +const DID = "did:plc:abc123"; + +function pdsWith(rotationKeys: string[]) { + return { + getRecommendedDidCredentials: vi.fn(async () => ({ + rotationKeys, + verificationMethods: { atproto: rotationKeys[0] }, + })), + } as unknown as PDSClient; +} + +function plcWith(operation: Record | null) { + return { + getLatestOperation: vi.fn(async () => (operation ? { operation } : null)), + } as unknown as PlcDirectoryClient; +} + +describe("checkPlcRotationKeys", () => { + it("reports that the PDS can sign, with the number of other keys", async () => { + const result = await checkPlcRotationKeys( + pdsWith([PDS]), + DID, + plcWith({ + type: "plc_operation", + rotationKeys: ["did:key:zRecovery", PDS], + }), + ); + expect(result).toEqual({ pdsCanSign: true, otherKeys: 1 }); + }); + + it("reports when the PDS key is missing", async () => { + const result = await checkPlcRotationKeys( + pdsWith([PDS]), + DID, + plcWith({ type: "plc_operation", rotationKeys: ["did:key:zOldPds"] }), + ); + expect(result).toEqual({ pdsCanSign: false, otherKeys: 1 }); + }); + + it("returns null when the PLC state can't be read", async () => { + expect( + await checkPlcRotationKeys(pdsWith([PDS]), DID, plcWith(null)), + ).toBeNull(); + expect( + await checkPlcRotationKeys( + pdsWith([PDS]), + DID, + plcWith({ type: "plc_tombstone" }), + ), + ).toBeNull(); + }); + + it("returns null when the PDS can't be reached", async () => { + const pds = { + getRecommendedDidCredentials: vi.fn(async () => { + throw new Error("offline"); + }), + } as unknown as PDSClient; + expect( + await checkPlcRotationKeys( + pds, + DID, + plcWith({ type: "plc_operation", rotationKeys: [PDS] }), + ), + ).toBeNull(); + }); +}); From efae325b87dc312446c91db0e8bfc973801653a4 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 5 Oct 2026 06:54:56 +0100 Subject: [PATCH 8/8] docs(pds): document rotation and recovery keys - README: `pds identity` steps, the new `pds rotation-keys` command, a note that outbound migration needs the PDS to be a rotation key, and a Rotation and Recovery Keys section covering priority, the 72-hour override and using the recovery key with goat. The goat commands were run against a local PLC directory with a generated recovery key. - Root README Key Safety: the signing key is now also a rotation key, and migration offers a recovery key. - Plans: record what was built and tested, and what still needs a live test; note rotation keys in the migration plan. - Changeset. --- .changeset/plc-rotation-keys.md | 9 +++ README.md | 8 ++- packages/pds/README.md | 65 ++++++++++++++++--- plans/complete/migration-wizard.md | 2 +- .../plc-rotation-keys.md | 31 ++++++++- 5 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 .changeset/plc-rotation-keys.md rename plans/{todo => in-progress}/plc-rotation-keys.md (78%) diff --git a/.changeset/plc-rotation-keys.md b/.changeset/plc-rotation-keys.md new file mode 100644 index 0000000..af1f43d --- /dev/null +++ b/.changeset/plc-rotation-keys.md @@ -0,0 +1,9 @@ +--- +"@getcirrus/pds": minor +--- + +Migrating a `did:plc` account with `pds identity` now gives your PDS control of your identity. Previously the PDS you migrated from kept the only rotation key, so Cirrus couldn't sign changes to your DID: it couldn't change your handle or rotate its signing key, and migrating away from Cirrus failed at the PLC directory. `pds identity` now makes your PDS's signing key a rotation key, removes the previous PDS's key, and offers to create a recovery key that ranks above it. The recovery key works with `goat` if you ever need to recover your identity without your PDS. + +Accounts that were already migrated can run the new `pds rotation-keys` command to fix this. The change can be signed by your previous PDS (with your password there and an email code), by a rotation key you hold, or by your PDS once it is a rotation key. `pds status` now reports whether your PDS can update your identity, and outbound migration explains the problem instead of failing at the PLC directory. + +`pds identity --token` also works now. It previously skipped the login that signing needs. diff --git a/README.md b/README.md index e6fbc84..922b468 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ Your signing key controls your identity. Cloudflare secrets cannot be retrieved When you run `pds init`, you'll be prompted to back up your signing key. Store it somewhere safe โ€“ a password manager, encrypted backup, or similar. +When you migrate a `did:plc` account with `pds identity`, you'll also be offered a recovery key. It lets you take back your identity if your PDS is lost or compromised. Keep it offline, separate from your signing key. + ### Key Recovery If you've cloned to a new machine and see the "Key Recovery Required" error: @@ -97,9 +99,9 @@ If you've cloned to a new machine and see the "Key Recovery Required" error: **For did:plc users:** -- If you have a recovery key registered with PLC, you can rotate to a new signing key -- Without a recovery key, you'll need to start a new identity -- See the [AT Protocol PLC documentation](https://github.com/did-method-plc/did-method-plc) for recovery operations +- Your signing key is also one of your DID's rotation keys, so while your deployed PDS is still running it can update your identity. Run `pds rotation-keys` to add a recovery key if you don't have one +- If you have a recovery key, you can use it to point your DID at a new signing key. See [Rotation and Recovery Keys](./packages/pds/README.md#rotation-and-recovery-keys) +- Without a recovery key or a running PDS, you'll need to start a new identity ## Requirements diff --git a/packages/pds/README.md b/packages/pds/README.md index 12710b0..be0465c 100644 --- a/packages/pds/README.md +++ b/packages/pds/README.md @@ -135,19 +135,38 @@ Updates your DID document to point to your new PDS. This is the critical step th ```bash pds identity # Update identity for production pds identity --dev # Update identity for local dev -pds identity --token XXX # Skip email step if you have a token +pds identity --token XXX # Skip the email step if you already have a code ``` The command: 1. Resolves your current DID to find the source PDS 2. Authenticates with your source PDS (requires your password) -3. Requests an email confirmation token -4. Gets the source PDS to sign a PLC operation with the new endpoint -5. Submits the signed operation to the PLC directory +3. Chooses your [rotation keys](#rotation-and-recovery-keys): any keys you already hold, then your new PDS's key. The source PDS's key is removed. If you don't hold any keys, it offers to create a recovery key and helps you back it up +4. Shows a summary and asks you to confirm +5. Requests an email confirmation code from the source PDS +6. Gets the source PDS to sign a PLC operation with the new endpoint, signing key and rotation keys +7. Submits the signed operation to the PLC directory and checks the result **Note:** Only `did:plc` identities are supported. `did:web` identities don't use PLC operations. +### `pds rotation-keys` + +Shows your DID's [rotation keys](#rotation-and-recovery-keys) and makes sure your PDS is one of them. + +```bash +pds rotation-keys # Check and update rotation keys for production +pds rotation-keys --dev # Same, for local dev +``` + +Accounts migrated with an earlier version of `pds identity` still list the previous PDS's key and not your PDS's, so your PDS can't change your handle or move your identity. This command fixes that, and can also add a recovery key. It sets the rotation keys to the keys you hold, then your PDS's key. The change has to be signed by a key that is already listed, so it uses: + +- **Your PDS**, if it is already a rotation key +- **Your previous PDS**, which needs your password there and an email code. This only works while your old account still exists +- **A rotation key you hold**, such as a recovery key. Paste the private key when asked; it is used locally and never sent anywhere + +`pds status` reports whether your PDS can update your identity. + ### `pds activate` Enables writes on the account after migration. @@ -195,6 +214,8 @@ When migrating to a new PDS, the destination will ask for a confirmation token. The token is copied to your clipboard and displayed in the terminal. After migration completes, run `pds deactivate` on this PDS. +The PDS signs the migration with its signing key, so that key must be one of your DID's [rotation keys](#rotation-and-recovery-keys). If `pds status` says your PDS can't update your identity, run `pds rotation-keys` first. + ### `pds passkey` Manage passkeys for passwordless authentication. @@ -545,14 +566,15 @@ If interrupted, run `pds migrate` again to resume. npx pds identity ``` -This updates your DID document to point to your new PDS. The command: +This updates your DID document to point to your new PDS and gives your new PDS control of your identity. The command: 1. Authenticates with your source PDS (requires password) -2. Requests an email confirmation token -3. Gets the source PDS to sign a PLC operation with your new endpoint -4. Submits the signed operation to the PLC directory +2. Sets your rotation keys: keys you hold first, then your new PDS's key. If you hold none, it offers to create a [recovery key](#rotation-and-recovery-keys). Back it up when asked +3. Requests an email confirmation code +4. Gets the source PDS to sign a PLC operation with your new endpoint, signing key and rotation keys +5. Submits the signed operation to the PLC directory -You'll receive an email with a confirmation token โ€“ enter it when prompted. +You'll receive an email with a confirmation code โ€“ enter it when prompted. ### Step 4: Activate the account @@ -594,6 +616,31 @@ npx pds activate # Enable writes npx pds status # Check everything is working ``` +## Rotation and Recovery Keys + +A `did:plc` identity lists up to five **rotation keys**. Any change to the DID document (your PDS, your signing key, your handle) has to be signed by one of them, so whoever holds a rotation key controls the identity. + +After migration your rotation keys are: + +1. Your **recovery key**, if you have one +2. Your PDS's signing key + +Your PDS can then change your handle, rotate its signing key, or migrate you to another PDS, without involving your previous PDS. + +The order matters. For 72 hours after any change, a higher-priority key can override it by signing a competing operation. Your recovery key ranks above your PDS's key, so if your PDS is compromised and someone changes your identity, you have 72 hours to undo it. After that the change is permanent. Your recovery key can also update your DID at any time if your PDS is gone. + +Keep the recovery key offline, such as in a password manager. It is a multibase private key in the format used by [goat](https://github.com/bluesky-social/goat). To use it in an emergency, for example to point your DID at a new PDS: + +```bash +goat plc update --pds https://new-pds.example.com did:plc:... > op.json +goat plc sign --plc-signing-key z... op.json > signed.json +goat plc submit --did did:plc:... signed.json +``` + +To override a change made in the last 72 hours, build the operation on top of the last one you trust by adding `--prev ` to `goat plc update`. The audit log at `https://plc.directory/did:plc:.../log/audit` lists each operation's CID. See the [PLC specification](https://github.com/did-method-plc/did-method-plc) for details. + +Run `pds rotation-keys` to check your keys or add a recovery key, and `pds status` to see whether your PDS can update your identity. + ## Validation Records are validated against AT Protocol lexicon schemas before being stored. The PDS uses optimistic validation: diff --git a/plans/complete/migration-wizard.md b/plans/complete/migration-wizard.md index 62453d0..009c221 100644 --- a/plans/complete/migration-wizard.md +++ b/plans/complete/migration-wizard.md @@ -159,7 +159,7 @@ curl "https://alice.example.com/xrpc/com.atproto.server.getAccountStatus" ### Step 5: Update PLC Directory -This points your DID to the new PDS. Requires signing by old PDS via email challenge: +This points your DID to the new PDS. Requires signing by old PDS via email challenge. `pds identity` does this, and also sets the DID's rotation keys to the keys the user holds followed by the new PDS's signing key, removing the old PDS's key and offering to create a recovery key (see `plans/in-progress/plc-rotation-keys.md`). Accounts migrated before that can fix their rotation keys with `pds rotation-keys`. ```bash # Request PLC operation signature from old PDS diff --git a/plans/todo/plc-rotation-keys.md b/plans/in-progress/plc-rotation-keys.md similarity index 78% rename from plans/todo/plc-rotation-keys.md rename to plans/in-progress/plc-rotation-keys.md index b1b63e5..d570e78 100644 --- a/plans/todo/plc-rotation-keys.md +++ b/plans/in-progress/plc-rotation-keys.md @@ -1,6 +1,6 @@ # PLC Rotation Keys and Recovery Keys -**Status:** ๐Ÿ“‹ Planning +**Status:** ๐Ÿงช Implemented, awaiting a live test on a new account **Priority:** P1 (affects every did:plc account migrated to Cirrus) ## Problem @@ -39,7 +39,7 @@ did:web accounts are unaffected; they have no rotation keys. | Recovery key type | secp256k1, generated locally by the CLI | Never sent to Cloudflare or written to `.dev.vars`. | | Emergency override (nullifying a malicious op within 72h) | Documented, using `goat` | Building a nullification flow is a separate piece of work. | -**Open:** the export format for the recovery key. It must be usable by `goat` for the emergency path. Check which private key encodings `goat plc` accepts (hex vs multibase) before implementing, and print the key in that form alongside the `did:key`. +**Recovery key format:** multibase, as `goat key generate` produces: `z` followed by base58btc of the multicodec prefix (`0x81 0x26` for secp256k1, `0x86 0x26` for P-256) and the 32-byte private key. `goat plc sign --plc-signing-key` takes this form. Checked against goat in both directions for both curves. ## Changes @@ -120,12 +120,37 @@ Every operation keeps `services`, `verificationMethods` and `alsoKnownAs` from t - `plans/complete/migration-wizard.md`: note rotation key handling in the identity step. - Changeset, `@getcirrus/pds` minor, describing the user-visible change: migration now gives your PDS control of your identity and offers a recovery key; existing accounts can run `pds rotation-keys`; leaving Cirrus reports a clear error instead of a PLC rejection. +## Implementation notes + +Differences from the plan above, and details worth knowing: + +- `PDSClient` only gained `getRecommendedDidCredentials` and `signPlcOperation`. Both commands submit straight to plc.directory, so no `submitPlcOperation` client method. +- When it is unknown who holds a key, the prompt defaults to keeping it. Keeping a key the user doesn't hold preserves the status quo; dropping one they do hold loses it. +- Signed operations are compared with the requested rotation keys (and, in `pds rotation-keys`, the endpoint) before submitting, so a source PDS that signs something else is caught before plc.directory sees it. +- `pds identity --token` now logs in to the source PDS too. Signing needs a session, so that path could not have worked before. +- `pds rotation-keys` finds the previous PDS from the most recent different endpoint in the PLC audit log. +- PR #250's `updatePlcHandle` should use `getLatestPlcOperation` and `signOperation` from `src/plc.ts`. +- **Auth on PLC signing.** `getMigrationToken` and `signPlcOperation` accepted any authenticated caller, including app passwords, OAuth tokens of any scope, and service JWTs. That only mattered where the signing key was a rotation key, which this work makes the norm, so both now require the static `AUTH_TOKEN`, a password session, or OAuth with `identity:*`. `AuthInfo.method` records how a request authenticated. Separately, `getServiceAuth` mints service JWTs with any `aud` and `lxm` for any caller, and a self-addressed one passes `requireAuth` with full trust. That is wider than PLC and is not fixed here. + ## Verification PLC operations are permanent, so manual testing uses throwaway did:plc accounts, never a real one. +**Done, against fakes:** a throwaway harness ran the built CLI under `expect`, with fetch routed to a fake PLC directory and fake PDSes. It used real genesis operations, DIDs and CIDs, and after every scenario checked the whole op log with goat's `VerifyOpLog` (the reference Go implementation). Passing scenarios: + +- `pds identity` from a PDS holding the only rotation key, creating a recovery key: result `[recovery, pds]`, signed by the old PDS. +- `pds rotation-keys` with nothing to change: no operation submitted. +- `pds rotation-keys` on an account migrated with the old CLI, signed by the previous PDS: `[recovery, pds]`. +- `pds rotation-keys` signed locally with a goat P-256 key, dropping the old PDS key: `[user key, pds]`. +- `pds rotation-keys` where the PDS is the only key, signed by the PDS, adding a recovery key. +- Refusals with nothing submitted: a pasted key that isn't a rotation key, and a source PDS that signs different rotation keys than requested. +- `pds status` for each state. +- The README's goat emergency commands with a generated recovery key, moving the DID to a new PDS. + +**Still to do, live:** + - Migrate a fresh bsky.social test account with the updated `pds identity`, with and without generating a recovery key. Check the audit log shows the expected rotation keys and Bluesky's key is gone. -- Run `pds rotation-keys` on an account migrated with the current CLI, signing through the old PDS. +- Run `pds rotation-keys` on an account migrated with the previous version of the CLI, signing through the old PDS. - Repeat with the old account deleted and a pre-existing recovery key. - Change the handle through PR #250's `updateHandle` on a fixed account; the PLC step should succeed. - Migrate the fixed account out of Cirrus to another PDS.