Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
94 changes: 83 additions & 11 deletions src/Challenge.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,37 +77,94 @@ public static function clampCost(int $cost): int
}

/**
* Verifies a base64-encoded payload as posted by the ALTCHA widget. Both
* client-solution payloads (the common case) and server-signature payloads
* (used by ALTCHA Sentinel / Spam Filter) are accepted — `altcha-org/altcha`
* tells the two apart by the presence of `verificationData`.
* A payload that passed every check.
*/
public const REASON_OK = 'ok';

/**
* No `altcha` field was posted at all — typically a bot posting straight at
* the form handler, or the widget script never running.
*/
public const REASON_MISSING = 'missing';

/**
* Present, but not a decodable ALTCHA payload.
*/
public const REASON_MALFORMED = 'malformed';

/**
* Solved correctly, but the challenge's validity window had already closed
* by the time the form was submitted — the visitor spent longer on the form
* than {@see self::DEFAULT_EXPIRES_SECONDS}.
*/
public const REASON_EXPIRED = 'expired';

/**
* Not a challenge we issued: forged, or signed with a different HMAC key
* (e.g. after a key rotation).
*/
public const REASON_INVALID_SIGNATURE = 'invalid_signature';

/**
* Our challenge, but the proof-of-work answer is wrong.
*/
public const REASON_INVALID_SOLUTION = 'invalid_solution';

/**
* Failed verification without the library attributing a specific cause.
*/
public const REASON_INVALID = 'invalid';

/**
* Verifies a base64-encoded payload as posted by the ALTCHA widget.
*/
public function verify(string $base64Payload): bool
{
return $this->classify($base64Payload) === self::REASON_OK;
}

/**
* Verifies a payload and reports *why* it failed instead of collapsing the
* outcome to a bool. Telling an expired challenge apart from a forged one is
* the difference between "our validity window is too short for this form"
* and "we're being attacked" — so the reason is what makes the decision log
* worth keeping.
*
* Both client-solution payloads (the common case) and server-signature
* payloads (used by ALTCHA Sentinel / Spam Filter) are accepted —
* `altcha-org/altcha` tells the two apart by the presence of
* `verificationData`.
*
* @return string one of the REASON_* constants
*/
public function classify(string $base64Payload): string
{
if ($base64Payload === '') {
return false;
return self::REASON_MISSING;
}

$decoded = base64_decode($base64Payload, true);
if ($decoded === false) {
return false;
return self::REASON_MALFORMED;
}

$payload = json_decode($decoded, true);
if (! is_array($payload)) {
return false;
return self::REASON_MALFORMED;
}

if (isset($payload['verificationData'])) {
return ServerSignature::verifyServerSignature($payload, $this->hmacSecret)->verified;
return ServerSignature::verifyServerSignature($payload, $this->hmacSecret)->verified
? self::REASON_OK
: self::REASON_INVALID;
}

if (! isset($payload['challenge'], $payload['solution'])
|| ! is_array($payload['challenge'])
|| ! is_array($payload['solution'])
|| ! is_array($payload['challenge']['parameters'] ?? null)
) {
return false;
return self::REASON_MALFORMED;
}

$challenge = new AltchaChallenge(
Expand All @@ -121,10 +178,25 @@ public function verify(string $base64Payload): bool

$altcha = new Altcha($this->hmacSecret);

return $altcha->verifySolution(new VerifySolutionOptions(
$result = $altcha->verifySolution(new VerifySolutionOptions(
algorithm: new Pbkdf2,
payload: new Payload($challenge, $solution),
))->verified;
));

if ($result->verified) {
return self::REASON_OK;
}
if ($result->expired) {
return self::REASON_EXPIRED;
}
if ($result->invalidSignature) {
return self::REASON_INVALID_SIGNATURE;
}
if ($result->invalidSolution) {
return self::REASON_INVALID_SOLUTION;
}

return self::REASON_INVALID;
}

/**
Expand Down
47 changes: 42 additions & 5 deletions src/Integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ class Integration

public const SCRIPT_HANDLE = 'gravityforms-altcha-widget';

/**
* A valid, correctly solved payload that had already been accepted once.
* Lives here rather than on {@see Challenge} because replay is enforced by
* this class, not by the verification itself.
*/
public const REASON_REPLAY = 'replay';

public static function register(): void
{
$instance = new self;
Expand Down Expand Up @@ -65,18 +72,19 @@ public function validate(array $result): array
? sanitize_text_field(wp_unslash($_POST[self::POST_FIELD]))
: '';

if ($this->challenge()->verify($payload)) {
$challenge = $this->challenge();
$reason = $challenge->classify($payload);

if ($reason === Challenge::REASON_OK) {
if (! $this->isReplay($payload)) {
Logger::record('altcha', 'pass', ['form' => $formId]);

return $result;
}
$reason = 'replay';
} else {
$reason = $payload === '' ? 'missing' : 'invalid';
$reason = self::REASON_REPLAY;
}

Logger::record('altcha', 'fail', ['form' => $formId, 'reason' => $reason]);
Logger::record('altcha', 'fail', $this->failureContext($formId, $reason, $payload, $challenge));

$result['is_valid'] = false;
$result['form']['validation_summary_message'] = $this->errorMessage();
Expand All @@ -85,6 +93,35 @@ public function validate(array $result): array
return $result;
}

/**
* Builds the non-PII context recorded alongside a rejection.
*
* `reason` alone answers most questions, but not the one that matters most
* for an expired challenge: was the visitor a few seconds over, or half an
* hour? `expired_for` (seconds past `expiresAt`) turns "expiry is hurting
* us" from a hunch into a number, and shows whether the widget's renewal is
* doing its job in the field.
*
* @return array<string, mixed>
*/
private function failureContext(mixed $formId, string $reason, string $payload, Challenge $challenge): array
{
$context = ['form' => $formId, 'reason' => $reason];

if ($reason !== Challenge::REASON_EXPIRED) {
return $context;
}

$fingerprint = $challenge->fingerprint($payload);
if ($fingerprint === null || $fingerprint['expiresAt'] === null) {
return $context;
}

$context['expired_for'] = max(0, time() - $fingerprint['expiresAt']);

return $context;
}

/**
* One-time-use enforcement. A signed challenge stays verifiable until it
* expires, so without this a bot could solve the proof once and replay the
Expand Down
79 changes: 79 additions & 0 deletions test/unit/ChallengeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,85 @@ public function test_fingerprint_returns_null_for_unparseable_payloads(): void
$this->assertNull($challenge->fingerprint(base64_encode(json_encode(['no' => 'signature']))));
}

public function test_classify_accepts_a_valid_solution(): void
{
[, $payload] = $this->solvedPayload();

$this->assertSame(Challenge::REASON_OK, (new Challenge(self::SECRET))->classify($payload));
}

public function test_classify_reports_missing_for_an_empty_payload(): void
{
$this->assertSame(Challenge::REASON_MISSING, (new Challenge(self::SECRET))->classify(''));
}

public function test_classify_reports_malformed_for_undecodable_payloads(): void
{
$challenge = new Challenge(self::SECRET);

$this->assertSame(Challenge::REASON_MALFORMED, $challenge->classify('!!!not-base64!!!'));
$this->assertSame(Challenge::REASON_MALFORMED, $challenge->classify(base64_encode('not json')));
$this->assertSame(
Challenge::REASON_MALFORMED,
$challenge->classify(base64_encode(json_encode(['challenge' => 'wrong-shape']))),
);
}

/**
* The distinction this whole classification exists for: a visitor who sat on
* a long form past the validity window must be distinguishable from an
* attacker posting a forged proof.
*/
public function test_classify_reports_expired_separately_from_invalid(): void
{
[, $payload] = $this->solvedPayload(expiresSeconds: -10);

$this->assertSame(Challenge::REASON_EXPIRED, (new Challenge(self::SECRET))->classify($payload));
}

public function test_classify_reports_invalid_signature_for_a_foreign_challenge(): void
{
$altcha = new Altcha('attacker-secret');
$foreign = (new Challenge('attacker-secret', cost: 1000))->create();

$solution = $altcha->solveChallenge(new SolveChallengeOptions(
algorithm: new Pbkdf2,
challenge: $foreign,
));
$this->assertNotNull($solution);

$payload = (new Payload($foreign, $solution))->toBase64();

$this->assertSame(
Challenge::REASON_INVALID_SIGNATURE,
(new Challenge(self::SECRET))->classify($payload),
);
}

public function test_classify_reports_invalid_solution_for_a_garbled_answer(): void
{
[$challenge, $payload] = $this->solvedPayload();

$arr = json_decode(base64_decode($payload, true), true);
$arr['solution']['derivedKey'] = str_repeat('0', strlen($arr['solution']['derivedKey']));

$this->assertSame(
Challenge::REASON_INVALID_SOLUTION,
(new Challenge(self::SECRET))->classify(base64_encode(json_encode($arr))),
);
}

public function test_verify_agrees_with_classify(): void
{
[, $valid] = $this->solvedPayload();
[, $expired] = $this->solvedPayload(expiresSeconds: -10);
$challenge = new Challenge(self::SECRET);

$this->assertTrue($challenge->verify($valid));
$this->assertFalse($challenge->verify($expired));
$this->assertFalse($challenge->verify(''));
}

public function test_clamp_cost_constrains_to_bounds(): void
{
$this->assertSame(Challenge::MIN_COST, Challenge::clampCost(1));
Expand Down
Loading