import { describe, expect, test } from "vitest"; import { readFileSync } from "node:fs"; 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"; const V3_VECTOR = JSON.parse( readFileSync("fixtures/golden/peach-payload-v3.json", "utf8"), ) as { passphrase: string; entry: { id: string; title: string; username: string; email: string; password: string; url: string }; envelopeBase64: string; }; const V4_VECTOR = JSON.parse( readFileSync("fixtures/golden/peach-payload-v4.json", "utf8"), ) as { passphrase: string; passwords: { ascii: string; unicode: string }; entryIds: string[]; envelopeBase64: string; }; function loginEntry(over: Partial = {}): VaultEntry { return { id: crypto.randomUUID(), type: "login", title: "github", username: "june@example.com", 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", 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, }, createdAt: 1700000000000, modifiedAt: 1700000000000, ...over, }; } describe("peachPayload", () => { test("shared V4 vector decodes 256-character ASCII and Unicode passwords", async () => { const envelope = Uint8Array.from(atob(V4_VECTOR.envelopeBase64), (character) => character.charCodeAt(0)); const decoded = await parsePayload(envelope, V4_VECTOR.passphrase); expect(decoded.entries.map((entry) => entry.id)).toEqual(V4_VECTOR.entryIds); expect(decoded.entries.map((entry) => entry.password)).toEqual([ V4_VECTOR.passwords.ascii, V4_VECTOR.passwords.unicode, ]); }); 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.each([ ["256 ASCII characters", "a".repeat(256)], ["256 multibyte characters", "界".repeat(256)], ])("round-trips a %s password through the V4 payload", async (_name, password) => { const env = await buildPayload({ entries: [loginEntry({ password })], settings: {}, passphrase: PASS, }); const out = await parsePayload(env, PASS); expect(out.entries[0].password).toBe(password); }); test("decodes the shared V3 compatibility vector", async () => { const out = await parsePayload(Buffer.from(V3_VECTOR.envelopeBase64, "base64"), V3_VECTOR.passphrase); expect(out.entries[0]).toMatchObject(V3_VECTOR.entry); }); 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.passkey?.rpId).toBe("github.com"); expect(gotPk.passkey?.privateKeyJwk).toEqual(passkey.passkey?.privateKeyJwk); expect(gotPk.passkey?.algorithm).toBe(-7); expect(gotPk.passkey?.signCount).toBe(5); expect(gotPk.passkey?.backupEligible).toBe(true); expect(gotPk.passkey?.backupState).toBe(false); }); test("passkey-bearing login entry round-trips ALL login fields (SEV-1-B)", async () => { const canary: Canary = { webhookUrl: "https://example.com/canary", label: "alert" }; const entry: VaultEntry = { id: crypto.randomUUID(), type: "login", title: "Google", username: "ada@proton.me", email: "ada@proton.me", password: "also-has-a-password", url: "https://google.com", additionalUrls: ["https://accounts.google.com"], totp: "JBSWY3DPEHPK3PXP", notes: "backup codes: 1234 5678", tags: ["work", "important"], canary, passwordChangedAt: 1700000000000, breach: 2, passkey: { rpId: "google.com", rpName: "Google", credentialId: "dGVzdC1pZA", publicKeyCose: "a40102032620", privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" }, userHandle: "dGVzdA", userName: "ada@proton.me", userDisplayName: "Ada Lovelace", algorithm: -7, signCount: 42, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt: 1690000000000, }, createdAt: 1690000000000, modifiedAt: 1700000000000, }; const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS }); const out = await parsePayload(env, PASS); const got = out.entries[0]!; // Login fields that were silently dropped on export before this fix. expect(got.totp).toBe(entry.totp); expect(got.notes).toBe(entry.notes); expect(got.tags).toEqual(entry.tags); expect(got.email).toBe(entry.email); expect(got.additionalUrls).toEqual(entry.additionalUrls); expect(got.canary).toEqual(canary); expect(got.passwordChangedAt).toBe(entry.passwordChangedAt); expect(got.breach).toBe(2); expect(got.password).toBe(entry.password); expect(got.passkey?.credentialId).toBe("dGVzdC1pZA"); expect(got.passkey?.signCount).toBe(42); expect(got.passkey?.privateKeyJwk).toEqual(entry.passkey!.privateKeyJwk); }); test("string table deduplicates shared domains", async () => { const entries = Array.from({ length: 20 }, (_, i) => loginEntry({ title: `acct-${i}`, username: "june@example.com", 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@example.com"); } }); 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"); }); }); describe("peachPayload v3 — email field (INFO-2)", () => { test("preserves email through v3 build + parse round-trip", async () => { const entry = loginEntry({ email: "ada@proton.me" }); const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS }); const out = await parsePayload(env, PASS); expect(out.entries).toHaveLength(1); expect(out.entries[0].email).toBe("ada@proton.me"); }); test("omitting email round-trips with email undefined", async () => { const entry = loginEntry(); expect(entry.email).toBeUndefined(); const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS }); const out = await parsePayload(env, PASS); expect(out.entries[0].email).toBeUndefined(); }); test("non-login entries ignore email in v3 (card, note, passkey)", async () => { const entries = [cardEntry(), noteEntry(), passkeyEntry()]; const env = await buildPayload({ entries, settings: {}, passphrase: PASS }); const out = await parsePayload(env, PASS); expect(out.entries).toHaveLength(3); for (const e of out.entries) expect((e as VaultEntry).email).toBeUndefined(); }); test("v3 envelope leading byte is 0x03 (format version)", async () => { // We can't see the plaintext, but we can verify decryption then check. // Instead, verify a v3 payload still parses successfully — the leading // byte check is enforced inside parsePayload. const env = await buildPayload({ entries: [loginEntry({ email: "x@y.test" })], settings: {}, passphrase: PASS, }); const out = await parsePayload(env, PASS); expect(out.entries[0].email).toBe("x@y.test"); }); }); describe("PCH-01-001 — NFC passphrase normalization", () => { // "café" composes (U+00E9) under NFC and decomposes (e + U+0301) under NFD. const NFC_PASS = "café".normalize("NFC"); const NFD_PASS = "café".normalize("NFD"); test("NFD/NFC forms genuinely differ (fixture sanity)", () => { expect(NFC_PASS).not.toBe(NFD_PASS); }); test("export_with_nfd_passphrase_imports_correctly", async () => { // Sealed under NFD form, opened with NFC form — both normalize to one key. const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: NFD_PASS, }); const out = await parsePayload(env, NFC_PASS); expect(out.entries[0].title).toBe("github"); }); test("export_with_nfc_passphrase_imports_correctly", async () => { // Regression: a pure-NFC passphrase still round-trips. const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: NFC_PASS, }); const out = await parsePayload(env, NFC_PASS); expect(out.entries[0].title).toBe("github"); }); test("codex_restore_with_nfd_passphrase_succeeds", async () => { // Lock-restore / unlockFromCodex parse through the same parsePayload // chokepoint: NFC-sealed blob opened with the NFD form must succeed. const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: NFC_PASS, }); const out = await parsePayload(env, NFD_PASS); expect(out.entries[0].title).toBe("github"); }); test("non_ascii_cjk_combining_round_trip", async () => { // Quality-bar: CJK + combining dakuten. "が" is U+304C (NFC) or // か + U+3099 (NFD); mixed with CJK ideographs. const base = "日本語がpassword"; const nfc = base.normalize("NFC"); const nfd = base.normalize("NFD"); expect(nfc).not.toBe(nfd); const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: nfd, }); const out = await parsePayload(env, nfc); expect(out.entries[0].title).toBe("github"); }); });