peachPayload.test.ts

15.7 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("passkey-bearing login entry round-trips ALL login fields (SEV-1-B)", async () => {164    const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" };165    const entry: VaultEntry = {166      id: crypto.randomUUID(),167      type: "login",168      title: "Google",169      username: "[email protected]",170      email: "[email protected]",171      password: "also-has-a-password",172      url: "https://google.com",173      additionalUrls: ["https://accounts.google.com"],174      totp: "JBSWY3DPEHPK3PXP",175      notes: "backup codes: 1234 5678",176      tags: ["work", "important"],177      canary,178      passwordChangedAt: 1700000000000,179      breach: 2,180      passkey: {181        rpId: "google.com",182        rpName: "Google",183        credentialId: "dGVzdC1pZA",184        publicKeyCose: "a40102032620",185        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },186        userHandle: "dGVzdA",187        userName: "[email protected]",188        userDisplayName: "Ada Lovelace",189        algorithm: -7,190        signCount: 42,191        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",192        backupEligible: true,193        backupState: false,194        createdAt: 1690000000000,195      },196      createdAt: 1690000000000,197      modifiedAt: 1700000000000,198    };199200    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });201    const out = await parsePayload(env, PASS);202    const got = out.entries[0]!;203204    // Login fields that were silently dropped on export before this fix.205    expect(got.totp).toBe(entry.totp);206    expect(got.notes).toBe(entry.notes);207    expect(got.tags).toEqual(entry.tags);208    expect(got.email).toBe(entry.email);209    expect(got.additionalUrls).toEqual(entry.additionalUrls);210    expect(got.canary).toEqual(canary);211    expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);212    expect(got.breach).toBe(2);213    expect(got.password).toBe(entry.password);214    // The passkey credential still round-trips (decoded as the flat legacy215    // shape; vault import re-nests it into the passkey sub-object).216    expect(got.credentialId).toBe("dGVzdC1pZA");217    expect(got.signCount).toBe(42);218    expect(got.privateKeyJwk).toEqual(entry.passkey!.privateKeyJwk);219  });220221  test("string table deduplicates shared domains", async () => {222    const entries = Array.from({ length: 20 }, (_, i) =>223      loginEntry({224        title: `acct-${i}`,225        username: "[email protected]",226        url: "https://github.com",227        password: `p-${i}`,228      })229    );230    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });231    const out = await parsePayload(env, PASS);232    expect(out.entries).toHaveLength(20);233    for (const e of out.entries) {234      expect(e.url).toBe("https://github.com");235      expect(e.username).toBe("[email protected]");236    }237  });238239  test("settings round-trip", async () => {240    const settings: VaultSettings = {241      accentColor: "rose",242      theme: "light",243      autoLockMinutes: 15,244      lockOnClose: true,245    };246    const env = await buildPayload({ entries: [], settings, passphrase: PASS });247    const out = await parsePayload(env, PASS);248    expect(out.settings).toEqual(settings);249  });250251  test("license round-trip and activation timestamp", async () => {252    const env = await buildPayload({253      entries: [],254      settings: {},255      license: { key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 },256      passphrase: PASS,257    });258    const out = await parsePayload(env, PASS);259    expect(out.license).toEqual({ key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 });260  });261262  test("wrong passphrase throws INVALID_PASSPHRASE", async () => {263    const env = await buildPayload({264      entries: [loginEntry()],265      settings: {},266      passphrase: PASS,267    });268    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");269  });270271  test("tampered ciphertext fails AEAD", async () => {272    const env = await buildPayload({273      entries: [loginEntry()],274      settings: {},275      passphrase: PASS,276    });277    env[env.length - 17] ^= 0xff; // flip a bit inside ciphertext (just before tag)278    await expect(parsePayload(env, PASS)).rejects.toThrow();279  });280281  test("trashed entries preserve deletedAt", async () => {282    const entry = loginEntry({ deletedAt: 1700002000000 });283    const env = await buildPayload({284      entries: [entry],285      settings: {},286      passphrase: PASS,287    });288    const out = await parsePayload(env, PASS);289    expect(out.entries[0].deletedAt).toBe(entry.deletedAt);290  });291292  test("100 entries fit comfortably in a small envelope", async () => {293    const entries = Array.from({ length: 100 }, (_, i) =>294      loginEntry({295        title: `account-${i}`,296        username: `user${i % 10}@example.com`,297        url: "https://example.com",298        password: "qwerty-12345-zxcvbn",299      })300    );301    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });302    expect(env.length).toBeLessThan(8000);303    const out = await parsePayload(env, PASS);304    expect(out.entries).toHaveLength(100);305  });306});307308describe("peachPayload bucket padding (v2 format)", () => {309  // Single-entry vault is small — inner content << 1 KiB so it pads to 1 KiB.310  // Salt(16) + Nonce(12) + ciphertext(1024) + tag(16) = 1068 outer bytes.311  test("single-entry vault outer envelope is exactly 1068 bytes", async () => {312    const env = await buildPayload({313      entries: [loginEntry()],314      settings: {},315      passphrase: PASS,316    });317    expect(env.length).toBe(1068); // 16 + 12 + 1024 + 16318  });319320  test("small vault and tiny vault produce the same outer envelope size", async () => {321    const tiny = await buildPayload({ entries: [], settings: {}, passphrase: PASS });322    const small = await buildPayload({323      entries: [loginEntry(), loginEntry(), loginEntry()],324      settings: {},325      passphrase: PASS,326    });327    // Both fit in the 1 KiB bucket → both produce 1068-byte envelopes328    expect(tiny.length).toBe(small.length);329  });330331  test("round-trip through padding: entries survive buildPayload + parsePayload", async () => {332    const entry = loginEntry({ title: "padding-test", password: "correcthorse" });333    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });334    const out = await parsePayload(env, PASS);335    expect(out.entries).toHaveLength(1);336    expect(out.entries[0].title).toBe("padding-test");337    expect(out.entries[0].password).toBe("correcthorse");338  });339340  test("wrong passphrase still returns INVALID_PASSPHRASE (padding does not help attacker)", async () => {341    const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS });342    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");343  });344});345346describe("peachPayload v3 — email field (INFO-2)", () => {347  test("preserves email through v3 build + parse round-trip", async () => {348    const entry = loginEntry({ email: "[email protected]" });349    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });350    const out = await parsePayload(env, PASS);351    expect(out.entries).toHaveLength(1);352    expect(out.entries[0].email).toBe("[email protected]");353  });354355  test("omitting email round-trips with email undefined", async () => {356    const entry = loginEntry();357    expect(entry.email).toBeUndefined();358    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });359    const out = await parsePayload(env, PASS);360    expect(out.entries[0].email).toBeUndefined();361  });362363  test("non-login entries ignore email in v3 (card, note, passkey)", async () => {364    const entries = [cardEntry(), noteEntry(), passkeyEntry()];365    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });366    const out = await parsePayload(env, PASS);367    expect(out.entries).toHaveLength(3);368    for (const e of out.entries) expect((e as VaultEntry).email).toBeUndefined();369  });370371  test("v3 envelope leading byte is 0x03 (format version)", async () => {372    // We can't see the plaintext, but we can verify decryption then check.373    // Instead, verify a v3 payload still parses successfully — the leading374    // byte check is enforced inside parsePayload.375    const env = await buildPayload({376      entries: [loginEntry({ email: "[email protected]" })],377      settings: {},378      passphrase: PASS,379    });380    const out = await parsePayload(env, PASS);381    expect(out.entries[0].email).toBe("[email protected]");382  });383});384385describe("PCH-01-001 — NFC passphrase normalization", () => {386  // "café" composes (U+00E9) under NFC and decomposes (e + U+0301) under NFD.387  const NFC_PASS = "café".normalize("NFC");388  const NFD_PASS = "café".normalize("NFD");389390  test("NFD/NFC forms genuinely differ (fixture sanity)", () => {391    expect(NFC_PASS).not.toBe(NFD_PASS);392  });393394  test("export_with_nfd_passphrase_imports_correctly", async () => {395    // Sealed under NFD form, opened with NFC form — both normalize to one key.396    const env = await buildPayload({397      entries: [loginEntry()],398      settings: {},399      passphrase: NFD_PASS,400    });401    const out = await parsePayload(env, NFC_PASS);402    expect(out.entries[0].title).toBe("github");403  });404405  test("export_with_nfc_passphrase_imports_correctly", async () => {406    // Regression: a pure-NFC passphrase still round-trips.407    const env = await buildPayload({408      entries: [loginEntry()],409      settings: {},410      passphrase: NFC_PASS,411    });412    const out = await parsePayload(env, NFC_PASS);413    expect(out.entries[0].title).toBe("github");414  });415416  test("codex_restore_with_nfd_passphrase_succeeds", async () => {417    // Lock-restore / unlockFromCodex parse through the same parsePayload418    // chokepoint: NFC-sealed blob opened with the NFD form must succeed.419    const env = await buildPayload({420      entries: [loginEntry()],421      settings: {},422      passphrase: NFC_PASS,423    });424    const out = await parsePayload(env, NFD_PASS);425    expect(out.entries[0].title).toBe("github");426  });427428  test("legacy_raw_fallback_opens_old_backup", async () => {429    // Mint a pre-fix export sealed under the RAW (un-normalized) NFD bytes via430    // the test seam, then confirm post-fix parsePayload opens it through the431    // raw fallback (normalized key fails, raw key succeeds).432    const env = await buildPayload(433      { entries: [loginEntry()], settings: {}, passphrase: NFD_PASS },434      { raw: true },435    );436    const out = await parsePayload(env, NFD_PASS);437    expect(out.entries[0].title).toBe("github");438  });439440  test("non_ascii_cjk_combining_round_trip", async () => {441    // Quality-bar: CJK + combining dakuten. "が" is U+304C (NFC) or442    // か + U+3099 (NFD); mixed with CJK ideographs.443    const base = "日本語がpassword";444    const nfc = base.normalize("NFC");445    const nfd = base.normalize("NFD");446    expect(nfc).not.toBe(nfd);447    const env = await buildPayload({448      entries: [loginEntry()],449      settings: {},450      passphrase: nfd,451    });452    const out = await parsePayload(env, nfc);453    expect(out.entries[0].title).toBe("github");454  });455});