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
6 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 −16
fixtures/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+}

view file

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": {

view file

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",

view file

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",

view file

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 () => {

view file

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);

view file