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
↓ peach-browser-900ec1e6.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/device-manifest.test.ts+72 −0src/shared/device-manifest.ts+177 −80manifest.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": {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",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",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,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