From 1c0d8717f25116fdab9a10de4d7720920e17f81b Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Mon, 8 Jun 2026 12:38:43 +0300 Subject: [PATCH 1/7] Fix HMAC CSRF token payload --- CHANGELOG.md | 1 + README.md | 20 ++++---- src/Hmac/HmacCsrfToken.php | 85 ++++++++++++++++++++------------ tests/Hmac/HmacCsrfTokenTest.php | 24 +++++++++ 4 files changed, 88 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c32f51..a14b513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.2.4 under development +- Bug #32: Stop exposing CSRF HMAC token identity in token payload and update OWASP documentation link (@samdark) - Enh #82: Explicitly import classes and functions in "use" section (@mspirkov) - Enh #83: Remove unnecessary files from Composer package (@mspirkov) diff --git a/README.md b/README.md index ba66755..2ede656 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ return [ In case Yii framework is used along with config plugin, the package is [configured](./config/di-web.php) automatically to use synchronizer token and masked decorator. You can change that depending on your needs. -Use synchronizer token for sensitive anonymous forms; use HMAC token for authenticated-only forms when a submitted -token may stay valid for a few minutes. +Use synchronizer token for sensitive anonymous forms; use HMAC token for authenticated-only forms when it is acceptable +for a submitted token to stay valid until it expires. ```mermaid flowchart TD @@ -168,7 +168,7 @@ Detailed comparison: | File based session GC | May scan session files | Not triggered by CSRF token storage | | Token storage growth | Depends on session storage | Nothing to store | | Token revocation | Possible by removing stored token | Not possible before token expiration | -| Replay within lifetime | Prevented by storage policy | Possible until the token expires | +| Replay within lifetime | Prevented by storage policy | Possible until expiration | To switch token to HMAC: @@ -205,12 +205,12 @@ Package provides `RandomCsrfTokenGenerator` that generates a random token and To learn more about the synchronizer token pattern, [check OWASP CSRF cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern). -### HMAC based token +### HMAC signed token -HMAC based token is a stateless CSRF token that does not require any storage. The token is a hash from session ID and -a timestamp used to prevent replay attacks. The token is added to a form. When the form is submitted, we re-generate -the token from the current session ID and a timestamp from the original token. If two hashes match, we check that the -timestamp is less than the token lifetime. +HMAC signed token is a stateless CSRF token that does not require any storage. The token contains expiration timestamp +and random value, and its signature is bound to the current session ID. The token is added to a form. When the form is +submitted, we verify the token signature, check that it belongs to the current session ID, and check that it has not +expired. `HmacCsrfToken` requires implementation of `CsrfTokenIdentityGeneratorInterface` for generating an identity. The package provides `SessionCsrfTokenIdentityGenerator` that is using session ID thus making the session a token scope. @@ -235,8 +235,8 @@ return [ ]; ``` -To learn more about HMAC based token pattern -[check OWASP CSRF cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#hmac-based-token-pattern). +To learn more about employing HMAC CSRF tokens, check the +[OWASP CSRF cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#employing-hmac-csrf-tokens). ### Stub CSRF token diff --git a/src/Hmac/HmacCsrfToken.php b/src/Hmac/HmacCsrfToken.php index a8a5487..704abd5 100644 --- a/src/Hmac/HmacCsrfToken.php +++ b/src/Hmac/HmacCsrfToken.php @@ -4,37 +4,40 @@ namespace Yiisoft\Csrf\Hmac; +use RuntimeException; use Yiisoft\Csrf\CsrfTokenInterface; use Yiisoft\Csrf\Hmac\IdentityGenerator\CsrfTokenIdentityGeneratorInterface; -use Yiisoft\Security\DataIsTamperedException; -use Yiisoft\Security\Mac; -use Yiisoft\Strings\StringHelper; use Yiisoft\Csrf\MaskedCsrfToken; +use Yiisoft\Security\Random; +use Yiisoft\Strings\StringHelper; use function count; +use function hash_equals; +use function hash_hmac; /** - * Stateless CSRF token that does not require any storage. The token is a hash from session ID and a timestamp - * (to prevent replay attacks). It is added to forms. When the form is submitted, we re-generate the token from - * the current session ID and a timestamp from the original token. If two hashes match, we check that timestamp is - * less than {@see HmacCsrfToken::$lifetime}. - * - * The algorithm is also known as "HMAC Based Token". + * Stateless CSRF token that does not require any storage. The token contains expiration timestamp and random value, + * and is signed with a session-bound identity. It is added to forms. When the form is submitted, we verify the token + * signature, check that it belongs to the current session identity, and check that it has not expired. * * Do not forget to decorate the token with {@see MaskedCsrfToken} to prevent BREACH attack. * - * @link https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#hmac-based-token-pattern + * @link https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#employing-hmac-csrf-tokens */ final class HmacCsrfToken implements CsrfTokenInterface { private CsrfTokenIdentityGeneratorInterface $identityGenerator; - private Mac $mac; /** * @var string Shared secret key used to generate the hash. */ private string $secretKey; + /** + * @var string Hash algorithm for message authentication. + */ + private string $algorithm; + /** * @var int|null Number of seconds that the token is valid for. */ @@ -47,8 +50,8 @@ public function __construct( ?int $lifetime = null ) { $this->identityGenerator = $identityGenerator; - $this->mac = new Mac($algorithm); $this->secretKey = $secretKey; + $this->algorithm = $algorithm; $this->lifetime = $lifetime; } @@ -66,39 +69,43 @@ public function validate(string $token): bool return false; } - [$expiration, $identity] = $data; + [$expiration, $payload] = $data; - if ($expiration !== null && time() > $expiration) { + $hashLength = $this->getHashLength(); + $hash = StringHelper::byteSubstring($payload, 0, $hashLength); + $message = StringHelper::byteSubstring($payload, $hashLength, null); + + if (!hash_equals($hash, $this->generateHash($message))) { return false; } - return $identity === $this->identityGenerator->generate(); + if ($expiration !== null && time() > $expiration) { + return false; + } + return true; } private function generateToken(?int $expiration): string { - return StringHelper::base64UrlEncode( - $this->mac->sign( - (string) $expiration . '~' . $this->identityGenerator->generate(), - $this->secretKey, - true, - ), - ); + $message = (string) $expiration . '~' . Random::string(32); + + return StringHelper::base64UrlEncode($this->generateHash($message) . $message); } + /** + * @return array{0: int|null, 1: string}|null + */ private function extractData(string $token): ?array { - try { - $raw = $this->mac->getMessage( - StringHelper::base64UrlDecode($token), - $this->secretKey, - true, - ); - } catch (DataIsTamperedException $e) { + $payload = StringHelper::base64UrlDecode($token); + $hashLength = $this->getHashLength(); + + if (StringHelper::byteLength($payload) <= $hashLength) { return null; } - $chunks = explode('~', $raw, 2); + $message = StringHelper::byteSubstring($payload, $hashLength, null); + $chunks = explode('~', $message, 2); if (count($chunks) !== 2) { return null; } @@ -112,8 +119,22 @@ private function extractData(string $token): ?array } } - $identity = $chunks[1]; + return [$expiration, $payload]; + } - return [$expiration, $identity]; + private function generateHash(string $message): string + { + $identity = $this->identityGenerator->generate(); + $message = StringHelper::byteLength($identity) . '~' . $identity . '~' . $message; + $hash = hash_hmac($this->algorithm, $message, $this->secretKey, true); + if (!$hash) { + throw new RuntimeException("Failed to generate HMAC with hash algorithm: {$this->algorithm}."); + } + return $hash; + } + + private function getHashLength(): int + { + return StringHelper::byteLength($this->generateHash('')); } } diff --git a/tests/Hmac/HmacCsrfTokenTest.php b/tests/Hmac/HmacCsrfTokenTest.php index f25cafe..859a84a 100644 --- a/tests/Hmac/HmacCsrfTokenTest.php +++ b/tests/Hmac/HmacCsrfTokenTest.php @@ -38,6 +38,30 @@ public function testBase(): void $this->assertTrue($csrfToken->validate($token)); } + public function testTokenValueChanges(): void + { + $csrfToken = new HmacCsrfToken( + new MockCsrfTokenIdentityGenerator('user7'), + 'mySecretKey', + ); + + $this->assertNotSame($csrfToken->getValue(), $csrfToken->getValue()); + } + + public function testTokenDoesNotExposeIdentity(): void + { + $identity = 'session-id-that-must-not-be-in-token'; + $csrfToken = new HmacCsrfToken( + new MockCsrfTokenIdentityGenerator($identity), + 'mySecretKey', + ); + + $token = $csrfToken->getValue(); + + $this->assertStringNotContainsString($identity, StringHelper::base64UrlDecode($token)); + $this->assertTrue($csrfToken->validate($token)); + } + public function testExpiration(): void { self::$timeResult = 300; From 90d93c54f13d1d157d58db0982e38c309490a5b6 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 11 Jun 2026 14:33:38 +0300 Subject: [PATCH 2/7] Address HMAC token review comments --- src/Hmac/HmacCsrfToken.php | 31 ++++++++++++++++++++++--------- tests/Hmac/HmacCsrfTokenTest.php | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/Hmac/HmacCsrfToken.php b/src/Hmac/HmacCsrfToken.php index 704abd5..f44e9c1 100644 --- a/src/Hmac/HmacCsrfToken.php +++ b/src/Hmac/HmacCsrfToken.php @@ -4,6 +4,7 @@ namespace Yiisoft\Csrf\Hmac; +use InvalidArgumentException; use RuntimeException; use Yiisoft\Csrf\CsrfTokenInterface; use Yiisoft\Csrf\Hmac\IdentityGenerator\CsrfTokenIdentityGeneratorInterface; @@ -38,6 +39,11 @@ final class HmacCsrfToken implements CsrfTokenInterface */ private string $algorithm; + /** + * @var int Hash length in bytes. + */ + private int $hashLength; + /** * @var int|null Number of seconds that the token is valid for. */ @@ -52,6 +58,7 @@ public function __construct( $this->identityGenerator = $identityGenerator; $this->secretKey = $secretKey; $this->algorithm = $algorithm; + $this->hashLength = $this->generateHashLength(); $this->lifetime = $lifetime; } @@ -71,9 +78,8 @@ public function validate(string $token): bool [$expiration, $payload] = $data; - $hashLength = $this->getHashLength(); - $hash = StringHelper::byteSubstring($payload, 0, $hashLength); - $message = StringHelper::byteSubstring($payload, $hashLength, null); + $hash = StringHelper::byteSubstring($payload, 0, $this->hashLength); + $message = StringHelper::byteSubstring($payload, $this->hashLength, null); if (!hash_equals($hash, $this->generateHash($message))) { return false; @@ -97,14 +103,17 @@ private function generateToken(?int $expiration): string */ private function extractData(string $token): ?array { - $payload = StringHelper::base64UrlDecode($token); - $hashLength = $this->getHashLength(); + try { + $payload = StringHelper::base64UrlDecode($token); + } catch (InvalidArgumentException $e) { + return null; + } - if (StringHelper::byteLength($payload) <= $hashLength) { + if (StringHelper::byteLength($payload) <= $this->hashLength) { return null; } - $message = StringHelper::byteSubstring($payload, $hashLength, null); + $message = StringHelper::byteSubstring($payload, $this->hashLength, null); $chunks = explode('~', $message, 2); if (count($chunks) !== 2) { return null; @@ -133,8 +142,12 @@ private function generateHash(string $message): string return $hash; } - private function getHashLength(): int + private function generateHashLength(): int { - return StringHelper::byteLength($this->generateHash('')); + $hash = hash_hmac($this->algorithm, '', '', true); + if (!$hash) { + throw new RuntimeException("Failed to generate HMAC with hash algorithm: {$this->algorithm}."); + } + return StringHelper::byteLength($hash); } } diff --git a/tests/Hmac/HmacCsrfTokenTest.php b/tests/Hmac/HmacCsrfTokenTest.php index 859a84a..ec8374f 100644 --- a/tests/Hmac/HmacCsrfTokenTest.php +++ b/tests/Hmac/HmacCsrfTokenTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Csrf\Hmac\HmacCsrfToken; +use Yiisoft\Csrf\Hmac\IdentityGenerator\CsrfTokenIdentityGeneratorInterface; use Yiisoft\Csrf\Tests\Hmac\IdentityGenerator\MockCsrfTokenIdentityGenerator; use Yiisoft\Security\Mac; use Yiisoft\Security\Random; @@ -92,6 +93,7 @@ public function testIncorrectToken(): void ); $this->assertFalse($csrfToken->validate(Random::string())); + $this->assertFalse($csrfToken->validate('*')); $token = StringHelper::base64UrlEncode( (new Mac('sha256'))->sign('a2~user1', 'mySecretKey', true), @@ -104,6 +106,23 @@ public function testIncorrectToken(): void $this->assertFalse($csrfToken->validate($token)); } + public function testInvalidTokenParsingDoesNotGenerateIdentity(): void + { + $identityGenerator = new class implements CsrfTokenIdentityGeneratorInterface { + public int $calls = 0; + + public function generate(): string + { + $this->calls++; + return 'user7'; + } + }; + $csrfToken = new HmacCsrfToken($identityGenerator, 'mySecretKey'); + + $this->assertFalse($csrfToken->validate(StringHelper::base64UrlEncode('short'))); + $this->assertSame(0, $identityGenerator->calls); + } + public function testIdentityWithTilda(): void { $csrfToken = new HmacCsrfToken( From 0ab86efcfa4af8872171f750b2276b1750fe4a5d Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 11 Jun 2026 14:44:33 +0300 Subject: [PATCH 3/7] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a14b513..4c3a6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 2.2.4 under development -- Bug #32: Stop exposing CSRF HMAC token identity in token payload and update OWASP documentation link (@samdark) +- Bug #32: Stop exposing CSRF HMAC token identity in token payload (@samdark) - Enh #82: Explicitly import classes and functions in "use" section (@mspirkov) - Enh #83: Remove unnecessary files from Composer package (@mspirkov) From 5f8674aa0e1a506d01f5c3158f7b470bdead0812 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 11 Jun 2026 14:48:37 +0300 Subject: [PATCH 4/7] Add HMAC token mutation tests --- src/Hmac/HmacCsrfToken.php | 6 +--- tests/Hmac/HmacCsrfTokenTest.php | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/Hmac/HmacCsrfToken.php b/src/Hmac/HmacCsrfToken.php index f44e9c1..95dbc05 100644 --- a/src/Hmac/HmacCsrfToken.php +++ b/src/Hmac/HmacCsrfToken.php @@ -93,7 +93,7 @@ public function validate(string $token): bool private function generateToken(?int $expiration): string { - $message = (string) $expiration . '~' . Random::string(32); + $message = ($expiration ?? '') . '~' . Random::string(32); return StringHelper::base64UrlEncode($this->generateHash($message) . $message); } @@ -109,10 +109,6 @@ private function extractData(string $token): ?array return null; } - if (StringHelper::byteLength($payload) <= $this->hashLength) { - return null; - } - $message = StringHelper::byteSubstring($payload, $this->hashLength, null); $chunks = explode('~', $message, 2); if (count($chunks) !== 2) { diff --git a/tests/Hmac/HmacCsrfTokenTest.php b/tests/Hmac/HmacCsrfTokenTest.php index ec8374f..54981aa 100644 --- a/tests/Hmac/HmacCsrfTokenTest.php +++ b/tests/Hmac/HmacCsrfTokenTest.php @@ -63,6 +63,23 @@ public function testTokenDoesNotExposeIdentity(): void $this->assertTrue($csrfToken->validate($token)); } + public function testTokenPayloadContainsExpirationAndRandomValue(): void + { + self::$timeResult = 300; + + $csrfToken = new HmacCsrfToken( + new MockCsrfTokenIdentityGenerator('user7'), + 'mySecretKey', + 'sha256', + 100, + ); + + $payload = StringHelper::base64UrlDecode($csrfToken->getValue()); + $message = StringHelper::byteSubstring($payload, $this->getHashLength(), null); + + $this->assertMatchesRegularExpression('/^400~[A-Za-z0-9_-]{32}$/', $message); + } + public function testExpiration(): void { self::$timeResult = 300; @@ -106,6 +123,32 @@ public function testIncorrectToken(): void $this->assertFalse($csrfToken->validate($token)); } + public function testValidatesTokenSignedWithCurrentIdentityAndMessage(): void + { + self::$timeResult = 300; + + $csrfToken = new HmacCsrfToken( + new MockCsrfTokenIdentityGenerator('user7'), + 'mySecretKey', + ); + + $this->assertTrue($csrfToken->validate($this->createToken('user7', '500~random-value-with~delimiter'))); + } + + public function testRejectsSignedTokenWithMalformedMessage(): void + { + self::$timeResult = 300; + + $csrfToken = new HmacCsrfToken( + new MockCsrfTokenIdentityGenerator('user7'), + 'mySecretKey', + ); + + $this->assertFalse($csrfToken->validate($this->createToken('user7', '500'))); + $this->assertFalse($csrfToken->validate($this->createToken('user7', '0500~random-value'))); + $this->assertFalse($csrfToken->validate($this->createToken('user7', 'not-a-timestamp~random-value'))); + } + public function testInvalidTokenParsingDoesNotGenerateIdentity(): void { $identityGenerator = new class implements CsrfTokenIdentityGeneratorInterface { @@ -134,6 +177,18 @@ public function testIdentityWithTilda(): void $this->assertTrue($csrfToken->validate($token)); } + + private function createToken(string $identity, string $message): string + { + $signedMessage = StringHelper::byteLength($identity) . '~' . $identity . '~' . $message; + + return StringHelper::base64UrlEncode(hash_hmac('sha256', $signedMessage, 'mySecretKey', true) . $message); + } + + private function getHashLength(): int + { + return StringHelper::byteLength(hash_hmac('sha256', '', '', true)); + } } namespace Yiisoft\Csrf\Hmac; From 30a4626d347e6074658abf59462ef071f3531498 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 11 Jun 2026 15:09:24 +0300 Subject: [PATCH 5/7] Address HMAC token review feedback --- README.md | 8 ++++---- src/Hmac/HmacCsrfToken.php | 7 ++++--- tests/Hmac/HmacCsrfTokenTest.php | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2ede656..3f3e12f 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ return [ In case Yii framework is used along with config plugin, the package is [configured](./config/di-web.php) automatically to use synchronizer token and masked decorator. You can change that depending on your needs. -Use synchronizer token for sensitive anonymous forms; use HMAC token for authenticated-only forms when it is acceptable -for a submitted token to stay valid until it expires. +Use synchronizer token for sensitive anonymous forms; use HMAC signed token for authenticated-only forms when it is +acceptable for a submitted token to stay valid until it expires. ```mermaid flowchart TD @@ -162,7 +162,7 @@ flowchart TD Detailed comparison: -| Factor | Synchronizer | HMAC | +| Factor | Synchronizer | HMAC signed | |--------|--------------|------| | I/O per request | Session read and write | No token storage I/O | | File based session GC | May scan session files | Not triggered by CSRF token storage | @@ -170,7 +170,7 @@ Detailed comparison: | Token revocation | Possible by removing stored token | Not possible before token expiration | | Replay within lifetime | Prevented by storage policy | Possible until expiration | -To switch token to HMAC: +To switch to HMAC signed token: ```php use Yiisoft\Csrf\CsrfTokenInterface; diff --git a/src/Hmac/HmacCsrfToken.php b/src/Hmac/HmacCsrfToken.php index 95dbc05..65b0c51 100644 --- a/src/Hmac/HmacCsrfToken.php +++ b/src/Hmac/HmacCsrfToken.php @@ -78,6 +78,10 @@ public function validate(string $token): bool [$expiration, $payload] = $data; + if ($expiration !== null && time() > $expiration) { + return false; + } + $hash = StringHelper::byteSubstring($payload, 0, $this->hashLength); $message = StringHelper::byteSubstring($payload, $this->hashLength, null); @@ -85,9 +89,6 @@ public function validate(string $token): bool return false; } - if ($expiration !== null && time() > $expiration) { - return false; - } return true; } diff --git a/tests/Hmac/HmacCsrfTokenTest.php b/tests/Hmac/HmacCsrfTokenTest.php index 54981aa..8e24581 100644 --- a/tests/Hmac/HmacCsrfTokenTest.php +++ b/tests/Hmac/HmacCsrfTokenTest.php @@ -166,6 +166,25 @@ public function generate(): string $this->assertSame(0, $identityGenerator->calls); } + public function testExpiredTokenDoesNotGenerateIdentity(): void + { + self::$timeResult = 300; + + $identityGenerator = new class implements CsrfTokenIdentityGeneratorInterface { + public int $calls = 0; + + public function generate(): string + { + $this->calls++; + return 'user7'; + } + }; + $csrfToken = new HmacCsrfToken($identityGenerator, 'mySecretKey'); + + $this->assertFalse($csrfToken->validate($this->createToken('user7', '299~random-value'))); + $this->assertSame(0, $identityGenerator->calls); + } + public function testIdentityWithTilda(): void { $csrfToken = new HmacCsrfToken( From f1ef8a1b34e18280db0de2f4283e61e69c95c328 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 00:43:43 +0300 Subject: [PATCH 6/7] Address HMAC token review comments --- README.md | 7 +++--- src/Hmac/HmacCsrfToken.php | 34 +++++++++++++------------- tests/Hmac/HmacCsrfTokenTest.php | 41 ++++++++++++-------------------- 3 files changed, 36 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 3f3e12f..c4cf52a 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ flowchart TD C -- No --> S C -- Yes --> D{Token replay within lifetime OK?} D -- No --> S - D -- Yes --> H[HMAC] + D -- Yes --> H[HMAC signed] ``` Detailed comparison: @@ -208,9 +208,8 @@ To learn more about the synchronizer token pattern, ### HMAC signed token HMAC signed token is a stateless CSRF token that does not require any storage. The token contains expiration timestamp -and random value, and its signature is bound to the current session ID. The token is added to a form. When the form is -submitted, we verify the token signature, check that it belongs to the current session ID, and check that it has not -expired. +and its signature is bound to the current identity. The token is added to a form. When the form is submitted, we verify +the token signature, check that it belongs to the current identity, and check that it has not expired. `HmacCsrfToken` requires implementation of `CsrfTokenIdentityGeneratorInterface` for generating an identity. The package provides `SessionCsrfTokenIdentityGenerator` that is using session ID thus making the session a token scope. diff --git a/src/Hmac/HmacCsrfToken.php b/src/Hmac/HmacCsrfToken.php index 65b0c51..e6ae704 100644 --- a/src/Hmac/HmacCsrfToken.php +++ b/src/Hmac/HmacCsrfToken.php @@ -9,17 +9,15 @@ use Yiisoft\Csrf\CsrfTokenInterface; use Yiisoft\Csrf\Hmac\IdentityGenerator\CsrfTokenIdentityGeneratorInterface; use Yiisoft\Csrf\MaskedCsrfToken; -use Yiisoft\Security\Random; use Yiisoft\Strings\StringHelper; -use function count; use function hash_equals; use function hash_hmac; /** - * Stateless CSRF token that does not require any storage. The token contains expiration timestamp and random value, - * and is signed with a session-bound identity. It is added to forms. When the form is submitted, we verify the token - * signature, check that it belongs to the current session identity, and check that it has not expired. + * Stateless CSRF token that does not require any storage. The token contains an expiration timestamp and is signed with + * an identity-bound key. It is added to forms. When the form is submitted, we verify the token signature, check that it + * belongs to the current identity, and check that it has not expired. * * Do not forget to decorate the token with {@see MaskedCsrfToken} to prevent BREACH attack. * @@ -53,12 +51,12 @@ public function __construct( CsrfTokenIdentityGeneratorInterface $identityGenerator, string $secretKey, string $algorithm = 'sha256', - ?int $lifetime = null + ?int $lifetime = null, ) { $this->identityGenerator = $identityGenerator; $this->secretKey = $secretKey; $this->algorithm = $algorithm; - $this->hashLength = $this->generateHashLength(); + $this->hashLength = $this->calcHashLength(); $this->lifetime = $lifetime; } @@ -94,7 +92,7 @@ public function validate(string $token): bool private function generateToken(?int $expiration): string { - $message = ($expiration ?? '') . '~' . Random::string(32); + $message = (string) $expiration; return StringHelper::base64UrlEncode($this->generateHash($message) . $message); } @@ -110,17 +108,16 @@ private function extractData(string $token): ?array return null; } - $message = StringHelper::byteSubstring($payload, $this->hashLength, null); - $chunks = explode('~', $message, 2); - if (count($chunks) !== 2) { + if (StringHelper::byteLength($payload) < $this->hashLength) { return null; } - if ($chunks[0] === '') { + $message = StringHelper::byteSubstring($payload, $this->hashLength, null); + if ($message === '') { $expiration = null; } else { - $expiration = (int) $chunks[0]; - if ((string) $expiration !== $chunks[0]) { + $expiration = (int) $message; + if ((string) $expiration !== $message) { return null; } } @@ -132,14 +129,19 @@ private function generateHash(string $message): string { $identity = $this->identityGenerator->generate(); $message = StringHelper::byteLength($identity) . '~' . $identity . '~' . $message; - $hash = hash_hmac($this->algorithm, $message, $this->secretKey, true); + $hash = hash_hmac( + $this->algorithm, + $message, + $this->secretKey, + true, + ); if (!$hash) { throw new RuntimeException("Failed to generate HMAC with hash algorithm: {$this->algorithm}."); } return $hash; } - private function generateHashLength(): int + private function calcHashLength(): int { $hash = hash_hmac($this->algorithm, '', '', true); if (!$hash) { diff --git a/tests/Hmac/HmacCsrfTokenTest.php b/tests/Hmac/HmacCsrfTokenTest.php index 8e24581..d3f39ee 100644 --- a/tests/Hmac/HmacCsrfTokenTest.php +++ b/tests/Hmac/HmacCsrfTokenTest.php @@ -8,7 +8,6 @@ use Yiisoft\Csrf\Hmac\HmacCsrfToken; use Yiisoft\Csrf\Hmac\IdentityGenerator\CsrfTokenIdentityGeneratorInterface; use Yiisoft\Csrf\Tests\Hmac\IdentityGenerator\MockCsrfTokenIdentityGenerator; -use Yiisoft\Security\Mac; use Yiisoft\Security\Random; use Yiisoft\Strings\StringHelper; @@ -39,16 +38,6 @@ public function testBase(): void $this->assertTrue($csrfToken->validate($token)); } - public function testTokenValueChanges(): void - { - $csrfToken = new HmacCsrfToken( - new MockCsrfTokenIdentityGenerator('user7'), - 'mySecretKey', - ); - - $this->assertNotSame($csrfToken->getValue(), $csrfToken->getValue()); - } - public function testTokenDoesNotExposeIdentity(): void { $identity = 'session-id-that-must-not-be-in-token'; @@ -63,7 +52,7 @@ public function testTokenDoesNotExposeIdentity(): void $this->assertTrue($csrfToken->validate($token)); } - public function testTokenPayloadContainsExpirationAndRandomValue(): void + public function testTokenPayloadContainsExpiration(): void { self::$timeResult = 300; @@ -77,7 +66,7 @@ public function testTokenPayloadContainsExpirationAndRandomValue(): void $payload = StringHelper::base64UrlDecode($csrfToken->getValue()); $message = StringHelper::byteSubstring($payload, $this->getHashLength(), null); - $this->assertMatchesRegularExpression('/^400~[A-Za-z0-9_-]{32}$/', $message); + $this->assertSame('400', $message); } public function testExpiration(): void @@ -112,18 +101,14 @@ public function testIncorrectToken(): void $this->assertFalse($csrfToken->validate(Random::string())); $this->assertFalse($csrfToken->validate('*')); - $token = StringHelper::base64UrlEncode( - (new Mac('sha256'))->sign('a2~user1', 'mySecretKey', true), - ); + $token = $this->createToken('user7', 'a2'); $this->assertFalse($csrfToken->validate($token)); - $token = StringHelper::base64UrlEncode( - (new Mac('sha256'))->sign('hello', 'mySecretKey', true), - ); + $token = $this->createToken('user7', 'hello'); $this->assertFalse($csrfToken->validate($token)); } - public function testValidatesTokenSignedWithCurrentIdentityAndMessage(): void + public function testValidatesTokenSignedWithCurrentIdentity(): void { self::$timeResult = 300; @@ -132,7 +117,7 @@ public function testValidatesTokenSignedWithCurrentIdentityAndMessage(): void 'mySecretKey', ); - $this->assertTrue($csrfToken->validate($this->createToken('user7', '500~random-value-with~delimiter'))); + $this->assertTrue($csrfToken->validate($this->createToken('user7', '500'))); } public function testRejectsSignedTokenWithMalformedMessage(): void @@ -144,9 +129,9 @@ public function testRejectsSignedTokenWithMalformedMessage(): void 'mySecretKey', ); - $this->assertFalse($csrfToken->validate($this->createToken('user7', '500'))); - $this->assertFalse($csrfToken->validate($this->createToken('user7', '0500~random-value'))); - $this->assertFalse($csrfToken->validate($this->createToken('user7', 'not-a-timestamp~random-value'))); + $this->assertFalse($csrfToken->validate($this->createToken('user7', '0500'))); + $this->assertFalse($csrfToken->validate($this->createToken('user7', 'not-a-timestamp'))); + $this->assertFalse($csrfToken->validate($this->createToken('user7', '500~extra'))); } public function testInvalidTokenParsingDoesNotGenerateIdentity(): void @@ -181,7 +166,7 @@ public function generate(): string }; $csrfToken = new HmacCsrfToken($identityGenerator, 'mySecretKey'); - $this->assertFalse($csrfToken->validate($this->createToken('user7', '299~random-value'))); + $this->assertFalse($csrfToken->validate($this->createToken('user7', '299'))); $this->assertSame(0, $identityGenerator->calls); } @@ -200,8 +185,12 @@ public function testIdentityWithTilda(): void private function createToken(string $identity, string $message): string { $signedMessage = StringHelper::byteLength($identity) . '~' . $identity . '~' . $message; + $hash = hash_hmac('sha256', $signedMessage, 'mySecretKey', true); + if (!$hash) { + self::fail('Failed to generate HMAC.'); + } - return StringHelper::base64UrlEncode(hash_hmac('sha256', $signedMessage, 'mySecretKey', true) . $message); + return StringHelper::base64UrlEncode($hash . $message); } private function getHashLength(): int From d0d7a699b3b0b867c487efdd2f385339a1dbe9a3 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 00:45:58 +0300 Subject: [PATCH 7/7] Revert HMAC token formatting-only changes --- src/Hmac/HmacCsrfToken.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Hmac/HmacCsrfToken.php b/src/Hmac/HmacCsrfToken.php index e6ae704..3e5d4d6 100644 --- a/src/Hmac/HmacCsrfToken.php +++ b/src/Hmac/HmacCsrfToken.php @@ -51,7 +51,7 @@ public function __construct( CsrfTokenIdentityGeneratorInterface $identityGenerator, string $secretKey, string $algorithm = 'sha256', - ?int $lifetime = null, + ?int $lifetime = null ) { $this->identityGenerator = $identityGenerator; $this->secretKey = $secretKey; @@ -129,12 +129,7 @@ private function generateHash(string $message): string { $identity = $this->identityGenerator->generate(); $message = StringHelper::byteLength($identity) . '~' . $identity . '~' . $message; - $hash = hash_hmac( - $this->algorithm, - $message, - $this->secretKey, - true, - ); + $hash = hash_hmac($this->algorithm, $message, $this->secretKey, true); if (!$hash) { throw new RuntimeException("Failed to generate HMAC with hash algorithm: {$this->algorithm}."); }