import { ab2hex, hash, hashHex, hmacHex, randomBytes, randomInt, } from './helpers.js'; const DEFAULT_MAX_NUMBER = 1e6; const DEFAULT_SALT_LEN = 12; const DEFAULT_ALG = 'SHA-256'; /** * Creates a challenge for the client to solve. * * @param {ChallengeOptions} options - Options for creating the challenge. * @returns {Promise} The created challenge. */ export async function createChallenge(options) { const algorithm = options.algorithm || DEFAULT_ALG; const maxnumber = options.maxnumber || options.maxNumber || DEFAULT_MAX_NUMBER; const saltLength = options.saltLength || DEFAULT_SALT_LEN; const params = new URLSearchParams(options.params); if (options.expires) { params.set('expires', String(Math.floor(options.expires.getTime() / 1000))); } let salt = options.salt || ab2hex(randomBytes(saltLength)); // params.size doesn't work with Node 16 if (Object.keys(Object.fromEntries(params)).length) { salt = salt + '?' + params.toString(); } // Add a delimiter to prevent parameter splicing if (!salt.endsWith('&')) { salt = salt + '&'; } const number = options.number === undefined ? randomInt(maxnumber) : options.number; const challenge = await hashHex(algorithm, salt + number); return { algorithm, challenge, maxnumber, salt, signature: await hmacHex(algorithm, challenge, options.hmacKey), }; } /** * Extracts parameters from the payload. * * @param {string | Payload | Challenge} payload - The payload from which to extract parameters. * @returns {Record} The extracted parameters. */ export function extractParams(payload) { if (typeof payload === 'string') { payload = JSON.parse(atob(payload)); } return Object.fromEntries(new URLSearchParams(payload?.salt?.split('?')?.[1] || '')); } /** * Verifies the solution provided by the client. * * @param {string | Payload} payload - The payload to verify. * @param {string} hmacKey - The HMAC key used for verification. * @param {boolean} [checkExpires=true] - Whether to check if the challenge has expired. * @returns {Promise} Whether the solution is valid. */ export async function verifySolution(payload, hmacKey, checkExpires = true) { if (typeof payload === 'string') { try { payload = JSON.parse(atob(payload)); } catch { return false; } } const params = extractParams(payload); const expires = params.expires || params.expire; if (checkExpires && expires) { const date = new Date(parseInt(expires, 10) * 1000); if (Number.isNaN(date.getTime()) || date.getTime() < Date.now()) { return false; } } const check = await createChallenge({ algorithm: payload.algorithm, hmacKey, number: payload.number, salt: payload.salt, }); return (check.challenge === payload.challenge && check.signature === payload.signature); } /** * Verifies the hash of form fields. * * @param {FormData | Record} formData - The form data to verify. * @param {string[]} fields - The fields to include in the hash. * @param {string} fieldsHash - The expected hash of the fields. * @param {string} [algorithm=DEFAULT_ALG] - The hash algorithm to use. * @returns {Promise} Whether the fields hash is valid. */ export async function verifyFieldsHash(formData, fields, fieldsHash, algorithm = DEFAULT_ALG) { const data = formData instanceof FormData ? Object.fromEntries(formData) : formData; const lines = []; for (const field of fields) { lines.push(String(data[field] || '')); } return (await hashHex(algorithm, lines.join('\n'))) === fieldsHash; } /** * Verifies the server's signature. * * @param {string | ServerSignaturePayload} payload - The payload to verify. * @param {string} hmacKey - The HMAC key used for verification. * @returns {Promise<{verificationData: ServerSignatureVerificationData | null, verified: boolean}>} The verification result. */ export async function verifyServerSignature(payload, hmacKey) { if (typeof payload === 'string') { try { payload = JSON.parse(atob(payload)); } catch { return { verificationData: null, verified: false, }; } } const signature = await hmacHex(payload.algorithm, await hash(payload.algorithm, payload.verificationData), hmacKey); let verificationData = null; try { const params = new URLSearchParams(payload.verificationData); verificationData = { ...Object.fromEntries(params), expire: parseInt(params.get('expire') || '0', 10), fields: params.get('fields')?.split(','), reasons: params.get('reasons')?.split(','), score: params.get('score') ? parseFloat(params.get('score') || '0') : undefined, time: parseInt(params.get('time') || '0', 10), verified: params.get('verified') === 'true', }; } catch { // noop } return { verificationData, verified: payload.verified === true && verificationData?.verified === true && verificationData.expire > Math.floor(Date.now() / 1000) && payload.signature === signature, }; } /** * Solves a challenge by brute force. * * @param {string} challenge - The challenge to solve. * @param {string} salt - The salt used in the challenge. * @param {string} [algorithm='SHA-256'] - The hash algorithm used. * @param {number} [max=1e6] - The maximum number to try. * @param {number} [start=0] - The starting number. * @returns {{promise: Promise, controller: AbortController}} The solution promise and abort controller. */ export function solveChallenge(challenge, salt, algorithm = 'SHA-256', max = 1e6, start = 0) { const controller = new AbortController(); const startTime = Date.now(); const fn = async () => { for (let n = start; n <= max; n += 1) { if (controller.signal.aborted) { return null; } const t = await hashHex(algorithm, salt + n); if (t === challenge) { return { number: n, took: Date.now() - startTime, }; } } return null; }; return { promise: fn(), controller, }; } /** * Solves a challenge using web workers for parallel computation. * * @param {string | URL | (() => Worker)} workerScript - The worker script or function to create a worker. * @param {number} concurrency - The number of workers to use. * @param {string} challenge - The challenge to solve. * @param {string} salt - The salt used in the challenge. * @param {string} [algorithm='SHA-256'] - The hash algorithm used. * @param {number} [max=1e6] - The maximum number to try. * @param {number} [startNumber=0] - The starting number. * @returns {Promise} The solution, or null if not found. */ export async function solveChallengeWorkers(workerScript, concurrency, challenge, salt, algorithm = 'SHA-256', max = 1e6, startNumber = 0) { const workers = []; concurrency = Math.max(1, Math.min(16, concurrency)); for (let i = 0; i < concurrency; i++) { if (typeof workerScript === 'function') { workers.push(workerScript()); } else { workers.push(new Worker(workerScript, { type: 'module', })); } } const step = Math.ceil(max / concurrency); const solutions = await Promise.all(workers.map((worker, i) => { const start = startNumber + i * step; return new Promise((resolve) => { worker.addEventListener('message', (message) => { if (message.data) { for (const w of workers) { if (w !== worker) { w.postMessage({ type: 'abort' }); } } } resolve(message.data); }); worker.postMessage({ payload: { algorithm, challenge, max: start + step, salt, start, }, type: 'work', }); }); })); for (const worker of workers) { worker.terminate(); } return solutions.find((solution) => !!solution) || null; } export default { createChallenge, extractParams, solveChallenge, solveChallengeWorkers, verifyFieldsHash, verifyServerSignature, verifySolution, };