import { describe, expect, test } from "vitest"; import { bytesToUuid, buildPayload, parsePayload, uuidToBytes, } from "./peachPayload"; import type { Canary, VaultEntry } from "../shared/types"; import type { VaultSettings } from "../background/vault"; const PASS = "correct horse battery staple"; function loginEntry(over: Partial = {}): VaultEntry { return { id: crypto.randomUUID(), type: "login", title: "github", username: "june@asha.software", password: "p4ssw0rd!", url: "https://github.com", createdAt: 1700000000000, modifiedAt: 1700001000000, ...over, }; } function noteEntry(over: Partial = {}): VaultEntry { return { id: crypto.randomUUID(), type: "note", title: "private note", notes: "remember the milk", createdAt: 1700000000000, modifiedAt: 1700000000000, ...over, }; } function cardEntry(over: Partial = {}): VaultEntry { return { id: crypto.randomUUID(), type: "card", title: "personal visa", cardholderName: "June Asha", cardNumber: "4111111111111111", expMonth: "08", expYear: "2029", cvc: "123", network: "visa", last4: "1111", createdAt: 1700000000000, modifiedAt: 1700000000000, ...over, }; } function passkeyEntry(over: Partial = {}): VaultEntry { return { id: crypto.randomUUID(), type: "passkey", title: "github passkey", rpId: "github.com", rpName: "GitHub", credentialId: "abc123", publicKeyCose: "a401020326...", privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, userHandle: "user-handle", userName: "june", userDisplayName: "June", algorithm: -7, signCount: 5, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt: 1700000000000, modifiedAt: 1700000000000, ...over, }; } describe("peachPayload", () => { test("UUID round-trip preserves the hyphenated string", () => { const id = "123e4567-e89b-42d3-a456-426614174000"; expect(bytesToUuid(uuidToBytes(id))).toBe(id); }); test("login entry round-trips all fields", async () => { const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" }; const entry = loginEntry({ totp: "JBSWY3DPEHPK3PXP", tags: ["personal", "dev"], notes: "second factor on yubikey", canary, breach: 4, passwordChangedAt: 1700000500000, additionalUrls: ["https://gist.github.com"], }); const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS, }); const out = await parsePayload(env, PASS); expect(out.entries).toHaveLength(1); const got = out.entries[0]; expect(got.id).toBe(entry.id); expect(got.title).toBe(entry.title); expect(got.url).toBe(entry.url); expect(got.username).toBe(entry.username); expect(got.password).toBe(entry.password); expect(got.totp).toBe(entry.totp); expect(got.tags).toEqual(entry.tags); expect(got.notes).toBe(entry.notes); expect(got.canary).toEqual(canary); expect(got.breach).toBe(4); expect(got.additionalUrls).toEqual(entry.additionalUrls); expect(got.passwordChangedAt).toBe(entry.passwordChangedAt); expect(got.createdAt).toBe(entry.createdAt); expect(got.modifiedAt).toBe(entry.modifiedAt); }); test("note, card, and passkey entries round-trip", async () => { const note = noteEntry({ tags: ["secrets"] }); const card = cardEntry({ cardNickname: "daily driver" }); const passkey = passkeyEntry(); const env = await buildPayload({ entries: [note, card, passkey], settings: {}, passphrase: PASS, }); const out = await parsePayload(env, PASS); expect(out.entries).toHaveLength(3); const gotNote = out.entries.find((e) => e.id === note.id)!; expect(gotNote.type).toBe("note"); expect(gotNote.notes).toBe(note.notes); expect(gotNote.tags).toEqual(["secrets"]); const gotCard = out.entries.find((e) => e.id === card.id)!; expect(gotCard.type).toBe("card"); expect(gotCard.cardNumber).toBe(card.cardNumber); expect(gotCard.expMonth).toBe(card.expMonth); expect(gotCard.expYear).toBe(card.expYear); expect(gotCard.cvc).toBe(card.cvc); expect(gotCard.network).toBe("visa"); expect(gotCard.last4).toBe("1111"); expect(gotCard.cardNickname).toBe("daily driver"); expect(gotCard.cardholderName).toBe(card.cardholderName); const gotPk = out.entries.find((e) => e.id === passkey.id)!; expect(gotPk.type).toBe("passkey"); expect(gotPk.rpId).toBe("github.com"); expect(gotPk.privateKeyJwk).toEqual(passkey.privateKeyJwk); expect(gotPk.algorithm).toBe(-7); expect(gotPk.signCount).toBe(5); expect(gotPk.backupEligible).toBe(true); expect(gotPk.backupState).toBe(false); }); test("string table deduplicates shared domains", async () => { const entries = Array.from({ length: 20 }, (_, i) => loginEntry({ title: `acct-${i}`, username: "june@asha.software", url: "https://github.com", password: `p-${i}`, }) ); const env = await buildPayload({ entries, settings: {}, passphrase: PASS }); const out = await parsePayload(env, PASS); expect(out.entries).toHaveLength(20); for (const e of out.entries) { expect(e.url).toBe("https://github.com"); expect(e.username).toBe("june@asha.software"); } }); test("settings round-trip", async () => { const settings: VaultSettings = { accentColor: "rose", theme: "light", autoLockMinutes: 15, lockOnClose: true, }; const env = await buildPayload({ entries: [], settings, passphrase: PASS }); const out = await parsePayload(env, PASS); expect(out.settings).toEqual(settings); }); test("license round-trip and activation timestamp", async () => { const env = await buildPayload({ entries: [], settings: {}, license: { key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 }, passphrase: PASS, }); const out = await parsePayload(env, PASS); expect(out.license).toEqual({ key: "PCH-AAAA-BBBB-CCCC", activatedAt: 1700000000000 }); }); test("wrong passphrase throws INVALID_PASSPHRASE", async () => { const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS, }); await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE"); }); test("tampered ciphertext fails AEAD", async () => { const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS, }); env[env.length - 17] ^= 0xff; // flip a bit inside ciphertext (just before tag) await expect(parsePayload(env, PASS)).rejects.toThrow(); }); test("trashed entries preserve deletedAt", async () => { const entry = loginEntry({ deletedAt: 1700002000000 }); const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS, }); const out = await parsePayload(env, PASS); expect(out.entries[0].deletedAt).toBe(entry.deletedAt); }); test("100 entries fit comfortably in a small envelope", async () => { const entries = Array.from({ length: 100 }, (_, i) => loginEntry({ title: `account-${i}`, username: `user${i % 10}@example.com`, url: "https://example.com", password: "qwerty-12345-zxcvbn", }) ); const env = await buildPayload({ entries, settings: {}, passphrase: PASS }); expect(env.length).toBeLessThan(8000); const out = await parsePayload(env, PASS); expect(out.entries).toHaveLength(100); }); }); describe("peachPayload bucket padding (v2 format)", () => { // Single-entry vault is small — inner content << 1 KiB so it pads to 1 KiB. // Salt(16) + Nonce(12) + ciphertext(1024) + tag(16) = 1068 outer bytes. test("single-entry vault outer envelope is exactly 1068 bytes", async () => { const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS, }); expect(env.length).toBe(1068); // 16 + 12 + 1024 + 16 }); test("small vault and tiny vault produce the same outer envelope size", async () => { const tiny = await buildPayload({ entries: [], settings: {}, passphrase: PASS }); const small = await buildPayload({ entries: [loginEntry(), loginEntry(), loginEntry()], settings: {}, passphrase: PASS, }); // Both fit in the 1 KiB bucket → both produce 1068-byte envelopes expect(tiny.length).toBe(small.length); }); test("round-trip through padding: entries survive buildPayload + parsePayload", async () => { const entry = loginEntry({ title: "padding-test", password: "correcthorse" }); const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS }); const out = await parsePayload(env, PASS); expect(out.entries).toHaveLength(1); expect(out.entries[0].title).toBe("padding-test"); expect(out.entries[0].password).toBe("correcthorse"); }); test("wrong passphrase still returns INVALID_PASSPHRASE (padding does not help attacker)", async () => { const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS }); await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE"); }); });