/** * 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 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) }