/* SPDX-License-Identifier: LicenseRef-GPL-3.0-or-later-WITH-js-exceptions * * Part of Hacktcha, a free/libre front-end for reCAPTCHA for use with Haketilo. * * Copyright (C) 2022 Wojtek Kosior * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * As additional permission under GNU GPL version 3 section 7, you * may distribute forms of that code without the copy of the GNU * GPL normally required by section 4, provided you include this * license notice and, in case of non-source distribution, a URL * through which recipients can access the Corresponding Source. * If you modify file(s) with this exception, you may extend this * exception to your version of the file(s), but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * As a special exception to the GPL, any HTML file which merely * makes function calls to this code, and for that purpose * includes it by reference shall be deemed a separate work for * copyright law purposes. If you modify this code, you may extend * this exception to your version of the code, but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * * I, Wojtek Kosior, thereby promise not to sue for violation of this file's * license. Although I request that you do not make use of this code in a * proprietary program, I am not going to enforce this in court. */ /* Use with https://google.com/recaptcha/api2/bframe */ /* Load captcha-strings.js first. */ "use strict"; /* We substitute the original HTML document for this one. */ const replacement_markup = ` <!DOCTYPE html> <html> <head> <style> body { margin: 0; background-color: white; } h4 { margin-bottom: 10px; margin-top: 10px; } #tiles_table, #tiles_body td { border: 1px solid white; padding: 0; } #tiles_table { width: 300px; height: 300px; border-spacing: unset; margin: auto; } div#main_tiles_challenge { text-align: center; } button#select_done_but { margin: 3px; } .hide { display: none; } .disabled-image { opacity: 0.5; pointer-events: none; } #tiles_body td.marked-tile { border-color: black; opacity: 0.5; } </style> </head> <body> <div> Google policies: <a href="https://www.google.com/intl/en/policies/privacy/">privacy</a> & <a href="https://www.google.com/intl/en/policies/terms/">terms</a> </div> <div id="main_loading"> <h4> Loading Hacktcha - a libre client for reCAPTCHA. </h4> </div> <div id="main_timed_out" class="hide"> <h4> Challenge timed out. </h4> <button class="retry_but">Reload Hacktcha</button> </div> <div id="main_challenge_blocked" class="hide"> <h4> Your browser got blocked by Google :( </h4> <p> You may consider clearing your cookies and connecting from a different IP address. </p> <button class="retry_but">Reload Hacktcha</button> </div> <div id="main_error" class="hide"> <h4> En error occured when communicating with Google reCAPTCHA servers :( </h4> <p> You can look into the JavaScript console for additional information about the error. On major browsers the console is found in developer tools opened with Ctrl+Shift+C. </p> <button class="retry_but">Retry</button> </div> <div id="main_tiles_challenge" class="hide"> <h4 id="goal_head"></h4> <table id="tiles_table"> <tbody id="tiles_body"></tbody> </table> <button id="select_done_but">Done</button> </div> </body> </html> `; /* How much time the user has to solve a challenge. */ const DEFAULT_TIMEOUT_MILLIS = 1000 * 120; /* Convenience functions. */ function add_child(elem, node_type) { const child = document.createElement(node_type); elem.appendChild(child); return child; } function range(n) { return Array.from({length: n}, (_, i) => i); } async function* hacktcha_iterate(iterable, make_api_helper_cb) { try { for await (const item of iterable) { yield item; } } catch(e) { if (e.hacktcha_error) e.api_helper = make_api_helper_cb(); throw e; } } function rc_base64(data) { return btoa(data) .replaceAll("+", "-") .replaceAll("/", "_") .replaceAll("=", "."); } const rc_json_prefix = ")]}'"; function rc_json_parse(text) { if (text.startsWith(rc_json_prefix)) text = text.substring(rc_json_prefix.length); return JSON.parse(text); } /* Unhide one of the div's beneath body while hiding all the other ones. */ function show_main_view(id) { for (const div of document.querySelectorAll("body>div[id^=main_]")) div.classList.add("hide"); document.getElementById(id).classList.remove("hide"); } /* * For each challenge there is a goal text provided like "Select all squares * with stairs.". */ function show_goal(goal) { goal_head.textContent = goal; } /* * At the beginning of a challenge reCAPTCHA image tiles are served as one * super-image. The tiles are displayed by putting this super-image in cell of * a table and positioning it so that only the relevant square is visible in * each tile. * * The show_img() function displays all the tiles of the image and then * asynchronously yields tile positions clicked by the user. */ let resolve_img_click = () => {}; async function* show_img(img_url, row_count, col_count, timeout) { const tbody = document.getElementById("tiles_body"); tbody.innerHTML = ` <style> #tiles_body td>div { width: ${300 / col_count - 2}px; height: ${300 / row_count - 2}px; overflow: hidden; } #tiles_body img { position: relative; width: ${col_count * 100}%; height: ${row_count * 100}%; } #tiles_body img.standalone-image { width: 100%; height: 100%; } </style> `; for (const row of range(row_count)) { const tr = add_child(tbody, "tr"); for (const col of range(col_count)) { const td = add_child(tr, "td"); const img_container = add_child(td, "div"); const img = add_child(img_container, "img"); td.id = `tile_${row}_${col}`; img.src = img_url; img.draggable = false; if (row) img.style.top = String(-row * 100) + "%"; if (col) img.style.left = String(-col * 100) + "%"; img.addEventListener("click", () => resolve_img_click([row, col])); } } let reject; document.getElementById("select_done_but").onclick = () => resolve_img_click("done"); setTimeout(() => reject({hacktcha_error: "timed_out"}), timeout); while (true) { const prom = new Promise((...cbs) => [resolve_img_click, reject] = cbs); const value = await prom; if (value === "done") return; yield value; } } function remove_tile_img(row, col) { document.querySelector(`#tile_${row}_${col}>div`).innerHTML = ""; } function mark_tile_img(row, col) { document.getElementById(`tile_${row}_${col}`).classList.add("marked-tile"); } function unmark_tile_img(row, col) { document.getElementById(`tile_${row}_${col}`) .classList.remove("marked-tile"); } function set_tile_img(img_url, row, col) { const img_container = document.querySelector(`#tile_${row}_${col}>div`); img_container.innerHTML = ""; const img = add_child(img_container, "img"); img.classList.add("standalone-image"); img.src = img_url; img.addEventListener("click", () => resolve_img_click([row, col])); return img; } async function slowly_enable_img(img) { img.classList.add("disabled-image"); await new Promise(cb => setTimeout(cb, 4500)); img.classList.remove("disabled-image"); } /* * reCAPTCHA uses protocol buffers for part of communication. Unfortunately, * protobuf libraries for JS are problematic to package properly. Here we * instead craft valid protobuf messages for reCAPTCHA API "by hand". * * Also see: https://developers.google.com/protocol-buffers/ */ function protobuf_encode_varint(n) { const out = []; if (n < 0) throw new Error("'n' must be nonnegative."); while (true) { const b = n & 127; n >>= 7; if (n > 0){ out.push(b | 128); } else { out.push(b); break; } } return out; } function protobuf_craft_message(data) { const encoder = new TextEncoder(); const byte_arrays = []; /* Note: We're not sending fields 3, 5, and 16. */ for (const [num, key] of [ [1, "rc_version"], [2, "token"], [6, "reason"], [14, "site_key"] ]) { const value_bin = encoder.encode(data[key]); /* Wire type of 2 indicates a length-delimited field. */ byte_arrays.push(protobuf_encode_varint((num << 3) | 2)); byte_arrays.push(protobuf_encode_varint(value_bin.length)); byte_arrays.push(value_bin); } const msg_len = byte_arrays.reduce((acc, array) => acc + array.length, 0); const msg = new Uint8Array(msg_len); let len_filled = 0; for (const array of byte_arrays) { msg.set(array, len_filled); len_filled += array.length; } return msg; } /* * Define APIHelper type which holds some recaptcha session parameters and * facilitates crafting common parts of reCAPTCHA AJAX requests. * * Each APIHelper instance should be supplied (through either of its * constructor's arguments) with at least the following parameters: rc_version, * token and site_key. */ function APIHelper(params, old_api_helper={}) { Object.assign(this, old_api_helper); Object.assign(this, params); } const api_keys = { "site_key": "k", "rc_version": "v", "p_param": "p", "token": "c", "image_id": "id" }; APIHelper.prototype.make_api_url = function(endpoint, base_param_names=[], extra_params={}) { const base_url = "https://google.com/recaptcha/api2/"; const endpoint_url = new URL(endpoint, base_url); const search_params = new URLSearchParams(); const base_param_pairs = base_param_names .map(n => [api_keys[n], this[n]]) .filter(([k, v]) => v !== undefined); const param_pairs = [...base_param_pairs, ...Object.entries(extra_params)]; endpoint_url.search = new URLSearchParams(param_pairs); return endpoint_url; } APIHelper.prototype.make_first_img_url = function() { return this.make_api_url("payload", ["p_param", "site_key"]); } APIHelper.prototype.make_subsequent_img_url = function() { return this.make_api_url("payload", ["p_param", "site_key", "image_id"]); } APIHelper.prototype.make_reload_message = function() { return protobuf_craft_message({ rc_version: this.rc_version, token: this.token, reason: "fi", site_key: this.site_key }); } APIHelper.prototype.make_post_data = function(param_names) { const param_pairs = param_names.map(name => [api_keys[name], this[name]]); return new URLSearchParams(param_pairs); } function update_api_helper_rresp(api_helper, rresp) { const updated_params = {token: rresp[1], p_param: rresp[9]}; return new APIHelper(updated_params, api_helper); } function update_api_helper_dresp(api_helper, dresp) { const updated_params = { token: dresp[1], p_param: dresp[5], image_id: (dresp[2] || [null])[0] }; return new APIHelper(updated_params, api_helper); } const challenge_handlers = {}; async function ajax_replaceimage(api_helper, partial_solution) { const post_data = api_helper.make_post_data(["rc_version", "token"]); post_data.append("ds", JSON.stringify(partial_solution)); const api_url = api_helper.make_api_url("replaceimage", ["site_key"]); const response = await fetch(api_url, { method: "POST", body: post_data }); const dresp = rc_json_parse(await response.text()); api_helper = update_api_helper_dresp(api_helper, dresp); return {api_helper, dresp}; } let tile_challenge_not_ready_counter; function tile_challenge_reset() { tile_challenge_not_ready_counter = 0; document.getElementById("select_done_but").removeAttribute("disabled"); } function tile_challenge_not_ready() { tile_challenge_not_ready_counter++; document.getElementById("select_done_but").setAttribute("disabled", ""); } function tile_challenge_ready() { if (--tile_challenge_not_ready_counter == 0) document.getElementById("select_done_but").removeAttribute("disabled"); } function make_captcha_goal(meta) { const to_select = rc_goal_strings[meta[0]] || rc_goal_strings[meta[6]]; if (to_select) { return `Select all squares with: "${to_select}".`; } else { console.warn("Cannot deduce goal. Metadata:", meta); return `Cannot decude this challenge's goal :(`; } } challenge_handlers.dynamic = async function(api_helper, pmeta) { const meta = pmeta.slice(1).filter(e => Array.isArray(e))[0]; if (!meta) throw new Error("Could not extract challenge metadata.", pmeta); show_main_view("main_tiles_challenge"); tile_challenge_reset(); show_goal(make_captcha_goal(meta)); const selection = []; const row_count = meta[3]; const col_count = meta[4]; const tile_count = row_count * col_count; const img_index_map = range(tile_count); let latest_index = tile_count - 1; const initial_img_url = api_helper.make_first_img_url(); const clicks = show_img(initial_img_url, row_count, col_count, DEFAULT_TIMEOUT_MILLIS); for await (const click of hacktcha_iterate(clicks, () => api_helper)) { remove_tile_img(...click); const tile_index = click[0] * col_count + click[1]; const img_index = img_index_map[tile_index]; selection.push(img_index); img_index_map[tile_index] = ++latest_index; tile_challenge_not_ready(); var {api_helper} = await ajax_replaceimage(api_helper, [img_index]); const img_url = api_helper.make_subsequent_img_url(); const img = set_tile_img(img_url, ...click); slowly_enable_img(img) .then(tile_challenge_ready); } return {api_helper, solution: selection}; } challenge_handlers.multicaptcha = async function(api_helper, pmeta) { try { var metas = pmeta.slice(5).filter(e => Array.isArray(e))[0][0]; } catch(e) { throw new Error("Could not extract challenge metadata.", pmeta); } show_main_view("main_tiles_challenge"); tile_challenge_reset(); const selections = [], start_time = Date.now(); let first = true; while (metas.length > 0) { const meta = metas.splice(0, 1)[0]; const row_count = meta[3], col_count = meta[4]; show_goal(make_captcha_goal(meta)); const clicked_imgs = new Set(); const img_url = first ? api_helper.make_first_img_url() : api_helper.make_subsequent_img_url(); const timeout = DEFAULT_TIMEOUT_MILLIS - (Date.now() - start_time); const clicks = show_img(img_url, row_count, col_count, timeout); for await (const click of hacktcha_iterate(clicks, () => api_helper)) { const clicked_id = click[0] * col_count + click[1]; if (clicked_imgs.has(clicked_id)) { clicked_imgs.delete(clicked_id); unmark_tile_img(...click); } else { clicked_imgs.add(clicked_id); mark_tile_img(...click); } } if (first) { api_helper = new APIHelper({image_id: "2"}, api_helper); first = false; } else { tile_challenge_not_ready(); var {api_helper} = await ajax_replaceimage(api_helper, selections); tile_challenge_ready(); } selections.push([...clicked_imgs].sort((a, b) => a - b)); } return {api_helper, solution: selections}; } challenge_handlers.doscaptcha = function(api_helper, pmeta) { throw {hacktcha_error: "challenge_blocked"}; } challenge_handlers.default = challenge_handlers.doscaptcha; challenge_handlers.null = challenge_handlers.doscaptcha; async function handle_challenge(api_helper, rresp) { const pmeta = rresp[4]; const challenge_type = rresp[5]; if (!challenge_handlers.hasOwnProperty(challenge_type)) throw new Error("Unsupported challenge type", challenge_type); return challenge_handlers[challenge_type](api_helper, pmeta); } async function ajax_reload(api_helper) { const url = api_helper.make_api_url("reload", ["site_key"]); const response = await fetch(url, { method: "POST", body: api_helper.make_reload_message(), headers: {"Content-Type": "application/x-protobuffer"} }); const rresp = rc_json_parse(await response.text()); api_helper = update_api_helper_rresp(api_helper, rresp); return {api_helper, rresp}; } async function run_challenges(api_helper, rresp) { var start_time = Date.now(); while (true) { var {api_helper, solution} = await handle_challenge(api_helper, rresp); /* * We're omitting some less important values that we don't know how * to produce. */ const post_data = api_helper.make_post_data(["rc_version", "token"]); const client_response_text = JSON.stringify({response: solution}); post_data.append("response", rc_base64(client_response_text)); const time = String(Date.now() - start_time); post_data.append("t", time); post_data.append("ct", time); const api_url = api_helper.make_api_url("userverify", ["site_key"]); const response = await fetch(api_url, { method: "POST", body: post_data }); const uvresp = rc_json_parse(await response.text()); rresp = uvresp.filter(e => Array.isArray(e) && e[0] === "rresp")[0]; if (rresp) api_helper = update_api_helper_rresp(api_helper, rresp); else if (typeof uvresp[1] === "string") return uvresp[1]; else throw new Error("No token and no 'rresp' in userverify response."); } } async function get_final_token(site_key, rc_version, token) { var api_helper = new APIHelper({site_key, rc_version, token}); while (true) { try { var {api_helper, rresp} = await ajax_reload(api_helper); return await run_challenges(api_helper, rresp); } catch(e) { if (e.hacktcha_error === "timed_out") { api_helper = e.api_helper; show_main_view("main_timed_out"); /* * Resuming after timeout does not work, so we allow the user to reload the * iframe instead. * TODO: fix resuming */ /* const resume_but = document.getElementById("resume_but"); */ /* await new Promise(cb => resume_but.onclick = cb); */ return new Promise(() => {}); } else if (e.hacktcha_error === "challenge_blocked") { show_main_view("main_challenge_blocked"); return new Promise(() => {}); } else { show_main_view("main_error"); throw e; } } } } async function main() { const rc_params = new URLSearchParams(window.location.search); const site_key = rc_params.get("k"); const rc_version = rc_params.get("v"); const smuggled_json = decodeURIComponent(window.location.hash.substring(1)); const {initial_token, origin} = JSON.parse(smuggled_json); document.open(); document.write(replacement_markup); document.close(); for (const but of document.getElementsByClassName("retry_but")) but.onclick = () => window.parent.postMessage("recreate_frame", origin); const final_token = await get_final_token(site_key, rc_version, initial_token); window.parent.postMessage(final_token, origin); } main();