CJKImport.tsx

26.5 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);391392    // Apply license last — Pro gate must be active before any Pro features initialize.393    // Silent on failure: a bad key must never block a successful vault restore.394    if (payload.license?.key) {395      await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});396    }397  }398399  async function finishSuccess(count: number) {400    setImportedCount(count);401    setRestoredPayload(null);402    setPassphrase("");403    setMasterPassword("");404    setMasterConfirm("");405    setProgress("idle");406    setScreen("success");407  }408409  async function handleOpenVault() {410    await props.onDone();411  }412413  // -------------------------------------------------------------------------414  // Render415  // -------------------------------------------------------------------------416417  return (418    <div class={props.mode === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>419      <Show420        when={props.mode === "onboarding"}421        fallback={422          <div class="peach-overlay-header">423            <button424              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"425              onClick={props.onBack}426              aria-label="Back"427              disabled={screen() === "restoring"}428            >429              <svg430                width="7"431                height="12"432                viewBox="0 0 7 12"433                fill="none"434                stroke="currentColor"435                stroke-width="1.1"436                stroke-linecap="round"437                stroke-linejoin="round"438              >439                <path d="M6 1L1 6L6 11" />440              </svg>441            </button>442            <span class="peach-overlay-title">import</span>443            <div class="peach-overlay-spacer"></div>444          </div>445        }446      >447        <div class="peach-lock-view-header">448          <PeachWordmark class="peach-wordmark-wrap" />449          <button450            class="peach-lock-restore-link"451            onClick={props.onBack}452            disabled={screen() === "restoring"}453          >454            ‹ back455          </button>456        </div>457      </Show>458459      <div class="peach-settings-body">460        <Show when={screen() === "paste"}>461          <PasteScreen462            phrase={phrase()}463            onPhraseInput={handlePhraseInput}464            structureOk={Boolean(structureOk())}465            summary={pasteSummary()}466            error={pasteError()}467            onPasteClipboard={() => void handlePasteFromClipboard()}468            onFileSelect={handleFileSelect}469            onContinue={() => void handleContinueFromPaste()}470          />471        </Show>472473        <Show when={screen() === "onboarding-master"}>474          <OnboardingMasterScreen475            password={masterPassword()}476            confirm={masterConfirm()}477            onPasswordInput={setMasterPassword}478            onConfirmInput={setMasterConfirm}479            error={masterError()}480            canContinue={masterOk()}481            onContinue={handleContinueFromMaster}482          />483        </Show>484485        <Show when={screen() === "passphrase"}>486          <PassphraseScreen487            passphrase={passphrase()}488            onPassphraseInput={setPassphrase}489            error={passphraseError()}490            entryCount={verifiedFields()?.entryCount ?? announcedEntryCount()}491            onRestore={() => void handleRestore()}492          />493        </Show>494495        <Show when={screen() === "restoring"}>496          <RestoringScreen stage={progress()} />497        </Show>498499        <Show when={screen() === "merge"}>500          <MergeScreen501            currentCount={existingEntryCount()}502            restoredCount={restoredPayload()?.entries.length ?? 0}503            onReplace={() => void handleReplace()}504            onMerge={() => void handleMerge()}505            onCancel={handleCancel}506          />507        </Show>508509        <Show when={screen() === "success"}>510          <SuccessScreen511            count={importedCount()}512            onOpen={() => void handleOpenVault()}513          />514        </Show>515      </div>516    </div>517  );518}519520// ---------------------------------------------------------------------------521// Screen 1: paste522// ---------------------------------------------------------------------------523524interface PasteScreenProps {525  phrase: string;526  onPhraseInput: (value: string) => void;527  structureOk: boolean;528  summary: { entryCount: number } | null;529  error: string | null;530  onPasteClipboard: () => void;531  onFileSelect: (e: Event) => void;532  onContinue: () => void;533}534535function PasteScreen(props: PasteScreenProps) {536  return (537    <div class="peach-settings-group">538      <p class="peach-settings-label">restore from backup phrase</p>539      <p class="peach-settings-copy peach-settings-copy--spacious">540        Paste your Peach backup phrase below, then enter the passphrase you used when you exported it.541      </p>542543      <textarea544        placeholder={545          'Paste your phrase here.\nIt begins with "peach:v2:entries=..." and consists of Chinese characters.'546        }547        class="peach-phrase-textarea"548        value={props.phrase}549        onInput={(e) => props.onPhraseInput((e.target as HTMLTextAreaElement).value)}550      />551552      <div class="peach-settings-actions" style="margin-top:10px">553        <button554          class="peach-btn peach-btn--ghost peach-btn--sm"555          onClick={props.onPasteClipboard}556        >557          Paste from clipboard558        </button>559        <label class="peach-btn peach-btn--ghost peach-btn--sm">560          Load from file561          <input562            type="file"563            accept=".txt"564            class="peach-hidden-file-input"565            onChange={props.onFileSelect}566          />567        </label>568      </div>569570      <Show when={props.phrase.trim().length > 0 && props.structureOk && props.summary}>571        <div class="peach-inline-feedback peach-inline-feedback--ok">572          ✓ Phrase recognised — {props.summary!.entryCount} entr{props.summary!.entryCount === 1 ? "y" : "ies"}573        </div>574      </Show>575576      <Show when={props.error}>577        <div class="peach-inline-feedback peach-inline-feedback--error">578          {props.error}579        </div>580      </Show>581      <Show when={props.phrase.trim().length > 0 && !props.structureOk && !props.error}>582        <div class="peach-inline-feedback peach-inline-feedback--error">583          {IMPORT_ERROR_COPY.INVALID_HEADER}584        </div>585      </Show>586587      <div class="peach-settings-actions--row peach-settings-actions--end">588        <button589          class="peach-btn peach-btn--subtle peach-btn--sm"590          onClick={props.onContinue}591          disabled={!props.structureOk}592        >593          Continue →594        </button>595      </div>596    </div>597  );598}599600// ---------------------------------------------------------------------------601// Screen 1.5 (onboarding): set new master password602// ---------------------------------------------------------------------------603604interface OnboardingMasterScreenProps {605  password: string;606  confirm: string;607  onPasswordInput: (value: string) => void;608  onConfirmInput: (value: string) => void;609  error: string | null;610  canContinue: boolean;611  onContinue: () => void;612}613614function OnboardingMasterScreen(props: OnboardingMasterScreenProps) {615  return (616    <div class="peach-settings-group">617      <p class="peach-settings-label">set a master password</p>618      <p class="peach-settings-copy peach-settings-copy--spacious">619        This locks your local vault. It's separate from the export passphrase620        on your backup phrase.621      </p>622623      <input624        class="peach-settings-input"625        type="password"626        placeholder="master password"627        value={props.password}628        onInput={(e) => props.onPasswordInput((e.target as HTMLInputElement).value)}629      />630      <input631        class="peach-settings-input"632        type="password"633        placeholder="confirm"634        value={props.confirm}635        onInput={(e) => props.onConfirmInput((e.target as HTMLInputElement).value)}636      />637638      <Show when={props.error}>639        <div class="peach-inline-feedback peach-inline-feedback--error">640          {props.error}641        </div>642      </Show>643644      <div class="peach-settings-actions--row peach-settings-actions--end">645        <button646          class="peach-btn peach-btn--subtle peach-btn--sm"647          onClick={props.onContinue}648          disabled={!props.canContinue}649        >650          Continue →651        </button>652      </div>653    </div>654  );655}656657// ---------------------------------------------------------------------------658// Screen 2: export passphrase659// ---------------------------------------------------------------------------660661interface PassphraseScreenProps {662  passphrase: string;663  onPassphraseInput: (value: string) => void;664  error: string | null;665  entryCount: number;666  onRestore: () => void;667}668669function PassphraseScreen(props: PassphraseScreenProps) {670  let inputRef: HTMLInputElement | undefined;671  onMount(() => { inputRef?.focus(); });672  return (673    <div class="peach-settings-group">674      <p class="peach-settings-label">enter your export passphrase</p>675      <p class="peach-settings-copy peach-settings-copy--spacious">676        This is the passphrase you chose when you exported, not your vault password.677      </p>678679      <input680        ref={inputRef}681        class="peach-settings-input"682        type="password"683        placeholder="export passphrase"684        value={props.passphrase}685        onInput={(e) => props.onPassphraseInput((e.target as HTMLInputElement).value)}686        onKeyDown={(e) => {687          if (e.key === "Enter") props.onRestore();688        }}689      />690691      <div class="peach-inline-feedback peach-inline-feedback--muted peach-inline-feedback--spaced">692        {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} will be restored.693      </div>694695      <Show when={props.error}>696        <div class="peach-inline-feedback peach-inline-feedback--error">697          {props.error}698        </div>699      </Show>700701      <div class="peach-settings-actions--row peach-settings-actions--end">702        <button703          class="peach-btn peach-btn--subtle peach-btn--sm"704          onClick={props.onRestore}705          disabled={props.passphrase.length === 0}706        >707          Restore →708        </button>709      </div>710    </div>711  );712}713714// ---------------------------------------------------------------------------715// Screen 3: restoring716// ---------------------------------------------------------------------------717718interface RestoringScreenProps {719  stage: ProgressStage;720}721722function RestoringScreen(props: RestoringScreenProps) {723  return (724    <div class="peach-settings-group peach-progress-screen">725      <div class="peach-progress-screen-title">Restoring your vault…</div>726      <div class="peach-spinner" aria-label="Working" />727      <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>728    </div>729  );730}731732// ---------------------------------------------------------------------------733// Screen 4: merge choice (existing vaults only)734// ---------------------------------------------------------------------------735736interface MergeScreenProps {737  currentCount: number;738  restoredCount: number;739  onReplace: () => void;740  onMerge: () => void;741  onCancel: () => void;742}743744function MergeScreen(props: MergeScreenProps) {745  return (746    <div class="peach-settings-group">747      <p class="peach-settings-label">you already have a vault</p>748      <p class="peach-settings-copy" style="margin:0 0 16px">749        Your current vault has <strong>{props.currentCount}</strong> entr750        {props.currentCount === 1 ? "y" : "ies"}. The backup has{" "}751        <strong>{props.restoredCount}</strong> entr752        {props.restoredCount === 1 ? "y" : "ies"}.753      </p>754755      <div class="peach-settings-stack peach-settings-stack--spacious">756        <button757          class="peach-btn peach-btn--subtle peach-btn--sm"758          onClick={props.onMerge}759        >760          Merge — combine both, keep newer on conflicts761        </button>762        <button763          class="peach-btn peach-btn--subtle peach-btn--sm"764          style="border-color:rgba(220,80,60,0.4)"765          onClick={props.onReplace}766        >767          Replace my vault — discards current {props.currentCount}768        </button>769        <button770          class="peach-btn peach-btn--ghost peach-btn--sm"771          onClick={props.onCancel}772        >773          Cancel — nothing changed774        </button>775      </div>776    </div>777  );778}779780// ---------------------------------------------------------------------------781// Screen 5: success782// ---------------------------------------------------------------------------783784interface SuccessScreenProps {785  count: number;786  onOpen: () => void;787}788789function SuccessScreen(props: SuccessScreenProps) {790  return (791    <div class="peach-settings-group peach-progress-screen">792      <div class="peach-progress-screen-title">Vault restored</div>793      <div class="peach-settings-copy">{props.count} entr{props.count === 1 ? "y" : "ies"} imported successfully.</div>794      <button795        class="peach-btn peach-btn--subtle peach-btn--sm"796        onClick={props.onOpen}797      >798        Open vault →799      </button>800    </div>801  );802}