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
↓ peach-browser-470beed1.tar.gzatlas at this save
files changed6 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 −1manifest.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": {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",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",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 provessrc/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+});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 }