aboutsummaryrefslogtreecommitdiff
/* 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>
      &amp;
      <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();