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-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 ];201202 const { phrase, entryCount, loginCount, trashedCount } = await exportToCJK({203 entries: withTrash,204 settings: {},205 passphrase,206 argon2: FAST_ARGON2,207 });208209 expect(entryCount).toBe(3);210 expect(loginCount).toBe(2);211 expect(trashedCount).toBe(1);212213 const firstLine = phrase.split("\n")[0];214 expect(firstLine).toMatch(/^peach:v3:entries=3:zstd:aes256gcm:argon2id$/);215216 const { entries: restored, announcedEntryCount, binaryEntryCount } =217 await importFromCJK(phrase, passphrase);218219 expect(announcedEntryCount).toBe(3);220 expect(binaryEntryCount).toBe(3);221 expect(restored).toHaveLength(3);222223 const active = restored.filter((e) => !e.deletedAt);224 const trashed = restored.filter((e) => !!e.deletedAt);225226 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);231232 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 });240241 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);247248 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});264265describe("CJK phrase size sanity (§10.4)", () => {266 it("a ~100-entry vault stays within reasonable bounds", async () => {267 const sharedDomains = [268 "github.com",269 "gitlab.com",270 "google.com",271 "amazon.com",272 "apple.com",273 ];274 const big: VaultEntry[] = [];275 for (let i = 0; i < 100; i++) {276 const domain =277 i < 20 ? sharedDomains[i % sharedDomains.length] : `example${i}.com`;278 big.push(279 makeLogin({280 id: `id-${i}`,281 title: `Service ${i}`,282 url: domain,283 username: `user${i}@acme.com`,284 password: `password-${i}-with-some-length-1234`,285 createdAt: 1_700_000_000_000 + i * 1000,286 modifiedAt: 1_700_000_000_000 + i * 2000,287 })288 );289 }290291 const { cjkCharCount } = await exportToCJK({292 entries: big,293 settings: { theme: "dark" },294 passphrase: "correct horse battery staple drift waves",295 argon2: FAST_ARGON2,296 });297298 299 300 301 expect(cjkCharCount).toBeGreaterThan(200);302 expect(cjkCharCount).toBeLessThan(8000);303 }, 30000);304});