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 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; 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 360 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); 368 });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 378 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 423 424 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 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 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 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 468 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 480 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});