From 69fa80cb5832568c45e2c3c252ae1f471f709f52 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 2 Jul 2026 19:20:15 +0100 Subject: [PATCH 01/19] Add proof-of-work challenge primitives Adds a stateless, self-hosted challenge engine under Utopia\WAF\Challenge: Signer (HMAC token sign/verify with key rotation), Issuer/Verifier (proof-of-work nonce), Clearance (short-lived solved token), Context and Ip (network-prefix binding). No new dependencies; verification is one hash. --- src/Challenge/Clearance.php | 70 +++++++ src/Challenge/Context.php | 24 +++ src/Challenge/Ip.php | 43 +++++ src/Challenge/Issuer.php | 58 ++++++ src/Challenge/Signer.php | 135 +++++++++++++ src/Challenge/Verifier.php | 92 +++++++++ src/Exception/Challenge.php | 7 + tests/Challenge/ChallengeTest.php | 302 ++++++++++++++++++++++++++++++ 8 files changed, 731 insertions(+) create mode 100644 src/Challenge/Clearance.php create mode 100644 src/Challenge/Context.php create mode 100644 src/Challenge/Ip.php create mode 100644 src/Challenge/Issuer.php create mode 100644 src/Challenge/Signer.php create mode 100644 src/Challenge/Verifier.php create mode 100644 src/Exception/Challenge.php create mode 100644 tests/Challenge/ChallengeTest.php diff --git a/src/Challenge/Clearance.php b/src/Challenge/Clearance.php new file mode 100644 index 0000000..f140d4b --- /dev/null +++ b/src/Challenge/Clearance.php @@ -0,0 +1,70 @@ +signer->sign([ + 'typ' => 'clr', + 'ver' => 1, + 'pid' => $context->projectId, + 'aud' => $context->audience, + 'iph' => $this->signer->fingerprintIp($context->ip), + 'iat' => $issuedAt, + 'exp' => $issuedAt + $ttl, + ]); + } + + public function verify(string $token, Context $context): bool + { + if ($token === '') { + return false; + } + + $claims = $this->signer->parse($token); + if ($claims === null || ($claims['typ'] ?? null) !== 'clr') { + return false; + } + + $now = \time(); + $expiresAt = (int) ($claims['exp'] ?? 0); + $issuedAt = (int) ($claims['iat'] ?? PHP_INT_MAX); + if ($expiresAt < $now - self::LEEWAY || $issuedAt > $now + self::LEEWAY) { + return false; + } + + if (($claims['pid'] ?? null) !== $context->projectId || ($claims['aud'] ?? null) !== $context->audience) { + return false; + } + + $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; + $expectedIp = $this->signer->fingerprintIp($context->ip, $kid); + + return \hash_equals($expectedIp, (string) ($claims['iph'] ?? '')); + } +} diff --git a/src/Challenge/Context.php b/src/Challenge/Context.php new file mode 100644 index 0000000..21f02cb --- /dev/null +++ b/src/Challenge/Context.php @@ -0,0 +1,24 @@ +signer->sign([ + 'typ' => 'pow', + 'ver' => 1, + 'pid' => $context->projectId, + 'aud' => $context->audience, + 'iph' => $this->signer->fingerprintIp($context->ip), + 'dif' => $difficulty, + 'iat' => $issuedAt, + 'exp' => $expiresAt, + 'rnd' => \bin2hex(\random_bytes(16)), + ]); + + return [ + 'nonce' => $nonce, + 'difficulty' => $difficulty, + 'algorithm' => self::ALGORITHM, + 'expiresAt' => $expiresAt, + ]; + } +} diff --git a/src/Challenge/Signer.php b/src/Challenge/Signer.php new file mode 100644 index 0000000..44acd49 --- /dev/null +++ b/src/Challenge/Signer.php @@ -0,0 +1,135 @@ + kid => secret, primary first + */ + private array $keyset; + + /** + * @param string $secret current signing secret + * @param int $kid identifier for the current secret + * @param array $previousSecrets superseded secrets kept for the rotation window, keyed by their kid + */ + public function __construct( + #[\SensitiveParameter] private readonly string $secret, + private readonly int $kid = 1, + #[\SensitiveParameter] array $previousSecrets = [], + ) { + if ($secret === '') { + throw new ChallengeException('Challenge signing secret must not be empty.'); + } + + $this->keyset = [$kid => $secret] + $previousSecrets; + } + + /** + * Sign a claims set, stamping it with the current key id. + * + * @param array $claims + */ + public function sign(array $claims): string + { + $claims['kid'] = $this->kid; + + try { + $json = \json_encode($claims, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } catch (\JsonException $exception) { + throw new ChallengeException('Unable to encode challenge claims: ' . $exception->getMessage()); + } + + $payload = self::encode($json); + $signature = self::encode($this->hmac($payload, $this->secret)); + + return $payload . '.' . $signature; + } + + /** + * Verify a token's signature and return its claims, or null if the token is + * malformed, signed with an unknown key, or tampered with. + * + * @return array|null + */ + public function parse(string $token): ?array + { + $parts = \explode('.', $token); + if (\count($parts) !== 2) { + return null; + } + + [$payload, $signature] = $parts; + if ($payload === '' || $signature === '') { + return null; + } + + $decoded = self::decode($payload); + if ($decoded === null) { + return null; + } + + $claims = \json_decode($decoded, true); + if (!\is_array($claims)) { + return null; + } + + $kid = $claims['kid'] ?? null; + if (!\is_int($kid) || !isset($this->keyset[$kid])) { + return null; + } + + $expected = self::encode($this->hmac($payload, $this->keyset[$kid])); + if (!\hash_equals($expected, $signature)) { + return null; + } + + return $claims; + } + + /** + * Derive a stable, privacy-preserving fingerprint of an IP's network prefix. + * + * Keyed with the signing secret so the value is meaningless in logs and + * cannot be reversed to an address. Pass the token's own kid at verify time + * so rotation does not invalidate the binding of already-issued tokens. + */ + public function fingerprintIp(string $ip, ?int $kid = null): string + { + $kid ??= $this->kid; + $secret = $this->keyset[$kid] ?? $this->secret; + + return \substr(\hash_hmac('sha256', Ip::prefix($ip), $secret), 0, 16); + } + + private function hmac(string $payload, string $secret): string + { + return \hash_hmac('sha256', $payload, $secret, true); + } + + private static function encode(string $binary): string + { + return \rtrim(\strtr(\base64_encode($binary), '+/', '-_'), '='); + } + + private static function decode(string $value): ?string + { + $decoded = \base64_decode(\strtr($value, '-_', '+/'), true); + + return $decoded === false ? null : $decoded; + } +} diff --git a/src/Challenge/Verifier.php b/src/Challenge/Verifier.php new file mode 100644 index 0000000..36c807d --- /dev/null +++ b/src/Challenge/Verifier.php @@ -0,0 +1,92 @@ + self::SOLUTION_MAX_LENGTH) { + return false; + } + + $claims = $this->signer->parse($nonce); + if ($claims === null || ($claims['typ'] ?? null) !== 'pow') { + return false; + } + + $now = \time(); + $expiresAt = (int) ($claims['exp'] ?? 0); + $issuedAt = (int) ($claims['iat'] ?? PHP_INT_MAX); + if ($expiresAt < $now - self::LEEWAY || $issuedAt > $now + self::LEEWAY) { + return false; + } + + if (($claims['pid'] ?? null) !== $context->projectId || ($claims['aud'] ?? null) !== $context->audience) { + return false; + } + + $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; + $expectedIp = $this->signer->fingerprintIp($context->ip, $kid); + if (!\hash_equals($expectedIp, (string) ($claims['iph'] ?? ''))) { + return false; + } + + $difficulty = (int) ($claims['dif'] ?? 0); + if ($difficulty <= 0) { + return false; + } + + $digest = \hash(Issuer::ALGORITHM, $nonce . '.' . $solution, true); + + return self::leadingZeroBits($digest) >= $difficulty; + } + + /** + * Count leading zero bits across the raw (binary) digest. + */ + private static function leadingZeroBits(string $digest): int + { + $bits = 0; + $length = \strlen($digest); + + for ($i = 0; $i < $length; $i++) { + $byte = \ord($digest[$i]); + + if ($byte === 0) { + $bits += 8; + + continue; + } + + for ($mask = 0x80; $mask > 0; $mask >>= 1) { + if (($byte & $mask) !== 0) { + return $bits; + } + + $bits++; + } + + break; + } + + return $bits; + } +} diff --git a/src/Exception/Challenge.php b/src/Exception/Challenge.php new file mode 100644 index 0000000..7a56fa3 --- /dev/null +++ b/src/Exception/Challenge.php @@ -0,0 +1,7 @@ +leadingZeroBits(\hash('sha256', $nonce . '.' . $solution, true)) >= $difficulty) { + return $solution; + } + } + + $this->fail('Could not find a solution for difficulty ' . $difficulty); + } + + private function leadingZeroBits(string $digest): int + { + $bits = 0; + foreach (\str_split($digest) as $char) { + $byte = \ord($char); + if ($byte === 0) { + $bits += 8; + + continue; + } + for ($mask = 0x80; $mask > 0; $mask >>= 1) { + if (($byte & $mask) !== 0) { + return $bits; + } + $bits++; + } + break; + } + + return $bits; + } + + public function testSignerRejectsEmptySecret(): void + { + $this->expectException(ChallengeException::class); + new Signer(''); + } + + public function testSignParseRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $token = $signer->sign(['typ' => 'clr', 'foo' => 'bar']); + + $claims = $signer->parse($token); + $this->assertIsArray($claims); + $this->assertSame('clr', $claims['typ']); + $this->assertSame('bar', $claims['foo']); + $this->assertSame(1, $claims['kid']); + } + + public function testParseRejectsTamperedPayload(): void + { + $signer = new Signer(self::SECRET); + $token = $signer->sign(['typ' => 'clr']); + + [$payload, $signature] = \explode('.', $token); + $forged = \rtrim(\strtr(\base64_encode('{"typ":"clr","kid":1,"admin":true}'), '+/', '-_'), '='); + + $this->assertNull($signer->parse($forged . '.' . $signature)); + } + + public function testParseRejectsMalformedTokens(): void + { + $signer = new Signer(self::SECRET); + + $this->assertNull($signer->parse('')); + $this->assertNull($signer->parse('no-dot')); + $this->assertNull($signer->parse('a.b.c')); + $this->assertNull($signer->parse('.sig')); + $this->assertNull($signer->parse('payload.')); + } + + public function testParseRejectsForeignSecret(): void + { + $minted = new Signer(self::SECRET); + $other = new Signer('a-completely-different-secret'); + + $token = $minted->sign(['typ' => 'clr']); + $this->assertNull($other->parse($token)); + } + + public function testKeyRotationAcceptsPreviousSecret(): void + { + $old = new Signer('old-secret', kid: 1); + $token = $old->sign(['typ' => 'clr', 'v' => 1]); + + // New primary key, old key retained for the rotation window. + $rotated = new Signer('new-secret', kid: 2, previousSecrets: [1 => 'old-secret']); + + $claims = $rotated->parse($token); + $this->assertIsArray($claims); + $this->assertSame(1, $claims['kid']); + + // Once the old key is dropped, the token no longer verifies. + $droppedOld = new Signer('new-secret', kid: 2); + $this->assertNull($droppedOld->parse($token)); + } + + public function testIssueClampsDifficulty(): void + { + $issuer = new Issuer(new Signer(self::SECRET)); + + $this->assertSame(Issuer::DIFFICULTY_MIN, $issuer->issue($this->context(), 1)['difficulty']); + $this->assertSame(Issuer::DIFFICULTY_MAX, $issuer->issue($this->context(), 999)['difficulty']); + } + + public function testProofOfWorkRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + $verifier = new Verifier($signer); + + // Low difficulty keeps the brute-force fast and deterministic. + $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); + $nonce = $challenge['nonce']; + $solution = $this->solve($nonce, 8); + + // Re-issue at difficulty 8 for the positive case would need a private hook; + // instead assert against a nonce we can satisfy by lowering the bar via a + // dedicated low-difficulty signer token. + $lowNonce = $signer->sign([ + 'typ' => 'pow', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'dif' => 8, + 'iat' => \time(), + 'exp' => \time() + 120, + ]); + $lowSolution = $this->solve($lowNonce, 8); + + $this->assertTrue($verifier->verify($lowNonce, $lowSolution, $this->context())); + $this->assertIsString($nonce); + $this->assertIsString($solution); + } + + public function testVerifyRejectsInsufficientWork(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + $verifier = new Verifier($signer); + + // Real difficulty (2^16); a trivial solution will not meet it. + $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT); + $this->assertFalse($verifier->verify($challenge['nonce'], '0', $this->context())); + } + + public function testVerifyRejectsOversizedAndEmptySolution(): void + { + $signer = new Signer(self::SECRET); + $verifier = new Verifier($signer); + $nonce = (new Issuer($signer))->issue($this->context(), Issuer::DIFFICULTY_MIN)['nonce']; + + $this->assertFalse($verifier->verify($nonce, '', $this->context())); + $this->assertFalse($verifier->verify($nonce, \str_repeat('a', Verifier::SOLUTION_MAX_LENGTH + 1), $this->context())); + } + + public function testVerifyRejectsExpiredNonce(): void + { + $signer = new Signer(self::SECRET); + $verifier = new Verifier($signer); + + // Hand-craft an already-expired nonce at difficulty 1 (trivially solvable). + $nonce = $signer->sign([ + 'typ' => 'pow', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'dif' => 1, + 'iat' => \time() - 1000, + 'exp' => \time() - 500, + ]); + $solution = $this->solve($nonce, 1); + + $this->assertFalse($verifier->verify($nonce, $solution, $this->context())); + } + + public function testVerifyRejectsWrongContext(): void + { + $signer = new Signer(self::SECRET); + $verifier = new Verifier($signer); + + $nonce = $signer->sign([ + 'typ' => 'pow', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'dif' => 1, + 'iat' => \time(), + 'exp' => \time() + 120, + ]); + $solution = $this->solve($nonce, 1); + + $this->assertFalse($verifier->verify($nonce, $solution, new Context('other-project', 'api', '203.0.113.9'))); + $this->assertFalse($verifier->verify($nonce, $solution, new Context('proj-123', 'mysite.example', '203.0.113.9'))); + // Different /24 network. + $this->assertFalse($verifier->verify($nonce, $solution, new Context('proj-123', 'api', '198.51.100.9'))); + // Same /24 still passes. + $this->assertTrue($verifier->verify($nonce, $solution, new Context('proj-123', 'api', '203.0.113.200'))); + } + + public function testClearanceRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $clearance = new Clearance($signer); + + $token = $clearance->issue($this->context()); + $this->assertTrue($clearance->verify($token, $this->context())); + } + + public function testClearanceClampsTtl(): void + { + $signer = new Signer(self::SECRET); + $clearance = new Clearance($signer); + + $token = $clearance->issue($this->context(), 5); + $claims = $signer->parse($token); + $this->assertIsArray($claims); + $this->assertSame(Clearance::TTL_MIN, $claims['exp'] - $claims['iat']); + } + + public function testClearanceRejectsExpired(): void + { + $signer = new Signer(self::SECRET); + $clearance = new Clearance($signer); + + $token = $signer->sign([ + 'typ' => 'clr', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'iat' => \time() - 10000, + 'exp' => \time() - 9000, + ]); + + $this->assertFalse($clearance->verify($token, $this->context())); + } + + public function testClearanceRejectsCrossType(): void + { + $signer = new Signer(self::SECRET); + $clearance = new Clearance($signer); + $verifier = new Verifier($signer); + + // A clearance token must not be accepted as a PoW nonce, and vice versa. + $clr = $clearance->issue($this->context()); + $this->assertFalse($verifier->verify($clr, '0', $this->context())); + + $pow = (new Issuer($signer))->issue($this->context())['nonce']; + $this->assertFalse($clearance->verify($pow, $this->context())); + } + + public function testClearanceRejectsWrongNetwork(): void + { + $signer = new Signer(self::SECRET); + $clearance = new Clearance($signer); + + $token = $clearance->issue($this->context('203.0.113.9')); + + $this->assertTrue($clearance->verify($token, $this->context('203.0.113.77'))); + $this->assertFalse($clearance->verify($token, $this->context('198.51.100.77'))); + } + + public function testIpPrefix(): void + { + $this->assertSame(Ip::prefix('203.0.113.9'), Ip::prefix('203.0.113.250')); + $this->assertNotSame(Ip::prefix('203.0.113.9'), Ip::prefix('203.0.114.9')); + + $this->assertSame(Ip::prefix('2001:db8:abcd:1234::1'), Ip::prefix('2001:db8:abcd:1234:ffff::9')); + $this->assertNotSame(Ip::prefix('2001:db8:abcd:1234::1'), Ip::prefix('2001:db8:abcd:9999::1')); + + // Non-address input binds to itself rather than throwing. + $this->assertSame('not-an-ip', Ip::prefix('not-an-ip')); + } +} From 25a73dc2df6436f73892ee16ee944488bea2a8de Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 2 Jul 2026 19:34:08 +0100 Subject: [PATCH 02/19] Address review: harden Signer, drop dead code, real PoW round-trip - Throw on unknown kid in fingerprintIp instead of silently using the primary secret - Reject empty previousSecrets values, matching the primary-secret guard - Remove unreachable break in leadingZeroBits - Drive the positive PoW test through a real issuer nonce at minimum difficulty; add tests for the two new guards --- src/Challenge/Signer.php | 12 ++++++++-- src/Challenge/Verifier.php | 2 -- tests/Challenge/ChallengeTest.php | 38 +++++++++++++++---------------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/Challenge/Signer.php b/src/Challenge/Signer.php index 44acd49..70e4aa0 100644 --- a/src/Challenge/Signer.php +++ b/src/Challenge/Signer.php @@ -36,6 +36,12 @@ public function __construct( throw new ChallengeException('Challenge signing secret must not be empty.'); } + foreach ($previousSecrets as $previousKid => $previousSecret) { + if ($previousSecret === '') { + throw new ChallengeException('Previous challenge secret for kid ' . $previousKid . ' must not be empty.'); + } + } + $this->keyset = [$kid => $secret] + $previousSecrets; } @@ -111,9 +117,11 @@ public function parse(string $token): ?array public function fingerprintIp(string $ip, ?int $kid = null): string { $kid ??= $this->kid; - $secret = $this->keyset[$kid] ?? $this->secret; + if (!isset($this->keyset[$kid])) { + throw new ChallengeException('Unknown key id: ' . $kid); + } - return \substr(\hash_hmac('sha256', Ip::prefix($ip), $secret), 0, 16); + return \substr(\hash_hmac('sha256', Ip::prefix($ip), $this->keyset[$kid]), 0, 16); } private function hmac(string $payload, string $secret): string diff --git a/src/Challenge/Verifier.php b/src/Challenge/Verifier.php index 36c807d..fda885c 100644 --- a/src/Challenge/Verifier.php +++ b/src/Challenge/Verifier.php @@ -83,8 +83,6 @@ private static function leadingZeroBits(string $digest): int $bits++; } - - break; } return $bits; diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php index 8e45999..59e13c0 100644 --- a/tests/Challenge/ChallengeTest.php +++ b/tests/Challenge/ChallengeTest.php @@ -63,6 +63,20 @@ public function testSignerRejectsEmptySecret(): void new Signer(''); } + public function testSignerRejectsEmptyPreviousSecret(): void + { + $this->expectException(ChallengeException::class); + new Signer(self::SECRET, kid: 2, previousSecrets: [1 => '']); + } + + public function testFingerprintIpRejectsUnknownKid(): void + { + $signer = new Signer(self::SECRET, kid: 1); + + $this->expectException(ChallengeException::class); + $signer->fingerprintIp('203.0.113.9', 9); + } + public function testSignParseRoundTrip(): void { $signer = new Signer(self::SECRET); @@ -137,28 +151,14 @@ public function testProofOfWorkRoundTrip(): void $issuer = new Issuer($signer); $verifier = new Verifier($signer); - // Low difficulty keeps the brute-force fast and deterministic. + // Drive the real Issuer -> Verifier path at the clamped minimum + // difficulty (2^16 ~ 65k hashes, well under a second). $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); - $nonce = $challenge['nonce']; - $solution = $this->solve($nonce, 8); + $this->assertSame(Issuer::DIFFICULTY_MIN, $challenge['difficulty']); - // Re-issue at difficulty 8 for the positive case would need a private hook; - // instead assert against a nonce we can satisfy by lowering the bar via a - // dedicated low-difficulty signer token. - $lowNonce = $signer->sign([ - 'typ' => 'pow', - 'pid' => 'proj-123', - 'aud' => 'api', - 'iph' => $signer->fingerprintIp('203.0.113.9'), - 'dif' => 8, - 'iat' => \time(), - 'exp' => \time() + 120, - ]); - $lowSolution = $this->solve($lowNonce, 8); + $solution = $this->solve($challenge['nonce'], $challenge['difficulty']); - $this->assertTrue($verifier->verify($lowNonce, $lowSolution, $this->context())); - $this->assertIsString($nonce); - $this->assertIsString($solution); + $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); } public function testVerifyRejectsInsufficientWork(): void From 58f2a8ba875046702fde0ebbf4d9018327feb646 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 2 Jul 2026 22:30:22 +0200 Subject: [PATCH 03/19] Expose relative WAF challenge expiry --- src/Challenge/Issuer.php | 7 ++++++- tests/Challenge/ChallengeTest.php | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php index 94dc5f3..95d5971 100644 --- a/src/Challenge/Issuer.php +++ b/src/Challenge/Issuer.php @@ -27,7 +27,11 @@ public function __construct(private readonly Signer $signer) /** * Mint a challenge for the given context. * - * @return array{nonce: string, difficulty: int, algorithm: string, expiresAt: int} + * `expiresAt` is an absolute timestamp for convenience; `expiresIn` is the + * same deadline expressed relative to issuance, which clients should prefer + * so a skewed local clock does not shorten or extend the solve window. + * + * @return array{nonce: string, difficulty: int, algorithm: string, expiresAt: int, expiresIn: int} */ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAULT): array { @@ -53,6 +57,7 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU 'difficulty' => $difficulty, 'algorithm' => self::ALGORITHM, 'expiresAt' => $expiresAt, + 'expiresIn' => self::NONCE_TTL, ]; } } diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php index 59e13c0..f4135e1 100644 --- a/tests/Challenge/ChallengeTest.php +++ b/tests/Challenge/ChallengeTest.php @@ -155,6 +155,9 @@ public function testProofOfWorkRoundTrip(): void // difficulty (2^16 ~ 65k hashes, well under a second). $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); $this->assertSame(Issuer::DIFFICULTY_MIN, $challenge['difficulty']); + $this->assertSame(Issuer::ALGORITHM, $challenge['algorithm']); + $this->assertSame(Issuer::NONCE_TTL, $challenge['expiresIn']); + $this->assertSame($challenge['expiresIn'], $challenge['expiresAt'] - \time()); $solution = $this->solve($challenge['nonce'], $challenge['difficulty']); From 3217bcc49943e02246ba5327900859bba045410e Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 2 Jul 2026 22:52:10 +0200 Subject: [PATCH 04/19] Add expiresIn and nonce-carried clearance TTL to Issuer - issue() now returns expiresIn (relative deadline) alongside expiresAt so clients can compute a skew-safe local deadline. - issue() accepts an optional clearanceTtl, carried in the signed nonce as the `ctl` claim; clearanceTtl() reads it back after verification. This lets a stateless solve endpoint mint the clearance with the rule's configured TTL, which it otherwise cannot know (it only receives the nonce). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Challenge/Issuer.php | 34 ++++++++++++++++++++++++++++--- tests/Challenge/ChallengeTest.php | 18 ++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php index 95d5971..8223712 100644 --- a/src/Challenge/Issuer.php +++ b/src/Challenge/Issuer.php @@ -31,16 +31,22 @@ public function __construct(private readonly Signer $signer) * same deadline expressed relative to issuance, which clients should prefer * so a skewed local clock does not shorten or extend the solve window. * + * `$clearanceTtl`, when given, is carried inside the signed nonce (claim + * `ctl`) so the solve endpoint — which only receives the nonce, never the + * matched rule — can mint the clearance with the rule's configured lifetime. + * It is authenticated by the nonce signature; read it back with + * {@see self::clearanceTtl()} after verification. + * * @return array{nonce: string, difficulty: int, algorithm: string, expiresAt: int, expiresIn: int} */ - public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAULT): array + public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAULT, ?int $clearanceTtl = null): array { $difficulty = \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); $issuedAt = \time(); $expiresAt = $issuedAt + self::NONCE_TTL; - $nonce = $this->signer->sign([ + $claims = [ 'typ' => 'pow', 'ver' => 1, 'pid' => $context->projectId, @@ -50,7 +56,13 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU 'iat' => $issuedAt, 'exp' => $expiresAt, 'rnd' => \bin2hex(\random_bytes(16)), - ]); + ]; + + if ($clearanceTtl !== null) { + $claims['ctl'] = $clearanceTtl; + } + + $nonce = $this->signer->sign($claims); return [ 'nonce' => $nonce, @@ -60,4 +72,20 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU 'expiresIn' => self::NONCE_TTL, ]; } + + /** + * Read the clearance TTL carried by a nonce, or null when it carries none. + * + * The caller MUST have already verified the nonce (see Verifier); this only + * decodes the signed claims and does not re-check authenticity or expiry. + */ + public function clearanceTtl(string $nonce): ?int + { + $claims = $this->signer->parse($nonce); + if ($claims === null || !isset($claims['ctl']) || !\is_int($claims['ctl'])) { + return null; + } + + return $claims['ctl']; + } } diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php index f4135e1..5403982 100644 --- a/tests/Challenge/ChallengeTest.php +++ b/tests/Challenge/ChallengeTest.php @@ -164,6 +164,24 @@ public function testProofOfWorkRoundTrip(): void $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); } + public function testNonceCarriesClearanceTtlRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + + // No ttl requested -> nonce carries none. + $plain = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); + $this->assertNull($issuer->clearanceTtl($plain['nonce'])); + + // Requested ttl is carried verbatim inside the signed nonce so the solve + // endpoint can honour the rule's configured lifetime. + $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN, 1800); + $this->assertSame(1800, $issuer->clearanceTtl($challenge['nonce'])); + + // A tampered nonce yields no ttl (signature no longer parses). + $this->assertNull($issuer->clearanceTtl($challenge['nonce'] . 'x')); + } + public function testVerifyRejectsInsufficientWork(): void { $signer = new Signer(self::SECRET); From 39cb473fb129d946f602fc2e0734fb3d6630efdc Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 2 Jul 2026 23:21:32 +0200 Subject: [PATCH 05/19] Add JS reference: pure-JS PoW solver + SDK interceptor (phases 1, 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reference/solver.js: dependency-free SHA-256 + proof-of-work solver, the single source of truth inlined by the edge interstitial and the web/node SDKs. Verified against the sha256("abc") vector and end-to-end against the PHP Verifier (a nonce this solves is accepted server-side). - reference/interceptor.js: reference SDK retry interceptor — single-flight solve, skew-safe token cache, one re-solve on a stale-token 403, never loops. - reference/README.md: contract, SDK integration notes, and the 0.1.0 benchmark gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- reference/README.md | 60 ++++++++++++++++ reference/interceptor.js | 114 ++++++++++++++++++++++++++++++ reference/solver.js | 149 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 reference/README.md create mode 100644 reference/interceptor.js create mode 100644 reference/solver.js diff --git a/reference/README.md b/reference/README.md new file mode 100644 index 0000000..817a048 --- /dev/null +++ b/reference/README.md @@ -0,0 +1,60 @@ +# WAF Challenge — JS reference + +Version-locked JavaScript companions to the PHP `Utopia\WAF\Challenge` +primitives. These are the **single source of truth** for the proof-of-work +solver used by two consumers: + +- the **edge browser interstitial** (`Utopia\WAF\Challenge\Interstitial` inlines + this algorithm), and +- the **web + node SDK retry interceptors**. + +Keeping them here, next to the primitives, means the solver and the +`DIFFICULTY_DEFAULT_*` constants change together. + +## Files + +| File | Purpose | +| --- | --- | +| `solver.js` | Dependency-free pure-JS SHA-256 + proof-of-work solver. `solve()` (chunked/yielding, for browsers) and `solveSync()` (blocking, for node). Mirrors the PHP `Verifier` leading-zero-bit rule exactly. | +| `interceptor.js` | Reference SDK retry interceptor: single-flight solve, token caching with a skew-safe deadline, one re-solve on a stale-token 403, never loops. | + +## Contract + +A client finds the smallest non-negative integer `solution` such that + +``` +sha256(nonce + '.' + solution) has >= difficulty leading zero bits +``` + +then `POST`s `{ nonce, solution }` to `/v1/waf/challenge` (API) or +`/__waf/challenge` (edge site) to obtain a clearance token/cookie. + +## Why pure-JS (not `crypto.subtle`) + +`crypto.subtle.digest` is async and its per-call overhead makes a +digest-in-a-loop 10–100× slower than a tight synchronous JS implementation for +this access pattern. The browser solver therefore uses the inline function and +yields between chunks (`requestAnimationFrame`) to keep the tab responsive. + +## Verified interop + +`solver.js` is checked against the SHA-256 known-answer vector for `"abc"` and, +end-to-end, a nonce minted by the PHP `Issuer` is solved here and accepted by the +PHP `Verifier` — so browser/SDK solutions are valid server-side. + +## SDK integration (phase 3) + +The generated `appwrite`/`node-appwrite` SDKs wrap their transport with +`createInterceptor({ endpoint, projectId, doFetch })` and route all requests +through `interceptor.request(url, init)`. `doFetch` performs one raw request and +returns `{ status, headers, json }`. Web solves via `solve()`; node via +`solveSync()`. Ship web first (it exercises both the browser solver and the CORS +header exposure), then node. + +## Benchmark gate (phase 1) + +Before tagging `0.1.0`, run `solver.js` on the reference devices (mid-range +Android, ~3-gen-old iPhone/Safari, low-end laptop). Acceptance: **p95 solve ≤ 4 s** +on the mid-range Android at `DIFFICULTY_DEFAULT_BROWSER`. PoW solve time is +exponentially distributed (p95 ≈ 3× median), so target median ≤ ~1.3 s. Set the +final `DIFFICULTY_DEFAULT_BROWSER` to the largest value that passes. diff --git a/reference/interceptor.js b/reference/interceptor.js new file mode 100644 index 0000000..7ff486c --- /dev/null +++ b/reference/interceptor.js @@ -0,0 +1,114 @@ +// Reference WAF-challenge retry interceptor for the Appwrite SDKs (web + node). +// +// This is the canonical behaviour the generated SDKs wrap around their transport +// (fetch on web, https/undici on node). It is intentionally transport-agnostic: +// pass a `doFetch` that performs one request and returns { status, headers, json }. +// +// Behaviour (HLD §2.4): +// 1. Trigger only on a response whose error type is 'waf_challenge_required'. +// 2. Read the challenge parameters from the CORS-exposed X-Appwrite-WAF-* headers. +// 3. SINGLE-FLIGHT: N concurrent requests that all 403 share ONE solve, keyed by +// (endpoint, projectId, nonce-audience); all retry with the minted token. +// 4. Solve with solver.js, POST /v1/waf/challenge, cache { token, deadline }. +// deadline = now + expiresIn - 30s (skew margin; prefer expiresIn over the +// absolute Expires so a wrong local clock cannot shorten/extend the window). +// 5. Attach X-Appwrite-WAF-Token on subsequent requests while now < deadline. +// 6. On a challenge 403 DESPITE a cached token (expiry race / IP change): drop +// the cache, re-solve ONCE, then surface the error. Never loop. + +'use strict'; + +const { solve, solveSync } = require('./solver.js'); + +const CHALLENGE_ERROR = 'waf_challenge_required'; +const DEADLINE_SKEW_SECONDS = 30; + +// Solve in a browser (yielding) when available, else synchronously (node). +function solveChallenge(nonce, difficulty) { + return (typeof window !== 'undefined') + ? solve(nonce, difficulty) + : Promise.resolve(solveSync(nonce, difficulty)); +} + +function isChallenge(res) { + if (!res || res.status !== 403) return false; + const body = res.json || {}; + return body.type === CHALLENGE_ERROR; +} + +function readChallenge(res) { + const h = res.headers || {}; + const get = (k) => h[k] || h[k.toLowerCase()] || ''; + return { + nonce: get('X-Appwrite-WAF-Nonce'), + difficulty: parseInt(get('X-Appwrite-WAF-Difficulty') || '0', 10) || 0, + expiresIn: parseInt(get('X-Appwrite-WAF-Expires-In') || '0', 10) || 0, + }; +} + +/** + * Create an interceptor bound to one project/endpoint. + * + * @param {object} opts + * @param {string} opts.endpoint e.g. https://cloud.appwrite.io/v1 + * @param {string} opts.projectId + * @param {(url:string, init:object)=>Promise<{status:number,headers:object,json:any}>} opts.doFetch + * @param {()=>number} [opts.now] injectable clock (seconds), for tests + */ +function createInterceptor(opts) { + const now = opts.now || (() => Math.floor(Date.now() / 1000)); + const key = opts.endpoint + '|' + opts.projectId; // single-flight + cache key + const cache = new Map(); // key -> { token, deadline } + const inflight = new Map(); // key -> Promise (single-flight) + + async function mintToken(res) { + // Coalesce concurrent solves for the same audience. + if (inflight.has(key)) return inflight.get(key); + + const p = (async () => { + const { nonce, difficulty, expiresIn } = readChallenge(res); + const solution = await solveChallenge(nonce, difficulty); + const solveRes = await opts.doFetch(opts.endpoint + '/waf/challenge', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-appwrite-project': opts.projectId }, + body: JSON.stringify({ nonce, solution }), + }); + if (solveRes.status < 200 || solveRes.status >= 300) { + throw new Error('waf challenge solve failed: ' + solveRes.status); + } + const token = (solveRes.json || {}).token; + const ttl = (solveRes.json || {}).expiresIn || expiresIn; + cache.set(key, { token, deadline: now() + ttl - DEADLINE_SKEW_SECONDS }); + return token; + })().finally(() => inflight.delete(key)); + + inflight.set(key, p); + return p; + } + + function attachToken(init) { + const cached = cache.get(key); + if (cached && now() < cached.deadline) { + init = init || {}; + init.headers = Object.assign({}, init.headers, { 'X-Appwrite-WAF-Token': cached.token }); + } + return init; + } + + // Wrap a single request with challenge handling. `alreadyRetried` guards the + // "re-solve once on a stale-token 403" path so we can never loop. + async function request(url, init, alreadyRetried) { + const res = await opts.doFetch(url, attachToken(init)); + if (!isChallenge(res)) return res; + + if (alreadyRetried) return res; // solved-and-still-challenged: surface it + if (cache.has(key)) cache.delete(key); // stale token — drop and re-solve once + + await mintToken(res); + return request(url, init, true); + } + + return { request, _cache: cache }; +} + +module.exports = { createInterceptor, isChallenge, readChallenge, solveChallenge }; diff --git a/reference/solver.js b/reference/solver.js new file mode 100644 index 0000000..c959a2b --- /dev/null +++ b/reference/solver.js @@ -0,0 +1,149 @@ +// Canonical proof-of-work solver for the Appwrite WAF challenge. +// +// This is the single source of truth for the browser interstitial (inlined into +// Utopia\WAF\Challenge\Interstitial on the edge) and the SDK retry interceptors +// (web + node). It is intentionally dependency-free and environment-agnostic: +// a pure-JS SHA-256 (no crypto.subtle-per-attempt, whose async overhead makes it +// 10-100x slower for this access pattern) plus a leading-zero-bit check that +// mirrors the PHP Verifier exactly. +// +// Contract: find the smallest non-negative integer `solution` such that +// sha256(nonce + '.' + solution) has >= difficulty leading zero bits. +// +// Usage (async, yields between chunks so a browser tab stays responsive): +// const solution = await solve(nonce, difficulty, { onProgress: p => ... }); +// Usage (synchronous, for node/server callers): +// const solution = solveSync(nonce, difficulty); + +'use strict'; + +const K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +function utf8Bytes(str) { + const b = []; + for (let i = 0; i < str.length; i++) { + let c = str.charCodeAt(i); + if (c < 0x80) b.push(c); + else if (c < 0x800) b.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c < 0xd800 || c >= 0xe000) b.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + else { + c = 0x10000 + (((c & 0x3ff) << 10) | (str.charCodeAt(++i) & 0x3ff)); + b.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + } + return b; +} + +function sha256Bytes(bytes) { + const l = bytes.length; + const withOne = l + 1; + const k = (56 - (withOne % 64) + 64) % 64; + const total = withOne + k + 8; + const m = new Uint8Array(total); + m.set(bytes, 0); + m[l] = 0x80; + const bitLen = l * 8; + m[total - 4] = (bitLen >>> 24) & 0xff; + m[total - 3] = (bitLen >>> 16) & 0xff; + m[total - 2] = (bitLen >>> 8) & 0xff; + m[total - 1] = bitLen & 0xff; + + let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a, + h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19; + const w = new Int32Array(64); + + for (let off = 0; off < total; off += 64) { + for (let i = 0; i < 16; i++) { + const j = off + i * 4; + w[i] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | m[j + 3]; + } + for (let i = 16; i < 64; i++) { + const a = w[i - 15], b = w[i - 2]; + const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3); + const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + let A = h0, B = h1, C = h2, D = h3, E = h4, F = h5, G = h6, H = h7; + for (let i = 0; i < 64; i++) { + const S1 = ((E >>> 6) | (E << 26)) ^ ((E >>> 11) | (E << 21)) ^ ((E >>> 25) | (E << 7)); + const ch = (E & F) ^ ((~E) & G); + const t1 = (H + S1 + ch + K[i] + w[i]) | 0; + const S0 = ((A >>> 2) | (A << 30)) ^ ((A >>> 13) | (A << 19)) ^ ((A >>> 22) | (A << 10)); + const maj = (A & B) ^ (A & C) ^ (B & C); + const t2 = (S0 + maj) | 0; + H = G; G = F; F = E; E = (D + t1) | 0; D = C; C = B; B = A; A = (t1 + t2) | 0; + } + h0 = (h0 + A) | 0; h1 = (h1 + B) | 0; h2 = (h2 + C) | 0; h3 = (h3 + D) | 0; + h4 = (h4 + E) | 0; h5 = (h5 + F) | 0; h6 = (h6 + G) | 0; h7 = (h7 + H) | 0; + } + const out = new Uint8Array(32); + const hs = [h0, h1, h2, h3, h4, h5, h6, h7]; + for (let i = 0; i < 8; i++) { + out[i * 4] = (hs[i] >>> 24) & 0xff; + out[i * 4 + 1] = (hs[i] >>> 16) & 0xff; + out[i * 4 + 2] = (hs[i] >>> 8) & 0xff; + out[i * 4 + 3] = hs[i] & 0xff; + } + return out; +} + +function leadingZeroBits(digest) { + let bits = 0; + for (let i = 0; i < digest.length; i++) { + const byte = digest[i]; + if (byte === 0) { bits += 8; continue; } + for (let mask = 0x80; mask > 0; mask >>= 1) { + if (byte & mask) return bits; + bits++; + } + } + return bits; +} + +function meets(nonce, solution, difficulty) { + return leadingZeroBits(sha256Bytes(utf8Bytes(nonce + '.' + solution))) >= difficulty; +} + +// Synchronous solve — for native/server callers (node interceptor) where blocking +// the event loop for a few hundred ms of native-fast hashing is acceptable. +function solveSync(nonce, difficulty) { + for (let n = 0; ; n++) { + if (meets(nonce, String(n), difficulty)) return String(n); + } +} + +// Chunked, yielding solve — for the browser, so the tab stays responsive and a +// progress callback can drive a UI. Resolves with the solution string. +function solve(nonce, difficulty, opts) { + opts = opts || {}; + const chunk = opts.chunk || 2000; + const yieldTo = (typeof requestAnimationFrame === 'function') + ? requestAnimationFrame + : (fn) => setTimeout(fn, 0); + const expectedTotal = Math.pow(2, difficulty); + return new Promise((resolve) => { + let n = 0; + function step() { + const end = n + chunk; + for (; n < end; n++) { + if (meets(nonce, String(n), difficulty)) { resolve(String(n)); return; } + } + if (opts.onProgress) opts.onProgress(Math.min(0.99, n / expectedTotal)); + yieldTo(step); + } + yieldTo(step); + }); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { solve, solveSync, meets, sha256Bytes, leadingZeroBits, utf8Bytes }; +} From b63d6c6fa7c2385a5d2cef721acc8b130bb66937 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 3 Jul 2026 00:06:16 +0200 Subject: [PATCH 06/19] reference: solve challenge unauthenticated (no API key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solving a proof-of-work is not a scoped operation. The interceptor must send the solve request with only the project header — never the API key or session — so a narrowly-scoped server key is not rejected by the scope check. Documents the contract the server route relies on (scope public/global). Co-Authored-By: Claude Opus 4.8 (1M context) --- reference/interceptor.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/reference/interceptor.js b/reference/interceptor.js index 7ff486c..a1b5ce4 100644 --- a/reference/interceptor.js +++ b/reference/interceptor.js @@ -68,6 +68,10 @@ function createInterceptor(opts) { const p = (async () => { const { nonce, difficulty, expiresIn } = readChallenge(res); const solution = await solveChallenge(nonce, difficulty); + // Solve UNAUTHENTICATED: send only the project header — never the API key + // or session. Solving a proof-of-work is not a scoped operation, and a + // narrowly-scoped server key would otherwise be rejected by the scope + // check. The server route accepts guests here (scope 'public'/'global'). const solveRes = await opts.doFetch(opts.endpoint + '/waf/challenge', { method: 'POST', headers: { 'content-type': 'application/json', 'x-appwrite-project': opts.projectId }, From ea45d869ff31ea1088656a35370d655bd9598559 Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Fri, 3 Jul 2026 15:45:06 +0200 Subject: [PATCH 07/19] WAF challenge: drop proof-of-work/captcha branding Rebrand the WAF challenge: stop calling it proof-of-work / captcha. The mechanism is unchanged; this is a naming/relabel pass only. - Prose: 'proof-of-work'/'PoW' -> 'challenge' / 'WAF challenge'. - Remove the unimplemented 'captcha' challenge type; 'custom' is the sole type. - Relabel the internal 'pow' token -> 'challenge' (nonce typ claim, the X-Appwrite-WAF-Challenge header value, and the solver function name). Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- reference/README.md | 6 +++--- reference/interceptor.js | 2 +- reference/solver.js | 2 +- src/Challenge/Issuer.php | 4 ++-- src/Challenge/Verifier.php | 4 ++-- src/Rules/Challenge.php | 5 ++--- tests/Challenge/ChallengeTest.php | 12 ++++++------ tests/RulesTest.php | 2 +- 9 files changed, 19 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7210b7b..ca523de 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ $firewall->addRule(new Bypass([ $firewall->addRule(new Challenge([ Condition::startsWith('path', '/admin'), -], Challenge::TYPE_CAPTCHA)); +], Challenge::TYPE_CUSTOM)); $firewall->addRule(new RateLimit([ Condition::equal('method', ['POST']), diff --git a/reference/README.md b/reference/README.md index 817a048..5bd23a5 100644 --- a/reference/README.md +++ b/reference/README.md @@ -1,7 +1,7 @@ # WAF Challenge — JS reference Version-locked JavaScript companions to the PHP `Utopia\WAF\Challenge` -primitives. These are the **single source of truth** for the proof-of-work +primitives. These are the **single source of truth** for the challenge solver used by two consumers: - the **edge browser interstitial** (`Utopia\WAF\Challenge\Interstitial` inlines @@ -15,7 +15,7 @@ Keeping them here, next to the primitives, means the solver and the | File | Purpose | | --- | --- | -| `solver.js` | Dependency-free pure-JS SHA-256 + proof-of-work solver. `solve()` (chunked/yielding, for browsers) and `solveSync()` (blocking, for node). Mirrors the PHP `Verifier` leading-zero-bit rule exactly. | +| `solver.js` | Dependency-free pure-JS SHA-256 + challenge solver. `solve()` (chunked/yielding, for browsers) and `solveSync()` (blocking, for node). Mirrors the PHP `Verifier` leading-zero-bit rule exactly. | | `interceptor.js` | Reference SDK retry interceptor: single-flight solve, token caching with a skew-safe deadline, one re-solve on a stale-token 403, never loops. | ## Contract @@ -55,6 +55,6 @@ header exposure), then node. Before tagging `0.1.0`, run `solver.js` on the reference devices (mid-range Android, ~3-gen-old iPhone/Safari, low-end laptop). Acceptance: **p95 solve ≤ 4 s** -on the mid-range Android at `DIFFICULTY_DEFAULT_BROWSER`. PoW solve time is +on the mid-range Android at `DIFFICULTY_DEFAULT_BROWSER`. Challenge solve time is exponentially distributed (p95 ≈ 3× median), so target median ≤ ~1.3 s. Set the final `DIFFICULTY_DEFAULT_BROWSER` to the largest value that passes. diff --git a/reference/interceptor.js b/reference/interceptor.js index a1b5ce4..215ff26 100644 --- a/reference/interceptor.js +++ b/reference/interceptor.js @@ -69,7 +69,7 @@ function createInterceptor(opts) { const { nonce, difficulty, expiresIn } = readChallenge(res); const solution = await solveChallenge(nonce, difficulty); // Solve UNAUTHENTICATED: send only the project header — never the API key - // or session. Solving a proof-of-work is not a scoped operation, and a + // or session. Solving a challenge is not a scoped operation, and a // narrowly-scoped server key would otherwise be rejected by the scope // check. The server route accepts guests here (scope 'public'/'global'). const solveRes = await opts.doFetch(opts.endpoint + '/waf/challenge', { diff --git a/reference/solver.js b/reference/solver.js index c959a2b..883fa0c 100644 --- a/reference/solver.js +++ b/reference/solver.js @@ -1,4 +1,4 @@ -// Canonical proof-of-work solver for the Appwrite WAF challenge. +// Canonical solver for the Appwrite WAF challenge. // // This is the single source of truth for the browser interstitial (inlined into // Utopia\WAF\Challenge\Interstitial on the edge) and the SDK retry interceptors diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php index 8223712..6ba6dad 100644 --- a/src/Challenge/Issuer.php +++ b/src/Challenge/Issuer.php @@ -3,7 +3,7 @@ namespace Utopia\WAF\Challenge; /** - * Issues proof-of-work challenges. + * Issues WAF challenges. * * The client must find a `solution` such that * `sha256(nonce . '.' . solution)` has at least `difficulty` leading zero bits. @@ -47,7 +47,7 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU $expiresAt = $issuedAt + self::NONCE_TTL; $claims = [ - 'typ' => 'pow', + 'typ' => 'challenge', 'ver' => 1, 'pid' => $context->projectId, 'aud' => $context->audience, diff --git a/src/Challenge/Verifier.php b/src/Challenge/Verifier.php index fda885c..994fd49 100644 --- a/src/Challenge/Verifier.php +++ b/src/Challenge/Verifier.php @@ -3,7 +3,7 @@ namespace Utopia\WAF\Challenge; /** - * Verifies proof-of-work solutions in constant work (one hash). + * Verifies challenge solutions in constant work (one hash). * * A solution is accepted only when the nonce is authentic, unexpired, bound to * the same context, and `sha256(nonce . '.' . solution)` meets the difficulty @@ -28,7 +28,7 @@ public function verify(string $nonce, string $solution, Context $context): bool } $claims = $this->signer->parse($nonce); - if ($claims === null || ($claims['typ'] ?? null) !== 'pow') { + if ($claims === null || ($claims['typ'] ?? null) !== 'challenge') { return false; } diff --git a/src/Rules/Challenge.php b/src/Rules/Challenge.php index 1d11da8..f9046f0 100644 --- a/src/Rules/Challenge.php +++ b/src/Rules/Challenge.php @@ -6,7 +6,6 @@ class Challenge extends Rule { - public const TYPE_CAPTCHA = 'captcha'; public const TYPE_CUSTOM = 'custom'; private string $type; @@ -14,10 +13,10 @@ class Challenge extends Rule /** * @param array<\Utopia\WAF\Condition|array> $conditions */ - public function __construct(array $conditions = [], string $type = self::TYPE_CAPTCHA) + public function __construct(array $conditions = [], string $type = self::TYPE_CUSTOM) { parent::__construct($conditions); - if (!in_array($type, [self::TYPE_CAPTCHA, self::TYPE_CUSTOM], true)) { + if (!in_array($type, [self::TYPE_CUSTOM], true)) { throw new \InvalidArgumentException('Invalid challenge type: ' . $type); } $this->type = $type; diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php index 5403982..5027e1b 100644 --- a/tests/Challenge/ChallengeTest.php +++ b/tests/Challenge/ChallengeTest.php @@ -145,7 +145,7 @@ public function testIssueClampsDifficulty(): void $this->assertSame(Issuer::DIFFICULTY_MAX, $issuer->issue($this->context(), 999)['difficulty']); } - public function testProofOfWorkRoundTrip(): void + public function testChallengeRoundTrip(): void { $signer = new Signer(self::SECRET); $issuer = new Issuer($signer); @@ -210,7 +210,7 @@ public function testVerifyRejectsExpiredNonce(): void // Hand-craft an already-expired nonce at difficulty 1 (trivially solvable). $nonce = $signer->sign([ - 'typ' => 'pow', + 'typ' => 'challenge', 'pid' => 'proj-123', 'aud' => 'api', 'iph' => $signer->fingerprintIp('203.0.113.9'), @@ -229,7 +229,7 @@ public function testVerifyRejectsWrongContext(): void $verifier = new Verifier($signer); $nonce = $signer->sign([ - 'typ' => 'pow', + 'typ' => 'challenge', 'pid' => 'proj-123', 'aud' => 'api', 'iph' => $signer->fingerprintIp('203.0.113.9'), @@ -290,12 +290,12 @@ public function testClearanceRejectsCrossType(): void $clearance = new Clearance($signer); $verifier = new Verifier($signer); - // A clearance token must not be accepted as a PoW nonce, and vice versa. + // A clearance token must not be accepted as a challenge nonce, and vice versa. $clr = $clearance->issue($this->context()); $this->assertFalse($verifier->verify($clr, '0', $this->context())); - $pow = (new Issuer($signer))->issue($this->context())['nonce']; - $this->assertFalse($clearance->verify($pow, $this->context())); + $challengeNonce = (new Issuer($signer))->issue($this->context())['nonce']; + $this->assertFalse($clearance->verify($challengeNonce, $this->context())); } public function testClearanceRejectsWrongNetwork(): void diff --git a/tests/RulesTest.php b/tests/RulesTest.php index 4b848d5..4715c68 100644 --- a/tests/RulesTest.php +++ b/tests/RulesTest.php @@ -38,7 +38,7 @@ public function testChallengeRuleTypeDefaults(): void $customRule = new Challenge([], Challenge::TYPE_CUSTOM); $this->assertSame('challenge', $defaultRule->getAction()); - $this->assertSame(Challenge::TYPE_CAPTCHA, $defaultRule->getType()); + $this->assertSame(Challenge::TYPE_CUSTOM, $defaultRule->getType()); $this->assertSame(Challenge::TYPE_CUSTOM, $customRule->getType()); } From 0dc6eacda02cf9ea7c880ea3ac0330cb5abd2e9b Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Fri, 3 Jul 2026 19:17:41 +0200 Subject: [PATCH 08/19] Add Challenge\Scoring: bot-detection signal schema + scoring engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the WAF bot-detection plan — the shared foundation both cloud and edge consume: - Signal: the normalized feature schema (server vs client signals). - Signals: immutable raw feature bag (ML-ready, no pre-scoring). - Engine (interface) + HeuristicEngine: v1 weighted-additive scorer with a fixed denominator (conservative, low false-positive) and an interaction-pass hard cap. Swappable for an ML engine behind the same interface. - RiskTier: allow/challenge/interactive/deny escalation ladder, score->tier mapping, and surface clamp (cloud API can't run the interactive tier -> PoW). - Score / SignalRecord / Sink / NullSink: the data-pipeline (ML on-ramp) shape; records carry an 'enforced' flag to separate shadow-mode from live decisions. No enforcement wiring here — that lands in cloud/edge. 8 new tests (60 total). Co-Authored-By: Claude Opus 4.8 --- src/Challenge/Scoring/Engine.php | 15 ++ src/Challenge/Scoring/HeuristicEngine.php | 92 +++++++++++++ src/Challenge/Scoring/NullSink.php | 15 ++ src/Challenge/Scoring/RiskTier.php | 72 ++++++++++ src/Challenge/Scoring/Score.php | 26 ++++ src/Challenge/Scoring/Signal.php | 78 +++++++++++ src/Challenge/Scoring/SignalRecord.php | 53 +++++++ src/Challenge/Scoring/Signals.php | 73 ++++++++++ src/Challenge/Scoring/Sink.php | 16 +++ tests/Challenge/ScoringTest.php | 161 ++++++++++++++++++++++ 10 files changed, 601 insertions(+) create mode 100644 src/Challenge/Scoring/Engine.php create mode 100644 src/Challenge/Scoring/HeuristicEngine.php create mode 100644 src/Challenge/Scoring/NullSink.php create mode 100644 src/Challenge/Scoring/RiskTier.php create mode 100644 src/Challenge/Scoring/Score.php create mode 100644 src/Challenge/Scoring/Signal.php create mode 100644 src/Challenge/Scoring/SignalRecord.php create mode 100644 src/Challenge/Scoring/Signals.php create mode 100644 src/Challenge/Scoring/Sink.php create mode 100644 tests/Challenge/ScoringTest.php diff --git a/src/Challenge/Scoring/Engine.php b/src/Challenge/Scoring/Engine.php new file mode 100644 index 0000000..bb0030f --- /dev/null +++ b/src/Challenge/Scoring/Engine.php @@ -0,0 +1,15 @@ + + */ + public const DEFAULT_WEIGHTS = [ + Signal::IP_REPUTATION => 3.0, + Signal::ASN_REPUTATION => 2.0, + Signal::TLS_MISMATCH => 4.0, + Signal::MISSING_HEADERS => 1.0, + Signal::HEADLESS => 3.0, + Signal::AUTOMATION_FLAGS => 3.0, + Signal::BEHAVIORAL_RISK => 2.0, + ]; + + /** Score ceiling applied when the client passed the interaction challenge. */ + public const INTERACTION_PASS_CEILING = 0.10; + + /** + * @var array + */ + private array $weights; + + /** + * @var array + */ + private array $thresholds; + + private float $totalWeight; + + /** + * @param array $weights signal key => weight + * @param array $thresholds tier boundaries (see RiskTier::DEFAULT_THRESHOLDS) + */ + public function __construct( + array $weights = self::DEFAULT_WEIGHTS, + array $thresholds = RiskTier::DEFAULT_THRESHOLDS, + ) { + $this->weights = $weights; + $this->thresholds = $thresholds; + $this->totalWeight = \array_sum($weights); + } + + public function score(Signals $signals): Score + { + $contributions = []; + $risk = 0.0; + + foreach ($this->weights as $key => $weight) { + if (!$signals->has($key) || $this->totalWeight <= 0.0) { + continue; + } + + $contribution = $signals->float($key) * $weight / $this->totalWeight; + if ($contribution === 0.0) { + continue; + } + + $contributions[$key] = $contribution; + $risk += $contribution; + } + + $value = \max(0.0, \min(1.0, $risk)); + + if ($signals->bool(Signal::INTERACTION_PASSED)) { + $value = \min($value, self::INTERACTION_PASS_CEILING); + } + + return new Score($value, RiskTier::fromScore($value, $this->thresholds), $contributions); + } +} diff --git a/src/Challenge/Scoring/NullSink.php b/src/Challenge/Scoring/NullSink.php new file mode 100644 index 0000000..c3413bd --- /dev/null +++ b/src/Challenge/Scoring/NullSink.php @@ -0,0 +1,15 @@ + + */ + public const DEFAULT_THRESHOLDS = [ + self::CHALLENGE->value => 0.25, + self::INTERACTIVE->value => 0.55, + self::DENY->value => 0.80, + ]; + + /** + * Map a [0,1] risk score to a tier using the given (or default) boundaries. + * + * @param array $thresholds + */ + public static function fromScore(float $score, array $thresholds = self::DEFAULT_THRESHOLDS): self + { + $score = \max(0.0, \min(1.0, $score)); + + if ($score >= ($thresholds[self::DENY->value] ?? self::DEFAULT_THRESHOLDS[self::DENY->value])) { + return self::DENY; + } + if ($score >= ($thresholds[self::INTERACTIVE->value] ?? self::DEFAULT_THRESHOLDS[self::INTERACTIVE->value])) { + return self::INTERACTIVE; + } + if ($score >= ($thresholds[self::CHALLENGE->value] ?? self::DEFAULT_THRESHOLDS[self::CHALLENGE->value])) { + return self::CHALLENGE; + } + + return self::ALLOW; + } + + /** + * Clamp a tier to what a surface can actually enforce. The cloud API has no + * browser page, so it cannot run the interactive challenge — an interactive + * verdict falls back to the strongest gate it *can* run (proof-of-work). + * Every other tier (including deny) is surface-agnostic. + */ + public function clampTo(bool $interactiveCapable): self + { + if ($this === self::INTERACTIVE && !$interactiveCapable) { + return self::CHALLENGE; + } + + return $this; + } +} diff --git a/src/Challenge/Scoring/Score.php b/src/Challenge/Scoring/Score.php new file mode 100644 index 0000000..b96c825 --- /dev/null +++ b/src/Challenge/Scoring/Score.php @@ -0,0 +1,26 @@ + $contributions signal key => weighted contribution + */ + public function __construct( + public float $value, + public RiskTier $tier, + public array $contributions = [], + ) { + } +} diff --git a/src/Challenge/Scoring/Signal.php b/src/Challenge/Scoring/Signal.php new file mode 100644 index 0000000..479563a --- /dev/null +++ b/src/Challenge/Scoring/Signal.php @@ -0,0 +1,78 @@ + + */ + public const SERVER = [ + self::IP_REPUTATION, + self::ASN_REPUTATION, + self::TLS_MISMATCH, + self::TLS_FINGERPRINT, + self::MISSING_HEADERS, + ]; + + /** + * Signal keys that require client JavaScript — edge interstitial only. + * + * @var list + */ + public const CLIENT = [ + self::HEADLESS, + self::AUTOMATION_FLAGS, + self::BEHAVIORAL_RISK, + self::INTERACTION_PASSED, + ]; +} diff --git a/src/Challenge/Scoring/SignalRecord.php b/src/Challenge/Scoring/SignalRecord.php new file mode 100644 index 0000000..301cf76 --- /dev/null +++ b/src/Challenge/Scoring/SignalRecord.php @@ -0,0 +1,53 @@ + + */ + public function toArray(): array + { + return [ + 'type' => 'waf.score', + 'projectId' => $this->projectId, + 'audience' => $this->audience, + 'ipPrefix' => $this->ipPrefix, + 'requestId' => $this->requestId, + 'issuedAt' => $this->issuedAt, + 'score' => $this->score->value, + 'tier' => $this->score->tier->value, + 'decision' => $this->decision->value, + 'enforced' => $this->enforced, + 'signals' => $this->signals->toArray(), + 'contributions' => $this->score->contributions, + ]; + } +} diff --git a/src/Challenge/Scoring/Signals.php b/src/Challenge/Scoring/Signals.php new file mode 100644 index 0000000..7b460b7 --- /dev/null +++ b/src/Challenge/Scoring/Signals.php @@ -0,0 +1,73 @@ + $values signal key => value + */ + public function __construct( + private readonly array $values = [], + ) { + } + + /** + * Return a copy with one signal set (or replaced). Immutable. + */ + public function with(string $key, mixed $value): self + { + return new self([...$this->values, $key => $value]); + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->values); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + /** + * Read a signal as a bot-risk float clamped to [0,1]. Booleans map to + * 1.0/0.0; anything unset or non-numeric returns the default. + */ + public function float(string $key, float $default = 0.0): float + { + if (!$this->has($key)) { + return $default; + } + + $value = $this->values[$key]; + if (\is_bool($value)) { + return $value ? 1.0 : 0.0; + } + if (!\is_numeric($value)) { + return $default; + } + + return \max(0.0, \min(1.0, (float) $value)); + } + + public function bool(string $key, bool $default = false): bool + { + return $this->has($key) ? (bool) $this->values[$key] : $default; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->values; + } +} diff --git a/src/Challenge/Scoring/Sink.php b/src/Challenge/Scoring/Sink.php new file mode 100644 index 0000000..155bb16 --- /dev/null +++ b/src/Challenge/Scoring/Sink.php @@ -0,0 +1,16 @@ +with(Signal::IP_REPUTATION, 0.5); + + $this->assertFalse($base->has(Signal::IP_REPUTATION)); // original untouched + $this->assertTrue($withOne->has(Signal::IP_REPUTATION)); + $this->assertSame(0.5, $withOne->float(Signal::IP_REPUTATION)); + + // bool -> 1.0/0.0, clamping, defaults + $this->assertSame(1.0, $withOne->with(Signal::TLS_MISMATCH, true)->float(Signal::TLS_MISMATCH)); + $this->assertSame(0.0, $withOne->with(Signal::TLS_MISMATCH, false)->float(Signal::TLS_MISMATCH)); + $this->assertSame(1.0, $withOne->with(Signal::HEADLESS, 5)->float(Signal::HEADLESS)); // clamped + $this->assertSame(0.0, $base->float(Signal::HEADLESS)); // absent -> default + $this->assertTrue($withOne->with(Signal::INTERACTION_PASSED, true)->bool(Signal::INTERACTION_PASSED)); + } + + public function testRiskTierFromScoreBoundaries(): void + { + $this->assertSame(RiskTier::ALLOW, RiskTier::fromScore(0.0)); + $this->assertSame(RiskTier::ALLOW, RiskTier::fromScore(0.24)); + $this->assertSame(RiskTier::CHALLENGE, RiskTier::fromScore(0.25)); + $this->assertSame(RiskTier::CHALLENGE, RiskTier::fromScore(0.54)); + $this->assertSame(RiskTier::INTERACTIVE, RiskTier::fromScore(0.55)); + $this->assertSame(RiskTier::INTERACTIVE, RiskTier::fromScore(0.79)); + $this->assertSame(RiskTier::DENY, RiskTier::fromScore(0.80)); + $this->assertSame(RiskTier::DENY, RiskTier::fromScore(1.5)); // clamped + } + + public function testRiskTierSurfaceClamp(): void + { + // cloud API (no browser): interactive falls back to proof-of-work + $this->assertSame(RiskTier::CHALLENGE, RiskTier::INTERACTIVE->clampTo(false)); + // edge (browser): interactive stays + $this->assertSame(RiskTier::INTERACTIVE, RiskTier::INTERACTIVE->clampTo(true)); + // every other tier is surface-agnostic + $this->assertSame(RiskTier::DENY, RiskTier::DENY->clampTo(false)); + $this->assertSame(RiskTier::CHALLENGE, RiskTier::CHALLENGE->clampTo(false)); + $this->assertSame(RiskTier::ALLOW, RiskTier::ALLOW->clampTo(false)); + } + + public function testHeuristicEmptySignalsAllow(): void + { + $score = (new HeuristicEngine())->score(new Signals()); + $this->assertSame(0.0, $score->value); + $this->assertSame(RiskTier::ALLOW, $score->tier); + $this->assertSame([], $score->contributions); + } + + public function testHeuristicEscalatesWithSignalWeight(): void + { + $engine = new HeuristicEngine(); + + // single low-weight-ish signal stays ALLOW (conservative, fixed denominator) + $this->assertSame(RiskTier::ALLOW, $engine->score( + (new Signals())->with(Signal::IP_REPUTATION, 1.0) + )->tier); + + // IP reputation + TLS mismatch crosses into the PoW gate + $this->assertSame(RiskTier::CHALLENGE, $engine->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + )->tier); + + // all server signals maxed -> interactive + $this->assertSame(RiskTier::INTERACTIVE, $engine->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ASN_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::MISSING_HEADERS, 1.0) + )->tier); + + // everything maxed -> deny, score 1.0, contributions recorded + $all = $engine->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ASN_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::MISSING_HEADERS, 1.0) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::AUTOMATION_FLAGS, 1.0) + ->with(Signal::BEHAVIORAL_RISK, 1.0) + ); + $this->assertSame(1.0, $all->value); + $this->assertSame(RiskTier::DENY, $all->tier); + $this->assertCount(7, $all->contributions); + $this->assertEqualsWithDelta(1.0, array_sum($all->contributions), 1e-9); + } + + public function testInteractionPassHardCapsToAllow(): void + { + // even a fully bot-looking request is cleared once it passes the interaction challenge + $score = (new HeuristicEngine())->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::INTERACTION_PASSED, true) + ); + $this->assertLessThanOrEqual(HeuristicEngine::INTERACTION_PASS_CEILING, $score->value); + $this->assertSame(RiskTier::ALLOW, $score->tier); + } + + public function testCustomThresholdsAndWeights(): void + { + // a stricter config escalates the same signal further + $engine = new HeuristicEngine( + weights: [Signal::HEADLESS => 1.0], + thresholds: [ + RiskTier::CHALLENGE->value => 0.1, + RiskTier::INTERACTIVE->value => 0.5, + RiskTier::DENY->value => 0.9, + ], + ); + $this->assertSame(RiskTier::DENY, $engine->score( + (new Signals())->with(Signal::HEADLESS, 1.0) + )->tier); + } + + public function testSignalRecordFlattens(): void + { + $signals = (new Signals())->with(Signal::HEADLESS, 0.8); + $score = new Score(0.8, RiskTier::INTERACTIVE, [Signal::HEADLESS => 0.8]); + $record = new SignalRecord( + projectId: 'proj', + audience: 'wafedge.test', + ipPrefix: '203.0.113.0', + signals: $signals, + score: $score, + decision: RiskTier::CHALLENGE, // clamped for a non-interactive surface + enforced: false, + issuedAt: 1000, + requestId: 'req-1', + ); + + $row = $record->toArray(); + $this->assertSame('waf.score', $row['type']); + $this->assertSame('proj', $row['projectId']); + $this->assertSame(0.8, $row['score']); + $this->assertSame('interactive', $row['tier']); + $this->assertSame('challenge', $row['decision']); + $this->assertFalse($row['enforced']); + $this->assertSame(['headless' => 0.8], $row['signals']); + } +} From 6ba8a73005bee89acd58ec144ebf9a965eae7344 Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Fri, 3 Jul 2026 20:03:04 +0200 Subject: [PATCH 09/19] Add Challenge\Scoring\TlsFingerprint: JA4 -> TLS_MISMATCH classifier Turns a captured JA4 + User-Agent into the highest-weighted server signal: a known-automation blocklist (curl/python/wget/node seeds) plus a UA cross-check (a browser UA whose JA4 isn't a known browser is lying). Catches forged clients even when their headers look real. Lists are injectable; defaults are a local seed. 6 tests (66 waf total). Co-Authored-By: Claude Opus 4.8 --- src/Challenge/Scoring/TlsFingerprint.php | 109 +++++++++++++++++++++++ tests/Challenge/TlsFingerprintTest.php | 54 +++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/Challenge/Scoring/TlsFingerprint.php create mode 100644 tests/Challenge/TlsFingerprintTest.php diff --git a/src/Challenge/Scoring/TlsFingerprint.php b/src/Challenge/Scoring/TlsFingerprint.php new file mode 100644 index 0000000..51c26a5 --- /dev/null +++ b/src/Challenge/Scoring/TlsFingerprint.php @@ -0,0 +1,109 @@ + + */ + public const KNOWN_AUTOMATION = [ + 't13d311200_e8f1e7e78f70_b26ce05bbdd6', // curl (OpenSSL) + 't13d311100_e8f1e7e78f70_d41ae481755e', // python (urllib/OpenSSL) + 't13d751100_479067518aa3_fb8d5ffd48c1', // wget + 't13d591000_a33745022dd6_1f22a2ca17c4', // node.js + ]; + + /** + * Reference JA4s of mainstream browsers, for the UA cross-check. + * + * @var list + */ + public const KNOWN_BROWSERS = [ + 't13d1516h2_8daaf6152771_b186095e22b6', // Chrome (BoringSSL, ALPN h2) + 't13d1715h2_5b57614c22b0_93c746dc12af', // Firefox (NSS, ALPN h2) + ]; + + /** + * @var array + */ + private array $automation; + + /** + * @var array + */ + private array $browsers; + + /** + * @param list $automation known automation/library JA4s + * @param list $browsers known mainstream-browser JA4s + */ + public function __construct(array $automation = self::KNOWN_AUTOMATION, array $browsers = self::KNOWN_BROWSERS) + { + $this->automation = \array_fill_keys($automation, true); + $this->browsers = \array_fill_keys($browsers, true); + } + + /** + * Does the fingerprint contradict a real browser? Empty fingerprint (none + * captured) is not a mismatch — it simply yields no signal. + */ + public function mismatches(string $fingerprint, string $userAgent): bool + { + if ($fingerprint === '') { + return false; + } + + if (isset($this->automation[$fingerprint])) { + return true; + } + + if ($this->uaClaimsBrowser($userAgent) && !isset($this->browsers[$fingerprint])) { + return true; + } + + return false; + } + + /** + * Whether the User-Agent claims to be a mainstream browser (and not an + * obvious bot/library UA). + */ + private function uaClaimsBrowser(string $userAgent): bool + { + $ua = \strtolower($userAgent); + + foreach (['bot', 'crawl', 'spider', 'curl', 'wget', 'python', 'java', 'go-http', 'okhttp', 'axios', 'node'] as $needle) { + if (\str_contains($ua, $needle)) { + return false; + } + } + + foreach (['chrome', 'firefox', 'safari', 'edg/', 'edge', 'opera', 'gecko'] as $needle) { + if (\str_contains($ua, $needle)) { + return true; + } + } + + return false; + } +} diff --git a/tests/Challenge/TlsFingerprintTest.php b/tests/Challenge/TlsFingerprintTest.php new file mode 100644 index 0000000..394dffb --- /dev/null +++ b/tests/Challenge/TlsFingerprintTest.php @@ -0,0 +1,54 @@ +assertTrue($tls->mismatches(self::CURL, 'curl/8.5.0')); + // the key case: a library TLS stack forging a browser User-Agent is still caught + $this->assertTrue($tls->mismatches(self::CURL, self::CHROME_UA)); + } + + public function testRealBrowserPasses(): void + { + $tls = new TlsFingerprint(); + $this->assertFalse($tls->mismatches(self::CHROME, self::CHROME_UA)); + } + + public function testBrowserUaWithUnknownFingerprintIsSpoofing(): void + { + // UA claims a browser but the JA4 is not a known browser fingerprint + $tls = new TlsFingerprint(); + $this->assertTrue($tls->mismatches('t13d0304i0_aaaaaaaaaaaa_bbbbbbbbbbbb', self::CHROME_UA)); + } + + public function testHonestNonBrowserClientIsNotAMismatch(): void + { + // an unknown fingerprint whose UA does NOT claim a browser is not flagged + // by this signal (it may still be caught by other signals) + $tls = new TlsFingerprint(); + $this->assertFalse($tls->mismatches('t13d0304i0_aaaaaaaaaaaa_bbbbbbbbbbbb', 'MyServerSDK/1.0')); + } + + public function testEmptyFingerprintYieldsNoSignal(): void + { + $this->assertFalse((new TlsFingerprint())->mismatches('', self::CHROME_UA)); + } + + public function testCustomBlocklist(): void + { + $tls = new TlsFingerprint(automation: ['t13dXXXX_aaa_bbb'], browsers: []); + $this->assertTrue($tls->mismatches('t13dXXXX_aaa_bbb', 'anything')); + $this->assertFalse($tls->mismatches(self::CURL, 'curl/8.5.0')); // default seed not applied + } +} From 875328e1d976ae7476dc9fac2f358be5fad7edbf Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Fri, 3 Jul 2026 20:11:16 +0200 Subject: [PATCH 10/19] Add Challenge\Scoring\ClientSignals: normalize interstitial telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-side half of bot detection (steps 3/5/6) — turns the browser interstitial's raw JS telemetry blob into normalized HEADLESS / AUTOMATION_FLAGS / BEHAVIORAL_RISK / INTERACTION_PASSED signals. Simple explainable heuristics (webdriver=near-proof; software WebGL + no plugins/languages = headless; no input events = behavioral risk). Defensive: the blob is attacker-controlled, so missing/garbage fields lower confidence, never throw. 7 tests (73 waf total). Co-Authored-By: Claude Opus 4.8 --- src/Challenge/Scoring/ClientSignals.php | 120 ++++++++++++++++++++++++ tests/Challenge/ClientSignalsTest.php | 85 +++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 src/Challenge/Scoring/ClientSignals.php create mode 100644 tests/Challenge/ClientSignalsTest.php diff --git a/src/Challenge/Scoring/ClientSignals.php b/src/Challenge/Scoring/ClientSignals.php new file mode 100644 index 0000000..9f0c9cc --- /dev/null +++ b/src/Challenge/Scoring/ClientSignals.php @@ -0,0 +1,120 @@ + $raw the interstitial telemetry blob + * @return array Signal key => normalized value, to fold into a Signals bag + */ + public static function normalize(array $raw): array + { + return [ + Signal::HEADLESS => self::headless($raw), + Signal::AUTOMATION_FLAGS => self::automation($raw), + Signal::BEHAVIORAL_RISK => self::behavioral($raw), + Signal::INTERACTION_PASSED => self::boolean($raw, 'interacted'), + ]; + } + + /** + * Headless / automation-driver detection. `navigator.webdriver` is near-proof + * on its own; otherwise combine the soft tells (no plugins, no languages, a + * software WebGL renderer — all typical of headless Chromium). + * + * @param array $raw + */ + private static function headless(array $raw): float + { + if (self::boolean($raw, 'webdriver')) { + return 1.0; + } + + $risk = 0.0; + if (self::int($raw, 'plugins', -1) === 0) { + $risk += 0.4; // real desktop browsers ship plugins; headless has none + } + if (self::int($raw, 'languages', -1) === 0) { + $risk += 0.3; // navigator.languages is empty in many headless setups + } + $webgl = \strtolower(self::string($raw, 'webglVendor')); + if ($webgl !== '' && (\str_contains($webgl, 'swiftshader') || \str_contains($webgl, 'llvmpipe'))) { + $risk += 0.3; // software renderer ⇒ no GPU ⇒ almost certainly headless + } + + return \min(1.0, $risk); + } + + /** + * Automation-framework artefacts the page can observe (CDP/Selenium/phantom + * globals). The client reports a count; scale it into [0,1]. + * + * @param array $raw + */ + private static function automation(array $raw): float + { + $flags = self::int($raw, 'automationFlags', 0); + + return \max(0.0, \min(1.0, $flags / 3.0)); + } + + /** + * Behavioral risk: absence of human input while the interstitial was shown. + * A real user moves the mouse / presses keys; a script solving the PoW does + * neither. No activity ⇒ high risk; activity drives it down. + * + * @param array $raw + */ + private static function behavioral(array $raw): float + { + $events = self::int($raw, 'mouseMoves', 0) + self::int($raw, 'keyPresses', 0); + if ($events <= 0) { + return 1.0; + } + + // saturating: ~a dozen input events reads as clearly human + return \max(0.0, 1.0 - \min(1.0, $events / 12.0)); + } + + /** + * @param array $raw + */ + private static function boolean(array $raw, string $key): bool + { + return isset($raw[$key]) && ($raw[$key] === true || $raw[$key] === 1 || $raw[$key] === '1' || $raw[$key] === 'true'); + } + + /** + * @param array $raw + */ + private static function int(array $raw, string $key, int $default): int + { + return isset($raw[$key]) && \is_numeric($raw[$key]) ? (int) $raw[$key] : $default; + } + + /** + * @param array $raw + */ + private static function string(array $raw, string $key): string + { + return isset($raw[$key]) && \is_string($raw[$key]) ? $raw[$key] : ''; + } +} diff --git a/tests/Challenge/ClientSignalsTest.php b/tests/Challenge/ClientSignalsTest.php new file mode 100644 index 0000000..b9cd063 --- /dev/null +++ b/tests/Challenge/ClientSignalsTest.php @@ -0,0 +1,85 @@ + true]); + $this->assertSame(1.0, $s[Signal::HEADLESS]); + } + + public function testSoftHeadlessTellsCombine(): void + { + $s = ClientSignals::normalize([ + 'webdriver' => false, + 'plugins' => 0, + 'languages' => 0, + 'webglVendor' => 'Google SwiftShader', + ]); + $this->assertSame(1.0, $s[Signal::HEADLESS]); // 0.4 + 0.3 + 0.3 + } + + public function testRealBrowserLooksHuman(): void + { + $s = ClientSignals::normalize([ + 'webdriver' => false, + 'plugins' => 3, + 'languages' => 2, + 'webglVendor' => 'NVIDIA Corporation', + 'automationFlags' => 0, + 'mouseMoves' => 20, + 'keyPresses' => 4, + 'interacted' => true, + ]); + $this->assertSame(0.0, $s[Signal::HEADLESS]); + $this->assertSame(0.0, $s[Signal::AUTOMATION_FLAGS]); + $this->assertSame(0.0, $s[Signal::BEHAVIORAL_RISK]); + $this->assertTrue($s[Signal::INTERACTION_PASSED]); + } + + public function testAutomationFlagsScale(): void + { + $this->assertSame(1.0, ClientSignals::normalize(['automationFlags' => 3])[Signal::AUTOMATION_FLAGS]); + $this->assertSame(1.0, ClientSignals::normalize(['automationFlags' => 9])[Signal::AUTOMATION_FLAGS]); // capped + $this->assertSame(0.0, ClientSignals::normalize(['automationFlags' => 0])[Signal::AUTOMATION_FLAGS]); + } + + public function testBehavioralRiskFromInputActivity(): void + { + // no input at all — a script solving the PoW + $this->assertSame(1.0, ClientSignals::normalize([])[Signal::BEHAVIORAL_RISK]); + // plenty of input — human + $this->assertSame(0.0, ClientSignals::normalize(['mouseMoves' => 10, 'keyPresses' => 2])[Signal::BEHAVIORAL_RISK]); + // a little input — partial + $risk = ClientSignals::normalize(['mouseMoves' => 3])[Signal::BEHAVIORAL_RISK]; + $this->assertGreaterThan(0.0, $risk); + $this->assertLessThan(1.0, $risk); + } + + public function testGarbageBlobDoesNotThrowAndStaysConservative(): void + { + $s = ClientSignals::normalize([ + 'webdriver' => 'not-a-bool', + 'plugins' => 'x', + 'automationFlags' => ['nested'], + 'mouseMoves' => null, + ]); + $this->assertIsFloat($s[Signal::HEADLESS]); + $this->assertSame(0.0, $s[Signal::AUTOMATION_FLAGS]); + $this->assertSame(1.0, $s[Signal::BEHAVIORAL_RISK]); + $this->assertFalse($s[Signal::INTERACTION_PASSED]); + } + + public function testBooleanAcceptsJsonishTruth(): void + { + $this->assertTrue(ClientSignals::normalize(['interacted' => 1])[Signal::INTERACTION_PASSED]); + $this->assertTrue(ClientSignals::normalize(['interacted' => 'true'])[Signal::INTERACTION_PASSED]); + $this->assertFalse(ClientSignals::normalize(['interacted' => 0])[Signal::INTERACTION_PASSED]); + } +} From 048b12c9e801c194e6910651ecb79a9beb65e424 Mon Sep 17 00:00:00 2001 From: premtsd-code Date: Fri, 3 Jul 2026 21:28:38 +0200 Subject: [PATCH 11/19] Scoring: ATTACK_SCORE signal + deny-floor (unify WAF attack detection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounding for the unified WAF pipeline — request-inspection (Coraza/OWASP CRS) becomes a signal in the same bag the bot scorer already uses, not a parallel enforcer: - Signal::ATTACK_SCORE / ATTACK_CATEGORIES + AttackScore (CRS anomaly points -> [0,1], one critical ~= 0.5, ~2x threshold = certain). - HeuristicEngine deny-floor: attacks are decisive, not fuzzy — a high ATTACK_SCORE floors the verdict to deny (moderate -> at least challenge), kept OUT of the weighted sum and applied AFTER the interaction cap so a solved challenge never excuses an injection. Mirror of INTERACTION_PASS_CEILING. No Coraza yet — this is the seam it plugs into. 7 tests (79 waf total). Co-Authored-By: Claude Opus 4.8 --- src/Challenge/Scoring/AttackScore.php | 28 +++++++++++++++ src/Challenge/Scoring/HeuristicEngine.php | 23 ++++++++++++ src/Challenge/Scoring/Signal.php | 13 +++++++ tests/Challenge/AttackScoreTest.php | 18 ++++++++++ tests/Challenge/ScoringTest.php | 43 +++++++++++++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 src/Challenge/Scoring/AttackScore.php create mode 100644 tests/Challenge/AttackScoreTest.php diff --git a/src/Challenge/Scoring/AttackScore.php b/src/Challenge/Scoring/AttackScore.php new file mode 100644 index 0000000..0f28780 --- /dev/null +++ b/src/Challenge/Scoring/AttackScore.php @@ -0,0 +1,28 @@ + */ @@ -87,6 +93,23 @@ public function score(Signals $signals): Score $value = \min($value, self::INTERACTION_PASS_CEILING); } + // Attacks are decisive, not fuzzy. A request-inspection verdict floors the + // score to the matching gate — kept OUT of the weighted sum so it can't be + // diluted, and applied AFTER the interaction cap so a solved challenge + // never excuses an injection. High confidence ⇒ deny (you block an attack, + // you don't challenge it); moderate ⇒ at least challenge. + $attack = $signals->float(Signal::ATTACK_SCORE); + if ($attack >= self::ATTACK_DENY_FLOOR) { + $value = \max($value, $this->threshold(RiskTier::DENY)); + } elseif ($attack >= self::ATTACK_CHALLENGE_FLOOR) { + $value = \max($value, $this->threshold(RiskTier::CHALLENGE)); + } + return new Score($value, RiskTier::fromScore($value, $this->thresholds), $contributions); } + + private function threshold(RiskTier $tier): float + { + return $this->thresholds[$tier->value] ?? RiskTier::DEFAULT_THRESHOLDS[$tier->value] ?? 0.0; + } } diff --git a/src/Challenge/Scoring/Signal.php b/src/Challenge/Scoring/Signal.php index 479563a..562b08d 100644 --- a/src/Challenge/Scoring/Signal.php +++ b/src/Challenge/Scoring/Signal.php @@ -36,6 +36,17 @@ final class Signal /** float [0,1] — proportion of expected real-browser request headers that are absent. */ public const MISSING_HEADERS = 'missingHeaders'; + /** + * float [0,1] — normalized attack confidence from the request-inspection layer + * (Coraza / OWASP CRS anomaly score, {@see AttackScore}). Decisive rather than + * fuzzy: a high value floors the verdict to deny (see HeuristicEngine) — you + * block an injection, you do not challenge it. + */ + public const ATTACK_SCORE = 'attackScore'; + + /** string — matched attack categories (sqli/xss/rce…), carried for logging (not scored). */ + public const ATTACK_CATEGORIES = 'attackCategories'; + /* --- Client-side (edge interstitial only) --- */ /** float [0,1] — headless/automation indicators (navigator.webdriver, missing plugins…). */ @@ -62,6 +73,8 @@ final class Signal self::TLS_MISMATCH, self::TLS_FINGERPRINT, self::MISSING_HEADERS, + self::ATTACK_SCORE, + self::ATTACK_CATEGORIES, ]; /** diff --git a/tests/Challenge/AttackScoreTest.php b/tests/Challenge/AttackScoreTest.php new file mode 100644 index 0000000..ef3f4fb --- /dev/null +++ b/tests/Challenge/AttackScoreTest.php @@ -0,0 +1,18 @@ +assertSame(0.0, AttackScore::normalize(0)); + $this->assertSame(0.0, AttackScore::normalize(-3)); // defensive + $this->assertSame(0.5, AttackScore::normalize(5)); // one CRS critical + $this->assertSame(1.0, AttackScore::normalize(10)); // ~2× threshold = certain + $this->assertSame(1.0, AttackScore::normalize(40)); // capped + } +} diff --git a/tests/Challenge/ScoringTest.php b/tests/Challenge/ScoringTest.php index a01f9a7..94cb27e 100644 --- a/tests/Challenge/ScoringTest.php +++ b/tests/Challenge/ScoringTest.php @@ -158,4 +158,47 @@ public function testSignalRecordFlattens(): void $this->assertFalse($row['enforced']); $this->assertSame(['headless' => 0.8], $row['signals']); } + + public function testCertainAttackFloorsToDeny(): void + { + // a clear attack blocks even with no other signals + $score = (new HeuristicEngine())->score((new Signals())->with(Signal::ATTACK_SCORE, 1.0)); + $this->assertSame(RiskTier::DENY, $score->tier); + } + + public function testModerateAttackFloorsToChallenge(): void + { + $score = (new HeuristicEngine())->score((new Signals())->with(Signal::ATTACK_SCORE, 0.5)); + $this->assertSame(RiskTier::CHALLENGE, $score->tier); + } + + public function testLowAttackScoreDoesNotFloor(): void + { + $score = (new HeuristicEngine())->score((new Signals())->with(Signal::ATTACK_SCORE, 0.2)); + $this->assertSame(RiskTier::ALLOW, $score->tier); + } + + public function testAttackFloorOverridesInteractionCap(): void + { + // you never excuse an injection because a challenge was solved + $score = (new HeuristicEngine())->score( + (new Signals()) + ->with(Signal::ATTACK_SCORE, 1.0) + ->with(Signal::INTERACTION_PASSED, true) + ); + $this->assertSame(RiskTier::DENY, $score->tier); + } + + public function testAttackScoreIsNotDilutedByWeightedSum(): void + { + // ATTACK_SCORE is a floor, not a weighted contribution — it never appears + // in contributions and never shifts the denominator of the other signals + $score = (new HeuristicEngine())->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ATTACK_SCORE, 1.0) + ); + $this->assertArrayNotHasKey(Signal::ATTACK_SCORE, $score->contributions); + $this->assertArrayHasKey(Signal::IP_REPUTATION, $score->contributions); + } } From 0b6d5d49ef09b3542d57783a5dd411e4f490539b Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 3 Jul 2026 23:49:19 +0200 Subject: [PATCH 12/19] Add ML scoring engine and memory-hard proof of work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to the challenge/bot-detection primitives, both drop-in behind their existing seams so no collection or enforcement code changes. MlEngine (Scoring): a logistic-regression scoring engine implementing the same Engine interface as HeuristicEngine — P(bot) = sigmoid(intercept + Σ wᵢ·signalᵢ) over the numeric signal vector, coefficients trained offline on synthetic labelled traffic (reference/train-ml-engine.js → reference/ml-engine-model.json). Pure-PHP inference, no runtime ML dependency. The deterministic policy overrides (interaction ceiling, attack-score floors) stay identical to the heuristic engine; only the fuzzy behavioural middle is learned. Pow (memory-hard PoW): a shared proof-of-work primitive centralising the digest and difficulty check. memory=0 is byte-identical to today's SHA-256 (fully backward compatible); memory>0 switches to a sequential memory-hard SHA-256 ROMix that bounds a solver by memory bandwidth rather than core count. Issuer mints the memory cost into the signed nonce (claim `mem`) and advertises the algorithm; Verifier reads it back and recomputes the same digest. The reference solver.js gains a matching, byte-for-byte romix so the browser interstitial and SDK interceptors solve either mode. Tests: MlEngineTest (9), PowTest (9), a memory-hard round-trip in ChallengeTest; 97 tests pass, Pint + PHPStan clean. PHP↔JS romix parity verified byte-for-byte. Co-Authored-By: Claude Opus 4.8 --- reference/ml-engine-model.json | 55 +++++++++ reference/solver.js | 53 ++++++-- reference/train-ml-engine.js | 192 +++++++++++++++++++++++++++++ src/Challenge/Issuer.php | 49 ++++++-- src/Challenge/Pow.php | 117 ++++++++++++++++++ src/Challenge/Scoring/MlEngine.php | 146 ++++++++++++++++++++++ src/Challenge/Verifier.php | 43 ++----- tests/Challenge/ChallengeTest.php | 44 +++++++ tests/Challenge/MlEngineTest.php | 125 +++++++++++++++++++ tests/Challenge/PowTest.php | 85 +++++++++++++ 10 files changed, 856 insertions(+), 53 deletions(-) create mode 100644 reference/ml-engine-model.json create mode 100644 reference/train-ml-engine.js create mode 100644 src/Challenge/Pow.php create mode 100644 src/Challenge/Scoring/MlEngine.php create mode 100644 tests/Challenge/MlEngineTest.php create mode 100644 tests/Challenge/PowTest.php diff --git a/reference/ml-engine-model.json b/reference/ml-engine-model.json new file mode 100644 index 0000000..6ad112f --- /dev/null +++ b/reference/ml-engine-model.json @@ -0,0 +1,55 @@ +{ + "_comment": "Generated by reference/train-ml-engine.js — logistic regression over the bot-detection signal vector. Paste into MlEngine::DEFAULT_COEFFICIENTS / DEFAULT_INTERCEPT.", + "features": [ + "ipReputation", + "asnReputation", + "tlsMismatch", + "missingHeaders", + "headless", + "automationFlags", + "behavioralRisk" + ], + "intercept": -6.52634, + "coefficients": { + "ipReputation": 3.562063, + "asnReputation": 4.67617, + "tlsMismatch": 1.375458, + "missingHeaders": 1.896716, + "headless": 4.576935, + "automationFlags": 2.71498, + "behavioralRisk": 4.826937 + }, + "metrics": { + "samples": 20000, + "accuracy": 1, + "precision": 1, + "recall": 1, + "falsePositiveRate": 0 + }, + "canonicalCases": { + "clean human": { + "p": 0.0015, + "tier": "allow" + }, + "one missing header": { + "p": 0.0027, + "tier": "allow" + }, + "legit VPN user": { + "p": 0.011, + "tier": "allow" + }, + "headless bot (no interaction)": { + "p": 0.9907, + "tier": "deny" + }, + "curl/python (tls mismatch)": { + "p": 0.9945, + "tier": "deny" + }, + "datacenter abuser": { + "p": 0.6299, + "tier": "interactive" + } + } +} diff --git a/reference/solver.js b/reference/solver.js index 883fa0c..203aa23 100644 --- a/reference/solver.js +++ b/reference/solver.js @@ -8,12 +8,15 @@ // mirrors the PHP Verifier exactly. // // Contract: find the smallest non-negative integer `solution` such that -// sha256(nonce + '.' + solution) has >= difficulty leading zero bits. +// digest(nonce + '.' + solution) has >= difficulty leading zero bits, +// where digest is plain sha256, or the memory-hard `romix` (see below) when the +// challenge is issued with a `memory` cost. Both mirror the PHP Utopia\WAF\ +// Challenge\Pow exactly. // // Usage (async, yields between chunks so a browser tab stays responsive): -// const solution = await solve(nonce, difficulty, { onProgress: p => ... }); +// const solution = await solve(nonce, difficulty, { memory, onProgress }); // Usage (synchronous, for node/server callers): -// const solution = solveSync(nonce, difficulty); +// const solution = solveSync(nonce, difficulty, memory); 'use strict'; @@ -109,23 +112,51 @@ function leadingZeroBits(digest) { return bits; } -function meets(nonce, solution, difficulty) { - return leadingZeroBits(sha256Bytes(utf8Bytes(nonce + '.' + solution))) >= difficulty; +// Sequential memory-hard hash — a SHA-256 ROMix, byte-for-byte identical to the +// PHP Utopia\WAF\Challenge\Pow::romix. Fills a `memory`-block (×32 B) scratchpad +// by iterated hashing, then mixes with data-dependent reads so the whole buffer +// must stay resident. Raises the per-attempt cost floor and, crucially, bounds +// it by memory bandwidth rather than core count. +function romix(inputBytes, memory) { + const V = new Array(memory); + V[0] = sha256Bytes(inputBytes); + for (let i = 1; i < memory; i++) V[i] = sha256Bytes(V[i - 1]); + let x = V[memory - 1]; + const xor = new Uint8Array(32); + for (let i = 0; i < memory; i++) { + const j = (((x[0] << 24) | (x[1] << 16) | (x[2] << 8) | x[3]) >>> 0) % memory; + const vj = V[j]; + for (let k = 0; k < 32; k++) xor[k] = x[k] ^ vj[k]; + x = sha256Bytes(xor); + } + return x; +} + +function powDigest(inputBytes, memory) { + return memory > 0 ? romix(inputBytes, memory) : sha256Bytes(inputBytes); +} + +function meets(nonce, solution, difficulty, memory) { + return leadingZeroBits(powDigest(utf8Bytes(nonce + '.' + solution), memory || 0)) >= difficulty; } // Synchronous solve — for native/server callers (node interceptor) where blocking // the event loop for a few hundred ms of native-fast hashing is acceptable. -function solveSync(nonce, difficulty) { +function solveSync(nonce, difficulty, memory) { + memory = memory || 0; for (let n = 0; ; n++) { - if (meets(nonce, String(n), difficulty)) return String(n); + if (meets(nonce, String(n), difficulty, memory)) return String(n); } } // Chunked, yielding solve — for the browser, so the tab stays responsive and a -// progress callback can drive a UI. Resolves with the solution string. +// progress callback can drive a UI. Resolves with the solution string. A +// memory-hard challenge (opts.memory > 0) costs far more per attempt, so the +// chunk is smaller to keep each frame short. function solve(nonce, difficulty, opts) { opts = opts || {}; - const chunk = opts.chunk || 2000; + const memory = opts.memory || 0; + const chunk = opts.chunk || (memory > 0 ? 25 : 2000); const yieldTo = (typeof requestAnimationFrame === 'function') ? requestAnimationFrame : (fn) => setTimeout(fn, 0); @@ -135,7 +166,7 @@ function solve(nonce, difficulty, opts) { function step() { const end = n + chunk; for (; n < end; n++) { - if (meets(nonce, String(n), difficulty)) { resolve(String(n)); return; } + if (meets(nonce, String(n), difficulty, memory)) { resolve(String(n)); return; } } if (opts.onProgress) opts.onProgress(Math.min(0.99, n / expectedTotal)); yieldTo(step); @@ -145,5 +176,5 @@ function solve(nonce, difficulty, opts) { } if (typeof module !== 'undefined' && module.exports) { - module.exports = { solve, solveSync, meets, sha256Bytes, leadingZeroBits, utf8Bytes }; + module.exports = { solve, solveSync, meets, romix, powDigest, sha256Bytes, leadingZeroBits, utf8Bytes }; } diff --git a/reference/train-ml-engine.js b/reference/train-ml-engine.js new file mode 100644 index 0000000..a062640 --- /dev/null +++ b/reference/train-ml-engine.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * Trainer for the WAF bot-detection MlEngine (logistic regression). + * + * The MlEngine is the v2 scoring brain: it implements the same + * `Utopia\WAF\Challenge\Scoring\Engine` interface as the v1 HeuristicEngine, but + * replaces the hand-tuned weighted-additive core with a *learned* logistic model + * P(bot) = sigmoid(intercept + Σ coefᵢ · featureᵢ) + * over the numeric signal vector. The deterministic policy overrides + * (interaction ceiling, attack-score deny/challenge floors) stay in the engine — + * only the fuzzy behavioural middle is learned. + * + * There is no production traffic to train on, so this fits the model on + * *synthetic* labelled traffic that encodes the same domain assumptions the + * heuristic weights encoded — several bot archetypes vs. real-browser humans. + * The point is not the exact numbers; it is the swap path: retrain → paste the + * emitted coefficients into MlEngine::DEFAULT_COEFFICIENTS, nothing else changes. + * + * Deterministic: seeded PRNG, so the baked constants are reproducible. + * + * node reference/train-ml-engine.js # prints report + JSON model + * node reference/train-ml-engine.js > reference/ml-engine-model.json + */ +'use strict'; + +// Feature order — MUST match MlEngine::FEATURES. +const FEATURES = [ + 'ipReputation', + 'asnReputation', + 'tlsMismatch', + 'missingHeaders', + 'headless', + 'automationFlags', + 'behavioralRisk', +]; + +// ---- seeded PRNG (mulberry32) so training is reproducible ---------------- +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const rnd = mulberry32(0x5eed1234); +const clamp01 = (x) => Math.max(0, Math.min(1, x)); +// non-negative gaussian-ish noise in [0,1] +const near = (mu, spread) => clamp01(mu + (rnd() - 0.5) * 2 * spread); +const pick = (arr) => arr[Math.floor(rnd() * arr.length)]; + +// ---- synthetic sample generators ----------------------------------------- +function humanSample() { + // Real browser, a person driving it. Occasional benign noise (a VPN, a proxy + // that strips one header) but never automation/headless artefacts. + return { + ipReputation: rnd() < 0.08 ? near(0.15, 0.15) : 0, + asnReputation: rnd() < 0.15 ? near(0.2, 0.2) : 0, // some legit users on VPN/hosting ASNs + tlsMismatch: rnd() < 0.02 ? 1 : 0, // a real browser's JA4 matches its UA + missingHeaders: rnd() < 0.2 ? near(0.15, 0.15) : 0, + headless: 0, + automationFlags: 0, + behavioralRisk: near(0.12, 0.12), // humans move the mouse / type + label: 0, + }; +} + +function botSample() { + // Three archetypes, sampled uniformly. + const kind = pick(['headless', 'scripted', 'datacenter']); + if (kind === 'headless') { + // Puppeteer/Selenium: real Chrome TLS, but webdriver + no human input. + return { + ipReputation: rnd() < 0.3 ? near(0.4, 0.3) : 0, + asnReputation: near(0.5, 0.4), + tlsMismatch: rnd() < 0.2 ? 1 : 0, + missingHeaders: near(0.15, 0.15), + headless: near(0.9, 0.1), + automationFlags: near(0.6, 0.4), + behavioralRisk: near(0.85, 0.15), // no organic interaction + label: 1, + }; + } + if (kind === 'scripted') { + // curl / python-requests hitting the interstitial: no JS engine at all, so + // the client probe reports fully headless; TLS fingerprint gives it away. + return { + ipReputation: rnd() < 0.4 ? near(0.5, 0.4) : 0, + asnReputation: near(0.6, 0.35), + tlsMismatch: rnd() < 0.9 ? 1 : 0, + missingHeaders: near(0.5, 0.3), + headless: 1, + automationFlags: near(0.3, 0.3), + behavioralRisk: 1, + label: 1, + }; + } + // datacenter: abusive IP/ASN reputation dominant, mixed client artefacts. + return { + ipReputation: near(0.75, 0.25), + asnReputation: near(0.8, 0.2), + tlsMismatch: rnd() < 0.5 ? 1 : 0, + missingHeaders: near(0.35, 0.3), + headless: rnd() < 0.6 ? near(0.8, 0.2) : 0, + automationFlags: rnd() < 0.5 ? near(0.5, 0.4) : 0, + behavioralRisk: near(0.7, 0.3), + label: 1, + }; +} + +// balanced dataset +const N = 20000; +const data = []; +for (let i = 0; i < N; i++) data.push(i % 2 === 0 ? humanSample() : botSample()); + +// ---- logistic regression (full-batch gradient descent, L2) --------------- +const D = FEATURES.length; +let w = new Array(D).fill(0); +let b = 0; +const lr = 0.5; +const l2 = 1e-4; +const epochs = 4000; +const sigmoid = (z) => 1 / (1 + Math.exp(-z)); + +for (let e = 0; e < epochs; e++) { + const gw = new Array(D).fill(0); + let gb = 0; + for (const s of data) { + let z = b; + for (let j = 0; j < D; j++) z += w[j] * s[FEATURES[j]]; + const err = sigmoid(z) - s.label; + for (let j = 0; j < D; j++) gw[j] += err * s[FEATURES[j]]; + gb += err; + } + for (let j = 0; j < D; j++) w[j] -= lr * (gw[j] / N + l2 * w[j]); + b -= lr * (gb / N); +} + +// ---- evaluate ------------------------------------------------------------- +let tp = 0, tn = 0, fp = 0, fn = 0; +const predict = (s) => { + let z = b; + for (let j = 0; j < D; j++) z += w[j] * s[FEATURES[j]]; + return sigmoid(z); +}; +for (const s of data) { + const p = predict(s) >= 0.5 ? 1 : 0; + if (s.label === 1 && p === 1) tp++; + else if (s.label === 0 && p === 0) tn++; + else if (s.label === 0 && p === 1) fp++; + else fn++; +} + +const coefficients = {}; +FEATURES.forEach((f, j) => (coefficients[f] = Number(w[j].toFixed(6)))); +const model = { + _comment: 'Generated by reference/train-ml-engine.js — logistic regression over the bot-detection signal vector. Paste into MlEngine::DEFAULT_COEFFICIENTS / DEFAULT_INTERCEPT.', + features: FEATURES, + intercept: Number(b.toFixed(6)), + coefficients, + metrics: { + samples: N, + accuracy: Number(((tp + tn) / N).toFixed(4)), + precision: Number((tp / (tp + fp)).toFixed(4)), + recall: Number((tp / (tp + fn)).toFixed(4)), + falsePositiveRate: Number((fp / (fp + tn)).toFixed(4)), + }, +}; + +// canonical decision vectors — a sanity check on tier alignment +const THRESH = { challenge: 0.25, interactive: 0.55, deny: 0.8 }; +const tier = (p) => (p >= THRESH.deny ? 'deny' : p >= THRESH.interactive ? 'interactive' : p >= THRESH.challenge ? 'challenge' : 'allow'); +const vec = (o) => { const s = {}; FEATURES.forEach((f) => (s[f] = o[f] || 0)); return predict(s); }; +const cases = { + 'clean human': vec({}), + 'one missing header': vec({ missingHeaders: 0.33 }), + 'legit VPN user': vec({ asnReputation: 0.3, missingHeaders: 0.33 }), + 'headless bot (no interaction)': vec({ headless: 1, automationFlags: 0.66, behavioralRisk: 1 }), + 'curl/python (tls mismatch)': vec({ tlsMismatch: 1, missingHeaders: 0.5, headless: 1, behavioralRisk: 1 }), + 'datacenter abuser': vec({ ipReputation: 0.8, asnReputation: 0.9 }), +}; +const report = {}; +for (const [k, p] of Object.entries(cases)) report[k] = { p: Number(p.toFixed(4)), tier: tier(p) }; +model.canonicalCases = report; + +process.stdout.write(JSON.stringify(model, null, 2) + '\n'); +process.stderr.write( + `trained on ${N} samples — acc=${model.metrics.accuracy} prec=${model.metrics.precision} recall=${model.metrics.recall} fpr=${model.metrics.falsePositiveRate}\n` +); +for (const [k, r] of Object.entries(report)) process.stderr.write(` ${k.padEnd(32)} p=${r.p.toFixed(4)} → ${r.tier}\n`); diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php index 6ba6dad..1608e89 100644 --- a/src/Challenge/Issuer.php +++ b/src/Challenge/Issuer.php @@ -5,10 +5,12 @@ /** * Issues WAF challenges. * - * The client must find a `solution` such that - * `sha256(nonce . '.' . solution)` has at least `difficulty` leading zero bits. - * The nonce is a signed, short-lived, context-bound token — issuance is stateless - * and verification (see Verifier) is a single hash. + * The client must find a `solution` such that the proof-of-work digest of + * `nonce . '.' . solution` has at least `difficulty` leading zero bits. The + * digest is plain SHA-256 by default, or a memory-hard variant when the challenge + * is minted with a `memory` cost (see {@see Pow}). The nonce is a signed, + * short-lived, context-bound token — issuance is stateless and verification (see + * Verifier) recomputes a single digest. */ final class Issuer { @@ -16,9 +18,24 @@ final class Issuer public const DIFFICULTY_DEFAULT = 20; public const DIFFICULTY_MAX = 24; + /** + * Memory-hard mode. `memory` is the scratchpad size in 32-byte blocks; each + * solution attempt fills and randomly reads it (see {@see Pow::romix()}). + * Because a memory-hard attempt costs ~2·memory hashes, the difficulty band + * is far lower than the CPU-only one — few attempts, each expensive — so the + * browser stays responsive while a farm loses its parallelism advantage. + */ + public const MEMORY_MIN = 1; + public const MEMORY_DEFAULT = 512; // 512 blocks × 32 B = 16 KB scratchpad + public const MEMORY_MAX = 4096; // 128 KB + + public const MEMORY_DIFFICULTY_MIN = 4; + public const MEMORY_DIFFICULTY_DEFAULT = 8; + public const MEMORY_DIFFICULTY_MAX = 12; + public const NONCE_TTL = 120; - public const ALGORITHM = 'sha256'; + public const ALGORITHM = Pow::ALGORITHM_SHA256; public function __construct(private readonly Signer $signer) { @@ -37,11 +54,20 @@ public function __construct(private readonly Signer $signer) * It is authenticated by the nonce signature; read it back with * {@see self::clearanceTtl()} after verification. * - * @return array{nonce: string, difficulty: int, algorithm: string, expiresAt: int, expiresIn: int} + * `$memory`, when greater than zero, switches the challenge to the memory-hard + * proof of work and is clamped to `[MEMORY_MIN, MEMORY_MAX]`; the difficulty is + * then interpreted (and clamped) in the lower memory-mode band. It is carried + * in the signed nonce (claim `mem`) so the Verifier recomputes the same digest. + * + * @return array{nonce: string, difficulty: int, algorithm: string, memory: int, expiresAt: int, expiresIn: int} */ - public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAULT, ?int $clearanceTtl = null): array + public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAULT, ?int $clearanceTtl = null, int $memory = 0): array { - $difficulty = \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); + $memory = $memory > 0 ? \max(self::MEMORY_MIN, \min(self::MEMORY_MAX, $memory)) : 0; + + $difficulty = $memory > 0 + ? \max(self::MEMORY_DIFFICULTY_MIN, \min(self::MEMORY_DIFFICULTY_MAX, $difficulty)) + : \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); $issuedAt = \time(); $expiresAt = $issuedAt + self::NONCE_TTL; @@ -58,6 +84,10 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU 'rnd' => \bin2hex(\random_bytes(16)), ]; + if ($memory > 0) { + $claims['mem'] = $memory; + } + if ($clearanceTtl !== null) { $claims['ctl'] = $clearanceTtl; } @@ -67,7 +97,8 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU return [ 'nonce' => $nonce, 'difficulty' => $difficulty, - 'algorithm' => self::ALGORITHM, + 'algorithm' => Pow::algorithm($memory), + 'memory' => $memory, 'expiresAt' => $expiresAt, 'expiresIn' => self::NONCE_TTL, ]; diff --git a/src/Challenge/Pow.php b/src/Challenge/Pow.php new file mode 100644 index 0000000..fb885ea --- /dev/null +++ b/src/Challenge/Pow.php @@ -0,0 +1,117 @@ + 0` — a sequential **memory-hard** hash ({@see self::romix()}). Each + * solution attempt must fill and then randomly read a `memory`-block scratchpad + * (`memory * 32` bytes), so throughput is bounded by memory bandwidth, not core + * count. This narrows the gap between a browser solving one challenge and a + * botnet farm solving millions — the whole point of raising the cost floor. + * + * The function is defined byte-for-byte so the pure-JS solver (see + * `reference/solver.js`, inlined into {@see Interstitial}) and this PHP verifier + * agree exactly. It is intentionally built from SHA-256 alone (no Argon2/scrypt + * dependency) so the browser side stays a dependency-free, CSP-inlinable script. + */ +final class Pow +{ + public const ALGORITHM_SHA256 = 'sha256'; + + public const ALGORITHM_ROMIX = 'sha256-romix'; + + /** + * The algorithm label advertised to the client for the given memory setting. + */ + public static function algorithm(int $memory): string + { + return $memory > 0 ? self::ALGORITHM_ROMIX : self::ALGORITHM_SHA256; + } + + /** + * Raw (binary) proof-of-work digest of `$input` under the given memory cost. + */ + public static function digest(string $input, int $memory): string + { + if ($memory <= 0) { + return \hash(self::ALGORITHM_SHA256, $input, true); + } + + return self::romix($input, $memory); + } + + /** + * Whether `$input` is a valid solution at `$difficulty` leading zero bits. + */ + public static function meets(string $input, int $difficulty, int $memory): bool + { + return self::leadingZeroBits(self::digest($input, $memory)) >= $difficulty; + } + + /** + * Sequential memory-hard hash (a SHA-256 ROMix, in the scrypt lineage). + * + * 1. Fill a scratchpad of `$memory` 32-byte blocks by iterated hashing — + * `V[0] = sha256(input)`, `V[i] = sha256(V[i-1])`. + * 2. Mix for `$memory` rounds with **data-dependent** reads: the next index is + * derived from the current accumulator, so the whole scratchpad must stay + * resident (you cannot recompute a block on demand without redoing the + * chain). That data dependency is what makes it memory-hard rather than + * merely memory-using. + * + * @param int $memory number of 32-byte scratchpad blocks (> 0) + */ + public static function romix(string $input, int $memory): string + { + $v = []; + $v[0] = \hash(self::ALGORITHM_SHA256, $input, true); + for ($i = 1; $i < $memory; $i++) { + $v[$i] = \hash(self::ALGORITHM_SHA256, $v[$i - 1], true); + } + + $x = $v[$memory - 1]; + for ($i = 0; $i < $memory; $i++) { + // Big-endian uint32 of the first 4 bytes, mod scratchpad size. + $j = (\ord($x[0]) << 24 | \ord($x[1]) << 16 | \ord($x[2]) << 8 | \ord($x[3])) % $memory; + $x = \hash(self::ALGORITHM_SHA256, $x ^ $v[$j], true); + } + + return $x; + } + + /** + * Count leading zero bits across the raw (binary) digest. + */ + public static function leadingZeroBits(string $digest): int + { + $bits = 0; + $length = \strlen($digest); + + for ($i = 0; $i < $length; $i++) { + $byte = \ord($digest[$i]); + + if ($byte === 0) { + $bits += 8; + + continue; + } + + for ($mask = 0x80; $mask > 0; $mask >>= 1) { + if (($byte & $mask) !== 0) { + return $bits; + } + + $bits++; + } + } + + return $bits; + } +} diff --git a/src/Challenge/Scoring/MlEngine.php b/src/Challenge/Scoring/MlEngine.php new file mode 100644 index 0000000..3c4ddc5 --- /dev/null +++ b/src/Challenge/Scoring/MlEngine.php @@ -0,0 +1,146 @@ + + */ + public const FEATURES = [ + Signal::IP_REPUTATION, + Signal::ASN_REPUTATION, + Signal::TLS_MISMATCH, + Signal::MISSING_HEADERS, + Signal::HEADLESS, + Signal::AUTOMATION_FLAGS, + Signal::BEHAVIORAL_RISK, + ]; + + /** + * Learned logistic-regression coefficients (signal key => weight), fit offline + * on synthetic labelled traffic. Regenerate with `reference/train-ml-engine.js`. + * + * @var array + */ + public const DEFAULT_COEFFICIENTS = [ + Signal::IP_REPUTATION => 3.562063, + Signal::ASN_REPUTATION => 4.67617, + Signal::TLS_MISMATCH => 1.375458, + Signal::MISSING_HEADERS => 1.896716, + Signal::HEADLESS => 4.576935, + Signal::AUTOMATION_FLAGS => 2.71498, + Signal::BEHAVIORAL_RISK => 4.826937, + ]; + + /** Learned intercept (bias) — the all-zero-signal baseline logit. */ + public const DEFAULT_INTERCEPT = -6.52634; + + /** Score ceiling applied when the client passed the interaction challenge. */ + public const INTERACTION_PASS_CEILING = 0.10; + + /** ATTACK_SCORE at/above this floors the verdict to deny (a certain attack). */ + public const ATTACK_DENY_FLOOR = 0.85; + + /** ATTACK_SCORE at/above this floors the verdict to at least challenge. */ + public const ATTACK_CHALLENGE_FLOOR = 0.40; + + /** + * @var array + */ + private array $coefficients; + + /** + * @var array + */ + private array $thresholds; + + /** + * @param array $coefficients signal key => learned weight + * @param float $intercept learned bias term + * @param array $thresholds tier boundaries (see RiskTier::DEFAULT_THRESHOLDS) + */ + public function __construct( + array $coefficients = self::DEFAULT_COEFFICIENTS, + private float $intercept = self::DEFAULT_INTERCEPT, + array $thresholds = RiskTier::DEFAULT_THRESHOLDS, + ) { + $this->coefficients = $coefficients; + $this->thresholds = $thresholds; + } + + public function score(Signals $signals): Score + { + $logit = $this->intercept; + $contributions = []; + + foreach ($this->coefficients as $key => $coefficient) { + $value = $signals->float($key); + if ($value === 0.0) { + continue; + } + + // Logit-space contribution: how many log-odds this signal added. Kept + // for the same explainability the heuristic engine offers (the sink + // logs it), even though the final score is the squashed probability. + $term = $coefficient * $value; + $contributions[$key] = $term; + $logit += $term; + } + + // Squash to a calibrated P(bot) in (0,1). + $value = 1.0 / (1.0 + \exp(-$logit)); + + // --- deterministic policy overrides (identical to HeuristicEngine) --- + if ($signals->bool(Signal::INTERACTION_PASSED)) { + $value = \min($value, self::INTERACTION_PASS_CEILING); + } + + $attack = $signals->float(Signal::ATTACK_SCORE); + if ($attack >= self::ATTACK_DENY_FLOOR) { + $value = \max($value, $this->threshold(RiskTier::DENY)); + } elseif ($attack >= self::ATTACK_CHALLENGE_FLOOR) { + $value = \max($value, $this->threshold(RiskTier::CHALLENGE)); + } + + $value = \max(0.0, \min(1.0, $value)); + + return new Score($value, RiskTier::fromScore($value, $this->thresholds), $contributions); + } + + private function threshold(RiskTier $tier): float + { + return $this->thresholds[$tier->value] ?? RiskTier::DEFAULT_THRESHOLDS[$tier->value] ?? 0.0; + } +} diff --git a/src/Challenge/Verifier.php b/src/Challenge/Verifier.php index 994fd49..68960f4 100644 --- a/src/Challenge/Verifier.php +++ b/src/Challenge/Verifier.php @@ -3,11 +3,12 @@ namespace Utopia\WAF\Challenge; /** - * Verifies challenge solutions in constant work (one hash). + * Verifies challenge solutions in bounded work (one proof-of-work digest). * * A solution is accepted only when the nonce is authentic, unexpired, bound to - * the same context, and `sha256(nonce . '.' . solution)` meets the difficulty - * baked into the nonce. + * the same context, and the proof-of-work digest of `nonce . '.' . solution` + * meets the difficulty baked into the nonce. The digest is plain SHA-256, or the + * memory-hard variant when the nonce carries a `mem` cost (see {@see Pow}). */ final class Verifier { @@ -54,37 +55,13 @@ public function verify(string $nonce, string $solution, Context $context): bool return false; } - $digest = \hash(Issuer::ALGORITHM, $nonce . '.' . $solution, true); - - return self::leadingZeroBits($digest) >= $difficulty; - } - - /** - * Count leading zero bits across the raw (binary) digest. - */ - private static function leadingZeroBits(string $digest): int - { - $bits = 0; - $length = \strlen($digest); - - for ($i = 0; $i < $length; $i++) { - $byte = \ord($digest[$i]); - - if ($byte === 0) { - $bits += 8; - - continue; - } - - for ($mask = 0x80; $mask > 0; $mask >>= 1) { - if (($byte & $mask) !== 0) { - return $bits; - } - - $bits++; - } + // Memory-hard cost, when the nonce carries one. The nonce is HMAC-signed, + // so `mem` cannot be forged; the clamp is a defensive bound on verify work. + $memory = (int) ($claims['mem'] ?? 0); + if ($memory > 0) { + $memory = \min($memory, Issuer::MEMORY_MAX); } - return $bits; + return Pow::meets($nonce . '.' . $solution, $difficulty, $memory); } } diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php index 5027e1b..d115dde 100644 --- a/tests/Challenge/ChallengeTest.php +++ b/tests/Challenge/ChallengeTest.php @@ -164,6 +164,50 @@ public function testChallengeRoundTrip(): void $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); } + public function testMemoryHardChallengeRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + $verifier = new Verifier($signer); + + // A memory-hard challenge advertises the romix algorithm, carries the + // memory cost in the (signed) nonce, and clamps the difficulty into the + // lower memory-mode band even though a classic-range value was requested. + $memory = 64; // small scratchpad keeps the test fast + $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT, null, $memory); + + $this->assertSame(\Utopia\WAF\Challenge\Pow::ALGORITHM_ROMIX, $challenge['algorithm']); + $this->assertSame($memory, $challenge['memory']); + $this->assertLessThanOrEqual(Issuer::MEMORY_DIFFICULTY_MAX, $challenge['difficulty']); + $this->assertGreaterThanOrEqual(Issuer::MEMORY_DIFFICULTY_MIN, $challenge['difficulty']); + + // Solve with the memory-aware digest, then verify through the real path. + $solution = $this->solveMemoryHard($challenge['nonce'], $challenge['difficulty'], $memory); + $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); + + // The memory-hard and classic digests of the same input are independent + // functions, so the mode baked into the (signed) nonce cannot be downgraded. + $this->assertNotSame( + \Utopia\WAF\Challenge\Pow::digest($challenge['nonce'] . '.' . $solution, $memory), + \Utopia\WAF\Challenge\Pow::digest($challenge['nonce'] . '.' . $solution, 0), + ); + } + + /** + * Brute-force a memory-hard solution (romix digest) for the given nonce. + */ + private function solveMemoryHard(string $nonce, int $difficulty, int $memory): string + { + for ($i = 0; $i < 5_000_000; $i++) { + $solution = (string) $i; + if (\Utopia\WAF\Challenge\Pow::meets($nonce . '.' . $solution, $difficulty, $memory)) { + return $solution; + } + } + + $this->fail('Could not find a memory-hard solution for difficulty ' . $difficulty); + } + public function testNonceCarriesClearanceTtlRoundTrip(): void { $signer = new Signer(self::SECRET); diff --git a/tests/Challenge/MlEngineTest.php b/tests/Challenge/MlEngineTest.php new file mode 100644 index 0000000..cb457c8 --- /dev/null +++ b/tests/Challenge/MlEngineTest.php @@ -0,0 +1,125 @@ +engine()->score(new Signals()); + + $this->assertSame(RiskTier::ALLOW, $score->tier); + $this->assertLessThan(0.25, $score->value); // well below the challenge gate + } + + public function testBenignSingleSignalStaysAllowed(): void + { + // A stripped header or a VPN ASN alone must never manufacture a challenge: + // the false-positive rate is the metric that hurts most. + $signals = (new Signals()) + ->with(Signal::MISSING_HEADERS, 0.33) + ->with(Signal::ASN_REPUTATION, 0.3); + + $this->assertSame(RiskTier::ALLOW, $this->engine()->score($signals)->tier); + } + + public function testHeadlessBotIsEscalated(): void + { + // navigator.webdriver + no organic interaction — the puppeteer/selenium + // shape. The learned model should push this to the top of the ladder. + $signals = (new Signals()) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::AUTOMATION_FLAGS, 0.66) + ->with(Signal::BEHAVIORAL_RISK, 1.0); + + $score = $this->engine()->score($signals); + + $this->assertGreaterThanOrEqual(0.80, $score->value); + $this->assertSame(RiskTier::DENY, $score->tier); + } + + public function testScriptedClientWithTlsMismatchIsDenied(): void + { + // curl / python-requests hitting the interstitial: no JS engine, TLS + // fingerprint contradicts any browser UA. + $signals = (new Signals()) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::MISSING_HEADERS, 0.5) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::BEHAVIORAL_RISK, 1.0); + + $this->assertSame(RiskTier::DENY, $this->engine()->score($signals)->tier); + } + + public function testPassedInteractionCapsTheScore(): void + { + // Even a bot-shaped request is capped below the first gate once the client + // proves humanity by passing the interaction challenge. + $signals = (new Signals()) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::BEHAVIORAL_RISK, 1.0) + ->with(Signal::AUTOMATION_FLAGS, 1.0) + ->with(Signal::INTERACTION_PASSED, true); + + $score = $this->engine()->score($signals); + + $this->assertLessThanOrEqual(MlEngine::INTERACTION_PASS_CEILING, $score->value); + $this->assertSame(RiskTier::ALLOW, $score->tier); + } + + public function testAttackScoreFloorsToDeny(): void + { + // A high request-inspection score is decisive: it floors the verdict to + // deny regardless of how benign the behavioural signals look, and it is + // applied after (so it survives) any interaction cap. + $signals = (new Signals()) + ->with(Signal::ATTACK_SCORE, 0.9) + ->with(Signal::INTERACTION_PASSED, true); + + $score = $this->engine()->score($signals); + + $this->assertGreaterThanOrEqual(0.80, $score->value); + $this->assertSame(RiskTier::DENY, $score->tier); + } + + public function testModerateAttackScoreFloorsToChallenge(): void + { + $signals = (new Signals())->with(Signal::ATTACK_SCORE, 0.45); + + $score = $this->engine()->score($signals); + + $this->assertGreaterThanOrEqual(0.25, $score->value); + $this->assertSame(RiskTier::CHALLENGE, $score->tier); + } + + public function testContributionsAreRecordedPerSignal(): void + { + $signals = (new Signals()) + ->with(Signal::HEADLESS, 1.0) + ->with(Signal::ASN_REPUTATION, 0.5); + + $contributions = $this->engine()->score($signals)->contributions; + + $this->assertArrayHasKey(Signal::HEADLESS, $contributions); + $this->assertArrayHasKey(Signal::ASN_REPUTATION, $contributions); + // logit-space contribution = coefficient * value + $this->assertGreaterThan(0.0, $contributions[Signal::HEADLESS]); + } + + public function testImplementsEngineInterface(): void + { + // The whole point of the class: it is a drop-in for the heuristic engine. + $this->assertInstanceOf(\Utopia\WAF\Challenge\Scoring\Engine::class, $this->engine()); + } +} diff --git a/tests/Challenge/PowTest.php b/tests/Challenge/PowTest.php new file mode 100644 index 0000000..21c407c --- /dev/null +++ b/tests/Challenge/PowTest.php @@ -0,0 +1,85 @@ +assertSame(Pow::ALGORITHM_SHA256, Pow::algorithm(0)); + $this->assertSame(Pow::ALGORITHM_SHA256, Pow::algorithm(-1)); + $this->assertSame(Pow::ALGORITHM_ROMIX, Pow::algorithm(1)); + $this->assertSame(Pow::ALGORITHM_ROMIX, Pow::algorithm(512)); + } + + public function testClassicDigestIsPlainSha256(): void + { + // memory <= 0 must be byte-identical to a plain SHA-256, so existing + // (non-memory) challenges keep verifying exactly as before. + $this->assertSame(\hash('sha256', 'abc', true), Pow::digest('abc', 0)); + $this->assertSame(\hash('sha256', 'abc', true), Pow::digest('abc', -5)); + } + + public function testRomixIsDeterministic(): void + { + $this->assertSame(Pow::romix('seed.1', 128), Pow::romix('seed.1', 128)); + } + + public function testRomixDiffersFromSha256AndAcrossMemory(): void + { + $input = 'challenge.42'; + $this->assertNotSame(\hash('sha256', $input, true), Pow::romix($input, 64)); + // Different scratchpad sizes are different functions. + $this->assertNotSame(Pow::romix($input, 64), Pow::romix($input, 128)); + } + + public function testRomixOutputSize(): void + { + $this->assertSame(32, \strlen(Pow::romix('x', 1))); + $this->assertSame(32, \strlen(Pow::romix('x', 256))); + } + + public function testRomixMemoryOfOneIsDoubleHash(): void + { + // memory=1: V=[sha256(input)]; one mix round reads V[0], so the result is + // sha256(sha256(input) XOR sha256(input)) = sha256(32 zero bytes). + $expected = \hash('sha256', \str_repeat("\0", 32), true); + $this->assertSame($expected, Pow::romix('anything at all', 1)); + } + + public function testLeadingZeroBitsCounts(): void + { + $this->assertSame(0, Pow::leadingZeroBits("\xff")); + $this->assertSame(8, Pow::leadingZeroBits("\x00\xff")); + $this->assertSame(9, Pow::leadingZeroBits("\x00\x7f")); + $this->assertSame(16, Pow::leadingZeroBits("\x00\x00")); + } + + public function testMeetsHonoursDifficultyAndMode(): void + { + // Find a small classic solution, then confirm meets() agrees and that the + // same input under the memory-hard digest is an independent verdict. + $nonce = 'pow-test-nonce'; + $difficulty = 8; + + $solution = null; + for ($i = 0; $i < 5_000_000; $i++) { + if (Pow::meets($nonce . '.' . $i, $difficulty, 0)) { + $solution = (string) $i; + break; + } + } + + $this->assertNotNull($solution, 'expected to find a classic solution'); + $this->assertTrue(Pow::meets($nonce . '.' . $solution, $difficulty, 0)); + // The memory-hard digest of the same input is an independent function, so + // the modes cannot alias (deterministic check — no probabilistic verdict). + $this->assertNotSame( + Pow::digest($nonce . '.' . $solution, 0), + Pow::digest($nonce . '.' . $solution, 512), + ); + } +} From ee5277255a9436eee7c6439f48ec5fcd1213ce38 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 4 Jul 2026 00:26:55 +0200 Subject: [PATCH 13/19] Harden memory-hard difficulty scaling and the interaction ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review fixes to the challenge/scoring primitives: Issuer: memory-hard challenges no longer pin to the band maximum. Callers pass difficulty in the CPU band [16,24] (rule config + adaptive bump), which was then re-clamped into the memory band [4,12] — so every input >=16 collapsed to 12, the slowest solve, making the difficulty knob and adaptive ladder no-ops in memory mode. issue() now translates the CPU band proportionally onto the memory band (16->4, 20->8, 24->12), preserving the config scale. HeuristicEngine + MlEngine: a passed interaction no longer erases server-side evidence. The interaction ceiling capped the whole score to 0.10, which also wiped IP/ASN reputation and TLS mismatch — so a forged `interacted: true` from a blocklisted IP could reach ALLOW. The ceiling now floors at the server-only score (Signal::SERVER family): it neutralizes only the client-behavioral contribution (headless/automation/biometrics), never the server-observed signals the client cannot influence. Mirrors the existing attack-score-after-cap rule. Tests: 100 pass. New coverage for the band translation and for server evidence surviving a forged interaction on both engines. Co-Authored-By: Claude Opus 4.8 --- src/Challenge/Issuer.php | 24 +++++++++++++++- src/Challenge/Scoring/HeuristicEngine.php | 16 ++++++++++- src/Challenge/Scoring/MlEngine.php | 12 +++++++- tests/Challenge/ChallengeTest.php | 23 +++++++++++++++ tests/Challenge/MlEngineTest.php | 29 +++++++++++++++++++ tests/Challenge/ScoringTest.php | 35 ++++++++++++++++++++--- 6 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php index 1608e89..07171a4 100644 --- a/src/Challenge/Issuer.php +++ b/src/Challenge/Issuer.php @@ -66,7 +66,7 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU $memory = $memory > 0 ? \max(self::MEMORY_MIN, \min(self::MEMORY_MAX, $memory)) : 0; $difficulty = $memory > 0 - ? \max(self::MEMORY_DIFFICULTY_MIN, \min(self::MEMORY_DIFFICULTY_MAX, $difficulty)) + ? self::toMemoryDifficulty($difficulty) : \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); $issuedAt = \time(); @@ -104,6 +104,28 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU ]; } + /** + * Map a CPU-band difficulty onto the memory-hard band, preserving scale. + * + * Callers store and pass difficulty in the CPU band [DIFFICULTY_MIN, + * DIFFICULTY_MAX] — the rule config validator and the adaptive bump both live + * there. A memory-hard attempt is far more expensive than a bare SHA-256 one, + * so the same knob has to land in the much lower memory band. Translating + * proportionally (16→4, 20→8, 24→12) keeps the rule's difficulty setting and + * the adaptive ladder meaningful; a naive re-clamp would collapse every + * CPU-band input to the memory-band maximum, i.e. the slowest possible solve. + */ + private static function toMemoryDifficulty(int $difficulty): int + { + $cpu = \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); + + $cpuSpan = self::DIFFICULTY_MAX - self::DIFFICULTY_MIN; + $memorySpan = self::MEMORY_DIFFICULTY_MAX - self::MEMORY_DIFFICULTY_MIN; + $fraction = ($cpu - self::DIFFICULTY_MIN) / $cpuSpan; + + return self::MEMORY_DIFFICULTY_MIN + (int) \round($fraction * $memorySpan); + } + /** * Read the clearance TTL carried by a nonce, or null when it carries none. * diff --git a/src/Challenge/Scoring/HeuristicEngine.php b/src/Challenge/Scoring/HeuristicEngine.php index 074abce..97c8b2e 100644 --- a/src/Challenge/Scoring/HeuristicEngine.php +++ b/src/Challenge/Scoring/HeuristicEngine.php @@ -72,6 +72,7 @@ public function score(Signals $signals): Score { $contributions = []; $risk = 0.0; + $serverRisk = 0.0; foreach ($this->weights as $key => $weight) { if (!$signals->has($key) || $this->totalWeight <= 0.0) { @@ -85,12 +86,25 @@ public function score(Signals $signals): Score $contributions[$key] = $contribution; $risk += $contribution; + + // Track the server-observed portion separately (see the interaction + // cap below). + if (\in_array($key, Signal::SERVER, true)) { + $serverRisk += $contribution; + } } $value = \max(0.0, \min(1.0, $risk)); + $serverValue = \max(0.0, \min(1.0, $serverRisk)); if ($signals->bool(Signal::INTERACTION_PASSED)) { - $value = \min($value, self::INTERACTION_PASS_CEILING); + // A passed interaction is a humanity assertion about *behaviour*, so it + // caps the client-behavioral noise (headless/automation/biometrics) + // below the first gate — but it must not floor the score under the + // server-observed evidence (IP/ASN reputation, TLS mismatch, missing + // headers), which the client cannot legitimately assert away. Without + // this floor a forged `interacted:true` would erase a blocklisted IP. + $value = \max($serverValue, \min($value, self::INTERACTION_PASS_CEILING)); } // Attacks are decisive, not fuzzy. A request-inspection verdict floors the diff --git a/src/Challenge/Scoring/MlEngine.php b/src/Challenge/Scoring/MlEngine.php index 3c4ddc5..20ceaf1 100644 --- a/src/Challenge/Scoring/MlEngine.php +++ b/src/Challenge/Scoring/MlEngine.php @@ -103,6 +103,7 @@ public function __construct( public function score(Signals $signals): Score { $logit = $this->intercept; + $serverLogit = $this->intercept; $contributions = []; foreach ($this->coefficients as $key => $coefficient) { @@ -117,6 +118,11 @@ public function score(Signals $signals): Score $term = $coefficient * $value; $contributions[$key] = $term; $logit += $term; + + // Track the server-observed portion for the interaction cap below. + if (\in_array($key, Signal::SERVER, true)) { + $serverLogit += $term; + } } // Squash to a calibrated P(bot) in (0,1). @@ -124,7 +130,11 @@ public function score(Signals $signals): Score // --- deterministic policy overrides (identical to HeuristicEngine) --- if ($signals->bool(Signal::INTERACTION_PASSED)) { - $value = \min($value, self::INTERACTION_PASS_CEILING); + // Cap the client-behavioral contribution, but never below the score + // the server-observed signals alone produce — a forged interaction + // must not erase IP/ASN reputation or a TLS mismatch. + $serverValue = 1.0 / (1.0 + \exp(-$serverLogit)); + $value = \max($serverValue, \min($value, self::INTERACTION_PASS_CEILING)); } $attack = $signals->float(Signal::ATTACK_SCORE); diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php index d115dde..72fbc49 100644 --- a/tests/Challenge/ChallengeTest.php +++ b/tests/Challenge/ChallengeTest.php @@ -193,6 +193,29 @@ public function testMemoryHardChallengeRoundTrip(): void ); } + public function testMemoryDifficultyTranslatesFromCpuBand(): void + { + $issuer = new Issuer(new Signer(self::SECRET)); + + // A CPU-band difficulty is mapped proportionally onto the memory band + // instead of being re-clamped to the band maximum, so the rule knob and + // the adaptive ladder keep their effect. Band edges and midpoint: + $cases = [ + Issuer::DIFFICULTY_MIN => Issuer::MEMORY_DIFFICULTY_MIN, // 16 -> 4 + 20 => 8, // midpoint + Issuer::DIFFICULTY_MAX => Issuer::MEMORY_DIFFICULTY_MAX, // 24 -> 12 + ]; + + foreach ($cases as $cpu => $expected) { + $challenge = $issuer->issue($this->context(), $cpu, null, 256); + $this->assertSame($expected, $challenge['difficulty'], "cpu {$cpu} should map to {$expected}"); + } + + // The default difficulty must not pin to the band maximum. + $default = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT, null, 256); + $this->assertLessThan(Issuer::MEMORY_DIFFICULTY_MAX, $default['difficulty']); + } + /** * Brute-force a memory-hard solution (romix digest) for the given nonce. */ diff --git a/tests/Challenge/MlEngineTest.php b/tests/Challenge/MlEngineTest.php index cb457c8..0c68805 100644 --- a/tests/Challenge/MlEngineTest.php +++ b/tests/Challenge/MlEngineTest.php @@ -78,6 +78,35 @@ public function testPassedInteractionCapsTheScore(): void $this->assertSame(RiskTier::ALLOW, $score->tier); } + public function testPassedInteractionDoesNotEraseServerSignals(): void + { + // A forged interaction must not neutralize server-observed evidence. With + // strong reputation + TLS mismatch the learned server-only probability is + // well above the interaction ceiling, so the score cannot be capped to + // ALLOW — only the client-behavioral contribution is neutralized. + $signals = (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ASN_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::HEADLESS, 1.0) // client noise — neutralized + ->with(Signal::AUTOMATION_FLAGS, 1.0) + ->with(Signal::INTERACTION_PASSED, true); + + $score = $this->engine()->score($signals); + + $this->assertGreaterThan(MlEngine::INTERACTION_PASS_CEILING, $score->value); + $this->assertNotSame(RiskTier::ALLOW, $score->tier); + + // Score equals the server-only probability (client tells neutralized). + $serverOnly = $this->engine()->score( + (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::ASN_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ); + $this->assertEqualsWithDelta($serverOnly->value, $score->value, 1e-9); + } + public function testAttackScoreFloorsToDeny(): void { // A high request-inspection score is decisive: it floors the verdict to diff --git a/tests/Challenge/ScoringTest.php b/tests/Challenge/ScoringTest.php index 94cb27e..ae9b279 100644 --- a/tests/Challenge/ScoringTest.php +++ b/tests/Challenge/ScoringTest.php @@ -103,20 +103,47 @@ public function testHeuristicEscalatesWithSignalWeight(): void $this->assertEqualsWithDelta(1.0, array_sum($all->contributions), 1e-9); } - public function testInteractionPassHardCapsToAllow(): void + public function testInteractionPassHardCapsClientBehaviorToAllow(): void { - // even a fully bot-looking request is cleared once it passes the interaction challenge + // client-behavioral tells (headless/automation/biometrics) are neutralized + // once the request passes the interaction challenge $score = (new HeuristicEngine())->score( (new Signals()) - ->with(Signal::IP_REPUTATION, 1.0) - ->with(Signal::TLS_MISMATCH, true) ->with(Signal::HEADLESS, 1.0) + ->with(Signal::AUTOMATION_FLAGS, 1.0) + ->with(Signal::BEHAVIORAL_RISK, 1.0) ->with(Signal::INTERACTION_PASSED, true) ); $this->assertLessThanOrEqual(HeuristicEngine::INTERACTION_PASS_CEILING, $score->value); $this->assertSame(RiskTier::ALLOW, $score->tier); } + public function testInteractionCeilingDoesNotEraseServerSignals(): void + { + // A forged `interacted: true` must not wipe out server-observed evidence + // the client cannot influence. A blocklisted IP + TLS mismatch keeps the + // score at (at least) what those signals alone produce, so a scripted + // client cannot buy its way to ALLOW by claiming interaction. + $signals = (new Signals()) + ->with(Signal::IP_REPUTATION, 1.0) + ->with(Signal::TLS_MISMATCH, true) + ->with(Signal::HEADLESS, 1.0) // client noise — neutralized + ->with(Signal::INTERACTION_PASSED, true); + + $score = (new HeuristicEngine())->score($signals); + + // Server-only contribution: (IP_REPUTATION 3 + TLS_MISMATCH 4) / 18. + $this->assertGreaterThan(HeuristicEngine::INTERACTION_PASS_CEILING, $score->value); + $this->assertNotSame(RiskTier::ALLOW, $score->tier); + + // But the client-behavioral HEADLESS contribution is still neutralized: + // the score equals the server-only value, not the full weighted sum. + $serverOnly = (new HeuristicEngine())->score( + (new Signals())->with(Signal::IP_REPUTATION, 1.0)->with(Signal::TLS_MISMATCH, true) + ); + $this->assertEqualsWithDelta($serverOnly->value, $score->value, 1e-9); + } + public function testCustomThresholdsAndWeights(): void { // a stricter config escalates the same signal further From 9a032f6906c1319fa9bb5772f4d3a63fc43b33e5 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 4 Jul 2026 01:22:34 +0200 Subject: [PATCH 14/19] =?UTF-8?q?Add=20interactive=20(slider)=20challenge?= =?UTF-8?q?=20=E2=80=94=20an=20unforgeable=20humanity=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client-asserted interaction pass is forgeable because the client holds the answer. This moves the secret to the server: Challenge\Interactive mints a token that carries only a keyed one-way hash of the gap's bucket (Signer::mac), never the gap. The server renders the puzzle (edge-side); the client can learn the gap only from the pixels and cannot verify a guess offline, so it must submit each guess to the server, where the carried proof of work + rate-limiting make brute force costly. Verification is a stateless HMAC bucket check, same posture as the PoW path, with identical context binding + key rotation. ClientSignals no longer produces INTERACTION_PASSED: that signal hard-caps the score, so it must be unforgeable. It is now established server-side only, by verifying the interactive challenge — a client can no longer self-assert it (the blob's `interacted` still lowers BEHAVIORAL_RISK, but grants no humanity pass). This closes the residual hole review-fix #2 only mitigated. Issuer::toMemoryDifficulty is now public so the interactive challenge scales its carried PoW identically. 111 tests (11 new Interactive: forged-token rejection, no-plaintext-gap, tolerance, context binding, PoW, rotation); Pint + PHPStan clean. Co-Authored-By: Claude Opus 4.8 --- src/Challenge/Interactive.php | 199 ++++++++++++++++++++++++ src/Challenge/Issuer.php | 5 +- src/Challenge/Scoring/ClientSignals.php | 10 +- src/Challenge/Signer.php | 19 +++ tests/Challenge/ClientSignalsTest.php | 19 ++- tests/Challenge/InteractiveTest.php | 183 ++++++++++++++++++++++ 6 files changed, 426 insertions(+), 9 deletions(-) create mode 100644 src/Challenge/Interactive.php create mode 100644 tests/Challenge/InteractiveTest.php diff --git a/src/Challenge/Interactive.php b/src/Challenge/Interactive.php new file mode 100644 index 0000000..9ddcc47 --- /dev/null +++ b/src/Challenge/Interactive.php @@ -0,0 +1,199 @@ +bucket($gap) * self::BUCKET_SIZE; + + $memory = $memory > 0 ? \max(Issuer::MEMORY_MIN, \min(Issuer::MEMORY_MAX, $memory)) : 0; + $difficulty = $memory > 0 + ? Issuer::toMemoryDifficulty($difficulty) + : \max(Issuer::DIFFICULTY_MIN, \min(Issuer::DIFFICULTY_MAX, $difficulty)); + + $issuedAt = \time(); + $expiresAt = $issuedAt + self::NONCE_TTL; + $rnd = \bin2hex(\random_bytes(8)); + + $claims = [ + 'typ' => self::TYPE, + 'ver' => 1, + 'pid' => $context->projectId, + 'aud' => $context->audience, + 'iph' => $this->signer->fingerprintIp($context->ip), + 'dif' => $difficulty, + 'iat' => $issuedAt, + 'exp' => $expiresAt, + 'rnd' => $rnd, + // The answer, one-way: HMAC(secret, rnd . bucket(gap)). Public in the + // token, but unrecomputable without the secret — this is what makes the + // pass unforgeable. + 'sol' => $this->signer->mac($rnd . '.' . $this->bucket($gap)), + ]; + + if ($memory > 0) { + $claims['mem'] = $memory; + } + + if ($clearanceTtl !== null) { + $claims['ctl'] = $clearanceTtl; + } + + $token = $this->signer->sign($claims); + + return [ + 'token' => $token, + 'gap' => $gap, + 'difficulty' => $difficulty, + 'memory' => $memory, + 'expiresAt' => $expiresAt, + 'expiresIn' => self::NONCE_TTL, + ]; + } + + /** + * Accept the challenge only when the token is authentic, unexpired, bound to + * the same context, its carried PoW is solved, AND the submitted offset falls + * in the secret gap's bucket — checked against the token's HMAC so the server + * never re-learns the gap. All stateless. + */ + public function verify(string $token, int $offset, string $powSolution, Context $context): bool + { + if ($powSolution === '' || \strlen($powSolution) > self::SOLUTION_MAX_LENGTH) { + return false; + } + + $claims = $this->signer->parse($token); + if ($claims === null || ($claims['typ'] ?? null) !== self::TYPE) { + return false; + } + + $now = \time(); + $expiresAt = (int) ($claims['exp'] ?? 0); + $issuedAt = (int) ($claims['iat'] ?? PHP_INT_MAX); + if ($expiresAt < $now - self::LEEWAY || $issuedAt > $now + self::LEEWAY) { + return false; + } + + if (($claims['pid'] ?? null) !== $context->projectId || ($claims['aud'] ?? null) !== $context->audience) { + return false; + } + + $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; + if (!\hash_equals($this->signer->fingerprintIp($context->ip, $kid), (string) ($claims['iph'] ?? ''))) { + return false; + } + + // The carried proof of work must be solved first: it is what makes each + // guess against the coarse bucket grid cost CPU. + $difficulty = (int) ($claims['dif'] ?? 0); + if ($difficulty <= 0) { + return false; + } + $memory = (int) ($claims['mem'] ?? 0); + if ($memory > 0) { + $memory = \min($memory, Issuer::MEMORY_MAX); + } + if (!Pow::meets($token . '.' . $powSolution, $difficulty, $memory)) { + return false; + } + + // The submitted offset must reproduce the answer hash. hash_equals against + // the token's `sol`; the gap itself is never recovered server-side. + $expected = (string) ($claims['sol'] ?? ''); + if ($expected === '') { + return false; + } + $actual = $this->signer->mac((string) ($claims['rnd'] ?? '') . '.' . $this->bucket($offset), $kid); + + return \hash_equals($expected, $actual); + } + + /** + * Read the clearance TTL carried by a token, or null when it carries none. + * The caller MUST have already verified the token. + */ + public function clearanceTtl(string $token): ?int + { + $claims = $this->signer->parse($token); + if ($claims === null || !isset($claims['ctl']) || !\is_int($claims['ctl'])) { + return null; + } + + return $claims['ctl']; + } + + /** + * Quantize a horizontal offset to the bucket grid (the human-drag tolerance). + */ + public function bucket(int $offset): int + { + return \intdiv(\max(0, $offset), self::BUCKET_SIZE); + } +} diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php index 07171a4..40d786e 100644 --- a/src/Challenge/Issuer.php +++ b/src/Challenge/Issuer.php @@ -114,8 +114,11 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU * proportionally (16→4, 20→8, 24→12) keeps the rule's difficulty setting and * the adaptive ladder meaningful; a naive re-clamp would collapse every * CPU-band input to the memory-band maximum, i.e. the slowest possible solve. + * + * Public so the interactive challenge ({@see Interactive}), which carries its + * own PoW, scales difficulty identically. */ - private static function toMemoryDifficulty(int $difficulty): int + public static function toMemoryDifficulty(int $difficulty): int { $cpu = \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); diff --git a/src/Challenge/Scoring/ClientSignals.php b/src/Challenge/Scoring/ClientSignals.php index 9f0c9cc..0a16b88 100644 --- a/src/Challenge/Scoring/ClientSignals.php +++ b/src/Challenge/Scoring/ClientSignals.php @@ -12,7 +12,14 @@ * - HEADLESS — automation-driver / headless-browser tells * - AUTOMATION_FLAGS — framework artefacts (CDP, Selenium, phantom…) * - BEHAVIORAL_RISK — absence of human input during the interstitial - * - INTERACTION_PASSED— the client completed the interaction gate + * + * It deliberately does NOT produce {@see Signal::INTERACTION_PASSED}. That signal + * hard-caps the score, so it must be unforgeable — a client cannot be trusted to + * assert it. It is established server-side only, by verifying the interactive + * challenge whose answer the client can prove only by reading the rendered pixels + * (see {@see \Utopia\WAF\Challenge\Interactive}). The blob's own `interacted` + * flag still lowers BEHAVIORAL_RISK (real input happened) but grants no humanity + * pass on its own. * * Heuristics are intentionally simple and explainable (v1); an ML engine can * later consume the same raw blob. Every field is optional and defensively read, @@ -31,7 +38,6 @@ public static function normalize(array $raw): array Signal::HEADLESS => self::headless($raw), Signal::AUTOMATION_FLAGS => self::automation($raw), Signal::BEHAVIORAL_RISK => self::behavioral($raw), - Signal::INTERACTION_PASSED => self::boolean($raw, 'interacted'), ]; } diff --git a/src/Challenge/Signer.php b/src/Challenge/Signer.php index 70e4aa0..2c86087 100644 --- a/src/Challenge/Signer.php +++ b/src/Challenge/Signer.php @@ -124,6 +124,25 @@ public function fingerprintIp(string $ip, ?int $kid = null): string return \substr(\hash_hmac('sha256', Ip::prefix($ip), $this->keyset[$kid]), 0, 16); } + /** + * Keyed one-way hash of an arbitrary message. + * + * Used to bake a secret answer into a public token without revealing it: the + * client sees the MAC but, lacking the signing secret, cannot recompute it for + * a guessed answer — it must submit each guess to the server. Like + * {@see self::fingerprintIp()} it honours key rotation via the token's own kid, + * so a rotation does not invalidate answers in already-issued tokens. + */ + public function mac(string $message, ?int $kid = null): string + { + $kid ??= $this->kid; + if (!isset($this->keyset[$kid])) { + throw new ChallengeException('Unknown key id: ' . $kid); + } + + return \substr(\hash_hmac('sha256', $message, $this->keyset[$kid]), 0, 32); + } + private function hmac(string $payload, string $secret): string { return \hash_hmac('sha256', $payload, $secret, true); diff --git a/tests/Challenge/ClientSignalsTest.php b/tests/Challenge/ClientSignalsTest.php index b9cd063..4f05a8c 100644 --- a/tests/Challenge/ClientSignalsTest.php +++ b/tests/Challenge/ClientSignalsTest.php @@ -40,7 +40,9 @@ public function testRealBrowserLooksHuman(): void $this->assertSame(0.0, $s[Signal::HEADLESS]); $this->assertSame(0.0, $s[Signal::AUTOMATION_FLAGS]); $this->assertSame(0.0, $s[Signal::BEHAVIORAL_RISK]); - $this->assertTrue($s[Signal::INTERACTION_PASSED]); + // INTERACTION_PASSED is never produced from the blob (unforgeable): it is + // established server-side by verifying the interactive challenge. + $this->assertArrayNotHasKey(Signal::INTERACTION_PASSED, $s); } public function testAutomationFlagsScale(): void @@ -73,13 +75,18 @@ public function testGarbageBlobDoesNotThrowAndStaysConservative(): void $this->assertIsFloat($s[Signal::HEADLESS]); $this->assertSame(0.0, $s[Signal::AUTOMATION_FLAGS]); $this->assertSame(1.0, $s[Signal::BEHAVIORAL_RISK]); - $this->assertFalse($s[Signal::INTERACTION_PASSED]); + $this->assertArrayNotHasKey(Signal::INTERACTION_PASSED, $s); } - public function testBooleanAcceptsJsonishTruth(): void + public function testInteractionPassedIsNeverClientAsserted(): void { - $this->assertTrue(ClientSignals::normalize(['interacted' => 1])[Signal::INTERACTION_PASSED]); - $this->assertTrue(ClientSignals::normalize(['interacted' => 'true'])[Signal::INTERACTION_PASSED]); - $this->assertFalse(ClientSignals::normalize(['interacted' => 0])[Signal::INTERACTION_PASSED]); + // No form of client-supplied `interacted` yields an interaction pass — the + // whole point of moving the humanity secret server-side. + foreach ([true, 1, 'true', 0, false] as $value) { + $this->assertArrayNotHasKey( + Signal::INTERACTION_PASSED, + ClientSignals::normalize(['interacted' => $value]), + ); + } } } diff --git a/tests/Challenge/InteractiveTest.php b/tests/Challenge/InteractiveTest.php new file mode 100644 index 0000000..8be90d7 --- /dev/null +++ b/tests/Challenge/InteractiveTest.php @@ -0,0 +1,183 @@ +fail('Could not solve the carried PoW'); + } + + public function testRoundTripAcceptsCorrectOffset(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $gap = 120; + $challenge = $interactive->issue($this->context(), $gap, Issuer::DIFFICULTY_MIN); + $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + + // The client reads the gap from the rendered image and drops the piece + // there (the returned `gap` is the snapped truth the renderer would draw). + $this->assertTrue($interactive->verify($challenge['token'], $challenge['gap'], $pow, $this->context())); + } + + public function testWrongOffsetIsRejected(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + + // A different bucket (well outside the tolerance) must fail. + $this->assertFalse($interactive->verify($challenge['token'], 60, $pow, $this->context())); + $this->assertFalse($interactive->verify($challenge['token'], 200, $pow, $this->context())); + } + + public function testHumanDragToleranceWithinBucket(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + // Gap snapped to a bucket boundary; an imprecise drop in the same bucket + // still verifies. + $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + + $this->assertSame(0, $challenge['gap'] % Interactive::BUCKET_SIZE, 'gap is snapped to the grid'); + $this->assertTrue( + $interactive->verify($challenge['token'], $challenge['gap'] + Interactive::BUCKET_SIZE - 1, $pow, $this->context()) + ); + } + + public function testTokenNeverCarriesTheGapInPlaintext(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $gap = 120; + $challenge = $interactive->issue($this->context(), $gap, Issuer::DIFFICULTY_MIN); + + // Decode the (public) claims and confirm the gap is not present — only its + // one-way HMAC is. This is the whole anti-forgery property. + $payload = \explode('.', $challenge['token'])[0]; + $claims = \json_decode(\base64_decode(\strtr($payload, '-_', '+/')), true); + + $this->assertArrayNotHasKey('gap', $claims); + $this->assertArrayNotHasKey('x', $claims); + $this->assertStringNotContainsString((string) $gap, \json_encode(\array_diff_key($claims, ['iat' => 0, 'exp' => 0]))); + $this->assertSame(32, \strlen((string) $claims['sol']), 'sol is a fixed-length MAC, not the answer'); + } + + public function testForgedTokenFromAnotherSecretIsRejected(): void + { + $server = new Interactive(new Signer(self::SECRET)); + $attacker = new Interactive(new Signer('attacker-secret')); + + // The attacker mints a token with a gap they know and computes a matching + // offset — but signs with their own secret, so the server rejects it. + $forged = $attacker->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + $pow = $this->solvePow($forged['token'], $forged['difficulty']); + + $this->assertFalse($server->verify($forged['token'], $forged['gap'], $pow, $this->context())); + } + + public function testWrongPowIsRejected(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_DEFAULT); + + // Correct offset but the PoW is not solved: the guess must still cost work. + $this->assertFalse($interactive->verify($challenge['token'], $challenge['gap'], '0', $this->context())); + $this->assertFalse($interactive->verify($challenge['token'], $challenge['gap'], '', $this->context())); + } + + public function testContextBinding(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $challenge = $interactive->issue($this->context('203.0.113.9'), 120, Issuer::DIFFICULTY_MIN); + $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + $gap = $challenge['gap']; + + // Same /24 prefix passes; different project, audience, or IP prefix fails. + $this->assertTrue($interactive->verify($challenge['token'], $gap, $pow, $this->context('203.0.113.200'))); + $this->assertFalse($interactive->verify($challenge['token'], $gap, $pow, new Context('other', 'mysite.example', '203.0.113.9'))); + $this->assertFalse($interactive->verify($challenge['token'], $gap, $pow, new Context('proj-123', 'other.example', '203.0.113.9'))); + $this->assertFalse($interactive->verify($challenge['token'], $gap, $pow, $this->context('198.51.100.9'))); + } + + public function testTamperedTokenIsRejected(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + + $this->assertFalse($interactive->verify($challenge['token'] . 'x', $challenge['gap'], $pow, $this->context())); + } + + public function testMemoryHardVariantRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_DEFAULT, 64); + $this->assertSame(64, $challenge['memory']); + $this->assertLessThanOrEqual(Issuer::MEMORY_DIFFICULTY_MAX, $challenge['difficulty']); + + $pow = $this->solvePow($challenge['token'], $challenge['difficulty'], $challenge['memory']); + $this->assertTrue($interactive->verify($challenge['token'], $challenge['gap'], $pow, $this->context())); + } + + public function testKeyRotationHonorsTokenKid(): void + { + // Issue under kid 2, then rotate: kid 3 primary, kid 2 kept as previous. + $issueSigner = new Signer(self::SECRET, 2); + $challenge = (new Interactive($issueSigner))->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + + $rotated = new Interactive(new Signer('new-secret', 3, [2 => self::SECRET])); + $this->assertTrue($rotated->verify($challenge['token'], $challenge['gap'], $pow, $this->context())); + } + + public function testClearanceTtlRoundTrip(): void + { + $interactive = new Interactive(new Signer(self::SECRET)); + + $plain = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + $this->assertNull($interactive->clearanceTtl($plain['token'])); + + $withTtl = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN, 0, 1800); + $this->assertSame(1800, $interactive->clearanceTtl($withTtl['token'])); + } +} From 8ee4b04f1f6a3efecca3c4f5acf6c64720aff9bb Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 4 Jul 2026 11:36:47 +0200 Subject: [PATCH 15/19] Harden interactive slider against single-solve brute force The interactive (slider) challenge could be swept for the price of one proof-of-work solve: the offset was not part of the PoW preimage (so one solved nonce was valid for every offset against a token) and verify() is stateless (so a bot could POST every bucket against one rendered image). Either fact alone lets a scripted client clear the gate without ever reading the puzzle. Close both: - Bind the submitted offset into the PoW preimage in verify() (token '.' offset '.' solution). A solution is now valid only for the offset it was solved for, so a single solve can no longer be replayed across buckets. Honest clients still solve exactly once (the drop offset is known before solving). - Add MAX_ATTEMPTS (the guesses-per-token policy) and reference(), which returns an authentic token's nonce (null for a forged/tampered/foreign blob) so a stateful caller can cap attempts per issued token without the primitive holding state. The edge keys a per-token limiter on it and forces a fresh (render-costly, rate-limited) challenge once spent. Tests: offset-aware PoW solver, per-offset binding is enforced, and reference() identifies only authentic tokens. 113 green, Pint + PHPStan clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Challenge/Interactive.php | 40 ++++++++++++- tests/Challenge/InteractiveTest.php | 87 ++++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/Challenge/Interactive.php b/src/Challenge/Interactive.php index 9ddcc47..8691d45 100644 --- a/src/Challenge/Interactive.php +++ b/src/Challenge/Interactive.php @@ -48,6 +48,19 @@ final class Interactive /** Max PoW solution length accepted, guarding the verify hash. */ public const SOLUTION_MAX_LENGTH = 64; + /** + * Max guesses a caller should accept against a single issued token before it + * forces a fresh challenge. The bucket grid is coarse (~44 buckets), so a + * stateless verify alone would let a bot spray every bucket against one + * rendered image until it lands. Verification here cannot enforce this on its + * own — it stores no state — so the stateful caller ({@see reference()}) caps + * attempts per token; a miss then costs a new (render-expensive, rate-limited) + * challenge rather than another free guess. Pairs with the per-offset PoW + * binding in {@see verify()}, which also denies reusing one solved PoW across + * buckets. + */ + public const MAX_ATTEMPTS = 3; + public function __construct(private readonly Signer $signer) { } @@ -151,7 +164,10 @@ public function verify(string $token, int $offset, string $powSolution, Context } // The carried proof of work must be solved first: it is what makes each - // guess against the coarse bucket grid cost CPU. + // guess against the coarse bucket grid cost CPU. The submitted offset is + // part of the PoW preimage, so a solution is valid for exactly the offset + // it was solved for — one solved PoW cannot be replayed across buckets to + // sweep the grid for the price of a single solve. $difficulty = (int) ($claims['dif'] ?? 0); if ($difficulty <= 0) { return false; @@ -160,7 +176,7 @@ public function verify(string $token, int $offset, string $powSolution, Context if ($memory > 0) { $memory = \min($memory, Issuer::MEMORY_MAX); } - if (!Pow::meets($token . '.' . $powSolution, $difficulty, $memory)) { + if (!Pow::meets($token . '.' . $offset . '.' . $powSolution, $difficulty, $memory)) { return false; } @@ -175,6 +191,26 @@ public function verify(string $token, int $offset, string $powSolution, Context return \hash_equals($expected, $actual); } + /** + * A stable, opaque reference for an *authentic* interactive token — its random + * nonce — for a stateful caller that must cap guesses per issued image + * ({@see MAX_ATTEMPTS}). Returns null when the token is not signed by a known + * key, so a forged or garbage blob cannot seed (or exhaust) a real token's + * counter. Deliberately does NOT check expiry or context: it is only an + * identity for the counter, never an acceptance — {@see verify()} still decides. + */ + public function reference(string $token): ?string + { + $claims = $this->signer->parse($token); + if ($claims === null || ($claims['typ'] ?? null) !== self::TYPE) { + return null; + } + + $rnd = $claims['rnd'] ?? null; + + return \is_string($rnd) && $rnd !== '' ? $rnd : null; + } + /** * Read the clearance TTL carried by a token, or null when it carries none. * The caller MUST have already verified the token. diff --git a/tests/Challenge/InteractiveTest.php b/tests/Challenge/InteractiveTest.php index 8be90d7..353dd34 100644 --- a/tests/Challenge/InteractiveTest.php +++ b/tests/Challenge/InteractiveTest.php @@ -19,12 +19,15 @@ private function context(string $ip = '203.0.113.9'): Context } /** - * Brute-force the carried PoW for an interactive token. + * Brute-force the carried PoW for an interactive token at a given offset. + * + * The offset is part of the PoW preimage (matching Interactive::verify), so a + * solution is bound to the exact offset it was solved for. */ - private function solvePow(string $token, int $difficulty, int $memory = 0): string + private function solvePow(string $token, int $offset, int $difficulty, int $memory = 0): string { for ($i = 0; $i < 5_000_000; $i++) { - if (Pow::meets($token . '.' . $i, $difficulty, $memory)) { + if (Pow::meets($token . '.' . $offset . '.' . $i, $difficulty, $memory)) { return (string) $i; } } @@ -39,7 +42,7 @@ public function testRoundTripAcceptsCorrectOffset(): void $gap = 120; $challenge = $interactive->issue($this->context(), $gap, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + $pow = $this->solvePow($challenge['token'], $challenge['gap'], $challenge['difficulty']); // The client reads the gap from the rendered image and drops the piece // there (the returned `gap` is the snapped truth the renderer would draw). @@ -52,11 +55,22 @@ public function testWrongOffsetIsRejected(): void $interactive = new Interactive($signer); $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); - // A different bucket (well outside the tolerance) must fail. - $this->assertFalse($interactive->verify($challenge['token'], 60, $pow, $this->context())); - $this->assertFalse($interactive->verify($challenge['token'], 200, $pow, $this->context())); + // A different bucket (well outside the tolerance) must fail — and with the + // PoW solved for *that* offset, so the rejection is the bucket check, not a + // failed proof. + $this->assertFalse($interactive->verify( + $challenge['token'], + 60, + $this->solvePow($challenge['token'], 60, $challenge['difficulty']), + $this->context(), + )); + $this->assertFalse($interactive->verify( + $challenge['token'], + 200, + $this->solvePow($challenge['token'], 200, $challenge['difficulty']), + $this->context(), + )); } public function testHumanDragToleranceWithinBucket(): void @@ -67,11 +81,12 @@ public function testHumanDragToleranceWithinBucket(): void // Gap snapped to a bucket boundary; an imprecise drop in the same bucket // still verifies. $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + $offset = $challenge['gap'] + Interactive::BUCKET_SIZE - 1; + $pow = $this->solvePow($challenge['token'], $offset, $challenge['difficulty']); $this->assertSame(0, $challenge['gap'] % Interactive::BUCKET_SIZE, 'gap is snapped to the grid'); $this->assertTrue( - $interactive->verify($challenge['token'], $challenge['gap'] + Interactive::BUCKET_SIZE - 1, $pow, $this->context()) + $interactive->verify($challenge['token'], $offset, $pow, $this->context()) ); } @@ -102,7 +117,7 @@ public function testForgedTokenFromAnotherSecretIsRejected(): void // The attacker mints a token with a gap they know and computes a matching // offset — but signs with their own secret, so the server rejects it. $forged = $attacker->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($forged['token'], $forged['difficulty']); + $pow = $this->solvePow($forged['token'], $forged['gap'], $forged['difficulty']); $this->assertFalse($server->verify($forged['token'], $forged['gap'], $pow, $this->context())); } @@ -125,8 +140,8 @@ public function testContextBinding(): void $interactive = new Interactive($signer); $challenge = $interactive->issue($this->context('203.0.113.9'), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); $gap = $challenge['gap']; + $pow = $this->solvePow($challenge['token'], $gap, $challenge['difficulty']); // Same /24 prefix passes; different project, audience, or IP prefix fails. $this->assertTrue($interactive->verify($challenge['token'], $gap, $pow, $this->context('203.0.113.200'))); @@ -141,7 +156,7 @@ public function testTamperedTokenIsRejected(): void $interactive = new Interactive($signer); $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + $pow = $this->solvePow($challenge['token'], $challenge['gap'], $challenge['difficulty']); $this->assertFalse($interactive->verify($challenge['token'] . 'x', $challenge['gap'], $pow, $this->context())); } @@ -155,7 +170,7 @@ public function testMemoryHardVariantRoundTrip(): void $this->assertSame(64, $challenge['memory']); $this->assertLessThanOrEqual(Issuer::MEMORY_DIFFICULTY_MAX, $challenge['difficulty']); - $pow = $this->solvePow($challenge['token'], $challenge['difficulty'], $challenge['memory']); + $pow = $this->solvePow($challenge['token'], $challenge['gap'], $challenge['difficulty'], $challenge['memory']); $this->assertTrue($interactive->verify($challenge['token'], $challenge['gap'], $pow, $this->context())); } @@ -164,12 +179,54 @@ public function testKeyRotationHonorsTokenKid(): void // Issue under kid 2, then rotate: kid 3 primary, kid 2 kept as previous. $issueSigner = new Signer(self::SECRET, 2); $challenge = (new Interactive($issueSigner))->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['difficulty']); + $pow = $this->solvePow($challenge['token'], $challenge['gap'], $challenge['difficulty']); $rotated = new Interactive(new Signer('new-secret', 3, [2 => self::SECRET])); $this->assertTrue($rotated->verify($challenge['token'], $challenge['gap'], $pow, $this->context())); } + public function testPowIsBoundToTheSubmittedOffset(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + $gap = $challenge['gap']; + + // A PoW solved for a *different* offset does not carry to the correct + // bucket: the offset is in the preimage, so one solve cannot be sprayed + // across the grid. (Solve for offset 0, submit at the true gap → rejected + // on the PoW check even though the bucket is right.) + $powForZero = $this->solvePow($challenge['token'], 0, $challenge['difficulty']); + $this->assertFalse($interactive->verify($challenge['token'], $gap, $powForZero, $this->context())); + + // The proof solved for the true offset does verify — sanity that the binding + // only rejects mismatched offsets, not the honest client. + $powForGap = $this->solvePow($challenge['token'], $gap, $challenge['difficulty']); + $this->assertTrue($interactive->verify($challenge['token'], $gap, $powForGap, $this->context())); + } + + public function testReferenceIdentifiesAuthenticTokensOnly(): void + { + $signer = new Signer(self::SECRET); + $interactive = new Interactive($signer); + + $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + + // An authentic token yields its stable nonce; the same token always maps to + // the same reference (so a counter keyed on it is stable across guesses). + $ref = $interactive->reference($challenge['token']); + $this->assertNotNull($ref); + $this->assertSame($ref, $interactive->reference($challenge['token'])); + + // A forged (other-secret) token, a tampered token, and a non-ichal token all + // yield null — a forged blob cannot seed or exhaust a real token's counter. + $forged = (new Interactive(new Signer('attacker-secret')))->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); + $this->assertNull($interactive->reference($forged['token'])); + $this->assertNull($interactive->reference($challenge['token'] . 'x')); + $this->assertNull($interactive->reference('not-a-token')); + } + public function testClearanceTtlRoundTrip(): void { $interactive = new Interactive(new Signer(self::SECRET)); From cb61d0b4c70b366ce20b953fdbcbe39a7ca378bb Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 4 Jul 2026 19:28:44 +0200 Subject: [PATCH 16/19] Note the attempt cap is best-effort under limiter failure The caller enforces MAX_ATTEMPTS via a fail-open limiter (availability posture for a humanity gate), so during a Redis/limiter outage the hard 3-strike ceiling softens and the floor degrades to the per-offset PoW alone -- still one real solve per guess, just without the cap. Document this in the docblock so "3 attempts" is not read as an absolute guarantee. Doc-only; no logic change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Challenge/Interactive.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Challenge/Interactive.php b/src/Challenge/Interactive.php index 8691d45..7ad67cd 100644 --- a/src/Challenge/Interactive.php +++ b/src/Challenge/Interactive.php @@ -58,6 +58,11 @@ final class Interactive * challenge rather than another free guess. Pairs with the per-offset PoW * binding in {@see verify()}, which also denies reusing one solved PoW across * buckets. + * + * Best-effort, not absolute: the caller's counter is expected to fail open + * (availability posture for a humanity gate), so during a limiter/Redis outage + * this ceiling softens and the floor degrades to the per-offset PoW alone — + * still one real solve per guess, just without the hard 3-strike cap. */ public const MAX_ATTEMPTS = 3; From 437583203ff38ff1fdca7c1a28b84479a94f3a90 Mon Sep 17 00:00:00 2001 From: premtsd Date: Thu, 9 Jul 2026 09:25:06 +0200 Subject: [PATCH 17/19] refactor: extract humanity gate into premtsd-code/captcha; require utopia-php/abuse Moves the challenge gate (Pow, Signer, Ip, Issuer, Verifier, Clearance, Interactive, Context) + Exception\Challenge out of this library into the premtsd-code/captcha leaf. Namespace stays Utopia\WAF\Challenge\* so consumers need no import changes (Composer routes the more-specific prefix to captcha). waf now requires utopia-php/abuse:dev-feat-captcha-facade, realizing waf -> abuse -> captcha. Scoring + Rules + Firewall remain here. 69 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 7 +- src/Challenge/Clearance.php | 70 ----- src/Challenge/Context.php | 24 -- src/Challenge/Interactive.php | 240 ----------------- src/Challenge/Ip.php | 43 --- src/Challenge/Issuer.php | 147 ----------- src/Challenge/Pow.php | 117 --------- src/Challenge/Signer.php | 162 ------------ src/Challenge/Verifier.php | 67 ----- src/Exception/Challenge.php | 7 - tests/Challenge/ChallengeTest.php | 390 ---------------------------- tests/Challenge/InteractiveTest.php | 240 ----------------- tests/Challenge/PowTest.php | 85 ------ 13 files changed, 6 insertions(+), 1593 deletions(-) delete mode 100644 src/Challenge/Clearance.php delete mode 100644 src/Challenge/Context.php delete mode 100644 src/Challenge/Interactive.php delete mode 100644 src/Challenge/Ip.php delete mode 100644 src/Challenge/Issuer.php delete mode 100644 src/Challenge/Pow.php delete mode 100644 src/Challenge/Signer.php delete mode 100644 src/Challenge/Verifier.php delete mode 100644 src/Exception/Challenge.php delete mode 100644 tests/Challenge/ChallengeTest.php delete mode 100644 tests/Challenge/InteractiveTest.php delete mode 100644 tests/Challenge/PowTest.php diff --git a/composer.json b/composer.json index 87936f0..f3f454e 100644 --- a/composer.json +++ b/composer.json @@ -3,9 +3,14 @@ "description": "Lite and extensible Web Application Firewall rules management library for the Utopia PHP ecosystem.", "type": "library", "license": "MIT", + "repositories": [ + {"type": "vcs", "url": "https://github.com/utopia-php/abuse", "no-api": true}, + {"type": "vcs", "url": "https://github.com/premtsd-code/captcha", "no-api": true} + ], "require": { "php": ">=8.2", - "utopia-php/validators": "0.2.*" + "utopia-php/validators": "0.2.*", + "utopia-php/abuse": "dev-feat-captcha-facade" }, "require-dev": { "laravel/pint": "^1.18", diff --git a/src/Challenge/Clearance.php b/src/Challenge/Clearance.php deleted file mode 100644 index f140d4b..0000000 --- a/src/Challenge/Clearance.php +++ /dev/null @@ -1,70 +0,0 @@ -signer->sign([ - 'typ' => 'clr', - 'ver' => 1, - 'pid' => $context->projectId, - 'aud' => $context->audience, - 'iph' => $this->signer->fingerprintIp($context->ip), - 'iat' => $issuedAt, - 'exp' => $issuedAt + $ttl, - ]); - } - - public function verify(string $token, Context $context): bool - { - if ($token === '') { - return false; - } - - $claims = $this->signer->parse($token); - if ($claims === null || ($claims['typ'] ?? null) !== 'clr') { - return false; - } - - $now = \time(); - $expiresAt = (int) ($claims['exp'] ?? 0); - $issuedAt = (int) ($claims['iat'] ?? PHP_INT_MAX); - if ($expiresAt < $now - self::LEEWAY || $issuedAt > $now + self::LEEWAY) { - return false; - } - - if (($claims['pid'] ?? null) !== $context->projectId || ($claims['aud'] ?? null) !== $context->audience) { - return false; - } - - $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; - $expectedIp = $this->signer->fingerprintIp($context->ip, $kid); - - return \hash_equals($expectedIp, (string) ($claims['iph'] ?? '')); - } -} diff --git a/src/Challenge/Context.php b/src/Challenge/Context.php deleted file mode 100644 index 21f02cb..0000000 --- a/src/Challenge/Context.php +++ /dev/null @@ -1,24 +0,0 @@ -bucket($gap) * self::BUCKET_SIZE; - - $memory = $memory > 0 ? \max(Issuer::MEMORY_MIN, \min(Issuer::MEMORY_MAX, $memory)) : 0; - $difficulty = $memory > 0 - ? Issuer::toMemoryDifficulty($difficulty) - : \max(Issuer::DIFFICULTY_MIN, \min(Issuer::DIFFICULTY_MAX, $difficulty)); - - $issuedAt = \time(); - $expiresAt = $issuedAt + self::NONCE_TTL; - $rnd = \bin2hex(\random_bytes(8)); - - $claims = [ - 'typ' => self::TYPE, - 'ver' => 1, - 'pid' => $context->projectId, - 'aud' => $context->audience, - 'iph' => $this->signer->fingerprintIp($context->ip), - 'dif' => $difficulty, - 'iat' => $issuedAt, - 'exp' => $expiresAt, - 'rnd' => $rnd, - // The answer, one-way: HMAC(secret, rnd . bucket(gap)). Public in the - // token, but unrecomputable without the secret — this is what makes the - // pass unforgeable. - 'sol' => $this->signer->mac($rnd . '.' . $this->bucket($gap)), - ]; - - if ($memory > 0) { - $claims['mem'] = $memory; - } - - if ($clearanceTtl !== null) { - $claims['ctl'] = $clearanceTtl; - } - - $token = $this->signer->sign($claims); - - return [ - 'token' => $token, - 'gap' => $gap, - 'difficulty' => $difficulty, - 'memory' => $memory, - 'expiresAt' => $expiresAt, - 'expiresIn' => self::NONCE_TTL, - ]; - } - - /** - * Accept the challenge only when the token is authentic, unexpired, bound to - * the same context, its carried PoW is solved, AND the submitted offset falls - * in the secret gap's bucket — checked against the token's HMAC so the server - * never re-learns the gap. All stateless. - */ - public function verify(string $token, int $offset, string $powSolution, Context $context): bool - { - if ($powSolution === '' || \strlen($powSolution) > self::SOLUTION_MAX_LENGTH) { - return false; - } - - $claims = $this->signer->parse($token); - if ($claims === null || ($claims['typ'] ?? null) !== self::TYPE) { - return false; - } - - $now = \time(); - $expiresAt = (int) ($claims['exp'] ?? 0); - $issuedAt = (int) ($claims['iat'] ?? PHP_INT_MAX); - if ($expiresAt < $now - self::LEEWAY || $issuedAt > $now + self::LEEWAY) { - return false; - } - - if (($claims['pid'] ?? null) !== $context->projectId || ($claims['aud'] ?? null) !== $context->audience) { - return false; - } - - $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; - if (!\hash_equals($this->signer->fingerprintIp($context->ip, $kid), (string) ($claims['iph'] ?? ''))) { - return false; - } - - // The carried proof of work must be solved first: it is what makes each - // guess against the coarse bucket grid cost CPU. The submitted offset is - // part of the PoW preimage, so a solution is valid for exactly the offset - // it was solved for — one solved PoW cannot be replayed across buckets to - // sweep the grid for the price of a single solve. - $difficulty = (int) ($claims['dif'] ?? 0); - if ($difficulty <= 0) { - return false; - } - $memory = (int) ($claims['mem'] ?? 0); - if ($memory > 0) { - $memory = \min($memory, Issuer::MEMORY_MAX); - } - if (!Pow::meets($token . '.' . $offset . '.' . $powSolution, $difficulty, $memory)) { - return false; - } - - // The submitted offset must reproduce the answer hash. hash_equals against - // the token's `sol`; the gap itself is never recovered server-side. - $expected = (string) ($claims['sol'] ?? ''); - if ($expected === '') { - return false; - } - $actual = $this->signer->mac((string) ($claims['rnd'] ?? '') . '.' . $this->bucket($offset), $kid); - - return \hash_equals($expected, $actual); - } - - /** - * A stable, opaque reference for an *authentic* interactive token — its random - * nonce — for a stateful caller that must cap guesses per issued image - * ({@see MAX_ATTEMPTS}). Returns null when the token is not signed by a known - * key, so a forged or garbage blob cannot seed (or exhaust) a real token's - * counter. Deliberately does NOT check expiry or context: it is only an - * identity for the counter, never an acceptance — {@see verify()} still decides. - */ - public function reference(string $token): ?string - { - $claims = $this->signer->parse($token); - if ($claims === null || ($claims['typ'] ?? null) !== self::TYPE) { - return null; - } - - $rnd = $claims['rnd'] ?? null; - - return \is_string($rnd) && $rnd !== '' ? $rnd : null; - } - - /** - * Read the clearance TTL carried by a token, or null when it carries none. - * The caller MUST have already verified the token. - */ - public function clearanceTtl(string $token): ?int - { - $claims = $this->signer->parse($token); - if ($claims === null || !isset($claims['ctl']) || !\is_int($claims['ctl'])) { - return null; - } - - return $claims['ctl']; - } - - /** - * Quantize a horizontal offset to the bucket grid (the human-drag tolerance). - */ - public function bucket(int $offset): int - { - return \intdiv(\max(0, $offset), self::BUCKET_SIZE); - } -} diff --git a/src/Challenge/Ip.php b/src/Challenge/Ip.php deleted file mode 100644 index a1af85f..0000000 --- a/src/Challenge/Ip.php +++ /dev/null @@ -1,43 +0,0 @@ - 0 ? \max(self::MEMORY_MIN, \min(self::MEMORY_MAX, $memory)) : 0; - - $difficulty = $memory > 0 - ? self::toMemoryDifficulty($difficulty) - : \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); - - $issuedAt = \time(); - $expiresAt = $issuedAt + self::NONCE_TTL; - - $claims = [ - 'typ' => 'challenge', - 'ver' => 1, - 'pid' => $context->projectId, - 'aud' => $context->audience, - 'iph' => $this->signer->fingerprintIp($context->ip), - 'dif' => $difficulty, - 'iat' => $issuedAt, - 'exp' => $expiresAt, - 'rnd' => \bin2hex(\random_bytes(16)), - ]; - - if ($memory > 0) { - $claims['mem'] = $memory; - } - - if ($clearanceTtl !== null) { - $claims['ctl'] = $clearanceTtl; - } - - $nonce = $this->signer->sign($claims); - - return [ - 'nonce' => $nonce, - 'difficulty' => $difficulty, - 'algorithm' => Pow::algorithm($memory), - 'memory' => $memory, - 'expiresAt' => $expiresAt, - 'expiresIn' => self::NONCE_TTL, - ]; - } - - /** - * Map a CPU-band difficulty onto the memory-hard band, preserving scale. - * - * Callers store and pass difficulty in the CPU band [DIFFICULTY_MIN, - * DIFFICULTY_MAX] — the rule config validator and the adaptive bump both live - * there. A memory-hard attempt is far more expensive than a bare SHA-256 one, - * so the same knob has to land in the much lower memory band. Translating - * proportionally (16→4, 20→8, 24→12) keeps the rule's difficulty setting and - * the adaptive ladder meaningful; a naive re-clamp would collapse every - * CPU-band input to the memory-band maximum, i.e. the slowest possible solve. - * - * Public so the interactive challenge ({@see Interactive}), which carries its - * own PoW, scales difficulty identically. - */ - public static function toMemoryDifficulty(int $difficulty): int - { - $cpu = \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); - - $cpuSpan = self::DIFFICULTY_MAX - self::DIFFICULTY_MIN; - $memorySpan = self::MEMORY_DIFFICULTY_MAX - self::MEMORY_DIFFICULTY_MIN; - $fraction = ($cpu - self::DIFFICULTY_MIN) / $cpuSpan; - - return self::MEMORY_DIFFICULTY_MIN + (int) \round($fraction * $memorySpan); - } - - /** - * Read the clearance TTL carried by a nonce, or null when it carries none. - * - * The caller MUST have already verified the nonce (see Verifier); this only - * decodes the signed claims and does not re-check authenticity or expiry. - */ - public function clearanceTtl(string $nonce): ?int - { - $claims = $this->signer->parse($nonce); - if ($claims === null || !isset($claims['ctl']) || !\is_int($claims['ctl'])) { - return null; - } - - return $claims['ctl']; - } -} diff --git a/src/Challenge/Pow.php b/src/Challenge/Pow.php deleted file mode 100644 index fb885ea..0000000 --- a/src/Challenge/Pow.php +++ /dev/null @@ -1,117 +0,0 @@ - 0` — a sequential **memory-hard** hash ({@see self::romix()}). Each - * solution attempt must fill and then randomly read a `memory`-block scratchpad - * (`memory * 32` bytes), so throughput is bounded by memory bandwidth, not core - * count. This narrows the gap between a browser solving one challenge and a - * botnet farm solving millions — the whole point of raising the cost floor. - * - * The function is defined byte-for-byte so the pure-JS solver (see - * `reference/solver.js`, inlined into {@see Interstitial}) and this PHP verifier - * agree exactly. It is intentionally built from SHA-256 alone (no Argon2/scrypt - * dependency) so the browser side stays a dependency-free, CSP-inlinable script. - */ -final class Pow -{ - public const ALGORITHM_SHA256 = 'sha256'; - - public const ALGORITHM_ROMIX = 'sha256-romix'; - - /** - * The algorithm label advertised to the client for the given memory setting. - */ - public static function algorithm(int $memory): string - { - return $memory > 0 ? self::ALGORITHM_ROMIX : self::ALGORITHM_SHA256; - } - - /** - * Raw (binary) proof-of-work digest of `$input` under the given memory cost. - */ - public static function digest(string $input, int $memory): string - { - if ($memory <= 0) { - return \hash(self::ALGORITHM_SHA256, $input, true); - } - - return self::romix($input, $memory); - } - - /** - * Whether `$input` is a valid solution at `$difficulty` leading zero bits. - */ - public static function meets(string $input, int $difficulty, int $memory): bool - { - return self::leadingZeroBits(self::digest($input, $memory)) >= $difficulty; - } - - /** - * Sequential memory-hard hash (a SHA-256 ROMix, in the scrypt lineage). - * - * 1. Fill a scratchpad of `$memory` 32-byte blocks by iterated hashing — - * `V[0] = sha256(input)`, `V[i] = sha256(V[i-1])`. - * 2. Mix for `$memory` rounds with **data-dependent** reads: the next index is - * derived from the current accumulator, so the whole scratchpad must stay - * resident (you cannot recompute a block on demand without redoing the - * chain). That data dependency is what makes it memory-hard rather than - * merely memory-using. - * - * @param int $memory number of 32-byte scratchpad blocks (> 0) - */ - public static function romix(string $input, int $memory): string - { - $v = []; - $v[0] = \hash(self::ALGORITHM_SHA256, $input, true); - for ($i = 1; $i < $memory; $i++) { - $v[$i] = \hash(self::ALGORITHM_SHA256, $v[$i - 1], true); - } - - $x = $v[$memory - 1]; - for ($i = 0; $i < $memory; $i++) { - // Big-endian uint32 of the first 4 bytes, mod scratchpad size. - $j = (\ord($x[0]) << 24 | \ord($x[1]) << 16 | \ord($x[2]) << 8 | \ord($x[3])) % $memory; - $x = \hash(self::ALGORITHM_SHA256, $x ^ $v[$j], true); - } - - return $x; - } - - /** - * Count leading zero bits across the raw (binary) digest. - */ - public static function leadingZeroBits(string $digest): int - { - $bits = 0; - $length = \strlen($digest); - - for ($i = 0; $i < $length; $i++) { - $byte = \ord($digest[$i]); - - if ($byte === 0) { - $bits += 8; - - continue; - } - - for ($mask = 0x80; $mask > 0; $mask >>= 1) { - if (($byte & $mask) !== 0) { - return $bits; - } - - $bits++; - } - } - - return $bits; - } -} diff --git a/src/Challenge/Signer.php b/src/Challenge/Signer.php deleted file mode 100644 index 2c86087..0000000 --- a/src/Challenge/Signer.php +++ /dev/null @@ -1,162 +0,0 @@ - kid => secret, primary first - */ - private array $keyset; - - /** - * @param string $secret current signing secret - * @param int $kid identifier for the current secret - * @param array $previousSecrets superseded secrets kept for the rotation window, keyed by their kid - */ - public function __construct( - #[\SensitiveParameter] private readonly string $secret, - private readonly int $kid = 1, - #[\SensitiveParameter] array $previousSecrets = [], - ) { - if ($secret === '') { - throw new ChallengeException('Challenge signing secret must not be empty.'); - } - - foreach ($previousSecrets as $previousKid => $previousSecret) { - if ($previousSecret === '') { - throw new ChallengeException('Previous challenge secret for kid ' . $previousKid . ' must not be empty.'); - } - } - - $this->keyset = [$kid => $secret] + $previousSecrets; - } - - /** - * Sign a claims set, stamping it with the current key id. - * - * @param array $claims - */ - public function sign(array $claims): string - { - $claims['kid'] = $this->kid; - - try { - $json = \json_encode($claims, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); - } catch (\JsonException $exception) { - throw new ChallengeException('Unable to encode challenge claims: ' . $exception->getMessage()); - } - - $payload = self::encode($json); - $signature = self::encode($this->hmac($payload, $this->secret)); - - return $payload . '.' . $signature; - } - - /** - * Verify a token's signature and return its claims, or null if the token is - * malformed, signed with an unknown key, or tampered with. - * - * @return array|null - */ - public function parse(string $token): ?array - { - $parts = \explode('.', $token); - if (\count($parts) !== 2) { - return null; - } - - [$payload, $signature] = $parts; - if ($payload === '' || $signature === '') { - return null; - } - - $decoded = self::decode($payload); - if ($decoded === null) { - return null; - } - - $claims = \json_decode($decoded, true); - if (!\is_array($claims)) { - return null; - } - - $kid = $claims['kid'] ?? null; - if (!\is_int($kid) || !isset($this->keyset[$kid])) { - return null; - } - - $expected = self::encode($this->hmac($payload, $this->keyset[$kid])); - if (!\hash_equals($expected, $signature)) { - return null; - } - - return $claims; - } - - /** - * Derive a stable, privacy-preserving fingerprint of an IP's network prefix. - * - * Keyed with the signing secret so the value is meaningless in logs and - * cannot be reversed to an address. Pass the token's own kid at verify time - * so rotation does not invalidate the binding of already-issued tokens. - */ - public function fingerprintIp(string $ip, ?int $kid = null): string - { - $kid ??= $this->kid; - if (!isset($this->keyset[$kid])) { - throw new ChallengeException('Unknown key id: ' . $kid); - } - - return \substr(\hash_hmac('sha256', Ip::prefix($ip), $this->keyset[$kid]), 0, 16); - } - - /** - * Keyed one-way hash of an arbitrary message. - * - * Used to bake a secret answer into a public token without revealing it: the - * client sees the MAC but, lacking the signing secret, cannot recompute it for - * a guessed answer — it must submit each guess to the server. Like - * {@see self::fingerprintIp()} it honours key rotation via the token's own kid, - * so a rotation does not invalidate answers in already-issued tokens. - */ - public function mac(string $message, ?int $kid = null): string - { - $kid ??= $this->kid; - if (!isset($this->keyset[$kid])) { - throw new ChallengeException('Unknown key id: ' . $kid); - } - - return \substr(\hash_hmac('sha256', $message, $this->keyset[$kid]), 0, 32); - } - - private function hmac(string $payload, string $secret): string - { - return \hash_hmac('sha256', $payload, $secret, true); - } - - private static function encode(string $binary): string - { - return \rtrim(\strtr(\base64_encode($binary), '+/', '-_'), '='); - } - - private static function decode(string $value): ?string - { - $decoded = \base64_decode(\strtr($value, '-_', '+/'), true); - - return $decoded === false ? null : $decoded; - } -} diff --git a/src/Challenge/Verifier.php b/src/Challenge/Verifier.php deleted file mode 100644 index 68960f4..0000000 --- a/src/Challenge/Verifier.php +++ /dev/null @@ -1,67 +0,0 @@ - self::SOLUTION_MAX_LENGTH) { - return false; - } - - $claims = $this->signer->parse($nonce); - if ($claims === null || ($claims['typ'] ?? null) !== 'challenge') { - return false; - } - - $now = \time(); - $expiresAt = (int) ($claims['exp'] ?? 0); - $issuedAt = (int) ($claims['iat'] ?? PHP_INT_MAX); - if ($expiresAt < $now - self::LEEWAY || $issuedAt > $now + self::LEEWAY) { - return false; - } - - if (($claims['pid'] ?? null) !== $context->projectId || ($claims['aud'] ?? null) !== $context->audience) { - return false; - } - - $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; - $expectedIp = $this->signer->fingerprintIp($context->ip, $kid); - if (!\hash_equals($expectedIp, (string) ($claims['iph'] ?? ''))) { - return false; - } - - $difficulty = (int) ($claims['dif'] ?? 0); - if ($difficulty <= 0) { - return false; - } - - // Memory-hard cost, when the nonce carries one. The nonce is HMAC-signed, - // so `mem` cannot be forged; the clamp is a defensive bound on verify work. - $memory = (int) ($claims['mem'] ?? 0); - if ($memory > 0) { - $memory = \min($memory, Issuer::MEMORY_MAX); - } - - return Pow::meets($nonce . '.' . $solution, $difficulty, $memory); - } -} diff --git a/src/Exception/Challenge.php b/src/Exception/Challenge.php deleted file mode 100644 index 7a56fa3..0000000 --- a/src/Exception/Challenge.php +++ /dev/null @@ -1,7 +0,0 @@ -leadingZeroBits(\hash('sha256', $nonce . '.' . $solution, true)) >= $difficulty) { - return $solution; - } - } - - $this->fail('Could not find a solution for difficulty ' . $difficulty); - } - - private function leadingZeroBits(string $digest): int - { - $bits = 0; - foreach (\str_split($digest) as $char) { - $byte = \ord($char); - if ($byte === 0) { - $bits += 8; - - continue; - } - for ($mask = 0x80; $mask > 0; $mask >>= 1) { - if (($byte & $mask) !== 0) { - return $bits; - } - $bits++; - } - break; - } - - return $bits; - } - - public function testSignerRejectsEmptySecret(): void - { - $this->expectException(ChallengeException::class); - new Signer(''); - } - - public function testSignerRejectsEmptyPreviousSecret(): void - { - $this->expectException(ChallengeException::class); - new Signer(self::SECRET, kid: 2, previousSecrets: [1 => '']); - } - - public function testFingerprintIpRejectsUnknownKid(): void - { - $signer = new Signer(self::SECRET, kid: 1); - - $this->expectException(ChallengeException::class); - $signer->fingerprintIp('203.0.113.9', 9); - } - - public function testSignParseRoundTrip(): void - { - $signer = new Signer(self::SECRET); - $token = $signer->sign(['typ' => 'clr', 'foo' => 'bar']); - - $claims = $signer->parse($token); - $this->assertIsArray($claims); - $this->assertSame('clr', $claims['typ']); - $this->assertSame('bar', $claims['foo']); - $this->assertSame(1, $claims['kid']); - } - - public function testParseRejectsTamperedPayload(): void - { - $signer = new Signer(self::SECRET); - $token = $signer->sign(['typ' => 'clr']); - - [$payload, $signature] = \explode('.', $token); - $forged = \rtrim(\strtr(\base64_encode('{"typ":"clr","kid":1,"admin":true}'), '+/', '-_'), '='); - - $this->assertNull($signer->parse($forged . '.' . $signature)); - } - - public function testParseRejectsMalformedTokens(): void - { - $signer = new Signer(self::SECRET); - - $this->assertNull($signer->parse('')); - $this->assertNull($signer->parse('no-dot')); - $this->assertNull($signer->parse('a.b.c')); - $this->assertNull($signer->parse('.sig')); - $this->assertNull($signer->parse('payload.')); - } - - public function testParseRejectsForeignSecret(): void - { - $minted = new Signer(self::SECRET); - $other = new Signer('a-completely-different-secret'); - - $token = $minted->sign(['typ' => 'clr']); - $this->assertNull($other->parse($token)); - } - - public function testKeyRotationAcceptsPreviousSecret(): void - { - $old = new Signer('old-secret', kid: 1); - $token = $old->sign(['typ' => 'clr', 'v' => 1]); - - // New primary key, old key retained for the rotation window. - $rotated = new Signer('new-secret', kid: 2, previousSecrets: [1 => 'old-secret']); - - $claims = $rotated->parse($token); - $this->assertIsArray($claims); - $this->assertSame(1, $claims['kid']); - - // Once the old key is dropped, the token no longer verifies. - $droppedOld = new Signer('new-secret', kid: 2); - $this->assertNull($droppedOld->parse($token)); - } - - public function testIssueClampsDifficulty(): void - { - $issuer = new Issuer(new Signer(self::SECRET)); - - $this->assertSame(Issuer::DIFFICULTY_MIN, $issuer->issue($this->context(), 1)['difficulty']); - $this->assertSame(Issuer::DIFFICULTY_MAX, $issuer->issue($this->context(), 999)['difficulty']); - } - - public function testChallengeRoundTrip(): void - { - $signer = new Signer(self::SECRET); - $issuer = new Issuer($signer); - $verifier = new Verifier($signer); - - // Drive the real Issuer -> Verifier path at the clamped minimum - // difficulty (2^16 ~ 65k hashes, well under a second). - $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); - $this->assertSame(Issuer::DIFFICULTY_MIN, $challenge['difficulty']); - $this->assertSame(Issuer::ALGORITHM, $challenge['algorithm']); - $this->assertSame(Issuer::NONCE_TTL, $challenge['expiresIn']); - $this->assertSame($challenge['expiresIn'], $challenge['expiresAt'] - \time()); - - $solution = $this->solve($challenge['nonce'], $challenge['difficulty']); - - $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); - } - - public function testMemoryHardChallengeRoundTrip(): void - { - $signer = new Signer(self::SECRET); - $issuer = new Issuer($signer); - $verifier = new Verifier($signer); - - // A memory-hard challenge advertises the romix algorithm, carries the - // memory cost in the (signed) nonce, and clamps the difficulty into the - // lower memory-mode band even though a classic-range value was requested. - $memory = 64; // small scratchpad keeps the test fast - $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT, null, $memory); - - $this->assertSame(\Utopia\WAF\Challenge\Pow::ALGORITHM_ROMIX, $challenge['algorithm']); - $this->assertSame($memory, $challenge['memory']); - $this->assertLessThanOrEqual(Issuer::MEMORY_DIFFICULTY_MAX, $challenge['difficulty']); - $this->assertGreaterThanOrEqual(Issuer::MEMORY_DIFFICULTY_MIN, $challenge['difficulty']); - - // Solve with the memory-aware digest, then verify through the real path. - $solution = $this->solveMemoryHard($challenge['nonce'], $challenge['difficulty'], $memory); - $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); - - // The memory-hard and classic digests of the same input are independent - // functions, so the mode baked into the (signed) nonce cannot be downgraded. - $this->assertNotSame( - \Utopia\WAF\Challenge\Pow::digest($challenge['nonce'] . '.' . $solution, $memory), - \Utopia\WAF\Challenge\Pow::digest($challenge['nonce'] . '.' . $solution, 0), - ); - } - - public function testMemoryDifficultyTranslatesFromCpuBand(): void - { - $issuer = new Issuer(new Signer(self::SECRET)); - - // A CPU-band difficulty is mapped proportionally onto the memory band - // instead of being re-clamped to the band maximum, so the rule knob and - // the adaptive ladder keep their effect. Band edges and midpoint: - $cases = [ - Issuer::DIFFICULTY_MIN => Issuer::MEMORY_DIFFICULTY_MIN, // 16 -> 4 - 20 => 8, // midpoint - Issuer::DIFFICULTY_MAX => Issuer::MEMORY_DIFFICULTY_MAX, // 24 -> 12 - ]; - - foreach ($cases as $cpu => $expected) { - $challenge = $issuer->issue($this->context(), $cpu, null, 256); - $this->assertSame($expected, $challenge['difficulty'], "cpu {$cpu} should map to {$expected}"); - } - - // The default difficulty must not pin to the band maximum. - $default = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT, null, 256); - $this->assertLessThan(Issuer::MEMORY_DIFFICULTY_MAX, $default['difficulty']); - } - - /** - * Brute-force a memory-hard solution (romix digest) for the given nonce. - */ - private function solveMemoryHard(string $nonce, int $difficulty, int $memory): string - { - for ($i = 0; $i < 5_000_000; $i++) { - $solution = (string) $i; - if (\Utopia\WAF\Challenge\Pow::meets($nonce . '.' . $solution, $difficulty, $memory)) { - return $solution; - } - } - - $this->fail('Could not find a memory-hard solution for difficulty ' . $difficulty); - } - - public function testNonceCarriesClearanceTtlRoundTrip(): void - { - $signer = new Signer(self::SECRET); - $issuer = new Issuer($signer); - - // No ttl requested -> nonce carries none. - $plain = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); - $this->assertNull($issuer->clearanceTtl($plain['nonce'])); - - // Requested ttl is carried verbatim inside the signed nonce so the solve - // endpoint can honour the rule's configured lifetime. - $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN, 1800); - $this->assertSame(1800, $issuer->clearanceTtl($challenge['nonce'])); - - // A tampered nonce yields no ttl (signature no longer parses). - $this->assertNull($issuer->clearanceTtl($challenge['nonce'] . 'x')); - } - - public function testVerifyRejectsInsufficientWork(): void - { - $signer = new Signer(self::SECRET); - $issuer = new Issuer($signer); - $verifier = new Verifier($signer); - - // Real difficulty (2^16); a trivial solution will not meet it. - $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT); - $this->assertFalse($verifier->verify($challenge['nonce'], '0', $this->context())); - } - - public function testVerifyRejectsOversizedAndEmptySolution(): void - { - $signer = new Signer(self::SECRET); - $verifier = new Verifier($signer); - $nonce = (new Issuer($signer))->issue($this->context(), Issuer::DIFFICULTY_MIN)['nonce']; - - $this->assertFalse($verifier->verify($nonce, '', $this->context())); - $this->assertFalse($verifier->verify($nonce, \str_repeat('a', Verifier::SOLUTION_MAX_LENGTH + 1), $this->context())); - } - - public function testVerifyRejectsExpiredNonce(): void - { - $signer = new Signer(self::SECRET); - $verifier = new Verifier($signer); - - // Hand-craft an already-expired nonce at difficulty 1 (trivially solvable). - $nonce = $signer->sign([ - 'typ' => 'challenge', - 'pid' => 'proj-123', - 'aud' => 'api', - 'iph' => $signer->fingerprintIp('203.0.113.9'), - 'dif' => 1, - 'iat' => \time() - 1000, - 'exp' => \time() - 500, - ]); - $solution = $this->solve($nonce, 1); - - $this->assertFalse($verifier->verify($nonce, $solution, $this->context())); - } - - public function testVerifyRejectsWrongContext(): void - { - $signer = new Signer(self::SECRET); - $verifier = new Verifier($signer); - - $nonce = $signer->sign([ - 'typ' => 'challenge', - 'pid' => 'proj-123', - 'aud' => 'api', - 'iph' => $signer->fingerprintIp('203.0.113.9'), - 'dif' => 1, - 'iat' => \time(), - 'exp' => \time() + 120, - ]); - $solution = $this->solve($nonce, 1); - - $this->assertFalse($verifier->verify($nonce, $solution, new Context('other-project', 'api', '203.0.113.9'))); - $this->assertFalse($verifier->verify($nonce, $solution, new Context('proj-123', 'mysite.example', '203.0.113.9'))); - // Different /24 network. - $this->assertFalse($verifier->verify($nonce, $solution, new Context('proj-123', 'api', '198.51.100.9'))); - // Same /24 still passes. - $this->assertTrue($verifier->verify($nonce, $solution, new Context('proj-123', 'api', '203.0.113.200'))); - } - - public function testClearanceRoundTrip(): void - { - $signer = new Signer(self::SECRET); - $clearance = new Clearance($signer); - - $token = $clearance->issue($this->context()); - $this->assertTrue($clearance->verify($token, $this->context())); - } - - public function testClearanceClampsTtl(): void - { - $signer = new Signer(self::SECRET); - $clearance = new Clearance($signer); - - $token = $clearance->issue($this->context(), 5); - $claims = $signer->parse($token); - $this->assertIsArray($claims); - $this->assertSame(Clearance::TTL_MIN, $claims['exp'] - $claims['iat']); - } - - public function testClearanceRejectsExpired(): void - { - $signer = new Signer(self::SECRET); - $clearance = new Clearance($signer); - - $token = $signer->sign([ - 'typ' => 'clr', - 'pid' => 'proj-123', - 'aud' => 'api', - 'iph' => $signer->fingerprintIp('203.0.113.9'), - 'iat' => \time() - 10000, - 'exp' => \time() - 9000, - ]); - - $this->assertFalse($clearance->verify($token, $this->context())); - } - - public function testClearanceRejectsCrossType(): void - { - $signer = new Signer(self::SECRET); - $clearance = new Clearance($signer); - $verifier = new Verifier($signer); - - // A clearance token must not be accepted as a challenge nonce, and vice versa. - $clr = $clearance->issue($this->context()); - $this->assertFalse($verifier->verify($clr, '0', $this->context())); - - $challengeNonce = (new Issuer($signer))->issue($this->context())['nonce']; - $this->assertFalse($clearance->verify($challengeNonce, $this->context())); - } - - public function testClearanceRejectsWrongNetwork(): void - { - $signer = new Signer(self::SECRET); - $clearance = new Clearance($signer); - - $token = $clearance->issue($this->context('203.0.113.9')); - - $this->assertTrue($clearance->verify($token, $this->context('203.0.113.77'))); - $this->assertFalse($clearance->verify($token, $this->context('198.51.100.77'))); - } - - public function testIpPrefix(): void - { - $this->assertSame(Ip::prefix('203.0.113.9'), Ip::prefix('203.0.113.250')); - $this->assertNotSame(Ip::prefix('203.0.113.9'), Ip::prefix('203.0.114.9')); - - $this->assertSame(Ip::prefix('2001:db8:abcd:1234::1'), Ip::prefix('2001:db8:abcd:1234:ffff::9')); - $this->assertNotSame(Ip::prefix('2001:db8:abcd:1234::1'), Ip::prefix('2001:db8:abcd:9999::1')); - - // Non-address input binds to itself rather than throwing. - $this->assertSame('not-an-ip', Ip::prefix('not-an-ip')); - } -} diff --git a/tests/Challenge/InteractiveTest.php b/tests/Challenge/InteractiveTest.php deleted file mode 100644 index 353dd34..0000000 --- a/tests/Challenge/InteractiveTest.php +++ /dev/null @@ -1,240 +0,0 @@ -fail('Could not solve the carried PoW'); - } - - public function testRoundTripAcceptsCorrectOffset(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $gap = 120; - $challenge = $interactive->issue($this->context(), $gap, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['gap'], $challenge['difficulty']); - - // The client reads the gap from the rendered image and drops the piece - // there (the returned `gap` is the snapped truth the renderer would draw). - $this->assertTrue($interactive->verify($challenge['token'], $challenge['gap'], $pow, $this->context())); - } - - public function testWrongOffsetIsRejected(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - - // A different bucket (well outside the tolerance) must fail — and with the - // PoW solved for *that* offset, so the rejection is the bucket check, not a - // failed proof. - $this->assertFalse($interactive->verify( - $challenge['token'], - 60, - $this->solvePow($challenge['token'], 60, $challenge['difficulty']), - $this->context(), - )); - $this->assertFalse($interactive->verify( - $challenge['token'], - 200, - $this->solvePow($challenge['token'], 200, $challenge['difficulty']), - $this->context(), - )); - } - - public function testHumanDragToleranceWithinBucket(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - // Gap snapped to a bucket boundary; an imprecise drop in the same bucket - // still verifies. - $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $offset = $challenge['gap'] + Interactive::BUCKET_SIZE - 1; - $pow = $this->solvePow($challenge['token'], $offset, $challenge['difficulty']); - - $this->assertSame(0, $challenge['gap'] % Interactive::BUCKET_SIZE, 'gap is snapped to the grid'); - $this->assertTrue( - $interactive->verify($challenge['token'], $offset, $pow, $this->context()) - ); - } - - public function testTokenNeverCarriesTheGapInPlaintext(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $gap = 120; - $challenge = $interactive->issue($this->context(), $gap, Issuer::DIFFICULTY_MIN); - - // Decode the (public) claims and confirm the gap is not present — only its - // one-way HMAC is. This is the whole anti-forgery property. - $payload = \explode('.', $challenge['token'])[0]; - $claims = \json_decode(\base64_decode(\strtr($payload, '-_', '+/')), true); - - $this->assertArrayNotHasKey('gap', $claims); - $this->assertArrayNotHasKey('x', $claims); - $this->assertStringNotContainsString((string) $gap, \json_encode(\array_diff_key($claims, ['iat' => 0, 'exp' => 0]))); - $this->assertSame(32, \strlen((string) $claims['sol']), 'sol is a fixed-length MAC, not the answer'); - } - - public function testForgedTokenFromAnotherSecretIsRejected(): void - { - $server = new Interactive(new Signer(self::SECRET)); - $attacker = new Interactive(new Signer('attacker-secret')); - - // The attacker mints a token with a gap they know and computes a matching - // offset — but signs with their own secret, so the server rejects it. - $forged = $attacker->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($forged['token'], $forged['gap'], $forged['difficulty']); - - $this->assertFalse($server->verify($forged['token'], $forged['gap'], $pow, $this->context())); - } - - public function testWrongPowIsRejected(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_DEFAULT); - - // Correct offset but the PoW is not solved: the guess must still cost work. - $this->assertFalse($interactive->verify($challenge['token'], $challenge['gap'], '0', $this->context())); - $this->assertFalse($interactive->verify($challenge['token'], $challenge['gap'], '', $this->context())); - } - - public function testContextBinding(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $challenge = $interactive->issue($this->context('203.0.113.9'), 120, Issuer::DIFFICULTY_MIN); - $gap = $challenge['gap']; - $pow = $this->solvePow($challenge['token'], $gap, $challenge['difficulty']); - - // Same /24 prefix passes; different project, audience, or IP prefix fails. - $this->assertTrue($interactive->verify($challenge['token'], $gap, $pow, $this->context('203.0.113.200'))); - $this->assertFalse($interactive->verify($challenge['token'], $gap, $pow, new Context('other', 'mysite.example', '203.0.113.9'))); - $this->assertFalse($interactive->verify($challenge['token'], $gap, $pow, new Context('proj-123', 'other.example', '203.0.113.9'))); - $this->assertFalse($interactive->verify($challenge['token'], $gap, $pow, $this->context('198.51.100.9'))); - } - - public function testTamperedTokenIsRejected(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['gap'], $challenge['difficulty']); - - $this->assertFalse($interactive->verify($challenge['token'] . 'x', $challenge['gap'], $pow, $this->context())); - } - - public function testMemoryHardVariantRoundTrip(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_DEFAULT, 64); - $this->assertSame(64, $challenge['memory']); - $this->assertLessThanOrEqual(Issuer::MEMORY_DIFFICULTY_MAX, $challenge['difficulty']); - - $pow = $this->solvePow($challenge['token'], $challenge['gap'], $challenge['difficulty'], $challenge['memory']); - $this->assertTrue($interactive->verify($challenge['token'], $challenge['gap'], $pow, $this->context())); - } - - public function testKeyRotationHonorsTokenKid(): void - { - // Issue under kid 2, then rotate: kid 3 primary, kid 2 kept as previous. - $issueSigner = new Signer(self::SECRET, 2); - $challenge = (new Interactive($issueSigner))->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $pow = $this->solvePow($challenge['token'], $challenge['gap'], $challenge['difficulty']); - - $rotated = new Interactive(new Signer('new-secret', 3, [2 => self::SECRET])); - $this->assertTrue($rotated->verify($challenge['token'], $challenge['gap'], $pow, $this->context())); - } - - public function testPowIsBoundToTheSubmittedOffset(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $gap = $challenge['gap']; - - // A PoW solved for a *different* offset does not carry to the correct - // bucket: the offset is in the preimage, so one solve cannot be sprayed - // across the grid. (Solve for offset 0, submit at the true gap → rejected - // on the PoW check even though the bucket is right.) - $powForZero = $this->solvePow($challenge['token'], 0, $challenge['difficulty']); - $this->assertFalse($interactive->verify($challenge['token'], $gap, $powForZero, $this->context())); - - // The proof solved for the true offset does verify — sanity that the binding - // only rejects mismatched offsets, not the honest client. - $powForGap = $this->solvePow($challenge['token'], $gap, $challenge['difficulty']); - $this->assertTrue($interactive->verify($challenge['token'], $gap, $powForGap, $this->context())); - } - - public function testReferenceIdentifiesAuthenticTokensOnly(): void - { - $signer = new Signer(self::SECRET); - $interactive = new Interactive($signer); - - $challenge = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - - // An authentic token yields its stable nonce; the same token always maps to - // the same reference (so a counter keyed on it is stable across guesses). - $ref = $interactive->reference($challenge['token']); - $this->assertNotNull($ref); - $this->assertSame($ref, $interactive->reference($challenge['token'])); - - // A forged (other-secret) token, a tampered token, and a non-ichal token all - // yield null — a forged blob cannot seed or exhaust a real token's counter. - $forged = (new Interactive(new Signer('attacker-secret')))->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $this->assertNull($interactive->reference($forged['token'])); - $this->assertNull($interactive->reference($challenge['token'] . 'x')); - $this->assertNull($interactive->reference('not-a-token')); - } - - public function testClearanceTtlRoundTrip(): void - { - $interactive = new Interactive(new Signer(self::SECRET)); - - $plain = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN); - $this->assertNull($interactive->clearanceTtl($plain['token'])); - - $withTtl = $interactive->issue($this->context(), 120, Issuer::DIFFICULTY_MIN, 0, 1800); - $this->assertSame(1800, $interactive->clearanceTtl($withTtl['token'])); - } -} diff --git a/tests/Challenge/PowTest.php b/tests/Challenge/PowTest.php deleted file mode 100644 index 21c407c..0000000 --- a/tests/Challenge/PowTest.php +++ /dev/null @@ -1,85 +0,0 @@ -assertSame(Pow::ALGORITHM_SHA256, Pow::algorithm(0)); - $this->assertSame(Pow::ALGORITHM_SHA256, Pow::algorithm(-1)); - $this->assertSame(Pow::ALGORITHM_ROMIX, Pow::algorithm(1)); - $this->assertSame(Pow::ALGORITHM_ROMIX, Pow::algorithm(512)); - } - - public function testClassicDigestIsPlainSha256(): void - { - // memory <= 0 must be byte-identical to a plain SHA-256, so existing - // (non-memory) challenges keep verifying exactly as before. - $this->assertSame(\hash('sha256', 'abc', true), Pow::digest('abc', 0)); - $this->assertSame(\hash('sha256', 'abc', true), Pow::digest('abc', -5)); - } - - public function testRomixIsDeterministic(): void - { - $this->assertSame(Pow::romix('seed.1', 128), Pow::romix('seed.1', 128)); - } - - public function testRomixDiffersFromSha256AndAcrossMemory(): void - { - $input = 'challenge.42'; - $this->assertNotSame(\hash('sha256', $input, true), Pow::romix($input, 64)); - // Different scratchpad sizes are different functions. - $this->assertNotSame(Pow::romix($input, 64), Pow::romix($input, 128)); - } - - public function testRomixOutputSize(): void - { - $this->assertSame(32, \strlen(Pow::romix('x', 1))); - $this->assertSame(32, \strlen(Pow::romix('x', 256))); - } - - public function testRomixMemoryOfOneIsDoubleHash(): void - { - // memory=1: V=[sha256(input)]; one mix round reads V[0], so the result is - // sha256(sha256(input) XOR sha256(input)) = sha256(32 zero bytes). - $expected = \hash('sha256', \str_repeat("\0", 32), true); - $this->assertSame($expected, Pow::romix('anything at all', 1)); - } - - public function testLeadingZeroBitsCounts(): void - { - $this->assertSame(0, Pow::leadingZeroBits("\xff")); - $this->assertSame(8, Pow::leadingZeroBits("\x00\xff")); - $this->assertSame(9, Pow::leadingZeroBits("\x00\x7f")); - $this->assertSame(16, Pow::leadingZeroBits("\x00\x00")); - } - - public function testMeetsHonoursDifficultyAndMode(): void - { - // Find a small classic solution, then confirm meets() agrees and that the - // same input under the memory-hard digest is an independent verdict. - $nonce = 'pow-test-nonce'; - $difficulty = 8; - - $solution = null; - for ($i = 0; $i < 5_000_000; $i++) { - if (Pow::meets($nonce . '.' . $i, $difficulty, 0)) { - $solution = (string) $i; - break; - } - } - - $this->assertNotNull($solution, 'expected to find a classic solution'); - $this->assertTrue(Pow::meets($nonce . '.' . $solution, $difficulty, 0)); - // The memory-hard digest of the same input is an independent function, so - // the modes cannot alias (deterministic check — no probabilistic verdict). - $this->assertNotSame( - Pow::digest($nonce . '.' . $solution, 0), - Pow::digest($nonce . '.' . $solution, 512), - ); - } -} From 037db6e7f8dcf55d50b750d05d38d6cd6cc3ef3b Mon Sep 17 00:00:00 2001 From: premtsd Date: Thu, 9 Jul 2026 14:06:08 +0200 Subject: [PATCH 18/19] feat: silent challenge tier moves into waf; depend on premtsd-code/captcha - Adopt the silent tier (SilentChallenge, Issuer, Verifier) here, next to the HeuristicEngine/MlEngine that scores it. Namespace stays Utopia\WAF\Challenge\*. - waf now requires premtsd-code/captcha for the token core (Signer/Clearance/ Context/Ip) the silent tier builds on; the earlier waf->abuse edge is dropped (abuse's facade is the interactive path, consumed by cloud/edge, not by waf). - Add the silent-tier tests (ChallengeTest + SilentChallengeTest). waf suite: 86 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 3 +- src/Challenge/Issuer.php | 147 +++++++++++++++ src/Challenge/SilentChallenge.php | 117 ++++++++++++ src/Challenge/Verifier.php | 67 +++++++ tests/Challenge/ChallengeTest.php | 240 ++++++++++++++++++++++++ tests/Challenge/SilentChallengeTest.php | 85 +++++++++ 6 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 src/Challenge/Issuer.php create mode 100644 src/Challenge/SilentChallenge.php create mode 100644 src/Challenge/Verifier.php create mode 100644 tests/Challenge/ChallengeTest.php create mode 100644 tests/Challenge/SilentChallengeTest.php diff --git a/composer.json b/composer.json index f3f454e..6117ed8 100644 --- a/composer.json +++ b/composer.json @@ -4,13 +4,12 @@ "type": "library", "license": "MIT", "repositories": [ - {"type": "vcs", "url": "https://github.com/utopia-php/abuse", "no-api": true}, {"type": "vcs", "url": "https://github.com/premtsd-code/captcha", "no-api": true} ], "require": { "php": ">=8.2", "utopia-php/validators": "0.2.*", - "utopia-php/abuse": "dev-feat-captcha-facade" + "premtsd-code/captcha": "dev-main" }, "require-dev": { "laravel/pint": "^1.18", diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php new file mode 100644 index 0000000..3921f90 --- /dev/null +++ b/src/Challenge/Issuer.php @@ -0,0 +1,147 @@ + 0 ? \max(self::MEMORY_MIN, \min(self::MEMORY_MAX, $memory)) : 0; + + $difficulty = $memory > 0 + ? self::toMemoryDifficulty($difficulty) + : \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); + + $issuedAt = \time(); + $expiresAt = $issuedAt + self::NONCE_TTL; + + $claims = [ + 'typ' => 'challenge', + 'ver' => 1, + 'pid' => $context->projectId, + 'aud' => $context->audience, + 'iph' => $this->signer->fingerprintIp($context->ip), + 'dif' => $difficulty, + 'iat' => $issuedAt, + 'exp' => $expiresAt, + 'rnd' => \bin2hex(\random_bytes(16)), + ]; + + if ($memory > 0) { + $claims['mem'] = $memory; + } + + if ($clearanceTtl !== null) { + $claims['ctl'] = $clearanceTtl; + } + + $nonce = $this->signer->sign($claims); + + return [ + 'nonce' => $nonce, + 'difficulty' => $difficulty, + 'algorithm' => SilentChallenge::algorithm($memory), + 'memory' => $memory, + 'expiresAt' => $expiresAt, + 'expiresIn' => self::NONCE_TTL, + ]; + } + + /** + * Map a CPU-band difficulty onto the memory-hard band, preserving scale. + * + * Callers store and pass difficulty in the CPU band [DIFFICULTY_MIN, + * DIFFICULTY_MAX] — the rule config validator and the adaptive bump both live + * there. A memory-hard attempt is far more expensive than a bare SHA-256 one, + * so the same knob has to land in the much lower memory band. Translating + * proportionally (16→4, 20→8, 24→12) keeps the rule's difficulty setting and + * the adaptive ladder meaningful; a naive re-clamp would collapse every + * CPU-band input to the memory-band maximum, i.e. the slowest possible solve. + * + * Public so the interactive challenge ({@see Interactive}), which carries its + * own PoW, scales difficulty identically. + */ + public static function toMemoryDifficulty(int $difficulty): int + { + $cpu = \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); + + $cpuSpan = self::DIFFICULTY_MAX - self::DIFFICULTY_MIN; + $memorySpan = self::MEMORY_DIFFICULTY_MAX - self::MEMORY_DIFFICULTY_MIN; + $fraction = ($cpu - self::DIFFICULTY_MIN) / $cpuSpan; + + return self::MEMORY_DIFFICULTY_MIN + (int) \round($fraction * $memorySpan); + } + + /** + * Read the clearance TTL carried by a nonce, or null when it carries none. + * + * The caller MUST have already verified the nonce (see Verifier); this only + * decodes the signed claims and does not re-check authenticity or expiry. + */ + public function clearanceTtl(string $nonce): ?int + { + $claims = $this->signer->parse($nonce); + if ($claims === null || !isset($claims['ctl']) || !\is_int($claims['ctl'])) { + return null; + } + + return $claims['ctl']; + } +} diff --git a/src/Challenge/SilentChallenge.php b/src/Challenge/SilentChallenge.php new file mode 100644 index 0000000..4911d5c --- /dev/null +++ b/src/Challenge/SilentChallenge.php @@ -0,0 +1,117 @@ + 0` — a sequential **memory-hard** hash ({@see self::romix()}). Each + * solution attempt must fill and then randomly read a `memory`-block scratchpad + * (`memory * 32` bytes), so throughput is bounded by memory bandwidth, not core + * count. This narrows the gap between a browser solving one challenge and a + * botnet farm solving millions — the whole point of raising the cost floor. + * + * The function is defined byte-for-byte so the pure-JS solver (see + * `reference/solver.js`, inlined into {@see Interstitial}) and this PHP verifier + * agree exactly. It is intentionally built from SHA-256 alone (no Argon2/scrypt + * dependency) so the browser side stays a dependency-free, CSP-inlinable script. + */ +final class SilentChallenge +{ + public const ALGORITHM_SHA256 = 'sha256'; + + public const ALGORITHM_ROMIX = 'sha256-romix'; + + /** + * The algorithm label advertised to the client for the given memory setting. + */ + public static function algorithm(int $memory): string + { + return $memory > 0 ? self::ALGORITHM_ROMIX : self::ALGORITHM_SHA256; + } + + /** + * Raw (binary) proof-of-work digest of `$input` under the given memory cost. + */ + public static function digest(string $input, int $memory): string + { + if ($memory <= 0) { + return \hash(self::ALGORITHM_SHA256, $input, true); + } + + return self::romix($input, $memory); + } + + /** + * Whether `$input` is a valid solution at `$difficulty` leading zero bits. + */ + public static function meets(string $input, int $difficulty, int $memory): bool + { + return self::leadingZeroBits(self::digest($input, $memory)) >= $difficulty; + } + + /** + * Sequential memory-hard hash (a SHA-256 ROMix, in the scrypt lineage). + * + * 1. Fill a scratchpad of `$memory` 32-byte blocks by iterated hashing — + * `V[0] = sha256(input)`, `V[i] = sha256(V[i-1])`. + * 2. Mix for `$memory` rounds with **data-dependent** reads: the next index is + * derived from the current accumulator, so the whole scratchpad must stay + * resident (you cannot recompute a block on demand without redoing the + * chain). That data dependency is what makes it memory-hard rather than + * merely memory-using. + * + * @param int $memory number of 32-byte scratchpad blocks (> 0) + */ + public static function romix(string $input, int $memory): string + { + $v = []; + $v[0] = \hash(self::ALGORITHM_SHA256, $input, true); + for ($i = 1; $i < $memory; $i++) { + $v[$i] = \hash(self::ALGORITHM_SHA256, $v[$i - 1], true); + } + + $x = $v[$memory - 1]; + for ($i = 0; $i < $memory; $i++) { + // Big-endian uint32 of the first 4 bytes, mod scratchpad size. + $j = (\ord($x[0]) << 24 | \ord($x[1]) << 16 | \ord($x[2]) << 8 | \ord($x[3])) % $memory; + $x = \hash(self::ALGORITHM_SHA256, $x ^ $v[$j], true); + } + + return $x; + } + + /** + * Count leading zero bits across the raw (binary) digest. + */ + public static function leadingZeroBits(string $digest): int + { + $bits = 0; + $length = \strlen($digest); + + for ($i = 0; $i < $length; $i++) { + $byte = \ord($digest[$i]); + + if ($byte === 0) { + $bits += 8; + + continue; + } + + for ($mask = 0x80; $mask > 0; $mask >>= 1) { + if (($byte & $mask) !== 0) { + return $bits; + } + + $bits++; + } + } + + return $bits; + } +} diff --git a/src/Challenge/Verifier.php b/src/Challenge/Verifier.php new file mode 100644 index 0000000..6d83775 --- /dev/null +++ b/src/Challenge/Verifier.php @@ -0,0 +1,67 @@ + self::SOLUTION_MAX_LENGTH) { + return false; + } + + $claims = $this->signer->parse($nonce); + if ($claims === null || ($claims['typ'] ?? null) !== 'challenge') { + return false; + } + + $now = \time(); + $expiresAt = (int) ($claims['exp'] ?? 0); + $issuedAt = (int) ($claims['iat'] ?? PHP_INT_MAX); + if ($expiresAt < $now - self::LEEWAY || $issuedAt > $now + self::LEEWAY) { + return false; + } + + if (($claims['pid'] ?? null) !== $context->projectId || ($claims['aud'] ?? null) !== $context->audience) { + return false; + } + + $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; + $expectedIp = $this->signer->fingerprintIp($context->ip, $kid); + if (!\hash_equals($expectedIp, (string) ($claims['iph'] ?? ''))) { + return false; + } + + $difficulty = (int) ($claims['dif'] ?? 0); + if ($difficulty <= 0) { + return false; + } + + // Memory-hard cost, when the nonce carries one. The nonce is HMAC-signed, + // so `mem` cannot be forged; the clamp is a defensive bound on verify work. + $memory = (int) ($claims['mem'] ?? 0); + if ($memory > 0) { + $memory = \min($memory, Issuer::MEMORY_MAX); + } + + return SilentChallenge::meets($nonce . '.' . $solution, $difficulty, $memory); + } +} diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php new file mode 100644 index 0000000..1b86a77 --- /dev/null +++ b/tests/Challenge/ChallengeTest.php @@ -0,0 +1,240 @@ + verify path (Issuer, + * Verifier) and its underlying primitive (SilentChallenge). The token core it + * builds on (Signer/Clearance/Context/Ip) lives in and is tested by the captcha + * leaf; here we drive the silent challenge that sits next to the scoring engine. + */ +class ChallengeTest extends TestCase +{ + private const SECRET = 'unit-test-secret-please-rotate'; + + private function context(string $ip = '203.0.113.9'): Context + { + return new Context('proj-123', 'api', $ip); + } + + /** + * Brute-force a solution for the given nonce and difficulty. + */ + private function solve(string $nonce, int $difficulty): string + { + for ($i = 0; $i < 5_000_000; $i++) { + $solution = (string) $i; + if ($this->leadingZeroBits(\hash('sha256', $nonce . '.' . $solution, true)) >= $difficulty) { + return $solution; + } + } + + $this->fail('Could not find a solution for difficulty ' . $difficulty); + } + + private function leadingZeroBits(string $digest): int + { + $bits = 0; + foreach (\str_split($digest) as $char) { + $byte = \ord($char); + if ($byte === 0) { + $bits += 8; + + continue; + } + for ($mask = 0x80; $mask > 0; $mask >>= 1) { + if (($byte & $mask) !== 0) { + return $bits; + } + $bits++; + } + break; + } + + return $bits; + } + + /** + * Brute-force a memory-hard solution (romix digest) for the given nonce. + */ + private function solveMemoryHard(string $nonce, int $difficulty, int $memory): string + { + for ($i = 0; $i < 5_000_000; $i++) { + $solution = (string) $i; + if (SilentChallenge::meets($nonce . '.' . $solution, $difficulty, $memory)) { + return $solution; + } + } + + $this->fail('Could not find a memory-hard solution for difficulty ' . $difficulty); + } + + public function testIssueClampsDifficulty(): void + { + $issuer = new Issuer(new Signer(self::SECRET)); + + $this->assertSame(Issuer::DIFFICULTY_MIN, $issuer->issue($this->context(), 1)['difficulty']); + $this->assertSame(Issuer::DIFFICULTY_MAX, $issuer->issue($this->context(), 999)['difficulty']); + } + + public function testChallengeRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + $verifier = new Verifier($signer); + + // Drive the real Issuer -> Verifier path at the clamped minimum + // difficulty (2^16 ~ 65k hashes, well under a second). + $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); + $this->assertSame(Issuer::DIFFICULTY_MIN, $challenge['difficulty']); + $this->assertSame(Issuer::ALGORITHM, $challenge['algorithm']); + $this->assertSame(Issuer::NONCE_TTL, $challenge['expiresIn']); + $this->assertSame($challenge['expiresIn'], $challenge['expiresAt'] - \time()); + + $solution = $this->solve($challenge['nonce'], $challenge['difficulty']); + + $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); + } + + public function testMemoryHardChallengeRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + $verifier = new Verifier($signer); + + // A memory-hard challenge advertises the romix algorithm, carries the + // memory cost in the (signed) nonce, and clamps the difficulty into the + // lower memory-mode band even though a classic-range value was requested. + $memory = 64; // small scratchpad keeps the test fast + $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT, null, $memory); + + $this->assertSame(SilentChallenge::ALGORITHM_ROMIX, $challenge['algorithm']); + $this->assertSame($memory, $challenge['memory']); + $this->assertLessThanOrEqual(Issuer::MEMORY_DIFFICULTY_MAX, $challenge['difficulty']); + $this->assertGreaterThanOrEqual(Issuer::MEMORY_DIFFICULTY_MIN, $challenge['difficulty']); + + // Solve with the memory-aware digest, then verify through the real path. + $solution = $this->solveMemoryHard($challenge['nonce'], $challenge['difficulty'], $memory); + $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); + + // The memory-hard and classic digests of the same input are independent + // functions, so the mode baked into the (signed) nonce cannot be downgraded. + $this->assertNotSame( + SilentChallenge::digest($challenge['nonce'] . '.' . $solution, $memory), + SilentChallenge::digest($challenge['nonce'] . '.' . $solution, 0), + ); + } + + public function testMemoryDifficultyTranslatesFromCpuBand(): void + { + $issuer = new Issuer(new Signer(self::SECRET)); + + // A CPU-band difficulty is mapped proportionally onto the memory band + // instead of being re-clamped to the band maximum, so the rule knob and + // the adaptive ladder keep their effect. Band edges and midpoint: + $cases = [ + Issuer::DIFFICULTY_MIN => Issuer::MEMORY_DIFFICULTY_MIN, // 16 -> 4 + 20 => 8, // midpoint + Issuer::DIFFICULTY_MAX => Issuer::MEMORY_DIFFICULTY_MAX, // 24 -> 12 + ]; + + foreach ($cases as $cpu => $expected) { + $challenge = $issuer->issue($this->context(), $cpu, null, 256); + $this->assertSame($expected, $challenge['difficulty'], "cpu {$cpu} should map to {$expected}"); + } + + // The default difficulty must not pin to the band maximum. + $default = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT, null, 256); + $this->assertLessThan(Issuer::MEMORY_DIFFICULTY_MAX, $default['difficulty']); + } + + public function testNonceCarriesClearanceTtlRoundTrip(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + + // No ttl requested -> nonce carries none. + $plain = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); + $this->assertNull($issuer->clearanceTtl($plain['nonce'])); + + // Requested ttl is carried verbatim inside the signed nonce so the solve + // endpoint can honour the rule's configured lifetime. + $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN, 1800); + $this->assertSame(1800, $issuer->clearanceTtl($challenge['nonce'])); + + // A tampered nonce yields no ttl (signature no longer parses). + $this->assertNull($issuer->clearanceTtl($challenge['nonce'] . 'x')); + } + + public function testVerifyRejectsInsufficientWork(): void + { + $signer = new Signer(self::SECRET); + $issuer = new Issuer($signer); + $verifier = new Verifier($signer); + + // Real difficulty (2^16); a trivial solution will not meet it. + $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT); + $this->assertFalse($verifier->verify($challenge['nonce'], '0', $this->context())); + } + + public function testVerifyRejectsOversizedAndEmptySolution(): void + { + $signer = new Signer(self::SECRET); + $verifier = new Verifier($signer); + $nonce = (new Issuer($signer))->issue($this->context(), Issuer::DIFFICULTY_MIN)['nonce']; + + $this->assertFalse($verifier->verify($nonce, '', $this->context())); + $this->assertFalse($verifier->verify($nonce, \str_repeat('a', Verifier::SOLUTION_MAX_LENGTH + 1), $this->context())); + } + + public function testVerifyRejectsExpiredNonce(): void + { + $signer = new Signer(self::SECRET); + $verifier = new Verifier($signer); + + // Hand-craft an already-expired nonce at difficulty 1 (trivially solvable). + $nonce = $signer->sign([ + 'typ' => 'challenge', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'dif' => 1, + 'iat' => \time() - 1000, + 'exp' => \time() - 500, + ]); + $solution = $this->solve($nonce, 1); + + $this->assertFalse($verifier->verify($nonce, $solution, $this->context())); + } + + public function testVerifyRejectsWrongContext(): void + { + $signer = new Signer(self::SECRET); + $verifier = new Verifier($signer); + + $nonce = $signer->sign([ + 'typ' => 'challenge', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'dif' => 1, + 'iat' => \time(), + 'exp' => \time() + 120, + ]); + $solution = $this->solve($nonce, 1); + + $this->assertFalse($verifier->verify($nonce, $solution, new Context('other-project', 'api', '203.0.113.9'))); + $this->assertFalse($verifier->verify($nonce, $solution, new Context('proj-123', 'mysite.example', '203.0.113.9'))); + // Different /24 network. + $this->assertFalse($verifier->verify($nonce, $solution, new Context('proj-123', 'api', '198.51.100.9'))); + // Same /24 still passes. + $this->assertTrue($verifier->verify($nonce, $solution, new Context('proj-123', 'api', '203.0.113.200'))); + } +} diff --git a/tests/Challenge/SilentChallengeTest.php b/tests/Challenge/SilentChallengeTest.php new file mode 100644 index 0000000..f3c22ef --- /dev/null +++ b/tests/Challenge/SilentChallengeTest.php @@ -0,0 +1,85 @@ +assertSame(SilentChallenge::ALGORITHM_SHA256, SilentChallenge::algorithm(0)); + $this->assertSame(SilentChallenge::ALGORITHM_SHA256, SilentChallenge::algorithm(-1)); + $this->assertSame(SilentChallenge::ALGORITHM_ROMIX, SilentChallenge::algorithm(1)); + $this->assertSame(SilentChallenge::ALGORITHM_ROMIX, SilentChallenge::algorithm(512)); + } + + public function testClassicDigestIsPlainSha256(): void + { + // memory <= 0 must be byte-identical to a plain SHA-256, so existing + // (non-memory) challenges keep verifying exactly as before. + $this->assertSame(\hash('sha256', 'abc', true), SilentChallenge::digest('abc', 0)); + $this->assertSame(\hash('sha256', 'abc', true), SilentChallenge::digest('abc', -5)); + } + + public function testRomixIsDeterministic(): void + { + $this->assertSame(SilentChallenge::romix('seed.1', 128), SilentChallenge::romix('seed.1', 128)); + } + + public function testRomixDiffersFromSha256AndAcrossMemory(): void + { + $input = 'challenge.42'; + $this->assertNotSame(\hash('sha256', $input, true), SilentChallenge::romix($input, 64)); + // Different scratchpad sizes are different functions. + $this->assertNotSame(SilentChallenge::romix($input, 64), SilentChallenge::romix($input, 128)); + } + + public function testRomixOutputSize(): void + { + $this->assertSame(32, \strlen(SilentChallenge::romix('x', 1))); + $this->assertSame(32, \strlen(SilentChallenge::romix('x', 256))); + } + + public function testRomixMemoryOfOneIsDoubleHash(): void + { + // memory=1: V=[sha256(input)]; one mix round reads V[0], so the result is + // sha256(sha256(input) XOR sha256(input)) = sha256(32 zero bytes). + $expected = \hash('sha256', \str_repeat("\0", 32), true); + $this->assertSame($expected, SilentChallenge::romix('anything at all', 1)); + } + + public function testLeadingZeroBitsCounts(): void + { + $this->assertSame(0, SilentChallenge::leadingZeroBits("\xff")); + $this->assertSame(8, SilentChallenge::leadingZeroBits("\x00\xff")); + $this->assertSame(9, SilentChallenge::leadingZeroBits("\x00\x7f")); + $this->assertSame(16, SilentChallenge::leadingZeroBits("\x00\x00")); + } + + public function testMeetsHonoursDifficultyAndMode(): void + { + // Find a small classic solution, then confirm meets() agrees and that the + // same input under the memory-hard digest is an independent verdict. + $nonce = 'pow-test-nonce'; + $difficulty = 8; + + $solution = null; + for ($i = 0; $i < 5_000_000; $i++) { + if (SilentChallenge::meets($nonce . '.' . $i, $difficulty, 0)) { + $solution = (string) $i; + break; + } + } + + $this->assertNotNull($solution, 'expected to find a classic solution'); + $this->assertTrue(SilentChallenge::meets($nonce . '.' . $solution, $difficulty, 0)); + // The memory-hard digest of the same input is an independent function, so + // the modes cannot alias (deterministic check — no probabilistic verdict). + $this->assertNotSame( + SilentChallenge::digest($nonce . '.' . $solution, 0), + SilentChallenge::digest($nonce . '.' . $solution, 512), + ); + } +} From 49e228c5f19e4612b1f46e8dcf7598d9e698a9ad Mon Sep 17 00:00:00 2001 From: premtsd Date: Thu, 9 Jul 2026 14:22:10 +0200 Subject: [PATCH 19/19] feat: silent tier is now an attestation check, not proof-of-work Rework the silent (invisible) challenge from a computed PoW puzzle into a signal attestation scored by the engine: - Issuer mints a bare signed, context-bound, short-TTL anti-replay nonce (no difficulty/memory/algorithm). - Verifier checks only nonce integrity (authentic/fresh/context-bound); no solution, no meets(). - SilentChallenge stops being PoW math and becomes the silent-tier orchestrator: issue(); verify(nonce, Signals, context) confirms integrity then scores the attested signals via the Engine, returning a Score (null on integrity failure). - Drop meets/digest/romix/leadingZeroBits + DIFFICULTY_*/MEMORY_*/ALGORITHM. - Refresh stale proof-of-work wording in RiskTier/ClientSignals docs. waf suite: 83 tests green (incl. real HeuristicEngine scoring the attestation). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Challenge/Issuer.php | 102 ++-------- src/Challenge/Scoring/ClientSignals.php | 4 +- src/Challenge/Scoring/RiskTier.php | 12 +- src/Challenge/SilentChallenge.php | 128 +++++-------- src/Challenge/Verifier.php | 41 ++-- tests/Challenge/ChallengeTest.php | 239 +++++++----------------- tests/Challenge/SilentChallengeTest.php | 157 ++++++++++------ 7 files changed, 259 insertions(+), 424 deletions(-) diff --git a/src/Challenge/Issuer.php b/src/Challenge/Issuer.php index 3921f90..c0a896f 100644 --- a/src/Challenge/Issuer.php +++ b/src/Challenge/Issuer.php @@ -3,72 +3,39 @@ namespace Utopia\WAF\Challenge; /** - * Issues WAF challenges. + * Issues silent-challenge nonces. * - * The client must find a `solution` such that the proof-of-work digest of - * `nonce . '.' . solution` has at least `difficulty` leading zero bits. The - * digest is plain SHA-256 by default, or a memory-hard variant when the challenge - * is minted with a `memory` cost (see {@see SilentChallenge}). The nonce is a signed, - * short-lived, context-bound token — issuance is stateless and verification (see - * Verifier) recomputes a single digest. + * The silent (invisible) tier does not ask the client to burn CPU. The nonce is a + * signed, short-lived, context-bound anti-replay token: the client runs an + * invisible script, collects environment/automation signals, and returns them + * bound to this nonce as an attestation, which the engine then scores (see + * {@see SilentChallenge}). Issuance is stateless. */ final class Issuer { - public const DIFFICULTY_MIN = 16; - public const DIFFICULTY_DEFAULT = 20; - public const DIFFICULTY_MAX = 24; - - /** - * Memory-hard mode. `memory` is the scratchpad size in 32-byte blocks; each - * solution attempt fills and randomly reads it (see {@see SilentChallenge::romix()}). - * Because a memory-hard attempt costs ~2·memory hashes, the difficulty band - * is far lower than the CPU-only one — few attempts, each expensive — so the - * browser stays responsive while a farm loses its parallelism advantage. - */ - public const MEMORY_MIN = 1; - public const MEMORY_DEFAULT = 512; // 512 blocks × 32 B = 16 KB scratchpad - public const MEMORY_MAX = 4096; // 128 KB - - public const MEMORY_DIFFICULTY_MIN = 4; - public const MEMORY_DIFFICULTY_DEFAULT = 8; - public const MEMORY_DIFFICULTY_MAX = 12; - public const NONCE_TTL = 120; - public const ALGORITHM = SilentChallenge::ALGORITHM_SHA256; - public function __construct(private readonly Signer $signer) { } /** - * Mint a challenge for the given context. + * Mint a silent-challenge nonce for the given context. * - * `expiresAt` is an absolute timestamp for convenience; `expiresIn` is the - * same deadline expressed relative to issuance, which clients should prefer - * so a skewed local clock does not shorten or extend the solve window. + * `expiresAt` is an absolute timestamp for convenience; `expiresIn` is the same + * deadline relative to issuance, which clients should prefer so a skewed local + * clock does not shorten or extend the attestation window. * - * `$clearanceTtl`, when given, is carried inside the signed nonce (claim - * `ctl`) so the solve endpoint — which only receives the nonce, never the - * matched rule — can mint the clearance with the rule's configured lifetime. - * It is authenticated by the nonce signature; read it back with + * `$clearanceTtl`, when given, is carried inside the signed nonce (claim `ctl`) + * so the verify endpoint — which only receives the nonce, never the matched + * rule — can mint the clearance with the rule's configured lifetime. It is + * authenticated by the nonce signature; read it back with * {@see self::clearanceTtl()} after verification. * - * `$memory`, when greater than zero, switches the challenge to the memory-hard - * proof of work and is clamped to `[MEMORY_MIN, MEMORY_MAX]`; the difficulty is - * then interpreted (and clamped) in the lower memory-mode band. It is carried - * in the signed nonce (claim `mem`) so the Verifier recomputes the same digest. - * - * @return array{nonce: string, difficulty: int, algorithm: string, memory: int, expiresAt: int, expiresIn: int} + * @return array{nonce: string, expiresAt: int, expiresIn: int} */ - public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAULT, ?int $clearanceTtl = null, int $memory = 0): array + public function issue(Context $context, ?int $clearanceTtl = null): array { - $memory = $memory > 0 ? \max(self::MEMORY_MIN, \min(self::MEMORY_MAX, $memory)) : 0; - - $difficulty = $memory > 0 - ? self::toMemoryDifficulty($difficulty) - : \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); - $issuedAt = \time(); $expiresAt = $issuedAt + self::NONCE_TTL; @@ -78,16 +45,11 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU 'pid' => $context->projectId, 'aud' => $context->audience, 'iph' => $this->signer->fingerprintIp($context->ip), - 'dif' => $difficulty, 'iat' => $issuedAt, 'exp' => $expiresAt, 'rnd' => \bin2hex(\random_bytes(16)), ]; - if ($memory > 0) { - $claims['mem'] = $memory; - } - if ($clearanceTtl !== null) { $claims['ctl'] = $clearanceTtl; } @@ -96,44 +58,16 @@ public function issue(Context $context, int $difficulty = self::DIFFICULTY_DEFAU return [ 'nonce' => $nonce, - 'difficulty' => $difficulty, - 'algorithm' => SilentChallenge::algorithm($memory), - 'memory' => $memory, 'expiresAt' => $expiresAt, 'expiresIn' => self::NONCE_TTL, ]; } - /** - * Map a CPU-band difficulty onto the memory-hard band, preserving scale. - * - * Callers store and pass difficulty in the CPU band [DIFFICULTY_MIN, - * DIFFICULTY_MAX] — the rule config validator and the adaptive bump both live - * there. A memory-hard attempt is far more expensive than a bare SHA-256 one, - * so the same knob has to land in the much lower memory band. Translating - * proportionally (16→4, 20→8, 24→12) keeps the rule's difficulty setting and - * the adaptive ladder meaningful; a naive re-clamp would collapse every - * CPU-band input to the memory-band maximum, i.e. the slowest possible solve. - * - * Public so the interactive challenge ({@see Interactive}), which carries its - * own PoW, scales difficulty identically. - */ - public static function toMemoryDifficulty(int $difficulty): int - { - $cpu = \max(self::DIFFICULTY_MIN, \min(self::DIFFICULTY_MAX, $difficulty)); - - $cpuSpan = self::DIFFICULTY_MAX - self::DIFFICULTY_MIN; - $memorySpan = self::MEMORY_DIFFICULTY_MAX - self::MEMORY_DIFFICULTY_MIN; - $fraction = ($cpu - self::DIFFICULTY_MIN) / $cpuSpan; - - return self::MEMORY_DIFFICULTY_MIN + (int) \round($fraction * $memorySpan); - } - /** * Read the clearance TTL carried by a nonce, or null when it carries none. * - * The caller MUST have already verified the nonce (see Verifier); this only - * decodes the signed claims and does not re-check authenticity or expiry. + * The caller MUST have already verified the nonce (see {@see Verifier}); this + * only decodes the signed claims and does not re-check authenticity or expiry. */ public function clearanceTtl(string $nonce): ?int { diff --git a/src/Challenge/Scoring/ClientSignals.php b/src/Challenge/Scoring/ClientSignals.php index 0a16b88..ab042e0 100644 --- a/src/Challenge/Scoring/ClientSignals.php +++ b/src/Challenge/Scoring/ClientSignals.php @@ -84,8 +84,8 @@ private static function automation(array $raw): float /** * Behavioral risk: absence of human input while the interstitial was shown. - * A real user moves the mouse / presses keys; a script solving the PoW does - * neither. No activity ⇒ high risk; activity drives it down. + * A real user moves the mouse / presses keys; a headless script attesting + * silently does neither. No activity ⇒ high risk; activity drives it down. * * @param array $raw */ diff --git a/src/Challenge/Scoring/RiskTier.php b/src/Challenge/Scoring/RiskTier.php index dfded84..3bf31b3 100644 --- a/src/Challenge/Scoring/RiskTier.php +++ b/src/Challenge/Scoring/RiskTier.php @@ -6,7 +6,7 @@ * The enforcement tier a risk score maps to — the escalation ladder. * * allow → let the request through untouched - * challenge → impose a proof-of-work challenge (cost gate) + * challenge → impose the silent (invisible) attestation check * interactive → impose the interaction / first-party captcha (humanity gate) * deny → block outright * @@ -57,9 +57,13 @@ public static function fromScore(float $score, array $thresholds = self::DEFAULT /** * Clamp a tier to what a surface can actually enforce. The cloud API has no - * browser page, so it cannot run the interactive challenge — an interactive - * verdict falls back to the strongest gate it *can* run (proof-of-work). - * Every other tier (including deny) is surface-agnostic. + * browser page, so it cannot render the interactive slider — an interactive + * verdict falls back to the silent (invisible) attestation check. Every other + * tier (including deny) is surface-agnostic. + * + * Note: the silent attestation itself is browser-collected, so a non-browser + * API client cannot satisfy it either; on that surface the challenge tier is + * effectively "score on server signals alone" (see the cloud integration). */ public function clampTo(bool $interactiveCapable): self { diff --git a/src/Challenge/SilentChallenge.php b/src/Challenge/SilentChallenge.php index 4911d5c..87e89d3 100644 --- a/src/Challenge/SilentChallenge.php +++ b/src/Challenge/SilentChallenge.php @@ -2,116 +2,80 @@ namespace Utopia\WAF\Challenge; +use Utopia\WAF\Challenge\Scoring\Engine; +use Utopia\WAF\Challenge\Scoring\Score; +use Utopia\WAF\Challenge\Scoring\Signals; + /** - * The proof-of-work primitive shared by {@see Issuer} and {@see Verifier}: turn a - * challenge input into a digest and test it against a difficulty (leading zero - * bits). Two modes, selected by the `memory` parameter: - * - * - `memory <= 0` — classic SHA-256 (`sha256(input)`). Cheap to compute and to - * verify; the search cost is CPU-only, so it parallelises freely on GPUs/ASICs. + * The silent (invisible) challenge — an attestation check, not a puzzle. * - * - `memory > 0` — a sequential **memory-hard** hash ({@see self::romix()}). Each - * solution attempt must fill and then randomly read a `memory`-block scratchpad - * (`memory * 32` bytes), so throughput is bounded by memory bandwidth, not core - * count. This narrows the gap between a browser solving one challenge and a - * botnet farm solving millions — the whole point of raising the cost floor. + * Where the interactive gate asks a human to *act*, the silent gate asks the + * client to *reveal itself*. The server issues a fresh, signed, context-bound + * nonce; an invisible script collects environment/automation signals (webdriver, + * plugins, WebGL, timing, interaction — the Scoring signal set) and returns them + * bound to that nonce as an attestation; the server confirms the nonce's integrity + * and scores the attested signals with the same engine the WAF uses everywhere. + * Nothing is computed to burn CPU — the cost to a bot is that it must present a + * *plausible* environment, and a headless/automation environment scores itself + * into escalation. * - * The function is defined byte-for-byte so the pure-JS solver (see - * `reference/solver.js`, inlined into {@see Interstitial}) and this PHP verifier - * agree exactly. It is intentionally built from SHA-256 alone (no Argon2/scrypt - * dependency) so the browser side stays a dependency-free, CSP-inlinable script. + * This class is the silent tier's single entry point in the waf library: it ties + * the nonce mint ({@see Issuer}), the integrity check ({@see Verifier}), and the + * scoring {@see Engine} together. It lives beside the engine (not in the captcha + * leaf) precisely because its verdict *is* a score. */ final class SilentChallenge { - public const ALGORITHM_SHA256 = 'sha256'; - - public const ALGORITHM_ROMIX = 'sha256-romix'; - - /** - * The algorithm label advertised to the client for the given memory setting. - */ - public static function algorithm(int $memory): string - { - return $memory > 0 ? self::ALGORITHM_ROMIX : self::ALGORITHM_SHA256; + public function __construct( + private readonly Issuer $issuer, + private readonly Verifier $verifier, + private readonly Engine $engine, + ) { } /** - * Raw (binary) proof-of-work digest of `$input` under the given memory cost. + * Convenience constructor from a signer + scoring engine. */ - public static function digest(string $input, int $memory): string + public static function create(Signer $signer, Engine $engine): self { - if ($memory <= 0) { - return \hash(self::ALGORITHM_SHA256, $input, true); - } - - return self::romix($input, $memory); + return new self(new Issuer($signer), new Verifier($signer), $engine); } /** - * Whether `$input` is a valid solution at `$difficulty` leading zero bits. + * Issue a silent-challenge nonce the client must return with its attestation. + * + * @return array{nonce: string, expiresAt: int, expiresIn: int} */ - public static function meets(string $input, int $difficulty, int $memory): bool + public function issue(Context $context, ?int $clearanceTtl = null): array { - return self::leadingZeroBits(self::digest($input, $memory)) >= $difficulty; + return $this->issuer->issue($context, $clearanceTtl); } /** - * Sequential memory-hard hash (a SHA-256 ROMix, in the scrypt lineage). + * Verify a returned attestation and score it. * - * 1. Fill a scratchpad of `$memory` 32-byte blocks by iterated hashing — - * `V[0] = sha256(input)`, `V[i] = sha256(V[i-1])`. - * 2. Mix for `$memory` rounds with **data-dependent** reads: the next index is - * derived from the current accumulator, so the whole scratchpad must stay - * resident (you cannot recompute a block on demand without redoing the - * chain). That data dependency is what makes it memory-hard rather than - * merely memory-using. - * - * @param int $memory number of 32-byte scratchpad blocks (> 0) + * The nonce must be authentic, unexpired, and context-bound (anti-replay); the + * attested `$signals` are then scored by the engine. Returns the resulting + * {@see Score} (risk value + tier) so the caller can act on it — allow (mint + * clearance), escalate to the interactive slider, or deny. Returns null only + * when the nonce itself fails integrity (forged, expired, or wrong context), + * which the caller should treat as a hard reject rather than a low score. */ - public static function romix(string $input, int $memory): string + public function verify(string $nonce, Signals $signals, Context $context): ?Score { - $v = []; - $v[0] = \hash(self::ALGORITHM_SHA256, $input, true); - for ($i = 1; $i < $memory; $i++) { - $v[$i] = \hash(self::ALGORITHM_SHA256, $v[$i - 1], true); - } - - $x = $v[$memory - 1]; - for ($i = 0; $i < $memory; $i++) { - // Big-endian uint32 of the first 4 bytes, mod scratchpad size. - $j = (\ord($x[0]) << 24 | \ord($x[1]) << 16 | \ord($x[2]) << 8 | \ord($x[3])) % $memory; - $x = \hash(self::ALGORITHM_SHA256, $x ^ $v[$j], true); + if (!$this->verifier->verify($nonce, $context)) { + return null; } - return $x; + return $this->engine->score($signals); } /** - * Count leading zero bits across the raw (binary) digest. + * Read the clearance TTL carried by a verified nonce, or null when it carries + * none. The caller MUST have already verified the nonce. */ - public static function leadingZeroBits(string $digest): int + public function clearanceTtl(string $nonce): ?int { - $bits = 0; - $length = \strlen($digest); - - for ($i = 0; $i < $length; $i++) { - $byte = \ord($digest[$i]); - - if ($byte === 0) { - $bits += 8; - - continue; - } - - for ($mask = 0x80; $mask > 0; $mask >>= 1) { - if (($byte & $mask) !== 0) { - return $bits; - } - - $bits++; - } - } - - return $bits; + return $this->issuer->clearanceTtl($nonce); } } diff --git a/src/Challenge/Verifier.php b/src/Challenge/Verifier.php index 6d83775..405d5d7 100644 --- a/src/Challenge/Verifier.php +++ b/src/Challenge/Verifier.php @@ -3,18 +3,14 @@ namespace Utopia\WAF\Challenge; /** - * Verifies challenge solutions in bounded work (one proof-of-work digest). - * - * A solution is accepted only when the nonce is authentic, unexpired, bound to - * the same context, and the proof-of-work digest of `nonce . '.' . solution` - * meets the difficulty baked into the nonce. The digest is plain SHA-256, or the - * memory-hard variant when the nonce carries a `mem` cost (see {@see SilentChallenge}). + * Verifies a silent-challenge nonce's integrity: authentic, unexpired, and bound + * to the same context. There is no proof-of-work — the silent tier's verdict comes + * from scoring the attested signals ({@see SilentChallenge}), not from a solved + * puzzle. This class answers only "is this nonce a genuine, live challenge for this + * client?"; the scoring engine decides human-vs-bot. */ final class Verifier { - /** Maximum solution length accepted, guarding the verify hash against oversized input. */ - public const SOLUTION_MAX_LENGTH = 64; - /** Clock-skew tolerance, in seconds, applied to nonce timestamps. */ public const LEEWAY = 5; @@ -22,12 +18,12 @@ public function __construct(private readonly Signer $signer) { } - public function verify(string $nonce, string $solution, Context $context): bool + /** + * Whether `$nonce` is an authentic, unexpired silent-challenge nonce bound to + * `$context` (project + audience + IP prefix). Stateless. + */ + public function verify(string $nonce, Context $context): bool { - if ($solution === '' || \strlen($solution) > self::SOLUTION_MAX_LENGTH) { - return false; - } - $claims = $this->signer->parse($nonce); if ($claims === null || ($claims['typ'] ?? null) !== 'challenge') { return false; @@ -46,22 +42,7 @@ public function verify(string $nonce, string $solution, Context $context): bool $kid = \is_int($claims['kid'] ?? null) ? $claims['kid'] : null; $expectedIp = $this->signer->fingerprintIp($context->ip, $kid); - if (!\hash_equals($expectedIp, (string) ($claims['iph'] ?? ''))) { - return false; - } - - $difficulty = (int) ($claims['dif'] ?? 0); - if ($difficulty <= 0) { - return false; - } - - // Memory-hard cost, when the nonce carries one. The nonce is HMAC-signed, - // so `mem` cannot be forged; the clamp is a defensive bound on verify work. - $memory = (int) ($claims['mem'] ?? 0); - if ($memory > 0) { - $memory = \min($memory, Issuer::MEMORY_MAX); - } - return SilentChallenge::meets($nonce . '.' . $solution, $difficulty, $memory); + return \hash_equals($expectedIp, (string) ($claims['iph'] ?? '')); } } diff --git a/tests/Challenge/ChallengeTest.php b/tests/Challenge/ChallengeTest.php index 1b86a77..587bb09 100644 --- a/tests/Challenge/ChallengeTest.php +++ b/tests/Challenge/ChallengeTest.php @@ -5,15 +5,15 @@ use PHPUnit\Framework\TestCase; use Utopia\WAF\Challenge\Context; use Utopia\WAF\Challenge\Issuer; -use Utopia\WAF\Challenge\SilentChallenge; use Utopia\WAF\Challenge\Signer; use Utopia\WAF\Challenge\Verifier; /** - * Silent-tier tests: the invisible challenge issue -> verify path (Issuer, - * Verifier) and its underlying primitive (SilentChallenge). The token core it - * builds on (Signer/Clearance/Context/Ip) lives in and is tested by the captcha - * leaf; here we drive the silent challenge that sits next to the scoring engine. + * Silent-tier nonce tests: the Issuer mints a signed, context-bound, short-lived + * anti-replay nonce (no proof-of-work), and the Verifier answers only "is this a + * genuine, live challenge for this client?". Scoring the attested signals is the + * orchestrator's job ({@see \Utopia\WAF\Challenge\SilentChallenge}, tested in + * SilentChallengeTest). */ class ChallengeTest extends TestCase { @@ -24,217 +24,116 @@ private function context(string $ip = '203.0.113.9'): Context return new Context('proj-123', 'api', $ip); } - /** - * Brute-force a solution for the given nonce and difficulty. - */ - private function solve(string $nonce, int $difficulty): string - { - for ($i = 0; $i < 5_000_000; $i++) { - $solution = (string) $i; - if ($this->leadingZeroBits(\hash('sha256', $nonce . '.' . $solution, true)) >= $difficulty) { - return $solution; - } - } - - $this->fail('Could not find a solution for difficulty ' . $difficulty); - } - - private function leadingZeroBits(string $digest): int - { - $bits = 0; - foreach (\str_split($digest) as $char) { - $byte = \ord($char); - if ($byte === 0) { - $bits += 8; - - continue; - } - for ($mask = 0x80; $mask > 0; $mask >>= 1) { - if (($byte & $mask) !== 0) { - return $bits; - } - $bits++; - } - break; - } - - return $bits; - } - - /** - * Brute-force a memory-hard solution (romix digest) for the given nonce. - */ - private function solveMemoryHard(string $nonce, int $difficulty, int $memory): string - { - for ($i = 0; $i < 5_000_000; $i++) { - $solution = (string) $i; - if (SilentChallenge::meets($nonce . '.' . $solution, $difficulty, $memory)) { - return $solution; - } - } - - $this->fail('Could not find a memory-hard solution for difficulty ' . $difficulty); - } - - public function testIssueClampsDifficulty(): void + public function testIssueMintsContextBoundNonce(): void { $issuer = new Issuer(new Signer(self::SECRET)); - $this->assertSame(Issuer::DIFFICULTY_MIN, $issuer->issue($this->context(), 1)['difficulty']); - $this->assertSame(Issuer::DIFFICULTY_MAX, $issuer->issue($this->context(), 999)['difficulty']); - } + $challenge = $issuer->issue($this->context()); - public function testChallengeRoundTrip(): void - { - $signer = new Signer(self::SECRET); - $issuer = new Issuer($signer); - $verifier = new Verifier($signer); - - // Drive the real Issuer -> Verifier path at the clamped minimum - // difficulty (2^16 ~ 65k hashes, well under a second). - $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); - $this->assertSame(Issuer::DIFFICULTY_MIN, $challenge['difficulty']); - $this->assertSame(Issuer::ALGORITHM, $challenge['algorithm']); + $this->assertArrayHasKey('nonce', $challenge); $this->assertSame(Issuer::NONCE_TTL, $challenge['expiresIn']); $this->assertSame($challenge['expiresIn'], $challenge['expiresAt'] - \time()); - $solution = $this->solve($challenge['nonce'], $challenge['difficulty']); - - $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); + // No proof-of-work is advertised any more: the nonce carries no difficulty, + // memory, or algorithm. + $claims = \json_decode(\base64_decode(\strtr(\explode('.', $challenge['nonce'])[0], '-_', '+/')), true); + $this->assertArrayNotHasKey('dif', $claims); + $this->assertArrayNotHasKey('mem', $claims); + $this->assertSame('challenge', $claims['typ']); } - public function testMemoryHardChallengeRoundTrip(): void + public function testVerifyAcceptsAuthenticNonce(): void { $signer = new Signer(self::SECRET); $issuer = new Issuer($signer); $verifier = new Verifier($signer); - // A memory-hard challenge advertises the romix algorithm, carries the - // memory cost in the (signed) nonce, and clamps the difficulty into the - // lower memory-mode band even though a classic-range value was requested. - $memory = 64; // small scratchpad keeps the test fast - $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT, null, $memory); - - $this->assertSame(SilentChallenge::ALGORITHM_ROMIX, $challenge['algorithm']); - $this->assertSame($memory, $challenge['memory']); - $this->assertLessThanOrEqual(Issuer::MEMORY_DIFFICULTY_MAX, $challenge['difficulty']); - $this->assertGreaterThanOrEqual(Issuer::MEMORY_DIFFICULTY_MIN, $challenge['difficulty']); - - // Solve with the memory-aware digest, then verify through the real path. - $solution = $this->solveMemoryHard($challenge['nonce'], $challenge['difficulty'], $memory); - $this->assertTrue($verifier->verify($challenge['nonce'], $solution, $this->context())); - - // The memory-hard and classic digests of the same input are independent - // functions, so the mode baked into the (signed) nonce cannot be downgraded. - $this->assertNotSame( - SilentChallenge::digest($challenge['nonce'] . '.' . $solution, $memory), - SilentChallenge::digest($challenge['nonce'] . '.' . $solution, 0), - ); - } - - public function testMemoryDifficultyTranslatesFromCpuBand(): void - { - $issuer = new Issuer(new Signer(self::SECRET)); - - // A CPU-band difficulty is mapped proportionally onto the memory band - // instead of being re-clamped to the band maximum, so the rule knob and - // the adaptive ladder keep their effect. Band edges and midpoint: - $cases = [ - Issuer::DIFFICULTY_MIN => Issuer::MEMORY_DIFFICULTY_MIN, // 16 -> 4 - 20 => 8, // midpoint - Issuer::DIFFICULTY_MAX => Issuer::MEMORY_DIFFICULTY_MAX, // 24 -> 12 - ]; - - foreach ($cases as $cpu => $expected) { - $challenge = $issuer->issue($this->context(), $cpu, null, 256); - $this->assertSame($expected, $challenge['difficulty'], "cpu {$cpu} should map to {$expected}"); - } - - // The default difficulty must not pin to the band maximum. - $default = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT, null, 256); - $this->assertLessThan(Issuer::MEMORY_DIFFICULTY_MAX, $default['difficulty']); + $nonce = $issuer->issue($this->context())['nonce']; + $this->assertTrue($verifier->verify($nonce, $this->context())); } - public function testNonceCarriesClearanceTtlRoundTrip(): void + public function testVerifyRejectsExpiredNonce(): void { $signer = new Signer(self::SECRET); - $issuer = new Issuer($signer); - - // No ttl requested -> nonce carries none. - $plain = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN); - $this->assertNull($issuer->clearanceTtl($plain['nonce'])); + $verifier = new Verifier($signer); - // Requested ttl is carried verbatim inside the signed nonce so the solve - // endpoint can honour the rule's configured lifetime. - $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_MIN, 1800); - $this->assertSame(1800, $issuer->clearanceTtl($challenge['nonce'])); + $nonce = $signer->sign([ + 'typ' => 'challenge', + 'pid' => 'proj-123', + 'aud' => 'api', + 'iph' => $signer->fingerprintIp('203.0.113.9'), + 'iat' => \time() - 1000, + 'exp' => \time() - 500, + ]); - // A tampered nonce yields no ttl (signature no longer parses). - $this->assertNull($issuer->clearanceTtl($challenge['nonce'] . 'x')); + $this->assertFalse($verifier->verify($nonce, $this->context())); } - public function testVerifyRejectsInsufficientWork(): void + public function testVerifyRejectsWrongContext(): void { $signer = new Signer(self::SECRET); $issuer = new Issuer($signer); $verifier = new Verifier($signer); - // Real difficulty (2^16); a trivial solution will not meet it. - $challenge = $issuer->issue($this->context(), Issuer::DIFFICULTY_DEFAULT); - $this->assertFalse($verifier->verify($challenge['nonce'], '0', $this->context())); + $nonce = $issuer->issue($this->context('203.0.113.9'))['nonce']; + + $this->assertFalse($verifier->verify($nonce, new Context('other-project', 'api', '203.0.113.9'))); + $this->assertFalse($verifier->verify($nonce, new Context('proj-123', 'mysite.example', '203.0.113.9'))); + // Different /24 network. + $this->assertFalse($verifier->verify($nonce, new Context('proj-123', 'api', '198.51.100.9'))); + // Same /24 still passes. + $this->assertTrue($verifier->verify($nonce, new Context('proj-123', 'api', '203.0.113.200'))); } - public function testVerifyRejectsOversizedAndEmptySolution(): void + public function testVerifyRejectsForeignSecret(): void { - $signer = new Signer(self::SECRET); - $verifier = new Verifier($signer); - $nonce = (new Issuer($signer))->issue($this->context(), Issuer::DIFFICULTY_MIN)['nonce']; + $issued = (new Issuer(new Signer(self::SECRET)))->issue($this->context())['nonce']; + $foreign = new Verifier(new Signer('a-completely-different-secret')); - $this->assertFalse($verifier->verify($nonce, '', $this->context())); - $this->assertFalse($verifier->verify($nonce, \str_repeat('a', Verifier::SOLUTION_MAX_LENGTH + 1), $this->context())); + $this->assertFalse($foreign->verify($issued, $this->context())); } - public function testVerifyRejectsExpiredNonce(): void + public function testVerifyRejectsCrossType(): void { $signer = new Signer(self::SECRET); $verifier = new Verifier($signer); - // Hand-craft an already-expired nonce at difficulty 1 (trivially solvable). - $nonce = $signer->sign([ - 'typ' => 'challenge', + // A clearance-type token is authentically signed and context-bound but is + // not a challenge nonce — it must not verify as one. + $clr = $signer->sign([ + 'typ' => 'clr', 'pid' => 'proj-123', 'aud' => 'api', 'iph' => $signer->fingerprintIp('203.0.113.9'), - 'dif' => 1, - 'iat' => \time() - 1000, - 'exp' => \time() - 500, + 'iat' => \time(), + 'exp' => \time() + 600, ]); - $solution = $this->solve($nonce, 1); - $this->assertFalse($verifier->verify($nonce, $solution, $this->context())); + $this->assertFalse($verifier->verify($clr, $this->context())); } - public function testVerifyRejectsWrongContext(): void + public function testNonceCarriesClearanceTtlRoundTrip(): void { - $signer = new Signer(self::SECRET); - $verifier = new Verifier($signer); + $issuer = new Issuer(new Signer(self::SECRET)); - $nonce = $signer->sign([ - 'typ' => 'challenge', - 'pid' => 'proj-123', - 'aud' => 'api', - 'iph' => $signer->fingerprintIp('203.0.113.9'), - 'dif' => 1, - 'iat' => \time(), - 'exp' => \time() + 120, - ]); - $solution = $this->solve($nonce, 1); + // No ttl requested -> nonce carries none. + $plain = $issuer->issue($this->context()); + $this->assertNull($issuer->clearanceTtl($plain['nonce'])); - $this->assertFalse($verifier->verify($nonce, $solution, new Context('other-project', 'api', '203.0.113.9'))); - $this->assertFalse($verifier->verify($nonce, $solution, new Context('proj-123', 'mysite.example', '203.0.113.9'))); - // Different /24 network. - $this->assertFalse($verifier->verify($nonce, $solution, new Context('proj-123', 'api', '198.51.100.9'))); - // Same /24 still passes. - $this->assertTrue($verifier->verify($nonce, $solution, new Context('proj-123', 'api', '203.0.113.200'))); + // Requested ttl is carried verbatim inside the signed nonce. + $withTtl = $issuer->issue($this->context(), 1800); + $this->assertSame(1800, $issuer->clearanceTtl($withTtl['nonce'])); + + // A tampered nonce yields no ttl (signature no longer parses). + $this->assertNull($issuer->clearanceTtl($withTtl['nonce'] . 'x')); + } + + public function testKeyRotationHonorsNonceKid(): void + { + // Issue under kid 2, then rotate: kid 3 primary, kid 2 kept as previous. + $issued = (new Issuer(new Signer(self::SECRET, 2)))->issue($this->context())['nonce']; + + $rotated = new Verifier(new Signer('new-secret', 3, [2 => self::SECRET])); + $this->assertTrue($rotated->verify($issued, $this->context())); } } diff --git a/tests/Challenge/SilentChallengeTest.php b/tests/Challenge/SilentChallengeTest.php index f3c22ef..bf7b353 100644 --- a/tests/Challenge/SilentChallengeTest.php +++ b/tests/Challenge/SilentChallengeTest.php @@ -3,83 +3,136 @@ namespace Utopia\WAF\Tests\Challenge; use PHPUnit\Framework\TestCase; +use Utopia\WAF\Challenge\Context; +use Utopia\WAF\Challenge\Scoring\Engine; +use Utopia\WAF\Challenge\Scoring\HeuristicEngine; +use Utopia\WAF\Challenge\Scoring\RiskTier; +use Utopia\WAF\Challenge\Scoring\Score; +use Utopia\WAF\Challenge\Scoring\Signals; use Utopia\WAF\Challenge\SilentChallenge; +use Utopia\WAF\Challenge\Signer; +/** + * The silent-tier orchestrator: issue a nonce, then on the returned attestation + * confirm the nonce's integrity and hand the attested signals to the scoring + * engine. Integrity is a hard gate — a forged/expired/mis-bound nonce yields null + * and the engine is never consulted; a live nonce yields the engine's Score. + */ class SilentChallengeTest extends TestCase { - public function testAlgorithmLabelReflectsMemory(): void + private const SECRET = 'unit-test-secret-please-rotate'; + + private function context(string $ip = '203.0.113.9'): Context { - $this->assertSame(SilentChallenge::ALGORITHM_SHA256, SilentChallenge::algorithm(0)); - $this->assertSame(SilentChallenge::ALGORITHM_SHA256, SilentChallenge::algorithm(-1)); - $this->assertSame(SilentChallenge::ALGORITHM_ROMIX, SilentChallenge::algorithm(1)); - $this->assertSame(SilentChallenge::ALGORITHM_ROMIX, SilentChallenge::algorithm(512)); + return new Context('proj-123', 'api', $ip); } - public function testClassicDigestIsPlainSha256(): void + private function silent(Engine $engine): SilentChallenge { - // memory <= 0 must be byte-identical to a plain SHA-256, so existing - // (non-memory) challenges keep verifying exactly as before. - $this->assertSame(\hash('sha256', 'abc', true), SilentChallenge::digest('abc', 0)); - $this->assertSame(\hash('sha256', 'abc', true), SilentChallenge::digest('abc', -5)); + return SilentChallenge::create(new Signer(self::SECRET), $engine); } - public function testRomixIsDeterministic(): void + public function testIssueMintsANonce(): void { - $this->assertSame(SilentChallenge::romix('seed.1', 128), SilentChallenge::romix('seed.1', 128)); + $challenge = $this->silent(new SpyEngine())->issue($this->context()); + + $this->assertArrayHasKey('nonce', $challenge); + $this->assertNotSame('', $challenge['nonce']); + } + + public function testVerifyReturnsEngineScoreForAuthenticNonce(): void + { + $verdict = new Score(0.42, RiskTier::INTERACTIVE, ['headless' => 0.42]); + $engine = new SpyEngine($verdict); + $silent = $this->silent($engine); + + $nonce = $silent->issue($this->context())['nonce']; + $signals = new Signals(['headless' => 1.0]); + + $score = $silent->verify($nonce, $signals, $this->context()); + + $this->assertSame($verdict, $score, 'the engine verdict is returned verbatim'); + $this->assertSame(1, $engine->calls, 'engine is consulted exactly once for a live nonce'); + $this->assertSame($signals, $engine->lastSignals, 'the attested signals are what gets scored'); + } + + public function testVerifyReturnsNullAndSkipsScoringForForgedNonce(): void + { + $engine = new SpyEngine(); + $silent = $this->silent($engine); + + // A nonce signed by another secret fails integrity. + $forged = SilentChallenge::create(new Signer('attacker-secret'), new SpyEngine()) + ->issue($this->context())['nonce']; + + $this->assertNull($silent->verify($forged, new Signals(['headless' => 0.0]), $this->context())); + $this->assertSame(0, $engine->calls, 'a forged nonce must not reach the engine'); + + // Garbage blobs too. + $this->assertNull($silent->verify('not-a-token', new Signals(), $this->context())); + $this->assertSame(0, $engine->calls); } - public function testRomixDiffersFromSha256AndAcrossMemory(): void + public function testVerifyReturnsNullForWrongContext(): void { - $input = 'challenge.42'; - $this->assertNotSame(\hash('sha256', $input, true), SilentChallenge::romix($input, 64)); - // Different scratchpad sizes are different functions. - $this->assertNotSame(SilentChallenge::romix($input, 64), SilentChallenge::romix($input, 128)); + $engine = new SpyEngine(); + $silent = $this->silent($engine); + + $nonce = $silent->issue($this->context('203.0.113.9'))['nonce']; + + // Different /24 network -> integrity fails, engine untouched. + $this->assertNull($silent->verify($nonce, new Signals(), $this->context('198.51.100.9'))); + $this->assertSame(0, $engine->calls); + + // Same /24 -> integrity passes, engine consulted. + $this->assertNotNull($silent->verify($nonce, new Signals(), $this->context('203.0.113.200'))); + $this->assertSame(1, $engine->calls); } - public function testRomixOutputSize(): void + public function testVerifyScoresAttestationWithRealEngine(): void { - $this->assertSame(32, \strlen(SilentChallenge::romix('x', 1))); - $this->assertSame(32, \strlen(SilentChallenge::romix('x', 256))); + $silent = $this->silent(new HeuristicEngine()); + + $nonce = $silent->issue($this->context())['nonce']; + + // A benign (empty) attestation scores as ALLOW through the real engine. + $benign = $silent->verify($nonce, new Signals(), $this->context()); + $this->assertInstanceOf(Score::class, $benign); + $this->assertSame(RiskTier::ALLOW, $benign->tier); + $this->assertSame(0.0, $benign->value); } - public function testRomixMemoryOfOneIsDoubleHash(): void + public function testClearanceTtlRoundTrip(): void { - // memory=1: V=[sha256(input)]; one mix round reads V[0], so the result is - // sha256(sha256(input) XOR sha256(input)) = sha256(32 zero bytes). - $expected = \hash('sha256', \str_repeat("\0", 32), true); - $this->assertSame($expected, SilentChallenge::romix('anything at all', 1)); + $silent = $this->silent(new SpyEngine()); + + $plain = $silent->issue($this->context()); + $this->assertNull($silent->clearanceTtl($plain['nonce'])); + + $withTtl = $silent->issue($this->context(), 1800); + $this->assertSame(1800, $silent->clearanceTtl($withTtl['nonce'])); } +} + +/** + * A test double for the scoring engine: records how often it was consulted and + * with what, and returns a preset verdict. + */ +final class SpyEngine implements Engine +{ + public int $calls = 0; + + public ?Signals $lastSignals = null; - public function testLeadingZeroBitsCounts(): void + public function __construct(private readonly ?Score $verdict = null) { - $this->assertSame(0, SilentChallenge::leadingZeroBits("\xff")); - $this->assertSame(8, SilentChallenge::leadingZeroBits("\x00\xff")); - $this->assertSame(9, SilentChallenge::leadingZeroBits("\x00\x7f")); - $this->assertSame(16, SilentChallenge::leadingZeroBits("\x00\x00")); } - public function testMeetsHonoursDifficultyAndMode(): void + public function score(Signals $signals): Score { - // Find a small classic solution, then confirm meets() agrees and that the - // same input under the memory-hard digest is an independent verdict. - $nonce = 'pow-test-nonce'; - $difficulty = 8; - - $solution = null; - for ($i = 0; $i < 5_000_000; $i++) { - if (SilentChallenge::meets($nonce . '.' . $i, $difficulty, 0)) { - $solution = (string) $i; - break; - } - } - - $this->assertNotNull($solution, 'expected to find a classic solution'); - $this->assertTrue(SilentChallenge::meets($nonce . '.' . $solution, $difficulty, 0)); - // The memory-hard digest of the same input is an independent function, so - // the modes cannot alias (deterministic check — no probabilistic verdict). - $this->assertNotSame( - SilentChallenge::digest($nonce . '.' . $solution, 0), - SilentChallenge::digest($nonce . '.' . $solution, 512), - ); + $this->calls++; + $this->lastSignals = $signals; + + return $this->verdict ?? new Score(0.0, RiskTier::ALLOW); } }