Account Recovery with Rotation Keys

I’ve been working on password reset and account recovery in atproto-pds and atproto-entryway, and I’ve landed on a feature I want to discuss broadly, get feedback on, and propose to the community.

Problem

Password and account recovery is strongly tied to email, and there aren’t any on-protocol or protocol-adjacent solutions that don’t require the identity to have immediate access to a single inbox. I have some mixed feelings about using email addresses and cell phone numbers that I’m not going to get into here, but the tl;dr is that reliance on them during an incident is precarious.

There are lots of reasons that you may not have access to incoming email from your PDS operator including, and not limited to, not having access to a shared inbox and related shared-account woes, being locked out of an email account, DNS*, etc.

Regardless, you’re left asking the operator for help, and on a network where the whole point is that your identity doesn’t depend on the operator’s goodwill, that’s a bad place to end up.

Another thing that bugs me is that account holders already have a credential that outranks their account password: rotation keys. A rotation key on a did:plc identity can sign a PLC operation that moves the identity to a different PDS, and that is a much more powerful proof of ownership than a password. Letting a rotation key get you back into your account recognizes that holder’s authority.

Design

The existing password reset flow is established as a “request a token, use the token” process that incorporates email.

To reset a password, the account holder needs to have both a server-generated token (like “ABCD-1234”) that was given to them by invoking the com.atproto.server.requestPasswordReset XRPC method with the email address associated with the account in question. The account holder then invokes the com.atproto.server.resetPassword XRPC method with both the token and password fields to change the account password.

I propose minting and submitting an account recovery JWT token instead of the server-generated token. This account recovery JWT (as {"typ": "recovery+jwt"}) proves ownership of the rotation key because only the holder can sign the JWT with it. A single-use, short-lived recovery JWT can be used in place of the PDS-generated token with the resetPassword XRPC method. From there, resetPassword works exactly as it does today.

This is an addition, not a replacement. The account portal’s “enter your code and a new password” page is the same page for both paths. A client with an “I already have a code” screen works without knowing recovery keys exist.

Solution

The account recovery JWT is a compact JWS that describes the identity and signature proof, and includes reasonable, paved-path security constraints.

The header includes the algorithm (alg), type (typ), and key identifier (kid). The JWT type limits this JWT’s use, so it can’t be confused with other token types, such as general OAuth access tokens or inter-service authentication tokens. The key identifier is the did-method-key serialized public key that must be byte-for-byte identical to one of the rotation keys (rotationKeys) in the DID document.

The claims include the issuer (iss), audience (aud), issued at (iat), expiration time (exp), and JWT ID (jti). Both the issuer and audience are DIDs that map to the identity that issued the JWT and the identity meant to receive and process the token, in this case the PDS on which the identity’s account is on. The issued at and expires at claims ensure short-lived tokens are created to prevent possible abuse. The JWT ID ensures the token cannot be replayed, and servers that consume this type of account recovery token must take special care to prevent reuse.

{
  "alg": "ES256K",
  "typ": "recovery+jwt",
  "kid": "did:key:zQ3sh…"
}
{
  "iss": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
  "aud": "did:web:mayon.pyroclastic.cloud",
  "iat": 1760000000,
  "exp": 1760000300,
  "jti": "Nk3n0WsT1F9m8Q2aXv7cLg"
}

The typ does not reuse the existing jwt, at+jwt, or refresh+jwt consumed by the PDS. There is also no lxm because the type already scopes the token to a single, specific purpose.

Rotation Key Constraints

PLC lists rotation keys in order of authority. A key earlier in the list can override (within the 72-hour recovery window) an operation signed by a key later in it. A PDS holds rotation keys for its accounts so it can sign PLC operations on their behalf. The rule I landed on is that the key signing a recovery token must rank above every rotation key the service signs with.

For example, if the rotation key list looks like [A, B, C, D] where the operator issues B and C, then only A could be used to recover an account.

A key ranked below the service’s is a key the service can override, so it represents control the service could take back. A key ranked above can override the service. Only the second kind is the right thing to hang a reset credential on.

There’s also a specific attack this closes. Someone who briefly holds a session and an email token can have the service sign a PLC operation that appends a rotation key. If any rotation key could mint a reset code, that appended key is a backdoor that survives a password change and an email change. Under this rule, it mints nothing, and the real holder, with a higher key, can remove it.

Additionally, the service’s own keys never qualify. The operator can’t walk this path on its own accounts, and a leaked operator rotation key is not a reset credential for every account the service hosts.

The set the rule compares against is the keys the service signs PLC operations with online. If a service has no rotation key for an identity at all, like an account that migrated while keeping its own keys, any rotation key qualifies. Granted, this is a weird edge case that I hope is just theoretical.

Reference Implementation

This is implemented and deployed as a feature of atproto-pds and atproto-entryway within atproto-crates. The atproto-identity crate has both recovery_token::mint and recovery_token::verify for minting and verifying the account recovery JWTs. The verifier takes the token, the accepted audiences, the current time, the lifetime ceiling, the identity’s current rotationKeys and the public halves of the service’s own keys. It hands back the verified claims or a typed refusal.

atpdid recovery-token mints a token from a rotation key kept in a file or an environment variable. It reads the DID’s audit log, checks the key is a current rotation key, and prints the token on stdout. atpdid recovery-redeem takes that token and the new password and posts both to com.atproto.server.resetPassword. It sends them to the server the token is addressed to: the token’s did:web audience, or the PDS named in the issuer’s verified PLC log, unless --pds says otherwise. The two commands are separate so that the minting machine can stay offline.

atpdid recovery-token --did did:plc:... \
  --aud did:web:mayon.pyroclastic.cloud \
  --key recovery.key > token.txt

atpdid recovery-redeem token.txt --password new-password.txt

The resetPassword XRPC takes a token and a password, with both as plain strings. The server does a small shape-check to see if the token looks like a JWT, and then proceeds to parse it as an account recovery JWT if it does. This one call checks the token, spends it, and resets the password. The lexicon defines only InvalidToken and ExpiredToken, so any other refusal comes back as InvalidToken, with a message saying which check failed, which feels appropriately vague.

The account portal’s forgot-password page offers two options: a code by email, or a recovery token plus a new password. atproto-entryway does the same for a cluster and accepts either its own DID or the hosting member’s DID as aud. A member PDS in entryway mode forwards resetPassword to the entryway, as it already did.

Because it’s an existing method, there’s no lexicon to publish or resolve, and any client that can call resetPassword can redeem a recovery token. A server without the feature looks the token up as an emailed code, finds nothing, and answers InvalidToken. A server that has the feature but has it switched off says so in the InvalidToken message. It’s deployed on Mayon and Vesuvius, and you can see the whole flow at https://mayon.pyroclastic.cloud/account/password/forgot.

Closing

I believe this is a worthwhile measure to ensure account holders can recover their accounts when they otherwise couldn’t. This builds on the strengths of did-method-plc and verification methods, and has the big benefit of not requiring any additional change to the existing password reset lexicon schema. The rank rule matches the authority model PLC already has instead of inventing one, and the verifier is a simple process that any implementation can lift.

Counterarguments and caveats: it serves did:plc identities, and only those whose holder has set up a rotation key above the service’s, which today is a minority of accounts. It adds a token type every bearer-token verifier has to refuse. It puts PLC resolution behind an unauthenticated endpoint, which means rate limiting and caching are part of the implementation rather than nice-to-haves.

The code is in the atproto-crates workspace: the token module is at https://tangled.org/ngerakines.me/atproto-crates/blob/main/crates/atproto-identity/src/recovery_token.rs.

11 Likes

I think this is pretty interesting and would align account management more directly with theory. That is, we talk about DIDs being the root of account identity in theory, and this would make that a bit more real.

If you control an identity DID, it is already possible to do adversarial account migration, so in some sense this doesn’t feel like it would change the overall control and account security picture too much. did:web is an interesting case to consider because control can pass on to another party if the domain registration lapses (or I guess if it is hijacked).

A thread modeling question is whether there is data which you can only get with account credentials on a specific host, and not with account identity control. The registered account email address is one example (assuming the PDS requires an email address). Things like chat.bsky.* DMs are accessed over XRPC, and an identity controller can mint service auth tokens. Space data which isn’t public could usually be accessed using identity control by requesting a space credential, then using that for requests to the account host.

I think I am onboard with the overall direction of this. But i’m curious to hear what others thing.


In terms of the details and prioritizing/sequencing this as an interoperable standard, I think it might take some time, at least for implementation in the Bluesky-maintained PDS distribution. There are some related open questions that might bundle up with this one: how does a PDS become aware of identity updates (PLC, did:web, or handle invalidation) for hosted accounts? How should the PDS treat accounts when the account identity is pointed to a different PDS instance? Should the PDS support hosting accounts with did:plc when the PDS has the atproto signing key, but doesn’t control any rotation keys (similar to how did:web works)?

5 Likes

Some follow-up thoughts:

  • this feels a lot like passkeys. for everyday use and external ecosystem support, passkeys would probably go a long way, even if they don’t necessarily touch on the atproto identity concept
  • currently PLC rotation keys are only used to sign PLC operations. could they even be used to sign JWTs if they were in a hardware enclave or something like that?
  • an emergency recovery flow that would align more directly with how adversarial account migration works would be to have the account update their PLC identity with a new #atproto signing keypair that they control, and then use that to do an XRPC service auth request to the PDS to update the password
3 Likes

If the only PLC rotation keys are the ones held by the account host provider, this might complicate things here because of whoever holds the keys hold the accounts (aka the “Not your (rotation) keys, not your crypto Atmosphere account” part of non-custodian account management). While the host operators can require additional proof of identity in case of issues with their email address or 2FA, I would want to worry if an attacker (human or otherwise AI agent) deliberately accessed the secret keys and do nasty things there. That’s my two cents for now.

I like this direction very much, but would like to take it a step further and also allow it to do OAuth login’s and session creations directly ( you could do it anyway after a password reset )

Actually it is more or less the same flow I used in my FedCM demo here:

and tried to discuss here:

In this demo I used an id-token signed by the OAuth issuer as an id_token_hint in the OAuth flow but my preferred way would be to use a jwt token (deliberately not naming it id-token here to remove the OAuth relation ) proofing you possess a non operator owned (top level) rotation key.

Also i would like to suggest to add client binding ( HTTP message signatures )

It boils down to be able to identify yourself (login) with a jwt proofing you possess a top level rotation key instead of a password.

2 Likes