900ec1e6d886

preverify device manifest budgets before canonicalization (v5.2.60)

time
2026-09-15 13:53 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
close latest browser audits
parent
7f2cc11320ef
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/device-manifest.test.ts+72 −0src/shared/device-manifest.ts+177 −80
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.59",4+  "version": "5.2.60",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": "5.2.59",5+  "version": "5.2.60",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": "5.2.59",3+  "version": "5.2.60",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/device-manifest.test.ts+72 −0
@@ -131,6 +131,78 @@131131     } as DeviceManifestError);132132   });133133 134+  it("rejects oversized arrays before signature verification", async () => {135+    const m = await buildSignedDeviceManifest(136+      ISSUER_DEVICE_ID,137+      [entry()],138+      signingPrivateKey,139+    );140+    const oversized = {141+      ...m,142+      body: { ...m.body, devices: new Array(257).fill(entry()) },143+    };144+145+    await expect(verifyDeviceManifest(oversized, signingPubkey)).rejects.toMatchObject({146+      kind: "malformed",147+    } as DeviceManifestError);148+  });149+150+  it("rejects unknown nested structures before canonicalization", async () => {151+    const m = await buildSignedDeviceManifest(152+      ISSUER_DEVICE_ID,153+      [entry()],154+      signingPrivateKey,155+    );156+    const cycle: Record<string, unknown> = {};157+    cycle.self = cycle;158+    const body = { ...m.body, unexpected: cycle } as unknown as typeof m.body;159+160+    await expect(161+      verifyDeviceManifest({ ...m, body }, signingPubkey),162+    ).rejects.toMatchObject({ kind: "malformed" } as DeviceManifestError);163+  });164+165+  it("rejects invalid numeric ranges before signature verification", async () => {166+    const m = await buildSignedDeviceManifest(167+      ISSUER_DEVICE_ID,168+      [entry()],169+      signingPrivateKey,170+    );171+    const outOfRange = {172+      ...m,173+      body: {174+        ...m.body,175+        devices: [{ ...entry(), wsPort: 65_536 }],176+      },177+    };178+179+    await expect(verifyDeviceManifest(outOfRange, signingPubkey)).rejects.toMatchObject({180+      kind: "malformed",181+    } as DeviceManifestError);182+  });183+184+  it.each([185+    ["device name", { deviceName: "é".repeat(129) }],186+    ["relay URL", { relayUrl: `wss://${"a".repeat(2_049)}.example` }],187+  ])("rejects an over-budget UTF-8 %s before signature verification", async (_label, fields) => {188+    const m = await buildSignedDeviceManifest(189+      ISSUER_DEVICE_ID,190+      [entry()],191+      signingPrivateKey,192+    );193+    const oversized = {194+      ...m,195+      body: {196+        ...m.body,197+        devices: [{ ...entry(), ...fields }],198+      },199+    };200+201+    await expect(verifyDeviceManifest(oversized, signingPubkey)).rejects.toMatchObject({202+      kind: "malformed",203+    } as DeviceManifestError);204+  });205+134206   it("rejects oversized revocation identifiers", async () => {135207     const m = await buildSignedDeviceManifest(136208       ISSUER_DEVICE_ID,

view file

src/shared/device-manifest.ts+177 −80
@@ -79,14 +79,43 @@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- */82+/** Entry counts and identifier lengths mirror Android's manifest contract. */8783 const MAX_MANIFEST_DEVICES = 256;8884 const MAX_MANIFEST_REVOCATIONS = 500;8985 const MAX_MANIFEST_STRING = 256;86+// Three UTF-8 bytes per UTF-16 code unit preserves the existing identifier limit.87+const MAX_MANIFEST_IDENTIFIER_BYTES = MAX_MANIFEST_STRING * 3;88+const MAX_MANIFEST_DEVICE_NAME_BYTES = 256;89+const MAX_MANIFEST_RELAY_URL_BYTES = 2_048;90+const MAX_REVOCATION_SIGNATURE_BYTES = 128;91+const UTF8_ENCODER = new TextEncoder();92+const MANIFEST_FIELDS = new Set(["body", "signatureHex"]);93+const MANIFEST_BODY_FIELDS = new Set([94+  "manifestVersion",95+  "issuerDeviceId",96+  "issuedAt",97+  "devices",98+  "revoked",99+]);100+const MANIFEST_DEVICE_FIELDS = new Set([101+  "deviceId",102+  "deviceName",103+  "deviceType",104+  "longTermPublicKeyHex",105+  "signingPublicKeyHex",106+  "relayUrl",107+  "wsPort",108+  "pairedAt",109+  "vouchDepth",110+]);111+const MANIFEST_REVOCATION_FIELDS = new Set([112+  "deviceId",113+  "longTermPublicKeyHex",114+  "revokedAt",115+  "revokedByDeviceId",116+  "recordVersion",117+  "signatureHex",118+]);90119 91120 /** Canonical fields authenticated by an issuer for one revocation record. */92121 export function canonicalDeviceRevocation(record: DeviceRevocation): string {@@ -131,34 +160,62 @@131160   }132161 }133162 134-/**135- * Verify a manifest's signature, version, and freshness window. Throws on136- * any failure; returns the body on success. The caller is responsible for137- * looking up the issuer's signing pubkey from the paired-device registry138- * and passing it in — that lookup is what enforces "manifest from a known139- * device" (§4.3 final paragraph).140- */141-export async function verifyDeviceManifest(163+function isRecord(value: unknown): value is Record<string, unknown> {164+  return value !== null && typeof value === "object" && !Array.isArray(value);165+}166+167+function assertAllowedFields(168+  value: Record<string, unknown>,169+  allowed: ReadonlySet<string>,170+  label: string,171+): void {172+  for (const field in value) {173+    if (Object.hasOwn(value, field) && !allowed.has(field)) {174+      throw new DeviceManifestError("malformed", `${label}.${field} is not allowed`);175+    }176+  }177+}178+179+function fitsUtf8Budget(value: string, maxBytes: number): boolean {180+  const buffer = new Uint8Array(maxBytes + 1);181+  const { read, written } = UTF8_ENCODER.encodeInto(value, buffer);182+  return read === value.length && written <= maxBytes;183+}184+185+function isBoundedIdentifier(value: unknown): value is string {186+  return (187+    typeof value === "string" &&188+    value.length > 0 &&189+    value.length <= MAX_MANIFEST_STRING &&190+    fitsUtf8Budget(value, MAX_MANIFEST_IDENTIFIER_BYTES)191+  );192+}193+194+function preflightDeviceManifest(142195   manifest: SignedDeviceManifest,143-  issuerSigningPubkey: Uint8Array,144-  options: { now?: number; freshnessMs?: number } = {},145-): Promise<DeviceManifestBody> {146-  if (!manifest || typeof manifest !== "object") {196+  now: number,197+  freshness: number,198+): void {199+  if (!isRecord(manifest)) {147200     throw new DeviceManifestError("malformed", "manifest is not an object");148201   }149-  if (!manifest.body || !manifest.signatureHex) {202+  assertAllowedFields(manifest, MANIFEST_FIELDS, "manifest");203+  if (!isRecord(manifest.body) || typeof manifest.signatureHex !== "string") {150204     throw new DeviceManifestError("malformed", "manifest missing body or signature");151205   }206+  if (manifest.signatureHex.length !== 128 || !/^[0-9a-f]+$/i.test(manifest.signatureHex)) {207+    throw new DeviceManifestError("signature", "signature must be 64-byte hex");208+  }209+  assertAllowedFields(manifest.body, MANIFEST_BODY_FIELDS, "manifest.body");152210   if (manifest.body.manifestVersion !== DEVICE_MANIFEST_VERSION) {153211     throw new DeviceManifestError(154212       "version",155213       `unsupported manifest version ${manifest.body.manifestVersion}`,156214     );157215   }158-  const now = options.now ?? Date.now();159-  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;160216   if (161217     typeof manifest.body.issuedAt !== "number" ||218+    !Number.isFinite(manifest.body.issuedAt) ||162219     Math.abs(now - manifest.body.issuedAt) > freshness163220   ) {164221     throw new DeviceManifestError(@@ -166,32 +223,12 @@166223       `manifest issuedAt ${manifest.body.issuedAt} outside ±${freshness}ms window`,167224     );168225   }169-170-  const signature = hexToBytes(manifest.signatureHex);171-  if (signature.length !== 64) {172-    throw new DeviceManifestError("signature", "signature must be 64 bytes");226+  if (!isBoundedIdentifier(manifest.body.issuerDeviceId)) {227+    throw new DeviceManifestError("malformed", "issuerDeviceId invalid");173228   }174-  const canonical = canonicalise(manifest.body);175-  const ok = await verifyEd25519(176-    issuerSigningPubkey,177-    new TextEncoder().encode(canonical),178-    signature,179-  );180-  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");181-182-  // Body shape — minimum guarantees the caller relies on. We do not183-  // enforce uniqueness of deviceId here; the registry-upsert side handles184-  // duplicates by deviceId.185229   if (!Array.isArray(manifest.body.devices)) {186230     throw new DeviceManifestError("malformed", "devices is not an array");187231   }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-  }195232   if (manifest.body.devices.length > MAX_MANIFEST_DEVICES) {196233     throw new DeviceManifestError("malformed", "devices exceeds MAX_MANIFEST_DEVICES");197234   }@@ -201,92 +238,152 @@201238   if (manifest.body.revoked.length > MAX_MANIFEST_REVOCATIONS) {202239     throw new DeviceManifestError("malformed", "revoked exceeds MAX_MANIFEST_REVOCATIONS");203240   }204-  for (const d of manifest.body.devices) {205-    if (typeof d.deviceId !== "string" || !d.deviceId || d.deviceId.length > MAX_MANIFEST_STRING) {241+242+  for (const device of manifest.body.devices) {243+    if (!isRecord(device)) {244+      throw new DeviceManifestError("malformed", "device is not an object");245+    }246+    assertAllowedFields(device, MANIFEST_DEVICE_FIELDS, "device");247+    if (!isBoundedIdentifier(device.deviceId)) {206248       throw new DeviceManifestError("malformed", "device.deviceId missing");207249     }208-    if (typeof d.deviceName !== "string" || !d.deviceName) {209-      throw new DeviceManifestError("malformed", "device.deviceName missing");250+    if (251+      typeof device.deviceName !== "string" ||252+      device.deviceName.length === 0 ||253+      !fitsUtf8Budget(device.deviceName, MAX_MANIFEST_DEVICE_NAME_BYTES)254+    ) {255+      throw new DeviceManifestError("malformed", "device.deviceName invalid");210256     }211257     if (212-      d.deviceType !== "extension" &&213-      d.deviceType !== "android" &&214-      d.deviceType !== "ios"258+      device.deviceType !== "extension" &&259+      device.deviceType !== "android" &&260+      device.deviceType !== "ios"215261     ) {216262       throw new DeviceManifestError("malformed", "device.deviceType invalid");217263     }218-    if (typeof d.longTermPublicKeyHex !== "string" || d.longTermPublicKeyHex.length !== 64) {264+    if (265+      typeof device.longTermPublicKeyHex !== "string" ||266+      device.longTermPublicKeyHex.length !== 64267+    ) {219268       throw new DeviceManifestError("malformed", "device.longTermPublicKeyHex invalid");220269     }221-    if (typeof d.signingPublicKeyHex !== "string" || d.signingPublicKeyHex.length !== 64) {270+    if (271+      typeof device.signingPublicKeyHex !== "string" ||272+      device.signingPublicKeyHex.length !== 64273+    ) {222274       throw new DeviceManifestError("malformed", "device.signingPublicKeyHex invalid");223275     }224-    if (d.vouchDepth != null && (!Number.isInteger(d.vouchDepth) || d.vouchDepth < 0 || d.vouchDepth > MAX_VOUCH_DEPTH)) {276+    if (277+      device.vouchDepth != null &&278+      (279+        !Number.isInteger(device.vouchDepth) ||280+        (device.vouchDepth as number) < 0 ||281+        (device.vouchDepth as number) > MAX_VOUCH_DEPTH282+      )283+    ) {225284       throw new DeviceManifestError("malformed", "device.vouchDepth invalid");226285     }227-    if (typeof d.pairedAt !== "number" || !Number.isFinite(d.pairedAt) || d.pairedAt < 0) {286+    if (287+      typeof device.pairedAt !== "number" ||288+      !Number.isFinite(device.pairedAt) ||289+      device.pairedAt < 0290+    ) {228291       throw new DeviceManifestError("malformed", "device.pairedAt invalid");229292     }230293     if (231-      d.wsPort != null &&294+      device.wsPort != null &&232295       (233-        typeof d.wsPort !== "number" ||234-        !Number.isInteger(d.wsPort) ||235-        d.wsPort < 1 ||236-        d.wsPort > 65535296+        typeof device.wsPort !== "number" ||297+        !Number.isInteger(device.wsPort) ||298+        device.wsPort < 1 ||299+        device.wsPort > 65_535237300       )238301     ) {239302       throw new DeviceManifestError("malformed", "device.wsPort invalid");240303     }241-    if (d.relayUrl != null) {304+    if (device.relayUrl != null) {305+      if (306+        typeof device.relayUrl !== "string" ||307+        !fitsUtf8Budget(device.relayUrl, MAX_MANIFEST_RELAY_URL_BYTES)308+      ) {309+        throw new DeviceManifestError("malformed", "device.relayUrl invalid");310+      }242311       try {243-        normalizeRelayUrl(d.relayUrl);312+        normalizeRelayUrl(device.relayUrl);244313       } catch {245314         throw new DeviceManifestError("malformed", "device.relayUrl invalid");246315       }247316     }248317   }249318 250-  if (!Array.isArray(manifest.body.revoked)) {251-    throw new DeviceManifestError("malformed", "revoked is not an array");252-  }253-  for (const r of manifest.body.revoked) {254-    if (255-      typeof r.deviceId !== "string" ||256-      !r.deviceId ||257-      r.deviceId.length > MAX_MANIFEST_STRING258-    ) {319+  for (const revocation of manifest.body.revoked) {320+    if (!isRecord(revocation)) {321+      throw new DeviceManifestError("malformed", "revocation is not an object");322+    }323+    assertAllowedFields(revocation, MANIFEST_REVOCATION_FIELDS, "revocation");324+    if (!isBoundedIdentifier(revocation.deviceId)) {259325       throw new DeviceManifestError("malformed", "revocation.deviceId invalid");260326     }261327     if (262-      typeof r.longTermPublicKeyHex !== "string" ||263-      !/^[0-9a-fA-F]{64}$/.test(r.longTermPublicKeyHex)328+      typeof revocation.longTermPublicKeyHex !== "string" ||329+      !/^[0-9a-fA-F]{64}$/.test(revocation.longTermPublicKeyHex)264330     ) {265331       throw new DeviceManifestError(266332         "malformed",267333         "revocation.longTermPublicKeyHex invalid",268334       );269335     }270-    if (typeof r.revokedAt !== "number" || !Number.isFinite(r.revokedAt)) {336+    if (typeof revocation.revokedAt !== "number" || !Number.isFinite(revocation.revokedAt)) {271337       throw new DeviceManifestError("malformed", "revocation.revokedAt invalid");272338     }273-    if (274-      typeof r.revokedByDeviceId !== "string" ||275-      !r.revokedByDeviceId ||276-      r.revokedByDeviceId.length > MAX_MANIFEST_STRING277-    ) {339+    if (!isBoundedIdentifier(revocation.revokedByDeviceId)) {278340       throw new DeviceManifestError("malformed", "revocation.revokedByDeviceId invalid");279341     }280342     if (281-      r.recordVersion != null &&282-      r.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION343+      revocation.recordVersion != null &&344+      revocation.recordVersion !== DEVICE_REVOCATION_RECORD_VERSION283345     ) {284346       throw new DeviceManifestError(285347         "malformed",286-        `revocation.recordVersion unsupported (${r.recordVersion})`,348+        `revocation.recordVersion unsupported (${revocation.recordVersion})`,287349       );288350     }351+    if (352+      revocation.signatureHex != null &&353+      (354+        typeof revocation.signatureHex !== "string" ||355+        !fitsUtf8Budget(revocation.signatureHex, MAX_REVOCATION_SIGNATURE_BYTES)356+      )357+    ) {358+      throw new DeviceManifestError("malformed", "revocation.signatureHex invalid");359+    }289360   }361+}362+363+/**364+ * Verify a manifest's signature, version, and freshness window. Throws on365+ * any failure; returns the body on success. The caller is responsible for366+ * looking up the issuer's signing pubkey from the paired-device registry367+ * and passing it in — that lookup is what enforces "manifest from a known368+ * device" (§4.3 final paragraph).369+ */370+export async function verifyDeviceManifest(371+  manifest: SignedDeviceManifest,372+  issuerSigningPubkey: Uint8Array,373+  options: { now?: number; freshnessMs?: number } = {},374+): Promise<DeviceManifestBody> {375+  const now = options.now ?? Date.now();376+  const freshness = options.freshnessMs ?? DEVICE_MANIFEST_FRESHNESS_MS;377+  preflightDeviceManifest(manifest, now, freshness);378+379+  const signature = hexToBytes(manifest.signatureHex);380+  const canonical = canonicalise(manifest.body);381+  const ok = await verifyEd25519(382+    issuerSigningPubkey,383+    new TextEncoder().encode(canonical),384+    signature,385+  );386+  if (!ok) throw new DeviceManifestError("signature", "Ed25519 signature did not verify");290387   return manifest.body;291388 }292389 

view file