From eede92d47db00aaf6f9f05b06c867af6a69fdf9f Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 1 Oct 2022 05:31:47 +1000 Subject: [PATCH 1/3] Allow a bit better granularity for the difficulty. Recommend an "easier" challenge in terms of memory and iterations, but higher diff. Make failed request for captcha/bot form show a little error text. Make CHALLENGE_INCLUDES_IP "1" = on, anything else = off instead of needing to be unset. --- docker-compose.yml | 6 +++--- haproxy/js/challenge.js | 12 ++++++++---- haproxy/js/worker.js | 6 ++++-- src/libs/utils.lua | 15 ++++++++++++++- src/scripts/hcaptcha.lua | 10 ++++++---- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 37e5e8c..41d7075 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,9 +35,9 @@ services: - BACKEND_NAME=servers - SERVER_PREFIX=websrv - CHALLENGE_INCLUDES_IP=1 - - POW_TIME=1 - - POW_KB=6000 - - POW_DIFFICULTY=3 + - POW_TIME=2 + - POW_KB=512 + - POW_DIFFICULTY=25 nginx: ports: diff --git a/haproxy/js/challenge.js b/haproxy/js/challenge.js index b1b8192..0c7fb71 100644 --- a/haproxy/js/challenge.js +++ b/haproxy/js/challenge.js @@ -19,7 +19,9 @@ function postResponse(powResponse, captchaResponse) { redirect: 'manual', }).then(res => { finishRedirect(); - }) + }).catch(err => { + document.querySelector('.lds-ring').insertAdjacentHTML('afterend', '

An error occurred.

'); + }); } const powFinished = new Promise((resolve, reject) => { @@ -33,7 +35,7 @@ const powFinished = new Promise((resolve, reject) => { type: argon2.ArgonType.Argon2id, }; console.log('Got pow', pow, 'with difficulty', diff); - const diffString = '0'.repeat(diff); + const diffString = '0'.repeat(Math.floor(diff/8)); const combined = pow; const [userkey, challenge, signature] = combined.split("#"); const start = Date.now(); @@ -59,7 +61,7 @@ const powFinished = new Promise((resolve, reject) => { } for (let i = 0; i < threads; i++) { await new Promise(res => setTimeout(res, 100)); - workers[i].postMessage([userkey, challenge, diffString, argonOpts, i, threads]); + workers[i].postMessage([userkey, challenge, diff, diffString, argonOpts, i, threads]); } } else { console.warn('No webworker support, running in main/UI thread!'); @@ -71,7 +73,9 @@ const powFinished = new Promise((resolve, reject) => { salt: userkey, ...argonOpts, }); - if (hash.hashHex.startsWith(diffString)) { + if (hash.hashHex.startsWith(diffString) + && ((parseInt(hash.hashHex[diffString.length],16) & + 0xff >> (((diffString.length+1)*8)-diff)) === 0)) { console.log('Main thread found solution:', hash.hashHex, 'in', (Date.now()-start)+'ms'); break; } diff --git a/haproxy/js/worker.js b/haproxy/js/worker.js index 57ab680..5ea8aaa 100644 --- a/haproxy/js/worker.js +++ b/haproxy/js/worker.js @@ -1,7 +1,7 @@ importScripts('/js/argon2.js'); onmessage = async function(e) { - const [userkey, challenge, diffString, argonOpts, id, threads] = e.data; + const [userkey, challenge, diff, diffString, argonOpts, id, threads] = e.data; console.log('Worker thread', id, 'started'); let i = id; while(true) { @@ -12,7 +12,9 @@ onmessage = async function(e) { }); // This throttle seems to really help some browsers not stop the workers abruptly i % 10 === 0 && await new Promise(res => setTimeout(res, 10)); - if (hash.hashHex.startsWith(diffString)) { + if (hash.hashHex.startsWith(diffString) + && ((parseInt(hash.hashHex[diffString.length],16) & + 0xff >> (((diffString.length+1)*8)-diff)) === 0)) { console.log('Worker', id, 'found solution'); postMessage([id, i]); break; diff --git a/src/libs/utils.lua b/src/libs/utils.lua index 3eff50d..a7ace91 100644 --- a/src/libs/utils.lua +++ b/src/libs/utils.lua @@ -12,7 +12,7 @@ function _M.generate_secret(context, salt, user_key, is_applet) -- optional IP to lock challenges/user_keys to IP (for clearnet or single-onion aka 99% of cases) local ip = "" - if challenge_includes_ip then + if challenge_includes_ip == "1" then ip = context.sf:src() end @@ -38,5 +38,18 @@ function _M.split(inputstr, sep) return t end +function _M.checkdiff(hash, diff) + local i = 1 + for j = 0, (diff-8), 8 do + if hash:sub(i, i) ~= "0" then + return false + end + i = i + 1 + end + local lnm = tonumber(hash:sub(i, i), 16) + local msk = 0xff >> ((i*8)-diff) + return (lnm & msk) == 0 +end + return _M diff --git a/src/scripts/hcaptcha.lua b/src/scripts/hcaptcha.lua index 12bcdbd..b8f2425 100644 --- a/src/scripts/hcaptcha.lua +++ b/src/scripts/hcaptcha.lua @@ -7,7 +7,7 @@ local json = require("json") local sha = require("sha") local randbytes = require("randbytes") local argon2 = require("argon2") -local pow_difficulty = tonumber(os.getenv("POW_DIFFICULTY") or 3) +local pow_difficulty = tonumber(os.getenv("POW_DIFFICULTY") or 18) local pow_kb = tonumber(os.getenv("POW_KB") or 6000) local pow_time = tonumber(os.getenv("POW_TIME") or 1) argon2.t_cost(pow_time) @@ -48,6 +48,9 @@ else end function _M.setup_servers() + if pow_difficulty < 8 then + error("POW_DIFFICULTY must be > 8. Around 16-32 is better") + end local backend_name = os.getenv("BACKEND_NAME") local server_prefix = os.getenv("SERVER_PREFIX") if backend_name == nil or server_prefix == nil then @@ -207,7 +210,7 @@ function _M.view(applet) else pow_body = pow_section_template noscript_extra_body = string.format(noscript_extra_template, user_key, challenge_hash, signature, - pow_difficulty, pow_time, pow_kb) + math.ceil(pow_difficulty/8), pow_time, pow_kb) end -- sub in the body sections @@ -303,9 +306,8 @@ function _M.view(applet) -- check the output is correct local hash_output = utils.split(full_hash, '$')[5]:sub(0, 43) -- https://github.com/thibaultcha/lua-argon2/issues/37 local hex_hash_output = sha.bin_to_hex(sha.base64_to_bin(hash_output)); - local hex_hash_sub = hex_hash_output:sub(0, pow_difficulty) - if hex_hash_sub == string.rep('0', pow_difficulty) then + if utils.checkdiff(hex_hash_output, pow_difficulty) then -- the answer was good, give them a cookie local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_answer) From efe430cf3b1d57a0c2ece26e1fd563b2c65d2c88 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 1 Oct 2022 15:36:15 +1000 Subject: [PATCH 2/3] Add check for Webassembly support, and error if unsupported Improve errors for 400/500 and failed but check post Remove spinner when inserting error --- haproxy/js/challenge.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/haproxy/js/challenge.js b/haproxy/js/challenge.js index 0c7fb71..6b6602c 100644 --- a/haproxy/js/challenge.js +++ b/haproxy/js/challenge.js @@ -1,7 +1,26 @@ +function insertError(str) { + const ring = document.querySelector('.lds-ring'); + ring.insertAdjacentHTML('afterend', `

Error: ${str}

`); + ring.remove(); +} + function finishRedirect() { window.location=location.search.slice(1)+location.hash || "/"; } +const wasmSupported = (() => { + try { + if (typeof WebAssembly === "object" + && typeof WebAssembly.instantiate === "function") { + const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)); + if (module instanceof WebAssembly.Module) + return new WebAssembly.Instance(module) instanceof WebAssembly.Instance; + } + } catch (e) { + } + return false; +})(); + function postResponse(powResponse, captchaResponse) { const body = { 'pow_response': powResponse, @@ -18,14 +37,23 @@ function postResponse(powResponse, captchaResponse) { body: new URLSearchParams(body), redirect: 'manual', }).then(res => { + const s = res.status; + if (s >= 400 && s < 500) { + return insertError('bad challenge response request.'); + } else if (s >= 500) { + return insertError('server responded with error.'); + } finishRedirect(); }).catch(err => { - document.querySelector('.lds-ring').insertAdjacentHTML('afterend', '

An error occurred.

'); + insertError('failed to send challenge response.'); }); } const powFinished = new Promise((resolve, reject) => { window.addEventListener('DOMContentLoaded', async () => { + if (!wasmSupported) { + return insertError('browser does not support WebAssembly.'); + } const { time, kb, pow, diff } = document.querySelector('[data-pow]').dataset; const argonOpts = { time: time, From eb1dc3e3785d65d95e8262ed1390d6725d9b96f2 Mon Sep 17 00:00:00 2001 From: Thomas Lynch Date: Sat, 1 Oct 2022 15:43:14 +1000 Subject: [PATCH 3/3] Slightly change/improve max used cpu threads, and make tor use all that it has --- haproxy/js/challenge.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/haproxy/js/challenge.js b/haproxy/js/challenge.js index 6b6602c..f5e5347 100644 --- a/haproxy/js/challenge.js +++ b/haproxy/js/challenge.js @@ -68,7 +68,11 @@ const powFinished = new Promise((resolve, reject) => { const [userkey, challenge, signature] = combined.split("#"); const start = Date.now(); if (window.Worker) { - const threads = Math.min(8,Math.ceil(window.navigator.hardwareConcurrency/2)); + const cpuThreads = window.navigator.hardwareConcurrency; + const isTor = location.hostname.endsWith('.onion'); + /* Try to use all threads on tor, because tor limits threads for anti fingerprinting but this + makes it awfully slow because workerThreads will always be = 1 */ + const workerThreads = isTor ? cpuThreads : Math.max(Math.ceil(cpuThreads/2),cpuThreads-1); let finished = false; const messageHandler = (e) => { if (finished) { return; } @@ -82,14 +86,14 @@ const powFinished = new Promise((resolve, reject) => { }, dummyTime); } const workers = []; - for (let i = 0; i < threads; i++) { + for (let i = 0; i < workerThreads; i++) { const argonWorker = new Worker('/js/worker.js'); argonWorker.onmessage = messageHandler; workers.push(argonWorker); } - for (let i = 0; i < threads; i++) { + for (let i = 0; i < workerThreads; i++) { await new Promise(res => setTimeout(res, 100)); - workers[i].postMessage([userkey, challenge, diff, diffString, argonOpts, i, threads]); + workers[i].postMessage([userkey, challenge, diff, diffString, argonOpts, i, workerThreads]); } } else { console.warn('No webworker support, running in main/UI thread!');