a31e6dadfa2a
mirror Android's manifest bounds and reject wsPort 0 so both clients judge a signed manifest identically (v5.2.36)
- time
- 2026-09-10 17:45 UTC · 1 month ago
- machine
- koh2
- agent
- deepseek/deepseek-v4-flash
- session
- cross-client remediation
- parent
- 2bbd5cf9fdad
↓ peach-browser-a31e6dad.tar.gzatlas at this save
files changed3 files
package.json+1 −1src/shared/device-manifest.test.ts+1 −12src/shared/device-manifest.ts+30 −2package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.2.35",3+ "version": "5.2.36",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/shared/device-manifest.test.ts+1 −12
@@ -247,18 +247,7 @@247247 });248248 });249249 250-describe("verifyDeviceManifest accepts wsPort:0", () => {251- it("does not reject wsPort:0 as malformed (Android Int default = no port)", async () => {252- const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);253- const e: ManifestDeviceEntry = {254- ...entry(),255- wsPort: 0,256- };257- const m = await buildSignedDeviceManifest(ISSUER_DEVICE_ID, [e], kp.privateKey);258- const body = await verifyDeviceManifest(m, kp.publicKeyRaw);259- expect(body.devices[0].wsPort).toBe(0);260- });261-250+describe("verifyDeviceManifest accepts wsPort:null", () => {262251 it("does not reject wsPort:null on the wire (Android nullable Int → JSON null)", async () => {263252 const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);264253 // Build a valid manifest first, then inject wsPort:null into the bodysrc/shared/device-manifest.ts+30 −2
@@ -79,6 +79,15 @@7979 );8080 return { body, signatureHex: bytesToHex(signature) };8181 }82+/**83+ * Manifest bounds, mirrored from Android's `DeviceManifestSupport`. A correctly84+ * signed manifest must be accepted or rejected identically by both clients, so85+ * these are contract, not defence-in-depth.86+ */87+const MAX_MANIFEST_DEVICES = 256;88+const MAX_MANIFEST_REVOCATIONS = 500;89+const MAX_MANIFEST_STRING = 256;90+8291 /** Canonical fields authenticated by an issuer for one revocation record. */8392 export function canonicalDeviceRevocation(record: DeviceRevocation): string {8493 return canonicalise({@@ -176,8 +185,24 @@176185 if (!Array.isArray(manifest.body.devices)) {177186 throw new DeviceManifestError("malformed", "devices is not an array");178187 }188+ if (189+ typeof manifest.body.issuerDeviceId !== "string" ||190+ manifest.body.issuerDeviceId.length === 0 ||191+ manifest.body.issuerDeviceId.length > MAX_MANIFEST_STRING192+ ) {193+ throw new DeviceManifestError("malformed", "issuerDeviceId invalid");194+ }195+ if (manifest.body.devices.length > MAX_MANIFEST_DEVICES) {196+ throw new DeviceManifestError("malformed", "devices exceeds MAX_MANIFEST_DEVICES");197+ }198+ if (!Array.isArray(manifest.body.revoked)) {199+ throw new DeviceManifestError("malformed", "revoked is not an array");200+ }201+ if (manifest.body.revoked.length > MAX_MANIFEST_REVOCATIONS) {202+ throw new DeviceManifestError("malformed", "revoked exceeds MAX_MANIFEST_REVOCATIONS");203+ }179204 for (const d of manifest.body.devices) {180- if (typeof d.deviceId !== "string" || !d.deviceId) {205+ if (typeof d.deviceId !== "string" || !d.deviceId || d.deviceId.length > MAX_MANIFEST_STRING) {181206 throw new DeviceManifestError("malformed", "device.deviceId missing");182207 }183208 if (typeof d.deviceName !== "string" || !d.deviceName) {@@ -199,12 +224,15 @@199224 if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {200225 throw new DeviceManifestError("malformed", "device.vouchDepth invalid");201226 }227+ if (typeof d.pairedAt !== "number" || !Number.isFinite(d.pairedAt) || d.pairedAt < 0) {228+ throw new DeviceManifestError("malformed", "device.pairedAt invalid");229+ }202230 if (203231 d.wsPort != null &&204232 (205233 typeof d.wsPort !== "number" ||206234 !Number.isInteger(d.wsPort) ||207- d.wsPort < 0 ||235+ d.wsPort < 1 ||208236 d.wsPort > 65535209237 )210238 ) {