aboutsummaryrefslogtreecommitdiff
/**
 * SPDX-License-Identifier: CC0-1.0
 *
 * Copyright (C) 2025 Woj. Kosior <koszko@koszko.org>
 */

/*
  #+begin_src manifest-jq
  .matches = ["<all_urls>"]
  #+end_src
*/

if (false) { /* #+begin_src background-js */
    blockJsOnCookie(/^techaro[.]lol-anubis-auth=/);
} /* #+end_src */

/* Solve Anubis challenge. */

function byteArray2Hex(arrayLike) {
    const byte2Hex = byte => ("0" + byte.toString(16)).slice(-2);
    return [...arrayLike].map(byte2Hex).join("");
}

const countingSchemes = {
    up: {
        startNum: "1000000000",
        delta: 1
    },
    down: {
        /* Server parses nonce as Int which might be a signed 32-bit type. */
        startNum: "2147483647",
        delta: -1
    }
};

const ascii0 = 48;
const ascii9 = 57;

let finished = false;

// let hashCount = 0;
// var start_time = Date.now();

async function powLoop(randomData, hash0sNeeded, counting) {
    const {startNum, delta} = counting;
    const inputArray = new TextEncoder().encode(randomData + startNum);
    const lastIdx = inputArray.length - 1;
    const halfedByteIdx = Math.floor(hash0sNeeded / 2);
    const bytesChecked = Math.ceil(hash0sNeeded / 2);

    while (!finished) {
        const digest = new Uint8Array(
            await crypto.subtle.digest("SHA-256", inputArray)
        );

        const savedByte = digest[halfedByteIdx];
        digest[halfedByteIdx] >>= 4;

        let success = true;
        for (let idx= 0; idx < bytesChecked; idx++)
            success = success && digest[idx] === 0;

        if (!success) {
            let idx = lastIdx;
            let add = delta;

            while (true) {
                const byte = inputArray[idx] += add;

                if (byte > ascii9) {
                    inputArray[idx] = ascii0;
                    add = 1;
                } else if (byte < ascii0) {
                    inputArray[idx] = ascii9;
                    add = -1;
                } else {
                    break;
                }

                idx--;
            }

            continue;
        }

        digest[halfedByteIdx] = savedByte;

        const suffixArray = inputArray.slice(-startNum.length);
        const suffix = parseInt(new TextDecoder().decode(suffixArray));

        finished = true;

        return [digest, suffix];
    }
}

async function solvePow(randomData, hash0sNeeded) {
    const startMillis = Date.now();

    /*
     * Under both IceCat 140.3.1-gnu1 and ungoogled-chromium 140.0.7339.207-1
     * (both from Guix) on a Core 2 CPU the best hash/s result was observed when
     * running 2 async loops.  TODO: use workers to leverage hw concurrency.
     */
    const [digest, suffix] = await Promise.any([
        powLoop(randomData, hash0sNeeded, countingSchemes.up),
        powLoop(randomData, hash0sNeeded, countingSchemes.down)
    ]);

    return {
        response: byteArray2Hex(digest),
        nonce: suffix,
        elapsedTime: Date.now() - startMillis
    };
}

// (async () => {
//     for (let i = 0; i < 15; i++) {
//         await new Promise(resolve => setTimeout(resolve, 1000));
//         if (i < 5) {
//             start_time = Date.now();
//             hashCount = 0;
//             continue;
//         }
//         const ms = Date.now() - start_time;
//         const hps = Math.round(hashCount / ms * 1000);
//         console.log(`${hashCount} hashes (${hps} hash/s)`);
//     }
//     stop = true;
// })()

async function solvePreact(randomData, waitTime) {
    const inputArray = new TextEncoder().encode(randomData);

    /*
     * The server checks if enough time has passed.  Challenge's difficulty
     * corresponds to a unit of 80 or 95 milliseconds (depending on Anubis
     * version).
     */
    const [digestBuffer, _] = await Promise.all([
        crypto.subtle.digest("SHA-256", inputArray),
        new Promise(resolve => setTimeout(resolve, 95 * waitTime))
    ]);

    return {result: byteArray2Hex(new Uint8Array(digestBuffer))};
}

function err(what) {
    console.error(what);
    /* TODO: Display message on the page. */
}

/*
 * Note that there exists a variant of Anubis "modified for phpBB", where
 *
 * - the relevant <script> element has id "challenge" rather than
 *   "anubis_challenge",
 * - a <script> elem with id "anubis_settings" is present and holds the path to
 *   use in the GET request (and element with id "anubis_base_prefix" seems to
 *   be absent),
 * - difficulty is provided as a string (like "4"), and
 * - "timestamp" present in the challenge data has to be included in the GET
 *   request.
 *
 * The code below aims to handle both Anubis variants.
 */

const settingsScript = document.getElementById("anubis_settings");
const challengeScript =
      document.getElementById("anubis_challenge") ||
      (settingsScript && document.getElementById("challenge"));
const anubisPrefixScript = document.getElementById("anubis_base_prefix");
const anubisUrlScript = document.getElementById("anubis_public_url");

async function solve() {
    const unsupportedAnubisErr =
          what => err(`${what}  Maybe the extension needs tweaking to ` +
                      "support this version of Anubis?");
    const badDataFormatErr =
          () => unsupportedAnubisErr("Challenge data format not understood.");

    let anubisPrefix = "", anubisUrl = null, challengeData, settingsData;
    try {
        challengeData = JSON.parse(challengeScript.textContent);

        if (!anubisPrefixScript) {
            console.warn("No Anubis base prefix found in page, trying empty " +
                         "string.");
        } else {
            anubisPrefix = JSON.parse(anubisPrefixScript.textContent);
        }

        if (anubisUrlScript) {
            const anubisUrlString = JSON.parse(anubisUrlScript.textContent);
            if (anubisUrlString)
                anubisUrl = new URL(anubisUrlString);
        }

        if (settingsScript)
            settingsData = JSON.parse(settingsScript.textContent);
    } catch(ex) {
        console.error(ex);

        return badDataFormatErr();
    }
    challengeData = new Object(challengeData);

    if (challengeData.rules?.algorithm === "metarefresh")
        return;

    /*
     * Older Anubis versions (and the "modified for phpBB" variant) have the
     * random data under `challenge' rather than `challenge.randomData'.
     */
    const randomData =
          (challengeData.challenge?.randomData || challengeData.challenge);
    const challengeId = challengeData.challenge?.id;
    const difficulty = challengeData.rules?.difficulty;
    const timestamp = challengeData.timestamp || "";
    const routePrefix =
          (settingsData?.route_prefix ||
           `${anubisPrefix}/.within.website/x/cmd/anubis/api/pass-challenge`);

    if (typeof randomData !== "string" ||
        !/^[0-9]+$/.test(difficulty) ||
        (challengeId && typeof challengeId !== "string") ||
        typeof anubisPrefix !== "string" ||
        !/^[0-9]*$/.test(timestamp) ||
        (routePrefix && typeof routePrefix !== "string"))
        return badDataFormatErr();

    if (!["fast", "preact", "slow"].includes(challengeData.rules.algorithm))
        return unsupportedAnubisErr("Unsupported challenge algorithm.");

    const anubisizedLocation = () => "" + new URL(
        /[^:]+:[/]*[^/]+(.*)/.exec(window.location.href + "")[1],
        anubisUrl
    );
    const isAnubisLocation = (anubisUrl &&
                              anubisizedLocation() === window.location.href);
    const redirectTarget = isAnubisLocation ?
          new URLSearchParams(window.location.search).get("redir") :
          window.location.href;

    if (!redirectTarget)
        return unsupportedAnubisErr("Failed to extract redirect target.");

    const solver = challengeData.rules.algorithm === "preact" ?
          solvePreact : solvePow;
    const solutionUrlParams = await solver(randomData, difficulty * 1);

    const destination = new URL(routePrefix + "?", window.location.href);
    destination.search = new URLSearchParams({
        ...solutionUrlParams,
        ...(challengeId && {id: challengeId}),
        ...(timestamp && {timestamp}),
        redir: redirectTarget
    });

    window.location.href = destination;
}

if (challengeScript && !window.location.href.startsWith("file://"))
    solve();