aboutsummaryrefslogtreecommitdiff
path: root/captcha-child-bframe.js
diff options
context:
space:
mode:
authorWojtek Kosior <koszko@koszko.org>2022-05-26 14:55:16 +0200
committerWojtek Kosior <koszko@koszko.org>2022-05-26 14:55:16 +0200
commit8bdb22d2ca4545ebc6bc39eb3f2447cdbcafd324 (patch)
tree3944c446971ca6b900ab072122fdcdee580db733 /captcha-child-bframe.js
downloadhacktcha-8bdb22d2ca4545ebc6bc39eb3f2447cdbcafd324.tar.gz
hacktcha-8bdb22d2ca4545ebc6bc39eb3f2447cdbcafd324.zip
initial commit
Diffstat (limited to 'captcha-child-bframe.js')
-rw-r--r--captcha-child-bframe.js735
1 files changed, 735 insertions, 0 deletions
diff --git a/captcha-child-bframe.js b/captcha-child-bframe.js
new file mode 100644
index 0000000..f113850
--- /dev/null
+++ b/captcha-child-bframe.js
@@ -0,0 +1,735 @@
+/* SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * 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;
+ }
+
+ #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 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 id="resume_but">Resume</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"]
+ ]) {
+ console.warn("data", key, data[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) {
+ console.debug("updating with dresp", dresp);
+ const updated_params = {
+ token: dresp[1],
+ p_param: dresp[5],
+ image_id: (dresp[2] || [null])[0]
+ };
+
+ console.debug("after:", new APIHelper(updated_params, api_helper));
+
+ 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"]);
+ console.debug("replaceimage url", api_url, api_helper);
+
+ 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", 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)) {
+ console.debug("clicked", click);
+
+ 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);
+ }
+
+ console.debug("multicaptcha metas", metas);
+
+ 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];
+ console.debug("multicaptcha current meta", meta);
+ 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)) {
+ console.debug("clicked", click);
+
+ 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;
+
+async function handle_challenge(api_helper, rresp) {
+ console.debug("token", rresp[1]);
+
+ const pmeta = rresp[4];
+ console.debug("pmeta", pmeta);
+
+ const challenge_type = rresp[5];
+ console.debug("challenge type", challenge_type);
+
+ console.debug("p_param", rresp[9]);
+
+ 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"]);
+
+ console.debug("POST", url, api_helper.make_reload_message());
+
+ 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");
+
+ const resume_but = document.getElementById("resume_but");
+ await new Promise(cb => resume_but.onclick = cb);
+ } 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.postMessage("recreate_frame", origin);
+
+ const final_token =
+ await get_final_token(site_key, rc_version, initial_token);
+
+ console.info("got grecaptcha_token", final_token);
+ window.parent.postMessage(final_token, origin);
+}
+
+main();