/** * Full export → import round-trip (spec §10.3) and size sanity (§10.4). * * These are integration tests: they run the real Argon2id KDF, AES-256-GCM, * zstd, and BLAKE3, exercising the entire pipeline as the UI would. * * We use the lowest Argon2 parameters the importer will accept (1 MB / 1 * iter / 1 lane) to keep the test suite fast. Production code uses * ARGON2_DEFAULT or ARGON2_HIGH_SECURITY — both are well above these floors. */ import { describe, expect, it } from "vitest"; import { exportToCJK, type Argon2Params } from "./cjkExport"; import { importFromCJK } from "./cjkImport"; import { ALPHABET_END, ALPHABET_START } from "./cjk"; import type { VaultEntry } from "../shared/types"; import type { VaultSettings } from "../background/vault"; const FAST_ARGON2: Argon2Params = { memoryKiB: 1024, iterations: 1, parallelism: 1, }; function makeLogin(overrides: Partial = {}): VaultEntry { return { id: "id-placeholder", type: "login", title: "Untitled", username: "", password: "", url: "", createdAt: 1_700_000_000_000, modifiedAt: 1_700_000_000_000, ...overrides, }; } function isCjkChar(ch: string): boolean { const cp = ch.codePointAt(0)!; return cp >= ALPHABET_START && cp <= ALPHABET_END; } function stripHeaderAndWhitespace(phrase: string): string { const firstNewline = phrase.indexOf("\n"); return phrase.slice(firstNewline + 1).replace(/\s+/g, ""); } describe("CJK export/import round-trip (§10.3)", () => { const passphrase = "correct horse battery staple drift waves"; const entries: VaultEntry[] = [ makeLogin({ id: "one", title: "GitHub", url: "github.com", username: "june@example.com", password: "hunter2", totp: "JBSWY3DPEHPK3PXP", createdAt: 1_700_000_000_000, modifiedAt: 1_700_000_000_500, canary: { webhookUrl: "https://hook.example/x", label: "GitHub decoy" }, }), makeLogin({ id: "two", title: "Spotify", url: "spotify.com", username: "june", password: "s3cret-pw!", notes: "family plan admin", tags: ["music"], createdAt: 1_700_000_001_000, modifiedAt: 1_700_000_002_000, }), { id: "note-one", type: "note", title: "Recovery codes", notes: "a1b2-c3d4-e5f6", createdAt: 1_700_000_003_000, modifiedAt: 1_700_000_003_000, }, ]; const settings: VaultSettings = { theme: "dark", autoLockMinutes: 15, breachMonitoring: true, }; it("produces a v3 phrase with the announced entry count", async () => { const result = await exportToCJK({ entries, settings, passphrase, argon2: FAST_ARGON2, }); const firstLine = result.phrase.split("\n")[0]; expect(firstLine).toMatch(/^peach:v3:entries=3:zstd:aes256gcm:argon2id$/); expect(result.entryCount).toBe(3); expect(result.loginCount).toBe(2); expect(result.noteCount).toBe(1); }, 15000); it("emits only CJK characters in the body", async () => { const { phrase } = await exportToCJK({ entries, settings, passphrase, argon2: FAST_ARGON2, }); const body = stripHeaderAndWhitespace(phrase); expect(body.length).toBeGreaterThan(0); for (const ch of body) { expect(isCjkChar(ch)).toBe(true); } }, 15000); it("round-trips all entry fields including notes, tags, canary, and settings", async () => { const { phrase } = await exportToCJK({ entries, settings, passphrase, argon2: FAST_ARGON2, }); const { entries: restored, settings: restoredSettings, announcedEntryCount, binaryEntryCount } = await importFromCJK(phrase, passphrase); expect(announcedEntryCount).toBe(3); expect(binaryEntryCount).toBe(3); expect(restored).toHaveLength(3); const github = restored.find((e) => e.title === "GitHub")!; expect(github.url).toBe("github.com"); expect(github.username).toBe("june@example.com"); expect(github.password).toBe("hunter2"); expect(github.totp).toBe("JBSWY3DPEHPK3PXP"); expect(github.canary?.webhookUrl).toBe("https://hook.example/x"); expect(github.canary?.label).toBe("GitHub decoy"); expect(github.createdAt).toBe(1_700_000_000_000); expect(github.modifiedAt).toBe(1_700_000_000_500); const spotify = restored.find((e) => e.title === "Spotify")!; expect(spotify.notes).toBe("family plan admin"); expect(spotify.tags).toEqual(["music"]); const note = restored.find((e) => e.type === "note")!; expect(note.title).toBe("Recovery codes"); expect(note.notes).toBe("a1b2-c3d4-e5f6"); expect(restoredSettings.theme).toBe("dark"); expect(restoredSettings.autoLockMinutes).toBe(15); expect(restoredSettings.breachMonitoring).toBe(true); }, 30000); it("rejects the wrong passphrase with DECRYPT_FAILED", async () => { const { phrase } = await exportToCJK({ entries, settings, passphrase, argon2: FAST_ARGON2, }); await expect( importFromCJK(phrase, "definitely-not-the-passphrase") ).rejects.toThrow(/DECRYPT_FAILED/); }, 30000); it("rejects a tampered phrase with CHECKSUM_FAILED", async () => { const { phrase } = await exportToCJK({ entries, settings, passphrase, argon2: FAST_ARGON2, }); const lines = phrase.split("\n"); const lastIdx = lines.length - 1; const lastLine = lines[lastIdx]; const lastChar = lastLine[lastLine.length - 1]; const cp = lastChar.codePointAt(0)!; const swappedCp = cp === ALPHABET_START ? ALPHABET_START + 1 : ALPHABET_START; const swappedChar = String.fromCodePoint(swappedCp); lines[lastIdx] = lastLine.slice(0, -1) + swappedChar; const tampered = lines.join("\n"); await expect(importFromCJK(tampered, passphrase)).rejects.toThrow( /CHECKSUM_FAILED/ ); }, 30000); it("round-trips 0 entries — phrase encodes and decodes back to an empty vault", async () => { const empty: VaultEntry[] = []; const { phrase } = await exportToCJK({ entries: empty, settings: {}, passphrase, argon2: FAST_ARGON2, }); const { entries: restored, announcedEntryCount, binaryEntryCount } = await importFromCJK(phrase, passphrase); expect(announcedEntryCount).toBe(0); expect(binaryEntryCount).toBe(0); expect(restored).toHaveLength(0); }, 30000); it("rejects a mismatched ASCII entry count with HEADER_COUNT_MISMATCH", async () => { const { phrase } = await exportToCJK({ entries, settings, passphrase, argon2: FAST_ARGON2, }); const tampered = phrase.replace( /^peach:v3:entries=3:/, "peach:v3:entries=5:" ); await expect(importFromCJK(tampered, passphrase)).rejects.toThrow( /HEADER_COUNT_MISMATCH/ ); }, 30000); }); describe("CJK phrase size sanity (§10.4)", () => { it("a ~100-entry vault stays within reasonable bounds", async () => { const sharedDomains = [ "github.com", "gitlab.com", "google.com", "amazon.com", "apple.com", ]; const big: VaultEntry[] = []; for (let i = 0; i < 100; i++) { const domain = i < 20 ? sharedDomains[i % sharedDomains.length] : `example${i}.com`; big.push( makeLogin({ id: `id-${i}`, title: `Service ${i}`, url: domain, username: `user${i}@acme.com`, password: `password-${i}-with-some-length-1234`, createdAt: 1_700_000_000_000 + i * 1000, modifiedAt: 1_700_000_000_000 + i * 2000, }) ); } const { cjkCharCount } = await exportToCJK({ entries: big, settings: { theme: "dark" }, passphrase: "correct horse battery staple drift waves", argon2: FAST_ARGON2, }); // v3's JSON payload is more verbose than the old binary layout, but zstd // level 19 still compresses the repetitive content well. Keep the loose // upper bound; the useful property is "not pathologically large". expect(cjkCharCount).toBeGreaterThan(200); expect(cjkCharCount).toBeLessThan(8000); }, 30000); });