UNPKG

busybot

Version:

Impose non-parallelisable proof of work to slow down pesky bots.

339 lines (299 loc) 13.6 kB
// Copyright 2025 http://github.com/autopulated // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Busybot. An implementation of the proof of work scheme used by kCTF // (https://github.com/google/kctf/blob/v1/docker-images/challenge/pow.py), to // impose expensive work on pesky bots before serving their requests. // // This implementation is pure js, with no run-time dependencies, and // compatible with any browser or js environment with BigInt support // (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt) // // We have a modulus m, which is a Mersenne prime (2^n -1) (kCTF uses n=1279. // but the ratio of difficulty of solving the challenge vs verifying it is // directly correlated to n) // // The challenge is for the client to perform a series of modular square roots // on some random value, provided by the server, flipping a bit between each // multiplication to prevent trivial shortcuts. // // For the server to verify the challenge, it will square the result, flipping // the same bits between each step, and check if it gets back to the correct // initial value. Squaring is much faster to calculate than the square root (in // this implementation, about 3000x for n=3217). // // Since Mersenne primes have remainder 3 mod 4, the square root step consists // of the calculation, x ^ ((m + 1)/4), from // https://en.wikipedia.org/wiki/Tonelli–Shanks_algorithm. // // It is worth mentioning that only half of the numbers between 0 and m have a // square root, but this scheme applies the calculation x ^ ((m + 1)/4) // indiscriminately to any number. If some x does not in fact have a square // root, then the result of squaring the result is instead, -x (mod m). So even // after any number of operations, we can only be out by a factor of -1 (this // is why the verify step checks the final result against -x as well as x.) // // For the squaring step in the verification, since a Mersenne prime is a // simple sum of 2^n - 2^0, the Barrett reduction of the square operation is // very cheap, consisting only of a few adds and shifts. // // // This proof of work scheme is useful over a hash-collision type scheme // because it is resistant to parallel implantation, not memory intensive, and // predictable in duration largely varying only by single core processing // speed, and the speed of the closest few KB of cache. A client with // significant parallel GPU compute cannot calculate a challenge dramatically // faster or cheaper than a client with a single core processor. // (And these reasons appear to be why it was chosen for kCTF // https://github.com/google/kctf/commit/b770fad71304cb060475c98bcabd2e150d217ad0) // // This scheme does of course cause all clients to 'waste' time and energy, // it's suggested to impose it only on a high rate of requests from a single ip // address, where it can avoid otherwise denying service to genuine users who // are sharing a connection: a group of 100 students in a school sharing the // same ip address are not significantly hampered by each having to wait 10 // seconds for an important request, but a single abusive client making 100 // requests would have to spend 15 minutes of computing time for the privilege. // from https://oeis.org/A000043 const Known_Mersenne_Exponents = [2, 3, 5, 7, 13, 17, 19, 31, 61, 89, 107, 127, 521, 607, 1279, 2203, 2281, 3217, 4253, 4423, 9689, 9941, 11213, 19937, 21701, 23209, 44497, 86243, 110503, 132049, 216091, 756839, 859433, 1257787, 1398269, 2976221, 3021377, 6972593, 13466917, 20996011, 24036583, 25964951, 30402457, 32582657, 37156667, 42643801, 43112609, 57885161, 74207281]; function isMersenneExponent(n) { return Known_Mersenne_Exponents.includes(Number(n)); } // The challenge works with any pattern of bits flipped between each iteration, // but the kCTF version flips only the lsb, and I can't see any reason to do // otherwise. const Flip_Bits = 1n; // pre-calculate a number of constants based on the base we're working in, and // return various functions to do modular arithmetic in this base. function initMathsFunctions({forMersenneExponent}) { // check that the chosen exponent is indeed a Mersenne prime: if (typeof forMersenneExponent !== 'number') { forMersenneExponent = Number(forMersenneExponent); } if (!isMersenneExponent(forMersenneExponent)) { throw new Error(`"${forMersenneExponent}" is not known to be a Mersenne exponent, expected one of: ${Known_Mersenne_Exponents}`); } // the modulus we're working in const modulusLog2 = BigInt(forMersenneExponent); const modulus = (1n << modulusLog2) - 1n; // exponent required to calculate the square root const exponent = (modulus + 1n) / 4n; const exponentLog2 = Number(modulusLog2) - 2; // Calculate modulus of a * b by Barrett reduction: // constants used by Barrett Reduction: // https://link.springer.com/chapter/10.1007/3-540-47721-7_24 const barrett_n = modulusLog2; const barrett_R = (1n << (2n*barrett_n)) / modulus; // this basic Barrett multiplication implementation is not used, but it is // useful to understand the faster version below. const barrettMul = function(a, b) { const w = a * b; const x2 = (w >> (barrett_n - 1n)) * barrett_R; const x3 = x2 >> (barrett_n + 1n); let x = w - (x3 * modulus); if (x >= modulus) { x -= modulus; } return x; }; // since we're using a Mersenne prime as modulus, the Barrett multiplication // can be simplified further, by noticing that barrett_R is of the form // 2^barrett_n + 1, and that modulus is 2^n -1, and multiplying by 2^n is // just a bit-shift by n bits. // if ((1n << barrett_n) + 1n !== barrett_R) { // throw new Error('emath'); // } // if ((1n << modulusLog2) - 1n !== modulus) { // throw new Error('emath'); // } const barrettFastSquare = function(a) { const w = a * a; const x2 = (w >> (barrett_n - 1n)); // multiply x2 by barrett_R, taking advantage of the fact it's 1 + 2^barrett_n: const x2m = (x2 << barrett_n) + x2; const x3 = x2m >> (barrett_n + 1n); // and multiply x3 by modulus, taking advantage of the fact modulus is 2^(modulusLog2+1) -1: let x = w - ((x3 << modulusLog2) - x3); if (x >= modulus) { x -= modulus; } return x; }; // fast modular power (raising base ^ constant exponent) which makes // assumptions to avoid unnecessary operations // // This is the classic right-to-left binary method // (https://en.wikipedia.org/wiki/Modular_exponentiation#Right-to-left_binary_method), // but with the multiplication and squaring operations replaced by // Barrett-reduction versions, it's 3-4x faster than a naïve implementation // of right-to-left binary. // // (It's relatively important that we use a fast-as-possible implementation // in the client to solve the proof of work, so that malicious clients // can't benefit from a faster re-implementation that means they do less // work than bona fide clients) const fastModPow = function(base) { // base %= modulus; if ((base >= modulus) || (base < 0n)) { throw new Error('base must be pre-divided by modulus'); } let result = 1n; let e = exponent; while (e > 0n) { if (e & 1n) { // result = (result * base) % modulus; result = barrettMul(result, base); } e >>= 1n; // base = (base ** 2n) % modulus; base = barrettFastSquare(base); } return result; }; // given that exponent is fixed, and is an even power of 2, exponentiation // by squaring can be reduced to squaring N times: // (This is not dramatically faster than fastModPow, since the number of // expensive multiplications ends up being the same, but it shows directly // how the 'solve' operation is approximately N times more difficult than // the verify operation, for a base of N bits, since solving must do these // n squaring 'difficulty' times, whereas verifying must just square once // 'difficulty' times.) const fastFixedExpPow = function(base) { let n = exponentLog2; while (n > 0) { base = barrettFastSquare(base); n -= 1; } return base; }; return { modulus, exponent, barrettMul, barrettFastSquare, fastModPow, fastFixedExpPow }; } let randomBytesP; // generate a (json-encoded) challenge. The encoding format is JSON, not the same format used by kCTF. async function generate({forMersenneExponent = 1279, withDifficulty}){ // challenge generation is only supported in a node environment if (!randomBytesP) { randomBytesP = (await import(/* webpackIgnore: true */ 'node:util')).promisify((await import(/* webpackIgnore: true */ 'node:crypto')).randomBytes); } if (!isMersenneExponent(forMersenneExponent)) { throw new Error(`"${forMersenneExponent}" is not known to be a Mersenne exponent, expected one of: ${Known_Mersenne_Exponents}`); } if (forMersenneExponent < 61) { throw new Error(`"${forMersenneExponent}" is too small of a base to produce a secure challenge.`); } if ((!Number.isSafeInteger(withDifficulty)) || withDifficulty < 0) { throw new Error('Difficulty must be a positive integer.'); } const challengeByteLength = (forMersenneExponent < 128)? Math.floor(forMersenneExponent/8) : 16; return { c: `0x${(await randomBytesP(challengeByteLength)).toString('hex')}`, d: withDifficulty, m: forMersenneExponent }; } // solve a challenge of the form: // { // c: '0xabc123......', (hex-encoded BigInt random challenge value) // d: 100, (integer, number of iterations of modular square root to calculate) // m: 3217, (a Mersenne prime exponent, of the Mersenne prime base to use for modular arithmetic) // } // returning a solution of the form: // { // s: '0xfe173.....' (hex-encoded BigInt, challenge value after d modular square roots, followed by bit flips) // } // Throws if challenge is invalid. function solve(challenge, {progressCallback}={}) { if ((!challenge) || (typeof challenge) !== 'object' || (typeof challenge.c) !== 'string' || (typeof challenge.d) !== 'number' || (typeof challenge.m) !== 'number') { throw new Error('Malformed challenge: must have .c, .m, and .d'); } if (progressCallback && (typeof progressCallback) !== 'function') { throw new Error('progressCallback must be a function'); } let solution = BigInt(challenge.c); const difficulty = challenge.d; const forMersenneExponent = challenge.m; const { fastFixedExpPow, modulus } = initMathsFunctions({ forMersenneExponent }); if (solution > modulus || solution < 0n) { throw new Error('Malformed challenge: out of range for exponent.'); } for (let i = 0; i < difficulty; i++) { solution = fastFixedExpPow(solution); solution ^= Flip_Bits; if (progressCallback){ progressCallback((i+1) / difficulty); } } return {s: `0x${solution.toString(16)}`}; } // verify a solution of the form: // { // s: '0xfe173...' (hex-encoded BigInt solution to be verified) // } // against a challenge of the same form accepted by solve(), and generated by generate(). // Returns true if the solution is correct, false otherwise. Throws for invalid arguments. function verify(challenge, solution) { if ((!challenge) || (typeof challenge) !== 'object' || (typeof challenge.c) !== 'string' || (typeof challenge.d) !== 'number' || (typeof challenge.m) !== 'number') { throw new Error('Malformed challenge: must have .c, .m, and .d'); } if ((!solution) || (typeof solution !== 'object') || (typeof solution.s) !== 'string' || !/^0x[0-9a-fA-F]+$/.exec(solution.s)) { throw new Error('Malformed solution: must have .s: hex-encoded BigInt'); } let check = BigInt(solution.s); const difficulty = challenge.d; const forMersenneExponent = challenge.m; const { barrettFastSquare, modulus } = initMathsFunctions({ forMersenneExponent }); // a malicious client could try to supply out of range values if (check >= modulus || check < 0n) { return false; } for (let i = 0; i < difficulty; i++) { check = barrettFastSquare(check ^ Flip_Bits); } const decodedChallange = BigInt(challenge.c); if (check === decodedChallange) { return true; } else if (check === (modulus - decodedChallange)){ return true; } return false; } const api = { generate, solve, verify, }; export { generate, solve, verify, initMathsFunctions }; export default api;