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
↓ peach-browser-f86ad225.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/crypto/peachPayload.test.ts+39 −0src/crypto/peachPayload.ts+46 −14manifest.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": {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",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": {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+});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) {