peachPayload.test.ts

9.3 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    rpId: "github.com",64    rpName: "GitHub",65    credentialId: "abc123",66    publicKeyCose: "a401020326...",67    privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },68    userHandle: "user-handle",69    userName: "june",70    userDisplayName: "June",71    algorithm: -7,72    signCount: 5,73    aaguid: "AAAAAAAAAAAAAAAAAAAAAA",74    backupEligible: true,75    backupState: false,76    createdAt: 1700000000000,77    modifiedAt: 1700000000000,78    ...over,79  };80}8182describe("peachPayload", () => {83  test("UUID round-trip preserves the hyphenated string", () => {84    const id = "123e4567-e89b-42d3-a456-426614174000";85    expect(bytesToUuid(uuidToBytes(id))).toBe(id);86  });8788  test("login entry round-trips all fields", async () => {89    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };90    const entry = loginEntry({91      totp: "JBSWY3DPEHPK3PXP",92      tags: ["personal", "dev"],93      notes: "second factor on yubikey",94      canary,95      breach: 4,96      passwordChangedAt: 1700000500000,97      additionalUrls: ["https://gist.github.com"],98    });99100    const env = await buildPayload({101      entries: [entry],102      settings: {},103      passphrase: PASS,104    });105    const out = await parsePayload(env, PASS);106    expect(out.entries).toHaveLength(1);107    const got = out.entries[0];108    expect(got.id).toBe(entry.id);109    expect(got.title).toBe(entry.title);110    expect(got.url).toBe(entry.url);111    expect(got.username).toBe(entry.username);112    expect(got.password).toBe(entry.password);113    expect(got.totp).toBe(entry.totp);114    expect(got.tags).toEqual(entry.tags);115    expect(got.notes).toBe(entry.notes);116    expect(got.canary).toEqual(canary);117    expect(got.breach).toBe(4);118    expect(got.additionalUrls).toEqual(entry.additionalUrls);119    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);120    expect(got.createdAt).toBe(entry.createdAt);121    expect(got.modifiedAt).toBe(entry.modifiedAt);122  });123124  test("note, card, and passkey entries round-trip", async () => {125    const note = noteEntry({ tags: ["secrets"] });126    const card = cardEntry({ cardNickname: "daily driver" });127    const passkey = passkeyEntry();128129    const env = await buildPayload({130      entries: [note, card, passkey],131      settings: {},132      passphrase: PASS,133    });134    const out = await parsePayload(env, PASS);135    expect(out.entries).toHaveLength(3);136137    const gotNote = out.entries.find((e) => e.id === note.id)!;138    expect(gotNote.type).toBe("note");139    expect(gotNote.notes).toBe(note.notes);140    expect(gotNote.tags).toEqual(["secrets"]);141142    const gotCard = out.entries.find((e) => e.id === card.id)!;143    expect(gotCard.type).toBe("card");144    expect(gotCard.cardNumber).toBe(card.cardNumber);145    expect(gotCard.expMonth).toBe(card.expMonth);146    expect(gotCard.expYear).toBe(card.expYear);147    expect(gotCard.cvc).toBe(card.cvc);148    expect(gotCard.network).toBe("visa");149    expect(gotCard.last4).toBe("1111");150    expect(gotCard.cardNickname).toBe("daily driver");151    expect(gotCard.cardholderName).toBe(card.cardholderName);152153    const gotPk = out.entries.find((e) => e.id === passkey.id)!;154    expect(gotPk.type).toBe("passkey");155    expect(gotPk.rpId).toBe("github.com");156    expect(gotPk.privateKeyJwk).toEqual(passkey.privateKeyJwk);157    expect(gotPk.algorithm).toBe(-7);158    expect(gotPk.signCount).toBe(5);159    expect(gotPk.backupEligible).toBe(true);160    expect(gotPk.backupState).toBe(false);161  });162163  test("string table deduplicates shared domains", async () => {164    const entries = Array.from({ length: 20 }, (_, i) =>165      loginEntry({166        title: `acct-${i}`,167        username: "[email protected]",168        url: "https://github.com",169        password: `p-${i}`,170      })171    );172    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });173    const out = await parsePayload(env, PASS);174    expect(out.entries).toHaveLength(20);175    for (const e of out.entries) {176      expect(e.url).toBe("https://github.com");177      expect(e.username).toBe("[email protected]");178    }179  });180181  test("settings round-trip", async () => {182    const settings: VaultSettings = {183      accentColor: "rose",184      theme: "light",185      autoLockMinutes: 15,186      lockOnClose: true,187    };188    const env = await buildPayload({ entries: [], settings, passphrase: PASS });189    const out = await parsePayload(env, PASS);190    expect(out.settings).toEqual(settings);191  });192193  test("license round-trip and activation timestamp", async () => {194    const env = await buildPayload({195      entries: [],196      settings: {},197      license: { key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 },198      passphrase: PASS,199    });200    const out = await parsePayload(env, PASS);201    expect(out.license).toEqual({ key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 });202  });203204  test("wrong passphrase throws INVALID_PASSPHRASE", async () => {205    const env = await buildPayload({206      entries: [loginEntry()],207      settings: {},208      passphrase: PASS,209    });210    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");211  });212213  test("tampered ciphertext fails AEAD", async () => {214    const env = await buildPayload({215      entries: [loginEntry()],216      settings: {},217      passphrase: PASS,218    });219    env[env.length - 17] ^= 0xff; // flip a bit inside ciphertext (just before tag)220    await expect(parsePayload(env, PASS)).rejects.toThrow();221  });222223  test("trashed entries preserve deletedAt", async () => {224    const entry = loginEntry({ deletedAt: 1700002000000 });225    const env = await buildPayload({226      entries: [entry],227      settings: {},228      passphrase: PASS,229    });230    const out = await parsePayload(env, PASS);231    expect(out.entries[0].deletedAt).toBe(entry.deletedAt);232  });233234  test("100 entries fit comfortably in a small envelope", async () => {235    const entries = Array.from({ length: 100 }, (_, i) =>236      loginEntry({237        title: `account-${i}`,238        username: `user${i % 10}@example.com`,239        url: "https://example.com",240        password: "qwerty-12345-zxcvbn",241      })242    );243    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });244    expect(env.length).toBeLessThan(8000);245    const out = await parsePayload(env, PASS);246    expect(out.entries).toHaveLength(100);247  });248});249250describe("peachPayload bucket padding (v2 format)", () => {251  // Single-entry vault is small — inner content << 1 KiB so it pads to 1 KiB.252  // Salt(16) + Nonce(12) + ciphertext(1024) + tag(16) = 1068 outer bytes.253  test("single-entry vault outer envelope is exactly 1068 bytes", async () => {254    const env = await buildPayload({255      entries: [loginEntry()],256      settings: {},257      passphrase: PASS,258    });259    expect(env.length).toBe(1068); // 16 + 12 + 1024 + 16260  });261262  test("small vault and tiny vault produce the same outer envelope size", async () => {263    const tiny = await buildPayload({ entries: [], settings: {}, passphrase: PASS });264    const small = await buildPayload({265      entries: [loginEntry(), loginEntry(), loginEntry()],266      settings: {},267      passphrase: PASS,268    });269    // Both fit in the 1 KiB bucket → both produce 1068-byte envelopes270    expect(tiny.length).toBe(small.length);271  });272273  test("round-trip through padding: entries survive buildPayload + parsePayload", async () => {274    const entry = loginEntry({ title: "padding-test", password: "correcthorse" });275    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });276    const out = await parsePayload(env, PASS);277    expect(out.entries).toHaveLength(1);278    expect(out.entries[0].title).toBe("padding-test");279    expect(out.entries[0].password).toBe("correcthorse");280  });281282  test("wrong passphrase still returns INVALID_PASSPHRASE (padding does not help attacker)", async () => {283    const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS });284    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");285  });286});