de56d93b26ce

fix: treat wsPort/relayUrl null same as undefined in manifest validation + normalise (v4.7.9)

Root cause layer 3: the v4.7.4 canonical fix strips null from canonical bytes (so Ed25519 signatures match between browser and Android), but the body validation runs on the RAW parsed JSON where wsPort is null. The guard was d.wsPort !== undefined — and null !== undefined is true, so typeof null !== 'number' (typeof null is 'object') throws 'device.wsPort invalid'. Android's Kotlin nullable Int serialises as JSON null when no LAN port is configured. Fix: !== undefined → != null (catches both null and undefined) in both the validation block and normaliseEntry. Same for relayUrl. Regression test: inject wsPort:null into a signed manifest body (simulating the wire), verify it passes validation without throwing. Three layers of the same root cause now fixed: v4.7.4 — canonicaliser skips null fields (signature bytes match) v4.7.8 — validator accepts wsPort:0 (Kotlin Int default) v4.7.9 — validator accepts wsPort:null (Kotlin nullable Int → JSON null) Diag lines from v4.7.7 kept for one more live round. Gates: tsc RC=0, device-manifest 15/15, build clean.

time
2026-08-01 17:25 UTC · 2 months ago
machine
koh2
parent
ceeb0d1edf66
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/device-manifest.test.ts+12 −0src/shared/device-manifest.ts+4 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.7.8",4+  "version": "4.7.9",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.8",5+  "version": "4.7.9",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.8",3+  "version": "4.7.9",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/device-manifest.test.ts+12 −0
@@ -251,4 +251,16 @@251251     const body = await verifyDeviceManifest(m, kp.publicKeyRaw);252252     expect(body.devices[0].wsPort).toBe(0);253253   });254+255+  it("does not reject wsPort:null on the wire (Android nullable Int → JSON null)", async () => {256+    const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);257+    // Build a valid manifest first, then inject wsPort:null into the body258+    // to simulate what Android puts on the wire (nullable Int serialised as259+    // JSON null). The canonicaliser strips null, so the signature still260+    // verifies — but the validator must not throw on the raw parsed body.261+    const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [entry()], kp.privateKey);262+    (m.body.devices[0] as unknown as Record<string, unknown>).wsPort = null;263+    const body = await verifyDeviceManifest(m, kp.publicKeyRaw);264+    expect(body.devices).toHaveLength(1);265+  });254266 });

view file

src/shared/device-manifest.ts+4 −4
@@ -151,7 +151,7 @@151151       throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");152152     }153153     if (154-      d.wsPort !== undefined &&154+      d.wsPort != null &&155155       (156156         typeof d.wsPort !== "number" ||157157         !Number.isInteger(d.wsPort) ||@@ -161,7 +161,7 @@161161     ) {162162       throw new DeviceManifestError("malformed", "device.wsPort invalid");163163     }164-    if (d.relayUrl !== undefined) {164+    if (d.relayUrl != null) {165165       try {166166         normalizeRelayUrl(d.relayUrl);167167       } catch {@@ -217,8 +217,8 @@217217     signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),218218     pairedAt: d.pairedAt,219219   };220-  if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;221-  if (d.relayUrl !== undefined) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);220+  if (d.wsPort != null) normalised.wsPort = d.wsPort;221+  if (d.relayUrl != null) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);222222   return normalised;223223 }224224 

view file