470beed172ad

fix: canonicalise skips null fields to match Android manifestEntryMap (v4.7.4)

time
2026-08-01 15:38 UTC · 2 months ago
machine
koh2
parent
7af03d846f2e
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-vault-adapter.ts+6 −52src/shared/device-manifest.test.ts+47 −0src/shared/device-manifest.ts+4 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.7.3",4+  "version": "4.7.4",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": "4.7.3",5+  "version": "4.7.4",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": "4.7.3",3+  "version": "4.7.4",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-vault-adapter.ts+6 −52
@@ -35,7 +35,6 @@3535 } from "../shared/sync-identity";3636 import {3737   buildSignedDeviceManifest,38-  canonicalise,3938   verifyDeviceManifest,4039 } from "../shared/device-manifest";4140 import { constantTimeEqualHex } from "../shared/constant-time";@@ -225,18 +224,10 @@225224 226225     async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {227226       requireUnlocked();228-      // ponytail: temporary mesh-propagation diagnostic, strip before save229-      console.info("[Peach diag] applyIncomingDeviceManifest: issuerDeviceId=" + manifest?.body?.issuerDeviceId + " devices=" + (manifest?.body?.devices ?? []).map((d) => d.deviceId).join(",") + " revoked=" + (manifest?.body?.revoked ?? []).length);230-      try {231-        await applyVerifiedPeerManifest(232-          manifest,233-          authenticatedRemoteStaticPublicKey,234-        );235-      } catch (diagError) {236-        console.info("[Peach diag] applyVerifiedPeerManifest threw:", diagError);237-      }238-      const all = await listPairedDevices();239-      console.info("[Peach diag] post-manifest paired-devices:", all.map((d) => d.deviceId + "/" + d.trustSource).join(","));227+      await applyVerifiedPeerManifest(228+        manifest,229+        authenticatedRemoteStaticPublicKey,230+      );240231     },241232   };242233 }@@ -283,18 +274,14 @@283274   manifest: SignedDeviceManifest,284275   authenticatedRemoteStaticPublicKey: Uint8Array,285276 ): Promise<void> {286-  // ponytail: temporary mesh-propagation diagnostic, strip before save287-  console.info("[Peach diag] applyVerifiedPeerManifest enter: authPubKeyLen=" + authenticatedRemoteStaticPublicKey.length + " manifestIssuer=" + manifest?.body?.issuerDeviceId);288277   const issuerId = manifest?.body?.issuerDeviceId;289-  if (typeof issuerId !== "string" || !issuerId) { console.info("[Peach diag] early return: missing issuerId"); return; }278+  if (typeof issuerId !== "string" || !issuerId) { return; }290279 291280   if (authenticatedRemoteStaticPublicKey.length !== 32) {292-    console.info("[Peach diag] early return: auth key not 32 bytes");293281     return;294282   }295283   const authenticatedPubkeyHex = pubkeyToHex(authenticatedRemoteStaticPublicKey);296284   const issuer = await findPairedDeviceByPubkey(authenticatedPubkeyHex);297-  console.info("[Peach diag] lookup issuer: found=" + (issuer !== null) + " storedDeviceId=" + (issuer === null ? "<none>" : issuer.deviceId) + " manifestIssuerId=" + issuerId + " trustSource=" + (issuer === null ? "<none>" : issuer.trustSource) + " hasSigningKey=" + (issuer !== null && issuer.signingPublicKeyHex !== undefined));298285   if (299286     !issuer ||300287     issuer.deviceId !== issuerId ||@@ -305,33 +292,16 @@305292     !issuer.signingPublicKeyHex ||306293     !/^[0-9a-f]{64}$/.test(issuer.signingPublicKeyHex)307294   ) {308-    console.info("[Peach diag] early return: issuer gate");309295     return;310296   }311297 312298   let body: Awaited<ReturnType<typeof verifyDeviceManifest>>;313-  // ponytail: temporary mesh-propagation diagnostic, strip before save314-  console.info("[Peach diag] verifyDeviceManifest sigHex=" + manifest.signatureHex?.slice(0, 16) + "... bodyIssuerId=" + manifest.body?.issuerDeviceId + " bodyIssuedAt=" + manifest.body?.issuedAt + " bodyDeviceCount=" + (manifest.body?.devices?.length ?? -1) + " deviceIds=" + (manifest.body?.devices ?? []).map((d) => d.deviceId).join(","));315299   try {316300     body = await verifyDeviceManifest(317301       manifest,318302       hexToPubkey(issuer!.signingPublicKeyHex!),319303     );320-    // ponytail: temporary mesh-propagation diagnostic, strip before save321-    console.info("[Peach diag] verifyDeviceManifest OK with stored key");322304   } catch (storedError) {323-    // ponytail: temporary mesh-propagation diagnostic, strip before save324-    console.info("[Peach diag] verifyDeviceManifest with stored key threw:", String(storedError));325-    // ponytail: temporary mesh-propagation diagnostic, strip before save326-    {327-      const canonicalStr = canonicalise(manifest.body);328-      // ponytail: dump the first device entry in full + head of revoked329-      const firstDevice = manifest.body?.devices?.[0];330-      const firstDeviceStr = firstDevice ? JSON.stringify(firstDevice) : "<none>";331-      console.info("[Peach diag] canonical len=" + canonicalStr.length + " firstDevice=" + firstDeviceStr);332-      const revokedArr = manifest.body?.revoked ?? [];333-      console.info("[Peach diag] revoked count=" + revokedArr.length + " firstRevoked=" + (revokedArr[0] ? JSON.stringify(revokedArr[0]) : "<none>"));334-    }335305     // Stored key may be stale (older builds persisted the key derived at336306     // first-pair and never refreshed it). The signing keypair is deterministic337307     // from (masterKey, deviceId) on both sides, so retry once with the locally@@ -343,22 +313,14 @@343313         masterForIssuer,344314         issuer.deviceId,345315       );346-      // ponytail: temporary mesh-propagation diagnostic, strip before save347-      const derivedHex = Array.from(derived.publicKeyRaw).map((b) => b.toString(16).padStart(2, "0")).join("");348-      // ponytail: temporary mesh-propagation diagnostic, strip before save349-      console.info("[Peach diag] retry with derived key: storedSigning=" + issuer!.signingPublicKeyHex!.slice(0,12) + " derivedSigning=" + derivedHex.slice(0,12));350316       body = await verifyDeviceManifest(manifest, derived.publicKeyRaw);351-      console.info("[Peach diag] verifyDeviceManifest OK with derived key");352317     } catch (derivedError) {353-      // ponytail: temporary mesh-propagation diagnostic, strip before save354-      console.info("[Peach diag] verifyDeviceManifest with derived key threw:", String(derivedError));355318       return;356319     } finally {357320       masterForIssuer.fill(0);358321     }359322   }360-  console.info("[Peach diag] body parsed: bodyIssuerId=" + body.issuerDeviceId + " issuerStoredId=" + issuer.deviceId);361-  if (body.issuerDeviceId !== issuer.deviceId) { console.info("[Peach diag] early return: body issuer mismatch"); return; }323+  if (body.issuerDeviceId !== issuer.deviceId) { return; }362324 363325   const ourDeviceId = await getOrCreateDeviceId();364326   const masterKey = requireMasterKey();@@ -388,8 +350,6 @@388350 389351     for (const entry of body.devices) {390352       if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {391-        // ponytail: temporary mesh-propagation diagnostic, strip before save392-        console.info("[Peach diag] skip entry (self or issuer):", entry.deviceId);393353         continue;394354       }395355       // Terminal: no manifest re-admits a revoked device. Matched on the key as@@ -398,8 +358,6 @@398358       // ahead of the existing-device lookup so it also refuses rotating a device399359       // we still trust onto a revoked key.400360       if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {401-        // ponytail: temporary mesh-propagation diagnostic, strip before save402-        console.info("[Peach diag] skip entry (revoked):", entry.deviceId, "longTerm=" + entry.longTermPublicKeyHex?.slice(0, 12) + "...", "hasRelay=" + !!entry.relayUrl);403361         continue;404362       }405363       const existing = await findPairedDeviceById(entry.deviceId);@@ -408,13 +366,9 @@408366         localKeys.publicKey,409367         entry,410368       );411-      // ponytail: temporary mesh-propagation diagnostic, strip before save412369       if (existing) {413-        console.info("[Peach diag] skip entry (existing):", entry.deviceId, "trustSource=" + existing.trustSource);414370       } else if (!meshRoute) {415-        console.info("[Peach diag] skip entry (no mesh route):", entry.deviceId, "hasRelay=" + !!entry.relayUrl);416371       } else {417-        console.info("[Peach diag] admit entry:", entry.deviceId, "relay=" + meshRoute.relayUrl);418372       }419373 420374       // A v3 manifest from an admitted, KK-authenticated issuer proves

view file

src/shared/device-manifest.test.ts+47 −0
@@ -179,3 +179,50 @@179179     ).toBe(CANONICAL);180180   });181181 });182+183+describe("canonicalise — null fields are omitted (cross-platform parity)", () => {184+  // Android's manifestEntryMap only inserts wsPort / relayUrl when they are185+  // non-null. Match that on the browser side so the manifest signed by186+  // Android verifies against the canonical bytes the browser recomputes.187+  // Without this, the wire would carry "wsPort":null and the browser would188+  // include it in the canonical bytes while Android would omit it — the189+  // two signatures would never agree even on a legitimate manifest.190+  it("omits null wsPort and null relayUrl at the device-entry level", () => {191+    const a = canonicalise({192+      deviceId: "peer-1",193+      deviceName: "Peer One",194+      deviceType: "extension",195+      longTermPublicKeyHex: "aa".repeat(32),196+      signingPublicKeyHex: "bb".repeat(32),197+      pairedAt: 1_700_000_000_000,198+      wsPort: null,199+      relayUrl: null,200+    });201+    const b = canonicalise({202+      deviceId: "peer-1",203+      deviceName: "Peer One",204+      deviceType: "extension",205+      longTermPublicKeyHex: "aa".repeat(32),206+      signingPublicKeyHex: "bb".repeat(32),207+      pairedAt: 1_700_000_000_000,208+    });209+    expect(a).toBe(b);210+    expect(a).not.toContain("wsPort");211+    expect(a).not.toContain("relayUrl");212+  });213+214+  it("keeps non-null wsPort and relayUrl", () => {215+    const got = canonicalise({216+      deviceId: "peer-1",217+      deviceName: "Peer One",218+      deviceType: "extension",219+      longTermPublicKeyHex: "aa".repeat(32),220+      signingPublicKeyHex: "bb".repeat(32),221+      pairedAt: 1_700_000_000_000,222+      wsPort: 49142,223+      relayUrl: "wss://relay.peachpasswords.com",224+    });225+    expect(got).toContain('"wsPort":49142');226+    expect(got).toContain('"relayUrl":"wss://relay.peachpasswords.com"');227+  });228+});

view file

src/shared/device-manifest.ts+4 −1
@@ -242,9 +242,12 @@242242   if (typeof value === "object") {243243     const obj = value as Record<string, unknown>;244244     const keys = Object.keys(obj).sort();245+    // ponytail: skip null values so canonical bytes match Android's246+    //            manifestEntryMap, which omits wsPort and relayUrl when null.247+    const present = keys.filter((k) => obj[k] !== null);245248     return (246249       "{" +247-      keys.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +250+      present.map((k) => JSON.stringify(k) + ":" + canonicalise(obj[k])).join(",") +248251       "}"249252     );250253   }

view file