CJKImport.tsx

26.2 KB · raw

1/**2 * CJK phrase import flow (spec §8).3 *4 * Screens, in order:5 *6 *   1. Paste — phrase pasted here. Structural validation runs live (§8.3)7 *      so the user sees "✓ Phrase recognised" or a precise error without8 *      clicking anything.9 *   2. Passphrase — only reached after the paste validates AND the10 *      BLAKE3 checksum verifies. Corruption is surfaced before asking11 *      the user to type their passphrase (§4.2 Step 4).12 *   3. Restoring — progress labels as the pipeline runs.13 *   4. Merge choice — only shown when the caller reports existing14 *      entries in the vault. Skipped entirely in onboarding mode.15 *   5. Success — entry count plus "Open vault" button.16 *17 * The component supports two modes:18 *19 *   - mode="existing": vault already unlocked. Entries can be merged or20 *     the vault can be replaced wholesale. This is the Settings → Import21 *     entry point.22 *   - mode="onboarding": no vault yet. Before restoring we ask the user23 *     to set a fresh master password, which is used to CREATE_VAULT and24 *     unlock. Entry point from the Lock screen "Restore from CJK phrase"25 *     card.26 */2728import { createMemo, createSignal, onMount, Show } from "solid-js";2930import {31  decodePhraseToBytes,32  decryptAndDeserialise,33  parseHeaderLine,34  splitPhrase,35  validatePhraseStructure,36  verifyIntegrity,37} from "../../crypto/cjkImport";38import type { VaultBackupPayload } from "../../crypto/cjkSerialise";39import type { ExtractedFields } from "../../crypto/cjkPayload";40import type { VaultEntry } from "../../shared/types";4142import { sendMessage } from "../App";43import { PeachWordmark } from "../components/PeachMark";4445// ---------------------------------------------------------------------------46// Error code → user-facing copy (spec §9)47// ---------------------------------------------------------------------------4849const IMPORT_ERROR_COPY: Record<string, string> = {50  INVALID_HEADER:51    "This doesn't look like a Peach backup phrase. Check that you copied the whole phrase including the first line.",52  INVALID_HEADER_FORMAT:53    "This doesn't look like a Peach backup phrase. Check that you copied the whole phrase including the first line.",54  INVALID_CHARACTER:55    "The phrase contains characters that aren't part of a Peach backup. It may have been modified or partially corrupted.",56  CHECKSUM_FAILED:57    "The phrase appears to be incomplete or corrupted. A few missing or changed characters will cause this. Try copying it again exactly as exported.",58  DECRYPT_FAILED:59    "Wrong passphrase. This is the passphrase you used when you exported, not your vault password. Try again.",60  UNSUPPORTED_VERSION:61    "This phrase was created by a newer version of Peach. Update Peach and try again.",62  UNSUPPORTED_FLAGS:63    "This phrase uses an encoding format not supported by this version of Peach.",64  PAYLOAD_TOO_SHORT:65    "The phrase is too short to be a complete backup. It may have been truncated.",66  ENTRY_COUNT_MISMATCH:67    "The phrase appears to be incomplete — the number of entries found doesn't match the header.",68  DESERIALISE_OVERFLOW:69    "The phrase contains more data than expected. It may have been modified.",70  DESERIALISE_UNDERFLOW:71    "The phrase is incomplete — not all entries could be read.",72  HEADER_COUNT_MISMATCH:73    "The phrase header is inconsistent. It may have been modified.",74  PHRASE_TOO_SHORT:75    "The phrase is too short to be a complete backup. It may have been truncated.",76  NO_CJK_CONTENT:77    "The phrase is missing its body. Copy the whole phrase, including all lines of Chinese characters.",78};7980function messageForError(err: unknown): string {81  const raw = err instanceof Error ? err.message : String(err);82  // Error messages may include a suffix like "UNSUPPORTED_VERSION: 3". Match83  // on the leading code.84  const code = raw.split(":")[0].trim();85  return IMPORT_ERROR_COPY[code] ?? `Import failed: ${raw}`;86}8788// ---------------------------------------------------------------------------89// Component90// ---------------------------------------------------------------------------9192type Screen =93  | "paste"94  | "passphrase"95  | "onboarding-master"96  | "restoring"97  | "merge"98  | "success";99100type ProgressStage =101  | "idle"102  | "decoding"103  | "verifying"104  | "deriving"105  | "decrypting"106  | "importing";107108const PROGRESS_COPY: Record<ProgressStage, string> = {109  idle: "",110  decoding: "Decoding phrase…",111  verifying: "Verifying integrity…",112  deriving: "Deriving key…",113  decrypting: "Decrypting…",114  importing: "Importing entries…",115};116117export type CJKImportMode = "existing" | "onboarding";118119interface CJKImportProps {120  mode: CJKImportMode;121  onBack: () => void;122  /** Called on successful import. For onboarding, the vault has been123   * created and unlocked before this fires. */124  onDone: () => void | Promise<void>;125}126127export function CJKImportView(props: CJKImportProps) {128  const [screen, setScreen] = createSignal<Screen>("paste");129  const [existingEntryCount, setExistingEntryCount] = createSignal(0);130131  // In "existing" mode, fetch the current vault size so we know whether to132  // show the merge screen. Onboarding mode has no existing vault.133  onMount(async () => {134    if (props.mode !== "existing") return;135    const response = await sendMessage({ type: "GET_ENTRIES" });136    const entries =137      (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];138    setExistingEntryCount(entries.filter((e) => !e.deletedAt).length);139  });140141  const [phrase, setPhrase] = createSignal("");142  const [passphrase, setPassphrase] = createSignal("");143  const [masterPassword, setMasterPassword] = createSignal("");144  const [masterConfirm, setMasterConfirm] = createSignal("");145146  // Populated once the phrase decodes + checksum verifies.147  const [verifiedFields, setVerifiedFields] = createSignal<ExtractedFields | null>(null);148  const [announcedEntryCount, setAnnouncedEntryCount] = createSignal(0);149150  // Populated after decryption — held in memory briefly while the merge151  // screen is on-screen.152  const [restoredPayload, setRestoredPayload] = createSignal<VaultBackupPayload | null>(null);153154  const [progress, setProgress] = createSignal<ProgressStage>("idle");155  const [pasteError, setPasteError] = createSignal<string | null>(null);156  const [passphraseError, setPassphraseError] = createSignal<string | null>(null);157  const [masterError, setMasterError] = createSignal<string | null>(null);158  const [importedCount, setImportedCount] = createSignal(0);159160  // -------------------------------------------------------------------------161  // Screen 1: paste + structural validation162  // -------------------------------------------------------------------------163164  const structureOk = createMemo(() =>165    phrase().trim().length > 0 ? validatePhraseStructure(phrase()) : null166  );167168  const pasteSummary = createMemo(() => {169    if (!structureOk()) return null;170    try {171      const { headerLine } = splitPhrase(phrase());172      const header = parseHeaderLine(headerLine);173      return { entryCount: header.entryCount };174    } catch {175      return null;176    }177  });178179  async function handlePhraseInput(value: string) {180    setPhrase(value);181    setPasteError(null);182  }183184  async function handlePasteFromClipboard() {185    try {186      const text = await navigator.clipboard.readText();187      setPhrase(text);188      setPasteError(null);189    } catch {190      setPasteError(191        "Couldn't read the clipboard. Paste into the box above instead."192      );193    }194  }195196  function handleFileSelect(e: Event) {197    const file = (e.target as HTMLInputElement).files?.[0];198    if (!file) return;199    const reader = new FileReader();200    reader.onload = (ev) => {201      const text = ev.target?.result as string;202      setPhrase(text);203      setPasteError(null);204    };205    reader.readAsText(file);206  }207208  /**209   * Verify integrity before moving to the passphrase screen. A corrupt210   * phrase is surfaced here — the user never sees the passphrase field211   * until we know the bytes are whole.212   */213  async function handleContinueFromPaste() {214    setPasteError(null);215    if (!structureOk()) {216      setPasteError(IMPORT_ERROR_COPY.INVALID_HEADER);217      return;218    }219220    try {221      setProgress("decoding");222      const { headerLine, cjkContent } = splitPhrase(phrase());223      const header = parseHeaderLine(headerLine);224      setAnnouncedEntryCount(header.entryCount);225226      const binary = decodePhraseToBytes(cjkContent);227228      setProgress("verifying");229      const fields = await verifyIntegrity(binary);230231      if (header.entryCount !== fields.entryCount) {232        throw new Error("HEADER_COUNT_MISMATCH");233      }234235      setVerifiedFields(fields);236      setProgress("idle");237      // Onboarding mode asks for a new master password before the export238      // passphrase. Existing mode jumps straight to the export passphrase.239      setScreen(240        props.mode === "onboarding" ? "onboarding-master" : "passphrase"241      );242    } catch (err) {243      setProgress("idle");244      setPasteError(messageForError(err));245    }246  }247248  // -------------------------------------------------------------------------249  // Screen 1.5 (onboarding only): master password for the new vault250  // -------------------------------------------------------------------------251252  const masterOk = createMemo(253    () =>254      masterPassword().length >= 8 && masterPassword() === masterConfirm()255  );256257  function handleContinueFromMaster() {258    setMasterError(null);259    if (masterPassword().length < 8) {260      setMasterError("Master password must be at least 8 characters.");261      return;262    }263    if (masterPassword() !== masterConfirm()) {264      setMasterError("Passwords don't match.");265      return;266    }267    setScreen("passphrase");268  }269270  // -------------------------------------------------------------------------271  // Screen 2: passphrase + decrypt272  // -------------------------------------------------------------------------273274  async function handleRestore() {275    setPassphraseError(null);276    const fields = verifiedFields();277    if (!fields) {278      setPassphraseError(IMPORT_ERROR_COPY.INVALID_HEADER);279      setScreen("paste");280      return;281    }282    if (!passphrase()) {283      setPassphraseError("Enter your export passphrase.");284      return;285    }286287    setScreen("restoring");288289    try {290      setProgress("deriving");291      // Let the UI paint once before we block the main thread on Argon2.292      await new Promise((r) => setTimeout(r, 0));293294      const payload = await decryptAndDeserialise(fields, passphrase());295      setProgress("decrypting");296      await new Promise((r) => setTimeout(r, 0));297298      if (payload.entries.length !== fields.entryCount) {299        throw new Error("ENTRY_COUNT_MISMATCH");300      }301302      setRestoredPayload(payload);303      setProgress("importing");304305      if (props.mode === "onboarding") {306        // Create the vault under the master password the user set on screen 1.5,307        // then restore everything in full fidelity.308        const create = await sendMessage({309          type: "CREATE_VAULT",310          payload: { password: masterPassword() },311        });312        if (!(create as { ok: boolean }).ok) {313          throw new Error("Could not create vault — please try again.");314        }315        await restorePayload(payload, "replace");316        await finishSuccess(payload.entries.length);317        return;318      }319320      // Existing vault: if it has entries, show the merge choice; otherwise321      // just restore straight away (treat as replace since nothing to merge).322      if (existingEntryCount() > 0) {323        setProgress("idle");324        setScreen("merge");325      } else {326        await restorePayload(payload, "replace");327        await finishSuccess(payload.entries.length);328      }329    } catch (err) {330      setProgress("idle");331      const message = messageForError(err);332      // If the error was about the phrase itself (not the passphrase), send333      // the user back to the paste screen with context.334      const raw = err instanceof Error ? err.message : String(err);335      const code = raw.split(":")[0].trim();336      if (337        code === "DECRYPT_FAILED"338      ) {339        setPassphraseError(message);340        setScreen("passphrase");341      } else {342        setPasteError(message);343        setScreen("paste");344      }345    }346  }347348  // -------------------------------------------------------------------------349  // Screen 4 (existing mode): merge / replace / cancel350  // -------------------------------------------------------------------------351352  async function handleReplace() {353    const payload = restoredPayload();354    if (!payload) return;355    setScreen("restoring");356    setProgress("importing");357    await restorePayload(payload, "replace");358    await finishSuccess(payload.entries.length);359  }360361  async function handleMerge() {362    const payload = restoredPayload();363    if (!payload) return;364    setScreen("restoring");365    setProgress("importing");366    await restorePayload(payload, "merge");367    await finishSuccess(payload.entries.length);368  }369370  function handleCancel() {371    setRestoredPayload(null);372    setPassphrase("");373    props.onBack();374  }375376  // -------------------------------------------------------------------------377  // Pipeline helpers378  // -------------------------------------------------------------------------379380  async function restorePayload(381    payload: VaultBackupPayload,382    mode: "replace" | "merge"383  ): Promise<void> {384    const res = await sendMessage({385      type: "RESTORE_ENTRIES",386      payload: { entries: payload.entries, settings: payload.settings, mode },387    });388    const summary = (res as { ok: boolean; data?: { added: number; updated: number; skipped: number } }).data;389    const total = (summary?.added ?? 0) + (summary?.updated ?? 0);390    setImportedCount(total || payload.entries.length);391  }392393  async function finishSuccess(count: number) {394    setImportedCount(count);395    setRestoredPayload(null);396    setPassphrase("");397    setMasterPassword("");398    setMasterConfirm("");399    setProgress("idle");400    setScreen("success");401  }402403  async function handleOpenVault() {404    await props.onDone();405  }406407  // -------------------------------------------------------------------------408  // Render409  // -------------------------------------------------------------------------410411  return (412    <div class={props.mode === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>413      <Show414        when={props.mode === "onboarding"}415        fallback={416          <div class="peach-overlay-header">417            <button418              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"419              onClick={props.onBack}420              aria-label="Back"421              disabled={screen() === "restoring"}422            >423              <svg424                width="7"425                height="12"426                viewBox="0 0 7 12"427                fill="none"428                stroke="currentColor"429                stroke-width="1.1"430                stroke-linecap="round"431                stroke-linejoin="round"432              >433                <path d="M6 1L1 6L6 11" />434              </svg>435            </button>436            <span class="peach-overlay-title">import</span>437            <div class="peach-overlay-spacer"></div>438          </div>439        }440      >441        <div class="peach-lock-view-header">442          <PeachWordmark class="peach-wordmark-wrap" />443          <button444            class="peach-lock-restore-link"445            onClick={props.onBack}446            disabled={screen() === "restoring"}447          >448            ‹ back449          </button>450        </div>451      </Show>452453      <div class="peach-settings-body">454        <Show when={screen() === "paste"}>455          <PasteScreen456            phrase={phrase()}457            onPhraseInput={handlePhraseInput}458            structureOk={Boolean(structureOk())}459            summary={pasteSummary()}460            error={pasteError()}461            onPasteClipboard={() => void handlePasteFromClipboard()}462            onFileSelect={handleFileSelect}463            onContinue={() => void handleContinueFromPaste()}464          />465        </Show>466467        <Show when={screen() === "onboarding-master"}>468          <OnboardingMasterScreen469            password={masterPassword()}470            confirm={masterConfirm()}471            onPasswordInput={setMasterPassword}472            onConfirmInput={setMasterConfirm}473            error={masterError()}474            canContinue={masterOk()}475            onContinue={handleContinueFromMaster}476          />477        </Show>478479        <Show when={screen() === "passphrase"}>480          <PassphraseScreen481            passphrase={passphrase()}482            onPassphraseInput={setPassphrase}483            error={passphraseError()}484            entryCount={verifiedFields()?.entryCount ?? announcedEntryCount()}485            onRestore={() => void handleRestore()}486          />487        </Show>488489        <Show when={screen() === "restoring"}>490          <RestoringScreen stage={progress()} />491        </Show>492493        <Show when={screen() === "merge"}>494          <MergeScreen495            currentCount={existingEntryCount()}496            restoredCount={restoredPayload()?.entries.length ?? 0}497            onReplace={() => void handleReplace()}498            onMerge={() => void handleMerge()}499            onCancel={handleCancel}500          />501        </Show>502503        <Show when={screen() === "success"}>504          <SuccessScreen505            count={importedCount()}506            onOpen={() => void handleOpenVault()}507          />508        </Show>509      </div>510    </div>511  );512}513514// ---------------------------------------------------------------------------515// Screen 1: paste516// ---------------------------------------------------------------------------517518interface PasteScreenProps {519  phrase: string;520  onPhraseInput: (value: string) => void;521  structureOk: boolean;522  summary: { entryCount: number } | null;523  error: string | null;524  onPasteClipboard: () => void;525  onFileSelect: (e: Event) => void;526  onContinue: () => void;527}528529function PasteScreen(props: PasteScreenProps) {530  return (531    <div class="peach-settings-group">532      <p class="peach-settings-label">restore from backup phrase</p>533      <p class="peach-settings-copy peach-settings-copy--spacious">534        Paste your Peach backup phrase below, then enter the passphrase you used when you exported it.535      </p>536537      <textarea538        placeholder={539          'Paste your phrase here.\nIt begins with "peach:v2:entries=..." and consists of Chinese characters.'540        }541        class="peach-phrase-textarea"542        value={props.phrase}543        onInput={(e) => props.onPhraseInput((e.target as HTMLTextAreaElement).value)}544      />545546      <div class="peach-settings-actions" style="margin-top:10px">547        <button548          class="peach-btn peach-btn--ghost peach-btn--sm"549          onClick={props.onPasteClipboard}550        >551          Paste from clipboard552        </button>553        <label class="peach-btn peach-btn--ghost peach-btn--sm">554          Load from file555          <input556            type="file"557            accept=".txt"558            class="peach-hidden-file-input"559            onChange={props.onFileSelect}560          />561        </label>562      </div>563564      <Show when={props.phrase.trim().length > 0 && props.structureOk && props.summary}>565        <div class="peach-inline-feedback peach-inline-feedback--ok">566          ✓ Phrase recognised — {props.summary!.entryCount} entr{props.summary!.entryCount === 1 ? "y" : "ies"}567        </div>568      </Show>569570      <Show when={props.error}>571        <div class="peach-inline-feedback peach-inline-feedback--error">572          {props.error}573        </div>574      </Show>575      <Show when={props.phrase.trim().length > 0 && !props.structureOk && !props.error}>576        <div class="peach-inline-feedback peach-inline-feedback--error">577          {IMPORT_ERROR_COPY.INVALID_HEADER}578        </div>579      </Show>580581      <div class="peach-settings-actions--row peach-settings-actions--end">582        <button583          class="peach-btn peach-btn--subtle peach-btn--sm"584          onClick={props.onContinue}585          disabled={!props.structureOk}586        >587          Continue →588        </button>589      </div>590    </div>591  );592}593594// ---------------------------------------------------------------------------595// Screen 1.5 (onboarding): set new master password596// ---------------------------------------------------------------------------597598interface OnboardingMasterScreenProps {599  password: string;600  confirm: string;601  onPasswordInput: (value: string) => void;602  onConfirmInput: (value: string) => void;603  error: string | null;604  canContinue: boolean;605  onContinue: () => void;606}607608function OnboardingMasterScreen(props: OnboardingMasterScreenProps) {609  return (610    <div class="peach-settings-group">611      <p class="peach-settings-label">set a master password</p>612      <p class="peach-settings-copy peach-settings-copy--spacious">613        This locks your local vault. It's separate from the export passphrase614        on your backup phrase.615      </p>616617      <input618        class="peach-settings-input"619        type="password"620        placeholder="master password"621        value={props.password}622        onInput={(e) => props.onPasswordInput((e.target as HTMLInputElement).value)}623      />624      <input625        class="peach-settings-input"626        type="password"627        placeholder="confirm"628        value={props.confirm}629        onInput={(e) => props.onConfirmInput((e.target as HTMLInputElement).value)}630      />631632      <Show when={props.error}>633        <div class="peach-inline-feedback peach-inline-feedback--error">634          {props.error}635        </div>636      </Show>637638      <div class="peach-settings-actions--row peach-settings-actions--end">639        <button640          class="peach-btn peach-btn--subtle peach-btn--sm"641          onClick={props.onContinue}642          disabled={!props.canContinue}643        >644          Continue →645        </button>646      </div>647    </div>648  );649}650651// ---------------------------------------------------------------------------652// Screen 2: export passphrase653// ---------------------------------------------------------------------------654655interface PassphraseScreenProps {656  passphrase: string;657  onPassphraseInput: (value: string) => void;658  error: string | null;659  entryCount: number;660  onRestore: () => void;661}662663function PassphraseScreen(props: PassphraseScreenProps) {664  let inputRef: HTMLInputElement | undefined;665  onMount(() => { inputRef?.focus(); });666  return (667    <div class="peach-settings-group">668      <p class="peach-settings-label">enter your export passphrase</p>669      <p class="peach-settings-copy peach-settings-copy--spacious">670        This is the passphrase you chose when you exported, not your vault password.671      </p>672673      <input674        ref={inputRef}675        class="peach-settings-input"676        type="password"677        placeholder="export passphrase"678        value={props.passphrase}679        onInput={(e) => props.onPassphraseInput((e.target as HTMLInputElement).value)}680        onKeyDown={(e) => {681          if (e.key === "Enter") props.onRestore();682        }}683      />684685      <div class="peach-inline-feedback peach-inline-feedback--muted peach-inline-feedback--spaced">686        {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} will be restored.687      </div>688689      <Show when={props.error}>690        <div class="peach-inline-feedback peach-inline-feedback--error">691          {props.error}692        </div>693      </Show>694695      <div class="peach-settings-actions--row peach-settings-actions--end">696        <button697          class="peach-btn peach-btn--subtle peach-btn--sm"698          onClick={props.onRestore}699          disabled={props.passphrase.length === 0}700        >701          Restore →702        </button>703      </div>704    </div>705  );706}707708// ---------------------------------------------------------------------------709// Screen 3: restoring710// ---------------------------------------------------------------------------711712interface RestoringScreenProps {713  stage: ProgressStage;714}715716function RestoringScreen(props: RestoringScreenProps) {717  return (718    <div class="peach-settings-group peach-progress-screen">719      <div class="peach-progress-screen-title">Restoring your vault…</div>720      <div class="peach-spinner" aria-label="Working" />721      <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>722    </div>723  );724}725726// ---------------------------------------------------------------------------727// Screen 4: merge choice (existing vaults only)728// ---------------------------------------------------------------------------729730interface MergeScreenProps {731  currentCount: number;732  restoredCount: number;733  onReplace: () => void;734  onMerge: () => void;735  onCancel: () => void;736}737738function MergeScreen(props: MergeScreenProps) {739  return (740    <div class="peach-settings-group">741      <p class="peach-settings-label">you already have a vault</p>742      <p class="peach-settings-copy" style="margin:0 0 16px">743        Your current vault has <strong>{props.currentCount}</strong> entr744        {props.currentCount === 1 ? "y" : "ies"}. The backup has{" "}745        <strong>{props.restoredCount}</strong> entr746        {props.restoredCount === 1 ? "y" : "ies"}.747      </p>748749      <div class="peach-settings-stack peach-settings-stack--spacious">750        <button751          class="peach-btn peach-btn--subtle peach-btn--sm"752          onClick={props.onMerge}753        >754          Merge — combine both, keep newer on conflicts755        </button>756        <button757          class="peach-btn peach-btn--subtle peach-btn--sm"758          style="border-color:rgba(220,80,60,0.4)"759          onClick={props.onReplace}760        >761          Replace my vault — discards current {props.currentCount}762        </button>763        <button764          class="peach-btn peach-btn--ghost peach-btn--sm"765          onClick={props.onCancel}766        >767          Cancel — nothing changed768        </button>769      </div>770    </div>771  );772}773774// ---------------------------------------------------------------------------775// Screen 5: success776// ---------------------------------------------------------------------------777778interface SuccessScreenProps {779  count: number;780  onOpen: () => void;781}782783function SuccessScreen(props: SuccessScreenProps) {784  return (785    <div class="peach-settings-group peach-progress-screen">786      <div class="peach-progress-screen-title">Vault restored</div>787      <div class="peach-settings-copy">{props.count} entr{props.count === 1 ? "y" : "ies"} imported successfully.</div>788      <button789        class="peach-btn peach-btn--subtle peach-btn--sm"790        onClick={props.onOpen}791      >792        Open vault →793      </button>794    </div>795  );796}