123456789101112import { describe, expect, it } from "vitest";1314import { exportToCJK, type Argon2Params } from "./cjkExport";15import { importFromCJK } from "./cjkImport";16import { ALPHABET_END, ALPHABET_START } from "./cjk";17import type { VaultEntry } from "../shared/types";18import type { VaultSettings } from "../background/vault";1920const FAST_ARGON2: Argon2Params = {21 memoryKiB: 1024,22 iterations: 1,23 parallelism: 1,24};2526function 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}3940function isCjkChar(ch: string): boolean {41 const cp = ch.codePointAt(0)!;42 return cp >= ALPHABET_START && cp <= ALPHABET_END;43}4445function stripHeaderAndWhitespace(phrase: string): string {46 const firstNewline = phrase.indexOf("\n");47 return phrase.slice(firstNewline + 1).replace(/\s+/g, "");48}4950describe("CJK export/import round-trip (§10.3)", () => {51 const passphrase = "correct horse battery staple drift waves";5253 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 ];8586 const settings: VaultSettings = {87 theme: "dark",88 autoLockMinutes: 15,89 breachMonitoring: true,90 };9192 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 });99100 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);106107 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);120121 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);130131 expect(announcedEntryCount).toBe(3);132 expect(binaryEntryCount).toBe(3);133 expect(restored).toHaveLength(3);134135 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);144145 const spotify = restored.find((e) => e.title === "Spotify")!;146 expect(spotify.notes).toBe("family plan admin");147 expect(spotify.tags).toEqual(["music"]);148149 const note = restored.find((e) => e.type === "note")!;150 expect(note.title).toBe("Recovery codes");151 expect(note.notes).toBe("a1b2-c3d4-e5f6");152153 expect(restoredSettings.theme).toBe("dark");154 expect(restoredSettings.autoLockMinutes).toBe(15);155 expect(restoredSettings.breachMonitoring).toBe(true);156 }, 30000);157158 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);169170 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 });177178 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");188189 await expect(importFromCJK(tampered, passphrase)).rejects.toThrow(190 /CHECKSUM_FAILED/191 );192 }, 30000);193194 it("round-trips 0 entries — phrase encodes and decodes back to an empty vault", async () => {195 const empty: VaultEntry[] = [];196 const { phrase } = await exportToCJK({197 entries: empty,198 settings: {},199 passphrase,200 argon2: FAST_ARGON2,201 });202203 const { entries: restored, announcedEntryCount, binaryEntryCount } =204 await importFromCJK(phrase, passphrase);205 expect(announcedEntryCount).toBe(0);206 expect(binaryEntryCount).toBe(0);207 expect(restored).toHaveLength(0);208 }, 30000);209210 it("rejects a mismatched ASCII entry count with HEADER_COUNT_MISMATCH", async () => {211 const { phrase } = await exportToCJK({212 entries,213 settings,214 passphrase,215 argon2: FAST_ARGON2,216 });217 const tampered = phrase.replace(218 /^peach:v3:entries=3:/,219 "peach:v3:entries=5:"220 );221 await expect(importFromCJK(tampered, passphrase)).rejects.toThrow(222 /HEADER_COUNT_MISMATCH/223 );224 }, 30000);225});226227describe("CJK phrase size sanity (§10.4)", () => {228 it("a ~100-entry vault stays within reasonable bounds", async () => {229 const sharedDomains = [230 "github.com",231 "gitlab.com",232 "google.com",233 "amazon.com",234 "apple.com",235 ];236 const big: VaultEntry[] = [];237 for (let i = 0; i < 100; i++) {238 const domain =239 i < 20 ? sharedDomains[i % sharedDomains.length] : `example${i}.com`;240 big.push(241 makeLogin({242 id: `id-${i}`,243 title: `Service ${i}`,244 url: domain,245 username: `user${i}@acme.com`,246 password: `password-${i}-with-some-length-1234`,247 createdAt: 1_700_000_000_000 + i * 1000,248 modifiedAt: 1_700_000_000_000 + i * 2000,249 })250 );251 }252253 const { cjkCharCount } = await exportToCJK({254 entries: big,255 settings: { theme: "dark" },256 passphrase: "correct horse battery staple drift waves",257 argon2: FAST_ARGON2,258 });259260 261 262 263 expect(cjkCharCount).toBeGreaterThan(200);264 expect(cjkCharCount).toBeLessThan(8000);265 }, 30000);266});