diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..aa49c943 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,9 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3" + +mkdocs: + configuration: mkdocs.yml diff --git a/docs/rotating-keys.md b/docs/rotating-keys.md new file mode 100644 index 00000000..25f17840 --- /dev/null +++ b/docs/rotating-keys.md @@ -0,0 +1,216 @@ +# Rotating Keys + +Key rotation consists in retiring and replacing cryptographic keys with new ones. +Performing that operation on a regular basis is an industry standard. + +## Why should I rotate my keys? + +Rotating keys allows us to: + +1. Limit the number of tokens signed with the same key, helping the prevention of attacks enabled by cryptanalysis +2. Adopt other algorithms or stronger keys +3. Limit the impact of eventual compromised keys + +## The challenges + +After rotating keys, apps will likely receive requests with tokens issues with the previous key. +If the key rotation of an app is done with a "hard cut", requests with non-expired tokens issued with the old key **will fail**! + +Imagine if you were the user who logged in just before a key rotation on that kind of app, you'd probably have to log in again! + +That's rather frustrating, right!? + +## Preventing issues + +It's possible to handle key rotation in a smoother way by leveraging the `SignedWithOneInSet` validation constraint! + +Say your application uses the symmetric algorithm `HS256` with a not so secure key to issue tokens: + +```php +issue( + new Signer\Hmac\Sha256(), + InMemory::plainText( + 'a-very-long-and-secure-key-that-should-actually-be-something-else' + ), + static fn (Builder $builder): Builder => $builder + ->issuedBy('https://api.my-awesome-app.io') + ->permittedFor('https://client-app.io') +); +``` + +!!! Sample + Here's a token issued with the code above, if you want to test the script locally: + +
+ Sample token + + // line breaks added for readability + eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 + .eyJpYXQiOjE2OTkxMzE5NjEsIm5iZiI6MTY5OTEzMTk2MSwiZXhwIjoxNjk5MTMyMjYxLCJpc3MiOiJ + odHRwczovL2FwaS5teS1hd2Vzb21lLWFwcC5pbyIsImF1ZCI6Imh0dHBzOi8vY2xpZW50LWFwcC5pbyJ9 + .IA9S0n8Q2O97lyR8KczVE8g-hxbbH6_TfJS-JWTQR4c +
+ +Your parsing logic (with validations) look like: + +```php +parse($jwt, ...$validationConstraints); +``` + +### Performing a backwards compatible rotation + +Now Imagine that you want to adopt the new `BLAKE2B` symmetric algorithm. + +These are the changes to your issuing logic: + +```diff + issue( +- new Signer\Hmac\Sha256(), ++ new Signer\Blake2b(), +- InMemory::plainText( +- 'a-very-long-and-secure-key-that-should-actually-be-something-else' ++ InMemory::base64Encoded( ++ 'GOu4rLyVCBxmxP+sbniU68ojAja5PkRdvv7vNvBCqDQ=' + ), + static fn (Builder $builder): Builder => $builder + ->issuedBy('https://api.my-awesome-app.io') + ->permittedFor('https://client-app.io') + ); +``` + +!!! Sample + Here's a token issued with the code above, if you want to test the script locally: + +
+ Sample token + + // line breaks added for readability + eyJ0eXAiOiJKV1QiLCJhbGciOiJCTEFLRTJCIn0 + .eyJpYXQiOjE2OTkxMzE5NjEsIm5iZiI6MTY5OTEzMTk2MSwiZXhwIjoxNjk5MTMyMjYxLCJpc3Mi + OiJodHRwczovL2FwaS5teS1hd2Vzb21lLWFwcC5pbyIsImF1ZCI6Imh0dHBzOi8vY2xpZW50LWFwc + C5pbyJ9.bD67s8IXpAJiBTIZn1et_M5WSS7kfmuNiacNRz5lArQ +
+ +So far, nothing different that a normal rotation. + +Now check the changes on the parsing and validation logic: + +```diff + parse($jwt, ...$validationConstraints); +``` + +Now the application is able to accept non-expired tokens issued with either old and new keys! +In this case, the old key would automatically only be accepted until `2023-12-31 23:59:59+00:00`, even if engineers forget to remove it from the list. + +!!! Important` + The order of `SignedWithUntilDate` constraints given to `SignedWithOneInSet` does matter, and it's recommended to leave older keys at the end of the list. diff --git a/docs/validating-tokens.md b/docs/validating-tokens.md index 7e130db9..fb5190a9 100644 --- a/docs/validating-tokens.md +++ b/docs/validating-tokens.md @@ -85,6 +85,8 @@ This library provides the following constraints: * `Lcobucci\JWT\Validation\Constraint\PermittedFor`: verifies if the claim `aud` contains the expected value * `Lcobucci\JWT\Validation\Constraint\RelatedTo`: verifies if the claim `sub` matches the expected value * `Lcobucci\JWT\Validation\Constraint\SignedWith`: verifies if the token was signed with the expected signer and key +* `Lcobucci\JWT\Validation\Constraint\SignedWithOneInSet`: verifies the token signature against multiple `SignedWithUntilDate` constraints +* `Lcobucci\JWT\Validation\Constraint\SignedWithUntilDate`: verifies if the token was signed with the expected signer and key (until a certain date) * `Lcobucci\JWT\Validation\Constraint\StrictValidAt`: verifies presence and validity of the claims `iat`, `nbf`, and `exp` (supports leeway configuration) * `Lcobucci\JWT\Validation\Constraint\LooseValidAt`: verifies the claims `iat`, `nbf`, and `exp`, when present (supports leeway configuration) * `Lcobucci\JWT\Validation\Constraint\HasClaimWithValue`: verifies that a **custom claim** has the expected value (not recommended when comparing cryptographic hashes) diff --git a/mkdocs.yml b/mkdocs.yml index 8dbba334..1a3ef858 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ nav: - 'configuration.md' - Guides: - 'extending-the-library.md' + - 'rotating-keys.md' - 'upgrading.md' markdown_extensions: diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 91f86967..27ae9c74 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -16,7 +16,7 @@ - tests/UnsupportedParser.php + tests diff --git a/src/Validation/Constraint/SignedWithOneInSet.php b/src/Validation/Constraint/SignedWithOneInSet.php new file mode 100644 index 00000000..fb542fb3 --- /dev/null +++ b/src/Validation/Constraint/SignedWithOneInSet.php @@ -0,0 +1,38 @@ + */ + private readonly array $constraints; + + public function __construct(SignedWithUntilDate ...$constraints) + { + $this->constraints = $constraints; + } + + public function assert(Token $token): void + { + $errorMessage = 'It was not possible to verify the signature of the token, reasons:'; + + foreach ($this->constraints as $constraint) { + try { + $constraint->assert($token); + + return; + } catch (ConstraintViolation $violation) { + $errorMessage .= PHP_EOL . '- ' . $violation->getMessage(); + } + } + + throw ConstraintViolation::error($errorMessage, $this); + } +} diff --git a/src/Validation/Constraint/SignedWithUntilDate.php b/src/Validation/Constraint/SignedWithUntilDate.php new file mode 100644 index 00000000..85429e89 --- /dev/null +++ b/src/Validation/Constraint/SignedWithUntilDate.php @@ -0,0 +1,47 @@ +verifySignature = new SignedWith($signer, $key); + + $this->clock = $clock ?? new class () implements ClockInterface { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable(); + } + }; + } + + public function assert(Token $token): void + { + if ($this->validUntil < $this->clock->now()) { + throw ConstraintViolation::error( + 'This constraint was only usable until ' + . $this->validUntil->format(DateTimeInterface::RFC3339), + $this, + ); + } + + $this->verifySignature->assert($token); + } +} diff --git a/tests/JwtFacadeTest.php b/tests/JwtFacadeTest.php index ed3caea4..abf9a0f0 100644 --- a/tests/JwtFacadeTest.php +++ b/tests/JwtFacadeTest.php @@ -14,14 +14,16 @@ use Lcobucci\JWT\Token\Plain; use Lcobucci\JWT\Validation\Constraint\IssuedBy; use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\Constraint\SignedWithOneInSet; +use Lcobucci\JWT\Validation\Constraint\SignedWithUntilDate; use Lcobucci\JWT\Validation\Constraint\StrictValidAt; use Lcobucci\JWT\Validation\RequiredConstraintsViolated; +use PHPUnit\Framework\Attributes as PHPUnit; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; /** - * @covers ::__construct - * @coversDefaultClass \Lcobucci\JWT\JwtFacade + * @covers \Lcobucci\JWT\JwtFacade * * @uses \Lcobucci\JWT\Token\Parser * @uses \Lcobucci\JWT\Encoding\JoseEncoder @@ -40,6 +42,8 @@ * @uses \Lcobucci\JWT\Validation\Validator * @uses \Lcobucci\JWT\Validation\Constraint\IssuedBy * @uses \Lcobucci\JWT\Validation\Constraint\SignedWith + * @uses \Lcobucci\JWT\Validation\Constraint\SignedWithOneInSet + * @uses \Lcobucci\JWT\Validation\Constraint\SignedWithUntilDate * @uses \Lcobucci\JWT\Validation\Constraint\StrictValidAt * @uses \Lcobucci\JWT\Validation\ConstraintViolation * @uses \Lcobucci\JWT\Validation\RequiredConstraintsViolated @@ -72,12 +76,7 @@ private function createToken(): string )->toString(); } - /** - * @test - * - * @covers ::issue - * @covers ::parse - */ + #[PHPUnit\Test] public function issueSetTimeValidity(): void { $token = (new JwtFacade(clock: $this->clock))->issue( @@ -105,12 +104,7 @@ public function issueSetTimeValidity(): void self::assertTrue($token->isExpired($inOneYear)); } - /** - * @test - * - * @covers ::issue - * @covers ::parse - */ + #[PHPUnit\Test] public function issueAllowsTimeValidityOverwrite(): void { $then = new DateTimeImmutable('2001-02-03 04:05:06'); @@ -144,12 +138,7 @@ static function (Builder $builder) use ($then): Builder { self::assertTrue($token->isExpired($inOneYear)); } - /** - * @test - * - * @covers ::issue - * @covers ::parse - */ + #[PHPUnit\Test] public function goodJwt(): void { $token = (new JwtFacade())->parse( @@ -162,12 +151,7 @@ public function goodJwt(): void self::assertInstanceOf(Plain::class, $token); } - /** - * @test - * - * @covers ::issue - * @covers ::parse - */ + #[PHPUnit\Test] public function badSigner(): void { $this->expectException(RequiredConstraintsViolated::class); @@ -181,12 +165,7 @@ public function badSigner(): void ); } - /** - * @test - * - * @covers ::issue - * @covers ::parse - */ + #[PHPUnit\Test] public function badKey(): void { $this->expectException(RequiredConstraintsViolated::class); @@ -200,12 +179,7 @@ public function badKey(): void ); } - /** - * @test - * - * @covers ::issue - * @covers ::parse - */ + #[PHPUnit\Test] public function badTime(): void { $token = $this->createToken(); @@ -222,12 +196,7 @@ public function badTime(): void ); } - /** - * @test - * - * @covers ::issue - * @covers ::parse - */ + #[PHPUnit\Test] public function badIssuer(): void { $this->expectException(RequiredConstraintsViolated::class); @@ -241,11 +210,7 @@ public function badIssuer(): void ); } - /** - * @test - * - * @covers ::parse - */ + #[PHPUnit\Test] public function parserForNonUnencryptedTokens(): void { $this->expectException(AssertionError::class); @@ -258,12 +223,7 @@ public function parserForNonUnencryptedTokens(): void ); } - /** - * @test - * - * @covers ::issue - * @covers ::parse - */ + #[PHPUnit\Test] public function customPsrClock(): void { $clock = new class () implements ClockInterface { @@ -290,4 +250,32 @@ public function now(): DateTimeImmutable ), ); } + + #[PHPUnit\Test] + public function multipleKeys(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:10:00')); + + $token = (new JwtFacade())->parse( + $this->createToken(), + new SignedWithOneInSet( + new SignedWithUntilDate( + $this->signer, + InMemory::base64Encoded('czyPTpN595zVNSuvoNNlXCRFgXS2fHscMR36dGojaUE='), + new DateTimeImmutable('2024-11-19 22:10:00'), + $clock, + ), + new SignedWithUntilDate( + $this->signer, + $this->key, + new DateTimeImmutable('2025-11-19 22:10:00'), + $clock, + ), + ), + new StrictValidAt($this->clock), + new IssuedBy($this->issuer), + ); + + self::assertInstanceOf(Plain::class, $token); + } } diff --git a/tests/Signer/FakeSigner.php b/tests/Signer/FakeSigner.php new file mode 100644 index 00000000..d6f934a4 --- /dev/null +++ b/tests/Signer/FakeSigner.php @@ -0,0 +1,30 @@ +signature; + } + + public function sign(string $payload, Key $key): string + { + return $this->signature . '-' . $key->contents(); + } + + public function verify(string $expected, string $payload, Key $key): bool + { + return $this->signature . '-' . $key->contents() === $expected; + } +} diff --git a/tests/Validation/Constraint/ConstraintTestCase.php b/tests/Validation/Constraint/ConstraintTestCase.php index 07860dda..9ef13b58 100644 --- a/tests/Validation/Constraint/ConstraintTestCase.php +++ b/tests/Validation/Constraint/ConstraintTestCase.php @@ -3,9 +3,14 @@ namespace Lcobucci\JWT\Tests\Validation\Constraint; +use Closure; +use Lcobucci\JWT\Builder; +use Lcobucci\JWT\JwtFacade; +use Lcobucci\JWT\Signer; use Lcobucci\JWT\Token\DataSet; use Lcobucci\JWT\Token\Plain; use Lcobucci\JWT\Token\Signature; +use Lcobucci\JWT\UnencryptedToken; use PHPUnit\Framework\TestCase; abstract class ConstraintTestCase extends TestCase @@ -25,4 +30,13 @@ protected function buildToken( $signature ?? new Signature('sig+hash', 'sig+encoded'), ); } + + protected function issueToken(Signer $signer, Signer\Key $key, ?Closure $customization = null): UnencryptedToken + { + return (new JwtFacade())->issue( + $signer, + $key, + $customization ?? static fn (Builder $builder) => $builder, + ); + } } diff --git a/tests/Validation/Constraint/SignedWithOneInSetTest.php b/tests/Validation/Constraint/SignedWithOneInSetTest.php new file mode 100644 index 00000000..622417a8 --- /dev/null +++ b/tests/Validation/Constraint/SignedWithOneInSetTest.php @@ -0,0 +1,86 @@ +now(), $clock), + new SignedWithUntilDate($signer, InMemory::plainText('c'), $clock->now()->modify('-2 minutes'), $clock), + ); + + $this->expectException(ConstraintViolation::class); + $this->expectExceptionMessage( + 'It was not possible to verify the signature of the token, reasons:' + . PHP_EOL . '- Token signature mismatch' + . PHP_EOL . '- This constraint was only usable until 2023-11-19T22:18:00+00:00', + ); + + $token = $this->issueToken($signer, InMemory::plainText('a')); + $constraint->assert($token); + } + + #[PHPUnit\Test] + public function assertShouldNotRaiseExceptionsWhenSignatureIsVerifiedByAtLeastOneConstraint(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:20:00')); + $signer = new FakeSigner('123'); + + $constraint = new SignedWithOneInSet( + new SignedWithUntilDate($signer, InMemory::plainText('b'), $clock->now(), $clock), + new SignedWithUntilDate($signer, InMemory::plainText('c'), $clock->now()->modify('-2 minutes'), $clock), + new SignedWithUntilDate($signer, InMemory::plainText('a'), $clock->now(), $clock), + ); + + $token = $this->issueToken($signer, InMemory::plainText('a')); + $constraint->assert($token); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/Validation/Constraint/SignedWithUntilDateTest.php b/tests/Validation/Constraint/SignedWithUntilDateTest.php new file mode 100644 index 00000000..03980f23 --- /dev/null +++ b/tests/Validation/Constraint/SignedWithUntilDateTest.php @@ -0,0 +1,125 @@ +expectException(ConstraintViolation::class); + $this->expectExceptionMessage('This constraint was only usable until 2023-11-19T21:45:10+00:00'); + + $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10')); + + $constraint = new SignedWithUntilDate( + new FakeSigner('1'), + InMemory::plainText('a'), + $clock->now()->modify('-1 hour'), + $clock, + ); + + $constraint->assert($this->issueToken(new FakeSigner('1'), InMemory::plainText('a'))); + } + + #[PHPUnit\Test] + public function assertShouldRaiseExceptionWhenTokenIsNotAPlainToken(): void + { + $this->expectException(ConstraintViolation::class); + $this->expectExceptionMessage('You should pass a plain token'); + + $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10')); + + $constraint = new SignedWithUntilDate(new FakeSigner('1'), InMemory::plainText('a'), $clock->now(), $clock); + $constraint->assert($this->createMock(Token::class)); + } + + #[PHPUnit\Test] + public function assertShouldRaiseExceptionWhenSignerIsNotTheSame(): void + { + $this->expectException(ConstraintViolation::class); + $this->expectExceptionMessage('Token signer mismatch'); + + $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10')); + $key = InMemory::plainText('a'); + + $constraint = new SignedWithUntilDate(new FakeSigner('1'), $key, $clock->now(), $clock); + $constraint->assert($this->issueToken(new FakeSigner('2'), $key)); + } + + #[PHPUnit\Test] + public function assertShouldRaiseExceptionWhenSignatureIsInvalid(): void + { + $this->expectException(ConstraintViolation::class); + $this->expectExceptionMessage('Token signature mismatch'); + + $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10')); + $signer = new FakeSigner('1'); + + $constraint = new SignedWithUntilDate($signer, InMemory::plainText('a'), $clock->now(), $clock); + $constraint->assert($this->issueToken($signer, InMemory::plainText('b'))); + } + + #[PHPUnit\Test] + public function assertShouldNotRaiseExceptionWhenSignatureIsValid(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2023-11-19 22:45:10')); + + $signer = new FakeSigner('1'); + $key = InMemory::plainText('a'); + + $constraint = new SignedWithUntilDate($signer, $key, $clock->now(), $clock); + $constraint->assert($this->issueToken($signer, $key)); + + $this->addToAssertionCount(1); + } + + #[PHPUnit\Test] + public function clockShouldBeOptional(): void + { + $signer = new FakeSigner('1'); + $key = InMemory::plainText('a'); + + $constraint = new SignedWithUntilDate($signer, $key, new DateTimeImmutable('+10 seconds')); + $constraint->assert($this->issueToken($signer, $key)); + + $this->addToAssertionCount(1); + } +}