clone

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

main
lofi / package / runtime / recovery.ts
recovery.ts 3.9K
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/** Stable recovery-phrase failure categories for actionable UI guidance. */25export type RecoveryErrorCode =26 | "invalid-length"27 | "invalid-word"28 | "invalid-checksum"29 | "invalid-secret";3031const MESSAGES: Record<RecoveryErrorCode, string> = {32 "invalid-length":33 `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,34 "invalid-word": "One of the words is not in the recovery word list — check your spelling.",35 "invalid-checksum":36 "That phrase is not a valid recovery phrase — re-check the words and their order.",37 "invalid-secret": "The account secret could not be encoded as a recovery phrase.",38};3940/** A precise, non-leaking failure reason for a recovery-phrase operation. */41export class RecoveryError extends Error {42 /** Stable error class name for diagnostics and error boundaries. */43 override readonly name = "RecoveryError";44 /** Actionable category that callers can map to recovery guidance. */45 readonly code: RecoveryErrorCode;46 /** Creates a phrase error without retaining the submitted phrase. */47 constructor(code: RecoveryErrorCode, message?: string) {48 super(message ?? MESSAGES[code]);49 this.code = code;50 }51}5253function mapError(error: unknown): RecoveryError {54 if (error instanceof RecoveryError) return error;55 if (error instanceof RecoveryPhraseError) {56 const code = error.code as RecoveryErrorCode;57 return new RecoveryError(code, MESSAGES[code] ?? undefined);58 }59 return new RecoveryError("invalid-checksum", error instanceof Error ? error.message : undefined);60}6162/**63 * Encodes an account secret as its {@link RECOVERY_PHRASE_WORDS}-word recovery64 * phrase. The phrase *is* the secret — show it once for the user to write down,65 * never persist it.66 */67export function toRecoveryPhrase(secret: string): string {68 try {69 return RecoveryPhrase.fromSecret(secret);70 } catch (error) {71 throw mapError(error);72 }73}7475// Collapses the many ways a person types a phrase (extra spaces, stray casing,76// smart punctuation) down to the canonical space-separated lowercase words the77// decoder expects, so an honest phrase is not rejected over formatting.78function normalizePhrase(input: string): string {79 return input80 .normalize("NFKD")81 .replace(/[^A-Za-z\s]/g, " ")82 .trim()83 .replace(/\s+/g, " ")84 .toLowerCase();85}8687/**88 * Decodes a user-entered recovery phrase back into the account secret,89 * tolerating whitespace and casing differences. Throws {@link RecoveryError}90 * with an actionable code — never a fabricated secret — when the phrase is91 * malformed.92 */93export function fromRecoveryPhrase(phrase: string): string {94 const normalized = normalizePhrase(phrase);95 if (normalized.length === 0) throw new RecoveryError("invalid-length");96 if (normalized.split(" ").length !== RECOVERY_PHRASE_WORDS) {97 throw new RecoveryError("invalid-length");98 }99 try {100 return RecoveryPhrase.toSecret(normalized);101 } catch (error) {102 throw mapError(error);103 }104}