peachPayload.test.ts

15.2 KB · raw

1import { describe, expect, test } from "vitest";23import {4  bytesToUuid,5  buildPayload,6  parsePayload,7  uuidToBytes,8} from "./peachPayload";9import type { Canary, VaultEntry } from "../shared/types";10import type { VaultSettings } from "../background/vault";1112const PASS = "correct horse battery staple";1314function loginEntry(over: Partial<VaultEntry> = {}): VaultEntry {15  return {16    id: crypto.randomUUID(),17    type: "login",18    title: "github",19    username: "[email protected]",20    password: "p4ssw0rd!",21    url: "https://github.com",22    createdAt: 1700000000000,23    modifiedAt: 1700001000000,24    ...over,25  };26}2728function noteEntry(over: Partial<VaultEntry> = {}): VaultEntry {29  return {30    id: crypto.randomUUID(),31    type: "note",32    title: "private note",33    notes: "remember the milk",34    createdAt: 1700000000000,35    modifiedAt: 1700000000000,36    ...over,37  };38}3940function cardEntry(over: Partial<VaultEntry> = {}): VaultEntry {41  return {42    id: crypto.randomUUID(),43    type: "card",44    title: "personal visa",45    cardholderName: "June Asha",46    cardNumber: "4111111111111111",47    expMonth: "08",48    expYear: "2029",49    cvc: "123",50    network: "visa",51    last4: "1111",52    createdAt: 1700000000000,53    modifiedAt: 1700000000000,54    ...over,55  };56}5758function passkeyEntry(over: Partial<VaultEntry> = {}): VaultEntry {59  return {60    id: crypto.randomUUID(),61    type: "passkey",62    title: "github passkey",63    passkey: {64      rpId: "github.com",65      rpName: "GitHub",66      credentialId: "abc123",67      publicKeyCose: "a401020326...",68      privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },69      userHandle: "user-handle",70      userName: "june",71      userDisplayName: "June",72      algorithm: -7,73      signCount: 5,74      aaguid: "AAAAAAAAAAAAAAAAAAAAAA",75      backupEligible: true,76      backupState: false,77      createdAt: 1700000000000,78    },79    createdAt: 1700000000000,80    modifiedAt: 1700000000000,81    ...over,82  };83}8485describe("peachPayload", () => {86  test("UUID round-trip preserves the hyphenated string", () => {87    const id = "123e4567-e89b-42d3-a456-426614174000";88    expect(bytesToUuid(uuidToBytes(id))).toBe(id);89  });9091  test("login entry round-trips all fields", async () => {92    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };93    const entry = loginEntry({94      totp: "JBSWY3DPEHPK3PXP",95      tags: ["personal", "dev"],96      notes: "second factor on yubikey",97      canary,98      breach: 4,99      passwordChangedAt: 1700000500000,100      additionalUrls: ["https://gist.github.com"],101    });102103    const env = await buildPayload({104      entries: [entry],105      settings: {},106      passphrase: PASS,107    });108    const out = await parsePayload(env, PASS);109    expect(out.entries).toHaveLength(1);110    const got = out.entries[0];111    expect(got.id).toBe(entry.id);112    expect(got.title).toBe(entry.title);113    expect(got.url).toBe(entry.url);114    expect(got.username).toBe(entry.username);115    expect(got.password).toBe(entry.password);116    expect(got.totp).toBe(entry.totp);117    expect(got.tags).toEqual(entry.tags);118    expect(got.notes).toBe(entry.notes);119    expect(got.canary).toEqual(canary);120    expect(got.breach).toBe(4);121    expect(got.additionalUrls).toEqual(entry.additionalUrls);122    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);123    expect(got.createdAt).toBe(entry.createdAt);124    expect(got.modifiedAt).toBe(entry.modifiedAt);125  });126127  test("note, card, and passkey entries round-trip", async () => {128    const note = noteEntry({ tags: ["secrets"] });129    const card = cardEntry({ cardNickname: "daily driver" });130    const passkey = passkeyEntry();131132    const env = await buildPayload({133      entries: [note, card, passkey],134      settings: {},135      passphrase: PASS,136    });137    const out = await parsePayload(env, PASS);138    expect(out.entries).toHaveLength(3);139140    const gotNote = out.entries.find((e) => e.id === note.id)!;141    expect(gotNote.type).toBe("note");142    expect(gotNote.notes).toBe(note.notes);143    expect(gotNote.tags).toEqual(["secrets"]);144145    const gotCard = out.entries.find((e) => e.id === card.id)!;146    expect(gotCard.type).toBe("card");147    expect(gotCard.cardNumber).toBe(card.cardNumber);148    expect(gotCard.expMonth).toBe(card.expMonth);149    expect(gotCard.expYear).toBe(card.expYear);150    expect(gotCard.cvc).toBe(card.cvc);151    expect(gotCard.network).toBe("visa");152    expect(gotCard.last4).toBe("1111");153    expect(gotCard.cardNickname).toBe("daily driver");154    expect(gotCard.cardholderName).toBe(card.cardholderName);155156    const gotPk = out.entries.find((e) => e.id === passkey.id)!;157    expect(gotPk.type).toBe("passkey");158    expect(gotPk.passkey?.rpId).toBe("github.com");159    expect(gotPk.passkey?.privateKeyJwk).toEqual(passkey.passkey?.privateKeyJwk);160    expect(gotPk.passkey?.algorithm).toBe(-7);161    expect(gotPk.passkey?.signCount).toBe(5);162    expect(gotPk.passkey?.backupEligible).toBe(true);163    expect(gotPk.passkey?.backupState).toBe(false);164  });165166  test("passkey-bearing login entry round-trips ALL login fields (SEV-1-B)", async () => {167    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };168    const entry: VaultEntry = {169      id: crypto.randomUUID(),170      type: "login",171      title: "Google",172      username: "[email protected]",173      email: "[email protected]",174      password: "also-has-a-password",175      url: "https://google.com",176      additionalUrls: ["https://accounts.google.com"],177      totp: "JBSWY3DPEHPK3PXP",178      notes: "backup codes: 1234 5678",179      tags: ["work", "important"],180      canary,181      passwordChangedAt: 1700000000000,182      breach: 2,183      passkey: {184        rpId: "google.com",185        rpName: "Google",186        credentialId: "dGVzdC1pZA",187        publicKeyCose: "a40102032620",188        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },189        userHandle: "dGVzdA",190        userName: "[email protected]",191        userDisplayName: "Ada Lovelace",192        algorithm: -7,193        signCount: 42,194        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",195        backupEligible: true,196        backupState: false,197        createdAt: 1690000000000,198      },199      createdAt: 1690000000000,200      modifiedAt: 1700000000000,201    };202203    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });204    const out = await parsePayload(env, PASS);205    const got = out.entries[0]!;206207    // Login fields that were silently dropped on export before this fix.208    expect(got.totp).toBe(entry.totp);209    expect(got.notes).toBe(entry.notes);210    expect(got.tags).toEqual(entry.tags);211    expect(got.email).toBe(entry.email);212    expect(got.additionalUrls).toEqual(entry.additionalUrls);213    expect(got.canary).toEqual(canary);214    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);215    expect(got.breach).toBe(2);216    expect(got.password).toBe(entry.password);217    expect(got.passkey?.credentialId).toBe("dGVzdC1pZA");218    expect(got.passkey?.signCount).toBe(42);219    expect(got.passkey?.privateKeyJwk).toEqual(entry.passkey!.privateKeyJwk);220  });221222  test("string table deduplicates shared domains", async () => {223    const entries = Array.from({ length: 20 }, (_, i) =>224      loginEntry({225        title: `acct-${i}`,226        username: "[email protected]",227        url: "https://github.com",228        password: `p-${i}`,229      })230    );231    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });232    const out = await parsePayload(env, PASS);233    expect(out.entries).toHaveLength(20);234    for (const e of out.entries) {235      expect(e.url).toBe("https://github.com");236      expect(e.username).toBe("[email protected]");237    }238  });239240  test("settings round-trip", async () => {241    const settings: VaultSettings = {242      accentColor: "rose",243      theme: "light",244      autoLockMinutes: 15,245      lockOnClose: true,246    };247    const env = await buildPayload({ entries: [], settings, passphrase: PASS });248    const out = await parsePayload(env, PASS);249    expect(out.settings).toEqual(settings);250  });251252  test("license round-trip and activation timestamp", async () => {253    const env = await buildPayload({254      entries: [],255      settings: {},256      license: { key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 },257      passphrase: PASS,258    });259    const out = await parsePayload(env, PASS);260    expect(out.license).toEqual({ key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 });261  });262263  test("wrong passphrase throws INVALID_PASSPHRASE", async () => {264    const env = await buildPayload({265      entries: [loginEntry()],266      settings: {},267      passphrase: PASS,268    });269    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");270  });271272  test("tampered ciphertext fails AEAD", async () => {273    const env = await buildPayload({274      entries: [loginEntry()],275      settings: {},276      passphrase: PASS,277    });278    env[env.length - 17] ^= 0xff; // flip a bit inside ciphertext (just before tag)279    await expect(parsePayload(env, PASS)).rejects.toThrow();280  });281282  test("trashed entries preserve deletedAt", async () => {283    const entry = loginEntry({ deletedAt: 1700002000000 });284    const env = await buildPayload({285      entries: [entry],286      settings: {},287      passphrase: PASS,288    });289    const out = await parsePayload(env, PASS);290    expect(out.entries[0].deletedAt).toBe(entry.deletedAt);291  });292293  test("100 entries fit comfortably in a small envelope", async () => {294    const entries = Array.from({ length: 100 }, (_, i) =>295      loginEntry({296        title: `account-${i}`,297        username: `user${i % 10}@example.com`,298        url: "https://example.com",299        password: "qwerty-12345-zxcvbn",300      })301    );302    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });303    expect(env.length).toBeLessThan(8000);304    const out = await parsePayload(env, PASS);305    expect(out.entries).toHaveLength(100);306  });307});308309describe("peachPayload bucket padding (v2 format)", () => {310  // Single-entry vault is small — inner content << 1 KiB so it pads to 1 KiB.311  // Salt(16) + Nonce(12) + ciphertext(1024) + tag(16) = 1068 outer bytes.312  test("single-entry vault outer envelope is exactly 1068 bytes", async () => {313    const env = await buildPayload({314      entries: [loginEntry()],315      settings: {},316      passphrase: PASS,317    });318    expect(env.length).toBe(1068); // 16 + 12 + 1024 + 16319  });320321  test("small vault and tiny vault produce the same outer envelope size", async () => {322    const tiny = await buildPayload({ entries: [], settings: {}, passphrase: PASS });323    const small = await buildPayload({324      entries: [loginEntry(), loginEntry(), loginEntry()],325      settings: {},326      passphrase: PASS,327    });328    // Both fit in the 1 KiB bucket → both produce 1068-byte envelopes329    expect(tiny.length).toBe(small.length);330  });331332  test("round-trip through padding: entries survive buildPayload + parsePayload", async () => {333    const entry = loginEntry({ title: "padding-test", password: "correcthorse" });334    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });335    const out = await parsePayload(env, PASS);336    expect(out.entries).toHaveLength(1);337    expect(out.entries[0].title).toBe("padding-test");338    expect(out.entries[0].password).toBe("correcthorse");339  });340341  test("wrong passphrase still returns INVALID_PASSPHRASE (padding does not help attacker)", async () => {342    const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS });343    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");344  });345});346347describe("peachPayload v3 — email field (INFO-2)", () => {348  test("preserves email through v3 build + parse round-trip", async () => {349    const entry = loginEntry({ email: "[email protected]" });350    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });351    const out = await parsePayload(env, PASS);352    expect(out.entries).toHaveLength(1);353    expect(out.entries[0].email).toBe("[email protected]");354  });355356  test("omitting email round-trips with email undefined", async () => {357    const entry = loginEntry();358    expect(entry.email).toBeUndefined();359    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });360    const out = await parsePayload(env, PASS);361    expect(out.entries[0].email).toBeUndefined();362  });363364  test("non-login entries ignore email in v3 (card, note, passkey)", async () => {365    const entries = [cardEntry(), noteEntry(), passkeyEntry()];366    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });367    const out = await parsePayload(env, PASS);368    expect(out.entries).toHaveLength(3);369    for (const e of out.entries) expect((e as VaultEntry).email).toBeUndefined();370  });371372  test("v3 envelope leading byte is 0x03 (format version)", async () => {373    // We can't see the plaintext, but we can verify decryption then check.374    // Instead, verify a v3 payload still parses successfully — the leading375    // byte check is enforced inside parsePayload.376    const env = await buildPayload({377      entries: [loginEntry({ email: "[email protected]" })],378      settings: {},379      passphrase: PASS,380    });381    const out = await parsePayload(env, PASS);382    expect(out.entries[0].email).toBe("[email protected]");383  });384});385386describe("PCH-01-001 — NFC passphrase normalization", () => {387  // "café" composes (U+00E9) under NFC and decomposes (e + U+0301) under NFD.388  const NFC_PASS = "café".normalize("NFC");389  const NFD_PASS = "café".normalize("NFD");390391  test("NFD/NFC forms genuinely differ (fixture sanity)", () => {392    expect(NFC_PASS).not.toBe(NFD_PASS);393  });394395  test("export_with_nfd_passphrase_imports_correctly", async () => {396    // Sealed under NFD form, opened with NFC form — both normalize to one key.397    const env = await buildPayload({398      entries: [loginEntry()],399      settings: {},400      passphrase: NFD_PASS,401    });402    const out = await parsePayload(env, NFC_PASS);403    expect(out.entries[0].title).toBe("github");404  });405406  test("export_with_nfc_passphrase_imports_correctly", async () => {407    // Regression: a pure-NFC passphrase still round-trips.408    const env = await buildPayload({409      entries: [loginEntry()],410      settings: {},411      passphrase: NFC_PASS,412    });413    const out = await parsePayload(env, NFC_PASS);414    expect(out.entries[0].title).toBe("github");415  });416417  test("codex_restore_with_nfd_passphrase_succeeds", async () => {418    // Lock-restore / unlockFromCodex parse through the same parsePayload419    // chokepoint: NFC-sealed blob opened with the NFD form must succeed.420    const env = await buildPayload({421      entries: [loginEntry()],422      settings: {},423      passphrase: NFC_PASS,424    });425    const out = await parsePayload(env, NFD_PASS);426    expect(out.entries[0].title).toBe("github");427  });428429  test("non_ascii_cjk_combining_round_trip", async () => {430    // Quality-bar: CJK + combining dakuten. "が" is U+304C (NFC) or431    // か + U+3099 (NFD); mixed with CJK ideographs.432    const base = "日本語がpassword";433    const nfc = base.normalize("NFC");434    const nfd = base.normalize("NFD");435    expect(nfc).not.toBe(nfd);436    const env = await buildPayload({437      entries: [loginEntry()],438      settings: {},439      passphrase: nfd,440    });441    const out = await parsePayload(env, nfc);442    expect(out.entries[0].title).toBe("github");443  });444});