62e1ce2b9167
feat: emit Vault V4 while retaining V3 reads (v5.1.0)
- time
- 2026-09-05 12:34 UTC · 1 month ago
- machine
- koh2
- agent
- gpt-6
- session
- Vault V4 password and passphrase support
- parent
- 9e704eea3bf4
↓ peach-browser-62e1ce2b.tar.gzatlas at this save
files changed6 files
fixtures/golden/peach-payload-v3.json+13 −0manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/crypto/peachPayload.test.ts+30 −0src/crypto/peachPayload.ts+18 −16fixtures/golden/peach-payload-v3.json+13 −0
@@ -0,0 +1,13 @@1+{2+ "format": 3,3+ "passphrase": "cross-platform-v3",4+ "entry": {5+ "id": "123e4567-e89b-42d3-a456-426614174000",6+ "title": "V3 vector",7+ "username": "june",8+ "email": "[email protected]",9+ "password": "legacy-v3-password",10+ "url": "https://example.com"11+ },12+ "envelopeBase64": "F3pBbUl9ZaAa8RIDf1rkjvT4FZmPQox8uV3wbD3QRJnbRQ+rWCUVVnm8xzwkmNE9D+B9Q2LdypGLbDQpB2O5b6emDYYpYRJtETTg/GgqvAUGjC2MEtyQz7/IeB5CHq1JRgWrBkUoKbpHr6WA/VJd31k1UlY3tQJMQ39av0ZrXyoXWqNoLlNCF3a4HQint48AcrY7RUgy7XDmjFOqwhQDtY9+wd9JHLhpDIyQeN8HBrLiqb+G7JlP20rHHmLZ07UWGE3Zlq3hXxgZmNmhgLI+sXZGcTtKTgLb9GFkE6nnPB0y6+3/M4BJv4iY3zlkeDROCE4vmtUBE1N8P/EVSr+L+yJq0tjP0NlMqRNO1CF3O1iunXNrAY8EsDO20K2/VMzmcDLRzunPM7m0oi2rWZhAT/Y1Gchzk35Pp7Mp0BVnQ9IT7hr4cfYIZs/tqGC4acMcjUZ3qDZ08xLjzXBi0F56kZ7noFpGBigEwApEQxPxOyxEDk03Sz9rTZuX/HIihmu5LGV31pmXGpztab2NkaBqvE6iSN5LZEoxrSRvk35dSZl/sVaRBMxKJz2T4clxtgglR4FtXMli8jCt1oCeYanVLBkYkcMD6yJwBSyQ/F+gViUy4QQgddvBzyzg2Gp4Phr45yfx6tmxLeECmk1AIBddtU0BShR3eFZQ1iQ6cNFD9YiV5Hck44b11hGL/wOnweh+i4vgn8vORw8MyQGdJtzRBFOvd33fq06e1XMomQxzGNVDUnB4o8V26NC2q18GBFl0kJakPtQl//m8svbjtMLrMi9WBa1UPC2uiupxKBcFwOKhzUuJbKjRUZM6+H0SbQ1lgLzWj1LWpbxFMRmQ+cyitK0+tFujodeA6qBYF40/veuVXzynGFc1uYG492lRyqd3Wz6ieAlDEoHZ4bX2HOsdIuoTyeMNpKTdBFEy8gip/fofbTxxvw7H4AygqjNflEN2E5Fi2eGit8QVfXVJv5azoRg46w2h0e9UT/NkUyKrOb99R2QP2z6nLmjHlN7O/LN413EZdg1fEKu4K455QED8FExvRvTI/LUlQZiDZ0BrxZTJ7IqEJI31mlmqsgiooh8YVLztL4N5u222WMu5FL3u1DSPTF4Eu+S9nm1Zu18wqedECF1FChC8OEFEUHRV9jL1kpPk5DWymORr69pjQCjw9Tc24k+NT2mcLiAqTle3UeKeRNd0PDeE+q3+QYIFa36EcU9+5payUi9X9WBSDNgbLBAtXsSRI/iVt8Iyd6w5TSxLnUqCeEQrZHHEoN83YLl1ghJs6iW5kAtCdhVzoZ1ZUamAa6TWZCEakEuCv9buyU/gZ1UJdhyw9mQ6lvR9PFq4//nH2P3UiSXjnqqo3GH2ug2FFrmHKEpJ3GwEuT42F0rzzJOf6JA1amMWrT3hWAwDqfi2dc2Tqy8Vo3pl"13+}manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.106",4+ "version": "5.1.0",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.0.106",5+ "version": "5.1.0",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.0.106",3+ "version": "5.1.0",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/crypto/peachPayload.test.ts+30 −0
@@ -1,4 +1,5 @@11 import { describe, expect, test } from "vitest";2+import { readFileSync } from "node:fs";23 34 import {45 bytesToUuid,@@ -11,6 +12,14 @@1112 1213 const PASS = "correct horse battery staple";1314 15+const 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+};22+1423 function loginEntry(over: Partial<VaultEntry> = {}): VaultEntry {1524 return {1625 id: crypto.randomUUID(),@@ -122,6 +131,27 @@122131 expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);123132 expect(got.createdAt).toBe(entry.createdAt);124133 expect(got.modifiedAt).toBe(entry.modifiedAt);134+ });135+136+ test.each([137+ ["256 ASCII characters", "a".repeat(256)],138+ ["256 multibyte characters", "界".repeat(256)],139+ ])("round-trips a %s password through the V4 payload", async (_name, password) => {140+ const env = await buildPayload({141+ entries: [loginEntry({ password })],142+ settings: {},143+ passphrase: PASS,144+ });145+146+ const out = await parsePayload(env, PASS);147+148+ expect(out.entries[0].password).toBe(password);149+ });150+151+ test("decodes the shared V3 compatibility vector", async () => {152+ const out = await parsePayload(Buffer.from(V3_VECTOR.envelopeBase64, "base64"), V3_VECTOR.passphrase);153+154+ expect(out.entries[0]).toMatchObject(V3_VECTOR.entry);125155 });126156 127157 test("note, card, and passkey entries round-trip", async () => {src/crypto/peachPayload.ts+18 −16
@@ -78,9 +78,10 @@7878 return padded;7979 }8080 81-// Current format byte. Login entries carry an extra `flags2` byte and an82-// optional email field.81+// V3 added login flags2/email. V4 widens only login passwords to a u1682+// byte-length prefix. Readers retain V3 compatibility; writers emit V4.8383 const FORMAT_V3 = 0x03;84+const FORMAT_V4 = 0x04;8485 8586 const ARGON2_MEMORY_KIB = 65536; // 64 MiB8687 const ARGON2_ITERATIONS = 3;@@ -154,7 +155,7 @@154155 ): Promise<Uint8Array> {155156 const sections: Uint8Array[] = [];156157 157- sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));158+ sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V4)));158159 sections.push(await buildSection(SECTION_SETTINGS, encodeJsonSection(input.settings)));159160 if (input.license) {160161 sections.push(await buildSection(SECTION_LICENSE, encodeJsonSection(input.license)));@@ -184,13 +185,13 @@184185 const checksum = await blake3Truncated(body);185186 const inner = concat([body, checksum]);186187 187- // v3 format: [FORMAT_V3(1)][inner_len(4 LE)][inner][zero padding to bucket]188- // Old clients see FORMAT_V3 (0x03) where they expect PCHS magic (0x50) and188+ // v4 format: [FORMAT_V4(1)][inner_len(4 LE)][inner][zero padding to bucket]189+ // Old clients see FORMAT_V4 (0x04) where they expect PCHS magic (0x50) and189190 // fail with BAD_MAGIC — an honest "can't read this" rather than a silent190- // wrong-password failure. Same envelope as v2; v3 adds optional email +191- // flags2 byte on login entries (see encodeEntry/decodeEntry).191+ // wrong-password failure. V4 keeps the v3 layout except for the widened192+ // login-password length prefix (see encodeEntry/decodeEntry).192193 const header5 = new Uint8Array(5);193- header5[0] = FORMAT_V3;194+ header5[0] = FORMAT_V4;194195 new DataView(header5.buffer).setUint32(1, inner.length, true);195196 const wrapped = padToNearestBucket(concat([header5, inner]));196197 @@ -235,9 +236,10 @@235236 }236237 237238 // Current envelopes always carry the version byte and length prefix.238- if (decrypted.length < 5 || decrypted[0] !== FORMAT_V3) {239+ if (decrypted.length < 5 || (decrypted[0] !== FORMAT_V3 && decrypted[0] !== FORMAT_V4)) {239240 throw new Error("UNSUPPORTED_VERSION");240241 }242+ const formatVersion = decrypted[0];241243 const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);242244 if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");243245 const inner = decrypted.subarray(5, 5 + innerLen);@@ -288,7 +290,7 @@288290 289291 switch (type) {290292 case SECTION_ENTRIES:291- out.entries = decodeEntriesSection(data, FORMAT_V3);293+ out.entries = decodeEntriesSection(data, formatVersion);292294 break;293295 case SECTION_SETTINGS:294296 out.settings = decodeJsonSection<VaultSettings>(data);@@ -460,10 +462,10 @@460462 // flags placeholder (we'll overwrite at the end)461463 const flagOffset = writer.length;462464 writer.u8(0);463- // v3-only flags2 placeholder, written for every entry so the section465+ // v3+ flags2 placeholder, written for every entry so the section464466 // layout stays uniform across types.465467 let flags2Offset = -1;466- if (formatVersion === FORMAT_V3) {468+ if (formatVersion >= FORMAT_V3) {467469 flags2Offset = writer.length;468470 writer.u8(0);469471 }@@ -480,8 +482,8 @@480482 if (type === ENTRY_TYPE_LOGIN) {481483 writer.u16(table.intern(entry.url));482484 writer.u16(table.intern(entry.username));483- writer.lpString(entry.password ?? "", 1);484- if (formatVersion === FORMAT_V3 && entry.email) {485+ writer.lpString(entry.password ?? "", formatVersion >= FORMAT_V4 ? 2 : 1);486+ if (formatVersion >= FORMAT_V3 && entry.email) {485487 flags2 |= F2_HAS_EMAIL;486488 writer.lpString(entry.email, 1);487489 }@@ -594,7 +596,7 @@594596 const type = reader.u8();595597 const id = bytesToUuid(reader.read(16));596598 const flags = reader.u8();597- const flags2 = formatVersion === FORMAT_V3 ? reader.u8() : 0;599+ const flags2 = formatVersion >= FORMAT_V3 ? reader.u8() : 0;598600 const createdSec = reader.u32();599601 const updatedDelta = reader.u32();600602 const deletedSec = reader.u32();@@ -616,7 +618,7 @@616618 if (type === ENTRY_TYPE_LOGIN) {617619 const urlIdx = reader.u16();618620 const usernameIdx = reader.u16();619- const password = reader.lpString(1);621+ const password = reader.lpString(formatVersion >= FORMAT_V4 ? 2 : 1);620622 const tail: Partial<VaultEntry> = {};621623 622624 if (flags2 & F2_HAS_EMAIL) tail.email = reader.lpString(1);