cjkRoundTrip.test.ts

10.5 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-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);263264  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    };280281    const { phrase } = await exportToCJK({282      entries: [cardEntry],283      settings: {},284      passphrase,285      argon2: FAST_ARGON2,286    });287288    const { entries: restored } = await importFromCJK(phrase, passphrase);289    expect(restored).toHaveLength(1);290291    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});303304describe("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    }329330    const { cjkCharCount } = await exportToCJK({331      entries: big,332      settings: { theme: "dark" },333      passphrase: "correct horse battery staple drift waves",334      argon2: FAST_ARGON2,335    });336337    // 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});