/** * CJK phrase import flow (spec §8). * * Screens, in order: * * 1. Paste — phrase pasted here. Structural validation runs live (§8.3) * so the user sees "✓ Phrase recognised" or a precise error without * clicking anything. * 2. Passphrase — only reached after the paste validates AND the * BLAKE3 checksum verifies. Corruption is surfaced before asking * the user to type their passphrase (§4.2 Step 4). * 3. Restoring — progress labels as the pipeline runs. * 4. Merge choice — only shown when the caller reports existing * entries in the vault. Skipped entirely in onboarding mode. * 5. Success — entry count plus "Open vault" button. * * The component supports two modes: * * - mode="existing": vault already unlocked. Entries can be merged or * the vault can be replaced wholesale. This is the Settings → Import * entry point. * - mode="onboarding": no vault yet. Before restoring we ask the user * to set a fresh master password, which is used to CREATE_VAULT and * unlock. Entry point from the Lock screen "Restore from CJK phrase" * card. */ import { createMemo, createSignal, onMount, Show } from "solid-js"; import { decodePhraseToBytes, decryptAndDeserialise, parseHeaderLine, splitPhrase, validatePhraseStructure, verifyIntegrity, } from "../../crypto/cjkImport"; import type { VaultBackupPayload } from "../../crypto/cjkSerialise"; import type { ExtractedFields } from "../../crypto/cjkPayload"; import type { VaultEntry } from "../../shared/types"; import { sendMessage } from "../App"; import { PeachWordmark } from "../components/PeachMark"; // --------------------------------------------------------------------------- // Error code → user-facing copy (spec §9) // --------------------------------------------------------------------------- const IMPORT_ERROR_COPY: Record = { INVALID_HEADER: "This doesn't look like a Peach backup phrase. Check that you copied the whole phrase including the first line.", INVALID_HEADER_FORMAT: "This doesn't look like a Peach backup phrase. Check that you copied the whole phrase including the first line.", INVALID_CHARACTER: "The phrase contains characters that aren't part of a Peach backup. It may have been modified or partially corrupted.", CHECKSUM_FAILED: "The phrase appears to be incomplete or corrupted. A few missing or changed characters will cause this. Try copying it again exactly as exported.", DECRYPT_FAILED: "Wrong passphrase. This is the passphrase you used when you exported, not your vault password. Try again.", UNSUPPORTED_VERSION: "This phrase was created by a newer version of Peach. Update Peach and try again.", UNSUPPORTED_FLAGS: "This phrase uses an encoding format not supported by this version of Peach.", PAYLOAD_TOO_SHORT: "The phrase is too short to be a complete backup. It may have been truncated.", ENTRY_COUNT_MISMATCH: "The phrase appears to be incomplete — the number of entries found doesn't match the header.", DESERIALISE_OVERFLOW: "The phrase contains more data than expected. It may have been modified.", DESERIALISE_UNDERFLOW: "The phrase is incomplete — not all entries could be read.", HEADER_COUNT_MISMATCH: "The phrase header is inconsistent. It may have been modified.", PHRASE_TOO_SHORT: "The phrase is too short to be a complete backup. It may have been truncated.", NO_CJK_CONTENT: "The phrase is missing its body. Copy the whole phrase, including all lines of Chinese characters.", }; function messageForError(err: unknown): string { const raw = err instanceof Error ? err.message : String(err); // Error messages may include a suffix like "UNSUPPORTED_VERSION: 3". Match // on the leading code. const code = raw.split(":")[0].trim(); return IMPORT_ERROR_COPY[code] ?? `Import failed: ${raw}`; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- type Screen = | "paste" | "passphrase" | "onboarding-master" | "restoring" | "merge" | "success"; type ProgressStage = | "idle" | "decoding" | "verifying" | "deriving" | "decrypting" | "importing"; const PROGRESS_COPY: Record = { idle: "", decoding: "Decoding phrase…", verifying: "Verifying integrity…", deriving: "Deriving key…", decrypting: "Decrypting…", importing: "Importing entries…", }; export type CJKImportMode = "existing" | "onboarding"; interface CJKImportProps { mode: CJKImportMode; onBack: () => void; /** Called on successful import. For onboarding, the vault has been * created and unlocked before this fires. */ onDone: () => void | Promise; } export function CJKImportView(props: CJKImportProps) { const [screen, setScreen] = createSignal("paste"); const [existingEntryCount, setExistingEntryCount] = createSignal(0); // In "existing" mode, fetch the current vault size so we know whether to // show the merge screen. Onboarding mode has no existing vault. onMount(async () => { if (props.mode !== "existing") return; const response = await sendMessage({ type: "GET_ENTRIES" }); const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? []; setExistingEntryCount(entries.filter((e) => !e.deletedAt).length); }); const [phrase, setPhrase] = createSignal(""); const [passphrase, setPassphrase] = createSignal(""); const [masterPassword, setMasterPassword] = createSignal(""); const [masterConfirm, setMasterConfirm] = createSignal(""); // Populated once the phrase decodes + checksum verifies. const [verifiedFields, setVerifiedFields] = createSignal(null); const [announcedEntryCount, setAnnouncedEntryCount] = createSignal(0); // Populated after decryption — held in memory briefly while the merge // screen is on-screen. const [restoredPayload, setRestoredPayload] = createSignal(null); const [progress, setProgress] = createSignal("idle"); const [pasteError, setPasteError] = createSignal(null); const [passphraseError, setPassphraseError] = createSignal(null); const [masterError, setMasterError] = createSignal(null); const [importedCount, setImportedCount] = createSignal(0); // ------------------------------------------------------------------------- // Screen 1: paste + structural validation // ------------------------------------------------------------------------- const structureOk = createMemo(() => phrase().trim().length > 0 ? validatePhraseStructure(phrase()) : null ); const pasteSummary = createMemo(() => { if (!structureOk()) return null; try { const { headerLine } = splitPhrase(phrase()); const header = parseHeaderLine(headerLine); return { entryCount: header.entryCount }; } catch { return null; } }); async function handlePhraseInput(value: string) { setPhrase(value); setPasteError(null); } async function handlePasteFromClipboard() { try { const text = await navigator.clipboard.readText(); setPhrase(text); setPasteError(null); } catch { setPasteError( "Couldn't read the clipboard. Paste into the box above instead." ); } } function handleFileSelect(e: Event) { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const text = ev.target?.result as string; setPhrase(text); setPasteError(null); }; reader.readAsText(file); } /** * Verify integrity before moving to the passphrase screen. A corrupt * phrase is surfaced here — the user never sees the passphrase field * until we know the bytes are whole. */ async function handleContinueFromPaste() { setPasteError(null); if (!structureOk()) { setPasteError(IMPORT_ERROR_COPY.INVALID_HEADER); return; } try { setProgress("decoding"); const { headerLine, cjkContent } = splitPhrase(phrase()); const header = parseHeaderLine(headerLine); setAnnouncedEntryCount(header.entryCount); const binary = decodePhraseToBytes(cjkContent); setProgress("verifying"); const fields = await verifyIntegrity(binary); if (header.entryCount !== fields.entryCount) { throw new Error("HEADER_COUNT_MISMATCH"); } setVerifiedFields(fields); setProgress("idle"); // Onboarding mode asks for a new master password before the export // passphrase. Existing mode jumps straight to the export passphrase. setScreen( props.mode === "onboarding" ? "onboarding-master" : "passphrase" ); } catch (err) { setProgress("idle"); setPasteError(messageForError(err)); } } // ------------------------------------------------------------------------- // Screen 1.5 (onboarding only): master password for the new vault // ------------------------------------------------------------------------- const masterOk = createMemo( () => masterPassword().length >= 8 && masterPassword() === masterConfirm() ); function handleContinueFromMaster() { setMasterError(null); if (masterPassword().length < 8) { setMasterError("Master password must be at least 8 characters."); return; } if (masterPassword() !== masterConfirm()) { setMasterError("Passwords don't match."); return; } setScreen("passphrase"); } // ------------------------------------------------------------------------- // Screen 2: passphrase + decrypt // ------------------------------------------------------------------------- async function handleRestore() { setPassphraseError(null); const fields = verifiedFields(); if (!fields) { setPassphraseError(IMPORT_ERROR_COPY.INVALID_HEADER); setScreen("paste"); return; } if (!passphrase()) { setPassphraseError("Enter your export passphrase."); return; } setScreen("restoring"); try { setProgress("deriving"); // Let the UI paint once before we block the main thread on Argon2. await new Promise((r) => setTimeout(r, 0)); const payload = await decryptAndDeserialise(fields, passphrase()); setProgress("decrypting"); await new Promise((r) => setTimeout(r, 0)); if (payload.entries.length !== fields.entryCount) { throw new Error("ENTRY_COUNT_MISMATCH"); } setRestoredPayload(payload); setProgress("importing"); if (props.mode === "onboarding") { // Create the vault under the master password the user set on screen 1.5, // then restore everything in full fidelity. const create = await sendMessage({ type: "CREATE_VAULT", payload: { password: masterPassword() }, }); if (!(create as { ok: boolean }).ok) { throw new Error("Could not create vault — please try again."); } await restorePayload(payload, "replace"); await finishSuccess(payload.entries.length); return; } // Existing vault: if it has entries, show the merge choice; otherwise // just restore straight away (treat as replace since nothing to merge). if (existingEntryCount() > 0) { setProgress("idle"); setScreen("merge"); } else { await restorePayload(payload, "replace"); await finishSuccess(payload.entries.length); } } catch (err) { setProgress("idle"); const message = messageForError(err); // If the error was about the phrase itself (not the passphrase), send // the user back to the paste screen with context. const raw = err instanceof Error ? err.message : String(err); const code = raw.split(":")[0].trim(); if ( code === "DECRYPT_FAILED" ) { setPassphraseError(message); setScreen("passphrase"); } else { setPasteError(message); setScreen("paste"); } } } // ------------------------------------------------------------------------- // Screen 4 (existing mode): merge / replace / cancel // ------------------------------------------------------------------------- async function handleReplace() { const payload = restoredPayload(); if (!payload) return; setScreen("restoring"); setProgress("importing"); await restorePayload(payload, "replace"); await finishSuccess(payload.entries.length); } async function handleMerge() { const payload = restoredPayload(); if (!payload) return; setScreen("restoring"); setProgress("importing"); await restorePayload(payload, "merge"); await finishSuccess(payload.entries.length); } function handleCancel() { setRestoredPayload(null); setPassphrase(""); props.onBack(); } // ------------------------------------------------------------------------- // Pipeline helpers // ------------------------------------------------------------------------- async function restorePayload( payload: VaultBackupPayload, mode: "replace" | "merge" ): Promise { const res = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, settings: payload.settings, mode }, }); const summary = (res as { ok: boolean; data?: { added: number; updated: number; skipped: number } }).data; const total = (summary?.added ?? 0) + (summary?.updated ?? 0); setImportedCount(total || payload.entries.length); // Apply license last — Pro gate must be active before any Pro features initialize. // Silent on failure: a bad key must never block a successful vault restore. if (payload.license?.key) { await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {}); } } async function finishSuccess(count: number) { setImportedCount(count); setRestoredPayload(null); setPassphrase(""); setMasterPassword(""); setMasterConfirm(""); setProgress("idle"); setScreen("success"); } async function handleOpenVault() { await props.onDone(); } // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- return (
import
} >
void handlePasteFromClipboard()} onFileSelect={handleFileSelect} onContinue={() => void handleContinueFromPaste()} /> void handleRestore()} /> void handleReplace()} onMerge={() => void handleMerge()} onCancel={handleCancel} /> void handleOpenVault()} />
); } // --------------------------------------------------------------------------- // Screen 1: paste // --------------------------------------------------------------------------- interface PasteScreenProps { phrase: string; onPhraseInput: (value: string) => void; structureOk: boolean; summary: { entryCount: number } | null; error: string | null; onPasteClipboard: () => void; onFileSelect: (e: Event) => void; onContinue: () => void; } function PasteScreen(props: PasteScreenProps) { return (

restore from backup phrase

Paste your Peach backup phrase below, then enter the passphrase you used when you exported it.