cjkRoundTrip.test.ts

8.0 KB · raw

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 */1112import { 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    // v3's JSON payload is more verbose than the old binary layout, but zstd261    // level 19 still compresses the repetitive content well. Keep the loose262    // upper bound; the useful property is "not pathologically large".263    expect(cjkCharCount).toBeGreaterThan(200);264    expect(cjkCharCount).toBeLessThan(8000);265  }, 30000);266});