Something went wrong. Try again.
Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow
Something went wrong. Try again.
3.5 kB · 87 lines
TypeScript
at oauth
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788/** * Persists the temporary atproto verification key the Restore page assigns to a DID's PLC * document during account recovery. That key is what signs the service-auth token used to create * the account on the new PDS, and it stays assigned to the DID until the recovery is handed over * to the PDS. Caching it means a page refresh or a retry reuses the same key and skips publishing * another temporary PLC operation, as long as the document still holds the key. * * One key is kept for one account at a time (the entry carries its DID; load() refuses a * different one). It is cleared when recovery completes, at which point the handover op has * replaced the temporary key and it is no longer useful. * * Security note: this stores a private key in localStorage. Browsers cannot hold a secp256k1 key * as a non-extractable WebCrypto key (ES256K is unsupported), so this is the same threat model as * the password-session JWTs the other caches keep. The exposure is limited: the key only does * anything while it is still the DID's atproto verification method, and only against one PDS. */
const STORAGE_KEY = 'pdsmoover:restore-verification-key:v1'
export interface RestoreVerificationKey { /** The account the key was created for. */ did: string /** Private key in multikey (`z...`) format, re-importable via `parsePrivateMultikey`. */ privateKey: string /** Matching public key as a `did:key` string, compared against the PLC document. */ publicKey: string savedAt: number}
function hasStorage(): boolean { return typeof localStorage !== 'undefined'}
function isEntry(value: unknown): value is RestoreVerificationKey { if (typeof value !== 'object' || value === null) return false const entry = value as Record<string, unknown> return ( typeof entry.did === 'string' && typeof entry.privateKey === 'string' && entry.privateKey.startsWith('z') && typeof entry.publicKey === 'string' && entry.publicKey.startsWith('did:key:') )}
function readRaw(): RestoreVerificationKey | null { if (!hasStorage()) return null const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return null try { const parsed: unknown = JSON.parse(raw) if (!isEntry(parsed)) throw new Error('unexpected shape') return parsed } catch { // Corrupt entry — drop it so we don't keep tripping over it. localStorage.removeItem(STORAGE_KEY) return null }}
/** * Persist a verification key for a DID, replacing any key saved for another account. Call after * the temp PLC op is signed (or reused) and before creating the account, so a failed run can be * retried with the same key. */export function save(did: string, privateKey: string, publicKey: string): void { if (!hasStorage()) return const entry: RestoreVerificationKey = { did, privateKey, publicKey, savedAt: Date.now() } localStorage.setItem(STORAGE_KEY, JSON.stringify(entry))}
/** * The saved key for this DID, or null when there is none (or the saved key belongs to a different * account). A null result is not itself a reason to clear the entry: a key for another DID may * still be resumable. */export function load(did: string): RestoreVerificationKey | null { const entry = readRaw() if (!entry || entry.did !== did) return null return entry}
/** Remove the saved key (recovery completed, or the user starts over). */export function clear(): void { if (!hasStorage()) return localStorage.removeItem(STORAGE_KEY)}