<?php
declare(strict_types=1);
namespace Plugin\fp_altcha_spamschutz\src\Service;
use AltchaOrg\Altcha\V1\Altcha;
use AltchaOrg\Altcha\V1\ChallengeOptions;
use AltchaOrg\Altcha\V1\Hasher\Algorithm;
use JTL\Plugin\PluginInterface;
/**
* Kapselt die ALTCHA V1 Proof-of-Work Bibliothek (MIT-Lizenz, https://github.com/altcha-org/altcha-lib-php).
*
* Warum V1 statt der aktuellen ALTCHA-Widget-Version (v3)?
* Das offizielle JS-Widget v3 nutzt ein neueres, komplexeres Protokoll (PBKDF2/Argon2id/Scrypt mit
* Key-Prefix-Matching), fuer das es keine einfache, robust dokumentierte "Challenge direkt einbetten"-
* Variante ohne zusaetzlichen Netzwerk-Endpunkt gibt. Das klassische V1-Protokoll (SHA-256 Hashcash:
* Client sucht per Brute-Force eine Zahl n, fuer die SHA256(salt+n) == challenge gilt) ist dagegen
* vollstaendig dokumentiert, einfach zu pruefen und wird hier zusammen mit einem kleinen eigenen
* JS-Loeser (assets/fp-altcha.js) eingesetzt. Serverseitig kommt weiterhin der offizielle,
* unveraenderte ALTCHA-Code zum Einsatz (siehe src/Vendor/AltchaOrg/Altcha/V1).
*/
class AltchaService
{
private PluginInterface $plugin;
private Altcha $altcha;
public function __construct(PluginInterface $plugin)
{
$this->plugin = $plugin;
$this->altcha = new Altcha($this->getHmacSecret());
}
public function isConfigured(): bool
{
return $this->getHmacSecret() !== '';
}
public function isEnabledForRegistration(): bool
{
return $this->getConfigValue('fp_altcha_protect_register', 'on') === 'on';
}
public function isEnabledForNewsletter(): bool
{
return $this->getConfigValue('fp_altcha_protect_newsletter', 'on') === 'on';
}
public function isDebug(): bool
{
return $this->getConfigValue('fp_altcha_debug', '') === 'on';
}
/**
* Erzeugt eine neue Pruefung und liefert sie als Array, das 1:1 als JSON in die Seite
* eingebettet werden kann (siehe TemplateHandler).
*
* @return array<string, string|int>
*/
public function createChallengeArray(): array
{
$maxNumber = (int) $this->getConfigValue('fp_altcha_max_number', '150000');
if ($maxNumber < 1000) {
$maxNumber = 150000;
}
$expirySeconds = (int) $this->getConfigValue('fp_altcha_expiry_seconds', '600');
if ($expirySeconds < 30) {
$expirySeconds = 600;
}
$expires = new \DateTimeImmutable('+' . $expirySeconds . ' seconds');
$challenge = $this->altcha->createChallenge(new ChallengeOptions(
algorithm: Algorithm::SHA256,
maxNumber: $maxNumber,
expires: $expires,
));
return [
'algorithm' => $challenge->algorithm,
'challenge' => $challenge->challenge,
'maxnumber' => $challenge->maxNumber,
'salt' => $challenge->salt,
'signature' => $challenge->signature,
];
}
/**
* Prueft das per POST["altcha"] gesendete, base64-kodierte Loesungs-Payload.
*/
public function verifyPost(): bool
{
$field = $_POST['altcha'] ?? null;
if (!\is_string($field) || $field === '') {
$this->log('kein altcha Feld im POST gefunden');
return false;
}
if (!$this->isConfigured()) {
// Kein HMAC-Secret hinterlegt -> Plugin ist nicht korrekt eingerichtet.
// Sicherheitshalber ablehnen statt durchzulassen.
$this->log('kein HMAC-Secret konfiguriert, Pruefung wird abgelehnt');
return false;
}
$verified = $this->altcha->verifySolution($field, true);
$this->log('Pruefungsergebnis: ' . ($verified ? 'erfolgreich' : 'fehlgeschlagen'));
return $verified;
}
private function getHmacSecret(): string
{
return $this->getConfigValue('fp_altcha_hmac_secret', '');
}
private function getConfigValue(string $name, string $default): string
{
$value = $this->plugin->getConfig()->getValue($name);
if (!\is_string($value) || $value === '') {
return $default;
}
return $value;
}
private function log(string $message): void
{
if (!$this->isDebug()) {
return;
}
error_log('[fp_altcha_spamschutz] ' . $message);
}
}