clone

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

feat/prf-provision-custody
lofi / package / runtime / recovery.ts
recovery.ts 3.9K
1/**2// Package-owned recovery encoding.3 * Recovery-phrase backup for the local-first account.4 *5 * A lofi account is a 32-byte secret held on the device. That is enough to work6 * offline forever, but it means a cleared browser or a second device has no way7 * back in. A recovery phrase is the honest, portable backup: the *same* secret,8 * encoded as 24 English words the user can write down. Typing them back on any9 * device reconstructs the identical account — so the data that synced up comes10 * straight back down, decrypted, under the same identity.11 *12 * This is a thin, feature-detected wrapper over Jazz's `RecoveryPhrase`13 * (`jazz-tools/passphrase`). It makes no server or escrow claim: the phrase is14 * the account, so whoever holds it holds the account, and losing every copy of15 * both the device and the phrase loses it for good.16 *17 * @module18 */1920import { RecoveryPhrase, RecoveryPhraseError } from "jazz-tools/passphrase";2122/** The number of words in a lofi recovery phrase. */23export const RECOVERY_PHRASE_WORDS = 24;2425/** A precise, non-leaking failure reason for a recovery-phrase operation. */26export class RecoveryError extends Error {27 /** Stable error class name for diagnostics and error boundaries. */28 override readonly name = "RecoveryError";29 /** Actionable category that callers can map to recovery guidance. */30 readonly code: "invalid-length" | "invalid-word" | "invalid-checksum" | "invalid-secret";31 /** Creates a phrase error without retaining the submitted phrase. */32 constructor(code: RecoveryError["code"], message?: string) {33 super(message ?? `Recovery phrase operation failed: ${code}.`);34 this.code = code;35 }36}3738const MESSAGES: Record<RecoveryError["code"], string> = {39 "invalid-length":40 `A recovery phrase is ${RECOVERY_PHRASE_WORDS} words — check for a missing or extra word.`,41 "invalid-word": "One of the words is not in the recovery word list — check your spelling.",42 "invalid-checksum":43 "That phrase is not a valid recovery phrase — re-check the words and their order.",44 "invalid-secret": "The account secret could not be encoded as a recovery phrase.",45};4647function mapError(error: unknown): RecoveryError {48 if (error instanceof RecoveryError) return error;49 if (error instanceof RecoveryPhraseError) {50 const code = error.code as RecoveryError["code"];51 return new RecoveryError(code, MESSAGES[code] ?? undefined);52 }53 return new RecoveryError("invalid-checksum", error instanceof Error ? error.message : undefined);54}5556/**57 * Encodes an account secret as its {@link RECOVERY_PHRASE_WORDS}-word recovery58 * phrase. The phrase *is* the secret — show it once for the user to write down,59 * never persist it.60 */61export function toRecoveryPhrase(secret: string): string {62 try {63 return RecoveryPhrase.fromSecret(secret);64 } catch (error) {65 throw mapError(error);66 }67}6869// Collapses the many ways a person types a phrase (extra spaces, stray casing,70// smart punctuation) down to the canonical space-separated lowercase words the71// decoder expects, so an honest phrase is not rejected over formatting.72function normalizePhrase(input: string): string {73 return input74 .normalize("NFKD")75 .replace(/[^A-Za-z\s]/g, " ")76 .trim()77 .replace(/\s+/g, " ")78 .toLowerCase();79}8081/**82 * Decodes a user-entered recovery phrase back into the account secret,83 * tolerating whitespace and casing differences. Throws {@link RecoveryError}84 * with an actionable code — never a fabricated secret — when the phrase is85 * malformed.86 */87export function fromRecoveryPhrase(phrase: string): string {88 const normalized = normalizePhrase(phrase);89 if (normalized.length === 0) throw new RecoveryError("invalid-length");90 if (normalized.split(" ").length !== RECOVERY_PHRASE_WORDS) {91 throw new RecoveryError("invalid-length");92 }93 try {94 return RecoveryPhrase.toSecret(normalized);95 } catch (error) {96 throw mapError(error);97 }98}