Engine
in package
Wraps the bundled ALTCHA library for the WordPress plugin.
Table of Contents
Constants
- CLEANUP_HOOK = 'creationell_captcha_cleanup_replay_markers'
- Cron hook that sweeps expired replay markers out of the options table.
- REPLAY_PREFIX = 'creationell_captcha_used_'
- Option-name prefix of the single-use replay markers claimed by verify().
- UA_GATE_PARAM = 'uagate'
- Key inside `challenge.parameters.data` that marks a challenge as "issued for the under-attack gate only".
- CLEANUP_INTERVAL = 900
- Seconds between two replay-marker sweeps while markers still exist.
- CODE_PASS_PREFIX = 'creationell_captcha_ccpass_'
- Transient-name prefix of the "image-code stage passed" markers that the `/code-verify` handler writes (see includes/code-challenge.php).
- PRESETS = ['pbkdf2' => ['low' => ['cost' => 3000, 'min' => 5, 'max' => 30], 'medium' => ['cost' => 6000, 'min' => 10, 'max' => 60], 'high' => ['cost' => 15000, 'min' => 20, 'max' => 120]], 'argon2id' => ['low' => ['cost' => 1, 'min' => 5, 'max' => 40], 'medium' => ['cost' => 2, 'min' => 10, 'max' => 80], 'high' => ['cost' => 3, 'min' => 20, 'max' => 160]]]
- Difficulty presets per algorithm: PoW cost + counter range [min, max].
Methods
- cleanup_replay_markers() : void
- Deletes replay markers whose lifetime has run out and re-arms itself while markers are still around. Registered on self::CLEANUP_HOOK at the bottom of this file.
- code_pass_key() : string
- Transient key of the "image-code stage passed" marker for a challenge.
- create_challenge() : array<string, mixed>
- Builds a fresh challenge as a JSON-serialisable array.
- verify() : bool
- Verifies a base64-encoded ALTCHA payload and enforces single use.
- verify_structural() : bool
- Verifies a base64 ALTCHA payload structurally — same signature requirement and same derive-key caps as `verify()`, but without the single-use replay claim and without the code-stage rule.
- algorithm() : DeriveKeyInterface
- The derive-key algorithm instance for the configured algorithm.
- algorithm_for_name() : DeriveKeyInterface
- Maps a challenge algorithm name to a derive-key instance.
- algorithm_key() : string
- Effective algorithm key — falls back to pbkdf2 when argon2id is selected but ext-sodium is unavailable.
- altcha() : Altcha
- Builds the underlying ALTCHA object with both HMAC secrets.
- claim_replay_marker() : bool
- Claims the single-use replay marker for a challenge signature. Returns true for exactly one caller, false for every other.
- difficulty_key() : string
- Normalises the configured difficulty.
- params_within_caps() : bool
- Server-side ceiling for the derive-key work an INCOMING payload may ask for (CM-6, Wurzel 3.1).
-
prepare_payload()
: array{params: array
, signature: string, counter: int, derived_key: string}|null - Decodes a base64 ALTCHA payload and enforces everything that has to hold BEFORE the vendor library is allowed to derive a single key.
- replay_marker_lifetime() : int
- How long the single-use marker of a just-verified payload has to stay around — namely for exactly as long as that payload can still be redeemed.
- run_verify_solution() : bool
- Hands a prepared payload to the vendor library.
- schedule_replay_cleanup() : void
- Makes sure the replay markers written above get swept again.
Constants
CLEANUP_HOOK
Cron hook that sweeps expired replay markers out of the options table.
public
mixed
CLEANUP_HOOK
= 'creationell_captcha_cleanup_replay_markers'
REPLAY_PREFIX
Option-name prefix of the single-use replay markers claimed by verify().
public
mixed
REPLAY_PREFIX
= 'creationell_captcha_used_'
Deliberately plain options, not transients — see claim_replay_marker() for why the transient pair could not be made atomic.
Public because two routines outside the engine have to find these rows as well (E4a): the deactivation sweep in includes/lifecycle.php and the unconditional removal in uninstall.php. Both used to carry their own copy of the literal; a rename here would have left their rows behind.
UA_GATE_PARAM
Key inside `challenge.parameters.data` that marks a challenge as "issued for the under-attack gate only".
public
mixed
UA_GATE_PARAM
= 'uagate'
Written by the /challenge handler (includes/rest.php) whenever a valid
ctx token suppressed the image-code stage, read by verify() below.
Deliberately a shared constant and not the same string typed twice: a
typo on either side would turn the rule into a silent no-op that nothing
on the interstitial path would notice.
CLEANUP_INTERVAL
Seconds between two replay-marker sweeps while markers still exist.
private
mixed
CLEANUP_INTERVAL
= 900
CODE_PASS_PREFIX
Transient-name prefix of the "image-code stage passed" markers that the `/code-verify` handler writes (see includes/code-challenge.php).
private
mixed
CODE_PASS_PREFIX
= 'creationell_captcha_ccpass_'
PRESETS
Difficulty presets per algorithm: PoW cost + counter range [min, max].
private
mixed
PRESETS
= ['pbkdf2' => ['low' => ['cost' => 3000, 'min' => 5, 'max' => 30], 'medium' => ['cost' => 6000, 'min' => 10, 'max' => 60], 'high' => ['cost' => 15000, 'min' => 20, 'max' => 120]], 'argon2id' => ['low' => ['cost' => 1, 'min' => 5, 'max' => 40], 'medium' => ['cost' => 2, 'min' => 10, 'max' => 80], 'high' => ['cost' => 3, 'min' => 20, 'max' => 160]]]
Erstkalibrierung (Spec §7) — bei Bedarf nach dem DDEV-Solve-Zeit-Test an dieser einen Stelle nachjustieren.
These values are also the reference point for the incoming-payload caps in params_within_caps(): the plugin never issues anything above them.
Methods
cleanup_replay_markers()
Deletes replay markers whose lifetime has run out and re-arms itself while markers are still around. Registered on self::CLEANUP_HOOK at the bottom of this file.
public
static cleanup_replay_markers() : void
code_pass_key()
Transient key of the "image-code stage passed" marker for a challenge.
public
static code_pass_key(string $signature) : string
The marker is keyed by the challenge SIGNATURE, and that signature
covers parameters.data.ccode (the ALTCHA library signs the canonical
JSON of the whole parameter set). Two consequences, and they are the
reason this binding exists:
- a code token cannot be re-paired with a different challenge (CM-8) —
changing
data.ccodeinvalidates the signature, and the signature is mandatory since prepare_payload(); - a payload that never passed the image-code stage has no marker at all
(CM-1) — omitting the JSON key
codeon/code-verifyno longer produces anything the form path accepts.
Parameters
- $signature : string
-
The
challenge.signatureof the payload.
Return values
string —Transient name (never empty).
create_challenge()
Builds a fresh challenge as a JSON-serialisable array.
public
create_challenge() : array<string, mixed>
Return values
array<string, mixed>verify()
Verifies a base64-encoded ALTCHA payload and enforces single use.
public
verify(string $payload_b64[, bool $under_attack_gate = false ]) : bool
Parameters
- $payload_b64 : string
-
The base64 payload from the
altchaform field. - $under_attack_gate : bool = false
-
Whether the CALLER is the under-attack interstitial gate. Only that one call site (UnderAttack::run()) passes true; it LIFTS the uagate rule rather than requiring the marker — the gate accepts an ordinary challenge just as well, and deliberately so (see below). Default false — every form path keeps rejecting a marked challenge.
Return values
boolverify_structural()
Verifies a base64 ALTCHA payload structurally — same signature requirement and same derive-key caps as `verify()`, but without the single-use replay claim and without the code-stage rule.
public
verify_structural(string $payload_b64) : bool
Used by the code-challenge verify endpoint (Modul 15), which must not
consume the incoming payload: the user may retry after a wrong code,
and the one and only single-use consumption happens in verify() when
the form is finally submitted.
Parameters
- $payload_b64 : string
-
The base64 payload from the widget.
Return values
boolalgorithm()
The derive-key algorithm instance for the configured algorithm.
private
algorithm() : DeriveKeyInterface
Return values
DeriveKeyInterfacealgorithm_for_name()
Maps a challenge algorithm name to a derive-key instance.
private
algorithm_for_name(string $name) : DeriveKeyInterface
Parameters
- $name : string
Return values
DeriveKeyInterfacealgorithm_key()
Effective algorithm key — falls back to pbkdf2 when argon2id is selected but ext-sodium is unavailable.
private
algorithm_key() : string
Return values
stringaltcha()
Builds the underlying ALTCHA object with both HMAC secrets.
private
altcha() : Altcha
Return values
Altchaclaim_replay_marker()
Claims the single-use replay marker for a challenge signature. Returns true for exactly one caller, false for every other.
private
claim_replay_marker(string $signature, int $expiry) : bool
Parameters
- $signature : string
-
The
challenge.signatureof the verified payload. - $expiry : int
-
Seconds the marker has to stay around.
Return values
booldifficulty_key()
Normalises the configured difficulty.
private
difficulty_key(array<string, mixed> $settings) : string
Parameters
- $settings : array<string, mixed>
-
Plugin settings.
Return values
stringparams_within_caps()
Server-side ceiling for the derive-key work an INCOMING payload may ask for (CM-6, Wurzel 3.1).
private
params_within_caps(array<string, mixed> $params_raw, int $counter, string $context) : bool
Pbkdf2::deriveKey() clamps cost only downwards (max(1, $cost)) and
Argon2id::deriveKey() does the same with memoryCost — an anonymous
POST could therefore request cost: 200000000 or memoryCost: 2000000
and bind a PHP worker inside a native C call that max_execution_time
cannot interrupt. The caps are derived from what the plugin itself ever
issues (self::PRESETS) plus headroom, so no legitimate payload can hit
them; every value is filterable for installs with custom presets.
Parameter extraction mirrors ChallengeParameters::fromArray()
one-to-one (same is_int() checks, same defaults) — otherwise a value
this method waves through could reach the library as something else.
Parameters
- $params_raw : array<string, mixed>
-
Raw
challenge.parametersfrom the payload. - $counter : int
-
Counter of the submitted solution.
- $context : string
-
Short label for the debug log.
Return values
boolprepare_payload()
Decodes a base64 ALTCHA payload and enforces everything that has to hold BEFORE the vendor library is allowed to derive a single key.
private
prepare_payload(string $payload_b64, string $context) : array{params: array, signature: string, counter: int, derived_key: string}|null
Parameters
- $payload_b64 : string
-
Base64 payload from the client.
- $context : string
-
Short label for the debug log.
Return values
array{params: arrayPrepared payload parts, or null when the payload must be rejected.
replay_marker_lifetime()
How long the single-use marker of a just-verified payload has to stay around — namely for exactly as long as that payload can still be redeemed.
private
replay_marker_lifetime(array<string, mixed> $params) : int
Until 1.1.0 this was the CURRENT challenge_expiry setting, which is not
the same thing: the redeemable lifetime of a payload is fixed when the
challenge is issued and travels with it as expiresAt. Lowering the
setting — the settings range is 60–3600 s and the help text explicitly
recommends short values — therefore handed every challenge issued before
the change a marker that dies BEFORE the challenge does. In that gap the
same payload verifies again, as often as the client likes, until
expiresAt is finally reached. That is the CM-9 guarantee ("one PoW =
one submission") leaking out through a side door.
Why reading expiresAt out of the payload is safe (and not a new
attacker-controlled criterion):
- the value is part of the SIGNED challenge parameters, and the caller reaches this method only after run_verify_solution() has checked that signature against this site's HMAC secret. A signature is mandatory since CM-2 (prepare_payload), and the empty-secret case fails closed there as well;
- the extraction mirrors
ChallengeParameters::fromArray()one-to-one (isset()+is_int(), see lib/altcha-org/altcha/src/ChallengeParameters.php), so this method reads exactly the value the signature was verified against. Anything the library would normalise differently — a string, a float, a missing key — changes the canonical JSON and thus kills the signature one step earlier; - inflating the number is therefore not "a longer marker", it is an unverifiable payload. And a longer marker would only ever REDUCE what an attacker can do.
Parameters
- $params : array<string, mixed>
-
Raw, signature-checked
challenge.parameters.
Return values
int —Seconds the marker has to stay around (at least 60).
run_verify_solution()
Hands a prepared payload to the vendor library.
private
run_verify_solution(array{params: array, signature: string, counter: int, derived_key: string} $prepared, string $context) : bool
Parameters
-
$prepared
: array{params: array
, signature: string, counter: int, derived_key: string} -
Prepared payload parts.
- $context : string
-
Short label for the debug log.
Return values
boolschedule_replay_cleanup()
Makes sure the replay markers written above get swept again.
private
schedule_replay_cleanup(int $expiry) : void
Unlike transients, plain options never expire on their own — so the sweep is their ONLY cleanup path and must not be optional.
Parameters
- $expiry : int
-
Lifetime of the marker that was just claimed.