From c86aef5c6d58a657fe8cf3c48213d66420855428 Mon Sep 17 00:00:00 2001 From: juice666 Date: Thu, 30 Jul 2026 01:40:05 +0800 Subject: [PATCH 1/2] test: enforce Cloudflare PBKDF2 limit --- tests/pbkdf2-cloudflare.test.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/pbkdf2-cloudflare.test.mjs diff --git a/tests/pbkdf2-cloudflare.test.mjs b/tests/pbkdf2-cloudflare.test.mjs new file mode 100644 index 0000000..cfb1113 --- /dev/null +++ b/tests/pbkdf2-cloudflare.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('PBKDF2 password hashes stay within the Cloudflare Workers limit', () => { + const source = readFileSync(new URL('../functions/api/[[path]].js', import.meta.url), 'utf8'); + const iterations = Number(source.match(/async function passwordHash[\s\S]{0,300}?iterations\s*=\s*(\d+)/)?.[1]); + + assert.equal(Number.isInteger(iterations), true, 'PBKDF2 iteration count was not found'); + assert.ok(iterations <= 100_000, `Cloudflare Workers supports at most 100000 iterations, received ${iterations}`); +}); From 50ead32d2ee1ee29b5cd0e628c4519fc6e3b173b Mon Sep 17 00:00:00 2001 From: juice666 Date: Thu, 30 Jul 2026 01:40:23 +0800 Subject: [PATCH 2/2] fix: cap PBKDF2 iterations for Cloudflare --- functions/api/[[path]].js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/api/[[path]].js b/functions/api/[[path]].js index f17d0a1..7548ef0 100644 --- a/functions/api/[[path]].js +++ b/functions/api/[[path]].js @@ -17,8 +17,8 @@ async function hmacHex(secret, message) { function bytesEqual(left, right) { if (left.length !== right.length) return false; let diff = 0; for (let i = 0; i < left.length; i++) diff |= left[i] ^ right[i]; return diff === 0; } function base64Bytes(bytes) { let output = ''; for (const byte of bytes) output += String.fromCharCode(byte); return btoa(output); } function decodeBase64Bytes(value) { const binary = atob(value); return Uint8Array.from(binary, char => char.charCodeAt(0)); } -async function passwordHash(password, salt = crypto.getRandomValues(new Uint8Array(16)), iterations = 210000) { const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']); const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, 256); return `pbkdf2$${iterations}$${base64Bytes(salt)}$${base64Bytes(new Uint8Array(bits))}`; } -async function passwordMatches(password, stored) { try { if (/^[0-9a-f]{64}$/i.test(stored || '')) return bytesEqual(new TextEncoder().encode(await sha256(password)), new TextEncoder().encode(stored.toLowerCase())); const [kind, rawIterations, rawSalt, rawHash] = String(stored || '').split('$'); if (kind !== 'pbkdf2') return false; const iterations = Number(rawIterations); if (!Number.isInteger(iterations) || iterations < 100000 || iterations > 1000000) return false; const salt = decodeBase64Bytes(rawSalt); const expected = decodeBase64Bytes(rawHash); const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']); const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, expected.byteLength * 8); return bytesEqual(new Uint8Array(bits), expected); } catch { return false; } } +async function passwordHash(password, salt = crypto.getRandomValues(new Uint8Array(16)), iterations = 100000) { const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']); const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, 256); return `pbkdf2$${iterations}$${base64Bytes(salt)}$${base64Bytes(new Uint8Array(bits))}`; } +async function passwordMatches(password, stored) { try { if (/^[0-9a-f]{64}$/i.test(stored || '')) return bytesEqual(new TextEncoder().encode(await sha256(password)), new TextEncoder().encode(stored.toLowerCase())); const [kind, rawIterations, rawSalt, rawHash] = String(stored || '').split('$'); if (kind !== 'pbkdf2') return false; const iterations = Number(rawIterations); if (!Number.isInteger(iterations) || iterations < 100000 || iterations > 100000) return false; const salt = decodeBase64Bytes(rawSalt); const expected = decodeBase64Bytes(rawHash); const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']); const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, expected.byteLength * 8); return bytesEqual(new Uint8Array(bits), expected); } catch { return false; } } async function sessionToken() { const bytes = crypto.getRandomValues(new Uint8Array(32)); return base64Bytes(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function updateManifest(component, sha, length) { return `v1\n${component}\n${sha}\n${length}\n`; }