peachPayload.test.ts

16.1 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};2223function loginEntry(over: Partial<VaultEntry> = {}): VaultEntry {24  return {25    id: crypto.randomUUID(),26    type: "login",27    title: "github",28    username: "[email protected]",29    password: "p4ssw0rd!",30    url: "https://github.com",31    createdAt: 1700000000000,32    modifiedAt: 1700001000000,33    ...over,34  };35}3637function noteEntry(over: Partial<VaultEntry> = {}): VaultEntry {38  return {39    id: crypto.randomUUID(),40    type: "note",41    title: "private note",42    notes: "remember the milk",43    createdAt: 1700000000000,44    modifiedAt: 1700000000000,45    ...over,46  };47}4849function cardEntry(over: Partial<VaultEntry> = {}): VaultEntry {50  return {51    id: crypto.randomUUID(),52    type: "card",53    title: "personal visa",54    cardholderName: "June Asha",55    cardNumber: "4111111111111111",56    expMonth: "08",57    expYear: "2029",58    cvc: "123",59    network: "visa",60    last4: "1111",61    createdAt: 1700000000000,62    modifiedAt: 1700000000000,63    ...over,64  };65}6667function passkeyEntry(over: Partial<VaultEntry> = {}): VaultEntry {68  return {69    id: crypto.randomUUID(),70    type: "passkey",71    title: "github passkey",72    passkey: {73      rpId: "github.com",74      rpName: "GitHub",75      credentialId: "abc123",76      publicKeyCose: "a401020326...",77      privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },78      userHandle: "user-handle",79      userName: "june",80      userDisplayName: "June",81      algorithm: -7,82      signCount: 5,83      aaguid: "AAAAAAAAAAAAAAAAAAAAAA",84      backupEligible: true,85      backupState: false,86      createdAt: 1700000000000,87    },88    createdAt: 1700000000000,89    modifiedAt: 1700000000000,90    ...over,91  };92}9394describe("peachPayload", () => {95  test("UUID round-trip preserves the hyphenated string", () => {96    const id = "123e4567-e89b-42d3-a456-426614174000";97    expect(bytesToUuid(uuidToBytes(id))).toBe(id);98  });99100  test("login entry round-trips all fields", async () => {101    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };102    const entry = loginEntry({103      totp: "JBSWY3DPEHPK3PXP",104      tags: ["personal", "dev"],105      notes: "second factor on yubikey",106      canary,107      breach: 4,108      passwordChangedAt: 1700000500000,109      additionalUrls: ["https://gist.github.com"],110    });111112    const env = await buildPayload({113      entries: [entry],114      settings: {},115      passphrase: PASS,116    });117    const out = await parsePayload(env, PASS);118    expect(out.entries).toHaveLength(1);119    const got = out.entries[0];120    expect(got.id).toBe(entry.id);121    expect(got.title).toBe(entry.title);122    expect(got.url).toBe(entry.url);123    expect(got.username).toBe(entry.username);124    expect(got.password).toBe(entry.password);125    expect(got.totp).toBe(entry.totp);126    expect(got.tags).toEqual(entry.tags);127    expect(got.notes).toBe(entry.notes);128    expect(got.canary).toEqual(canary);129    expect(got.breach).toBe(4);130    expect(got.additionalUrls).toEqual(entry.additionalUrls);131    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);132    expect(got.createdAt).toBe(entry.createdAt);133    expect(got.modifiedAt).toBe(entry.modifiedAt);134  });135136  test.each([137    ["256 ASCII characters", "a".repeat(256)],138    ["256 multibyte characters", "界".repeat(256)],139  ])("round-trips a %s password through the V4 payload", async (_name, password) => {140    const env = await buildPayload({141      entries: [loginEntry({ password })],142      settings: {},143      passphrase: PASS,144    });145146    const out = await parsePayload(env, PASS);147148    expect(out.entries[0].password).toBe(password);149  });150151  test("decodes the shared V3 compatibility vector", async () => {152    const out = await parsePayload(Buffer.from(V3_VECTOR.envelopeBase64, "base64"), V3_VECTOR.passphrase);153154    expect(out.entries[0]).toMatchObject(V3_VECTOR.entry);155  });156157  test("note, card, and passkey entries round-trip", async () => {158    const note = noteEntry({ tags: ["secrets"] });159    const card = cardEntry({ cardNickname: "daily driver" });160    const passkey = passkeyEntry();161162    const env = await buildPayload({163      entries: [note, card, passkey],164      settings: {},165      passphrase: PASS,166    });167    const out = await parsePayload(env, PASS);168    expect(out.entries).toHaveLength(3);169170    const gotNote = out.entries.find((e) => e.id === note.id)!;171    expect(gotNote.type).toBe("note");172    expect(gotNote.notes).toBe(note.notes);173    expect(gotNote.tags).toEqual(["secrets"]);174175    const gotCard = out.entries.find((e) => e.id === card.id)!;176    expect(gotCard.type).toBe("card");177    expect(gotCard.cardNumber).toBe(card.cardNumber);178    expect(gotCard.expMonth).toBe(card.expMonth);179    expect(gotCard.expYear).toBe(card.expYear);180    expect(gotCard.cvc).toBe(card.cvc);181    expect(gotCard.network).toBe("visa");182    expect(gotCard.last4).toBe("1111");183    expect(gotCard.cardNickname).toBe("daily driver");184    expect(gotCard.cardholderName).toBe(card.cardholderName);185186    const gotPk = out.entries.find((e) => e.id === passkey.id)!;187    expect(gotPk.type).toBe("passkey");188    expect(gotPk.passkey?.rpId).toBe("github.com");189    expect(gotPk.passkey?.privateKeyJwk).toEqual(passkey.passkey?.privateKeyJwk);190    expect(gotPk.passkey?.algorithm).toBe(-7);191    expect(gotPk.passkey?.signCount).toBe(5);192    expect(gotPk.passkey?.backupEligible).toBe(true);193    expect(gotPk.passkey?.backupState).toBe(false);194  });195196  test("passkey-bearing login entry round-trips ALL login fields (SEV-1-B)", async () => {197    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };198    const entry: VaultEntry = {199      id: crypto.randomUUID(),200      type: "login",201      title: "Google",202      username: "[email protected]",203      email: "[email protected]",204      password: "also-has-a-password",205      url: "https://google.com",206      additionalUrls: ["https://accounts.google.com"],207      totp: "JBSWY3DPEHPK3PXP",208      notes: "backup codes: 1234 5678",209      tags: ["work", "important"],210      canary,211      passwordChangedAt: 1700000000000,212      breach: 2,213      passkey: {214        rpId: "google.com",215        rpName: "Google",216        credentialId: "dGVzdC1pZA",217        publicKeyCose: "a40102032620",218        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },219        userHandle: "dGVzdA",220        userName: "[email protected]",221        userDisplayName: "Ada Lovelace",222        algorithm: -7,223        signCount: 42,224        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",225        backupEligible: true,226        backupState: false,227        createdAt: 1690000000000,228      },229      createdAt: 1690000000000,230      modifiedAt: 1700000000000,231    };232233    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });234    const out = await parsePayload(env, PASS);235    const got = out.entries[0]!;236237    // Login fields that were silently dropped on export before this fix.238    expect(got.totp).toBe(entry.totp);239    expect(got.notes).toBe(entry.notes);240    expect(got.tags).toEqual(entry.tags);241    expect(got.email).toBe(entry.email);242    expect(got.additionalUrls).toEqual(entry.additionalUrls);243    expect(got.canary).toEqual(canary);244    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);245    expect(got.breach).toBe(2);246    expect(got.password).toBe(entry.password);247    expect(got.passkey?.credentialId).toBe("dGVzdC1pZA");248    expect(got.passkey?.signCount).toBe(42);249    expect(got.passkey?.privateKeyJwk).toEqual(entry.passkey!.privateKeyJwk);250  });251252  test("string table deduplicates shared domains", async () => {253    const entries = Array.from({ length: 20 }, (_, i) =>254      loginEntry({255        title: `acct-${i}`,256        username: "[email protected]",257        url: "https://github.com",258        password: `p-${i}`,259      })260    );261    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });262    const out = await parsePayload(env, PASS);263    expect(out.entries).toHaveLength(20);264    for (const e of out.entries) {265      expect(e.url).toBe("https://github.com");266      expect(e.username).toBe("[email protected]");267    }268  });269270  test("settings round-trip", async () => {271    const settings: VaultSettings = {272      accentColor: "rose",273      theme: "light",274      autoLockMinutes: 15,275      lockOnClose: true,276    };277    const env = await buildPayload({ entries: [], settings, passphrase: PASS });278    const out = await parsePayload(env, PASS);279    expect(out.settings).toEqual(settings);280  });281282  test("license round-trip and activation timestamp", async () => {283    const env = await buildPayload({284      entries: [],285      settings: {},286      license: { key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 },287      passphrase: PASS,288    });289    const out = await parsePayload(env, PASS);290    expect(out.license).toEqual({ key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 });291  });292293  test("wrong passphrase throws INVALID_PASSPHRASE", async () => {294    const env = await buildPayload({295      entries: [loginEntry()],296      settings: {},297      passphrase: PASS,298    });299    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");300  });301302  test("tampered ciphertext fails AEAD", async () => {303    const env = await buildPayload({304      entries: [loginEntry()],305      settings: {},306      passphrase: PASS,307    });308    env[env.length - 17] ^= 0xff; // flip a bit inside ciphertext (just before tag)309    await expect(parsePayload(env, PASS)).rejects.toThrow();310  });311312  test("trashed entries preserve deletedAt", async () => {313    const entry = loginEntry({ deletedAt: 1700002000000 });314    const env = await buildPayload({315      entries: [entry],316      settings: {},317      passphrase: PASS,318    });319    const out = await parsePayload(env, PASS);320    expect(out.entries[0].deletedAt).toBe(entry.deletedAt);321  });322323  test("100 entries fit comfortably in a small envelope", async () => {324    const entries = Array.from({ length: 100 }, (_, i) =>325      loginEntry({326        title: `account-${i}`,327        username: `user${i % 10}@example.com`,328        url: "https://example.com",329        password: "qwerty-12345-zxcvbn",330      })331    );332    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });333    expect(env.length).toBeLessThan(8000);334    const out = await parsePayload(env, PASS);335    expect(out.entries).toHaveLength(100);336  });337});338339describe("peachPayload bucket padding (v2 format)", () => {340  // Single-entry vault is small — inner content << 1 KiB so it pads to 1 KiB.341  // Salt(16) + Nonce(12) + ciphertext(1024) + tag(16) = 1068 outer bytes.342  test("single-entry vault outer envelope is exactly 1068 bytes", async () => {343    const env = await buildPayload({344      entries: [loginEntry()],345      settings: {},346      passphrase: PASS,347    });348    expect(env.length).toBe(1068); // 16 + 12 + 1024 + 16349  });350351  test("small vault and tiny vault produce the same outer envelope size", async () => {352    const tiny = await buildPayload({ entries: [], settings: {}, passphrase: PASS });353    const small = await buildPayload({354      entries: [loginEntry(), loginEntry(), loginEntry()],355      settings: {},356      passphrase: PASS,357    });358    // Both fit in the 1 KiB bucket → both produce 1068-byte envelopes359    expect(tiny.length).toBe(small.length);360  });361362  test("round-trip through padding: entries survive buildPayload + parsePayload", async () => {363    const entry = loginEntry({ title: "padding-test", password: "correcthorse" });364    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });365    const out = await parsePayload(env, PASS);366    expect(out.entries).toHaveLength(1);367    expect(out.entries[0].title).toBe("padding-test");368    expect(out.entries[0].password).toBe("correcthorse");369  });370371  test("wrong passphrase still returns INVALID_PASSPHRASE (padding does not help attacker)", async () => {372    const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS });373    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");374  });375});376377describe("peachPayload v3 — email field (INFO-2)", () => {378  test("preserves email through v3 build + parse round-trip", async () => {379    const entry = loginEntry({ email: "[email protected]" });380    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });381    const out = await parsePayload(env, PASS);382    expect(out.entries).toHaveLength(1);383    expect(out.entries[0].email).toBe("[email protected]");384  });385386  test("omitting email round-trips with email undefined", async () => {387    const entry = loginEntry();388    expect(entry.email).toBeUndefined();389    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });390    const out = await parsePayload(env, PASS);391    expect(out.entries[0].email).toBeUndefined();392  });393394  test("non-login entries ignore email in v3 (card, note, passkey)", async () => {395    const entries = [cardEntry(), noteEntry(), passkeyEntry()];396    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });397    const out = await parsePayload(env, PASS);398    expect(out.entries).toHaveLength(3);399    for (const e of out.entries) expect((e as VaultEntry).email).toBeUndefined();400  });401402  test("v3 envelope leading byte is 0x03 (format version)", async () => {403    // We can't see the plaintext, but we can verify decryption then check.404    // Instead, verify a v3 payload still parses successfully — the leading405    // byte check is enforced inside parsePayload.406    const env = await buildPayload({407      entries: [loginEntry({ email: "[email protected]" })],408      settings: {},409      passphrase: PASS,410    });411    const out = await parsePayload(env, PASS);412    expect(out.entries[0].email).toBe("[email protected]");413  });414});415416describe("PCH-01-001 — NFC passphrase normalization", () => {417  // "café" composes (U+00E9) under NFC and decomposes (e + U+0301) under NFD.418  const NFC_PASS = "café".normalize("NFC");419  const NFD_PASS = "café".normalize("NFD");420421  test("NFD/NFC forms genuinely differ (fixture sanity)", () => {422    expect(NFC_PASS).not.toBe(NFD_PASS);423  });424425  test("export_with_nfd_passphrase_imports_correctly", async () => {426    // Sealed under NFD form, opened with NFC form — both normalize to one key.427    const env = await buildPayload({428      entries: [loginEntry()],429      settings: {},430      passphrase: NFD_PASS,431    });432    const out = await parsePayload(env, NFC_PASS);433    expect(out.entries[0].title).toBe("github");434  });435436  test("export_with_nfc_passphrase_imports_correctly", async () => {437    // Regression: a pure-NFC passphrase still round-trips.438    const env = await buildPayload({439      entries: [loginEntry()],440      settings: {},441      passphrase: NFC_PASS,442    });443    const out = await parsePayload(env, NFC_PASS);444    expect(out.entries[0].title).toBe("github");445  });446447  test("codex_restore_with_nfd_passphrase_succeeds", async () => {448    // Lock-restore / unlockFromCodex parse through the same parsePayload449    // chokepoint: NFC-sealed blob opened with the NFD form must succeed.450    const env = await buildPayload({451      entries: [loginEntry()],452      settings: {},453      passphrase: NFC_PASS,454    });455    const out = await parsePayload(env, NFD_PASS);456    expect(out.entries[0].title).toBe("github");457  });458459  test("non_ascii_cjk_combining_round_trip", async () => {460    // Quality-bar: CJK + combining dakuten. "が" is U+304C (NFC) or461    // か + U+3099 (NFD); mixed with CJK ideographs.462    const base = "日本語がpassword";463    const nfc = base.normalize("NFC");464    const nfd = base.normalize("NFD");465    expect(nfc).not.toBe(nfd);466    const env = await buildPayload({467      entries: [loginEntry()],468      settings: {},469      passphrase: nfd,470    });471    const out = await parsePayload(env, nfc);472    expect(out.entries[0].title).toBe("github");473  });474});