Files
altcha-lib/dist/index.js

245 lines
8.7 KiB
JavaScript

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<Challenge>} 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<string, string>} 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<boolean>} 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<string, unknown>} 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<boolean>} 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<Solution | null>, 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<Solution | null>} 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,
};