Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0b2b78c
start: proof of work captcha feature.
crhallberg Jul 21, 2025
b3b6c80
feat: session id used to generate consistent secrets.
crhallberg Jul 22, 2025
6fc0381
fix: alignment of captcha with comment.
crhallberg Jul 22, 2025
356676b
fix: send less information back with form, since we have a better sta…
crhallberg Jul 22, 2025
35a4318
doc: copyright 2
crhallberg Jul 22, 2025
5a6f033
chore: qa-js-and-scss
crhallberg Jul 22, 2025
843c2ea
chore: php-cs-fixer
crhallberg Jul 22, 2025
a21c1e2
doc: rename CAPTCHA
crhallberg Jul 22, 2025
1d3336e
Update module/VuFind/src/VuFind/Captcha/PoW.php
crhallberg Jul 22, 2025
5746da2
doc: update copyright
crhallberg Jul 22, 2025
136e3cb
doc: copyright
crhallberg Jul 22, 2025
04fe429
feat: add Altcha as an option.
crhallberg Jul 28, 2025
6b5884f
fix: woke up in the middle of the night in a cold sweat with the abso…
crhallberg Jul 28, 2025
8ff7271
refactor: pass Altcha instance from Factory.
crhallberg Jul 31, 2025
761e780
rm: unused file since vendor supplies WebComponent.
crhallberg Jul 31, 2025
c97bfba
fix: remove postinstall.
crhallberg Jul 31, 2025
8dc7d17
doc: add Altcha LICENSE.
crhallberg Jul 31, 2025
62ca158
remove home-grown pow solution.
crhallberg Aug 6, 2025
d06cb50
fix: escape challenge JSON.
crhallberg Aug 6, 2025
7f1b88a
Update config/vufind/config.ini
crhallberg Aug 6, 2025
182f5bd
docs: fix parameter types.
crhallberg Aug 6, 2025
a9f9003
style: phpcs
crhallberg Aug 6, 2025
a358335
Merge branch 'pow-captcha' of https://github.com/crhallberg/vufind in…
crhallberg Aug 6, 2025
d6972fa
Merge branch 'dev' into pow-captcha
crhallberg Sep 9, 2025
fb11bf4
Merge branch 'dev' into pow-captcha
demiankatz Dec 4, 2025
de5dae0
Fix outdated license text.
demiankatz Dec 4, 2025
826c5a9
Handle garbled input data more robustly.
demiankatz Dec 4, 2025
2e485c6
php-cs-fixer
demiankatz Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions module/VuFind/src/VuFind/Captcha/PluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
'dumb' => Dumb::class,
'image' => Image::class,
'interval' => Interval::class,
'pow' => PoW::class,
'recaptcha' => ReCaptcha::class,
];

Expand All @@ -65,6 +66,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
Dumb::class => DumbFactory::class,
Image::class => ImageFactory::class,
Interval::class => IntervalFactory::class,
PoW::class => PoWFactory::class,
ReCaptcha::class => ReCaptchaFactory::class,
];

Expand Down
103 changes: 103 additions & 0 deletions module/VuFind/src/VuFind/Captcha/PoW.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

/**
* ReCaptcha CAPTCHA.
*
* PHP version 8
*
* Copyright (C) Villanova University 2020.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* 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.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*
* @category VuFind
* @package CAPTCHA
* @author Mario Trojan <mario.trojan@uni-tuebingen.de>
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License
* @link https://vufind.org Main Page
*/

namespace VuFind\Captcha;

use Laminas\Mvc\Controller\Plugin\Params;

/**
* ReCaptcha CAPTCHA.
*
* @category VuFind
* @package CAPTCHA
* @author Mario Trojan <mario.trojan@uni-tuebingen.de>
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License
* @link https://vufind.org/wiki/development Wiki
*/
class PoW extends AbstractBase
{
/**
* Constructor
*
* @param string $hashAlgo hash algorithm (default sha256)
* @param int $difficulty number of zeroes needed for proof (default 5)
*/
public function __construct(
string $hashAlgo,
int $difficulty,
) {
$this->hashAlgo = $hashAlgo;
$this->difficulty = $difficulty;
}

/**
* Get list of URLs with JS dependencies to load for the active CAPTCHA type.
*
* @return array
*/
public function getJsIncludes(): array
{
return ['captcha-pow.js'];
}

/**
* Pull the captcha field from controller params and check them for accuracy
*
* @param Params $params Controller params
*
* @return bool
*/
public function verify(Params $params): bool
{
error_log($params->fromPost('pow-captcha-challenge'));
error_log($params->fromPost('pow-captcha-nonce'));

// @TODO: compare to Session challenge

return false;
}

public function getChallenge() {
// @TODO: random challenge
// @TODO: story in session for verify
return $this->challenge ?? "WOW VERY CHALLENGE MUCH FIND";
}

public function getDifficulty() {
return $this->difficulty;
}

public function getHashAlgo() {
return $this->hashAlgo;
}

public function getStart() {
return random_int(0, 1000);
}
}
87 changes: 87 additions & 0 deletions module/VuFind/src/VuFind/Captcha/PoWFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

/**
* Factory for Image CAPTCHA module.
*
* PHP version 8
*
* Copyright (C) Villanova University 2020.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* 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.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*
* @category VuFind
* @package CAPTCHA
* @author Mario Trojan <mario.trojan@uni-tuebingen.de>
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License
* @link https://vufind.org/wiki/development Wiki
*/

namespace VuFind\Captcha;

use Laminas\ServiceManager\Exception\ServiceNotCreatedException;
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
use Laminas\ServiceManager\Factory\FactoryInterface;
use Psr\Container\ContainerExceptionInterface as ContainerException;
use Psr\Container\ContainerInterface;

use function is_callable;

/**
* Image CAPTCHA factory.
*
* @category VuFind
* @package CAPTCHA
* @author Mario Trojan <mario.trojan@uni-tuebingen.de>
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License
* @link https://vufind.org/wiki/development Wiki
*/
class PoWFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container Service manager
* @param string $requestedName Service being created
* @param null|array $options Extra options (optional)
*
* @return object
*
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException&\Throwable if any other error occurs
*/
public function __invoke(
ContainerInterface $container,
$requestedName,
?array $options = null
) {
if (!empty($options)) {
throw new \Exception('Unexpected options passed to factory.');
}

$config = $container
->get(\VuFind\Config\PluginManager::class)
->get('config');

$hashAlgo = $config->Captcha->hashAlgo ?? 'sha256';
if (!in_array($hashAlgo, hash_algos())) {
throw new \Exception('Invalid hash algorithm: ' . $hashAlgo . '.');
}

$difficulty = intval($config->Captcha->pow_difficulty ?? 5);

return new $requestedName($hashAlgo, $difficulty);
}
}
49 changes: 49 additions & 0 deletions themes/root/js/captcha-pow-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
function hashChallenge(hashAlgo, challenge, nonce) {
return new Promise((resolve, reject) => {
let buffer = new TextEncoder().encode(`${challenge}:${nonce}`);
crypto.subtle.digest(phpAlgoToJS(hashAlgo), buffer.buffer).then((result) => {
// convert buffer to byte array
const bytes = Array.from(new Uint8Array(result));

// convert bytes to hex string
const hex = bytes
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")

resolve(hex);
}, reject);
});
}

function phpAlgoToJS(algo) {
return {
"sha1": "SHA-1",
"sha256": "SHA-256",
"sha384": "SHA-384",
"sha512": "SHA-512",
}[algo] ?? algo;
}


self.addEventListener("message", async (event) => {
const { challenge, difficulty, hashAlgo, start } = event.data;
const algo = phpAlgoToJS(hashAlgo);

let nonce = start;
const target = "0".repeat(difficulty);
console.log(target);
let attempt = await hashChallenge(algo, challenge, nonce);
while (!attempt.startsWith(target)) {
nonce += 1;
attempt = await hashChallenge(algo, challenge, nonce);

if (attempt.startsWith("0000")) {
console.log(attempt);
}
}

self.postMessage({
nonce,
iters: nonce - start,
});
});
87 changes: 87 additions & 0 deletions themes/root/js/captcha-pow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/* global VuFind */

VuFind.register('pow-captcha', function PoWCaptchas() {
const READY_CLASS = "js-pow-captcha-ready";
const CHALLENGE_SELECTOR = '[name="pow-captcha-challenge"]';

function powCaptchaInit(form) {
if (form.classList.contains(READY_CLASS)) {
return;
}

form.classList.add(READY_CLASS);

// Wait for user interaction to start work

form.addEventListener("input", () => { powPerformWork(form) }, { once: true });

const statusEl = form.querySelector(".pow-captcha-status");
statusEl.textContent = "PoW Captcha waiting for interaction.";
}

function powPerformWork(form) {
const challenge = form.querySelector('[name="pow-captcha-challenge"]').value;
const difficulty = Number(form.querySelector('[name="pow-captcha-difficulty"]').value);
const hashAlgo = form.querySelector('[name="pow-captcha-hash-algo"]').value;
const start = Number(form.querySelector('[name="pow-captcha-start"]')?.value ?? 0);

const statusEl = form.querySelector(".pow-captcha-status");
statusEl.textContent = `PoW Captcha doing work (${hashAlgo} x${difficulty}).`;

// Do the number crunching in a Web Worker for speed and responsiveness
const worker = new Worker(`${VuFind.path}/themes/root/js/captcha-pow-worker.js`);

worker.onmessage = (event) => {
worker.terminate();
powResolveWork(form, event.data);
};

worker.onerror = (event) => {
worker.terminate();
powRejectWork(form, event);
};

worker.postMessage({ challenge, difficulty, hashAlgo, start });
}

function powResolveWork(form, { nonce, iters }) {
const nonceInput = form.querySelector('[name="pow-captcha-nonce"]');
nonceInput.value = nonce;

const statusEl = form.querySelector(".pow-captcha-status");
statusEl.textContent = `PoW Captcha: nonce (${nonce}) found after ${iters} iterations.`;
}

function powRejectWork(form, event) {
console.error(event);
}

function init() {
// Find PoW challenges
for (const pow of document.querySelectorAll(CHALLENGE_SELECTOR)) {
powCaptchaInit(pow.closest("form"));
}

// Listen for future forms
const observer = new MutationObserver(lookForNewPoWCaptchas);
observer.observe(document, { childList: true, subtree: true });

function lookForNewPoWCaptchas(records, observer) {
for (const record of records) {
for (const addedNode of record.addedNodes) {
if (addedNode instanceof Text) {
continue;
}
if (addedNode.matches(CHALLENGE_SELECTOR)) {
powCaptchaInit(addedNode.closest("form"));
}
for (const pow of document.querySelectorAll(CHALLENGE_SELECTOR)) {
powCaptchaInit(pow.closest("form"));
}
}
}
}
}

return { init };
});
7 changes: 7 additions & 0 deletions themes/root/templates/Captcha/pow.phtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php ?>
<input type="hidden" name="pow-captcha-challenge" value="<?=$this->captcha->getChallenge() ?>">
<input type="hidden" name="pow-captcha-difficulty" value="<?=$this->captcha->getDifficulty() ?>">
<input type="hidden" name="pow-captcha-hash-algo" value="<?=$this->captcha->getHashAlgo() ?>">
<input type="hidden" name="pow-captcha-start" value="<?=$this->captcha->getStart() ?>">
<input type="hidden" name="pow-captcha-nonce" value="">
<p class="pow-captcha-status">PoW Captcha not initiated.</p>
Loading