clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/184-task-notice-migration
lofi / package / runtime / recovery.ts
recovery.ts 3.8K
1/**2 * Recovery-phrase backup for the local-first account.3 *4 * A lofi account is a 32-byte secret held on the device. That is enough to work5 * offline forever, but it means a cleared browser or a second device has no way6 * back in. A recovery phrase is the honest, portable backup: the *same* secret,7 * encoded as 24 English words the user can write down. Typing them back on any8 * device reconstructs the identical account — so the data that synced up comes9 * straight back down, decrypted, under the same identity.10 *11 * This is a thin, feature-detected wrapper over Jazz's `RecoveryPhrase`12 * (`jazz-tools/passphrase`). It makes no server or escrow claim: the phrase is13 * the account, so whoever holds it holds the account, and losing every copy of14 * both the device and the phrase loses it for good.15 *16 * @module17 */1819import { RecoveryPhrase, RecoveryPhraseError } from "jazz-tools/passphrase";2021/** The number of words in a lofi recovery phrase. */22export const RECOVERY_PHRASE_WORDS = 24;2324/** A precise, non-leaking failure reason for a recovery-phrase operation. */25export class RecoveryError extends Error {26 /** Stable error class name for diagnostics and error boundaries. */27 override readonly name = "RecoveryError";28 /** Actionable category that callers can map to recovery guidance. */29 readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret";30 /** Creates a phrase error without retaining the submitted phrase. */31 constructor(code: RecoveryError["code"], message?: string) {32 super(message ?? `Recovery phrase operation failed: ${code}.`);33 this.code = code;34 }35}3637const MESSAGES: Record<RecoveryError["code"], string> = {38 "invalid-length":39 `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,40 "invalid-word": "One of the words is not in the recovery word list — check your spelling.",41 "invalid-checksum":42 "That phrase is not a valid recovery phrase — re-check the words and their order.",43 "invalid-secret": "The account secret could not be encoded as a recovery phrase.",44};4546function mapError(error: unknown): RecoveryError {47 if (error instanceof RecoveryError) return error;48 if (error instanceof RecoveryPhraseError) {49 const code = error.code as RecoveryError["code"];50 return new RecoveryError(code, MESSAGES[code] ?? undefined);51 }52 return new RecoveryError("invalid-checksum", error instanceof Error ? error.message : undefined);53}5455/**56 * Encodes an account secret as its {@link RECOVERY_PHRASE_WORDS}-word recovery57 * phrase. The phrase *is* the secret — show it once for the user to write down,58 * never persist it.59 */60export function toRecoveryPhrase(secret: string): string {61 try {62 return RecoveryPhrase.fromSecret(secret);63 } catch (error) {64 throw mapError(error);65 }66}6768// Collapses the many ways a person types a phrase (extra spaces, stray casing,69// smart punctuation) down to the canonical space-separated lowercase words the70// decoder expects, so an honest phrase is not rejected over formatting.71function normalizePhrase(input: string): string {72 return input73 .normalize("NFKD")74 .replace(/[^A-Za-z\s]/g, " ")75 .trim()76 .replace(/\s+/g, " ")77 .toLowerCase();78}7980/**81 * Decodes a user-entered recovery phrase back into the account secret,82 * tolerating whitespace and casing differences. Throws {@link RecoveryError}83 * with an actionable code — never a fabricated secret — when the phrase is84 * malformed.85 */86export function fromRecoveryPhrase(phrase: string): string {87 const normalized = normalizePhrase(phrase);88 if (normalized.length === 0) throw new RecoveryError("invalid-length");89 if (normalized.split(" ").length !== RECOVERY_PHRASE_WORDS) {90 throw new RecoveryError("invalid-length");91 }92 try {93 return RecoveryPhrase.toSecret(normalized);94 } catch (error) {95 throw mapError(error);96 }97}