ceeb0d1edf66

fix: accept wsPort:0 as valid no-port sentinel in manifest validation (v4.7.8)

Root cause from live diag (v4.7.7): Android sends wsPort:0 (Kotlin Int default for no-port) in the manifest body. The browser's body validation rejected anything below 1, throwing 'device.wsPort invalid' BEFORE the Ed25519 signature was even checked. The v4.7.4 canonical fix was necessary but not sufficient — this validation gate was the second blocker. Fix: d.wsPort < 1 → d.wsPort < 0. Port 0 is a valid sentinel meaning 'no LAN port'. Both canonicalisers (browser + Android) include wsPort:0 in their bytes (neither filters 0, only null), so signatures match. Two regression tests: canonicalise preserves wsPort:0; verifyDeviceManifest accepts a signed manifest carrying wsPort:0. Diag lines from v4.7.7 kept for one more live round. Gates: tsc RC=0, device-manifest 14/14, build clean.

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

view file

src/shared/device-manifest.test.ts+26 −0
@@ -225,4 +225,30 @@225225     expect(got).toContain('"wsPort":49142');226226     expect(got).toContain('"relayUrl":"wss://relay.peachpasswords.com"');227227   });228+229+  it("preserves wsPort:0 in canonical bytes (Android sends 0 as no-port sentinel)", () => {230+    const got = canonicalise({231+      deviceId: "peer-1",232+      deviceName: "Peer One",233+      deviceType: "extension",234+      longTermPublicKeyHex: "aa".repeat(32),235+      signingPublicKeyHex: "bb".repeat(32),236+      pairedAt: 1_700_000_000_000,237+      wsPort: 0,238+    });239+    expect(got).toContain('"wsPort":0');240+  });241+});242+243+describe("verifyDeviceManifest accepts wsPort:0", () => {244+  it("does not reject wsPort:0 as malformed (Android Int default = no port)", async () => {245+    const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);246+    const e: ManifestDeviceEntry = {247+      ...entry(),248+      wsPort: 0,249+    };250+    const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [e], kp.privateKey);251+    const body = await verifyDeviceManifest(m, kp.publicKeyRaw);252+    expect(body.devices[0].wsPort).toBe(0);253+  });228254 });

view file

src/shared/device-manifest.ts+1 −1
@@ -155,7 +155,7 @@155155       (156156         typeof d.wsPort !== "number" ||157157         !Number.isInteger(d.wsPort) ||158-        d.wsPort < 1 ||158+        d.wsPort < 0 ||159159         d.wsPort > 65535160160       )161161     ) {

view file