9d435183c145

delete all CJK code: 9 crypto modules + tests, CJKImport view, all UI references in App/Lock/Import/Settings, CSS rules, and stale comments; rename peach-cjk-qr-card to peach-passkey-card; bump 2.7.0

time
2026-04-28 01:09 UTC · 6 months ago
machine
koh2
parent
8b496c05bd31
22 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.ts+3 −3src/crypto/cjk.test.ts+0 −108src/crypto/cjk.ts+0 −173src/crypto/cjkExport.ts+0 −234src/crypto/cjkImport.ts+0 −385src/crypto/cjkPayload.test.ts+0 −174src/crypto/cjkPayload.ts+0 −242src/crypto/cjkRoundTrip.test.ts+0 −343src/crypto/cjkSerialise.test.ts+0 −135src/crypto/cjkSerialise.ts+0 −104src/popup/App.tsx+1 −18src/popup/styles/popup.css+4 −112src/popup/views/CJKImport.tsx+0 −802src/popup/views/Import.tsx+7 −47src/popup/views/Lock.tsx+2 −24src/popup/views/PasskeyApproval.tsx+1 −1src/popup/views/Settings.tsx+0 −1src/popup/views/contracts.test.tsx+0 −4src/shared/types.ts+1 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.6.4",4+  "version": "2.7.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.6.4",4+  "version": "2.7.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.6.4",3+  "version": "2.7.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/vault.ts+3 −3
@@ -645,9 +645,9 @@645645 /**646646  * Restore a set of entries in full fidelity — preserves id, createdAt,647647  * modifiedAt, and every other field unlike `batchAddEntries` which648- * assigns fresh identity. Used by the CJK phrase restore path where the649- * backup is the authoritative source of truth. `mode` controls how650- * entries already in the vault are treated.648+ * assigns fresh identity. Used by restore paths where the backup is the649+ * authoritative source of truth. `mode` controls how entries already in650+ * the vault are treated.651651  */652652 export async function restoreEntries(653653   incoming: VaultEntry[],

view file

src/crypto/cjk.test.ts+0 −108
@@ -1,108 +0,0 @@1-/**2- * CJK encode/decode vectors (spec §10.1).3- */4-5-import { describe, expect, it } from "vitest";6-7-import {8-  ALPHABET_END,9-  ALPHABET_SIZE,10-  ALPHABET_START,11-  cjkDecode,12-  cjkEncode,13-  isValidPhraseCharset,14-  remainingBits,15-} from "./cjk";16-17-describe("cjkEncode", () => {18-  it("encodes 0x00 to U+4E00 (first character of the alphabet)", () => {19-    // 0x00 = 8 bits, padded left into a 14-bit slot → index 020-    expect(cjkEncode(new Uint8Array([0x00]))).toBe("\u4E00");21-  });22-23-  it("encodes 0xFF to U+8DC0 (verified programmatically)", () => {24-    // 0xFF = 8 bits = 11111111; left-padded to 14 = 11111111_000000 = 0x3FC025-    // 0x4E00 + 0x3FC0 = 0x8DC0. Spec's Vector 1 second line cites U+6A3F,26-    // which is incorrect — this test asserts the real value.27-    expect(cjkEncode(new Uint8Array([0xFF]))).toBe("\u8DC0");28-  });29-30-  it("encodes 0xAB 0xCD to [U+78F3, U+5E00]", () => {31-    // 0xAB=10101011, 0xCD=11001101 → 1010101111001101 (16 bits)32-    // First 14 bits → 0x2AF3; first char = 0x4E00 + 0x2AF3 = 0x78F333-    // Remaining 2 bits = 01, left-padded → 0x1000; second = 0x4E00 + 0x1000 = 0x5E0034-    expect(cjkEncode(new Uint8Array([0xAB, 0xCD]))).toBe("\u78F3\u5E00");35-  });36-37-  it("produces only characters within the 16384-entry alphabet", () => {38-    const bytes = new Uint8Array(256);39-    for (let i = 0; i < 256; i++) bytes[i] = i;40-    const encoded = cjkEncode(bytes);41-    for (const ch of encoded) {42-      const cp = ch.codePointAt(0)!;43-      expect(cp).toBeGreaterThanOrEqual(ALPHABET_START);44-      expect(cp).toBeLessThanOrEqual(ALPHABET_END);45-    }46-  });47-});48-49-describe("cjkEncode / cjkDecode round-trip", () => {50-  it("round-trips every single-byte value", () => {51-    for (let b = 0; b <= 255; b++) {52-      const original = new Uint8Array([b]);53-      const encoded = cjkEncode(original);54-      const decoded = cjkDecode(encoded, remainingBits(1));55-      expect(decoded).toEqual(original);56-    }57-  });58-59-  it("round-trips random buffers of length 1..200", () => {60-    for (let len = 1; len <= 200; len++) {61-      const original = new Uint8Array(len);62-      crypto.getRandomValues(original);63-      const encoded = cjkEncode(original);64-      const rb = remainingBits(len);65-      const decoded = cjkDecode(encoded, rb);66-      expect(decoded).toEqual(original);67-    }68-  });69-});70-71-describe("remainingBits", () => {72-  it("returns zero when bytes * 8 is a multiple of 14", () => {73-    // 7 bytes = 56 bits = 4 chars exactly74-    expect(remainingBits(7)).toBe(0);75-    // 14 bytes = 112 bits = 8 chars exactly76-    expect(remainingBits(14)).toBe(0);77-  });78-79-  it("returns the non-zero remainder otherwise", () => {80-    expect(remainingBits(1)).toBe(8);81-    expect(remainingBits(2)).toBe(2);82-    expect(remainingBits(3)).toBe(10);83-  });84-});85-86-describe("isValidPhraseCharset", () => {87-  it("accepts the alphabet bounds", () => {88-    expect(isValidPhraseCharset("\u4E00")).toBe(true);89-    expect(isValidPhraseCharset("\u8DFF")).toBe(true);90-  });91-92-  it("rejects characters just outside the alphabet", () => {93-    expect(isValidPhraseCharset("\u4DFF")).toBe(false); // one below start94-    expect(isValidPhraseCharset("\u8E00")).toBe(false); // one above end95-  });96-97-  it("rejects non-CJK characters", () => {98-    expect(isValidPhraseCharset("a")).toBe(false);99-    expect(isValidPhraseCharset("一二 三")).toBe(false); // contains space100-  });101-});102-103-describe("alphabet constants", () => {104-  it("has 16,384 entries so each character packs exactly 14 bits", () => {105-    expect(ALPHABET_SIZE).toBe(2 ** 14);106-    expect(ALPHABET_END - ALPHABET_START + 1).toBe(ALPHABET_SIZE);107-  });108-});

view file at the parent

src/crypto/cjk.ts+0 −173
@@ -1,173 +0,0 @@1-/**2- * CJK Phrase Encoding3- * -------------------4- *5- * Packs arbitrary binary data into a sequence of CJK Unified Ideographs.6- *7- * Each character encodes exactly 14 bits. The alphabet is the first 16,3848- * code points starting at U+4E00 (一), ending at U+8DFF. This is a fixed9- * power-of-two subset chosen for maximum font coverage across desktop and10- * mobile operating systems.11- *12- * Power-of-two alignment (2^14 = 16,384) means there is no modulo bias, no13- * padding at the character level, and `codepoint = ALPHABET_START + index`14- * is a direct arithmetic mapping — no lookup table required.15- *16- * NOTE ON SPEC DEVIATION: The implementation spec cites ALPHABET_END as17- * U+87FF, which is inconsistent with ALPHABET_SIZE = 16384 (U+4E00 + 1638418- * lands at U+8DFF, not U+87FF). The power-of-two invariant is load-bearing,19- * so this file treats U+8DFF as the correct upper bound. See the project20- * summary note for details.21- */22-23-export const ALPHABET_START = 0x4e00;24-export const ALPHABET_SIZE = 16384; // 2^14 — every character packs exactly 14 bits25-export const ALPHABET_END = ALPHABET_START + ALPHABET_SIZE - 1; // 0x8DFF26-27-/**28- * Number of meaningful bits in the final CJK character of an encoding.29- * `totalBits = byteCount * 8`. If that is an exact multiple of 14 the last30- * character is fully used and this returns 0. Otherwise it returns the31- * remainder in the range 1..13.32- */33-export function remainingBits(byteCount: number): number {34-  const r = (byteCount * 8) % 14;35-  return r;36-}37-38-/**39- * Encode a byte array into a CJK phrase string.40- *41- * Bytes are fed into a bit accumulator 8 bits at a time. Whenever the42- * accumulator holds at least 14 bits, the top 14 are extracted and emitted43- * as one character. Any leftover bits at the end become the most significant44- * bits of one final character, with the low bits zero-padded. The decoder45- * uses `remainingBits` to discard that padding.46- */47-export function cjkEncode(bytes: Uint8Array): string {48-  let bits = 0; // number of meaningful bits currently in the accumulator49-  let buffer = 0; // bit accumulator — at peak can hold up to 21 bits50-  const out: string[] = [];51-52-  for (let i = 0; i < bytes.length; i++) {53-    buffer = (buffer << 8) | bytes[i];54-    bits += 8;55-56-    while (bits >= 14) {57-      bits -= 14;58-      const index = (buffer >>> bits) & 0x3fff;59-      out.push(String.fromCodePoint(ALPHABET_START + index));60-    }61-  }62-63-  if (bits > 0) {64-    // Leftover 1..13 bits: left-align into a 14-bit slot (zero-pad low bits)65-    const index = (buffer << (14 - bits)) & 0x3fff;66-    out.push(String.fromCodePoint(ALPHABET_START + index));67-  }68-69-  return out.join("");70-}71-72-/**73- * Decode a CJK phrase string back into bytes. `remainingBitsValue` must be74- * the value computed by `remainingBits(originalByteLength)` at encode time,75- * and is stored in the binary header at §2.1.76- *77- * Throws on any character outside the 16,384-entry alphabet, or if the78- * phrase length is inconsistent with `remainingBitsValue`.79- */80-export function cjkDecode(phrase: string, remainingBitsValue: number): Uint8Array {81-  let bits = 0;82-  let buffer = 0;83-  const out: number[] = [];84-85-  // Spread handles surrogate pairs safely — every CJK ideograph in our86-  // alphabet fits in a single BMP code unit but we spread defensively.87-  const chars = [...phrase];88-  const lastIdx = chars.length - 1;89-90-  for (let i = 0; i < chars.length; i++) {91-    const cp = chars[i].codePointAt(0)!;92-    const index = cp - ALPHABET_START;93-94-    if (index < 0 || index >= ALPHABET_SIZE) {95-      throw new Error(96-        `INVALID_CHARACTER: U+${cp.toString(16).toUpperCase().padStart(4, "0")} at position ${i}`97-      );98-    }99-100-    const isLast = i === lastIdx;101-    const bitsInThisChar = isLast && remainingBitsValue > 0 ? remainingBitsValue : 14;102-103-    // The encoder left-aligned partial bits into the 14-bit slot; undo that104-    // by right-shifting to discard the zero-padding on the last character.105-    const value =106-      isLast && remainingBitsValue > 0 ? index >>> (14 - remainingBitsValue) : index;107-108-    buffer = (buffer << bitsInThisChar) | value;109-    bits += bitsInThisChar;110-111-    while (bits >= 8) {112-      bits -= 8;113-      out.push((buffer >>> bits) & 0xff);114-    }115-  }116-117-  if (bits !== 0) {118-    throw new Error("DECODE_ALIGNMENT_ERROR: residual bits after full decode");119-  }120-121-  return new Uint8Array(out);122-}123-124-/**125- * True if every code point in `phrase` is within the 16,384-entry alphabet.126- * Used by the paste-validator before attempting a full decode.127- */128-export function isValidPhraseCharset(phrase: string): boolean {129-  for (const ch of phrase) {130-    const cp = ch.codePointAt(0)!;131-    if (cp < ALPHABET_START || cp > ALPHABET_END) return false;132-  }133-  return true;134-}135-136-/**137- * Decode the first `charCount` characters of a phrase assuming they all use138- * the full 14 bits (remaining_bits = 0), and return whatever bytes fully139- * accumulated. Residual sub-byte bits are discarded — this is the bootstrap140- * path used by the import pipeline to read the binary header before the141- * true `remaining_bits` value is available.142- *143- * Throws on any character outside the alphabet. Unlike `cjkDecode` this does144- * NOT enforce that the accumulator lands on an 8-bit boundary, because the145- * caller is slicing out of the middle of a longer phrase.146- */147-export function cjkDecodeBootstrap(phrase: string, charCount: number): Uint8Array {148-  let bits = 0;149-  let buffer = 0;150-  const out: number[] = [];151-152-  const chars = [...phrase];153-  const n = Math.min(charCount, chars.length);154-155-  for (let i = 0; i < n; i++) {156-    const cp = chars[i].codePointAt(0)!;157-    const index = cp - ALPHABET_START;158-    if (index < 0 || index >= ALPHABET_SIZE) {159-      throw new Error(160-        `INVALID_CHARACTER: U+${cp.toString(16).toUpperCase().padStart(4, "0")} at position ${i}`161-      );162-    }163-    buffer = (buffer << 14) | index;164-    bits += 14;165-166-    while (bits >= 8) {167-      bits -= 8;168-      out.push((buffer >>> bits) & 0xff);169-    }170-  }171-172-  return new Uint8Array(out);173-}

view file at the parent

src/crypto/cjkExport.ts+0 −234
@@ -1,234 +0,0 @@1-/**2- * Export pipeline for a CJK phrase backup (spec §4.1, §5).3- *4- * The 12 steps defined in the spec must execute in order. Each one has a5- * reason the next one depends on. `exportToCJK` is the public entry point;6- * the sub-helpers are exported for direct use by the test vectors.7- */8-9-import { argon2id } from "hash-wasm";10-11-import { cjkEncode } from "./cjk";12-import {13-  buildHeader,14-  finalisePayload,15-  NONCE_LEN,16-  PAYLOAD_FLAGS,17-  PAYLOAD_VERSION,18-  remainingBitsForCiphertextLen,19-  SALT_LEN,20-  type HeaderFields,21-} from "./cjkPayload";22-import { serialiseVault } from "./cjkSerialise";23-import { compressZstd } from "./zstd";24-import type { PeachLicenseExport, VaultEntry } from "../shared/types";25-import type { VaultSettings } from "../background/vault";26-27-// ---------------------------------------------------------------------------28-// Argon2id parameter sets29-// ---------------------------------------------------------------------------30-31-export interface Argon2Params {32-  memoryKiB: number;33-  iterations: number;34-  parallelism: number;35-}36-37-/** Default set — ~1 second on modern hardware. */38-export const ARGON2_DEFAULT: Argon2Params = {39-  memoryKiB: 65536,40-  iterations: 3,41-  parallelism: 4,42-};43-44-/** High-security set — ~3 seconds, 256 MB memory. */45-export const ARGON2_HIGH_SECURITY: Argon2Params = {46-  memoryKiB: 262144,47-  iterations: 4,48-  parallelism: 4,49-};50-51-// ---------------------------------------------------------------------------52-// Low-level helpers used by both the export pipeline and the test vectors53-// ---------------------------------------------------------------------------54-55-export async function deriveKdfKey(56-  passphrase: string,57-  salt: Uint8Array,58-  params: Argon2Params59-): Promise<Uint8Array> {60-  return argon2id({61-    password: passphrase,62-    salt,63-    parallelism: params.parallelism,64-    iterations: params.iterations,65-    memorySize: params.memoryKiB,66-    hashLength: 32,67-    outputType: "binary",68-  });69-}70-71-export async function encryptBinary(72-  key: Uint8Array,73-  nonce: Uint8Array,74-  plaintext: Uint8Array75-): Promise<{ ciphertext: Uint8Array; authTag: Uint8Array }> {76-  const cryptoKey = await crypto.subtle.importKey(77-    "raw",78-    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,79-    { name: "AES-GCM" },80-    false,81-    ["encrypt"]82-  );83-84-  // AES-GCM with no AAD (spec §4.1 Step 6 — the header version byte is85-  // protected by the BLAKE3 checksum, not by AEAD-AAD binding).86-  const combined = await crypto.subtle.encrypt(87-    { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, tagLength: 128 },88-    cryptoKey,89-    plaintext.buffer.slice(90-      plaintext.byteOffset,91-      plaintext.byteOffset + plaintext.byteLength92-    ) as ArrayBuffer93-  );94-95-  const combinedBytes = new Uint8Array(combined);96-  const ciphertext = combinedBytes.slice(0, combinedBytes.length - 16);97-  const authTag = combinedBytes.slice(combinedBytes.length - 16);98-  return { ciphertext, authTag };99-}100-101-// ---------------------------------------------------------------------------102-// Phrase formatting103-// ---------------------------------------------------------------------------104-105-const LINE_WIDTH = 60;106-107-function buildHeaderLine(entryCount: number): string {108-  return `peach:v${PAYLOAD_VERSION}:entries=${entryCount}:zstd:aes256gcm:argon2id`;109-}110-111-function countsByType(entries: VaultEntry[]): {112-  logins: number;113-  notes: number;114-  passkeys: number;115-} {116-  let logins = 0;117-  let notes = 0;118-  let passkeys = 0;119-  for (const e of entries) {120-    if (e.type === "passkey") passkeys += 1;121-    else if (e.type === "note") notes += 1;122-    else logins += 1;123-  }124-  return { logins, notes, passkeys };125-}126-127-export function formatPhrase(cjkChars: string, entryCount: number): string {128-  const lines: string[] = [buildHeaderLine(entryCount)];129-  for (let i = 0; i < cjkChars.length; i += LINE_WIDTH) {130-    lines.push(cjkChars.slice(i, i + LINE_WIDTH));131-  }132-  return lines.join("\n");133-}134-135-// ---------------------------------------------------------------------------136-// Top-level export137-// ---------------------------------------------------------------------------138-139-export interface ExportInput {140-  entries: VaultEntry[];141-  settings?: VaultSettings;142-  license?: PeachLicenseExport;143-  passphrase: string;144-  argon2?: Argon2Params;145-}146-147-export interface ExportResult {148-  phrase: string;149-  /** Total entries in the export, including trashed. */150-  entryCount: number;151-  cjkCharCount: number;152-  /** Active (non-trashed) login count. */153-  loginCount: number;154-  /** Active (non-trashed) note count. */155-  noteCount: number;156-  /** Active (non-trashed) passkey count. */157-  passkeyCount: number;158-  /** Entries with deletedAt set — land back in trash on import. */159-  trashedCount: number;160-  /** The raw binary that `cjkEncode` processed (checksum appended). Useful for tests. */161-  binary: Uint8Array;162-}163-164-/**165- * Run the full export pipeline end-to-end. Includes all entries — active166- * and trashed — so a restore is a complete, lossless round-trip. Trashed167- * entries arrive back in trash (deletedAt preserved). `settings` is168- * round-tripped verbatim so a restore carries theme, auto-lock, etc.169- */170-export async function exportToCJK(input: ExportInput): Promise<ExportResult> {171-  const params = input.argon2 ?? ARGON2_DEFAULT;172-173-  const allEntries = input.entries;174-  const activeEntries = allEntries.filter((e) => !e.deletedAt);175-  const { logins, notes, passkeys } = countsByType(activeEntries);176-  const trashedCount = allEntries.length - activeEntries.length;177-178-  const serialised = serialiseVault(allEntries, input.settings ?? {}, input.license);179-180-  // Step 3: compress181-  const compressed = await compressZstd(serialised, 19);182-183-  // Step 4: fresh salt and nonce — independent of the at-rest vault184-  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));185-  const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));186-187-  // Step 5: derive key188-  const key = await deriveKdfKey(input.passphrase, salt, params);189-  try {190-    // Step 6: encrypt (no AAD)191-    const { ciphertext, authTag } = await encryptBinary(key, nonce, compressed);192-193-    // Step 10 (resolved): remaining_bits derives from the final byte count194-    const remainingBitsValue = remainingBitsForCiphertextLen(ciphertext.length);195-196-    // Step 7: assemble header197-    const headerFields: HeaderFields = {198-      entryCount: allEntries.length,199-      argon2Memory: params.memoryKiB,200-      argon2Time: params.iterations,201-      argon2Parallel: params.parallelism,202-      salt,203-      remainingBitsValue,204-    };205-    const header = buildHeader(headerFields);206-207-    // Step 8+9: finalise (concat + BLAKE3 checksum append)208-    const binary = await finalisePayload(header, nonce, ciphertext, authTag);209-210-    // Step 11: encode211-    const cjkChars = cjkEncode(binary);212-213-    // Step 12: format214-    const phrase = formatPhrase(cjkChars, allEntries.length);215-216-    return {217-      phrase,218-      entryCount: allEntries.length,219-      cjkCharCount: [...cjkChars].length,220-      loginCount: logins,221-      noteCount: notes,222-      passkeyCount: passkeys,223-      trashedCount,224-      binary,225-    };226-  } finally {227-    // Zero the derived key promptly. The underlying Uint8Array was produced228-    // by hash-wasm and is not referenced anywhere else after encryptBinary.229-    key.fill(0);230-  }231-}232-233-// Re-export flag constants callers may want for assertions234-export { PAYLOAD_FLAGS, PAYLOAD_VERSION };

view file at the parent

src/crypto/cjkImport.ts+0 −385
@@ -1,385 +0,0 @@1-/**2- * Import pipeline for a CJK phrase backup (spec §4.2, §9).3- *4- * The order matters for security and UX both:5- *6- *   1. Parse and validate the ASCII header line.7- *   2. Strip header + whitespace to get pure CJK content.8- *   3. Bootstrap-decode the first 18 chars (= 31 bytes, exactly the header9- *      length) so we can read `remaining_bits` before doing the full decode.10- *   4. Full `cjkDecode` with the correct remaining_bits.11- *   5. `extractFields` — layout validation, version/flags check.12- *   6. BLAKE3 checksum verify. If this fails, stop BEFORE prompting for a13- *      passphrase: a corrupt phrase is never worth asking the user to type14- *      their master phrase for.15- *   7. Argon2id key derivation using the parameters stored in the header.16- *   8. AES-256-GCM decrypt. AEAD failure is surfaced as DECRYPT_FAILED.17- *   9. zstd decompress.18- *  10. `deserialiseVault` — the entry count from the binary header drives19- *      the loop. Afterwards we cross-check against the ASCII header line's20- *      `entries=N` value (spec §4.2 Step 10).21- *22- * Every error path uses the exact error codes listed in §9 so the UI layer23- * (§8) can map them to the prescribed user-facing copy.24- */25-26-import { cjkDecode, cjkDecodeBootstrap, isValidPhraseCharset } from "./cjk";27-import {28-  AUTH_TAG_LEN,29-  CHECKSUM_LEN,30-  computeBlake3,31-  concatBytes,32-  constantTimeEqual,33-  extractFields,34-  HEADER_LEN,35-  MIN_PAYLOAD_LEN,36-  NONCE_LEN,37-  PAYLOAD_VERSION,38-  type ExtractedFields,39-} from "./cjkPayload";40-import { deriveKdfKey, type Argon2Params } from "./cjkExport";41-import { deserialiseVault, type VaultBackupPayload } from "./cjkSerialise";42-import { decompressZstd } from "./zstd";43-import type { VaultEntry } from "../shared/types";44-import type { VaultSettings } from "../background/vault";45-46-// ---------------------------------------------------------------------------47-// Bounds on the Argon2 parameters we'll accept from a header. A malicious or48-// corrupt phrase could advertise "400 GB of memory, 1 billion iterations" and49-// hang the browser before the user could cancel. The ceilings here are above50-// the spec's ARGON2_HIGH_SECURITY set (256 MB / 4 iter / 4 lanes) with51-// headroom for future tuning.52-// ---------------------------------------------------------------------------53-54-const ARGON2_MAX_MEMORY_KIB = 1024 * 1024; // 1 GB55-const ARGON2_MAX_ITERATIONS = 32;56-const ARGON2_MAX_PARALLELISM = 16;57-const ARGON2_MIN_MEMORY_KIB = 1024; // 1 MB floor to reject nonsense low values58-const ARGON2_MIN_ITERATIONS = 1;59-const ARGON2_MIN_PARALLELISM = 1;60-61-// Bootstrap: 18 CJK chars × 14 bits = 252 bits = 31 full bytes + 4 residual62-// bits. 31 is exactly HEADER_LEN, so the bootstrap yields the complete header63-// without needing any special handling for the residual bits (they're64-// discarded by cjkDecodeBootstrap because it doesn't enforce alignment).65-const BOOTSTRAP_CHAR_COUNT = 18;66-67-// ---------------------------------------------------------------------------68-// Public types69-// ---------------------------------------------------------------------------70-71-export interface ParsedHeaderLine {72-  version: number;73-  entryCount: number;74-}75-76-export interface ImportResult {77-  entries: VaultEntry[];78-  settings: VaultSettings;79-  /** License key from the backup, if the backup was a Pro export. */80-  licenseKey?: string;81-  /** Activation timestamp from the backup license, if present. */82-  licenseActivatedAt?: number;83-  /** The entry count advertised by the ASCII header line. */84-  announcedEntryCount: number;85-  /** The entry count recorded in the binary header. */86-  binaryEntryCount: number;87-}88-89-// ---------------------------------------------------------------------------90-// Step 1: ASCII header line91-// ---------------------------------------------------------------------------92-93-const HEADER_LINE_RE =94-  /^peach:v(\d+):entries=(\d+):zstd:aes256gcm:argon2id$/;95-96-/**97- * Parse the first line of a phrase. Returns { version, entryCount }.98- *99- * Throws INVALID_HEADER for anything unparseable — including wrong cipher100- * tags, wrong KDF tag, non-numeric entry count, or a version number whose101- * format is right but whose value we don't support.102- */103-export function parseHeaderLine(line: string): ParsedHeaderLine {104-  const m = HEADER_LINE_RE.exec(line.trim());105-  if (!m) {106-    throw new Error("INVALID_HEADER");107-  }108-  const version = Number.parseInt(m[1], 10);109-  const entryCount = Number.parseInt(m[2], 10);110-  if (!Number.isFinite(version) || !Number.isFinite(entryCount)) {111-    throw new Error("INVALID_HEADER");112-  }113-  return { version, entryCount };114-}115-116-/**117- * Split a pasted phrase into its ASCII header line and its pure CJK content118- * (newlines and any other whitespace stripped from the body). Any empty119- * lines after the header are tolerated — common copy/paste artefacts.120- */121-export function splitPhrase(phrase: string): {122-  headerLine: string;123-  cjkContent: string;124-} {125-  const firstNewline = phrase.indexOf("\n");126-  if (firstNewline === -1) {127-    throw new Error("INVALID_HEADER");128-  }129-  const headerLine = phrase.slice(0, firstNewline).replace(/\r$/, "");130-  const body = phrase.slice(firstNewline + 1);131-  // Strip ALL whitespace from the body — spaces, tabs, CR, LF. The encoder132-  // emits 60-char lines joined with "\n" but we don't rely on that here.133-  const cjkContent = body.replace(/\s+/g, "");134-  return { headerLine, cjkContent };135-}136-137-/**138- * Fast pre-flight used by the paste-validator in §8.3. Verifies the header139- * line parses AND every body character is within the CJK alphabet. Returns140- * true/false instead of throwing so the UI can show a green/red dot live141- * as the user pastes.142- */143-export function validatePhraseStructure(phrase: string): boolean {144-  try {145-    const { headerLine, cjkContent } = splitPhrase(phrase);146-    const parsed = parseHeaderLine(headerLine);147-    if (parsed.version !== PAYLOAD_VERSION) return false;148-    if (cjkContent.length === 0) return false;149-    return isValidPhraseCharset(cjkContent);150-  } catch {151-    return false;152-  }153-}154-155-// ---------------------------------------------------------------------------156-// Steps 2–5: phrase → binary → extracted fields157-// ---------------------------------------------------------------------------158-159-/**160- * Decode a CJK phrase body to its raw binary payload. Two-pass:161- *   pass A: bootstrap-decode 18 chars to read remaining_bits from the header162- *   pass B: full decode with the correct remaining_bits163- *164- * Separated from `extractFields` so the UI can run a cheap structural check165- * before the (slower) alignment-enforcing full decode.166- */167-export function decodePhraseToBytes(cjkContent: string): Uint8Array {168-  if (cjkContent.length < BOOTSTRAP_CHAR_COUNT) {169-    throw new Error("PAYLOAD_TOO_SHORT");170-  }171-172-  // Pass A: get the first 31 bytes — exactly the header.173-  const headerBytes = cjkDecodeBootstrap(cjkContent, BOOTSTRAP_CHAR_COUNT);174-  if (headerBytes.length < HEADER_LEN) {175-    // Impossible in practice — 18 chars give 31 full bytes — but guard for176-    // the `cjkDecodeBootstrap` contract in case the caller changes it.177-    throw new Error("PAYLOAD_TOO_SHORT");178-  }179-180-  // Version gate before full decode. Catching UNSUPPORTED_VERSION here lets181-  // us avoid burning a full-decode pass on a phrase we'd reject anyway.182-  const version = headerBytes[0];183-  if (version !== PAYLOAD_VERSION) {184-    throw new Error(`UNSUPPORTED_VERSION: ${version}`);185-  }186-187-  // remaining_bits lives at offset 13 (u16 LE).188-  const view = new DataView(189-    headerBytes.buffer,190-    headerBytes.byteOffset,191-    headerBytes.byteLength192-  );193-  const remainingBitsValue = view.getUint16(13, true);194-  if (remainingBitsValue > 13) {195-    // Valid values are 0..13 (remainder of totalBits % 14). Anything else196-    // means the header is garbage.197-    throw new Error("INVALID_HEADER");198-  }199-200-  // Pass B: full decode. cjkDecode enforces the 8-bit alignment invariant.201-  return cjkDecode(cjkContent, remainingBitsValue);202-}203-204-/**205- * Verify the BLAKE3 checksum appended to the binary payload. Uses206- * constant-time equality to avoid leaking how many leading bytes matched.207- * This runs BEFORE the passphrase prompt so a corrupt or truncated phrase208- * fails fast — spec §4.2 Step 4.209- */210-export async function verifyIntegrity(binary: Uint8Array): Promise<ExtractedFields> {211-  if (binary.length < MIN_PAYLOAD_LEN) {212-    throw new Error("PAYLOAD_TOO_SHORT");213-  }214-  const fields = extractFields(binary);215-  const computed = await computeBlake3(fields.payloadForChecksum);216-  if (!constantTimeEqual(computed, fields.storedChecksum)) {217-    throw new Error("CHECKSUM_FAILED");218-  }219-  return fields;220-}221-222-// ---------------------------------------------------------------------------223-// Steps 6–10: decrypt, decompress, deserialise224-// ---------------------------------------------------------------------------225-226-function argon2ParamsFromFields(fields: ExtractedFields): Argon2Params {227-  const memoryKiB = fields.argon2Memory;228-  const iterations = fields.argon2Time;229-  const parallelism = fields.argon2Parallel;230-231-  if (232-    memoryKiB < ARGON2_MIN_MEMORY_KIB ||233-    memoryKiB > ARGON2_MAX_MEMORY_KIB ||234-    iterations < ARGON2_MIN_ITERATIONS ||235-    iterations > ARGON2_MAX_ITERATIONS ||236-    parallelism < ARGON2_MIN_PARALLELISM ||237-    parallelism > ARGON2_MAX_PARALLELISM238-  ) {239-    throw new Error("INVALID_HEADER");240-  }241-242-  return { memoryKiB, iterations, parallelism };243-}244-245-async function decryptBinary(246-  key: Uint8Array,247-  nonce: Uint8Array,248-  ciphertext: Uint8Array,249-  authTag: Uint8Array250-): Promise<Uint8Array> {251-  const cryptoKey = await crypto.subtle.importKey(252-    "raw",253-    key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer,254-    { name: "AES-GCM" },255-    false,256-    ["decrypt"]257-  );258-259-  // WebCrypto wants ciphertext || authTag as a single buffer.260-  const combined = concatBytes([ciphertext, authTag]);261-262-  try {263-    const plaintext = await crypto.subtle.decrypt(264-      {265-        name: "AES-GCM",266-        iv: nonce.buffer.slice(267-          nonce.byteOffset,268-          nonce.byteOffset + nonce.byteLength269-        ) as ArrayBuffer,270-        tagLength: 128,271-      },272-      cryptoKey,273-      combined.buffer.slice(274-        combined.byteOffset,275-        combined.byteOffset + combined.byteLength276-      ) as ArrayBuffer277-    );278-    return new Uint8Array(plaintext);279-  } catch {280-    // WebCrypto surfaces AEAD failure as a plain OperationError with no281-    // distinguishing info. Per spec §9 we normalise all AEAD failures to a282-    // single DECRYPT_FAILED code — do not leak more than that.283-    throw new Error("DECRYPT_FAILED");284-  }285-}286-287-/**288- * Decrypt + decompress + deserialise. Takes the already-verified header289- * extraction so callers can't accidentally skip the checksum step. The290- * derived key is zeroed in a `finally` block regardless of success.291- * Returns the full backup payload — entries AND settings.292- */293-export async function decryptAndDeserialise(294-  fields: ExtractedFields,295-  passphrase: string296-): Promise<VaultBackupPayload> {297-  const params = argon2ParamsFromFields(fields);298-  const key = await deriveKdfKey(passphrase, fields.salt, params);299-  try {300-    const compressed = await decryptBinary(301-      key,302-      fields.nonce,303-      fields.ciphertext,304-      fields.authTag305-    );306-307-    let serialised: Uint8Array;308-    try {309-      serialised = await decompressZstd(compressed);310-    } catch {311-      // A corrupt zstd frame after a successful AEAD tag check means the312-      // key was right but the plaintext is damaged. Exceedingly unlikely313-      // given AES-GCM's integrity guarantees, but if it happens we treat314-      // it as a decrypt failure rather than a silent empty vault.315-      throw new Error("DECRYPT_FAILED");316-    }317-318-    return deserialiseVault(serialised);319-  } finally {320-    key.fill(0);321-  }322-}323-324-// ---------------------------------------------------------------------------325-// Top-level orchestration326-// ---------------------------------------------------------------------------327-328-/**329- * Run the full import pipeline end-to-end. This is the convenience entry330- * point for tests and for callers that already have the passphrase in hand.331- *332- * For the real UI flow, prefer composing `decodePhraseToBytes` +333- * `verifyIntegrity` up front (to detect corruption before prompting the334- * user for a passphrase), then calling `decryptAndDeserialise` after the335- * prompt. The structural enforcement comes from the type signature: you336- * must hold an `ExtractedFields` to call the second step, and the only337- * way to get one is via `verifyIntegrity`.338- */339-export async function importFromCJK(340-  phrase: string,341-  passphrase: string342-): Promise<ImportResult> {343-  const { headerLine, cjkContent } = splitPhrase(phrase);344-  const parsed = parseHeaderLine(headerLine);345-346-  if (parsed.version !== PAYLOAD_VERSION) {347-    throw new Error(`UNSUPPORTED_VERSION: ${parsed.version}`);348-  }349-350-  const binary = decodePhraseToBytes(cjkContent);351-  const fields = await verifyIntegrity(binary);352-353-  // Cross-check: the ASCII header's entry count must match the binary354-  // header's. A mismatch means the phrase was edited across layers —355-  // someone swapped the CJK body but left the ASCII header, or vice versa.356-  if (parsed.entryCount !== fields.entryCount) {357-    throw new Error("HEADER_COUNT_MISMATCH");358-  }359-360-  const payload = await decryptAndDeserialise(fields, passphrase);361-362-  // Final cross-check (spec §4.2 Step 10): the JSON entry count must match363-  // the binary header's entry_count. A mismatch means someone edited one364-  // layer without the other.365-  if (payload.entries.length !== fields.entryCount) {366-    throw new Error("ENTRY_COUNT_MISMATCH");367-  }368-369-  return {370-    entries: payload.entries,371-    settings: payload.settings,372-    ...(payload.license?.key ? { licenseKey: payload.license.key, licenseActivatedAt: payload.license.activatedAt } : {}),373-    announcedEntryCount: parsed.entryCount,374-    binaryEntryCount: fields.entryCount,375-  };376-}377-378-// Re-export types and utilities the UI layer needs379-export {380-  AUTH_TAG_LEN,381-  CHECKSUM_LEN,382-  HEADER_LEN,383-  MIN_PAYLOAD_LEN,384-  NONCE_LEN,385-};

view file at the parent

src/crypto/cjkPayload.test.ts+0 −174
@@ -1,174 +0,0 @@1-/**2- * Binary payload assembly and extraction (spec §2, §10.2).3- */4-5-import { describe, expect, it } from "vitest";6-7-import {8-  AUTH_TAG_LEN,9-  buildHeader,10-  CHECKSUM_LEN,11-  computeBlake3,12-  concatBytes,13-  constantTimeEqual,14-  extractFields,15-  finalisePayload,16-  HEADER_LEN,17-  MIN_PAYLOAD_LEN,18-  NONCE_LEN,19-  PAYLOAD_FLAGS,20-  PAYLOAD_VERSION,21-  remainingBitsForCiphertextLen,22-  SALT_LEN,23-  type HeaderFields,24-} from "./cjkPayload";25-26-function makeFields(overrides: Partial<HeaderFields> = {}): HeaderFields {27-  return {28-    entryCount: 1,29-    argon2Memory: 65536,30-    argon2Time: 3,31-    argon2Parallel: 4,32-    salt: new Uint8Array(SALT_LEN).fill(0x42),33-    remainingBitsValue: 0,34-    ...overrides,35-  };36-}37-38-describe("buildHeader", () => {39-  it("writes the exact byte layout from §2.1", () => {40-    const ciphertextLen = 128;41-    const rb = remainingBitsForCiphertextLen(ciphertextLen);42-43-    const header = buildHeader(44-      makeFields({ remainingBitsValue: rb, entryCount: 1 })45-    );46-47-    expect(header.length).toBe(HEADER_LEN);48-    const view = new DataView(49-      header.buffer,50-      header.byteOffset,51-      header.byteLength52-    );53-54-    expect(header[0]).toBe(PAYLOAD_VERSION);55-    expect(view.getUint16(1, true)).toBe(1); // entry_count56-    expect(header[3]).toBe(PAYLOAD_FLAGS);57-    expect(view.getUint32(4, true)).toBe(65536); // argon2_memory58-    expect(view.getUint32(8, true)).toBe(3); // argon2_time59-    expect(header[12]).toBe(4); // argon2_parallel60-    expect(view.getUint16(13, true)).toBe(rb);61-    for (let i = 0; i < SALT_LEN; i++) {62-      expect(header[15 + i]).toBe(0x42);63-    }64-  });65-66-  it("rejects wrongly-sized salts", () => {67-    expect(() =>68-      buildHeader(makeFields({ salt: new Uint8Array(8) }))69-    ).toThrow(/SALT_LEN/);70-  });71-72-  it("rejects entryCounts that overflow uint16", () => {73-    expect(() =>74-      buildHeader(makeFields({ entryCount: 70000 }))75-    ).toThrow(/uint16/);76-  });77-});78-79-describe("finalisePayload + extractFields round-trip", () => {80-  it("assembles header|nonce|ct|tag|checksum and extracts each field", async () => {81-    const ciphertext = new Uint8Array(64);82-    crypto.getRandomValues(ciphertext);83-    const authTag = new Uint8Array(AUTH_TAG_LEN).fill(0xAA);84-    const nonce = new Uint8Array(NONCE_LEN).fill(0x13);85-    const rb = remainingBitsForCiphertextLen(ciphertext.length);86-87-    const header = buildHeader(88-      makeFields({ entryCount: 2, remainingBitsValue: rb })89-    );90-91-    const full = await finalisePayload(header, nonce, ciphertext, authTag);92-93-    // Length sanity94-    expect(full.length).toBe(95-      HEADER_LEN + NONCE_LEN + ciphertext.length + AUTH_TAG_LEN + CHECKSUM_LEN96-    );97-98-    const extracted = extractFields(full);99-100-    expect(extracted.entryCount).toBe(2);101-    expect(extracted.argon2Memory).toBe(65536);102-    expect(extracted.argon2Time).toBe(3);103-    expect(extracted.argon2Parallel).toBe(4);104-    expect(extracted.remainingBitsValue).toBe(rb);105-    expect(extracted.salt).toEqual(new Uint8Array(SALT_LEN).fill(0x42));106-    expect(extracted.nonce).toEqual(nonce);107-    expect(extracted.ciphertext).toEqual(ciphertext);108-    expect(extracted.authTag).toEqual(authTag);109-110-    // Stored checksum must match a fresh BLAKE3 over the payload-minus-checksum111-    const recomputed = await computeBlake3(extracted.payloadForChecksum);112-    expect(constantTimeEqual(recomputed, extracted.storedChecksum)).toBe(true);113-  });114-115-  it("rejects a truncated payload", () => {116-    const tiny = new Uint8Array(MIN_PAYLOAD_LEN - 1);117-    expect(() => extractFields(tiny)).toThrow(/PAYLOAD_TOO_SHORT/);118-  });119-120-  it("rejects an unsupported version byte", async () => {121-    const ciphertext = new Uint8Array(16);122-    const authTag = new Uint8Array(AUTH_TAG_LEN);123-    const nonce = new Uint8Array(NONCE_LEN);124-    const rb = remainingBitsForCiphertextLen(ciphertext.length);125-    const header = buildHeader(makeFields({ remainingBitsValue: rb }));126-127-    const full = await finalisePayload(header, nonce, ciphertext, authTag);128-    full[0] = 0x99; // corrupt version byte129-    expect(() => extractFields(full)).toThrow(/UNSUPPORTED_VERSION/);130-  });131-132-  it("rejects unsupported flags", async () => {133-    const ciphertext = new Uint8Array(16);134-    const authTag = new Uint8Array(AUTH_TAG_LEN);135-    const nonce = new Uint8Array(NONCE_LEN);136-    const rb = remainingBitsForCiphertextLen(ciphertext.length);137-    const header = buildHeader(makeFields({ remainingBitsValue: rb }));138-139-    const full = await finalisePayload(header, nonce, ciphertext, authTag);140-    full[3] = 0x05; // corrupt flags byte141-    expect(() => extractFields(full)).toThrow(/UNSUPPORTED_FLAGS/);142-  });143-});144-145-describe("constantTimeEqual", () => {146-  it("returns true for identical buffers", () => {147-    const a = new Uint8Array([1, 2, 3, 4]);148-    const b = new Uint8Array([1, 2, 3, 4]);149-    expect(constantTimeEqual(a, b)).toBe(true);150-  });151-152-  it("returns false when any byte differs", () => {153-    const a = new Uint8Array([1, 2, 3, 4]);154-    const b = new Uint8Array([1, 2, 3, 5]);155-    expect(constantTimeEqual(a, b)).toBe(false);156-  });157-158-  it("returns false for different lengths", () => {159-    expect(160-      constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))161-    ).toBe(false);162-  });163-});164-165-describe("concatBytes", () => {166-  it("concatenates in order", () => {167-    const out = concatBytes([168-      new Uint8Array([1, 2]),169-      new Uint8Array([3]),170-      new Uint8Array([4, 5, 6]),171-    ]);172-    expect(out).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6]));173-  });174-});

view file at the parent

src/crypto/cjkPayload.ts+0 −242
@@ -1,242 +0,0 @@1-/**2- * Binary payload layout for a CJK phrase (spec §2).3- *4- *   [31 bytes: header]5- *     [1]  version          = 0x026- *     [2]  entry_count      (uint16 LE)7- *     [1]  flags            = 0x01 (zstd + AES-256-GCM)8- *     [4]  argon2_memory    (uint32 LE, KiB)9- *     [4]  argon2_time      (uint32 LE)10- *     [1]  argon2_parallel  (uint8)11- *     [2]  remaining_bits   (uint16 LE)12- *     [16] salt             (CSPRNG, per-export)13- *14- *   [12 bytes: nonce]       (CSPRNG, per-export)15- *   [N bytes: ciphertext]   (AES-256-GCM)16- *   [16 bytes: auth_tag]    (AES-256-GCM tag)17- *   [32 bytes: checksum]    (BLAKE3 of everything above)18- *19- * The checksum covers every byte of the payload except itself. It is20- * verified before the user is prompted for the passphrase, so a corrupt or21- * truncated phrase fails fast with a clear error.22- */23-24-import { blake3 } from "hash-wasm";25-import { remainingBits } from "./cjk";26-27-export const PAYLOAD_VERSION = 0x03;28-export const PAYLOAD_FLAGS = 0x01; // zstd + AES-256-GCM29-30-export const HEADER_LEN = 31;31-export const NONCE_LEN = 12;32-export const AUTH_TAG_LEN = 16;33-export const CHECKSUM_LEN = 32;34-export const SALT_LEN = 16;35-36-/** Minimum valid payload: header + nonce + 0-byte ciphertext + tag + checksum. */37-export const MIN_PAYLOAD_LEN = HEADER_LEN + NONCE_LEN + AUTH_TAG_LEN + CHECKSUM_LEN;38-39-// ---------------------------------------------------------------------------40-// Header-driven field positions, so the rest of the module has a single41-// source of truth for where things live.42-// ---------------------------------------------------------------------------43-44-const OFFSET_VERSION = 0;45-const OFFSET_ENTRY_COUNT = 1;46-const OFFSET_FLAGS = 3;47-const OFFSET_ARGON2_MEMORY = 4;48-const OFFSET_ARGON2_TIME = 8;49-const OFFSET_ARGON2_PARALLEL = 12;50-const OFFSET_REMAINING_BITS = 13;51-const OFFSET_SALT = 15;52-53-// ---------------------------------------------------------------------------54-// Assembly55-// ---------------------------------------------------------------------------56-57-export interface HeaderFields {58-  entryCount: number;59-  argon2Memory: number;60-  argon2Time: number;61-  argon2Parallel: number;62-  salt: Uint8Array;63-  remainingBitsValue: number;64-}65-66-/**67- * Build the 31-byte header exactly as specified in §2.1.68- */69-export function buildHeader(fields: HeaderFields): Uint8Array {70-  if (fields.salt.length !== SALT_LEN) {71-    throw new Error(`SALT_LEN must be ${SALT_LEN}, got ${fields.salt.length}`);72-  }73-  if (fields.entryCount > 0xffff) {74-    throw new Error("entryCount exceeds uint16 range");75-  }76-77-  const header = new Uint8Array(HEADER_LEN);78-  const view = new DataView(header.buffer);79-80-  header[OFFSET_VERSION] = PAYLOAD_VERSION;81-  view.setUint16(OFFSET_ENTRY_COUNT, fields.entryCount, true);82-  header[OFFSET_FLAGS] = PAYLOAD_FLAGS;83-  view.setUint32(OFFSET_ARGON2_MEMORY, fields.argon2Memory, true);84-  view.setUint32(OFFSET_ARGON2_TIME, fields.argon2Time, true);85-  header[OFFSET_ARGON2_PARALLEL] = fields.argon2Parallel;86-  view.setUint16(OFFSET_REMAINING_BITS, fields.remainingBitsValue, true);87-  header.set(fields.salt, OFFSET_SALT);88-89-  return header;90-}91-92-/**93- * Compute `remaining_bits` for the fully-assembled binary given just the94- * ciphertext length. This resolves the circular dependency called out in95- * spec §4.1 Step 10: remaining_bits depends on the final byte count, but is96- * itself stored in the header. The total byte count is deterministic once97- * the ciphertext length is known.98- */99-export function remainingBitsForCiphertextLen(ciphertextLen: number): number {100-  return remainingBits(HEADER_LEN + NONCE_LEN + ciphertextLen + AUTH_TAG_LEN + CHECKSUM_LEN);101-}102-103-/**104- * Concatenate header | nonce | ciphertext | auth_tag, and append the BLAKE3105- * checksum. Returns the full binary that the CJK encoder will turn into106- * characters.107- */108-export async function finalisePayload(109-  header: Uint8Array,110-  nonce: Uint8Array,111-  ciphertext: Uint8Array,112-  authTag: Uint8Array113-): Promise<Uint8Array> {114-  if (header.length !== HEADER_LEN) throw new Error("header wrong length");115-  if (nonce.length !== NONCE_LEN) throw new Error("nonce wrong length");116-  if (authTag.length !== AUTH_TAG_LEN) throw new Error("authTag wrong length");117-118-  const withoutChecksum = concatBytes([header, nonce, ciphertext, authTag]);119-  const checksum = await computeBlake3(withoutChecksum);120-121-  return concatBytes([withoutChecksum, checksum]);122-}123-124-// ---------------------------------------------------------------------------125-// Extraction126-// ---------------------------------------------------------------------------127-128-export interface ExtractedFields extends HeaderFields {129-  nonce: Uint8Array;130-  ciphertext: Uint8Array;131-  authTag: Uint8Array;132-  storedChecksum: Uint8Array;133-  /** Bytes over which BLAKE3 should be recomputed (everything except the checksum). */134-  payloadForChecksum: Uint8Array;135-}136-137-/**138- * Parse the binary blob that came out of `cjkDecode`. Throws for any139- * structural problem — unknown version, unsupported flags, truncated140- * payload — using the exact error codes the import UI surfaces.141- */142-export function extractFields(binary: Uint8Array): ExtractedFields {143-  if (binary.length < MIN_PAYLOAD_LEN) {144-    throw new Error("PAYLOAD_TOO_SHORT");145-  }146-147-  const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);148-149-  const version = binary[OFFSET_VERSION];150-  if (version !== PAYLOAD_VERSION) {151-    throw new Error(`UNSUPPORTED_VERSION: ${version}`);152-  }153-154-  const flags = binary[OFFSET_FLAGS];155-  if (flags !== PAYLOAD_FLAGS) {156-    throw new Error(`UNSUPPORTED_FLAGS: ${flags}`);157-  }158-159-  const entryCount = view.getUint16(OFFSET_ENTRY_COUNT, true);160-  const argon2Memory = view.getUint32(OFFSET_ARGON2_MEMORY, true);161-  const argon2Time = view.getUint32(OFFSET_ARGON2_TIME, true);162-  const argon2Parallel = binary[OFFSET_ARGON2_PARALLEL];163-  const remainingBitsValue = view.getUint16(OFFSET_REMAINING_BITS, true);164-  const salt = binary.slice(OFFSET_SALT, OFFSET_SALT + SALT_LEN);165-166-  const nonceStart = HEADER_LEN;167-  const nonceEnd = nonceStart + NONCE_LEN;168-  const nonce = binary.slice(nonceStart, nonceEnd);169-170-  const checksumStart = binary.length - CHECKSUM_LEN;171-  const authTagStart = checksumStart - AUTH_TAG_LEN;172-173-  if (authTagStart < nonceEnd) {174-    throw new Error("PAYLOAD_TOO_SHORT");175-  }176-177-  const ciphertext = binary.slice(nonceEnd, authTagStart);178-  const authTag = binary.slice(authTagStart, checksumStart);179-  const storedChecksum = binary.slice(checksumStart);180-  const payloadForChecksum = binary.slice(0, checksumStart);181-182-  return {183-    entryCount,184-    argon2Memory,185-    argon2Time,186-    argon2Parallel,187-    salt,188-    remainingBitsValue,189-    nonce,190-    ciphertext,191-    authTag,192-    storedChecksum,193-    payloadForChecksum,194-  };195-}196-197-// ---------------------------------------------------------------------------198-// BLAKE3199-// ---------------------------------------------------------------------------200-201-export async function computeBlake3(data: Uint8Array): Promise<Uint8Array> {202-  const hex = await blake3(data);203-  return hexToBytes(hex);204-}205-206-/**207- * Constant-time equality for Uint8Arrays. Required for checksum verification208- * — an early-exit comparison leaks timing information about how many leading209- * bytes of a tampered checksum matched the computed one.210- */211-export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {212-  if (a.length !== b.length) return false;213-  let diff = 0;214-  for (let i = 0; i < a.length; i++) {215-    diff |= a[i] ^ b[i];216-  }217-  return diff === 0;218-}219-220-// ---------------------------------------------------------------------------221-// Byte utilities222-// ---------------------------------------------------------------------------223-224-export function concatBytes(chunks: Uint8Array[]): Uint8Array {225-  let total = 0;226-  for (const c of chunks) total += c.length;227-  const out = new Uint8Array(total);228-  let offset = 0;229-  for (const c of chunks) {230-    out.set(c, offset);231-    offset += c.length;232-  }233-  return out;234-}235-236-function hexToBytes(hex: string): Uint8Array {237-  const out = new Uint8Array(hex.length / 2);238-  for (let i = 0; i < hex.length; i += 2) {239-    out[i / 2] = parseInt(hex.slice(i, i + 2), 16);240-  }241-  return out;242-}

view file at the parent

src/crypto/cjkRoundTrip.test.ts+0 −343
@@ -1,343 +0,0 @@1-/**2- * Full export → import round-trip (spec §10.3) and size sanity (§10.4).3- *4- * These are integration tests: they run the real Argon2id KDF, AES-256-GCM,5- * zstd, and BLAKE3, exercising the entire pipeline as the UI would.6- *7- * We use the lowest Argon2 parameters the importer will accept (1 MB / 18- * iter / 1 lane) to keep the test suite fast. Production code uses9- * ARGON2_DEFAULT or ARGON2_HIGH_SECURITY — both are well above these floors.10- */11-12-import { describe, expect, it } from "vitest";13-14-import { exportToCJK, type Argon2Params } from "./cjkExport";15-import { importFromCJK } from "./cjkImport";16-import { ALPHABET_END, ALPHABET_START } from "./cjk";17-import type { VaultEntry } from "../shared/types";18-import type { VaultSettings } from "../background/vault";19-20-const FAST_ARGON2: Argon2Params = {21-  memoryKiB: 1024,22-  iterations: 1,23-  parallelism: 1,24-};25-26-function makeLogin(overrides: Partial<VaultEntry> = {}): VaultEntry {27-  return {28-    id: "id-placeholder",29-    type: "login",30-    title: "Untitled",31-    username: "",32-    password: "",33-    url: "",34-    createdAt: 1_700_000_000_000,35-    modifiedAt: 1_700_000_000_000,36-    ...overrides,37-  };38-}39-40-function isCjkChar(ch: string): boolean {41-  const cp = ch.codePointAt(0)!;42-  return cp >= ALPHABET_START && cp <= ALPHABET_END;43-}44-45-function stripHeaderAndWhitespace(phrase: string): string {46-  const firstNewline = phrase.indexOf("\n");47-  return phrase.slice(firstNewline + 1).replace(/\s+/g, "");48-}49-50-describe("CJK export/import round-trip (§10.3)", () => {51-  const passphrase = "correct horse battery staple drift waves";52-53-  const entries: VaultEntry[] = [54-    makeLogin({55-      id: "one",56-      title: "GitHub",57-      url: "github.com",58-      username: "[email protected]",59-      password: "hunter2",60-      totp: "JBSWY3DPEHPK3PXP",61-      createdAt: 1_700_000_000_000,62-      modifiedAt: 1_700_000_000_500,63-      canary: { webhookUrl: "https://hook.example/x", label: "GitHub decoy" },64-    }),65-    makeLogin({66-      id: "two",67-      title: "Spotify",68-      url: "spotify.com",69-      username: "june",70-      password: "s3cret-pw!",71-      notes: "family plan admin",72-      tags: ["music"],73-      createdAt: 1_700_000_001_000,74-      modifiedAt: 1_700_000_002_000,75-    }),76-    {77-      id: "note-one",78-      type: "note",79-      title: "Recovery codes",80-      notes: "a1b2-c3d4-e5f6",81-      createdAt: 1_700_000_003_000,82-      modifiedAt: 1_700_000_003_000,83-    },84-  ];85-86-  const settings: VaultSettings = {87-    theme: "dark",88-    autoLockMinutes: 15,89-    breachMonitoring: true,90-  };91-92-  it("produces a v3 phrase with the announced entry count", async () => {93-    const result = await exportToCJK({94-      entries,95-      settings,96-      passphrase,97-      argon2: FAST_ARGON2,98-    });99-100-    const firstLine = result.phrase.split("\n")[0];101-    expect(firstLine).toMatch(/^peach:v3:entries=3:zstd:aes256gcm:argon2id$/);102-    expect(result.entryCount).toBe(3);103-    expect(result.loginCount).toBe(2);104-    expect(result.noteCount).toBe(1);105-  }, 15000);106-107-  it("emits only CJK characters in the body", async () => {108-    const { phrase } = await exportToCJK({109-      entries,110-      settings,111-      passphrase,112-      argon2: FAST_ARGON2,113-    });114-    const body = stripHeaderAndWhitespace(phrase);115-    expect(body.length).toBeGreaterThan(0);116-    for (const ch of body) {117-      expect(isCjkChar(ch)).toBe(true);118-    }119-  }, 15000);120-121-  it("round-trips all entry fields including notes, tags, canary, and settings", async () => {122-    const { phrase } = await exportToCJK({123-      entries,124-      settings,125-      passphrase,126-      argon2: FAST_ARGON2,127-    });128-    const { entries: restored, settings: restoredSettings, announcedEntryCount, binaryEntryCount } =129-      await importFromCJK(phrase, passphrase);130-131-    expect(announcedEntryCount).toBe(3);132-    expect(binaryEntryCount).toBe(3);133-    expect(restored).toHaveLength(3);134-135-    const github = restored.find((e) => e.title === "GitHub")!;136-    expect(github.url).toBe("github.com");137-    expect(github.username).toBe("[email protected]");138-    expect(github.password).toBe("hunter2");139-    expect(github.totp).toBe("JBSWY3DPEHPK3PXP");140-    expect(github.canary?.webhookUrl).toBe("https://hook.example/x");141-    expect(github.canary?.label).toBe("GitHub decoy");142-    expect(github.createdAt).toBe(1_700_000_000_000);143-    expect(github.modifiedAt).toBe(1_700_000_000_500);144-145-    const spotify = restored.find((e) => e.title === "Spotify")!;146-    expect(spotify.notes).toBe("family plan admin");147-    expect(spotify.tags).toEqual(["music"]);148-149-    const note = restored.find((e) => e.type === "note")!;150-    expect(note.title).toBe("Recovery codes");151-    expect(note.notes).toBe("a1b2-c3d4-e5f6");152-153-    expect(restoredSettings.theme).toBe("dark");154-    expect(restoredSettings.autoLockMinutes).toBe(15);155-    expect(restoredSettings.breachMonitoring).toBe(true);156-  }, 30000);157-158-  it("rejects the wrong passphrase with DECRYPT_FAILED", async () => {159-    const { phrase } = await exportToCJK({160-      entries,161-      settings,162-      passphrase,163-      argon2: FAST_ARGON2,164-    });165-    await expect(166-      importFromCJK(phrase, "definitely-not-the-passphrase")167-    ).rejects.toThrow(/DECRYPT_FAILED/);168-  }, 30000);169-170-  it("rejects a tampered phrase with CHECKSUM_FAILED", async () => {171-    const { phrase } = await exportToCJK({172-      entries,173-      settings,174-      passphrase,175-      argon2: FAST_ARGON2,176-    });177-178-    const lines = phrase.split("\n");179-    const lastIdx = lines.length - 1;180-    const lastLine = lines[lastIdx];181-    const lastChar = lastLine[lastLine.length - 1];182-    const cp = lastChar.codePointAt(0)!;183-    const swappedCp =184-      cp === ALPHABET_START ? ALPHABET_START + 1 : ALPHABET_START;185-    const swappedChar = String.fromCodePoint(swappedCp);186-    lines[lastIdx] = lastLine.slice(0, -1) + swappedChar;187-    const tampered = lines.join("\n");188-189-    await expect(importFromCJK(tampered, passphrase)).rejects.toThrow(190-      /CHECKSUM_FAILED/191-    );192-  }, 30000);193-194-  it("round-trip preserves trashed entries with deletedAt intact", async () => {195-    const trashedAt = 1_700_000_010_000;196-    const withTrash: VaultEntry[] = [197-      makeLogin({ id: "active-1", title: "Active Login", password: "pw1" }),198-      makeLogin({ id: "active-2", title: "Active Note", password: "pw2" }),199-      makeLogin({ id: "trashed-1", title: "Old Account", password: "old", deletedAt: trashedAt }),200-    ];201-202-    const { phrase, entryCount, loginCount, trashedCount } = await exportToCJK({203-      entries: withTrash,204-      settings: {},205-      passphrase,206-      argon2: FAST_ARGON2,207-    });208-209-    expect(entryCount).toBe(3);210-    expect(loginCount).toBe(2);211-    expect(trashedCount).toBe(1);212-213-    const firstLine = phrase.split("\n")[0];214-    expect(firstLine).toMatch(/^peach:v3:entries=3:zstd:aes256gcm:argon2id$/);215-216-    const { entries: restored, announcedEntryCount, binaryEntryCount } =217-      await importFromCJK(phrase, passphrase);218-219-    expect(announcedEntryCount).toBe(3);220-    expect(binaryEntryCount).toBe(3);221-    expect(restored).toHaveLength(3);222-223-    const active = restored.filter((e) => !e.deletedAt);224-    const trashed = restored.filter((e) => !!e.deletedAt);225-226-    expect(active).toHaveLength(2);227-    expect(trashed).toHaveLength(1);228-    expect(trashed[0].title).toBe("Old Account");229-    expect(trashed[0].deletedAt).toBe(trashedAt);230-  }, 30000);231-232-  it("round-trips 0 entries — phrase encodes and decodes back to an empty vault", async () => {233-    const empty: VaultEntry[] = [];234-    const { phrase } = await exportToCJK({235-      entries: empty,236-      settings: {},237-      passphrase,238-      argon2: FAST_ARGON2,239-    });240-241-    const { entries: restored, announcedEntryCount, binaryEntryCount } =242-      await importFromCJK(phrase, passphrase);243-    expect(announcedEntryCount).toBe(0);244-    expect(binaryEntryCount).toBe(0);245-    expect(restored).toHaveLength(0);246-  }, 30000);247-248-  it("rejects a mismatched ASCII entry count with HEADER_COUNT_MISMATCH", async () => {249-    const { phrase } = await exportToCJK({250-      entries,251-      settings,252-      passphrase,253-      argon2: FAST_ARGON2,254-    });255-    const tampered = phrase.replace(256-      /^peach:v3:entries=3:/,257-      "peach:v3:entries=5:"258-    );259-    await expect(importFromCJK(tampered, passphrase)).rejects.toThrow(260-      /HEADER_COUNT_MISMATCH/261-    );262-  }, 30000);263-264-  it("CJK round-trip preserves card entries with all fields intact", async () => {265-    const cardEntry: VaultEntry = {266-      id: "card-one",267-      type: "card",268-      title: "Personal Visa",269-      cardholderName: "Jane Smith",270-      cardNumber: "4111111111111111",271-      expMonth: "05",272-      expYear: "2028",273-      cvc: "123",274-      cardNickname: "Everyday Card",275-      network: "visa",276-      last4: "1111",277-      createdAt: 1_700_000_005_000,278-      modifiedAt: 1_700_000_005_000,279-    };280-281-    const { phrase } = await exportToCJK({282-      entries: [cardEntry],283-      settings: {},284-      passphrase,285-      argon2: FAST_ARGON2,286-    });287-288-    const { entries: restored } = await importFromCJK(phrase, passphrase);289-    expect(restored).toHaveLength(1);290-291-    const card = restored[0];292-    expect(card.type).toBe("card");293-    expect(card.cardholderName).toBe("Jane Smith");294-    expect(card.cardNumber).toBe("4111111111111111");295-    expect(card.expMonth).toBe("05");296-    expect(card.expYear).toBe("2028");297-    expect(card.cvc).toBe("123");298-    expect(card.cardNickname).toBe("Everyday Card");299-    expect(card.network).toBe("visa");300-    expect(card.last4).toBe("1111");301-  }, 30000);302-});303-304-describe("CJK phrase size sanity (§10.4)", () => {305-  it("a ~100-entry vault stays within reasonable bounds", async () => {306-    const sharedDomains = [307-      "github.com",308-      "gitlab.com",309-      "google.com",310-      "amazon.com",311-      "apple.com",312-    ];313-    const big: VaultEntry[] = [];314-    for (let i = 0; i < 100; i++) {315-      const domain =316-        i < 20 ? sharedDomains[i % sharedDomains.length] : `example${i}.com`;317-      big.push(318-        makeLogin({319-          id: `id-${i}`,320-          title: `Service ${i}`,321-          url: domain,322-          username: `user${i}@acme.com`,323-          password: `password-${i}-with-some-length-1234`,324-          createdAt: 1_700_000_000_000 + i * 1000,325-          modifiedAt: 1_700_000_000_000 + i * 2000,326-        })327-      );328-    }329-330-    const { cjkCharCount } = await exportToCJK({331-      entries: big,332-      settings: { theme: "dark" },333-      passphrase: "correct horse battery staple drift waves",334-      argon2: FAST_ARGON2,335-    });336-337-    // v3's JSON payload is more verbose than the old binary layout, but zstd338-    // level 19 still compresses the repetitive content well. Keep the loose339-    // upper bound; the useful property is "not pathologically large".340-    expect(cjkCharCount).toBeGreaterThan(200);341-    expect(cjkCharCount).toBeLessThan(8000);342-  }, 30000);343-});

view file at the parent

src/crypto/cjkSerialise.test.ts+0 −135
@@ -1,135 +0,0 @@1-/**2- * Vault serialisation round-trip for the full-fidelity v3 payload.3- */4-5-import { describe, expect, it } from "vitest";6-7-import { deserialiseVault, serialiseVault } from "./cjkSerialise";8-import type { VaultEntry } from "../shared/types";9-import type { VaultSettings } from "../background/vault";10-11-function makeLogin(overrides: Partial<VaultEntry> = {}): VaultEntry {12-  return {13-    id: "id-login",14-    type: "login",15-    title: "Untitled",16-    username: "",17-    password: "",18-    url: "",19-    createdAt: 1_700_000_000_000,20-    modifiedAt: 1_700_000_000_000,21-    ...overrides,22-  };23-}24-25-describe("serialiseVault / deserialiseVault (v3 JSON payload)", () => {26-  it("round-trips logins field-by-field", () => {27-    const entries: VaultEntry[] = [28-      makeLogin({29-        id: "one",30-        title: "GitHub",31-        url: "https://github.com",32-        username: "[email protected]",33-        password: "hunter2",34-        totp: "JBSWY3DPEHPK3PXP",35-        notes: "personal",36-        tags: ["work", "critical"],37-      }),38-    ];39-    const settings: VaultSettings = { theme: "dark", autoLockMinutes: 15 };40-41-    const bytes = serialiseVault(entries, settings);42-    const payload = deserialiseVault(bytes);43-44-    expect(payload.v).toBe(3);45-    expect(payload.entries).toHaveLength(1);46-    const [a] = payload.entries;47-    expect(a.id).toBe("one");48-    expect(a.title).toBe("GitHub");49-    expect(a.url).toBe("https://github.com");50-    expect(a.username).toBe("[email protected]");51-    expect(a.password).toBe("hunter2");52-    expect(a.totp).toBe("JBSWY3DPEHPK3PXP");53-    expect(a.notes).toBe("personal");54-    expect(a.tags).toEqual(["work", "critical"]);55-    expect(payload.settings.theme).toBe("dark");56-    expect(payload.settings.autoLockMinutes).toBe(15);57-  });58-59-  it("preserves notes and passkey entries", () => {60-    const entries: VaultEntry[] = [61-      {62-        id: "note-1",63-        type: "note",64-        title: "Safe combo",65-        notes: "left-30-right-12-left-4",66-        createdAt: 1,67-        modifiedAt: 2,68-      },69-      {70-        id: "passkey-1",71-        type: "passkey",72-        title: "example.com",73-        rpId: "example.com",74-        rpName: "Example",75-        credentialId: "cred-abc",76-        publicKeyCose: "cose-bytes-b64",77-        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },78-        userHandle: "user-handle",79-        userName: "june",80-        userDisplayName: "June",81-        algorithm: -7,82-        signCount: 0,83-        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",84-        backupEligible: true,85-        backupState: false,86-        createdAt: 3,87-        modifiedAt: 4,88-      },89-    ];90-91-    const bytes = serialiseVault(entries, {});92-    const payload = deserialiseVault(bytes);93-94-    expect(payload.entries).toHaveLength(2);95-    const note = payload.entries.find((e) => e.type === "note")!;96-    expect(note.notes).toBe("left-30-right-12-left-4");97-98-    const passkey = payload.entries.find((e) => e.type === "passkey")!;99-    expect(passkey.rpId).toBe("example.com");100-    expect(passkey.credentialId).toBe("cred-abc");101-    expect(passkey.privateKeyJwk?.kty).toBe("EC");102-    expect(passkey.algorithm).toBe(-7);103-  });104-105-  it("preserves canary and breach data", () => {106-    const entries: VaultEntry[] = [107-      makeLogin({108-        id: "c1",109-        title: "honey",110-        password: "trap",111-        url: "honey.example",112-        canary: { webhookUrl: "https://hook.example/x", label: "AWS decoy", pingCount: 3 },113-        breach: 42,114-      }),115-    ];116-    const bytes = serialiseVault(entries, {});117-    const payload = deserialiseVault(bytes);118-    expect(payload.entries[0].canary?.webhookUrl).toBe("https://hook.example/x");119-    expect(payload.entries[0].canary?.label).toBe("AWS decoy");120-    expect(payload.entries[0].canary?.pingCount).toBe(3);121-    expect(payload.entries[0].breach).toBe(42);122-  });123-124-  it("throws DESERIALISE_MALFORMED on garbage bytes", () => {125-    const bytes = new TextEncoder().encode("not-json");126-    expect(() => deserialiseVault(bytes)).toThrow(/DESERIALISE_MALFORMED/);127-  });128-129-  it("throws DESERIALISE_MALFORMED on wrong version", () => {130-    const bytes = new TextEncoder().encode(131-      JSON.stringify({ v: 2, entries: [], settings: {} })132-    );133-    expect(() => deserialiseVault(bytes)).toThrow(/DESERIALISE_MALFORMED/);134-  });135-});

view file at the parent

src/crypto/cjkSerialise.ts+0 −104
@@ -1,104 +0,0 @@1-/**2- * Full-fidelity vault serialisation for the CJK phrase backup (v3).3- *4- * v3 breaks with the earlier custom binary layout: the payload is now a5- * UTF-8 JSON document describing the complete vault — every entry with6- * every field the in-memory `VaultEntry` type carries, plus the user's7- * `VaultSettings`. The JSON is compressed with zstd (same as before) and8- * encrypted with AES-256-GCM (same as before), so on-the-wire size stays9- * competitive even though the plaintext is more verbose.10- *11- * Why the change: a CJK backup must be a *complete* Peach export. The12- * previous binary format silently dropped passkeys, canary webhooks,13- * tags, notes-typed entries, and all user settings. zstd on14- * well-structured JSON gets most of the compression back; correctness15- * and completeness are worth the extra kilobytes.16- */17-18-import type { PeachLicenseExport, VaultEntry } from "../shared/types";19-import type { VaultSettings } from "../background/vault";20-21-/**22- * The top-level shape that lives inside the encrypted+compressed CJK23- * payload. `v: 3` is the format version — unrelated to `PAYLOAD_VERSION`24- * (the byte in the binary header), though they happen to match.25- */26-export interface VaultBackupPayload {27-  v: 3;28-  entries: VaultEntry[];29-  settings: VaultSettings;30-  license?: PeachLicenseExport;31-}32-33-export type { PeachLicenseExport };34-35-/**36- * Serialise the live vault (excluding tombstones — the caller filters) and37- * the current user settings into the bytes that will be zstd-compressed38- * and encrypted.39- */40-export function serialiseVault(41-  entries: VaultEntry[],42-  settings: VaultSettings,43-  license?: PeachLicenseExport44-): Uint8Array {45-  const payload: VaultBackupPayload = {46-    v: 3,47-    entries: entries.map(stripEphemeral),48-    settings: { ...settings },49-    ...(license ? { license } : {}),50-  };51-  return new TextEncoder().encode(JSON.stringify(payload));52-}53-54-/**55- * Decode the bytes from a successful decrypt+decompress into a typed56- * payload. Throws `DESERIALISE_MALFORMED` for anything that isn't JSON57- * with the expected shape.58- */59-export function deserialiseVault(bytes: Uint8Array): VaultBackupPayload {60-  let parsed: unknown;61-  try {62-    parsed = JSON.parse(new TextDecoder().decode(bytes));63-  } catch {64-    throw new Error("DESERIALISE_MALFORMED");65-  }66-67-  if (!parsed || typeof parsed !== "object") {68-    throw new Error("DESERIALISE_MALFORMED");69-  }70-71-  const candidate = parsed as Partial<VaultBackupPayload>;72-  if (candidate.v !== 3 || !Array.isArray(candidate.entries)) {73-    throw new Error("DESERIALISE_MALFORMED");74-  }75-76-  const entries = candidate.entries.filter((e): e is VaultEntry =>77-    Boolean(e && typeof e === "object" && typeof (e as VaultEntry).id === "string")78-  );79-80-  const settings =81-    candidate.settings && typeof candidate.settings === "object"82-      ? (candidate.settings as VaultSettings)83-      : {};84-85-  const license =86-    candidate.license &&87-    typeof candidate.license === "object" &&88-    typeof (candidate.license as PeachLicenseExport).key === "string"89-      ? (candidate.license as PeachLicenseExport)90-      : undefined;91-92-  return { v: 3, entries, settings, ...(license ? { license } : {}) };93-}94-95-/**96- * Strip fields that are ephemeral (derived from runtime state, not97- * authoritative) before writing them into a backup. Currently no fields98- * are stripped — `breach` counts, canary `lastPinged`/`pingCount`, and99- * `lastUsedAt` on passkeys are genuine vault data the user may want to100- * preserve across a round-trip. Kept as a seam for future removals.101- */102-function stripEphemeral(entry: VaultEntry): VaultEntry {103-  return entry;104-}

view file at the parent

src/popup/App.tsx+1 −18
@@ -24,7 +24,6 @@2424 2525 const ImportView    = lazy(() => import("./views/Import").then(m => ({ default: m.ImportView })));2626 const ExportView    = lazy(() => import("./views/Export").then(m => ({ default: m.ExportView })));27-const CJKImport     = lazy(() => import("./views/CJKImport").then(m => ({ default: m.CJKImportView })));2827 const SettingsView  = lazy(() => import("./views/Settings").then(m => ({ default: m.SettingsView })));2928 const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));3029 const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));@@ -183,7 +182,7 @@183182   const [shortcutBindings, setShortcutBindings] = createSignal<Record<ShortcutAction, Keybinding>>(DEFAULT_BINDINGS);184183   const [showShortcutsHelp, setShowShortcutsHelp] = createSignal(false);185184   const [trashRetentionDays, setTrashRetentionDays] = createSignal(30);186-  const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null);185+  const [restoreView, setRestoreView] = createSignal<"file" | null>(null);187186   const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);188187   const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");189188   // Cached Pro status — synchronous for render paths. Background's@@ -397,7 +396,6 @@397396       import("./views/Export").catch(() => {}),398397       import("./views/Health").catch(() => {}),399398       import("./views/TrashView").catch(() => {}),400-      import("./views/CJKImport").catch(() => {}),401399     ]);402400 403401     // Lock-on-close via a persistent port. sendMessage in pagehide is@@ -777,10 +775,6 @@777775     setView("health");778776   }779777 780-  function handleOpenCJKImport() {781-    setRestoreView("cjk");782-  }783-784778   function handleOpenFileImport() {785779     setRestoreView("file");786780   }@@ -1040,7 +1034,6 @@10401034                   onExport={() => setView("export")}10411035                   onBreachCheckDone={loadEntries}10421036                   onOpenHealth={openHealth}1043-                  onOpenCJKImport={handleOpenCJKImport}10441037                   onOpenFileImport={handleOpenFileImport}10451038                 />10461039               </Suspense>@@ -1081,16 +1074,6 @@10811074                   onRestored={() => void loadEntries()}10821075                 />10831076               </Suspense>1084-            </Show>1085-            <Show when={restoreView() === "cjk"}>1086-              <CJKImport1087-                mode="onboarding"1088-                onBack={() => setRestoreView(null)}1089-                onDone={async () => {1090-                  setRestoreView(null);1091-                  await handleUnlock();1092-                }}1093-              />10941077             </Show>10951078             <Show when={restoreView() === "file"}>10961079               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>

view file

src/popup/styles/popup.css+4 −112
@@ -1162,26 +1162,6 @@11621162   letter-spacing: 0.04em;11631163 }11641164 1165-.peach-phrase-textarea {1166-  width: 100%;1167-  box-sizing: border-box;1168-  min-height: 120px;1169-  padding: 10px 12px;1170-  background: var(--peach-s2);1171-  border: 1px solid var(--peach-t3);1172-  border-radius: 6px;1173-  color: var(--peach-t0);1174-  font-size: 12px;1175-  font-family: "Noto Sans CJK SC", "PingFang SC", monospace;1176-  line-height: 1.5;1177-  resize: vertical;1178-  outline: none;1179-}1180-1181-.peach-phrase-textarea:focus {1182-  border-color: var(--peach-accent);1183-}1184-11851165 .peach-hidden-file-input {11861166   display: none;11871167 }@@ -1200,26 +1180,6 @@12001180   margin-top: 1px;12011181 }12021182 1203-.peach-cjk-phrase-box {1204-  font-family: "Noto Sans CJK SC", "PingFang SC", "Hiragino Sans GB", sans-serif;1205-  font-size: 13px;1206-  line-height: 1.5;1207-  background: var(--peach-s2);1208-  border: 1px solid var(--peach-t3);1209-  border-radius: 6px;1210-  padding: 10px 12px;1211-  margin: 0 0 10px;1212-  word-break: break-all;1213-  white-space: pre-wrap;1214-  max-height: 220px;1215-  overflow-y: auto;1216-  color: var(--peach-t0);1217-  user-select: all;1218-  scrollbar-width: none;1219-  -ms-overflow-style: none;1220-}1221-.peach-cjk-phrase-box::-webkit-scrollbar { display: none; }1222-12231183 .peach-danger-notice {12241184   background: rgba(220, 80, 60, 0.08);12251185   border: 1px solid rgba(220, 80, 60, 0.2);@@ -1619,22 +1579,6 @@16191579   font-size: 12px;16201580   line-height: 1.45;16211581 }1622-.peach-legacy-cjk-summary {1623-  cursor: pointer;1624-  color: var(--peach-t2);1625-  font-family: "IBM Plex Mono", monospace;1626-  font-size: 10px;1627-  text-transform: uppercase;1628-  letter-spacing: 0.14em;1629-  padding: 6px 0;1630-}1631-.peach-legacy-cjk-note {1632-  color: var(--peach-t2);1633-  font-size: 11px;1634-  line-height: 1.5;1635-  margin: 6px 0 0;1636-}1637-16381582 .peach-export-card--primary {16391583   background:16401584     linear-gradient(180deg, color-mix(in srgb, var(--peach-accent) 8%, transparent), color-mix(in srgb, var(--peach-accent) 2%, transparent));@@ -3807,63 +3751,11 @@38073751   line-height: 1.55;38083752 }38093753 3810-.peach-cjk-qr-panel {3811-  margin-top: 14px;3812-  padding: 12px;3813-  border-radius: 16px;3814-  border: 1px solid var(--peach-t3);3815-  background: rgba(240, 232, 216, 0.02);3816-  display: grid;3817-  gap: 10px;3818-}3819-3820-.peach-cjk-qr-frame {3821-  display: grid;3822-  place-items: center;3823-  min-height: 212px;3824-  border-radius: 14px;3825-  background: #fff;3826-  overflow: hidden;3827-}3828-3829-.peach-cjk-qr-frame svg {3830-  display: block;3831-  width: 196px;3832-  height: 196px;3833-}3834-3835-.peach-cjk-qr-error,3836-.peach-cjk-qr-caption {3837-  color: var(--peach-t1);3838-  font-size: 11px;3839-  line-height: 1.55;3840-}3841-3842-.peach-cjk-qr-card {3754+.peach-passkey-card {38433755   max-width: 400px;38443756   margin: auto;38453757 }38463758 3847-.peach-cjk-qr-body {3848-  padding: 0 20px 20px;3849-  text-align: center;3850-}3851-3852-.peach-cjk-qr-hint {3853-  font-size: 11px;3854-  color: var(--peach-t2);3855-  margin: 0 0 14px;3856-}3857-3858-.peach-cjk-qr-image {3859-  border-radius: 4px;3860-  display: block;3861-  margin: 0 auto;3862-  width: 100%;3863-  max-width: 320px;3864-  height: auto;3865-}3866-38673759 .peach-pro-badge {38683760   padding: 2px 6px;38693761   border-radius: 999px;@@ -4786,9 +4678,9 @@47864678   to   { opacity: 1; transform: translateX(0) scale(1); }47874679 }47884680 4789-/* ── Onboarding-mode restore screens (CJK phrase / file import) ── */4790-/* Used when CJKImportView or MainImportView renders during onboarding4791-   (no vault yet). Animates like a lock-screen transition. */4681+/* ── Onboarding-mode restore screens (file import) ── */4682+/* Used when MainImportView renders during onboarding (no vault yet).4683+   Animates like a lock-screen transition. */47924684 .peach-lock-view {47934685   position: absolute;47944686   inset: 0;

view file

src/popup/views/CJKImport.tsx+0 −802
@@ -1,802 +0,0 @@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- */27-28-import { createMemo, createSignal, onMount, Show } from "solid-js";29-30-import {31-  decodePhraseToBytes,32-  decryptAndDeserialise,33-  parseHeaderLine,34-  splitPhrase,35-  validatePhraseStructure,36-  verifyIntegrity,37-} from "../../crypto/cjkImport";38-import type { VaultBackupPayload } from "../../crypto/cjkSerialise";39-import type { ExtractedFields } from "../../crypto/cjkPayload";40-import type { VaultEntry } from "../../shared/types";41-42-import { sendMessage } from "../App";43-import { PeachWordmark } from "../components/PeachMark";44-45-// ---------------------------------------------------------------------------46-// Error code → user-facing copy (spec §9)47-// ---------------------------------------------------------------------------48-49-const 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-};79-80-function 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-}87-88-// ---------------------------------------------------------------------------89-// Component90-// ---------------------------------------------------------------------------91-92-type Screen =93-  | "paste"94-  | "passphrase"95-  | "onboarding-master"96-  | "restoring"97-  | "merge"98-  | "success";99-100-type ProgressStage =101-  | "idle"102-  | "decoding"103-  | "verifying"104-  | "deriving"105-  | "decrypting"106-  | "importing";107-108-const 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-};116-117-export type CJKImportMode = "existing" | "onboarding";118-119-interface 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-}126-127-export function CJKImportView(props: CJKImportProps) {128-  const [screen, setScreen] = createSignal<Screen>("paste");129-  const [existingEntryCount, setExistingEntryCount] = createSignal(0);130-131-  // 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-  });140-141-  const [phrase, setPhrase] = createSignal("");142-  const [passphrase, setPassphrase] = createSignal("");143-  const [masterPassword, setMasterPassword] = createSignal("");144-  const [masterConfirm, setMasterConfirm] = createSignal("");145-146-  // Populated once the phrase decodes + checksum verifies.147-  const [verifiedFields, setVerifiedFields] = createSignal<ExtractedFields | null>(null);148-  const [announcedEntryCount, setAnnouncedEntryCount] = createSignal(0);149-150-  // Populated after decryption — held in memory briefly while the merge151-  // screen is on-screen.152-  const [restoredPayload, setRestoredPayload] = createSignal<VaultBackupPayload | null>(null);153-154-  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);159-160-  // -------------------------------------------------------------------------161-  // Screen 1: paste + structural validation162-  // -------------------------------------------------------------------------163-164-  const structureOk = createMemo(() =>165-    phrase().trim().length > 0 ? validatePhraseStructure(phrase()) : null166-  );167-168-  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-  });178-179-  async function handlePhraseInput(value: string) {180-    setPhrase(value);181-    setPasteError(null);182-  }183-184-  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-  }195-196-  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-  }207-208-  /**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-    }219-220-    try {221-      setProgress("decoding");222-      const { headerLine, cjkContent } = splitPhrase(phrase());223-      const header = parseHeaderLine(headerLine);224-      setAnnouncedEntryCount(header.entryCount);225-226-      const binary = decodePhraseToBytes(cjkContent);227-228-      setProgress("verifying");229-      const fields = await verifyIntegrity(binary);230-231-      if (header.entryCount !== fields.entryCount) {232-        throw new Error("HEADER_COUNT_MISMATCH");233-      }234-235-      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-  }247-248-  // -------------------------------------------------------------------------249-  // Screen 1.5 (onboarding only): master password for the new vault250-  // -------------------------------------------------------------------------251-252-  const masterOk = createMemo(253-    () =>254-      masterPassword().length >= 8 && masterPassword() === masterConfirm()255-  );256-257-  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-  }269-270-  // -------------------------------------------------------------------------271-  // Screen 2: passphrase + decrypt272-  // -------------------------------------------------------------------------273-274-  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-    }286-287-    setScreen("restoring");288-289-    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));293-294-      const payload = await decryptAndDeserialise(fields, passphrase());295-      setProgress("decrypting");296-      await new Promise((r) => setTimeout(r, 0));297-298-      if (payload.entries.length !== fields.entryCount) {299-        throw new Error("ENTRY_COUNT_MISMATCH");300-      }301-302-      setRestoredPayload(payload);303-      setProgress("importing");304-305-      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-      }319-320-      // 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-  }347-348-  // -------------------------------------------------------------------------349-  // Screen 4 (existing mode): merge / replace / cancel350-  // -------------------------------------------------------------------------351-352-  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-  }360-361-  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-  }369-370-  function handleCancel() {371-    setRestoredPayload(null);372-    setPassphrase("");373-    props.onBack();374-  }375-376-  // -------------------------------------------------------------------------377-  // Pipeline helpers378-  // -------------------------------------------------------------------------379-380-  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-392-    // 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-  }398-399-  async function finishSuccess(count: number) {400-    setImportedCount(count);401-    setRestoredPayload(null);402-    setPassphrase("");403-    setMasterPassword("");404-    setMasterConfirm("");405-    setProgress("idle");406-    setScreen("success");407-  }408-409-  async function handleOpenVault() {410-    await props.onDone();411-  }412-413-  // -------------------------------------------------------------------------414-  // Render415-  // -------------------------------------------------------------------------416-417-  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>458-459-      <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>472-473-        <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>484-485-        <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>494-495-        <Show when={screen() === "restoring"}>496-          <RestoringScreen stage={progress()} />497-        </Show>498-499-        <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>508-509-        <Show when={screen() === "success"}>510-          <SuccessScreen511-            count={importedCount()}512-            onOpen={() => void handleOpenVault()}513-          />514-        </Show>515-      </div>516-    </div>517-  );518-}519-520-// ---------------------------------------------------------------------------521-// Screen 1: paste522-// ---------------------------------------------------------------------------523-524-interface 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-}534-535-function 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>542-543-      <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-      />551-552-      <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>569-570-      <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>575-576-      <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>586-587-      <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-}599-600-// ---------------------------------------------------------------------------601-// Screen 1.5 (onboarding): set new master password602-// ---------------------------------------------------------------------------603-604-interface 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-}613-614-function 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>622-623-      <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-      />637-638-      <Show when={props.error}>639-        <div class="peach-inline-feedback peach-inline-feedback--error">640-          {props.error}641-        </div>642-      </Show>643-644-      <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-}656-657-// ---------------------------------------------------------------------------658-// Screen 2: export passphrase659-// ---------------------------------------------------------------------------660-661-interface PassphraseScreenProps {662-  passphrase: string;663-  onPassphraseInput: (value: string) => void;664-  error: string | null;665-  entryCount: number;666-  onRestore: () => void;667-}668-669-function 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>678-679-      <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-      />690-691-      <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>694-695-      <Show when={props.error}>696-        <div class="peach-inline-feedback peach-inline-feedback--error">697-          {props.error}698-        </div>699-      </Show>700-701-      <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-}713-714-// ---------------------------------------------------------------------------715-// Screen 3: restoring716-// ---------------------------------------------------------------------------717-718-interface RestoringScreenProps {719-  stage: ProgressStage;720-}721-722-function 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-}731-732-// ---------------------------------------------------------------------------733-// Screen 4: merge choice (existing vaults only)734-// ---------------------------------------------------------------------------735-736-interface MergeScreenProps {737-  currentCount: number;738-  restoredCount: number;739-  onReplace: () => void;740-  onMerge: () => void;741-  onCancel: () => void;742-}743-744-function 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>754-755-      <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-}779-780-// ---------------------------------------------------------------------------781-// Screen 5: success782-// ---------------------------------------------------------------------------783-784-interface SuccessScreenProps {785-  count: number;786-  onOpen: () => void;787-}788-789-function 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-}

view file at the parent

src/popup/views/Import.tsx+7 −47
@@ -15,7 +15,6 @@1515 } from "../../crypto/peachScript";1616 import { parsePayload } from "../../crypto/peachPayload";1717 import type { VaultEntry } from "../../shared/types";18-import { CJKImportView } from "./CJKImport";1918 2019 interface ImportProps {2120   mode?: "existing" | "onboarding";@@ -27,33 +26,17 @@2726 type ImportMergeMode = "add" | "replace";2827 2928 export function ImportView(props: ImportProps) {30-  const [showCjkImport, setShowCjkImport] = createSignal(false);3129   return (32-    <Show33-      when={showCjkImport()}34-      fallback={35-        <MainImportView36-          mode={props.mode}37-          replacesExistingVault={props.replacesExistingVault}38-          onBack={props.onBack}39-          onImport={props.onImport}40-          onOpenCjk={() => setShowCjkImport(true)}41-        />42-      }43-    >44-      <CJKImportView45-        mode="existing"46-        onBack={() => setShowCjkImport(false)}47-        onDone={async () => {48-          setShowCjkImport(false);49-          await props.onImport();50-        }}51-      />52-    </Show>30+    <MainImportView31+      mode={props.mode}32+      replacesExistingVault={props.replacesExistingVault}33+      onBack={props.onBack}34+      onImport={props.onImport}35+    />5336   );5437 }5538 56-function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {39+function MainImportView(props: ImportProps) {5740   const mode = () => props.mode ?? "existing";5841 5942   // PeachScript paste flow@@ -646,29 +629,6 @@646629           </div>647630         </Show>648631 649-        {/* Legacy CJK — collapsed, secondary */}650-        <Show when={mode() === "existing"}>651-          <div class="peach-settings-group">652-            <details>653-              <summary class="peach-legacy-cjk-summary">654-                Have an old CJK phrase backup?655-              </summary>656-              <p class="peach-legacy-cjk-note">657-                CJK phrase backups from earlier versions of Peach are still658-                supported. After restoring, we recommend creating a fresh659-                PeachScript or QR backup.660-              </p>661-              <div class="peach-settings-actions" style="margin-top:6px">662-                <button663-                  class="peach-btn peach-btn--ghost peach-btn--sm"664-                  onClick={props.onOpenCjk}665-                >666-                  Restore from CJK phrase667-                </button>668-              </div>669-            </details>670-          </div>671-        </Show>672632       </div>673633     </div>674634   );

view file

src/popup/views/Lock.tsx+2 −24
@@ -2,7 +2,6 @@22 33 import { sendMessage } from "../App";44 import { PeachMark, PeachWordmark } from "../components/PeachMark";5-import { CJKImportView } from "./CJKImport";65 import { ImportView } from "./Import";76 87 // Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation@@ -21,8 +20,8 @@2120   onUnlock: () => void | Promise<void>;2221 }2322 24-// "restore" = picker, "snapshots" = vault history, "cjk" / "file" = detail flows25-type RestoreSurface = "restore" | "snapshots" | "cjk" | "file" | null;23+// "restore" = picker, "snapshots" = vault history, "file" = detail flow24+type RestoreSurface = "restore" | "snapshots" | "file" | null;2625 2726 export function LockView(props: LockProps) {2827   const [password, setPassword] = createSignal("");@@ -135,7 +134,6 @@135134       <Match when={restoreSurface() === "restore"}>136135         <RestorePickerView137136           onBack={() => setRestoreSurface(null)}138-          onPickCjk={() => setRestoreSurface("cjk")}139137           onPickFile={() => setRestoreSurface("file")}140138           onPickSnapshots={() => setRestoreSurface("snapshots")}141139         />@@ -149,18 +147,6 @@149147         />150148       </Match>151149 152-      {/* ── CJK restore ───────────────────────────────────────────── */}153-      <Match when={restoreSurface() === "cjk"}>154-        <CJKImportView155-          mode="onboarding"156-          onBack={() => setRestoreSurface("restore")}157-          onDone={async () => {158-            setRestoreSurface(null);159-            await props.onUnlock();160-          }}161-        />162-      </Match>163-164150       {/* ── File restore ──────────────────────────────────────────── */}165151       <Match when={restoreSurface() === "file"}>166152         <ImportView@@ -368,7 +354,6 @@368354 369355 function RestorePickerView(props: {370356   onBack: () => void;371-  onPickCjk: () => void;372357   onPickFile: () => void;373358   onPickSnapshots: () => void;374359 }) {@@ -395,13 +380,6 @@395380 396381       <section class="peach-lock-panel peach-lock-panel--restore">397382         <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>398-399-        <button class="peach-lock-option" onClick={props.onPickCjk}>400-          <span class="peach-lock-option-title">backup phrase</span>401-          <span class="peach-lock-option-copy">402-            A block of Chinese characters exported directly from Peach. You'll need the export passphrase you set at the time.403-          </span>404-        </button>405383 406384         <button class="peach-lock-option" onClick={props.onPickFile}>407385           <span class="peach-lock-option-title">backup file</span>

view file

src/popup/views/PasskeyApproval.tsx+1 −1
@@ -13,7 +13,7 @@1313 export function PasskeyApprovalView(props: PasskeyApprovalProps) {1414   return (1515     <div class="peach-overlay-view ov-enter-up" role="dialog" aria-modal="true">16-      <div class="peach-overlay-card peach-cjk-qr-card">16+      <div class="peach-overlay-card peach-passkey-card">1717         <div class="peach-passkey-approval">1818           <div class="peach-passkey-approval-icon" aria-hidden="true">1919             <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">

view file

src/popup/views/Settings.tsx+0 −1
@@ -52,7 +52,6 @@5252   onBreachCheckDone: () => void;5353   onOpenHealth?: () => void;5454   onOpenSyncPairing?: (mode: "initiate" | "accept") => void;55-  onOpenCJKImport?: () => void;5655   onOpenFileImport?: () => void;5756 }5857 

view file

src/popup/views/contracts.test.tsx+0 −4
@@ -46,10 +46,6 @@4646   GeneratorDrawer: () => null,4747 }));4848 49-vi.mock("./CJKImport", () => ({50-  CJKImportView: () => null,51-}));52-5349 vi.mock("zxcvbn", () => ({5450   default: (value: string) => zxcvbnMock(value),5551 }));

view file

src/shared/types.ts+1 −1
@@ -232,7 +232,7 @@232232   | "ios_app";233233 234234 /**235- * License export shape included in CJK phrase and encrypted JSON backups.235+ * License export shape included in PeachScript and encrypted JSON backups.236236  * `celebrationPlayed` is intentionally absent — a new device deserves the moment.237237  */238238 export interface PeachLicenseExport {

view file