Skip to content

feat(pds): give Cirrus a PLC rotation key and offer recovery keys - #253

Open
ascorbic wants to merge 8 commits into
mainfrom
feat/plc-rotation-keys
Open

ascorbic wants to merge 8 commits into
mainfrom
feat/plc-rotation-keys

Conversation

@ascorbic

@ascorbic ascorbic commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Summary

Migrating a did:plc account left the previous PDS holding the only rotation key. pds identity asked the source PDS to sign an operation with rotationKeys: undefined, so the Cirrus signing key became a verification method but never a rotation key. As a result Cirrus could not sign any change to the DID:

  • Outbound migration from Cirrus failed at plc.directory, since signPlcOperation signed with a key that wasn't allowed to.
  • Handle changes through the PLC directory (feat(pds): implement com.atproto.identity.updateHandle #250) only work for accounts that happen to list the Cirrus key.
  • The old host could still rewrite the DID, including the PDS endpoint.
  • Users who deleted their old account and had no recovery key were left with nobody able to change their DID.

This PR makes the migrated account's rotation keys [keys the user holds…, Cirrus signing key], removes the old PDS's key, and offers a recovery key that ranks above Cirrus. It adds a command to fix already-migrated accounts, and locks down who can get the PDS to sign PLC operations.

Commits

  1. docs(plans): the plan (plans/in-progress/plc-rotation-keys.md, updated with results in the last commit).
  2. fix(pds): refuse to sign PLC operations without a rotation key. signPlcOperation returns a clear error instead of an operation plc.directory will reject. PLC types, audit-log lookup and signing move to src/plc.ts.
  3. fix(pds): require full access to sign PLC operations. getMigrationToken and signPlcOperation accepted app passwords, OAuth tokens of any scope, and service JWTs. Once Cirrus is a rotation key that would let any signed-in app take over the identity, so this lands before the commits that make it one. They now need AUTH_TOKEN, a password session, or OAuth with identity:*.
  4. feat(pds): CLI helpers. PLC client changes, rotation key ordering and labelling, recovery key generation, and import of pasted keys. Keys use goat's multibase encoding. The signing key backup prompt moves out of init so recovery keys can reuse it.
  5. feat(pds): pds identity sets rotation keys. It identifies the source PDS's keys via its getRecommendedDidCredentials, asks about any others, and offers a recovery key (added only after the user confirms it's backed up). It takes the PDS key from the deployed PDS rather than .dev.vars, checks the signed operation before submitting, and reads it back afterwards. --token now logs in too; it couldn't have worked before.
  6. feat(pds): pds rotation-keys. Fixes already-migrated accounts. The change is signed by the PDS (if already a rotation key), the previous PDS (found from the PLC audit log; password plus email code), or a rotation key the user pastes (signed locally).
  7. feat(pds): pds status reports whether the PDS can update the identity and whether there's a recovery key.
  8. docs(pds): README (commands, a Rotation and Recovery Keys section with goat emergency steps), root README Key Safety, and changesets.

Notes for review

  • Unknown keys default to "keep". When the CLI can't tell who holds a key, pressing enter keeps it. Keeping a key the user doesn't hold preserves the status quo; dropping one they do hold loses it.
  • Not fixed here: getServiceAuth mints service JWTs with any aud/lxm for any authenticated caller, and a self-addressed one passes requireAuth with full trust. The PLC endpoints now refuse service JWTs, but the wider issue remains.
  • feat(pds): implement com.atproto.identity.updateHandle #250 should use getLatestPlcOperation/signOperation from src/plc.ts. With this merged, its PLC path works for migrated accounts.

Test plan

  • Unit tests: 349 PDS, 124 CLI. Each commit builds and passes on its own; no new type errors.
  • Recovery key format checked against goat in both directions, for K-256 and P-256.
  • End to end against fakes: the built CLI driven by expect, with fetch routed to a fake PLC directory and fake PDSes using real genesis ops, DIDs and CIDs. After each scenario the whole op log was verified with goat's VerifyOpLog (the reference Go implementation):
    • pds identity from a PDS holding the only key, creating a recovery key → [recovery, pds]
    • pds rotation-keys with nothing to change → nothing submitted
    • on an account migrated with the old CLI, signed by the previous PDS → [recovery, pds]
    • signed locally with a goat P-256 key, dropping the old PDS key → [user key, pds]
    • PDS as the only key, PDS signs to add a recovery key
    • refused with nothing submitted: a pasted key that isn't a rotation key; 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
  • Live, on a new throwaway bsky.social account:
    • pds identity: check the audit log has [recovery, cirrus] and Bluesky's key is gone. Confirm bsky.social's key is recognised (not offered as "probably a recovery key you added"); that depends on its getRecommendedDidCredentials returning the key that is in the PLC log
    • pds rotation-keys on an account migrated with the previous CLI, signing through bsky.social (does a deactivated account still get an email code?)
    • Migrate out of Cirrus to another PDS
    • Use the recovery key with goat

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.
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.
- 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.
`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.
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.
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.
- 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.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
pdscheck efae325 Oct 05 2026, 06:06 AM

@pkg-pr-new

pkg-pr-new Bot commented Oct 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/create-pds@253
npm i https://pkg.pr.new/@getcirrus/oauth-provider@253
npm i https://pkg.pr.new/@getcirrus/pds@253

commit: efae325

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
cirrusdocs efae325 Commit Preview URL

Branch Preview URL
Oct 05 2026, 06:06 AM

@decoded-cipher

Copy link
Copy Markdown

#250 (updateHandle) is now rebased onto this branch and uses getLatestPlcOperation/signOperation from src/plc.ts, as mentioned in the description. It targets feat/plc-rotation-keys, so it can be merged here and reach main with this PR. If you'd rather keep them separate, I'll point it back at main.

It also refuses service JWTs in updateHandle, using AuthInfo.method from here, since a self-addressed one from getServiceAuth skipped the identity:handle check.

Tested together on a did:plc instance where the Cirrus key is already a rotation key: handle changes there write PLC operations through src/plc.ts (audit log). That account wasn't migrated and then fixed with pds rotation-keys, so the updateHandle check in the plan's live tests still applies.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants