Skip to content

Commit

Permalink
Merge pull request #10 from LanerCodire/feature/separatedCaptchaLogic…
Browse files Browse the repository at this point in the history
…FromWebserverHandling

Separated captcha logic from http handling
  • Loading branch information
amenk committed Jul 4, 2023
2 parents 354361f + 69e6134 commit e6dd01e
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 164 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ If you need more advanced security features and reliability or want to support o
You need a web server running PHP 7.4 or later.

1. Install the public folder to the your document root.
2. Copy and adapt env.template.php to env.php
2. Copy and adapt Env.template.php to Env.php in classes folder.
3. Change the friendly captcha widgets endpoint to user your server
4. In your backend configuration, use the your own server endpoint and

Expand Down
22 changes: 0 additions & 22 deletions public/env.template.php

This file was deleted.

59 changes: 6 additions & 53 deletions public/puzzle.php
Original file line number Diff line number Diff line change
@@ -1,66 +1,19 @@
<?php

require_once 'env.php';
require_once 'polite.class.php';
use FriendlyCaptcha\Lite\Captcha;
use FriendlyCaptcha\Lite\Polite;

require_once 'vendor/autoload.php';

Polite::cors();

header('Content-type: application/json');

$accountId = 1;
$appId = 1;
$puzzleVersion = 1;
$puzzleExpiry = EXPIRY_TIMES_5_MINUTES;


// smart scaling
$anonymizedIp = Polite::anonymizeIp($_SERVER['REMOTE_ADDR']);
$ipKey = 'ip_rate_limit_' . $anonymizedIp;

if ($requestTimes = apcu_fetch($ipKey)) {
$requestTimes++;
} else {
$requestTimes = 1;
}
apcu_store($ipKey, $requestTimes, SCALING_TTL_SECOUNDS);

Polite::log(sprintf('This is request %d from IP %s in the last 30 minutes (or longer, if there were subsequent requests)', $requestTimes, $anonymizedIp));

foreach(array_reverse(SCALING, true) as $threshold => $scale) {
if ($requestTimes > $threshold) {
$numberOfSolutions = $scale['solutions'];
$puzzleDifficulty = $scale['difficulty'];
break;
}
}

if (!isset($numberOfSolutions) || !isset($puzzleDifficulty)) {
die('Error in configuration');
}

Polite::log(sprintf('configured with %d solutions of %d difficulty', $numberOfSolutions, $puzzleDifficulty));

$nonce = random_bytes(8);
$timeHex = dechex(time());
$accountIdHex = Polite::padHex(dechex($accountId), 4);
$appIdHex = Polite::padHex(dechex($appId), 4);
$puzzleVersionHex = Polite::padHex(dechex($appId), 1);
$puzzleExpiryHex = Polite::padHex(dechex($puzzleExpiry), 1);
$numberOfSolutionsHex = Polite::padHex(dechex($numberOfSolutions), 1);
$puzzleDifficultyHex = Polite::padHex(dechex($puzzleDifficulty), 1);
$reservedHex = Polite::padHex('', 8);
$puzzleNonceHex = Polite::padHex(bin2hex($nonce), 8);

$bufferHex = Polite::padHex($timeHex, 4) . $accountIdHex . $appIdHex . $puzzleVersionHex . $puzzleExpiryHex . $numberOfSolutionsHex . $puzzleDifficultyHex . $reservedHex . $puzzleNonceHex;

$buffer = hex2bin($bufferHex);
$hash = Polite::signBuffer($buffer);

$puzzle = $hash . '.' . base64_encode($buffer);
$captcha = new Captcha();

$result = [
'data' => [
'puzzle' => $puzzle
'puzzle' => $captcha->buildPuzzle($_SERVER['REMOTE_ADDR'])
]
];

Expand Down
103 changes: 18 additions & 85 deletions public/siteverify.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
<?php

require_once 'env.php';
require_once 'polite.class.php';
use FriendlyCaptcha\Lite\Captcha;
use FriendlyCaptcha\Lite\Exceptions\EmptySolutionException;
use FriendlyCaptcha\Lite\Exceptions\TimeoutOrDuplicateException;
use FriendlyCaptcha\Lite\Exceptions\WrongApiKeyException;
use FriendlyCaptcha\Lite\Polite;

require_once 'vendor/autoload.php';

/**
* https://github.com/FriendlyCaptcha/friendly-pow
Expand All @@ -23,90 +28,18 @@
$apiKey = $input['secret'];
}

// check API key
if ($apiKey != API_KEY){
Polite::returnWrongApiKeyError();
}

if (empty($solution) || $solution[0] === '.') {
Polite::log('Empty or pending solution: ' . $solution);
Polite::returnErrorEmptySolution();
}

list($signature, $puzzle, $solutions, $diagnostics) = explode('.', $solution);
$puzzleBin = base64_decode($puzzle);
$puzzleHex = bin2hex($puzzleBin);

if (($calculated = Polite::signBuffer($puzzleBin)) !== $signature) {
Polite::log(sprintf('Signature mismatch. Calculated "%s", given "%s"', $calculated, $signature));
Polite::returnSolutionInvalid();
}

// only need to store as long as valid, after that the timeout will kick in
if (!apcu_add($puzzleHex, true, EXPIRY_TIMES_5_MINUTES * 300)) {
Polite::log(sprintf('Puzzle "%s" was already successfully used before', $puzzleHex));
Polite::returnSolutionTimeoutOrDuplicate();
}

$numberOfSolutions = hexdec(Polite::extractHexBytes($puzzleHex, 14, 1));
$timeStamp = hexdec(Polite::extractHexBytes($puzzleHex, 0, 4));
$expiry = hexdec(Polite::extractHexBytes($puzzleHex, 13, 1));
$expiryInSeconds = $expiry * 300;
$solutionsHex = bin2hex(base64_decode($solutions));

Polite::log('puzzleHex: ' . $puzzleHex);

Polite::log("timeStamp: " . $timeStamp);
$age = time() - $timeStamp;
Polite::log("age:" . $age);

if ($expiry == 0) {
Polite::log("does not expire" );
} else {
if ($age <= $expiryInSeconds) {
Polite::log("puzzle is young enough");
} else {
Polite::log(sprintf("puzzle is too old (%d seconds, allowed: %d", $age, $expiry));
Polite::returnSolutionTimeoutOrDuplicate();
}
}

Polite::log("numberOfSolutions: " . $numberOfSolutions);

$d = hexdec(Polite::extractHexBytes($puzzleHex, 15, 1));
Polite::log("d: " . $d);
$T = floor(pow(2, (255.999 - $d) / 8.0));
Polite::log("T: " . $T);


$solutionSeenInThisRequest = [];

for ($solutionIndex = 0; $solutionIndex < $numberOfSolutions; $solutionIndex++) {
$currentSolution = Polite::extractHexBytes($solutionsHex, $solutionIndex * 8, 8);

if (isset($solutionSeenInThisRequest[$currentSolution])) {
Polite::log('Solution seen in this request before');
Polite::returnSolutionInvalid();
}
$solutionSeenInThisRequest[$currentSolution] = true;

$fullSolution = Polite::padHex($puzzleHex, 120, STR_PAD_RIGHT) . $currentSolution;

Polite::log('fullsolution length: ' . strlen($fullSolution));
Polite::log('fullsolution: ' . $fullSolution);
$captcha = new Captcha();

$blake2b256hash = bin2hex(sodium_crypto_generichash(hex2bin($fullSolution), '', 32));
Polite::log('Blake hash: ' . $blake2b256hash);
$first4Bytes = Polite::extractHexBytes($blake2b256hash, 0, 4);
$first4Int = Polite::littleEndianHexToDec($first4Bytes);

if ($first4Int < $T) {
Polite::log($currentSolution . ' is valid');
try {
if($captcha->verifyPuzzle($apiKey, $solution)) {
Polite::returnValid();
} else {
Polite::log($currentSolution . ' (index: ' . $solutionIndex . ') is invalid (' . $first4Int . ' >= ' . $T . ')');
Polite::returnSolutionInvalid();
}
}

Polite::log('all valid');
Polite::returnValid();
} catch(TimeoutOrDuplicateException $e) {
Polite::returnSolutionTimeoutOrDuplicate();
} catch(WrongApiKeyException $e) {
Polite::returnWrongApiKeyError();
} catch(EmptySolutionException $e) {
Polite::returnErrorEmptySolution();
}
6 changes: 6 additions & 0 deletions public/vendor/autoload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php
set_include_path(get_include_path().PATH_SEPARATOR.'../src');
spl_autoload_extensions('.php');
spl_autoload_register(function(string $className) {
include str_replace('\\', '/', $className) . '.php';
});
153 changes: 153 additions & 0 deletions src/FriendlyCaptcha/Lite/Captcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php
namespace FriendlyCaptcha\Lite;

use FriendlyCaptcha\Lite\Exceptions\EmptySolutionException;
use FriendlyCaptcha\Lite\Exceptions\TimeoutOrDuplicateException;
use FriendlyCaptcha\Lite\Exceptions\WrongApiKeyException;

class Captcha {
public function buildPuzzle(string $remoteIp): string {
$accountId = 1;
$appId = 1;
$puzzleVersion = 1;
$puzzleExpiry = Env::EXPIRY_TIMES_5_MINUTES;

// smart scaling
$anonymizedIp = Polite::anonymizeIp($remoteIp);
$ipKey = 'ip_rate_limit_' . $anonymizedIp;

if ($requestTimes = apcu_fetch($ipKey)) {
$requestTimes++;
} else {
$requestTimes = 1;
}
apcu_store($ipKey, $requestTimes, Env::SCALING_TTL_SECOUNDS);

Polite::log(sprintf('This is request %d from IP %s in the last 30 minutes (or longer, if there were subsequent requests)', $requestTimes, $anonymizedIp));

foreach(array_reverse(Env::SCALING, true) as $threshold => $scale) {
if ($requestTimes > $threshold) {
$numberOfSolutions = $scale['solutions'];
$puzzleDifficulty = $scale['difficulty'];
break;
}
}

if (!isset($numberOfSolutions) || !isset($puzzleDifficulty)) {
die('Error in configuration');
}

Polite::log(sprintf('configured with %d solutions of %d difficulty', $numberOfSolutions, $puzzleDifficulty));


$nonce = random_bytes(8);
$timeHex = dechex(time());
$accountIdHex = Polite::padHex(dechex($accountId), 4);
$appIdHex = Polite::padHex(dechex($appId), 4);
$puzzleVersionHex = Polite::padHex(dechex($appId), 1);
$puzzleExpiryHex = Polite::padHex(dechex($puzzleExpiry), 1);
$numberOfSolutionsHex = Polite::padHex(dechex($numberOfSolutions), 1);
$puzzleDifficultyHex = Polite::padHex(dechex($puzzleDifficulty), 1);
$reservedHex = Polite::padHex('', 8);
$puzzleNonceHex = Polite::padHex(bin2hex($nonce), 8);

$bufferHex = Polite::padHex($timeHex, 4) . $accountIdHex . $appIdHex . $puzzleVersionHex . $puzzleExpiryHex . $numberOfSolutionsHex . $puzzleDifficultyHex . $reservedHex . $puzzleNonceHex;


$buffer = hex2bin($bufferHex);
$hash = Polite::signBuffer($buffer);

$puzzle = $hash . '.' . base64_encode($buffer);
return $puzzle;
}

public function verifyPuzzle(string $apiKey, string $solution): bool {
// check API key
if ($apiKey != Env::API_KEY){
throw new WrongApiKeyException();
}

if (empty($solution) || $solution[0] === '.') {
Polite::log('Empty or pending solution: ' . $solution);
throw new EmptySolutionException();
}

list($signature, $puzzle, $solutions, $diagnostics) = explode('.', $solution);
$puzzleBin = base64_decode($puzzle);
$puzzleHex = bin2hex($puzzleBin);

if (($calculated = Polite::signBuffer($puzzleBin)) !== $signature) {
Polite::log(sprintf('Signature mismatch. Calculated "%s", given "%s"', $calculated, $signature));
return false;
}

// only need to store as long as valid, after that the timeout will kick in
if (!apcu_add($puzzleHex, true, Env::EXPIRY_TIMES_5_MINUTES * 300)) {
Polite::log(sprintf('Puzzle "%s" was already successfully used before', $puzzleHex));
throw new TimeoutOrDuplicateException();
}

$numberOfSolutions = hexdec(Polite::extractHexBytes($puzzleHex, 14, 1));
$timeStamp = hexdec(Polite::extractHexBytes($puzzleHex, 0, 4));
$expiry = hexdec(Polite::extractHexBytes($puzzleHex, 13, 1));
$expiryInSeconds = $expiry * 300;
$solutionsHex = bin2hex(base64_decode($solutions));

Polite::log('puzzleHex: ' . $puzzleHex);

Polite::log("timeStamp: " . $timeStamp);
$age = time() - $timeStamp;
Polite::log("age:" . $age);

if ($expiry == 0) {
Polite::log("does not expire" );
} else {
if ($age <= $expiryInSeconds) {
Polite::log("puzzle is young enough");
} else {
Polite::log(sprintf("puzzle is too old (%d seconds, allowed: %d", $age, $expiry));
throw new TimeoutOrDuplicateException();
}
}

Polite::log("numberOfSolutions: " . $numberOfSolutions);

$d = hexdec(Polite::extractHexBytes($puzzleHex, 15, 1));
Polite::log("d: " . $d);
$T = floor(pow(2, (255.999 - $d) / 8.0));
Polite::log("T: " . $T);


$solutionSeenInThisRequest = [];

for ($solutionIndex = 0; $solutionIndex < $numberOfSolutions; $solutionIndex++) {
$currentSolution = Polite::extractHexBytes($solutionsHex, $solutionIndex * 8, 8);

if (isset($solutionSeenInThisRequest[$currentSolution])) {
Polite::log('Solution seen in this request before');
return false;
}
$solutionSeenInThisRequest[$currentSolution] = true;

$fullSolution = Polite::padHex($puzzleHex, 120, STR_PAD_RIGHT) . $currentSolution;

Polite::log('fullsolution length: ' . strlen($fullSolution));
Polite::log('fullsolution: ' . $fullSolution);

$blake2b256hash = bin2hex(sodium_crypto_generichash(hex2bin($fullSolution), '', 32));
Polite::log('Blake hash: ' . $blake2b256hash);
$first4Bytes = Polite::extractHexBytes($blake2b256hash, 0, 4);
$first4Int = Polite::littleEndianHexToDec($first4Bytes);

if ($first4Int < $T) {
Polite::log($currentSolution . ' is valid');
} else {
Polite::log($currentSolution . ' (index: ' . $solutionIndex . ') is invalid (' . $first4Int . ' >= ' . $T . ')');
return false;
}
}

Polite::log('all valid');
return true;
}
}
Loading

0 comments on commit e6dd01e

Please sign in to comment.