f86ad225e14e

feat: add email field to PeachScript v3 export format — bump payload version, add flags2 byte for login entries, preserve v1/v2 import compatibility (v3.6.83)

time
2026-05-21 15:56 UTC · 5 months ago
machine
koh2
parent
f5e09bec3b47
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/crypto/peachPayload.test.ts+39 −0src/crypto/peachPayload.ts+46 −14
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.82",5+  "version": "3.6.83",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.82",4+  "version": "3.6.83",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.82",3+  "version": "3.6.83",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/crypto/peachPayload.test.ts+39 −0
@@ -284,3 +284,42 @@284284     await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");285285   });286286 });287+288+describe("peachPayload v3 — email field (INFO-2)", () => {289+  test("preserves email through v3 build + parse round-trip", async () => {290+    const entry = loginEntry({ email: "[email protected]" });291+    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });292+    const out = await parsePayload(env, PASS);293+    expect(out.entries).toHaveLength(1);294+    expect(out.entries[0].email).toBe("[email protected]");295+  });296+297+  test("omitting email round-trips with email undefined", async () => {298+    const entry = loginEntry();299+    expect(entry.email).toBeUndefined();300+    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });301+    const out = await parsePayload(env, PASS);302+    expect(out.entries[0].email).toBeUndefined();303+  });304+305+  test("non-login entries ignore email in v3 (card, note, passkey)", async () => {306+    const entries = [cardEntry(), noteEntry(), passkeyEntry()];307+    const env = await buildPayload({ entries, settings: {}, passphrase: PASS });308+    const out = await parsePayload(env, PASS);309+    expect(out.entries).toHaveLength(3);310+    for (const e of out.entries) expect((e as VaultEntry).email).toBeUndefined();311+  });312+313+  test("v3 envelope leading byte is 0x03 (format version)", async () => {314+    // We can't see the plaintext, but we can verify decryption then check.315+    // Instead, verify a v3 payload still parses successfully — the leading316+    // byte check is enforced inside parsePayload.317+    const env = await buildPayload({318+      entries: [loginEntry({ email: "[email protected]" })],319+      settings: {},320+      passphrase: PASS,321+    });322+    const out = await parsePayload(env, PASS);323+    expect(out.entries[0].email).toBe("[email protected]");324+  });325+});

view file

src/crypto/peachPayload.ts+46 −14
@@ -80,6 +80,10 @@8080 // v2 format byte (prepended inside the plaintext so old clients see BAD_MAGIC8181 // and refuse gracefully; new clients detect v2 by this leading byte).8282 const FORMAT_V2 = 0x02;83+// v3 format byte. Same envelope as v2 (length-prefixed inner + bucket84+// padding) but login entries carry an extra `flags2` byte and an optional85+// email field. Decoders fall back to v2 behavior for older payloads.86+const FORMAT_V3 = 0x03;8387 8488 const ARGON2_MEMORY_KIB = 65536; // 64 MiB8589 const ARGON2_ITERATIONS = 3;@@ -106,6 +110,10 @@106110 const F_HAS_ADDITIONAL_URLS = 1 << 6;107111 const F_HAS_DELETED = 1 << 7;108112 113+// v3-only flags2 byte (login entries only). Reserves room for further114+// post-v3.6.82 fields without another envelope bump.115+const F2_HAS_EMAIL = 1 << 0;116+109117 const NETWORK_VALUES: CardNetwork[] = ["visa", "mastercard", "amex", "discover", "unknown"];110118 111119 // ---------------------------------------------------------------------------@@ -140,7 +148,7 @@140148 export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {141149   const sections: Uint8Array[] = [];142150 143-  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries)));151+  sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));144152   sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));145153   if (input.license) {146154     sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));@@ -170,12 +178,13 @@170178   const checksum = await blake3Truncated(body);171179   const inner = concat([body, checksum]);172180 173-  // v2 format: [FORMAT_V2(1)][inner_len(4 LE)][inner][zero padding to bucket]174-  // Old clients see FORMAT_V2 (0x02) where they expect PCHS magic (0x50) and181+  // v3 format: [FORMAT_V3(1)][inner_len(4 LE)][inner][zero padding to bucket]182+  // Old clients see FORMAT_V3 (0x03) where they expect PCHS magic (0x50) and175183   // fail with BAD_MAGIC — an honest "can't read this" rather than a silent176-  // wrong-password failure.184+  // wrong-password failure. Same envelope as v2; v3 adds optional email +185+  // flags2 byte on login entries (see encodeEntry/decodeEntry).177186   const header5 = new Uint8Array(5);178-  header5[0] = FORMAT_V2;187+  header5[0] = FORMAT_V3;179188   new DataView(header5.buffer).setUint32(1, inner.length, true);180189   const wrapped = padToNearestBucket(concat([header5, inner]));181190 @@ -219,9 +228,15 @@219228     key.fill(0);220229   }221230 222-  // Detect v2 padded format: leading byte FORMAT_V2 followed by 4-byte inner length.231+  // Detect padded format: leading byte FORMAT_V2 or FORMAT_V3 followed by232+  // 4-byte inner length. v1 (no leading byte) is decrypted plaintext.223233   let inner: Uint8Array;224-  if (decrypted.length >= 5 && decrypted[0] === FORMAT_V2) {234+  let formatVersion: number = 1;235+  if (236+    decrypted.length >= 5 &&237+    (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)238+  ) {239+    formatVersion = decrypted[0];225240     const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);226241     if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");227242     inner = decrypted.subarray(5, 5 + innerLen);@@ -261,7 +276,7 @@261276 262277     switch (type) {263278       case SECTION_ENTRIES:264-        out.entries = decodeEntriesSection(data);279+        out.entries = decodeEntriesSection(data, formatVersion);265280         break;266281       case SECTION_SETTINGS:267282         out.settings = decodeJsonSection<VaultSettings>(data);@@ -371,7 +386,7 @@371386   return strings[idx - 1];372387 }373388 374-function encodeEntriesSection(entries: VaultEntry[]): Uint8Array {389+function encodeEntriesSection(entries: VaultEntry[], formatVersion: number): Uint8Array {375390   const table = new StringTableBuilder();376391   const bodies: Uint8Array[] = [];377392 @@ -379,7 +394,7 @@379394   for (const entry of entries) table.intern(entry.title);380395 381396   for (const entry of entries) {382-    bodies.push(encodeEntry(entry, table));397+    bodies.push(encodeEntry(entry, table, formatVersion));383398   }384399 385400   const tableBytes = table.serialize();@@ -399,7 +414,7 @@399414   return out;400415 }401416 402-function decodeEntriesSection(data: Uint8Array): VaultEntry[] {417+function decodeEntriesSection(data: Uint8Array, formatVersion: number): VaultEntry[] {403418   const view = new DataView(data.buffer, data.byteOffset, data.byteLength);404419   const count = view.getUint32(0, true);405420   const { strings, cursor: afterTable } = readStringTable(data, 4);@@ -407,7 +422,7 @@407422   const out: VaultEntry[] = [];408423   let cursor = afterTable;409424   for (let i = 0; i < count; i++) {410-    const { entry, cursor: next } = decodeEntry(data, cursor, strings);425+    const { entry, cursor: next } = decodeEntry(data, cursor, strings, formatVersion);411426     out.push(entry);412427     cursor = next;413428   }@@ -418,7 +433,7 @@418433 // Per-entry packing419434 // ---------------------------------------------------------------------------420435 421-function encodeEntry(entry: VaultEntry, table: StringTableBuilder): Uint8Array {436+function encodeEntry(entry: VaultEntry, table: StringTableBuilder, formatVersion: number): Uint8Array {422437   const writer = new BinaryWriter();423438   const type = entryTypeByte(entry);424439   writer.u8(type);@@ -433,6 +448,13 @@433448   // flags placeholder (we'll overwrite at the end)434449   const flagOffset = writer.length;435450   writer.u8(0);451+  // v3-only flags2 placeholder, written for every entry so the section452+  // layout stays uniform across types.453+  let flags2Offset = -1;454+  if (formatVersion === FORMAT_V3) {455+    flags2Offset = writer.length;456+    writer.u8(0);457+  }436458 437459   writer.u32(createdSec);438460   writer.u32(updatedDelta);@@ -440,12 +462,17 @@440462   writer.u16(table.intern(entry.title));441463 442464   let flags = 0;465+  let flags2 = 0;443466   if (entry.deletedAt) flags |= F_HAS_DELETED;444467 445468   if (type === ENTRY_TYPE_LOGIN) {446469     writer.u16(table.intern(entry.url));447470     writer.u16(table.intern(entry.username));448471     writer.lpString(entry.password ?? "", 1);472+    if (formatVersion === FORMAT_V3 && entry.email) {473+      flags2 |= F2_HAS_EMAIL;474+      writer.lpString(entry.email, 1);475+    }449476 450477     if (entry.totp) {451478       flags |= F_HAS_TOTP;@@ -545,18 +572,21 @@545572   }546573 547574   writer.setU8(flagOffset, flags);575+  if (flags2Offset >= 0) writer.setU8(flags2Offset, flags2);548576   return writer.toBytes();549577 }550578 551579 function decodeEntry(552580   data: Uint8Array,553581   start: number,554-  strings: string[]582+  strings: string[],583+  formatVersion: number555584 ): { entry: VaultEntry; cursor: number } {556585   const reader = new BinaryReader(data, start);557586   const type = reader.u8();558587   const id = bytesToUuid(reader.read(16));559588   const flags = reader.u8();589+  const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;560590   const createdSec = reader.u32();561591   const updatedDelta = reader.u32();562592   const deletedSec = reader.u32();@@ -580,6 +610,8 @@580610     const usernameIdx = reader.u16();581611     const password = reader.lpString(1);582612     const tail: Partial<VaultEntry> = {};613+614+    if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);583615 584616     if (flags & F_HAS_TOTP) tail.totp = reader.lpString(1);585617     if (flags & F_HAS_TAGS) {

view file