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
↓ peach-browser-9d435183.tar.gzatlas at this save
files changed22 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 −1manifest.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": {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",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": {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[],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-});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-}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 };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-};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-});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-}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-});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-});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-}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>}>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;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-}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 );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>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">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 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 }));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 {