peachPayload.test.ts

16.8 KB · raw

1import { describe, expect, test } from "vitest";2import { readFileSync } from "node:fs";34import {5  bytesToUuid,6  buildPayload,7  parsePayload,8  uuidToBytes,9} from "./peachPayload";10import type { Canary, VaultEntry } from "../shared/types";11import type { VaultSettings } from "../background/vault";1213const PASS = "correct horse battery staple";1415const V3_VECTOR = JSON.parse(16  readFileSync("fixtures/golden/peach-payload-v3.json", "utf8"),17) as {18  passphrase: string;19  entry: { id: string; title: string; username: string; email: string; password: string; url: string };20  envelopeBase64: string;21};2223const V4_VECTOR = JSON.parse(24  readFileSync("fixtures/golden/peach-payload-v4.json", "utf8"),25) as {26  passphrase: string;27  passwords: { ascii: string; unicode: string };28  entryIds: string[];29  envelopeBase64: string;30};3132function loginEntry(over: Partial<VaultEntry> = {}): VaultEntry {33  return {34    id: crypto.randomUUID(),35    type: "login",36    title: "github",37    username: "[email protected]",38    password: "p4ssw0rd!",39    url: "https://github.com",40    createdAt: 1700000000000,41    modifiedAt: 1700001000000,42    ...over,43  };44}4546function noteEntry(over: Partial<VaultEntry> = {}): VaultEntry {47  return {48    id: crypto.randomUUID(),49    type: "note",50    title: "private note",51    notes: "remember the milk",52    createdAt: 1700000000000,53    modifiedAt: 1700000000000,54    ...over,55  };56}5758function cardEntry(over: Partial<VaultEntry> = {}): VaultEntry {59  return {60    id: crypto.randomUUID(),61    type: "card",62    title: "personal visa",63    cardholderName: "June Asha",64    cardNumber: "4111111111111111",65    expMonth: "08",66    expYear: "2029",67    cvc: "123",68    network: "visa",69    last4: "1111",70    createdAt: 1700000000000,71    modifiedAt: 1700000000000,72    ...over,73  };74}7576function passkeyEntry(over: Partial<VaultEntry> = {}): VaultEntry {77  return {78    id: crypto.randomUUID(),79    type: "passkey",80    title: "github passkey",81    passkey: {82      rpId: "github.com",83      rpName: "GitHub",84      credentialId: "abc123",85      publicKeyCose: "a401020326...",86      privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },87      userHandle: "user-handle",88      userName: "june",89      userDisplayName: "June",90      algorithm: -7,91      signCount: 5,92      aaguid: "AAAAAAAAAAAAAAAAAAAAAA",93      backupEligible: true,94      backupState: false,95      createdAt: 1700000000000,96    },97    createdAt: 1700000000000,98    modifiedAt: 1700000000000,99    ...over,100  };101}102103describe("peachPayload", () => {104  test("shared V4 vector decodes 256-character ASCII and Unicode passwords", async () => {105    const envelope = Uint8Array.from(atob(V4_VECTOR.envelopeBase64), (character) => character.charCodeAt(0));106    const decoded = await parsePayload(envelope, V4_VECTOR.passphrase);107    expect(decoded.entries.map((entry) => entry.id)).toEqual(V4_VECTOR.entryIds);108    expect(decoded.entries.map((entry) => entry.password)).toEqual([109      V4_VECTOR.passwords.ascii,110      V4_VECTOR.passwords.unicode,111    ]);112  });113114  test("UUID round-trip preserves the hyphenated string", () => {115    const id = "123e4567-e89b-42d3-a456-426614174000";116    expect(bytesToUuid(uuidToBytes(id))).toBe(id);117  });118119  test("login entry round-trips all fields", async () => {120    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };121    const entry = loginEntry({122      totp: "JBSWY3DPEHPK3PXP",123      tags: ["personal", "dev"],124      notes: "second factor on yubikey",125      canary,126      breach: 4,127      passwordChangedAt: 1700000500000,128      additionalUrls: ["https://gist.github.com"],129    });130131    const env = await buildPayload({132      entries: [entry],133      settings: {},134      passphrase: PASS,135    });136    const out = await parsePayload(env, PASS);137    expect(out.entries).toHaveLength(1);138    const got = out.entries[0];139    expect(got.id).toBe(entry.id);140    expect(got.title).toBe(entry.title);141    expect(got.url).toBe(entry.url);142    expect(got.username).toBe(entry.username);143    expect(got.password).toBe(entry.password);144    expect(got.totp).toBe(entry.totp);145    expect(got.tags).toEqual(entry.tags);146    expect(got.notes).toBe(entry.notes);147    expect(got.canary).toEqual(canary);148    expect(got.breach).toBe(4);149    expect(got.additionalUrls).toEqual(entry.additionalUrls);150    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);151    expect(got.createdAt).toBe(entry.createdAt);152    expect(got.modifiedAt).toBe(entry.modifiedAt);153  });154155  test.each([156    ["256 ASCII characters", "a".repeat(256)],157    ["256 multibyte characters", "界".repeat(256)],158  ])("round-trips a %s password through the V4 payload", async (_name, password) => {159    const env = await buildPayload({160      entries: [loginEntry({ password })],161      settings: {},162      passphrase: PASS,163    });164165    const out = await parsePayload(env, PASS);166167    expect(out.entries[0].password).toBe(password);168  });169170  test("decodes the shared V3 compatibility vector", async () => {171    const out = await parsePayload(Buffer.from(V3_VECTOR.envelopeBase64, "base64"), V3_VECTOR.passphrase);172173    expect(out.entries[0]).toMatchObject(V3_VECTOR.entry);174  });175176  test("note, card, and passkey entries round-trip", async () => {177    const note = noteEntry({ tags: ["secrets"] });178    const card = cardEntry({ cardNickname: "daily driver" });179    const passkey = passkeyEntry();180181    const env = await buildPayload({182      entries: [note, card, passkey],183      settings: {},184      passphrase: PASS,185    });186    const out = await parsePayload(env, PASS);187    expect(out.entries).toHaveLength(3);188189    const gotNote = out.entries.find((e) => e.id === note.id)!;190    expect(gotNote.type).toBe("note");191    expect(gotNote.notes).toBe(note.notes);192    expect(gotNote.tags).toEqual(["secrets"]);193194    const gotCard = out.entries.find((e) => e.id === card.id)!;195    expect(gotCard.type).toBe("card");196    expect(gotCard.cardNumber).toBe(card.cardNumber);197    expect(gotCard.expMonth).toBe(card.expMonth);198    expect(gotCard.expYear).toBe(card.expYear);199    expect(gotCard.cvc).toBe(card.cvc);200    expect(gotCard.network).toBe("visa");201    expect(gotCard.last4).toBe("1111");202    expect(gotCard.cardNickname).toBe("daily driver");203    expect(gotCard.cardholderName).toBe(card.cardholderName);204205    const gotPk = out.entries.find((e) => e.id === passkey.id)!;206    expect(gotPk.type).toBe("passkey");207    expect(gotPk.passkey?.rpId).toBe("github.com");208    expect(gotPk.passkey?.privateKeyJwk).toEqual(passkey.passkey?.privateKeyJwk);209    expect(gotPk.passkey?.algorithm).toBe(-7);210    expect(gotPk.passkey?.signCount).toBe(5);211    expect(gotPk.passkey?.backupEligible).toBe(true);212    expect(gotPk.passkey?.backupState).toBe(false);213  });214215  test("passkey-bearing login entry round-trips ALL login fields (SEV-1-B)", async () => {216    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };217    const entry: VaultEntry = {218      id: crypto.randomUUID(),219      type: "login",220      title: "Google",221      username: "[email protected]",222      email: "[email protected]",223      password: "also-has-a-password",224      url: "https://google.com",225      additionalUrls: ["https://accounts.google.com"],226      totp: "JBSWY3DPEHPK3PXP",227      notes: "backup codes: 1234 5678",228      tags: ["work", "important"],229      canary,230      passwordChangedAt: 1700000000000,231      breach: 2,232      passkey: {233        rpId: "google.com",234        rpName: "Google",235        credentialId: "dGVzdC1pZA",236        publicKeyCose: "a40102032620",237        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },238        userHandle: "dGVzdA",239        userName: "[email protected]",240        userDisplayName: "Ada Lovelace",241        algorithm: -7,242        signCount: 42,243        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",244        backupEligible: true,245        backupState: false,246        createdAt: 1690000000000,247      },248      createdAt: 1690000000000,249      modifiedAt: 1700000000000,250    };251252    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });253    const out = await parsePayload(env, PASS);254    const got = out.entries[0]!;255256    // Login fields that were silently dropped on export before this fix.257    expect(got.totp).toBe(entry.totp);258    expect(got.notes).toBe(entry.notes);259    expect(got.tags).toEqual(entry.tags);260    expect(got.email).toBe(entry.email);261    expect(got.additionalUrls).toEqual(entry.additionalUrls);262    expect(got.canary).toEqual(canary);263    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);264    expect(got.breach).toBe(2);265    expect(got.password).toBe(entry.password);266    expect(got.passkey?.credentialId).toBe("dGVzdC1pZA");267    expect(got.passkey?.signCount).toBe(42);268    expect(got.passkey?.privateKeyJwk).toEqual(entry.passkey!.privateKeyJwk);269  });270271  test("string table deduplicates shared domains", async () => {272    const entries = Array.from({ length: 20 }, (_, i) =>273      loginEntry({274        title: `acct-${i}`,275        username: "[email protected]",276        url: "https://github.com",277        password: `p-${i}`,278      })279    );280    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });281    const out = await parsePayload(env, PASS);282    expect(out.entries).toHaveLength(20);283    for (const e of out.entries) {284      expect(e.url).toBe("https://github.com");285      expect(e.username).toBe("[email protected]");286    }287  });288289  test("settings round-trip", async () => {290    const settings: VaultSettings = {291      accentColor: "rose",292      theme: "light",293      autoLockMinutes: 15,294      lockOnClose: true,295    };296    const env = await buildPayload({ entries: [], settings, passphrase: PASS });297    const out = await parsePayload(env, PASS);298    expect(out.settings).toEqual(settings);299  });300301  test("license round-trip and activation timestamp", async () => {302    const env = await buildPayload({303      entries: [],304      settings: {},305      license: { key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 },306      passphrase: PASS,307    });308    const out = await parsePayload(env, PASS);309    expect(out.license).toEqual({ key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 });310  });311312  test("wrong passphrase throws INVALID_PASSPHRASE", async () => {313    const env = await buildPayload({314      entries: [loginEntry()],315      settings: {},316      passphrase: PASS,317    });318    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");319  });320321  test("tampered ciphertext fails AEAD", async () => {322    const env = await buildPayload({323      entries: [loginEntry()],324      settings: {},325      passphrase: PASS,326    });327    env[env.length - 17] ^= 0xff; // flip a bit inside ciphertext (just before tag)328    await expect(parsePayload(env, PASS)).rejects.toThrow();329  });330331  test("trashed entries preserve deletedAt", async () => {332    const entry = loginEntry({ deletedAt: 1700002000000 });333    const env = await buildPayload({334      entries: [entry],335      settings: {},336      passphrase: PASS,337    });338    const out = await parsePayload(env, PASS);339    expect(out.entries[0].deletedAt).toBe(entry.deletedAt);340  });341342  test("100 entries fit comfortably in a small envelope", async () => {343    const entries = Array.from({ length: 100 }, (_, i) =>344      loginEntry({345        title: `account-${i}`,346        username: `user${i % 10}@example.com`,347        url: "https://example.com",348        password: "qwerty-12345-zxcvbn",349      })350    );351    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });352    expect(env.length).toBeLessThan(8000);353    const out = await parsePayload(env, PASS);354    expect(out.entries).toHaveLength(100);355  });356});357358describe("peachPayload bucket padding (v2 format)", () => {359  // Single-entry vault is small — inner content << 1 KiB so it pads to 1 KiB.360  // Salt(16) + Nonce(12) + ciphertext(1024) + tag(16) = 1068 outer bytes.361  test("single-entry vault outer envelope is exactly 1068 bytes", async () => {362    const env = await buildPayload({363      entries: [loginEntry()],364      settings: {},365      passphrase: PASS,366    });367    expect(env.length).toBe(1068); // 16 + 12 + 1024 + 16368  });369370  test("small vault and tiny vault produce the same outer envelope size", async () => {371    const tiny = await buildPayload({ entries: [], settings: {}, passphrase: PASS });372    const small = await buildPayload({373      entries: [loginEntry(), loginEntry(), loginEntry()],374      settings: {},375      passphrase: PASS,376    });377    // Both fit in the 1 KiB bucket → both produce 1068-byte envelopes378    expect(tiny.length).toBe(small.length);379  });380381  test("round-trip through padding: entries survive buildPayload + parsePayload", async () => {382    const entry = loginEntry({ title: "padding-test", password: "correcthorse" });383    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });384    const out = await parsePayload(env, PASS);385    expect(out.entries).toHaveLength(1);386    expect(out.entries[0].title).toBe("padding-test");387    expect(out.entries[0].password).toBe("correcthorse");388  });389390  test("wrong passphrase still returns INVALID_PASSPHRASE (padding does not help attacker)", async () => {391    const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS });392    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");393  });394});395396describe("peachPayload v3 — email field (INFO-2)", () => {397  test("preserves email through v3 build + parse round-trip", async () => {398    const entry = loginEntry({ email: "[email protected]" });399    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });400    const out = await parsePayload(env, PASS);401    expect(out.entries).toHaveLength(1);402    expect(out.entries[0].email).toBe("[email protected]");403  });404405  test("omitting email round-trips with email undefined", async () => {406    const entry = loginEntry();407    expect(entry.email).toBeUndefined();408    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });409    const out = await parsePayload(env, PASS);410    expect(out.entries[0].email).toBeUndefined();411  });412413  test("non-login entries ignore email in v3 (card, note, passkey)", async () => {414    const entries = [cardEntry(), noteEntry(), passkeyEntry()];415    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });416    const out = await parsePayload(env, PASS);417    expect(out.entries).toHaveLength(3);418    for (const e of out.entries) expect((e as VaultEntry).email).toBeUndefined();419  });420421  test("v3 envelope leading byte is 0x03 (format version)", async () => {422    // We can't see the plaintext, but we can verify decryption then check.423    // Instead, verify a v3 payload still parses successfully — the leading424    // byte check is enforced inside parsePayload.425    const env = await buildPayload({426      entries: [loginEntry({ email: "[email protected]" })],427      settings: {},428      passphrase: PASS,429    });430    const out = await parsePayload(env, PASS);431    expect(out.entries[0].email).toBe("[email protected]");432  });433});434435describe("PCH-01-001 — NFC passphrase normalization", () => {436  // "café" composes (U+00E9) under NFC and decomposes (e + U+0301) under NFD.437  const NFC_PASS = "café".normalize("NFC");438  const NFD_PASS = "café".normalize("NFD");439440  test("NFD/NFC forms genuinely differ (fixture sanity)", () => {441    expect(NFC_PASS).not.toBe(NFD_PASS);442  });443444  test("export_with_nfd_passphrase_imports_correctly", async () => {445    // Sealed under NFD form, opened with NFC form — both normalize to one key.446    const env = await buildPayload({447      entries: [loginEntry()],448      settings: {},449      passphrase: NFD_PASS,450    });451    const out = await parsePayload(env, NFC_PASS);452    expect(out.entries[0].title).toBe("github");453  });454455  test("export_with_nfc_passphrase_imports_correctly", async () => {456    // Regression: a pure-NFC passphrase still round-trips.457    const env = await buildPayload({458      entries: [loginEntry()],459      settings: {},460      passphrase: NFC_PASS,461    });462    const out = await parsePayload(env, NFC_PASS);463    expect(out.entries[0].title).toBe("github");464  });465466  test("codex_restore_with_nfd_passphrase_succeeds", async () => {467    // Lock-restore / unlockFromCodex parse through the same parsePayload468    // chokepoint: NFC-sealed blob opened with the NFD form must succeed.469    const env = await buildPayload({470      entries: [loginEntry()],471      settings: {},472      passphrase: NFC_PASS,473    });474    const out = await parsePayload(env, NFD_PASS);475    expect(out.entries[0].title).toBe("github");476  });477478  test("non_ascii_cjk_combining_round_trip", async () => {479    // Quality-bar: CJK + combining dakuten. "が" is U+304C (NFC) or480    // か + U+3099 (NFD); mixed with CJK ideographs.481    const base = "日本語がpassword";482    const nfc = base.normalize("NFC");483    const nfd = base.normalize("NFD");484    expect(nfc).not.toBe(nfd);485    const env = await buildPayload({486      entries: [loginEntry()],487      settings: {},488      passphrase: nfd,489    });490    const out = await parsePayload(env, nfc);491    expect(out.entries[0].title).toBe("github");492  });493});