/* 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 .
*
*
* 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 = `
Loading Hacktcha - a libre client for reCAPTCHA.
Challenge timed out.
Your browser got blocked by Google :(
You may consider clearing your cookies and connecting from a different
IP address.
En error occured when communicating with Google reCAPTCHA servers :(
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.
`;
/* 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 = `
`;
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();