From e2bbc2f75824f7c1c821d7f9d3a66a9b81535839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 1 Aug 2026 11:17:23 +0200 Subject: [PATCH 1/9] Revoke preauth code after use --- .../Api/VciCredentialOfferApiController.php | 11 +- src/Factories/CredentialOfferUriFactory.php | 19 +- src/Repositories/AuthCodeRepository.php | 30 ++ src/Server/Grants/PreAuthCodeGrant.php | 48 ++- .../CredentialOfferUriFactoryTest.php | 160 ++++++++++ .../Repositories/AuthCodeRepositoryTest.php | 78 +++++ .../Server/Grants/PreAuthCodeGrantTest.php | 289 ++++++++++++++++++ 7 files changed, 588 insertions(+), 47 deletions(-) create mode 100644 tests/unit/src/Factories/CredentialOfferUriFactoryTest.php create mode 100644 tests/unit/src/Server/Grants/PreAuthCodeGrantTest.php diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php index 58e84f81..a7b8ac83 100644 --- a/src/Controllers/Api/VciCredentialOfferApiController.php +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -51,10 +51,7 @@ public function credentialOffer(Request $request): Response $this->loggerService->debug('VciCredentialOfferApiController::credentialOffer'); - $this->loggerService->debug( - 'VciCredentialOfferApiController: Request data: ', - $request->getPayload()->all(), - ); + $this->loggerService->debug('VciCredentialOfferApiController: Processing credential-offer request.'); try { $this->authorization->requireTokenForAnyOfScope( @@ -175,9 +172,8 @@ public function credentialOffer(Request $request): Response } $this->loggerService->debug( - 'VciCredentialOfferApiController: PreAuthorizedCode data:', + 'VciCredentialOfferApiController: Pre-authorized credential-offer request accepted.', [ - 'userAttributes' => $userAttributes, 'useTxCode' => $useTxCode, 'authenticationSourceId' => $authenticationSourceId, 'usersEmailAttributeName' => $usersEmailAttributeName, @@ -198,8 +194,7 @@ public function credentialOffer(Request $request): Response ]; $this->loggerService->debug( - 'VciCredentialOfferApiController: Credential Offer URI built successfully, returning data:', - $data, + 'VciCredentialOfferApiController: Credential Offer URI built successfully.', ); return $this->routes->newJsonResponse( data: $data, diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index c8e572c7..29bf4b05 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -146,11 +146,8 @@ public function buildPreAuthorized( if ($userId === null) { throw new RuntimeException('User identifier attribute value is not available.'); } - } catch (\Throwable $e) { - $this->loggerService->warning( - 'Could not extract user identifier from user attributes: ' . $e->getMessage(), - $userAttributes, - ); + } catch (\Throwable) { + $this->loggerService->warning('Could not extract user identifier from credential-offer attributes.'); } if ($userId === null) { @@ -158,10 +155,7 @@ public function buildPreAuthorized( $sortedAttributes = $userAttributes; $this->verifiableCredentials->helpers()->arr()->hybridSort($sortedAttributes); $userId = 'vci_credential_offer_preauthz_' . hash('sha256', serialize($sortedAttributes)); - $this->loggerService->info( - 'Generated user identifier based on user attributes: ' . $userId, - $userAttributes, - ); + $this->loggerService->info('Generated user identifier based on credential-offer attributes.'); } $oldUserEntity = $this->userRepository->getUserEntityByIdentifier($userId); @@ -181,10 +175,7 @@ public function buildPreAuthorized( $userEmail = $this->getUserEmail($userEmailAttributeName, $userAttributes); $txCodeDescription = 'Please provide the one-time code that was sent to e-mail ' . $userEmail; $txCode = $this->buildTxCode($txCodeDescription); - $this->loggerService->debug( - 'Generated TxCode for sending by email: ' . $txCode->getCodeAsString(), - $txCode->jsonSerialize(), - ); + $this->loggerService->debug('Generated transaction code for delivery by email.'); } $authCodeIdGenerationAttempts = 3; @@ -278,7 +269,7 @@ public function buildTxCode( string $description, int|string $txCode = null, ): TxCode { - $txCode ??= rand(1000, 9999); + $txCode ??= random_int(1000, 9999); return $this->verifiableCredentials->txCodeFactory()->build( $txCode, diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index bebf8433..fcc46a0f 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -22,6 +22,7 @@ use SimpleSAML\Database; use SimpleSAML\Error\Error; use SimpleSAML\Module\oidc\Codebooks\DateFormatsEnum; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; @@ -178,6 +179,35 @@ public function revokeAuthCode(string $codeId): void $this->update($authCode); } + /** + * Atomically consume a VCI pre-authorized code. + * + * The database is the source of truth for this replay guard. A conditional + * update ensures that only one request can change an unrevoked code to the + * revoked state, even when concurrent requests read the same cached entity. + */ + public function consumePreAuthorizedCode(string $codeId): bool + { + $stmt = "UPDATE {$this->getTableName()} SET is_revoked = :revoked " . + "WHERE id = :id AND is_revoked = :not_revoked AND flow_type = :flow_type AND expires_at >= :now"; + + $affected = $this->database->write( + $stmt, + [ + 'id' => $codeId, + 'revoked' => [true, PDO::PARAM_BOOL], + 'not_revoked' => [false, PDO::PARAM_BOOL], + 'flow_type' => FlowTypeEnum::VciPreAuthorizedCode->value, + 'now' => $this->helpers->dateTime()->getUtc()->format(DateFormatsEnum::DB_DATETIME->value), + ], + ); + + // Never allow a stale cached entity to bypass the database replay guard. + $this->protocolCache?->delete($this->getCacheKey($codeId)); + + return $affected === 1; + } + /** * {@inheritdoc} * @throws \Exception diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index 3bfbee18..e6a2c98e 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -27,6 +27,8 @@ use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; +use function hash_equals; + /** * @psalm-suppress PropertyNotSetInConstructor */ @@ -117,10 +119,7 @@ public function respondToAccessTokenRequest( // TODO mivanci client authentication? - $this->loggerService->debug( - 'PreAuthCodeGrant::respondToAccessTokenRequest: Request parameters: ', - $this->requestParamsResolver->getAllFromRequest($request), - ); + $this->loggerService->debug('PreAuthCodeGrant::respondToAccessTokenRequest'); $preAuthorizedCodeId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::PreAuthorizedCode->value, @@ -143,7 +142,7 @@ public function respondToAccessTokenRequest( is_null($preAuthorizedCode) || !is_a($preAuthorizedCode, AuthCodeEntity::class) ) { - $this->loggerService->error('Invalid pre-authorized code ID. Value was: ' . $preAuthorizedCodeId); + $this->loggerService->notice('Token request rejected: pre-authorized code was not found.'); throw OidcServerException::invalidGrant('Invalid pre-authorized code.'); } @@ -153,7 +152,7 @@ public function respondToAccessTokenRequest( // Validate Transaction Code. if (($preAuthorizedCodeTxCode = $preAuthorizedCode->getTxCode()) !== null) { - $this->loggerService->debug('Validating transaction code ' . $preAuthorizedCodeTxCode); + $this->loggerService->debug('Validating transaction code.'); $txCodeParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::TxCode->value, $request, @@ -165,12 +164,9 @@ public function respondToAccessTokenRequest( throw OidcServerException::invalidRequest(ParamsEnum::TxCode->value, 'Transaction Code is missing.'); } - $this->loggerService->debug('Transaction code parameter value: ' . $txCodeParam); - - if ($preAuthorizedCodeTxCode !== $txCodeParam) { + if (!hash_equals($preAuthorizedCodeTxCode, $txCodeParam)) { $this->loggerService->warning( 'Transaction code parameter value does not match pre-authorized code transaction code.', - ['txCodeParam' => $txCodeParam, 'preAuthorizedCodeTxCode' => $preAuthorizedCodeTxCode,], ); throw OidcServerException::invalidRequest(ParamsEnum::TxCode->value, 'Transaction Code is invalid.'); } @@ -193,6 +189,16 @@ public function respondToAccessTokenRequest( $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); + // Consume immediately before token issuance. The conditional database update is the + // authoritative replay guard, so only one concurrent request can proceed. If token + // persistence subsequently fails, the code remains consumed (fail closed). + if (!$this->authCodeRepository->consumePreAuthorizedCode($preAuthorizedCodeId)) { + $this->loggerService->notice( + 'Token request rejected: pre-authorized code was already consumed or is no longer valid.', + ); + throw OidcServerException::invalidGrant('Invalid pre-authorized code.'); + } + // Issue and persist new access token $accessToken = $this->issueAccessToken( $accessTokenTTL, @@ -208,9 +214,10 @@ public function respondToAccessTokenRequest( $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); $responseType->setAccessToken($accessToken); - - // TODO mivanci revoke pre-authorized code or let it expire only after access token is issued? - // $this->authCodeRepository->revokeAuthCode($preAuthorizedCode); + $this->loggerService->notice( + 'Pre-authorized code redeemed; access token issued.', + ['client_id' => $client->getIdentifier()], + ); return $responseType; } @@ -233,27 +240,18 @@ protected function validateAuthorizationCode( $this->loggerService->debug('PreAuthCodeGrant::validateAuthorizationCode'); if (!$storedAuthCodeEntity->isVciPreAuthorized()) { - $this->loggerService->error( - 'Pre-authorized code is not pre-authorized. ID was: ', - ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], - ); + $this->loggerService->error('Pre-authorized code is not pre-authorized.'); throw OidcServerException::invalidGrant('Pre-authorized code is not pre-authorized.'); } if ($storedAuthCodeEntity->getExpiryDateTime()->getTimestamp() < time()) { - $this->loggerService->error( - 'Pre-authorized code is expired. ID was: ', - ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], - ); + $this->loggerService->error('Pre-authorized code is expired.'); throw OidcServerException::invalidGrant('Pre-authorized code is expired.'); } if ($storedAuthCodeEntity->isRevoked()) { - $this->loggerService->error( - 'Pre-authorized code is revoked. ID was: ', - ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], - ); + $this->loggerService->error('Pre-authorized code is revoked.'); throw OidcServerException::invalidGrant('Pre-authorized code is revoked.'); } diff --git a/tests/unit/src/Factories/CredentialOfferUriFactoryTest.php b/tests/unit/src/Factories/CredentialOfferUriFactoryTest.php new file mode 100644 index 00000000..9d3113f2 --- /dev/null +++ b/tests/unit/src/Factories/CredentialOfferUriFactoryTest.php @@ -0,0 +1,160 @@ + */ + private array $logRecords = []; + + public function testFallbackUserIdentifierDoesNotLogAttributesOrExceptionDetails(): void + { + $sensitiveAttributeValue = 'sensitive-user-attribute-value'; + $sensitiveExceptionValue = 'sensitive-resolver-exception-value'; + $userAttributes = [ + 'displayName' => [$sensitiveAttributeValue], + 'privateClaim' => ['private-claim-value'], + ]; + $logger = $this->createMock(LoggerService::class); + $this->captureLogs($logger, 'warning'); + $this->captureLogs($logger, 'info'); + $userIdentifierResolver = $this->createMock(UserIdentifierResolver::class); + $userIdentifierResolver->method('resolve') + ->willThrowException(new RuntimeException($sensitiveExceptionValue)); + + $client = $this->createMock(ClientEntity::class); + $client->method('getIdentifier')->willReturn('vci-client'); + $authCode = new AuthCodeEntity( + 'pre-authorized-code-secret', + $client, + [], + new DateTimeImmutable('+10 minutes'), + 'fallback-user', + 'openid-credential-offer://', + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, + ); + $authCodeEntityFactory = $this->createMock(AuthCodeEntityFactory::class); + $authCodeEntityFactory->expects($this->once())->method('fromData')->willReturn($authCode); + $authCodeRepository = $this->createMock(AuthCodeRepository::class); + $authCodeRepository->expects($this->once())->method('persistNewAuthCode')->with($authCode); + + $clientRepository = $this->createMock(ClientRepository::class); + $clientRepository->method('getGenericForVci')->willReturn($client); + $userRepository = $this->createMock(UserRepository::class); + $userRepository->method('getUserEntityByIdentifier')->willReturn(null); + $userRepository->expects($this->once())->method('add'); + $userEntityFactory = $this->createMock(UserEntityFactory::class); + $userEntityFactory->method('fromData')->willReturn( + new UserEntity('fallback-user', new DateTimeImmutable(), new DateTimeImmutable(), $userAttributes), + ); + + $credentialOfferUri = $this->factory( + $logger, + $userIdentifierResolver, + $authCodeRepository, + $authCodeEntityFactory, + $clientRepository, + $userRepository, + $userEntityFactory, + )->buildPreAuthorized(['credential-configuration'], $userAttributes); + + $this->assertStringStartsWith('openid-credential-offer://?', $credentialOfferUri); + $logs = json_encode($this->logRecords, JSON_THROW_ON_ERROR); + $this->assertStringNotContainsString($sensitiveAttributeValue, $logs); + $this->assertStringNotContainsString('private-claim-value', $logs); + $this->assertStringNotContainsString($sensitiveExceptionValue, $logs); + } + + public function testBuildTxCodeGeneratesFourDigitNumericCode(): void + { + $txCode = $this->factory( + $this->createMock(LoggerService::class), + $this->createMock(UserIdentifierResolver::class), + )->buildTxCode('Enter the separately delivered code.'); + + $this->assertMatchesRegularExpression('/^[0-9]{4}$/', $txCode->getCodeAsString()); + } + + private function factory( + LoggerService $logger, + UserIdentifierResolver $userIdentifierResolver, + ?AuthCodeRepository $authCodeRepository = null, + ?AuthCodeEntityFactory $authCodeEntityFactory = null, + ?ClientRepository $clientRepository = null, + ?UserRepository $userRepository = null, + ?UserEntityFactory $userEntityFactory = null, + ): CredentialOfferUriFactory { + $moduleConfig = $this->createMock(ModuleConfig::class); + $moduleConfig->method('getVciCredentialConfigurationIdsSupported') + ->willReturn(['credential-configuration']); + $moduleConfig->method('getUserIdentifierAttributes')->willReturn(['uid']); + $moduleConfig->method('getDefaultUsersEmailAttributeName')->willReturn('mail'); + $moduleConfig->method('getAuthCodeDuration')->willReturn(new DateInterval('PT10M')); + $moduleConfig->method('getIssuer')->willReturn('https://issuer.example.org'); + + $random = $this->createMock(Random::class); + $random->method('generateID')->willReturn('pre-authorized-code-secret'); + $utils = $this->createMock(Utils::class); + $utils->method('random')->willReturn($random); + $sspBridge = $this->createMock(SspBridge::class); + $sspBridge->method('utils')->willReturn($utils); + + return new CredentialOfferUriFactory( + new VerifiableCredentials(), + $moduleConfig, + $sspBridge, + $authCodeRepository ?? $this->createMock(AuthCodeRepository::class), + $authCodeEntityFactory ?? $this->createMock(AuthCodeEntityFactory::class), + $clientRepository ?? $this->createMock(ClientRepository::class), + $logger, + $userRepository ?? $this->createMock(UserRepository::class), + $userEntityFactory ?? $this->createMock(UserEntityFactory::class), + $this->createMock(EmailFactory::class), + $this->createMock(IssuerStateEntityFactory::class), + $this->createMock(IssuerStateRepository::class), + $userIdentifierResolver, + ); + } + + private function captureLogs(LoggerService&MockObject $logger, string $level): void + { + $logger->method($level)->willReturnCallback( + function (string|Stringable $message, array $context = []): void { + $this->logRecords[] = ['message' => (string)$message, 'context' => $context]; + }, + ); + } +} diff --git a/tests/unit/src/Repositories/AuthCodeRepositoryTest.php b/tests/unit/src/Repositories/AuthCodeRepositoryTest.php index 966ed188..4eb7a6fc 100644 --- a/tests/unit/src/Repositories/AuthCodeRepositoryTest.php +++ b/tests/unit/src/Repositories/AuthCodeRepositoryTest.php @@ -25,6 +25,7 @@ use SimpleSAML\Database; use SimpleSAML\Error\Error; use SimpleSAML\Module\oidc\Codebooks\DateFormatsEnum; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\ScopeEntity; @@ -209,6 +210,83 @@ public function testErrorCheckIsRevokedInvalidAuthCode(): void $this->repository->isAuthCodeRevoked('nocode'); } + /** + * @throws \JsonException + * @throws \SimpleSAML\Error\Error + */ + public function testConsumePreAuthorizedCodeReturnsTrueOnlyOnce(): void + { + $codeId = 'pre_authorized_code_to_consume'; + $now = new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')); + $authCode = new AuthCodeEntity( + $codeId, + $this->clientEntityMock, + $this->scopes, + $now->modify('+1 hour'), + self::USER_ID, + self::REDIRECT_URI, + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, + txCode: '1234', + ); + + $this->dateTimeHelperMock->method('getUtc')->willReturn($now); + $this->protocolCacheMock->expects($this->exactly(2)) + ->method('delete') + ->with('phpunit_oidc_auth_code_' . $codeId); + + $this->repository->persistNewAuthCode($authCode); + + $this->assertTrue($this->repository->consumePreAuthorizedCode($codeId)); + $this->assertFalse($this->repository->consumePreAuthorizedCode($codeId)); + } + + /** + * @throws \JsonException + * @throws \SimpleSAML\Error\Error + */ + public function testConsumePreAuthorizedCodeDoesNotConsumeStandardAuthorizationCode(): void + { + $codeId = 'standard_authorization_code_not_to_consume'; + $now = new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')); + $authCode = new AuthCodeEntity( + $codeId, + $this->clientEntityMock, + $this->scopes, + $now->modify('+1 hour'), + self::USER_ID, + self::REDIRECT_URI, + ); + + $this->dateTimeHelperMock->method('getUtc')->willReturn($now); + $this->repository->persistNewAuthCode($authCode); + + $this->assertFalse($this->repository->consumePreAuthorizedCode($codeId)); + } + + /** + * @throws \JsonException + * @throws \SimpleSAML\Error\Error + */ + public function testConsumePreAuthorizedCodeRejectsCodeExpiredAtConsumptionTime(): void + { + $codeId = 'expired_pre_authorized_code_not_to_consume'; + $now = new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')); + $authCode = new AuthCodeEntity( + $codeId, + $this->clientEntityMock, + $this->scopes, + $now->modify('-1 second'), + self::USER_ID, + self::REDIRECT_URI, + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, + ); + + $this->dateTimeHelperMock->method('getUtc')->willReturn($now); + $this->repository->persistNewAuthCode($authCode); + + $this->assertFalse($this->repository->consumePreAuthorizedCode($codeId)); + } + /** * @throws \Exception */ diff --git a/tests/unit/src/Server/Grants/PreAuthCodeGrantTest.php b/tests/unit/src/Server/Grants/PreAuthCodeGrantTest.php new file mode 100644 index 00000000..6eaf7d0b --- /dev/null +++ b/tests/unit/src/Server/Grants/PreAuthCodeGrantTest.php @@ -0,0 +1,289 @@ + */ + private array $logRecords = []; + + protected function setUp(): void + { + $this->authCodeRepositoryMock = $this->createMock(AuthCodeRepository::class); + $this->accessTokenRepositoryMock = $this->createMock(AccessTokenRepositoryInterface::class); + $this->refreshTokenRepositoryMock = $this->createMock(RefreshTokenRepositoryInterface::class); + $this->requestRulesManagerMock = $this->createMock(RequestRulesManager::class); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->accessTokenEntityFactoryMock = $this->createMock(AccessTokenEntityFactory::class); + $this->authCodeEntityFactoryMock = $this->createMock(AuthCodeEntityFactory::class); + $this->refreshTokenIssuerMock = $this->createMock(RefreshTokenIssuer::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->requestMock = $this->createMock(ServerRequestInterface::class); + + $this->requestRulesManagerMock->method('check')->willReturn(new ResultBag()); + $this->captureLogs('debug'); + $this->captureLogs('notice'); + $this->captureLogs('warning'); + $this->captureLogs('error'); + } + + public function testRedeemsPreAuthorizedCodeOnlyAfterAtomicConsumption(): void + { + $this->configureRequestParameters(self::TRANSACTION_CODE); + $authCode = $this->preAuthorizedCode(self::TRANSACTION_CODE); + $accessToken = $this->createMock(AccessTokenEntity::class); + $responseType = $this->createMock(ResponseTypeInterface::class); + $operationOrder = []; + + $this->authCodeRepositoryMock->expects($this->once()) + ->method('findById') + ->with(self::PRE_AUTHORIZED_CODE) + ->willReturn($authCode); + $this->authCodeRepositoryMock->expects($this->once()) + ->method('consumePreAuthorizedCode') + ->with(self::PRE_AUTHORIZED_CODE) + ->willReturnCallback(function () use (&$operationOrder): bool { + $operationOrder[] = 'consume'; + return true; + }); + + $this->accessTokenEntityFactoryMock->expects($this->once()) + ->method('fromData') + ->willReturn($accessToken); + $this->accessTokenRepositoryMock->expects($this->once()) + ->method('persistNewAccessToken') + ->with($accessToken) + ->willReturnCallback(function () use (&$operationOrder): void { + $operationOrder[] = 'persist'; + }); + $responseType->expects($this->once())->method('setAccessToken')->with($accessToken); + + $result = $this->sut()->respondToAccessTokenRequest( + $this->requestMock, + $responseType, + new DateInterval('PT5M'), + ); + + $this->assertSame($responseType, $result); + $this->assertSame(['consume', 'persist'], $operationOrder); + $this->assertSecretsWereNotLogged(self::PRE_AUTHORIZED_CODE, self::TRANSACTION_CODE); + } + + public function testRejectsReplayBeforeIssuingAnotherAccessToken(): void + { + $this->configureRequestParameters(null); + $authCode = $this->preAuthorizedCode(); + + $this->authCodeRepositoryMock->method('findById')->willReturn($authCode); + $this->authCodeRepositoryMock->expects($this->once()) + ->method('consumePreAuthorizedCode') + ->with(self::PRE_AUTHORIZED_CODE) + ->willReturn(false); + $this->accessTokenEntityFactoryMock->expects($this->never())->method('fromData'); + $this->accessTokenRepositoryMock->expects($this->never())->method('persistNewAccessToken'); + + try { + $this->sut()->respondToAccessTokenRequest( + $this->requestMock, + $this->createMock(ResponseTypeInterface::class), + new DateInterval('PT5M'), + ); + $this->fail('A replayed pre-authorized code must be rejected.'); + } catch (OidcServerException $exception) { + $this->assertSame('invalid_grant', $exception->getErrorType()); + } + + $this->assertSecretsWereNotLogged(self::PRE_AUTHORIZED_CODE); + } + + public function testRejectsInvalidTransactionCodeWithoutConsumingPreAuthorizedCode(): void + { + $submittedTransactionCode = '9999'; + $this->configureRequestParameters($submittedTransactionCode); + $authCode = $this->preAuthorizedCode(self::TRANSACTION_CODE); + + $this->authCodeRepositoryMock->method('findById')->willReturn($authCode); + $this->authCodeRepositoryMock->expects($this->never())->method('consumePreAuthorizedCode'); + $this->accessTokenRepositoryMock->expects($this->never())->method('persistNewAccessToken'); + + try { + $this->sut()->respondToAccessTokenRequest( + $this->requestMock, + $this->createMock(ResponseTypeInterface::class), + new DateInterval('PT5M'), + ); + $this->fail('An invalid transaction code must be rejected.'); + } catch (OidcServerException $exception) { + $this->assertSame('invalid_request', $exception->getErrorType()); + } + + $this->assertSecretsWereNotLogged( + self::PRE_AUTHORIZED_CODE, + self::TRANSACTION_CODE, + $submittedTransactionCode, + ); + } + + public function testTokenPersistenceFailureLeavesPreAuthorizedCodeConsumed(): void + { + $this->configureRequestParameters(null); + $authCode = $this->preAuthorizedCode(); + $accessToken = $this->createMock(AccessTokenEntity::class); + + $this->authCodeRepositoryMock->expects($this->exactly(2)) + ->method('findById') + ->with(self::PRE_AUTHORIZED_CODE) + ->willReturn($authCode); + $this->authCodeRepositoryMock->expects($this->exactly(2)) + ->method('consumePreAuthorizedCode') + ->with(self::PRE_AUTHORIZED_CODE) + ->willReturnOnConsecutiveCalls(true, false); + $this->accessTokenEntityFactoryMock->expects($this->once()) + ->method('fromData') + ->willReturn($accessToken); + $this->accessTokenRepositoryMock->expects($this->once()) + ->method('persistNewAccessToken') + ->with($accessToken) + ->willThrowException(new RuntimeException('Access-token storage failed.')); + + try { + $this->sut()->respondToAccessTokenRequest( + $this->requestMock, + $this->createMock(ResponseTypeInterface::class), + new DateInterval('PT5M'), + ); + $this->fail('The access-token persistence failure must be propagated.'); + } catch (RuntimeException $exception) { + $this->assertSame('Access-token storage failed.', $exception->getMessage()); + } + + try { + $this->sut()->respondToAccessTokenRequest( + $this->requestMock, + $this->createMock(ResponseTypeInterface::class), + new DateInterval('PT5M'), + ); + $this->fail('A retry after access-token persistence failure must be rejected.'); + } catch (OidcServerException $exception) { + $this->assertSame('invalid_grant', $exception->getErrorType()); + } + + $this->assertSecretsWereNotLogged(self::PRE_AUTHORIZED_CODE); + } + + private function sut(): PreAuthCodeGrant + { + return new PreAuthCodeGrant( + $this->authCodeRepositoryMock, + $this->accessTokenRepositoryMock, + $this->refreshTokenRepositoryMock, + new DateInterval('PT1M'), + $this->requestRulesManagerMock, + $this->requestParamsResolverMock, + $this->accessTokenEntityFactoryMock, + $this->authCodeEntityFactoryMock, + $this->refreshTokenIssuerMock, + $this->helpersMock, + $this->loggerServiceMock, + ); + } + + private function preAuthorizedCode(?string $transactionCode = null): AuthCodeEntity + { + $client = $this->createMock(ClientEntity::class); + $client->method('getIdentifier')->willReturn(self::CLIENT_ID); + + return new AuthCodeEntity( + self::PRE_AUTHORIZED_CODE, + $client, + [], + new DateTimeImmutable('+1 hour'), + 'user-id', + 'openid-credential-offer://', + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, + txCode: $transactionCode, + ); + } + + private function configureRequestParameters(?string $transactionCode): void + { + $this->requestParamsResolverMock->expects($this->never())->method('getAllFromRequest'); + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods') + ->willReturnCallback( + static fn(string $parameter): ?string => match ($parameter) { + ParamsEnum::PreAuthorizedCode->value => self::PRE_AUTHORIZED_CODE, + ParamsEnum::TxCode->value => $transactionCode, + ParamsEnum::ClientId->value => self::CLIENT_ID, + default => null, + }, + ); + } + + private function captureLogs(string $level): void + { + $this->loggerServiceMock->method($level)->willReturnCallback( + function (string|Stringable $message, array $context = []): void { + $this->logRecords[] = ['message' => (string)$message, 'context' => $context]; + }, + ); + } + + private function assertSecretsWereNotLogged(string ...$secrets): void + { + $logs = json_encode($this->logRecords, JSON_THROW_ON_ERROR); + foreach ($secrets as $secret) { + $this->assertStringNotContainsString($secret, $logs); + } + } +} From 9d5dc2b72a882026b1e56d2dc541b5449c835c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 3 Aug 2026 14:32:32 +0200 Subject: [PATCH 2/9] Add Token Status List storage and index allocation --- composer.json | 2 +- config/module_oidc.php.dist | 100 ++ locales/en/LC_MESSAGES/oidc.po | 46 + locales/es/LC_MESSAGES/oidc.po | 46 + locales/fr/LC_MESSAGES/oidc.po | 46 + locales/hr/LC_MESSAGES/oidc.po | 46 + locales/it/LC_MESSAGES/oidc.po | 46 + locales/nl/LC_MESSAGES/oidc.po | 46 + routing/services/services.yml | 15 + .../ConfigOverview/VciOverviewBuilder.php | 126 ++ src/Codebooks/RoutesEnum.php | 9 + src/Codebooks/StatusChangeSourceEnum.php | 20 + src/Codebooks/StatusListKeyProfileEnum.php | 36 + src/Controllers/JwksController.php | 43 +- src/Exceptions/StatusConflictException.php | 16 + src/Exceptions/StatusListException.php | 16 + src/Exceptions/UnsupportedStatusException.php | 17 + src/Factories/TokenStatusListFactory.php | 31 + src/ModuleConfig.php | 161 +++ src/Repositories/StatusAuditRepository.php | 91 ++ .../StatusListEntryRepository.php | 333 +++++ src/Repositories/StatusListRepository.php | 456 +++++++ src/Services/DatabaseMigration.php | 264 ++++ .../StatusIndexAllocatorInterface.php | 43 + .../Contracts/StatusUpdaterInterface.php | 40 + src/StatusList/DbStatusIndexAllocator.php | 594 +++++++++ src/StatusList/DbStatusUpdater.php | 214 ++++ src/StatusList/StatusListKeyResolver.php | 91 ++ src/StatusList/SubjectRefHasher.php | 90 ++ src/StatusList/Values/AllocationAttempt.php | 38 + .../Values/DatabaseRowValuesTrait.php | 102 ++ src/StatusList/Values/StatusAllocation.php | 48 + .../Values/StatusListEntryRecord.php | 136 ++ src/StatusList/Values/StatusListPool.php | 572 +++++++++ src/StatusList/Values/StatusListPoolBag.php | 138 ++ src/StatusList/Values/StatusListRecord.php | 258 ++++ src/Utils/Routes.php | 24 + tests/integration/src/DatabaseContainers.php | 198 +++ .../AccessTokenRepositoryTest.php | 141 +- .../src/StatusList/StatusListStorageTest.php | 449 +++++++ tests/unit/src/ModuleConfigTest.php | 157 +++ .../StatusList/DbStatusIndexAllocatorTest.php | 1133 +++++++++++++++++ .../src/StatusList/DbStatusUpdaterTest.php | 429 +++++++ .../src/StatusList/SubjectRefHasherTest.php | 91 ++ .../Values/StatusListPoolBagTest.php | 103 ++ .../StatusList/Values/StatusListPoolTest.php | 386 ++++++ 46 files changed, 7352 insertions(+), 135 deletions(-) create mode 100644 src/Codebooks/StatusChangeSourceEnum.php create mode 100644 src/Codebooks/StatusListKeyProfileEnum.php create mode 100644 src/Exceptions/StatusConflictException.php create mode 100644 src/Exceptions/StatusListException.php create mode 100644 src/Exceptions/UnsupportedStatusException.php create mode 100644 src/Factories/TokenStatusListFactory.php create mode 100644 src/Repositories/StatusAuditRepository.php create mode 100644 src/Repositories/StatusListEntryRepository.php create mode 100644 src/Repositories/StatusListRepository.php create mode 100644 src/StatusList/Contracts/StatusIndexAllocatorInterface.php create mode 100644 src/StatusList/Contracts/StatusUpdaterInterface.php create mode 100644 src/StatusList/DbStatusIndexAllocator.php create mode 100644 src/StatusList/DbStatusUpdater.php create mode 100644 src/StatusList/StatusListKeyResolver.php create mode 100644 src/StatusList/SubjectRefHasher.php create mode 100644 src/StatusList/Values/AllocationAttempt.php create mode 100644 src/StatusList/Values/DatabaseRowValuesTrait.php create mode 100644 src/StatusList/Values/StatusAllocation.php create mode 100644 src/StatusList/Values/StatusListEntryRecord.php create mode 100644 src/StatusList/Values/StatusListPool.php create mode 100644 src/StatusList/Values/StatusListPoolBag.php create mode 100644 src/StatusList/Values/StatusListRecord.php create mode 100644 tests/integration/src/DatabaseContainers.php create mode 100644 tests/integration/src/StatusList/StatusListStorageTest.php create mode 100644 tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php create mode 100644 tests/unit/src/StatusList/DbStatusUpdaterTest.php create mode 100644 tests/unit/src/StatusList/SubjectRefHasherTest.php create mode 100644 tests/unit/src/StatusList/Values/StatusListPoolBagTest.php create mode 100644 tests/unit/src/StatusList/Values/StatusListPoolTest.php diff --git a/composer.json b/composer.json index 8579b3e8..697aba03 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "psr/log": "^3", "psr/simple-cache": "^3", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~0.4.1", + "simplesamlphp/openid": "~0.5", "spomky-labs/base64url": "^2.0", "symfony/cache": "^7.4", "symfony/expression-language": "^7.4", diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 3f41848e..2f477f27 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1539,6 +1539,106 @@ $config = [ */ ModuleConfig::OPTION_VCI_NONCE_TTL => 'PT5M', // 5 minutes + /** + * (optional) Whether issued Verifiable Credentials get a Token Status List + * entry allocated to them, which is what makes them revocable and + * suspendable. Defaults to false. + * + * Note what this switch does NOT do: it never stops the Status List + * endpoint from serving. Credentials which were already issued carry a + * `status` claim pointing at a list, and they have to stay verifiable, so + * lists keep being served until they complete their own lifecycle. Turning + * this off only stops new entries from being allocated. + * + * This requires a SimpleSAMLphp providing SimpleSAML\Database::readPrimary(). + * Deciding whether a credential has been revoked off a lagging database + * secondary could publish a revoked credential as valid, so the module + * refuses to enable this capability rather than fall back to a replica read. + */ + ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED => false, + + /** + * (optional) How Status List Tokens identify the key they were signed with. + * Defaults to StatusListKeyProfileEnum::DidJwk. + * + * The specification deliberately mandates no key resolution method, so this + * is a deployment profile rather than something the specification decides: + * + * - DidJwk: `kid` is the issuer's `did:jwk:...#0` and `iss` is the same + * `did:jwk:...`. The token carries the key with it, so it verifies without + * any external lookup. This is the default. + * - Jwks: `iss` is this module's issuer URL and `kid` is the JWKS key ID, + * so the key is resolved through the published JWKS. Use this for Relying + * Parties which will not accept a `did:jwk` key identifier. + * + * Each Status List records the profile it was created under, so changing + * this routes newly issued credentials to newly created lists while existing + * lists keep being served under the profile their holders already resolved + * them by. Changing it therefore never invalidates credentials which are + * already in wallets. + */ + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => \SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum::DidJwk, + + /** + * (optional) Token Status List pools. + * + * A pool, not a credential configuration, is the unit which shares a Status + * List, and several credential configurations can map onto one pool. This is + * a deliberate privacy trade: the specification's herd privacy rests on many + * credentials sharing one list, so splitting configurations into separate + * pools costs herd size and should be done only when their policies genuinely + * differ. A credential configuration must appear in at most one pool. + * + * Credentials of a configuration which is in no pool are issued without a + * `status` claim, and can not be revoked or suspended. + * + * These settings are deliberately kept here rather than inside + * OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED, because those are published + * wholesale as Credential Issuer metadata and anything placed among them + * would become visible to every wallet. + * + * Per-pool settings, all optional except the credential configurations: + * + * - 'credential_configurations': credential configuration IDs allocating + * from this pool. Required, and each must be declared under + * OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED. + * - 'bits': bits per entry, one of 1, 2, 4, 8. Default 1. This must be large + * enough for every status the pool can emit, and can not be changed for + * lists which already exist, so a pool which may suspend needs at least 2. + * It affects transfer size, never herd size. + * - 'capacity': entries per list, a positive multiple of 8. Default 131072. + * - 'allowed_statuses': statuses this pool may emit, besides Valid, which is + * always allowed. Default: Invalid only. + * - 'ttl': how long a Relying Party may cache a fetched token. Default PT12H. + * This is the revocation latency an RP is entitled to: with the default, a + * conforming RP may keep accepting a revoked credential for up to 12 hours. + * - 'token_validity': lifetime of a published Status List Token. Default P7D. + * - 'refresh_interval': how old a published token may get before it is + * re-signed. Default PT1H. The refresh interval plus a 15 minute safety + * margin must stay below the token validity, otherwise a published token + * expires before its replacement is produced. + * - 'key_profile': overrides OPTION_VCI_STATUS_LIST_KEY_PROFILE for this pool. + */ +// ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS => [ +// 'default' => [ +// 'credential_configurations' => [ +// 'UniversityDegreeCredential', +// ], +// ], +// // A pool which can also suspend, so it needs at least 2 bits per entry. +// 'suspendable' => [ +// 'credential_configurations' => [ +// 'EmployeeBadgeCredential', +// ], +// 'bits' => 2, +// 'allowed_statuses' => [ +// \SimpleSAML\OpenID\Codebooks\StatusTypeEnum::Invalid, +// \SimpleSAML\OpenID\Codebooks\StatusTypeEnum::Suspended, +// ], +// 'ttl' => 'PT1H', +// ], +// ], + /** * Map of authentication sources and user's email attribute names. This * enables you to define a specific attribute name which contains the diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index fe2d316e..d08737c1 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -1545,3 +1545,49 @@ msgid "" "never removed from storage." msgstr "" +msgid "" +"Credentials of a configuration which is in no pool are issued " +"without a status claim. Privacy rests on many credentials sharing " +"one list, so more pools means a smaller group each credential hides " +"in." +msgstr "" + +msgid "" +"How Status List Tokens identify their signing key. Each list records " +"the profile it was created under, so changing this affects newly " +"created lists only and never invalidates credentials already issued. " +"A pool may override it." +msgstr "" + +msgid "" +"Issued credentials get a Status List entry, so they can be revoked " +"and suspended." +msgstr "" + +msgid "" +"New credentials are issued without a status claim, so they can not " +"be revoked. Status Lists which already exist keep being served " +"regardless of this setting, so credentials issued earlier stay " +"verifiable." +msgstr "" + +msgid "" +"No credential configuration allocates a Status List entry, so no " +"issued credential can be revoked." +msgstr "" + +msgid "Status List Key Profile" +msgstr "" + +msgid "Status List Pools" +msgstr "" + +msgid "Status Lists" +msgstr "" + +msgid "Status Lists Enabled" +msgstr "" + +msgid "These pools are inert, since Status Lists are disabled." +msgstr "" + diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index fa08aa63..b6261991 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -1545,3 +1545,49 @@ msgid "" "never removed from storage." msgstr "" +msgid "" +"Credentials of a configuration which is in no pool are issued " +"without a status claim. Privacy rests on many credentials sharing " +"one list, so more pools means a smaller group each credential hides " +"in." +msgstr "" + +msgid "" +"How Status List Tokens identify their signing key. Each list records " +"the profile it was created under, so changing this affects newly " +"created lists only and never invalidates credentials already issued. " +"A pool may override it." +msgstr "" + +msgid "" +"Issued credentials get a Status List entry, so they can be revoked " +"and suspended." +msgstr "" + +msgid "" +"New credentials are issued without a status claim, so they can not " +"be revoked. Status Lists which already exist keep being served " +"regardless of this setting, so credentials issued earlier stay " +"verifiable." +msgstr "" + +msgid "" +"No credential configuration allocates a Status List entry, so no " +"issued credential can be revoked." +msgstr "" + +msgid "Status List Key Profile" +msgstr "" + +msgid "Status List Pools" +msgstr "" + +msgid "Status Lists" +msgstr "" + +msgid "Status Lists Enabled" +msgstr "" + +msgid "These pools are inert, since Status Lists are disabled." +msgstr "" + diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index ca46aca5..b95d0a71 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -1545,3 +1545,49 @@ msgid "" "never removed from storage." msgstr "" +msgid "" +"Credentials of a configuration which is in no pool are issued " +"without a status claim. Privacy rests on many credentials sharing " +"one list, so more pools means a smaller group each credential hides " +"in." +msgstr "" + +msgid "" +"How Status List Tokens identify their signing key. Each list records " +"the profile it was created under, so changing this affects newly " +"created lists only and never invalidates credentials already issued. " +"A pool may override it." +msgstr "" + +msgid "" +"Issued credentials get a Status List entry, so they can be revoked " +"and suspended." +msgstr "" + +msgid "" +"New credentials are issued without a status claim, so they can not " +"be revoked. Status Lists which already exist keep being served " +"regardless of this setting, so credentials issued earlier stay " +"verifiable." +msgstr "" + +msgid "" +"No credential configuration allocates a Status List entry, so no " +"issued credential can be revoked." +msgstr "" + +msgid "Status List Key Profile" +msgstr "" + +msgid "Status List Pools" +msgstr "" + +msgid "Status Lists" +msgstr "" + +msgid "Status Lists Enabled" +msgstr "" + +msgid "These pools are inert, since Status Lists are disabled." +msgstr "" + diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index 6e780cf1..d9c04046 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -1593,3 +1593,49 @@ msgid "" "never removed from storage." msgstr "" +msgid "" +"Credentials of a configuration which is in no pool are issued " +"without a status claim. Privacy rests on many credentials sharing " +"one list, so more pools means a smaller group each credential hides " +"in." +msgstr "" + +msgid "" +"How Status List Tokens identify their signing key. Each list records " +"the profile it was created under, so changing this affects newly " +"created lists only and never invalidates credentials already issued. " +"A pool may override it." +msgstr "" + +msgid "" +"Issued credentials get a Status List entry, so they can be revoked " +"and suspended." +msgstr "" + +msgid "" +"New credentials are issued without a status claim, so they can not " +"be revoked. Status Lists which already exist keep being served " +"regardless of this setting, so credentials issued earlier stay " +"verifiable." +msgstr "" + +msgid "" +"No credential configuration allocates a Status List entry, so no " +"issued credential can be revoked." +msgstr "" + +msgid "Status List Key Profile" +msgstr "" + +msgid "Status List Pools" +msgstr "" + +msgid "Status Lists" +msgstr "" + +msgid "Status Lists Enabled" +msgstr "" + +msgid "These pools are inert, since Status Lists are disabled." +msgstr "" + diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index 3229c629..ccf51446 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -1545,3 +1545,49 @@ msgid "" "never removed from storage." msgstr "" +msgid "" +"Credentials of a configuration which is in no pool are issued " +"without a status claim. Privacy rests on many credentials sharing " +"one list, so more pools means a smaller group each credential hides " +"in." +msgstr "" + +msgid "" +"How Status List Tokens identify their signing key. Each list records " +"the profile it was created under, so changing this affects newly " +"created lists only and never invalidates credentials already issued. " +"A pool may override it." +msgstr "" + +msgid "" +"Issued credentials get a Status List entry, so they can be revoked " +"and suspended." +msgstr "" + +msgid "" +"New credentials are issued without a status claim, so they can not " +"be revoked. Status Lists which already exist keep being served " +"regardless of this setting, so credentials issued earlier stay " +"verifiable." +msgstr "" + +msgid "" +"No credential configuration allocates a Status List entry, so no " +"issued credential can be revoked." +msgstr "" + +msgid "Status List Key Profile" +msgstr "" + +msgid "Status List Pools" +msgstr "" + +msgid "Status Lists" +msgstr "" + +msgid "Status Lists Enabled" +msgstr "" + +msgid "These pools are inert, since Status Lists are disabled." +msgstr "" + diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 8317d11f..55d93859 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -1499,3 +1499,49 @@ msgid "" "never removed from storage." msgstr "" +msgid "" +"Credentials of a configuration which is in no pool are issued " +"without a status claim. Privacy rests on many credentials sharing " +"one list, so more pools means a smaller group each credential hides " +"in." +msgstr "" + +msgid "" +"How Status List Tokens identify their signing key. Each list records " +"the profile it was created under, so changing this affects newly " +"created lists only and never invalidates credentials already issued. " +"A pool may override it." +msgstr "" + +msgid "" +"Issued credentials get a Status List entry, so they can be revoked " +"and suspended." +msgstr "" + +msgid "" +"New credentials are issued without a status claim, so they can not " +"be revoked. Status Lists which already exist keep being served " +"regardless of this setting, so credentials issued earlier stay " +"verifiable." +msgstr "" + +msgid "" +"No credential configuration allocates a Status List entry, so no " +"issued credential can be revoked." +msgstr "" + +msgid "Status List Key Profile" +msgstr "" + +msgid "Status List Pools" +msgstr "" + +msgid "Status Lists" +msgstr "" + +msgid "Status Lists Enabled" +msgstr "" + +msgid "These pools are inert, since Status Lists are disabled." +msgstr "" + diff --git a/routing/services/services.yml b/routing/services/services.yml index cb4866c8..fdd549b1 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -33,6 +33,19 @@ services: resource: '../../src/Repositories/*' exclude: '../../src/Repositories/{Interfaces}' + # Token Status List. Values hold objects built from configuration or read back from storage rather + # than autowired, so it is excluded along with the interfaces. + SimpleSAML\Module\oidc\StatusList\: + resource: '../../src/StatusList/*' + exclude: '../../src/StatusList/{Contracts,Values}' + + # Autowiring by directory glob does not resolve interfaces to implementations, so each port is + # aliased to the implementation it is served by. + SimpleSAML\Module\oidc\StatusList\Contracts\StatusIndexAllocatorInterface: + alias: SimpleSAML\Module\oidc\StatusList\DbStatusIndexAllocator + SimpleSAML\Module\oidc\StatusList\Contracts\StatusUpdaterInterface: + alias: SimpleSAML\Module\oidc\StatusList\DbStatusUpdater + SimpleSAML\Module\oidc\Factories\: resource: '../../src/Factories/*' @@ -137,6 +150,8 @@ services: factory: [ '@SimpleSAML\Module\oidc\Factories\FederationFactory', 'build' ] SimpleSAML\OpenID\VerifiableCredentials: factory: [ '@SimpleSAML\Module\oidc\Factories\VerifiableCredentialsFactory', 'build' ] + SimpleSAML\OpenID\TokenStatusList: + factory: [ '@SimpleSAML\Module\oidc\Factories\TokenStatusListFactory', 'build' ] SimpleSAML\OpenID\Jwks: factory: [ '@SimpleSAML\Module\oidc\Factories\JwksFactory', 'build' ] SimpleSAML\OpenID\Jwk: ~ diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index da903a52..ba9880f0 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -7,8 +7,11 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Codebooks\ConfigOverviewValueTypeEnum; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; +use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; +use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; use Stringable; use Throwable; @@ -51,11 +54,134 @@ public function build(): array $this->buildSignatureKeysSection(), $this->buildCredentialConfigurationsSection(), $this->buildNonRegisteredClientsSection(), + $this->buildStatusListsSection(), $this->buildDurationsSection(), $this->buildCredentialOfferSection(), ]; } + /** + * Token Status List settings, being what makes issued credentials revocable. + */ + protected function buildStatusListsSection(): Section + { + $isEnabled = false; + + try { + $isEnabled = $this->moduleConfig->getVciStatusListEnabled(); + } catch (Throwable) { + // Reported on its own row below, where the option which failed to resolve is named. + } + + $rows = [ + $this->guardRow( + Translate::noop('Status Lists Enabled'), + ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED, + function (): Row { + $enabled = $this->moduleConfig->getVciStatusListEnabled(); + + return new Row( + Translate::noop('Status Lists Enabled'), + $this->yesNo($enabled), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED, + $enabled ? + Translate::noop( + 'Issued credentials get a Status List entry, so they can be revoked and ' . + 'suspended.', + ) : + Translate::noop( + 'New credentials are issued without a status claim, so they can not be ' . + 'revoked. Status Lists which already exist keep being served regardless ' . + 'of this setting, so credentials issued earlier stay verifiable.', + ), + ); + }, + ), + $this->guardRow( + Translate::noop('Status List Key Profile'), + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE, + fn(): Row => new Row( + Translate::noop('Status List Key Profile'), + $this->moduleConfig->getVciStatusListKeyProfile()->value, + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE, + Translate::noop( + 'How Status List Tokens identify their signing key. Each list records the ' . + 'profile it was created under, so changing this affects newly created lists ' . + 'only and never invalidates credentials already issued. A pool may override it.', + ), + ), + ), + $this->guardRow( + Translate::noop('Status List Pools'), + ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS, + function () use ($isEnabled): Row { + $poolBag = $this->moduleConfig->getVciStatusListPoolBag(); + + if ($poolBag->isEmpty()) { + return new Row( + Translate::noop('Status List Pools'), + Translate::noop('None configured'), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS, + Translate::noop( + 'No credential configuration allocates a Status List entry, so no ' . + 'issued credential can be revoked.', + ), + ); + } + + return new Row( + Translate::noop('Status List Pools'), + $this->describeStatusListPools($poolBag), + ConfigOverviewValueTypeEnum::Json, + ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS, + $isEnabled ? + Translate::noop( + 'Credentials of a configuration which is in no pool are issued without a ' . + 'status claim. Privacy rests on many credentials sharing one list, so ' . + 'more pools means a smaller group each credential hides in.', + ) : + Translate::noop( + 'These pools are inert, since Status Lists are disabled.', + ), + ); + }, + ), + ]; + + return new Section(Translate::noop('Status Lists'), 'statusLists', ...$rows); + } + + /** + * @return array> + */ + protected function describeStatusListPools(StatusListPoolBag $poolBag): array + { + $described = []; + + foreach ($poolBag->getAll() as $poolId => $pool) { + $described[$poolId] = [ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => $pool->getCredentialConfigurationIds(), + StatusListPool::KEY_BITS => $pool->getBits(), + StatusListPool::KEY_CAPACITY => $pool->getCapacity(), + StatusListPool::KEY_ALLOWED_STATUSES => array_map( + static fn(StatusTypeEnum $status): string => $status->name, + $pool->getAllowedStatuses(), + ), + StatusListPool::KEY_TTL => $this->dateIntervalFormatter->toDurationSpec($pool->getTtl()), + StatusListPool::KEY_TOKEN_VALIDITY => $this->dateIntervalFormatter + ->toDurationSpec($pool->getTokenValidity()), + StatusListPool::KEY_REFRESH_INTERVAL => $this->dateIntervalFormatter + ->toDurationSpec($pool->getRefreshInterval()), + StatusListPool::KEY_KEY_PROFILE => $pool->getKeyProfile()->value, + ]; + } + + return $described; + } + /** * @throws \Exception */ diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 301a2639..903185e8 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -71,6 +71,15 @@ enum RoutesEnum: string case CredentialIssuerNonce = 'credential-issuer/nonce'; case CredentialJsonLdContext = 'credential-issuer/context/{credentialConfigurationId}'; + /***************************************************************************************************************** + * Token Status List + ****************************************************************************************************************/ + + // Publishes one Status List Token. Deliberately not gated on the Verifiable Credential Issuance + // switch: credentials which were already issued point at these URIs and have to stay verifiable, + // so turning issuance off must not make them unresolvable. + case StatusList = 'statuslist/{statusListId}'; + /***************************************************************************************************************** * SD-JWT-based Verifiable Credentials (SD-JWT VC) ****************************************************************************************************************/ diff --git a/src/Codebooks/StatusChangeSourceEnum.php b/src/Codebooks/StatusChangeSourceEnum.php new file mode 100644 index 00000000..c9fe659a --- /dev/null +++ b/src/Codebooks/StatusChangeSourceEnum.php @@ -0,0 +1,20 @@ +moduleConfig->getFederationSignatureKeyPairBag()->getAllPublicKeys() : []; - $vciPublicKeys = $this->moduleConfig->getVciEnabled() + // Published while Verifiable Credential Issuance is on, and also while any Status List pool is + // configured to identify its signing key through this key set. Status Lists outlive the switch + // which stops new credentials being issued -- credentials already in wallets point at those + // lists and have to stay verifiable -- so withdrawing the key their tokens are signed with the + // moment issuance is turned off would break exactly the guarantee that lifecycle rests on. + $vciPublicKeys = ($this->moduleConfig->getVciEnabled() || $this->isAnyStatusListKeyPublished()) ? $this->moduleConfig->getVciSignatureKeyPairBag()->getAllPublicKeys() : []; @@ -53,6 +60,40 @@ public function __invoke(): JsonResponse ); } + /** + * Whether any configured Status List pool expects its tokens to be verified through this key set. + * + * Answered from configuration alone, deliberately. Asking the stored lists instead would be more + * precise -- a list outlives the pool which created it, so a pool removed or moved to the other key + * profile leaves lists behind which still need this key. But answering it would mean this endpoint + * holding a repository, and resolving that dependency opens a database connection before the + * controller is even entered. A key set which has never needed a database would then fail whenever + * the database did, taking down verification of every ID token and access token this issuer has + * ever signed. That is a far larger failure than the one it would prevent. + * + * The gap this leaves is an operator removing a pool, or switching it to the other key profile, + * while lists created under the old one are still being served. That is the same class of change as + * removing the signing key itself, and it is caught where it can be acted on: publication resolves + * a list's key by the ID stored on it and fails closed rather than signing with something else. + */ + protected function isAnyStatusListKeyPublished(): bool + { + try { + foreach ($this->moduleConfig->getVciStatusListPoolBag()->getAll() as $pool) { + if ($pool->getKeyProfile() === StatusListKeyProfileEnum::Jwks) { + return true; + } + } + } catch (Throwable) { + // A pool which can not be resolved is reported on the configuration overview screen, which + // owns that error. Here the conservative reading is that no pool needs its key published, + // which leaves the key set exactly as it was before Status Lists existed. + return false; + } + + return false; + } + public function jwks(): Response { $response = $this->psrHttpBridge->getHttpFoundationFactory()->createResponse($this->__invoke()); diff --git a/src/Exceptions/StatusConflictException.php b/src/Exceptions/StatusConflictException.php new file mode 100644 index 00000000..8c804663 --- /dev/null +++ b/src/Exceptions/StatusConflictException.php @@ -0,0 +1,16 @@ +moduleConfig->getSupportedAlgorithms(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), + logger: $this->loggerService, + ); + } +} diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 7304140f..e49cf383 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -20,10 +20,14 @@ use Defuse\Crypto\Exception\CryptoException; use Defuse\Crypto\Key; use SimpleSAML\Configuration; +use SimpleSAML\Database; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum; +use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; +use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; @@ -54,6 +58,13 @@ class ModuleConfig public const string KEY_PRIVATE_KEY_PASSWORD = 'private_key_password'; public const string KEY_KEY_ID = 'key_id'; final public const string DEFAULT_FILE_NAME = 'module_oidc.php'; + + /** + * SimpleSAMLphp\Database method performing a read which is guaranteed to hit the primary rather + * than a possibly lagging secondary. Required by the Token Status List capability. + */ + final public const string SSP_PRIMARY_READ_METHOD = 'readPrimary'; + final public const string OPTION_PKI_PRIVATE_KEY_PASSPHRASE = 'pass_phrase'; final public const string DEFAULT_PKI_PRIVATE_KEY_FILENAME = 'oidc_module.key'; final public const string DEFAULT_PKI_CERTIFICATE_FILENAME = 'oidc_module.crt'; @@ -141,6 +152,9 @@ class ModuleConfig final public const string OPTION_TIMESTAMP_VALIDATION_LEEWAY = 'timestamp_validation_leeway'; final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; final public const string OPTION_VCI_CREDENTIAL_JSON_LD_CONTEXT = 'vci_credential_json_ld_context'; + final public const string OPTION_VCI_STATUS_LIST_ENABLED = 'vci_status_list_enabled'; + final public const string OPTION_VCI_STATUS_LIST_KEY_PROFILE = 'vci_status_list_key_profile'; + final public const string OPTION_VCI_STATUS_LIST_POOLS = 'vci_status_list_pools'; final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; final public const string OPTION_DCR_INITIAL_ACCESS_TOKENS = 'dcr_initial_access_tokens'; @@ -191,6 +205,7 @@ class ModuleConfig protected ?SignatureKeyPairBag $federationSignatureKeyPairBag = null; protected ?SignatureKeyPairBag $vciSignatureKeyPairBag = null; protected ?SignatureKeyPairConfigBag $vciSignatureKeyPairConfigBag = null; + protected ?StatusListPoolBag $vciStatusListPoolBag = null; /** * @throws \Exception @@ -1223,6 +1238,152 @@ public function getVciEnabled(): bool return $this->config()->getOptionalBoolean(self::OPTION_VCI_ENABLED, false); } + /** + * Whether new credentials get a Token Status List entry allocated to them. + * + * Note what this switch does not do: it never stops the Status List endpoint from serving. Turning + * it off must leave already issued credentials verifiable, so lists keep being served until they + * are retired through their own lifecycle. Only allocation of new entries stops. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciStatusListEnabled(): bool + { + if (!$this->config()->getOptionalBoolean(self::OPTION_VCI_STATUS_LIST_ENABLED, false)) { + return false; + } + + // Refuse to enable rather than fall back to a replica read. Deciding whether a credential has + // been revoked off a lagging secondary can publish a revoked credential as valid, and the host + // SimpleSAMLphp is a development dependency here, so Composer can not enforce a floor for us: + // this module can be installed into an older SimpleSAMLphp and nothing would object. + if (!self::hasPrimaryDatabaseReadCapability()) { + throw new ConfigurationError( + sprintf( + 'Token Status Lists are enabled ("%s"), but the installed SimpleSAMLphp does not ' . + 'provide %s::%s(). Status List correctness depends on reading back what was just ' . + 'written rather than a possibly lagging secondary, so this capability is required. ' . + 'Upgrade SimpleSAMLphp to a version providing it, or disable "%s".', + self::OPTION_VCI_STATUS_LIST_ENABLED, + Database::class, + self::SSP_PRIMARY_READ_METHOD, + self::OPTION_VCI_STATUS_LIST_ENABLED, + ), + self::DEFAULT_FILE_NAME, + ); + } + + return true; + } + + /** + * Whether the host SimpleSAMLphp can perform reads which bypass secondaries. + * + * Checked against the class rather than an instance, so that this stays a pure capability question + * and config loading does not have to reach for a database connection. + */ + public static function hasPrimaryDatabaseReadCapability(): bool + { + return method_exists(Database::class, self::SSP_PRIMARY_READ_METHOD); + } + + /** + * Key profile used for Status List Tokens which do not have one set on their own pool. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciStatusListKeyProfile(): StatusListKeyProfileEnum + { + /** @var mixed $configured */ + $configured = $this->config()->getOptionalValue(self::OPTION_VCI_STATUS_LIST_KEY_PROFILE, null); + + if ($configured === null) { + return StatusListKeyProfileEnum::DidJwk; + } + + if ($configured instanceof StatusListKeyProfileEnum) { + return $configured; + } + + if (is_string($configured) && ($profile = StatusListKeyProfileEnum::tryFrom($configured)) !== null) { + return $profile; + } + + throw new ConfigurationError( + sprintf( + 'Option "%s" must be one of: %s.', + self::OPTION_VCI_STATUS_LIST_KEY_PROFILE, + implode( + ', ', + array_map( + static fn(StatusListKeyProfileEnum $case): string => $case->value, + StatusListKeyProfileEnum::cases(), + ), + ), + ), + self::DEFAULT_FILE_NAME, + ); + } + + /** + * The configured Status List pools. + * + * Deliberately a separate top-level option rather than something nested inside the credential + * configurations: those are returned wholesale as published Credential Issuer metadata, so a + * private control placed among them would become visible to every wallet. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciStatusListPoolBag(): StatusListPoolBag + { + if ($this->vciStatusListPoolBag instanceof StatusListPoolBag) { + return $this->vciStatusListPoolBag; + } + + $poolBag = StatusListPoolBag::fromConfig( + $this->config()->getOptionalArray(self::OPTION_VCI_STATUS_LIST_POOLS, []), + $this->getVciStatusListKeyProfile(), + ); + + $supportedIds = $this->getVciCredentialConfigurationIdsSupported(); + + foreach ($poolBag->getAllCredentialConfigurationIds() as $credentialConfigurationId) { + if (in_array($credentialConfigurationId, $supportedIds, true)) { + continue; + } + + // A typo here would otherwise be silent: the pool would simply never be allocated from, and + // the credentials which were meant to be revocable would be issued without a status claim. + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" lists the credential configuration "%s", which is not one of ' . + 'the configurations declared under "%s".', + (string)$poolBag->getForCredentialConfigurationId($credentialConfigurationId)?->getId(), + $credentialConfigurationId, + self::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED, + ), + self::DEFAULT_FILE_NAME, + ); + } + + return $this->vciStatusListPoolBag = $poolBag; + } + + /** + * The pool a credential configuration allocates Status List entries from, or null if it is not + * configured to use them, in which case its credentials are issued without a `status` claim. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciStatusListPoolFor(string $credentialConfigurationId): ?StatusListPool + { + if (!$this->getVciStatusListEnabled()) { + return null; + } + + return $this->getVciStatusListPoolBag()->getForCredentialConfigurationId($credentialConfigurationId); + } + /***************************************************************************************************************** * OpenID Connect Dynamic Client Registration related config. diff --git a/src/Repositories/StatusAuditRepository.php b/src/Repositories/StatusAuditRepository.php new file mode 100644 index 00000000..3cdff6d5 --- /dev/null +++ b/src/Repositories/StatusAuditRepository.php @@ -0,0 +1,91 @@ +database->applyPrefix(self::TABLE_NAME); + } + + /** + * @param ?string $actorRef Who asked for the change: an API token principal's name, an + * administrator's identifier, or null for an unattended one. Never the API token itself, which + * would put a bearer secret in the audit trail. + * @throws \Exception + */ + public function record( + string $credentialIdHash, + string $statusListId, + int $idx, + int $observedOldStatus, + int $newStatus, + StatusChangeSourceEnum $source, + ?string $actorRef = null, + ?DateTimeImmutable $createdAt = null, + ): void { + $this->database->write( + sprintf( + 'INSERT INTO %s ( + id, credential_id_hash, status_list_id, idx, old_status, new_status, actor_ref, + source, created_at + ) VALUES ( + :id, :credential_id_hash, :status_list_id, :idx, :old_status, :new_status, + :actor_ref, :source, :created_at + )', + $this->getTableName(), + ), + [ + 'id' => $this->helpers->random()->getIdentifier(), + 'credential_id_hash' => $credentialIdHash, + 'status_list_id' => $statusListId, + 'idx' => [$idx, PDO::PARAM_INT], + 'old_status' => [$observedOldStatus, PDO::PARAM_INT], + 'new_status' => [$newStatus, PDO::PARAM_INT], + 'actor_ref' => $actorRef, + 'source' => $source->value, + 'created_at' => ($createdAt ?? $this->helpers->dateTime()->getUtc()) + ->format(DateFormatsEnum::DB_DATETIME->value), + ], + ); + } +} diff --git a/src/Repositories/StatusListEntryRepository.php b/src/Repositories/StatusListEntryRepository.php new file mode 100644 index 00000000..73643966 --- /dev/null +++ b/src/Repositories/StatusListEntryRepository.php @@ -0,0 +1,333 @@ +database->applyPrefix(self::TABLE_NAME); + } + + /** + * The form a credential identifier is looked up by. + * + * The identifier itself is a URI, which has no length this schema could safely assume, so the + * column carrying it is unindexed text and a fixed width hash of it carries the unique index. This + * lives here because it is a property of how the row is stored, and every caller which stores or + * finds one has to agree on it. + */ + public function hashCredentialId(string $credentialId): string + { + return hash('sha256', $credentialId); + } + + /** + * Creates every index of a newly created list, unallocated and Valid. + * + * Only the two key columns are written; `allocated` and `status` take their column defaults, which + * halves the statement size and keeps the defaults defined in exactly one place. + * + * @throws \Exception + */ + public function seed(string $statusListId, int $capacity): void + { + for ($offset = 0; $offset < $capacity; $offset += self::SEED_BATCH_SIZE) { + $batchSize = min(self::SEED_BATCH_SIZE, $capacity - $offset); + $placeholders = []; + $params = []; + + for ($position = 0; $position < $batchSize; $position++) { + // Each value gets its own placeholder name. Repeating one is not portable: PDO turns + // named placeholders into positional ones for some drivers, and a repeat then binds + // only the first occurrence. + $placeholders[] = sprintf('(:list_%d, :idx_%d)', $position, $position); + $params['list_' . $position] = $statusListId; + $params['idx_' . $position] = [$offset + $position, PDO::PARAM_INT]; + } + + $this->database->write( + sprintf( + 'INSERT INTO %s (status_list_id, idx) VALUES %s', + $this->getTableName(), + implode(', ', $placeholders), + ), + $params, + ); + } + } + + /** + * Claims one index for a credential, if it is still free and its list still accepts allocations. + * + * The linkage is written by the same statement which claims the index, so there is never a moment + * where an index is taken but nothing records what took it. + * + * @return bool Whether this caller got the index. False means another request claimed it first, or + * the list stopped accepting allocations, and the caller should probe again or select another list. + * @throws \Exception + */ + public function allocate( + string $statusListId, + int $idx, + string $credentialId, + string $credentialIdHash, + string $credentialConfigurationId, + ?string $subjectRef, + ?DateTimeImmutable $expiresAt, + ?DateTimeImmutable $issuedAt = null, + ): bool { + $now = $this->formatForDatabase($issuedAt ?? $this->helpers->dateTime()->getUtc()); + $statusListTableName = $this->database->applyPrefix(StatusListRepository::TABLE_NAME); + + $affected = $this->database->write( + sprintf( + 'UPDATE %s SET + allocated = :allocated, + credential_id = :credential_id, + credential_id_hash = :credential_id_hash, + credential_configuration_id = :credential_configuration_id, + subject_ref = :subject_ref, + issued_at = :issued_at, + expires_at = :expires_at, + updated_at = :updated_at + WHERE status_list_id = :status_list_id + AND idx = :idx + AND allocated = :is_free + AND EXISTS ( + SELECT 1 FROM %s WHERE id = :guarded_status_list_id AND is_active = :is_active + )', + $this->getTableName(), + $statusListTableName, + ), + [ + 'allocated' => [true, PDO::PARAM_BOOL], + 'credential_id' => $credentialId, + 'credential_id_hash' => $credentialIdHash, + 'credential_configuration_id' => $credentialConfigurationId, + 'subject_ref' => $subjectRef, + 'issued_at' => $now, + 'expires_at' => $expiresAt instanceof DateTimeImmutable ? + $this->formatForDatabase($expiresAt) : + null, + 'updated_at' => $now, + 'status_list_id' => $statusListId, + 'idx' => [$idx, PDO::PARAM_INT], + 'is_free' => [false, PDO::PARAM_BOOL], + // The same value as :status_list_id, under its own name because a repeated placeholder + // is not portable across drivers. + 'guarded_status_list_id' => $statusListId, + 'is_active' => [true, PDO::PARAM_BOOL], + ], + ); + + return is_int($affected) && $affected > 0; + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function findByCredentialIdHash(string $credentialIdHash): ?StatusListEntryRecord + { + return $this->buildRecord( + $this->readPrimary( + "SELECT * FROM {$this->getTableName()} WHERE credential_id_hash = :credential_id_hash", + ['credential_id_hash' => $credentialIdHash], + ), + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function findByListAndIdx(string $statusListId, int $idx): ?StatusListEntryRecord + { + return $this->buildRecord( + $this->readPrimary( + "SELECT * FROM {$this->getTableName()} WHERE status_list_id = :status_list_id AND idx = :idx", + [ + 'status_list_id' => $statusListId, + 'idx' => [$idx, PDO::PARAM_INT], + ], + ), + ); + } + + /** + * Moves an allocated entry from one status to another. + * + * Conditioned on the status the caller observed, so that two concurrent changes do not silently + * overwrite one another: the second one finds the status is no longer what it read and gets false + * back rather than clobbering the first. + * + * @return bool Whether the change was applied. + * @throws \Exception + */ + public function updateStatus( + string $statusListId, + int $idx, + int $observedStatus, + int $newStatus, + ): bool { + $affected = $this->database->write( + sprintf( + 'UPDATE %s SET status = :new_status, updated_at = :updated_at + WHERE status_list_id = :status_list_id + AND idx = :idx + AND allocated = :allocated + AND status = :observed_status', + $this->getTableName(), + ), + [ + 'new_status' => [$newStatus, PDO::PARAM_INT], + 'updated_at' => $this->formatForDatabase($this->helpers->dateTime()->getUtc()), + 'status_list_id' => $statusListId, + 'idx' => [$idx, PDO::PARAM_INT], + 'allocated' => [true, PDO::PARAM_BOOL], + 'observed_status' => [$observedStatus, PDO::PARAM_INT], + ], + ); + + return is_int($affected) && $affected > 0; + } + + /** + * Index to status for every entry which is not Valid, which is all a Status List needs in order to + * be rebuilt: every index the query does not return is Valid, including the ones never allocated. + * + * A primary read, since this is what gets signed and published. + * + * @return array + */ + public function findNonValidStatuses(string $statusListId): array + { + $rows = $this->readPrimary( + "SELECT idx, status FROM {$this->getTableName()} " . + 'WHERE status_list_id = :status_list_id AND status <> :valid_status ORDER BY idx', + [ + 'status_list_id' => $statusListId, + 'valid_status' => [0, PDO::PARAM_INT], + ], + ); + + $statuses = []; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + /** @var mixed $idx */ + $idx = $row['idx'] ?? null; + /** @var mixed $status */ + $status = $row['status'] ?? null; + + if (!is_numeric($idx) || !is_numeric($status)) { + continue; + } + + $statuses[(int)$idx] = (int)$status; + } + + return $statuses; + } + + public function countAllocated(string $statusListId): int + { + $rows = $this->readPrimary( + "SELECT COUNT(*) AS allocated_total FROM {$this->getTableName()} " . + 'WHERE status_list_id = :status_list_id AND allocated = :allocated', + [ + 'status_list_id' => $statusListId, + 'allocated' => [true, PDO::PARAM_BOOL], + ], + ); + + /** @var mixed $total */ + $total = $rows[0]['allocated_total'] ?? null; + + return is_numeric($total) ? (int)$total : 0; + } + + /** + * @param array $rows + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function buildRecord(array $rows): ?StatusListEntryRecord + { + /** @var mixed $row */ + $row = $rows === [] ? null : current($rows); + + return is_array($row) ? StatusListEntryRecord::fromRow($row) : null; + } + + /** + * @param array $params + * @return array + */ + protected function readPrimary(string $statement, array $params = []): array + { + return $this->database->readPrimary($statement, $params)->fetchAll(); + } + + /** + * Timestamps are stored without a zone and read back as UTC, so a moment is converted to UTC on the + * way in rather than having its wall clock written as-is. Without this, an expiry handed in as a + * local time would be stored as that local wall clock and later read as though it were UTC, moving + * the credential's expiry by the offset -- which for a deployment west of UTC means expiring it + * early, and can retire a list while a credential in it is still live. + */ + protected function formatForDatabase(DateTimeImmutable $moment): string + { + return $moment->setTimezone(new DateTimeZone('UTC'))->format(DateFormatsEnum::DB_DATETIME->value); + } +} diff --git a/src/Repositories/StatusListRepository.php b/src/Repositories/StatusListRepository.php new file mode 100644 index 00000000..37ed6767 --- /dev/null +++ b/src/Repositories/StatusListRepository.php @@ -0,0 +1,456 @@ +database->applyPrefix(self::TABLE_NAME); + } + + /** + * Reads a list for the purpose of serving it. + * + * Deliberately a secondary read: this is the endpoint's hot path, and a token which is one + * replica-lag interval stale is well inside the staleness the `ttl` claim already sanctions. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function findById(string $id): ?StatusListRecord + { + $statusList = $this->buildRecord( + $this->database->read( + "SELECT * FROM {$this->getTableName()} WHERE id = :id", + ['id' => $id], + )->fetchAll(), + ); + + if ($statusList instanceof StatusListRecord) { + return $statusList; + } + + // Not finding it is the one answer a secondary is not allowed to give on its own. Serving a + // token a replication interval old is fine, and bounded by the token's own `ttl`; saying the + // list does not exist is a 404 for a credential which was just issued and does. The extra read + // only ever happens on this path, which is either that race or a genuinely unknown list. + return $this->findByIdOnPrimary($id); + } + + /** + * Reads a list for the purpose of deciding something about it. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function findByIdOnPrimary(string $id): ?StatusListRecord + { + return $this->buildRecord( + $this->readPrimary( + "SELECT * FROM {$this->getTableName()} WHERE id = :id", + ['id' => $id], + ), + ); + } + + /** + * The lists new credentials of this pool may currently be allocated into. + * + * Filtering on the policy fingerprint and not merely on the pool is what keeps a settings change + * from leaving lists created under the old policy eligible. During a signing key rotation in + * particular, the issuer signs credentials with the current key while a list bound to the previous + * one would still be selected, quietly breaking the profile which says the two are the same key. + * + * @return \SimpleSAML\Module\oidc\StatusList\Values\StatusListRecord[] + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function findActiveForPolicy(string $poolId, string $policyFingerprint): array + { + $rows = $this->readPrimary( + "SELECT * FROM {$this->getTableName()} " . + 'WHERE pool_id = :pool_id AND policy_fingerprint = :policy_fingerprint ' . + 'AND is_active = :is_active AND retired_at IS NULL', + [ + 'pool_id' => $poolId, + 'policy_fingerprint' => $policyFingerprint, + 'is_active' => [true, PDO::PARAM_BOOL], + ], + ); + + $records = []; + + /** @var mixed $row */ + foreach ($rows as $row) { + if (is_array($row)) { + $records[] = StatusListRecord::fromRow($row); + } + } + + return $records; + } + + /** + * Lists of this pool which exist but are not open for allocation yet, because whichever request + * created them is still seeding their entries. + * + * These are invisible to findActiveForPolicy() by design -- nothing may allocate into a list whose + * indices do not all exist yet -- but they are not invisible to the decision of whether to start + * another list. Without this, every request arriving during a seed would conclude the pool is empty + * and start a list of its own, and the pool would end up with several sparse lists instead of one. + * That costs herd privacy, which is the whole reason credentials share a list. + * + * @param \DateTimeImmutable $createdAfter Ignore anything older, which is taken to have been + * abandoned by a request that died partway through seeding. Waiting on those would stall every + * later request for nothing. + * @param ?int $belowGeneration Restrict to generations below this one. Used by a request which has + * just created a list to find out whether another request is already preparing an earlier one, in + * which case its own is redundant. + * @return \SimpleSAML\Module\oidc\StatusList\Values\StatusListRecord[] + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function findBeingPreparedForPolicy( + string $poolId, + string $policyFingerprint, + DateTimeImmutable $createdAfter, + ?int $belowGeneration = null, + ): array { + $params = [ + 'pool_id' => $poolId, + 'policy_fingerprint' => $policyFingerprint, + 'is_active' => [false, PDO::PARAM_BOOL], + 'created_after' => $this->nowForDatabase($createdAfter), + ]; + + $generationCondition = ''; + + if ($belowGeneration !== null) { + $generationCondition = ' AND generation < :below_generation'; + $params['below_generation'] = [$belowGeneration, PDO::PARAM_INT]; + } + + $rows = $this->readPrimary( + "SELECT * FROM {$this->getTableName()} " . + 'WHERE pool_id = :pool_id AND policy_fingerprint = :policy_fingerprint ' . + 'AND is_active = :is_active AND deactivated_at IS NULL AND retired_at IS NULL ' . + 'AND created_at > :created_after' . $generationCondition, + $params, + ); + + $records = []; + + /** @var mixed $row */ + foreach ($rows as $row) { + if (is_array($row)) { + $records[] = StatusListRecord::fromRow($row); + } + } + + return $records; + } + + /** + * Removes a list this request created and then decided not to use. + * + * Guarded so it can only ever remove one which was never opened, and so never one that a credential + * could be pointing at: a list's URI is only handed out once it is open for allocation. + * + * The entries go first, explicitly, rather than through the foreign key. The constraint is declared + * ON DELETE CASCADE and MySQL and PostgreSQL honour it, but SQLite enforces foreign keys only when + * the connection asks it to, which this module's database wrapper does not do. Relying on the + * cascade would therefore leave every entry behind on one of the three supported drivers. + * + * @throws \Exception + */ + public function deleteUnopened(string $id): bool + { + $statusList = $this->findByIdOnPrimary($id); + + if ( + !$statusList instanceof StatusListRecord || + $statusList->isActive() || + $statusList->getDeactivatedAt() instanceof DateTimeImmutable + ) { + return false; + } + + // Entries first, parent second. The other order looks more natural and is not retryable: a + // crash in between would leave a list's worth of orphaned entries whose parent is already gone, + // so a second call would find nothing to delete and return before cleaning them up. This way a + // crash in between leaves an empty unopened list, which the next call simply finishes off. + $this->database->write( + sprintf( + 'DELETE FROM %s WHERE status_list_id = :status_list_id', + $this->database->applyPrefix(StatusListEntryRepository::TABLE_NAME), + ), + ['status_list_id' => $id], + ); + + $affected = $this->database->write( + sprintf( + 'DELETE FROM %s WHERE id = :id AND is_active = :is_active AND deactivated_at IS NULL', + $this->getTableName(), + ), + [ + 'id' => $id, + 'is_active' => [false, PDO::PARAM_BOOL], + ], + ); + + return is_int($affected) && $affected > 0; + } + + /** + * Highest generation used in a pool so far, or 0 when the pool has no lists yet. + */ + public function getHighestGeneration(string $poolId): int + { + $rows = $this->readPrimary( + "SELECT MAX(generation) AS highest FROM {$this->getTableName()} WHERE pool_id = :pool_id", + ['pool_id' => $poolId], + ); + + /** @var mixed $highest */ + $highest = $rows[0]['highest'] ?? null; + + return is_numeric($highest) ? (int)$highest : 0; + } + + /** + * Inserts a new list. + * + * The unique constraint on (pool_id, generation) is what settles a race between two workers both + * deciding a successor is needed: one insert succeeds and the other fails. The caller does not need + * to work out *why* it failed -- and could not reliably, since the database wrapper reports the + * connection's error rather than the statement's -- because the recovery is the same for any + * failure: read the pool again and use whichever list is active now. + * + * @param string $allowedStatuses Comma separated status values, already serialised by the caller. + * Persisted rather than looked up from configuration later, so the list stays publishable exactly + * as its holders resolved it even after the pool's settings change. + * @throws \Exception + */ + public function create( + string $id, + string $uri, + string $poolId, + string $policyFingerprint, + int $generation, + int $bits, + int $capacity, + string $allowedStatuses, + int $ttlSeconds, + int $tokenValiditySeconds, + int $refreshIntervalSeconds, + string $signingKeyId, + StatusListKeyProfileEnum $keyProfile, + ): void { + $this->database->write( + sprintf( + 'INSERT INTO %s ( + id, uri, pool_id, policy_fingerprint, generation, bits, capacity, allowed_statuses, + ttl_seconds, token_validity_seconds, refresh_interval_seconds, signing_key_id, + key_profile, allocated_count, is_active, signed_token_content_hash, created_at + ) VALUES ( + :id, :uri, :pool_id, :policy_fingerprint, :generation, :bits, :capacity, + :allowed_statuses, :ttl_seconds, :token_validity_seconds, :refresh_interval_seconds, + :signing_key_id, :key_profile, :allocated_count, :is_active, + :signed_token_content_hash, :created_at + )', + $this->getTableName(), + ), + [ + 'id' => $id, + 'uri' => $uri, + 'pool_id' => $poolId, + 'policy_fingerprint' => $policyFingerprint, + 'generation' => [$generation, PDO::PARAM_INT], + 'bits' => [$bits, PDO::PARAM_INT], + 'capacity' => [$capacity, PDO::PARAM_INT], + 'allowed_statuses' => $allowedStatuses, + 'ttl_seconds' => [$ttlSeconds, PDO::PARAM_INT], + 'token_validity_seconds' => [$tokenValiditySeconds, PDO::PARAM_INT], + 'refresh_interval_seconds' => [$refreshIntervalSeconds, PDO::PARAM_INT], + 'signing_key_id' => $signingKeyId, + 'key_profile' => $keyProfile->value, + 'allocated_count' => [0, PDO::PARAM_INT], + // Created inactive, and activated only once every index has been seeded. Otherwise a + // concurrent request could select this list and probe indices which do not exist yet, + // find nothing to claim, and rotate away from a list which was perfectly good. + 'is_active' => [false, PDO::PARAM_BOOL], + // Empty rather than null, so that the compare-and-set which publishes the first token + // has something to match: a NULL would never equal a NULL and the update would affect + // no rows. + 'signed_token_content_hash' => '', + 'created_at' => $this->nowForDatabase(), + ], + ); + } + + /** + * Opens a freshly seeded list for allocation. + * + * Guarded on the list never having been deactivated, so that this can not resurrect a list which + * was retired while it was being seeded. + * + * @throws \Exception + */ + public function activate(string $id): bool + { + $affected = $this->database->write( + sprintf( + 'UPDATE %s SET is_active = :new_is_active ' . + 'WHERE id = :id AND is_active = :current_is_active AND deactivated_at IS NULL', + $this->getTableName(), + ), + [ + 'new_is_active' => [true, PDO::PARAM_BOOL], + 'id' => $id, + 'current_is_active' => [false, PDO::PARAM_BOOL], + ], + ); + + return is_int($affected) && $affected > 0; + } + + /** + * Stops a list accepting new allocations. + * + * @return bool Whether this call is the one which deactivated it, so that of several workers + * deciding at the same time that the list is full, exactly one goes on to create the successor. + * @throws \Exception + */ + public function deactivate(string $id): bool + { + $affected = $this->database->write( + sprintf( + 'UPDATE %s SET is_active = :new_is_active, deactivated_at = :deactivated_at ' . + 'WHERE id = :id AND is_active = :current_is_active', + $this->getTableName(), + ), + [ + 'new_is_active' => [false, PDO::PARAM_BOOL], + 'deactivated_at' => $this->nowForDatabase(), + 'id' => $id, + 'current_is_active' => [true, PDO::PARAM_BOOL], + ], + ); + + return is_int($affected) && $affected > 0; + } + + /** + * Bumps the advisory allocation counter. + * + * Separate from the allocation itself, so it can undercount when a request dies in between. That is + * tolerated because the counter only ever decides when to *consider* rotating, and running out of + * probes rotates anyway. + * + * @throws \Exception + */ + public function incrementAllocatedCount(string $id): void + { + $this->database->write( + sprintf( + 'UPDATE %s SET allocated_count = allocated_count + 1 WHERE id = :id', + $this->getTableName(), + ), + ['id' => $id], + ); + } + + /** + * Marks the published token as no longer representing the list's content. + * + * Called by whichever path changed a status, right after it changed it. Doing it in this order + * matters: a crash between the two leaves a published token which is one refresh interval too + * optimistic, whereas invalidating first and crashing before the change would lose the change + * entirely. A spurious re-sign is cheap; a lost revocation is not. + * + * The guard keeps this a real modification when it matches, so that a driver reporting changed + * rather than matched rows still reports it accurately. + * + * @throws \Exception + */ + public function invalidatePublishedToken(string $id): void + { + $this->database->write( + sprintf( + "UPDATE %s SET signed_token_content_hash = '' " . + "WHERE id = :id AND signed_token_content_hash <> ''", + $this->getTableName(), + ), + ['id' => $id], + ); + } + + /** + * @param array $rows + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function buildRecord(array $rows): ?StatusListRecord + { + /** @var mixed $row */ + $row = $rows === [] ? null : current($rows); + + return is_array($row) ? StatusListRecord::fromRow($row) : null; + } + + /** + * A read which is guaranteed not to come from a lagging secondary. + * + * @param array $params + * @return array + */ + protected function readPrimary(string $statement, array $params = []): array + { + return $this->database->readPrimary($statement, $params)->fetchAll(); + } + + /** + * Timestamps are stored without a zone and read back as UTC, so a moment is converted to UTC on the + * way in rather than having its wall clock written as-is. A value handed in as a local time would + * otherwise be stored as that local wall clock and later read as though it were UTC, shifting it by + * the offset. + */ + protected function nowForDatabase(?DateTimeImmutable $moment = null): string + { + return ($moment ?? $this->helpers->dateTime()->getUtc()) + ->setTimezone(new DateTimeZone('UTC')) + ->format(DateFormatsEnum::DB_DATETIME->value); + } +} diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index abe3c833..580cddc5 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -18,6 +18,7 @@ use PDO; use SimpleSAML\Database; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; @@ -25,11 +26,17 @@ use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; +use SimpleSAML\Module\oidc\Repositories\StatusAuditRepository; +use SimpleSAML\Module\oidc\Repositories\StatusListEntryRepository; +use SimpleSAML\Module\oidc\Repositories\StatusListRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; class DatabaseMigration { + /** Driver name reported for MySQL and MariaDB, the one driver needing its own DDL below. */ + private const string DRIVER_MYSQL = 'mysql'; + private readonly Database $database; public function __construct(?Database $database = null) @@ -230,6 +237,26 @@ public function migrate(): void $this->version20260624000001(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260624000001')"); } + + // Token Status List storage. Deliberately one table per version rather than one version for + // all three: a version is recorded only once its whole method has succeeded, and there are no + // transactions to undo what a method managed before it failed, so a method which creates + // several tables would leave some of them behind and then fail again on the next run. The DDL + // below is idempotent for the same reason. + if (!in_array('20260801000001', $versions, true)) { + $this->version20260801000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260801000001')"); + } + + if (!in_array('20260801000002', $versions, true)) { + $this->version20260801000002(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260801000002')"); + } + + if (!in_array('20260801000003', $versions, true)) { + $this->version20260801000003(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260801000003')"); + } } private function versionsTableName(): string @@ -791,6 +818,243 @@ private function version20260624000001(): void } + /** + * Create the Status List table. + * + * Everything the pool configured is stored on the row rather than looked up at use time. A list has + * to keep being served exactly as the credentials pointing at it were issued against, so changing + * a pool's settings routes new credentials to new lists and leaves existing ones alone. + */ + private function version20260801000001(): void + { + $statusListTableName = $this->database->applyPrefix(StatusListRepository::TABLE_NAME); + $uqPoolGeneration = $this->generateIdentifierName([$statusListTableName, 'pool_generation'], 'uq'); + $ckBits = $this->generateIdentifierName([$statusListTableName, 'bits'], 'ck'); + $ckCapacity = $this->generateIdentifierName([$statusListTableName, 'capacity'], 'ck'); + $idxAllocationCandidates = $this->generateIdentifierName( + [$statusListTableName, 'allocation_candidates'], + 'idx', + ); + + $dateTime = $this->dateTimeColumnType(); + // Worst case, at 8 bits per entry and the default capacity, the signed token runs to some + // 233 KB: the byte array is 128 KiB, its base64url form around 175 KB, and the JWT payload is + // that JSON base64url encoded a second time. MySQL's TEXT tops out at 64 KB, so it needs + // MEDIUMTEXT; the other drivers have no such limit. + $largeText = $this->largeTextColumnType(); + + // Every fixed length looking column here is VARCHAR rather than CHAR, deliberately. These hold + // values produced by application code and then compared for equality, and PostgreSQL blank-pads + // CHAR and keeps the padding on the way out. A value even one character short would come back + // padded and never compare equal to itself again -- silently, and only on PostgreSQL. + $this->database->write(<<< EOT + CREATE TABLE IF NOT EXISTS $statusListTableName ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + uri TEXT NOT NULL, + pool_id VARCHAR(191) NOT NULL, + policy_fingerprint VARCHAR(64) NOT NULL, + generation INT NOT NULL, + bits SMALLINT NOT NULL, + capacity INT NOT NULL, + allowed_statuses VARCHAR(64) NOT NULL, + ttl_seconds INT NOT NULL, + token_validity_seconds INT NOT NULL, + refresh_interval_seconds INT NOT NULL, + signing_key_id TEXT NOT NULL, + key_profile VARCHAR(32) NOT NULL, + allocated_count INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + deactivated_at $dateTime NULL, + retired_at $dateTime NULL, + signed_token $largeText NULL, + -- VARCHAR rather than CHAR because this column is the one place a fixed width would not + -- hold: it carries either a 64 character hash or the empty string meaning nothing is + -- published. PostgreSQL blank-pads CHAR and keeps the padding on the way out, so an empty + -- string would read back as 64 spaces and never compare equal to '' again, which would + -- make the compare-and-set that publishes a token match no rows for ever. + signed_token_content_hash VARCHAR(64) NOT NULL DEFAULT '', + signed_token_iat $dateTime NULL, + signed_token_exp $dateTime NULL, + created_at $dateTime NOT NULL, + CONSTRAINT $uqPoolGeneration UNIQUE (pool_id, generation), + CONSTRAINT $ckBits CHECK (bits IN (1, 2, 4, 8)), + CONSTRAINT $ckCapacity CHECK (capacity > 0) + ) +EOT + ,); + + // Allocation asks for the active lists of one pool whose policy matches the current one, so + // all three columns are in the index, in that order. + $this->createIndex( + $idxAllocationCandidates, + $statusListTableName, + 'pool_id, is_active, policy_fingerprint', + ); + } + + /** + * Create the Status List entry table. + * + * Every index of a list exists as a row from the moment the list is created, which is what lets an + * index be claimed by an UPDATE that matches only while it is free. The affected row count then + * answers "did I get it", with no need to tell a unique constraint violation apart from any other + * database error, which this module can not do reliably. + */ + private function version20260801000002(): void + { + $statusListTableName = $this->database->applyPrefix(StatusListRepository::TABLE_NAME); + $entryTableName = $this->database->applyPrefix(StatusListEntryRepository::TABLE_NAME); + + $fkEntryStatusList = $this->generateIdentifierName([$entryTableName, 'status_list_id'], 'fk'); + $ckStatus = $this->generateIdentifierName([$entryTableName, 'status'], 'ck'); + $ckIdx = $this->generateIdentifierName([$entryTableName, 'idx'], 'ck'); + $uqCredentialIdHash = $this->generateIdentifierName([$entryTableName, 'credential_id_hash'], 'uq'); + $idxReconstruction = $this->generateIdentifierName([$entryTableName, 'reconstruction'], 'idx'); + $idxExpiresAt = $this->generateIdentifierName([$entryTableName, 'expires_at'], 'idx'); + $idxListExpiresAt = $this->generateIdentifierName([$entryTableName, 'list_expires_at'], 'idx'); + + $dateTime = $this->dateTimeColumnType(); + + // The credential ID is a URI, so it has no length this schema could safely assume; lookups go + // through its hash, which does. Note that expires_at being NULL is meaningful: it marks a + // credential which never expires, and a list holding one can never be retired. + $this->database->write(<<< EOT + CREATE TABLE IF NOT EXISTS $entryTableName ( + status_list_id VARCHAR(64) NOT NULL, + idx INT NOT NULL, + allocated BOOLEAN NOT NULL DEFAULT false, + status SMALLINT NOT NULL DEFAULT 0, + expires_at $dateTime NULL, + credential_id TEXT NULL, + credential_id_hash VARCHAR(64) NULL, + credential_configuration_id VARCHAR(191) NULL, + subject_ref VARCHAR(64) NULL, + issued_at $dateTime NULL, + updated_at $dateTime NULL, + PRIMARY KEY (status_list_id, idx), + CONSTRAINT $fkEntryStatusList FOREIGN KEY (status_list_id) + REFERENCES $statusListTableName (id) ON DELETE CASCADE, + CONSTRAINT $ckStatus CHECK (status >= 0), + CONSTRAINT $ckIdx CHECK (idx >= 0) + ) +EOT + ,); + + // Unallocated rows leave this null, and every driver here allows repeated nulls in a unique + // index, so the constraint applies to the allocated rows only, which is what is wanted. + $this->createIndex($uqCredentialIdHash, $entryTableName, 'credential_id_hash', true); + + // Rebuilding a list reads the entries which are not Valid, in index order. Carrying idx in the + // index as well makes that query answerable from the index alone. + $this->createIndex($idxReconstruction, $entryTableName, 'status_list_id, status, idx'); + + // Deleting the linkage of credentials which have expired, across all lists. + $this->createIndex($idxExpiresAt, $entryTableName, 'expires_at'); + + // Deciding whether one list has any entry left which keeps it from being retired. + $this->createIndex($idxListExpiresAt, $entryTableName, 'status_list_id, expires_at'); + } + + /** + * Create the Status List audit table. + * + * Deliberately without a foreign key to the Status List table: the trail records what was done and + * has its own retention, so it must not be cascaded away when a list is eventually removed. Only + * the hash of a credential ID is kept, so the trail does not outlive the linkage which is dropped + * when a credential expires. + */ + private function version20260801000003(): void + { + $auditTableName = $this->database->applyPrefix(StatusAuditRepository::TABLE_NAME); + $idxCreatedAt = $this->generateIdentifierName([$auditTableName, 'created_at'], 'idx'); + $idxCredentialIdHash = $this->generateIdentifierName([$auditTableName, 'credential_id_hash'], 'idx'); + + $dateTime = $this->dateTimeColumnType(); + + $this->database->write(<<< EOT + CREATE TABLE IF NOT EXISTS $auditTableName ( + id VARCHAR(191) PRIMARY KEY NOT NULL, + credential_id_hash VARCHAR(64) NOT NULL, + status_list_id VARCHAR(64) NOT NULL, + idx INT NOT NULL, + old_status SMALLINT NOT NULL, + new_status SMALLINT NOT NULL, + actor_ref VARCHAR(191) NULL, + source VARCHAR(16) NOT NULL, + created_at $dateTime NOT NULL + ) +EOT + ,); + + $this->createIndex($idxCreatedAt, $auditTableName, 'created_at'); + $this->createIndex($idxCredentialIdHash, $auditTableName, 'credential_id_hash'); + } + + /** + * Column type for a value which can reach a few hundred kilobytes. + * + * MySQL's TEXT holds 64 KB, which a Status List Token can exceed, so it needs the next size up. + * PostgreSQL and SQLite place no such limit on TEXT. + */ + private function largeTextColumnType(): string + { + return $this->database->getDriver() === self::DRIVER_MYSQL ? 'MEDIUMTEXT' : 'TEXT'; + } + + /** + * Column type for a point in time. + * + * MySQL's TIMESTAMP only spans 1970 to 2038, which a credential expiry can legitimately outlive, so + * DATETIME is used there. PostgreSQL has no DATETIME, and SQLite stores whatever it is given. + */ + private function dateTimeColumnType(): string + { + return $this->database->getDriver() === self::DRIVER_MYSQL ? 'DATETIME' : 'TIMESTAMP'; + } + + /** + * Create an index unless it is already there. + * + * PostgreSQL and SQLite say so directly. MySQL has no IF NOT EXISTS for CREATE INDEX, so its + * catalog is consulted first. Being able to re-run this is what makes a version method safe to + * retry after it failed partway through, which it can: a version is recorded only once the whole + * method succeeded, and nothing rolls back what it managed before that. + */ + private function createIndex( + string $indexName, + string $tableName, + string $columns, + bool $isUnique = false, + ): void { + $unique = $isUnique ? 'UNIQUE ' : ''; + + if ($this->database->getDriver() !== self::DRIVER_MYSQL) { + $this->database->write( + "CREATE {$unique}INDEX IF NOT EXISTS $indexName ON $tableName ($columns)", + ); + + return; + } + + $statement = 'SELECT 1 FROM information_schema.statistics ' . + 'WHERE table_schema = DATABASE() AND table_name = :tableName AND index_name = :indexName'; + $params = ['tableName' => $tableName, 'indexName' => $indexName]; + + // Every statement above writes to the primary, so asking a secondary whether the index is + // there can get a stale no and turn this retry into a duplicate index error on the primary, + // which is exactly what the check exists to avoid. Migrations run on every deployment, + // including hosts predating the primary read, so it is used only where it is available. + $existing = ModuleConfig::hasPrimaryDatabaseReadCapability() ? + $this->database->readPrimary($statement, $params)->fetchAll() : + $this->database->read($statement, $params)->fetchAll(); + + if ($existing !== []) { + return; + } + + $this->database->write("CREATE {$unique}INDEX $indexName ON $tableName ($columns)"); + } + /** * @param string[] $columnNames */ diff --git a/src/StatusList/Contracts/StatusIndexAllocatorInterface.php b/src/StatusList/Contracts/StatusIndexAllocatorInterface.php new file mode 100644 index 00000000..d8b71ca0 --- /dev/null +++ b/src/StatusList/Contracts/StatusIndexAllocatorInterface.php @@ -0,0 +1,43 @@ +statusListKeyResolver->getCurrentKeyId(); + $policyFingerprint = $pool->getPolicyFingerprint($signingKeyId); + + $allocationAttempt = new AllocationAttempt(); + + for ($attempt = 1; $attempt <= self::MAX_LIST_ATTEMPTS; $attempt++) { + $statusList = $this->selectList($pool, $policyFingerprint, $signingKeyId, $allocationAttempt); + + $allocation = $this->tryAllocateIn( + $statusList, + $credentialId, + $credentialConfigurationId, + $subjectRef, + $expiresAt, + ); + + if ($allocation instanceof StatusAllocation) { + // The index is claimed and the linkage is written, so the credential can be issued. + // The counter is advisory and allowed to undercount, so failing to bump it must not + // undo that: throwing here would lose an index which is already durably taken, and a + // retry of the same credential would then collide on its unique hash. + try { + $this->statusListRepository->incrementAllocatedCount($statusList->getId()); + } catch (Throwable $throwable) { + $this->loggerService->warning( + 'Could not update the Status List allocation counter, which only affects when ' . + 'the list is rotated.', + ['statusListId' => $statusList->getId(), 'error' => $throwable->getMessage()], + ); + } + + $this->loggerService->debug( + 'Allocated a Status List index.', + [ + 'statusListId' => $statusList->getId(), + 'idx' => $allocation->getIdx(), + 'poolId' => $pool->getId(), + 'credentialConfigurationId' => $credentialConfigurationId, + ], + ); + + return $allocation; + } + + // Every pick collided, or the list stopped accepting allocations while we were picking. + // Either way this list is done; close it so the next round starts a successor. + $this->loggerService->info( + 'Status List did not yield a free index, rotating to a successor.', + [ + 'statusListId' => $statusList->getId(), + 'poolId' => $pool->getId(), + 'attempt' => $attempt, + ], + ); + + $this->statusListRepository->deactivate($statusList->getId()); + } + + throw new StatusListException( + sprintf( + 'Unable to allocate a Status List index for pool "%s" after %d attempts.', + $pool->getId(), + self::MAX_LIST_ATTEMPTS, + ), + ); + } + + /** + * A list of this pool which is accepting allocations, creating one if there is none. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \JsonException + * @throws \Exception + */ + protected function selectList( + StatusListPool $pool, + string $policyFingerprint, + string $signingKeyId, + AllocationAttempt $allocationAttempt, + ): StatusListRecord { + $openList = $this->findOpenListWithRoom($pool, $policyFingerprint); + + if ($openList instanceof StatusListRecord) { + return $openList; + } + + // Nothing open, but another request may already be seeding one. Joining it rather than starting + // a second list is what keeps a pool to one list, which is what its credentials hide in. + $preparedList = $this->awaitListBeingPrepared($pool, $policyFingerprint, $allocationAttempt); + + if ($preparedList instanceof StatusListRecord) { + return $preparedList; + } + + return $this->createList($pool, $policyFingerprint, $signingKeyId, $allocationAttempt); + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function findOpenListWithRoom( + StatusListPool $pool, + string $policyFingerprint, + ): ?StatusListRecord { + $candidates = $this->statusListRepository->findActiveForPolicy($pool->getId(), $policyFingerprint); + + // Chosen in PHP rather than with ORDER BY RANDOM(), whose spelling differs between the drivers + // this module supports. + shuffle($candidates); + + $full = []; + + foreach ($candidates as $candidate) { + if ($this->hasRoom($candidate)) { + return $candidate; + } + + $full[] = $candidate; + } + + // Nothing here has room, so these are done. Closing them now rather than merely stepping over + // them keeps them out of every later candidate query, stops a worker holding a stale selection + // from still allocating into one, and starts the clock which retirement waits on. The counter + // can undercount but never overcount, so a list which reads as full really is at least that + // full and closing it is safe. + foreach ($full as $candidate) { + $this->statusListRepository->deactivate($candidate->getId()); + } + + return null; + } + + /** + * Waits, for a bounded time, on a list another request is still seeding. + * + * Returns null when there is nothing to wait for, or when the wait ran out. Both mean the same + * thing to the caller: get on with creating a list of its own. Running out is not treated as an + * error, because a wasted list costs herd privacy while a failed allocation costs the credential. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + protected function awaitListBeingPrepared( + StatusListPool $pool, + string $policyFingerprint, + AllocationAttempt $allocationAttempt, + ): ?StatusListRecord { + // Waited once already and got nothing, so whoever holds that list is not coming back. Waiting + // again would just spend another budget arriving at the same answer. + if ($allocationAttempt->hasWaitedInVain()) { + return null; + } + + if (!$this->isListBeingPrepared($pool, $policyFingerprint)) { + return null; + } + + $this->loggerService->debug( + 'Waiting for a Status List another request is preparing.', + ['poolId' => $pool->getId()], + ); + + for ($attempt = 0; $attempt < self::PREPARING_LIST_WAIT_ATTEMPTS; $attempt++) { + usleep(self::PREPARING_LIST_WAIT_MICROSECONDS); + + $openList = $this->findOpenListWithRoom($pool, $policyFingerprint); + + if ($openList instanceof StatusListRecord) { + return $openList; + } + + // The other request gave up, died, or its list went straight to being closed. Either way + // there is no longer anything to wait for. + if (!$this->isListBeingPrepared($pool, $policyFingerprint)) { + return null; + } + } + + $this->loggerService->warning( + 'Gave up waiting for a Status List another request is preparing, creating one instead.', + ['poolId' => $pool->getId()], + ); + + // Recorded only when the budget actually ran out with the list still unfinished, which is what + // distinguishes a creator that is merely slow from one that is gone. + $allocationAttempt->recordWaitedInVain(); + + return null; + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + protected function isListBeingPrepared(StatusListPool $pool, string $policyFingerprint): bool + { + return $this->statusListRepository->findBeingPreparedForPolicy( + $pool->getId(), + $policyFingerprint, + $this->staleBefore(), + ) !== []; + } + + /** + * Whether the list this request has just created turned out to be redundant. + * + * Two cases, and both have to be asked about because the unique constraint on (pool_id, generation) + * only settles a race between requests which picked the *same* generation. Two requests which read + * the highest generation a moment apart pick different ones, so both inserts succeed and nothing + * collides. + * + * Deliberately not phrased as "does any earlier list exist": a list which is open but full is + * earlier and is not a reason to stand down, since it is exactly what this request is replacing. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + protected function isSupersededAfterCreating( + StatusListPool $pool, + string $policyFingerprint, + int $generation, + ): bool { + // Someone else's list became open while this request was inserting its own. + if ($this->findOpenListWithRoom($pool, $policyFingerprint) instanceof StatusListRecord) { + return true; + } + + // Someone else is seeding an earlier generation. Exactly one request holds the lowest, so + // exactly one carries on and the rest stand down. + return $this->statusListRepository->findBeingPreparedForPolicy( + $pool->getId(), + $policyFingerprint, + $this->staleBefore(), + $generation, + ) !== []; + } + + /** + * The point past which a list which is still not open counts as abandoned rather than in progress. + * + * @throws \Exception + */ + protected function staleBefore(): DateTimeImmutable + { + return $this->helpers->dateTime()->getUtc() + ->sub(new DateInterval(self::PREPARING_LIST_STALE_AFTER)); + } + + /** + * Whether a list is below the point at which a successor should be started. + * + * The counter this reads can undercount, since it is bumped by a statement separate from the + * allocation itself. That is tolerable precisely because running out of picks rotates: the counter + * only decides when to rotate early, never whether allocation can succeed. + */ + protected function hasRoom(StatusListRecord $statusList): bool + { + return $statusList->getAllocatedCount() < (int)floor( + (float)$statusList->getCapacity() * self::ROTATION_LOAD_FACTOR, + ); + } + + /** + * Creates a list, seeds every index, and opens it for allocation. + * + * The unique constraint on (pool_id, generation) settles a race between two requests both deciding + * a successor is needed: one insert wins. The loser does not need to establish *why* its insert + * failed -- and could not reliably -- because the recovery is the same whatever the reason: look at + * the pool again and use whichever list is open now. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \JsonException + * @throws \Exception + */ + protected function createList( + StatusListPool $pool, + string $policyFingerprint, + string $signingKeyId, + AllocationAttempt $allocationAttempt, + int $attempt = 1, + ): StatusListRecord { + $id = hash('sha256', $this->helpers->random()->getIdentifier()); + $generation = $this->statusListRepository->getHighestGeneration($pool->getId()) + 1; + + // Minted once, here, and stored. Everything downstream uses the stored string rather than + // rebuilding it, because a Relying Party rejects a Status List Token whose subject is not byte + // for byte the URI the credential named. + $uri = $this->routes->urlStatusList($id); + + try { + $this->statusListRepository->create( + $id, + $uri, + $pool->getId(), + $policyFingerprint, + $generation, + $pool->getBits(), + $pool->getCapacity(), + $pool->getAllowedStatusesAsString(), + $pool->getTtlInSeconds(), + $pool->getTokenValidityInSeconds(), + $pool->getRefreshIntervalInSeconds(), + $signingKeyId, + $pool->getKeyProfile(), + ); + } catch (Throwable $throwable) { + $this->loggerService->info( + 'Could not create a Status List, checking whether another request already did.', + [ + 'poolId' => $pool->getId(), + 'generation' => $generation, + 'error' => $throwable->getMessage(), + ], + ); + + return $this->adoptListCreatedByAnotherRequest( + $pool, + $policyFingerprint, + $signingKeyId, + $allocationAttempt, + $attempt, + $throwable, + ); + } + + // Standing down means waiting on somebody else's list, so a request which has already waited in + // vain must not do it again: what it is looking at is a list nobody is going to finish. It + // takes over instead, at a cost of one surplus list, rather than deleting its own, waiting, and + // repeating until it ran out of attempts and failed the issuance. + if ( + !$allocationAttempt->hasWaitedInVain() && + $this->isSupersededAfterCreating($pool, $policyFingerprint, $generation) + ) { + $this->loggerService->info( + 'Another request already produced a Status List for this pool, standing down.', + ['statusListId' => $id, 'poolId' => $pool->getId(), 'generation' => $generation], + ); + + $this->statusListRepository->deleteUnopened($id); + + return $this->adoptListCreatedByAnotherRequest( + $pool, + $policyFingerprint, + $signingKeyId, + $allocationAttempt, + $attempt, + new StatusListException('Superseded by a Status List of a lower generation.'), + ); + } + + // The list is inactive until this finishes, so nothing can pick an index which does not exist + // yet. A crash partway leaves an inactive list nothing points at, which is inert rather than + // harmful. + $this->statusListEntryRepository->seed($id, $pool->getCapacity()); + $this->statusListRepository->activate($id); + + $statusList = $this->statusListRepository->findByIdOnPrimary($id); + + if (!$statusList instanceof StatusListRecord) { + throw new StatusListException( + sprintf('Status List "%s" was created but could not be read back.', $id), + ); + } + + $this->loggerService->info( + 'Created a Status List.', + [ + 'statusListId' => $id, + 'poolId' => $pool->getId(), + 'generation' => $generation, + 'capacity' => $pool->getCapacity(), + ], + ); + + return $statusList; + } + + /** + * Recovers from an insert which did not succeed. + * + * The cause is deliberately not examined. The database wrapper reports the connection's error + * rather than the failing statement's and rethrows without the original, so a lost race for a + * generation can not be reliably told apart from anything else -- and it does not need to be, + * because the recovery is the same either way: use whichever list the winner produced, waiting for + * it if it is still being seeded, and start the next generation if there is nothing to wait for. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \JsonException + * @throws \Exception + */ + protected function adoptListCreatedByAnotherRequest( + StatusListPool $pool, + string $policyFingerprint, + string $signingKeyId, + AllocationAttempt $allocationAttempt, + int $attempt, + Throwable $cause, + ): StatusListRecord { + // The winner is very often still seeding at this point -- that is the whole reason this + // request's own insert lost -- so waiting here is what actually makes the pool converge on one + // list. It is a no-op once this request has already waited in vain, which is what stops a dead + // creator from costing a wait on every attempt. + $adopted = $this->findOpenListWithRoom($pool, $policyFingerprint) + ?? $this->awaitListBeingPrepared($pool, $policyFingerprint, $allocationAttempt); + + if ($adopted instanceof StatusListRecord) { + return $adopted; + } + + // Nothing to adopt, so the generation this request picked is taken but produced no usable list. + // Trying again recomputes it, which is what lets this make progress rather than fail outright. + if ($attempt < self::MAX_CREATE_ATTEMPTS) { + return $this->createList( + $pool, + $policyFingerprint, + $signingKeyId, + $allocationAttempt, + $attempt + 1, + ); + } + + throw new StatusListException( + sprintf( + 'Unable to create a Status List for pool "%s" after %d attempts, and no other list is ' . + 'available: %s', + $pool->getId(), + $attempt, + $cause->getMessage(), + ), + (int)$cause->getCode(), + $cause, + ); + } + + /** + * Tries random indices in one list until one is free or the budget runs out. + * + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \Exception + */ + protected function tryAllocateIn( + StatusListRecord $statusList, + string $credentialId, + string $credentialConfigurationId, + ?string $subjectRef, + ?DateTimeImmutable $expiresAt, + ): ?StatusAllocation { + $capacity = $statusList->getCapacity(); + + if ($capacity < 1) { + return null; + } + + $tried = []; + + for ($probe = 0; $probe < self::PROBES_PER_LIST; $probe++) { + $idx = random_int(0, $capacity - 1); + + // Retrying an index already known to be taken would waste part of the budget. + if (isset($tried[$idx])) { + continue; + } + + $tried[$idx] = true; + + $isAllocated = $this->statusListEntryRepository->allocate( + $statusList->getId(), + $idx, + $credentialId, + $this->statusListEntryRepository->hashCredentialId($credentialId), + $credentialConfigurationId, + $subjectRef, + $expiresAt, + ); + + if ($isAllocated) { + return new StatusAllocation( + $statusList->getId(), + $this->tokenStatusList->statusReferenceFactory()->build($statusList->getUri(), $idx), + ); + } + } + + return null; + } +} diff --git a/src/StatusList/DbStatusUpdater.php b/src/StatusList/DbStatusUpdater.php new file mode 100644 index 00000000..01c412b0 --- /dev/null +++ b/src/StatusList/DbStatusUpdater.php @@ -0,0 +1,214 @@ +statusListRepository->findByIdOnPrimary($statusListId); + + if (!$statusList instanceof StatusListRecord) { + throw new StatusListException(sprintf('Status List "%s" was not found.', $statusListId)); + } + + $this->enforceStatusFits($statusList, $status); + + // Read, compare and set, and try again against the value which actually got there. Returning + // false for a lost race would be indistinguishable from the entry already holding the requested + // status, and those mean opposite things: one says the credential is in the state that was + // asked for, the other says it is in somebody else's. + for ($attempt = 1; $attempt <= self::MAX_UPDATE_ATTEMPTS; $attempt++) { + $entry = $this->requireAllocatedEntry($statusListId, $idx); + + if ($entry->getStatus() === $status->value) { + $this->loggerService->debug( + 'Status List entry already holds the requested status, leaving it alone.', + ['statusListId' => $statusListId, 'idx' => $idx, 'status' => $status->value], + ); + + return false; + } + + $isUpdated = $this->statusListEntryRepository->updateStatus( + $statusListId, + $idx, + $entry->getStatus(), + $status->value, + ); + + if (!$isUpdated) { + $this->loggerService->warning( + 'Status List entry changed while it was being updated, retrying.', + [ + 'statusListId' => $statusListId, + 'idx' => $idx, + 'observedStatus' => $entry->getStatus(), + 'requestedStatus' => $status->value, + 'attempt' => $attempt, + ], + ); + + continue; + } + + // Only now, and always after the entry write. See the note on this class. + $this->statusListRepository->invalidatePublishedToken($statusListId); + + $this->loggerService->info( + 'Changed a Status List entry status.', + [ + 'statusListId' => $statusListId, + 'idx' => $idx, + 'oldStatus' => $entry->getStatus(), + 'newStatus' => $status->value, + ], + ); + + return true; + } + + throw new StatusConflictException( + sprintf( + 'Status List "%s" index %d kept being changed by something else, so it could not be set ' . + 'to %s (0x%02X) after %d attempts. It holds whatever those changes left, not the ' . + 'requested status.', + $statusListId, + $idx, + $status->name, + $status->value, + self::MAX_UPDATE_ATTEMPTS, + ), + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function getStatusValue(string $statusListId, int $idx): ?int + { + $entry = $this->statusListEntryRepository->findByListAndIdx($statusListId, $idx); + + if (!$entry instanceof StatusListEntryRecord || !$entry->isAllocated()) { + return null; + } + + return $entry->getStatus(); + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function requireAllocatedEntry(string $statusListId, int $idx): StatusListEntryRecord + { + $entry = $this->statusListEntryRepository->findByListAndIdx($statusListId, $idx); + + if (!$entry instanceof StatusListEntryRecord) { + throw new StatusListException( + sprintf('Status List "%s" has no entry at index %d.', $statusListId, $idx), + ); + } + + // Every index exists as a row from the moment the list is created, so an unallocated row is not + // an entry which was issued to anything, and changing its status would be describing a + // credential which does not exist. + if (!$entry->isAllocated()) { + throw new StatusListException( + sprintf( + 'Status List "%s" index %d was never allocated, so its status can not be changed.', + $statusListId, + $idx, + ), + ); + } + + return $entry; + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\UnsupportedStatusException + */ + protected function enforceStatusFits(StatusListRecord $statusList, StatusTypeEnum $status): void + { + // Checked against what the list itself records, not against the pool's current configuration. + // Raising a pool's bits does not change lists which already exist, so the list is the only + // thing which can answer what it is able to carry. + $largestRepresentable = (1 << $statusList->getBits()) - 1; + + if ($status->value > $largestRepresentable) { + throw new UnsupportedStatusException( + sprintf( + 'Status List "%s" carries %d bit(s) per entry, so it can not represent the status ' . + '%s (0x%02X). That is fixed for the lifetime of this list; a pool needing this ' . + 'status must be configured with at least %d bits before its lists are created.', + $statusList->getId(), + $statusList->getBits(), + $status->name, + $status->value, + $status->requiredBits(), + ), + ); + } + + if (!$statusList->isStatusValueAllowed($status->value)) { + throw new UnsupportedStatusException( + sprintf( + 'Status List "%s" was created allowing only the statuses %s, so it can not be set ' . + 'to %s (0x%02X).', + $statusList->getId(), + $statusList->getAllowedStatusesAsString(), + $status->name, + $status->value, + ), + ); + } + } +} diff --git a/src/StatusList/StatusListKeyResolver.php b/src/StatusList/StatusListKeyResolver.php new file mode 100644 index 00000000..f912b22b --- /dev/null +++ b/src/StatusList/StatusListKeyResolver.php @@ -0,0 +1,91 @@ +moduleConfig->getVciSignatureKeyPairBag()->getFirstOrFail(); + } catch (\Throwable $throwable) { + throw new StatusListException( + 'No Verifiable Credential Issuance signature key pair is configured, so Status Lists ' . + 'can not be signed: ' . $throwable->getMessage(), + (int)$throwable->getCode(), + $throwable, + ); + } + } + + /** + * The identifier stored against a list, being either the configured key ID or, when none was + * configured, the thumbprint derived from the key itself. It has to be exactly what the key pair + * bag indexes by, otherwise a list could never find its key again. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function getCurrentKeyId(): string + { + return $this->getCurrent()->getKeyPair()->getKeyId(); + } + + /** + * The key a list was created with. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException When that key is no longer + * configured. The caller must treat this as a failure to publish rather than reach for another key. + */ + public function getByKeyId(string $keyId): SignatureKeyPair + { + $signatureKeyPair = $this->moduleConfig->getVciSignatureKeyPairBag()->getByKeyId($keyId); + + if ($signatureKeyPair === null) { + throw new StatusListException( + sprintf( + 'Status List was signed with the key "%s", which is no longer configured. Private ' . + 'keys must be retained for as long as any Status List they signed is still being ' . + 'served, otherwise the credentials pointing at that list can not be verified.', + $keyId, + ), + ); + } + + return $signatureKeyPair; + } +} diff --git a/src/StatusList/SubjectRefHasher.php b/src/StatusList/SubjectRefHasher.php new file mode 100644 index 00000000..65746671 --- /dev/null +++ b/src/StatusList/SubjectRefHasher.php @@ -0,0 +1,90 @@ +deriveKey()); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function deriveKey(): string + { + if (is_string($this->derivedKey)) { + return $this->derivedKey; + } + + $encryptionKey = $this->moduleConfig->getEncryptionKey(); + + $inputKeyMaterial = $encryptionKey instanceof Key ? + $encryptionKey->getRawBytes() : + $encryptionKey; + + if ($inputKeyMaterial === '') { + throw new ConfigurationError( + 'Unable to derive the Status List subject reference key: neither a module encryption ' . + 'key nor a SimpleSAMLphp secret salt is set.', + ); + } + + return $this->derivedKey = hash_hkdf( + self::HASH_ALGORITHM, + $inputKeyMaterial, + self::DERIVED_KEY_BYTES, + self::HKDF_INFO, + ); + } +} diff --git a/src/StatusList/Values/AllocationAttempt.php b/src/StatusList/Values/AllocationAttempt.php new file mode 100644 index 00000000..3661bec2 --- /dev/null +++ b/src/StatusList/Values/AllocationAttempt.php @@ -0,0 +1,38 @@ +hasWaitedInVain; + } + + public function recordWaitedInVain(): void + { + $this->hasWaitedInVain = true; + } +} diff --git a/src/StatusList/Values/DatabaseRowValuesTrait.php b/src/StatusList/Values/DatabaseRowValuesTrait.php new file mode 100644 index 00000000..96f721c4 --- /dev/null +++ b/src/StatusList/Values/DatabaseRowValuesTrait.php @@ -0,0 +1,102 @@ + $row + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected static function asString(array $row, string $key): string + { + return self::asNullableString($row, $key) ?? throw new StatusListException( + sprintf('Row is missing the required column "%s".', $key), + ); + } + + /** + * @param array $row + */ + protected static function asNullableString(array $row, string $key): ?string + { + /** @var mixed $value */ + $value = $row[$key] ?? null; + + if ($value === null) { + return null; + } + + return is_scalar($value) ? (string)$value : null; + } + + /** + * @param array $row + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected static function asInt(array $row, string $key): int + { + /** @var mixed $value */ + $value = $row[$key] ?? null; + + if (!is_int($value) && !(is_string($value) && preg_match('/^-?\d+$/', $value) === 1)) { + throw new StatusListException( + sprintf('Row column "%s" is not an integer, %s given.', $key, get_debug_type($value)), + ); + } + + return (int)$value; + } + + /** + * @param array $row + */ + protected static function asBool(array $row, string $key): bool + { + /** @var mixed $value */ + $value = $row[$key] ?? false; + + // PostgreSQL can return 't' / 'f' or a real boolean, MySQL an integer, SQLite an integer or a + // numeric string. Only the values which actually mean false are treated as false, so that an + // unexpected shape does not quietly read as one. + if (is_string($value)) { + return !in_array(strtolower($value), ['', '0', 'f', 'false', 'n', 'no'], true); + } + + return (bool)$value; + } + + /** + * @param array $row + */ + protected static function asNullableDateTime(array $row, string $key): ?DateTimeImmutable + { + $value = self::asNullableString($row, $key); + + if ($value === null || $value === '') { + return null; + } + + // Timestamps are stored in UTC and come back without a zone, so the zone is supplied here + // rather than left to whatever the server's default happens to be. + try { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } catch (Throwable) { + return null; + } + } +} diff --git a/src/StatusList/Values/StatusAllocation.php b/src/StatusList/Values/StatusAllocation.php new file mode 100644 index 00000000..5acab76b --- /dev/null +++ b/src/StatusList/Values/StatusAllocation.php @@ -0,0 +1,48 @@ +statusListId; + } + + /** + * The reference as the credential carries it, for the `status` claim. + */ + public function getStatusReference(): StatusReference + { + return $this->statusReference; + } + + public function getUri(): string + { + return $this->statusReference->getUri(); + } + + public function getIdx(): int + { + return $this->statusReference->getIdx(); + } +} diff --git a/src/StatusList/Values/StatusListEntryRecord.php b/src/StatusList/Values/StatusListEntryRecord.php new file mode 100644 index 00000000..7059e306 --- /dev/null +++ b/src/StatusList/Values/StatusListEntryRecord.php @@ -0,0 +1,136 @@ +statusListId; + } + + public function getIdx(): int + { + return $this->idx; + } + + public function isAllocated(): bool + { + return $this->allocated; + } + + public function getStatus(): int + { + return $this->status; + } + + /** + * The Status Type for this entry, or null when the stored value is application specific or not yet + * registered. Callers deciding only whether the credential is usable should compare getStatus() + * against zero instead, so that an unrecognised value is not mistaken for an absent one. + */ + public function getStatusType(): ?StatusTypeEnum + { + return StatusTypeEnum::tryFrom($this->status); + } + + public function getExpiresAt(): ?DateTimeImmutable + { + return $this->expiresAt; + } + + public function isNonExpiring(): bool + { + return !$this->expiresAt instanceof DateTimeImmutable; + } + + public function getCredentialId(): ?string + { + return $this->credentialId; + } + + public function getCredentialIdHash(): ?string + { + return $this->credentialIdHash; + } + + public function getCredentialConfigurationId(): ?string + { + return $this->credentialConfigurationId; + } + + public function getSubjectRef(): ?string + { + return $this->subjectRef; + } + + public function getIssuedAt(): ?DateTimeImmutable + { + return $this->issuedAt; + } + + public function getUpdatedAt(): ?DateTimeImmutable + { + return $this->updatedAt; + } + + /** + * @param array $row + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public static function fromRow(array $row): self + { + return new self( + self::asString($row, 'status_list_id'), + self::asInt($row, 'idx'), + self::asBool($row, 'allocated'), + self::asInt($row, 'status'), + self::asNullableDateTime($row, 'expires_at'), + self::asNullableString($row, 'credential_id'), + self::asNullableString($row, 'credential_id_hash'), + self::asNullableString($row, 'credential_configuration_id'), + self::asNullableString($row, 'subject_ref'), + self::asNullableDateTime($row, 'issued_at'), + self::asNullableDateTime($row, 'updated_at'), + ); + } +} diff --git a/src/StatusList/Values/StatusListPool.php b/src/StatusList/Values/StatusListPool.php new file mode 100644 index 00000000..d1bfb2d9 --- /dev/null +++ b/src/StatusList/Values/StatusListPool.php @@ -0,0 +1,572 @@ +validate(); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function validate(): void + { + if ($this->id === '') { + throw new ConfigurationError('Status List pool identifier must not be empty.'); + } + + if ($this->credentialConfigurationIds === []) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" lists no credential configurations, so nothing would ever ' . + 'allocate from it. Remove the pool, or add the credential configuration IDs which ' . + 'should use it under "%s".', + $this->id, + self::KEY_CREDENTIAL_CONFIGURATIONS, + ), + ); + } + + if (!StatusList::isAllowedBits($this->bits)) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" is configured with %d bit(s) per Referenced Token, expected ' . + 'one of: %s.', + $this->id, + $this->bits, + implode(', ', StatusList::ALLOWED_BITS), + ), + ); + } + + if ($this->capacity < 1 || $this->capacity % self::CAPACITY_MULTIPLE !== 0) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" is configured with a capacity of %d, which must be a ' . + 'positive multiple of %d.', + $this->id, + $this->capacity, + self::CAPACITY_MULTIPLE, + ), + ); + } + + // The number of bits fixes the largest status the list can carry at all, and reconfiguring it + // later can not retrofit lists which already exist. A pool which may suspend therefore needs + // to say so up front. + $largestRepresentable = (1 << $this->bits) - 1; + + foreach ($this->allowedStatuses as $status) { + if ($status->value > $largestRepresentable) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" allows the status %s (0x%02X), which can not be ' . + 'represented using %d bit(s) per Referenced Token (largest is 0x%02X). Raise ' . + '"%s" to at least %d, or remove the status.', + $this->id, + $status->name, + $status->value, + $this->bits, + $largestRepresentable, + self::KEY_BITS, + $status->requiredBits(), + ), + ); + } + } + + $ttlSeconds = self::toSeconds($this->ttl); + + if ($ttlSeconds < 1) { + throw new ConfigurationError( + sprintf('Status List pool "%s" must have a positive "%s".', $this->id, self::KEY_TTL), + ); + } + + $refreshSeconds = self::toSeconds($this->refreshInterval); + $validitySeconds = self::toSeconds($this->tokenValidity); + $marginSeconds = self::toSeconds(new DateInterval(self::SAFETY_MARGIN)); + + if ($refreshSeconds < 1) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" must have a positive "%s".', + $this->id, + self::KEY_REFRESH_INTERVAL, + ), + ); + } + + // Getting this the wrong way round leaves a recurring window in every cycle during which the + // published token has expired and its replacement has not been produced yet, so the endpoint + // has nothing valid to serve. + if ($refreshSeconds + $marginSeconds >= $validitySeconds) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" refreshes every %d second(s) but its tokens are only valid ' . + 'for %d second(s). The refresh interval plus the %d second safety margin must stay ' . + 'below the token validity, otherwise a published token expires before its ' . + 'replacement is produced. Raise "%s" or lower "%s".', + $this->id, + $refreshSeconds, + $validitySeconds, + $marginSeconds, + self::KEY_TOKEN_VALIDITY, + self::KEY_REFRESH_INTERVAL, + ), + ); + } + } + + /** + * How many seconds a duration is worth, measured from a fixed point rather than from now. + * + * A DateInterval has no length until something anchors it, and anchoring it to the current moment + * in the server's timezone makes the answer move: across a daylight saving transition, P7D is worth + * an hour more or less than it is the rest of the year. That number goes into the policy + * fingerprint, so an unchanged configuration would fingerprint differently on either side of a + * transition and quietly move every pool onto fresh lists, splitting the herd for no reason. + * Anchoring to the epoch in UTC removes both the timezone and the moving reference, so the same + * configuration always yields the same number. + */ + protected static function toSeconds(DateInterval $interval): int + { + return (new DateTimeImmutable('@0'))->add($interval)->getTimestamp(); + } + + public function getId(): string + { + return $this->id; + } + + /** + * @return string[] + */ + public function getCredentialConfigurationIds(): array + { + return $this->credentialConfigurationIds; + } + + public function hasCredentialConfigurationId(string $credentialConfigurationId): bool + { + return in_array($credentialConfigurationId, $this->credentialConfigurationIds, true); + } + + public function getBits(): int + { + return $this->bits; + } + + public function getCapacity(): int + { + return $this->capacity; + } + + /** + * @return \SimpleSAML\OpenID\Codebooks\StatusTypeEnum[] + */ + public function getAllowedStatuses(): array + { + return $this->allowedStatuses; + } + + public function isStatusAllowed(StatusTypeEnum $status): bool + { + return in_array($status, $this->allowedStatuses, true); + } + + /** + * The allowed statuses in the form persisted on the Status List row, being their values in + * ascending order and comma separated. + */ + public function getAllowedStatusesAsString(): string + { + $values = array_map( + static fn(StatusTypeEnum $status): int => $status->value, + $this->allowedStatuses, + ); + + sort($values); + + return implode(',', $values); + } + + public function getTtl(): DateInterval + { + return $this->ttl; + } + + public function getTtlInSeconds(): int + { + return self::toSeconds($this->ttl); + } + + public function getTokenValidity(): DateInterval + { + return $this->tokenValidity; + } + + public function getTokenValidityInSeconds(): int + { + return self::toSeconds($this->tokenValidity); + } + + public function getRefreshInterval(): DateInterval + { + return $this->refreshInterval; + } + + public function getRefreshIntervalInSeconds(): int + { + return self::toSeconds($this->refreshInterval); + } + + public function getKeyProfile(): StatusListKeyProfileEnum + { + return $this->keyProfile; + } + + /** + * Hash of the immutable part of this pool's policy, which allocation filters candidate lists on. + * + * Without it, changing a pool setting would leave lists created under the old settings eligible for + * new allocations. The signing key is part of the tuple for the same reason: during a key rotation + * the issuer signs credentials with the current key, and a list still bound to the previous one + * would quietly break the profile which says the two are the same key. The key profile is included + * so that changing which identifier tokens carry routes new credentials to new lists, leaving + * existing lists to be served under the profile their holders already resolved them by. + * + * The refresh interval is deliberately absent: it governs when a token is re-signed, not what any + * credential in the list resolves to, so changing it must not strand a half-full list. + * + * @throws \JsonException + */ + public function getPolicyFingerprint(string $signingKeyId): string + { + return hash( + 'sha256', + json_encode( + [ + 'bits' => $this->bits, + 'capacity' => $this->capacity, + 'signing_key_id' => $signingKeyId, + 'allowed_statuses' => $this->getAllowedStatusesAsString(), + 'ttl_seconds' => $this->getTtlInSeconds(), + 'token_validity_seconds' => $this->getTokenValidityInSeconds(), + 'key_profile' => $this->keyProfile->value, + ], + JSON_THROW_ON_ERROR, + ), + ); + } + + /** + * Builds a pool from its configured settings, applying the defaults for everything left out. + * + * @param array $config + * @throws \SimpleSAML\Error\ConfigurationError + */ + public static function fromConfig( + string $id, + array $config, + StatusListKeyProfileEnum $defaultKeyProfile, + ): self { + return new self( + $id, + self::resolveCredentialConfigurationIds($id, $config), + self::resolveInt($id, $config, self::KEY_BITS, self::DEFAULT_BITS), + self::resolveInt($id, $config, self::KEY_CAPACITY, self::DEFAULT_CAPACITY), + self::resolveAllowedStatuses($id, $config), + self::resolveInterval($id, $config, self::KEY_TTL, self::DEFAULT_TTL), + self::resolveInterval($id, $config, self::KEY_TOKEN_VALIDITY, self::DEFAULT_TOKEN_VALIDITY), + self::resolveInterval($id, $config, self::KEY_REFRESH_INTERVAL, self::DEFAULT_REFRESH_INTERVAL), + self::resolveKeyProfile($id, $config, $defaultKeyProfile), + ); + } + + /** + * @param array $config + * @return string[] + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected static function resolveCredentialConfigurationIds(string $id, array $config): array + { + /** @var mixed $ids */ + $ids = $config[self::KEY_CREDENTIAL_CONFIGURATIONS] ?? []; + + if (!is_array($ids)) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" has a "%s" which is not an array.', + $id, + self::KEY_CREDENTIAL_CONFIGURATIONS, + ), + ); + } + + /** @var mixed $credentialConfigurationId */ + foreach ($ids as $credentialConfigurationId) { + if (!is_string($credentialConfigurationId) || $credentialConfigurationId === '') { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" lists a credential configuration ID which is not a ' . + 'non-empty string.', + $id, + ), + ); + } + } + + /** @var string[] $ids */ + return array_values(array_unique($ids)); + } + + /** + * @param array $config + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected static function resolveInt(string $id, array $config, string $key, int $default): int + { + if (!array_key_exists($key, $config)) { + return $default; + } + + /** @var mixed $value */ + $value = $config[$key]; + + if (!is_int($value)) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" has a "%s" which is not an integer, %s given.', + $id, + $key, + get_debug_type($value), + ), + ); + } + + return $value; + } + + /** + * @param array $config + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected static function resolveInterval( + string $id, + array $config, + string $key, + string $default, + ): DateInterval { + /** @var mixed $value */ + $value = $config[$key] ?? $default; + + if (!is_string($value)) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" has a "%s" which is not a duration string, %s given.', + $id, + $key, + get_debug_type($value), + ), + ); + } + + try { + return new DateInterval($value); + } catch (Throwable $throwable) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" has a "%s" which is not a valid duration: %s', + $id, + $key, + $throwable->getMessage(), + ), + ); + } + } + + /** + * @param array $config + * @return \SimpleSAML\OpenID\Codebooks\StatusTypeEnum[] + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected static function resolveAllowedStatuses(string $id, array $config): array + { + /** @var mixed $configured */ + $configured = $config[self::KEY_ALLOWED_STATUSES] ?? [StatusTypeEnum::Invalid]; + + if (!is_array($configured)) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" has an "%s" which is not an array.', + $id, + self::KEY_ALLOWED_STATUSES, + ), + ); + } + + // Valid is always allowed, whether or not it was configured: an entry which can be revoked has + // to be able to be reinstated, and an unallocated index reads as Valid regardless. + $statuses = [StatusTypeEnum::Valid]; + + /** @var mixed $status */ + foreach ($configured as $status) { + // Integers are accepted as a convenience, but only ones which name a registered Status + // Type. A string is not: casting one to an integer turns every typo into 0, which is + // Valid, so a misspelt status would silently configure the pool to allow nothing. + if (is_int($status)) { + $status = StatusTypeEnum::tryFrom($status) ?? $status; + } + + if (!$status instanceof StatusTypeEnum) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" allows a status which is not a %s case: %s.', + $id, + StatusTypeEnum::class, + var_export($status, true), + ), + ); + } + + if (!in_array($status, $statuses, true)) { + $statuses[] = $status; + } + } + + return $statuses; + } + + /** + * @param array $config + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected static function resolveKeyProfile( + string $id, + array $config, + StatusListKeyProfileEnum $default, + ): StatusListKeyProfileEnum { + if (!array_key_exists(self::KEY_KEY_PROFILE, $config)) { + return $default; + } + + /** @var mixed $value */ + $value = $config[self::KEY_KEY_PROFILE]; + + if ($value instanceof StatusListKeyProfileEnum) { + return $value; + } + + if (is_string($value) && ($profile = StatusListKeyProfileEnum::tryFrom($value)) !== null) { + return $profile; + } + + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" has a "%s" which is not one of: %s.', + $id, + self::KEY_KEY_PROFILE, + implode( + ', ', + array_map( + static fn(StatusListKeyProfileEnum $case): string => $case->value, + StatusListKeyProfileEnum::cases(), + ), + ), + ), + ); + } +} diff --git a/src/StatusList/Values/StatusListPoolBag.php b/src/StatusList/Values/StatusListPoolBag.php new file mode 100644 index 00000000..1088752b --- /dev/null +++ b/src/StatusList/Values/StatusListPoolBag.php @@ -0,0 +1,138 @@ + */ + protected array $pools = []; + + /** @var array Credential configuration ID to the ID of the pool it allocates from. */ + protected array $poolIdsByCredentialConfigurationId = []; + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function __construct(StatusListPool ...$pools) + { + foreach ($pools as $pool) { + $this->add($pool); + } + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function add(StatusListPool $pool): void + { + if (array_key_exists($pool->getId(), $this->pools)) { + throw new ConfigurationError( + sprintf('Status List pool "%s" is defined more than once.', $pool->getId()), + ); + } + + foreach ($pool->getCredentialConfigurationIds() as $credentialConfigurationId) { + $existingPoolId = $this->poolIdsByCredentialConfigurationId[$credentialConfigurationId] ?? null; + + if ($existingPoolId !== null) { + throw new ConfigurationError( + sprintf( + 'Credential configuration "%s" is listed in both the "%s" and the "%s" Status ' . + 'List pools, so there is no single policy its credentials would be allocated ' . + 'under. List it in exactly one pool.', + $credentialConfigurationId, + $existingPoolId, + $pool->getId(), + ), + ); + } + + $this->poolIdsByCredentialConfigurationId[$credentialConfigurationId] = $pool->getId(); + } + + $this->pools[$pool->getId()] = $pool; + } + + /** + * @return array + */ + public function getAll(): array + { + return $this->pools; + } + + public function getById(string $poolId): ?StatusListPool + { + return $this->pools[$poolId] ?? null; + } + + public function isEmpty(): bool + { + return $this->pools === []; + } + + /** + * The pool a credential configuration allocates from, or null if it is not configured to use + * Status Lists at all. Credentials of such a configuration are issued without a `status` claim. + */ + public function getForCredentialConfigurationId(string $credentialConfigurationId): ?StatusListPool + { + $poolId = $this->poolIdsByCredentialConfigurationId[$credentialConfigurationId] ?? null; + + return $poolId === null ? null : $this->getById($poolId); + } + + /** + * @return string[] + */ + public function getAllCredentialConfigurationIds(): array + { + return array_keys($this->poolIdsByCredentialConfigurationId); + } + + /** + * @param array $config Pool identifier to that pool's settings. + * @throws \SimpleSAML\Error\ConfigurationError + */ + public static function fromConfig(array $config, StatusListKeyProfileEnum $defaultKeyProfile): self + { + $pools = []; + + /** @var mixed $poolConfig */ + foreach ($config as $poolId => $poolConfig) { + if (!is_string($poolId) || $poolId === '') { + throw new ConfigurationError( + 'Status List pools must be keyed by a non-empty pool identifier string.', + ); + } + + if (!is_array($poolConfig)) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" is configured with %s rather than an array of settings.', + $poolId, + get_debug_type($poolConfig), + ), + ); + } + + $pools[] = StatusListPool::fromConfig($poolId, $poolConfig, $defaultKeyProfile); + } + + return new self(...$pools); + } +} diff --git a/src/StatusList/Values/StatusListRecord.php b/src/StatusList/Values/StatusListRecord.php new file mode 100644 index 00000000..f0218751 --- /dev/null +++ b/src/StatusList/Values/StatusListRecord.php @@ -0,0 +1,258 @@ +id; + } + + public function getUri(): string + { + return $this->uri; + } + + public function getPoolId(): string + { + return $this->poolId; + } + + public function getPolicyFingerprint(): string + { + return $this->policyFingerprint; + } + + public function getGeneration(): int + { + return $this->generation; + } + + public function getBits(): int + { + return $this->bits; + } + + public function getCapacity(): int + { + return $this->capacity; + } + + /** + * @return int[] + */ + public function getAllowedStatusValues(): array + { + if ($this->allowedStatuses === '') { + return []; + } + + return array_map('intval', explode(',', $this->allowedStatuses)); + } + + /** + * Whether this list may carry the given status. + * + * Compared as a raw value rather than as a Status Type, so that a value which is application + * specific or not yet registered is answered the same way as a registered one. + */ + public function isStatusValueAllowed(int $status): bool + { + return in_array($status, $this->getAllowedStatusValues(), true); + } + + public function getAllowedStatusesAsString(): string + { + return $this->allowedStatuses; + } + + public function getTtlSeconds(): int + { + return $this->ttlSeconds; + } + + public function getTokenValiditySeconds(): int + { + return $this->tokenValiditySeconds; + } + + public function getRefreshIntervalSeconds(): int + { + return $this->refreshIntervalSeconds; + } + + public function getSigningKeyId(): string + { + return $this->signingKeyId; + } + + public function getKeyProfile(): StatusListKeyProfileEnum + { + return $this->keyProfile; + } + + /** + * Advisory count of allocated entries. Incrementing it is a separate statement from the allocation + * itself, so it can undercount; it drives the decision to rotate, never a correctness decision. + */ + public function getAllocatedCount(): int + { + return $this->allocatedCount; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function getDeactivatedAt(): ?DateTimeImmutable + { + return $this->deactivatedAt; + } + + public function getRetiredAt(): ?DateTimeImmutable + { + return $this->retiredAt; + } + + public function isRetired(): bool + { + return $this->retiredAt instanceof DateTimeImmutable; + } + + public function getSignedToken(): ?string + { + return $this->signedToken; + } + + public function getSignedTokenContentHash(): string + { + return $this->signedTokenContentHash; + } + + public function getSignedTokenIssuedAt(): ?DateTimeImmutable + { + return $this->signedTokenIssuedAt; + } + + public function getSignedTokenExpiresAt(): ?DateTimeImmutable + { + return $this->signedTokenExpiresAt; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + /** + * Whether a published token exists which can be served as-is. + */ + public function hasPublishedToken(): bool + { + return $this->signedTokenContentHash !== '' && + is_string($this->signedToken) && + $this->signedToken !== ''; + } + + /** + * @param array $row + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public static function fromRow(array $row): self + { + return new self( + self::asString($row, 'id'), + self::asString($row, 'uri'), + self::asString($row, 'pool_id'), + self::asString($row, 'policy_fingerprint'), + self::asInt($row, 'generation'), + self::asInt($row, 'bits'), + self::asInt($row, 'capacity'), + self::asString($row, 'allowed_statuses'), + self::asInt($row, 'ttl_seconds'), + self::asInt($row, 'token_validity_seconds'), + self::asInt($row, 'refresh_interval_seconds'), + self::asString($row, 'signing_key_id'), + self::asKeyProfile($row, 'key_profile'), + self::asInt($row, 'allocated_count'), + self::asBool($row, 'is_active'), + self::asNullableDateTime($row, 'deactivated_at'), + self::asNullableDateTime($row, 'retired_at'), + self::asNullableString($row, 'signed_token'), + // Written NOT NULL DEFAULT '', but a row created before that default existed, or by hand, + // would still read as null; treating it as "nothing published" is the safe reading. + self::asNullableString($row, 'signed_token_content_hash') ?? '', + self::asNullableDateTime($row, 'signed_token_iat'), + self::asNullableDateTime($row, 'signed_token_exp'), + self::asNullableDateTime($row, 'created_at'), + ); + } + + /** + * @param array $row + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected static function asKeyProfile(array $row, string $key): StatusListKeyProfileEnum + { + $value = self::asString($row, $key); + + return StatusListKeyProfileEnum::tryFrom($value) ?? throw new StatusListException( + sprintf('Status List row column "%s" holds an unknown key profile "%s".', $key, $value), + ); + } +} diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 8f2fe288..466e1495 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -257,6 +257,30 @@ public function urlCredentialJsonLdContext(string $credentialConfigurationId, ar return $this->getModuleUrl($path, $parameters); } + /***************************************************************************************************************** + * Token Status List URLs. + ****************************************************************************************************************/ + + /** + * URL of one Status List Token. + * + * This is minted once, when the list is created, and stored on it. Referenced Tokens carry that + * stored string and Status List Tokens repeat it as their `sub`, both verbatim, because a Relying + * Party rejects a token whose subject is not byte for byte the URI its credential named. Never + * re-derive it for an existing list: changing the base URL would produce a different string for a + * list which credentials in the wild already point at. + */ + public function urlStatusList(string $statusListId, array $parameters = []): string + { + $path = str_replace( + '{statusListId}', + rawurlencode($statusListId), + RoutesEnum::StatusList->value, + ); + + return $this->getModuleUrl($path, $parameters); + } + /***************************************************************************************************************** * SD-JWT-based Verifiable Credentials (SD-JWT VC) ****************************************************************************************************************/ diff --git a/tests/integration/src/DatabaseContainers.php b/tests/integration/src/DatabaseContainers.php new file mode 100644 index 00000000..9bed4cbc --- /dev/null +++ b/tests/integration/src/DatabaseContainers.php @@ -0,0 +1,198 @@ +|null */ + private static ?array $postgresConfig = null; + + /** @var array|null */ + private static ?array $mysqlConfig = null; + + private static ?string $containerAddress = null; + + private static ?string $mysqlPort = null; + + private static ?string $postgresPort = null; + + private static bool $isEnvironmentResolved = false; + + private function __construct() + { + } + + /** + * @return array + * @throws \Exception + */ + public static function postgres(): array + { + self::resolveEnvironment(); + + return self::$postgresConfig ??= self::startPostgres(); + } + + /** + * @return array + * @throws \Exception + */ + public static function mysql(): array + { + self::resolveEnvironment(); + + return self::$mysqlConfig ??= self::startMysql(); + } + + /** + * @return array + */ + public static function sqlite(): array + { + return [ + 'database.dsn' => 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ]; + } + + /** + * @return array + */ + public static function all(): array + { + return [ + 'PostgreSql' => ['pgConfig'], + 'MySql' => ['mysqlConfig'], + 'Sqlite' => ['sqliteConfig'], + ]; + } + + private static function resolveEnvironment(): void + { + if (self::$isEnvironmentResolved) { + return; + } + + self::$containerAddress = getenv('HOSTADDRESS') ?: null; + self::$mysqlPort = getenv('HOSTPORT_MY') ?: null; + self::$postgresPort = getenv('HOSTPORT_PG') ?: null; + + // Docker on macOS needs the mapped port on localhost rather than the container address. + if (in_array(PHP_OS_FAMILY, ['Darwin', 'Linux'], true) && getenv('HOSTADDRESS') === false) { + self::$containerAddress = '127.0.0.1'; + } else { + self::$mysqlPort ??= '3306'; + self::$postgresPort ??= '5432'; + } + + self::$isEnvironmentResolved = true; + } + + /** + * @return array + * @throws \Exception + */ + private static function startPostgres(): array + { + $container = PostgresContainer::make('15.0', 'password'); + $container->withPostgresDatabase('database'); + $container->withPostgresUser('username'); + $hostPort = self::$postgresPort ?: self::findFreePort(); + $container->withPort($hostPort, '5432'); + + $container->run(); + $container->withWait(new WaitForHealthCheck()); + $container->withWait(new WaitForLog('Ready to accept connections')); + + $hostAddress = self::$containerAddress ?: $container->getAddress(); + + return [ + 'database.dsn' => sprintf('pgsql:host=%s;port=%s;dbname=database', $hostAddress, $hostPort), + 'database.username' => 'username', + 'database.password' => 'password', + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + 'database.driver_options' => [PDO::ATTR_TIMEOUT => 2], + ]; + } + + /** + * @return array + * @throws \Exception + */ + private static function startMysql(): array + { + $container = MySQLContainer::make('8.0'); + $container->withMySQLDatabase('database'); + $container->withMySQLUser('username', 'password'); + $hostPort = self::$mysqlPort ?: self::findFreePort(); + $container->withPort($hostPort, '3306'); + + $container->run(); + $container->withWait(new WaitForHealthCheck()); + $container->withWait(new WaitForLog('Ready to accept connections')); + + $hostAddress = self::$containerAddress ?: $container->getAddress(); + + if ($hostAddress === 'localhost') { + //phpcs:ignore Generic.Files.LineLength.TooLong + throw new Exception('To connect to localhost with mysql use IP 127.0.0.1, otherwise mysql tries to use a file socket'); + } + + return [ + 'database.dsn' => sprintf('mysql:host=%s;port=%s;dbname=database', $hostAddress, $hostPort), + 'database.username' => 'username', + 'database.password' => 'password', + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + 'database.driver_options' => [PDO::ATTR_TIMEOUT => 2], + ]; + } + + /** + * A free port, found by opening a listening socket and closing it again. + * + * @throws \Exception + */ + private static function findFreePort(): string + { + $socket = socket_create_listen(0); + + if (socket_getsockname($socket, $address, $port)) { + socket_close($socket); + + return '' . $port; + } + + throw new Exception('unable to allocate port'); + } +} diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index a2bd4845..644ea645 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -4,7 +4,6 @@ namespace SimpleSAML\Test\Module\oidc\integration\Repositories; -use PDO; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -28,10 +27,7 @@ use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; use SimpleSAML\OpenID\Jws; -use Testcontainers\Container\MySQLContainer; -use Testcontainers\Container\PostgresContainer; -use Testcontainers\Wait\WaitForHealthCheck; -use Testcontainers\Wait\WaitForLog; +use SimpleSAML\Test\Module\oidc\integration\DatabaseContainers; #[CoversClass(AccessTokenRepository::class)] class AccessTokenRepositoryTest extends TestCase @@ -57,10 +53,6 @@ class AccessTokenRepositoryTest extends TestCase protected ScopeEntity $scopeEntityOpenId; protected ScopeEntity $scopeEntityProfile; - private static ?string $containerAddress = null; - private static ?string $mysqlPort = null; - private static ?string $postgresPort = null; - protected MockObject $accessTokenEntityFactoryMock; protected MockObject $accessTokenEntityMock; protected array $accessTokenState; @@ -68,29 +60,15 @@ class AccessTokenRepositoryTest extends TestCase protected MockObject $jwsMock; protected MockObject $moduleConfigMock; + /** + * @throws \Exception + */ public static function setUpBeforeClass(): void { - - self::$containerAddress = getenv('HOSTADDRESS') ?: null; - self::$mysqlPort = getenv('HOSTPORT_MY') ?: null; - self::$postgresPort = getenv('HOSTPORT_PG') ?: null; - // Mac docker seems to require connecting to localhost and mapped port to access containers - if ( - in_array(PHP_OS_FAMILY, ['Darwin', 'Linux']) && - getenv('HOSTADDRESS') === false - ) { - //phpcs:ignore Generic.Files.LineLength.TooLong - echo "Defaulting docker host address to 127.0.0.1. Disable this behavior by setting HOSTADDRESS to a blank.\n\tHOSTADDRESS= ./vendor/bin/phpunit"; - self::$containerAddress = "127.0.0.1"; - } else { - //Use the container ips and ports if not on a Mac - self::$mysqlPort ??= "3306"; - self::$postgresPort ??= "5432"; - } Configuration::setConfigDir(__DIR__ . '/../../../config'); - self::$pgConfig = self::loadPGDatabase(); - self::$mysqlConfig = self::loadMySqlDatabase(); - self::$sqliteConfig = self::loadSqliteDatabase(); + self::$pgConfig = DatabaseContainers::postgres(); + self::$mysqlConfig = DatabaseContainers::mysql(); + self::$sqliteConfig = DatabaseContainers::sqlite(); } /** @@ -199,97 +177,9 @@ public function getDatabase(): Database $userRepositoryMock->add($user); } - /** - * @throws \Exception - */ - public static function loadPGDatabase(): array - { - $pgContainer = PostgresContainer::make('15.0', 'password'); - $pgContainer->withPostgresDatabase('database'); - $pgContainer->withPostgresUser('username'); - $hostPort = self::$postgresPort ?: self::findFreePort(); - $pgContainer->withPort($hostPort, '5432'); - - $pgContainer->run(); - // Wait until the docker heartcheck is green - $pgContainer->withWait(new WaitForHealthCheck()); - // Wait until that message is in the logs - $pgContainer->withWait(new WaitForLog('Ready to accept connections')); - - $hostAddress = self::$containerAddress ?: $pgContainer->getAddress(); - $pgConfig = [ - 'database.dsn' => sprintf( - 'pgsql:host=%s;port=%s;dbname=database', - $hostAddress, - $hostPort, - ), - 'database.username' => 'username', - 'database.password' => 'password', - 'database.prefix' => 'phpunit_', - 'database.persistent' => true, - 'database.secondaries' => [], - 'database.driver_options' => [ - PDO::ATTR_TIMEOUT => 2, // Timeout quickly if there are docker issues - ], - ]; - - return $pgConfig; - } - - public static function loadSqliteDatabase(): array - { - $config = [ - 'database.dsn' => 'sqlite::memory:', - 'database.username' => null, - 'database.password' => null, - 'database.prefix' => 'phpunit_', - 'database.persistent' => true, - 'database.secondaries' => [], - ]; - - return $config; - } - - public static function loadMySqlDatabase(): array - { - $mysqlContainer = MySQLContainer::make('8.0'); - $mysqlContainer->withMySQLDatabase('database'); - $mysqlContainer->withMySQLUser('username', 'password'); - $hostPort = self::$mysqlPort ?: self::findFreePort(); - $mysqlContainer->withPort($hostPort, '3306'); - - $mysqlContainer->run(); - // Wait until the docker heartcheck is green - $mysqlContainer->withWait(new WaitForHealthCheck()); - // Wait until that message is in the logs - $mysqlContainer->withWait(new WaitForLog('Ready to accept connections')); - - $hostAddress = self::$containerAddress ?: $mysqlContainer->getAddress(); - if ($hostAddress === 'localhost') { - //phpcs:ignore Generic.Files.LineLength.TooLong - throw new \Exception('To connect to localhost with mysql use IP 127.0.0.1, otherwise mysql tries to use a file socket'); - } - return [ - 'database.dsn' => - sprintf('mysql:host=%s;port=%s;dbname=database', $hostAddress, $hostPort), - 'database.username' => 'username', - 'database.password' => 'password', - 'database.prefix' => 'phpunit_', - 'database.persistent' => true, - 'database.secondaries' => [], - 'database.driver_options' => [ - PDO::ATTR_TIMEOUT => 2, // Timeout quickly if there are docker issues - ], - ]; - } - public static function databaseToTest(): array { - return [ - 'PostgreSql' => ['pgConfig'], - 'MySql' => ['mysqlConfig'], - 'Sqlite' => ['sqliteConfig'], - ]; + return DatabaseContainers::all(); } /** @@ -345,19 +235,4 @@ public static function clientRepositoryGetClient( $owner, ); } - - /** - * Determine a free port for the docker container - * by creating a closing a socket - */ - private static function findFreePort(): string - { - $sock = socket_create_listen(0); - if (socket_getsockname($sock, $addr, $port)) { - socket_close($sock); - return '' . $port; - } else { - throw new \Exception('unable to allocate port'); - } - } } diff --git a/tests/integration/src/StatusList/StatusListStorageTest.php b/tests/integration/src/StatusList/StatusListStorageTest.php new file mode 100644 index 00000000..8ef08eec --- /dev/null +++ b/tests/integration/src/StatusList/StatusListStorageTest.php @@ -0,0 +1,449 @@ + $config + * @throws \Exception + */ + protected function useDatabase(array $config): void + { + $this->database = Database::getInstance(Configuration::loadFromArray($config, '', 'simplesaml')); + (new DatabaseMigration($this->database))->migrate(); + + $this->database->write('DELETE FROM ' . $this->database->applyPrefix('oidc_status_list_entry')); + $this->database->write('DELETE FROM ' . $this->database->applyPrefix('oidc_status_list')); + + $moduleConfig = new ModuleConfig(); + $helpers = new Helpers(); + + $this->statusListRepository = new StatusListRepository($moduleConfig, $this->database, null, $helpers); + $this->statusListEntryRepository = new StatusListEntryRepository( + $moduleConfig, + $this->database, + null, + $helpers, + ); + } + + /** + * @throws \Exception + */ + protected function givenSeededList(int $bits = 2, string $allowedStatuses = '0,1,2'): void + { + $this->statusListRepository->create( + self::LIST_ID, + 'https://op.example.org/module.php/oidc/statuslist/' . self::LIST_ID, + 'integration-pool', + 'integration-fingerprint', + 1, + $bits, + self::CAPACITY, + $allowedStatuses, + 43200, + 604800, + 3600, + 'integration-signing-key', + StatusListKeyProfileEnum::DidJwk, + ); + + $this->statusListEntryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->statusListRepository->activate(self::LIST_ID); + } + + /** + * A newly created list has no published token, which is recorded as an empty content hash rather + * than as null so that the compare-and-set which publishes the first token has something to match. + * + * This is asserted per driver because a fixed width column does not treat a short value the same + * way everywhere: PostgreSQL blank-pads CHAR and keeps the padding on the way out, so storing this + * in a CHAR would read back as spaces and never compare equal to an empty string again -- which + * would leave publication matching no rows, for ever, on PostgreSQL only. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testEmptyContentHashRoundTripsAsAnEmptyString(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $statusList = $this->statusListRepository->findByIdOnPrimary(self::LIST_ID); + + $this->assertSame('', $statusList?->getSignedTokenContentHash()); + $this->assertFalse($statusList?->hasPublishedToken()); + } + + /** + * Claiming an index is a conditional update, and the number of rows it affected is the whole + * answer to whether this caller got it. That only works if every driver reports it the same way. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testClaimingAnIndexReportsWhetherItWasFree(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $credentialId = 'https://op.example.org/vc/first'; + + $this->assertTrue( + $this->statusListEntryRepository->allocate( + self::LIST_ID, + 7, + $credentialId, + $this->statusListEntryRepository->hashCredentialId($credentialId), + 'IntegrationCredential', + 'subject-ref', + null, + ), + ); + + // The same index a second time must report that it was not free, rather than raising, and + // rather than overwriting the credential already holding it. + $this->assertFalse( + $this->statusListEntryRepository->allocate( + self::LIST_ID, + 7, + 'https://op.example.org/vc/second', + $this->statusListEntryRepository->hashCredentialId('https://op.example.org/vc/second'), + 'IntegrationCredential', + null, + null, + ), + ); + + $entry = $this->statusListEntryRepository->findByListAndIdx(self::LIST_ID, 7); + $this->assertSame($credentialId, $entry?->getCredentialId()); + $this->assertTrue($entry?->isAllocated()); + + // Deliberately shorter than the column, since that is what catches blank padding: PostgreSQL + // pads CHAR and keeps the padding on the way out, so a short value would come back with + // trailing spaces and never compare equal to what was written. + $this->assertSame('subject-ref', $entry?->getSubjectRef()); + } + + /** + * A list which stopped accepting allocations must take no more, which the guard in the same + * statement enforces. Whether a boolean comparison behaves is driver specific. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testADeactivatedListAcceptsNoFurtherClaims(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $this->assertTrue($this->statusListRepository->deactivate(self::LIST_ID)); + + $this->assertFalse( + $this->statusListEntryRepository->allocate( + self::LIST_ID, + 9, + 'https://op.example.org/vc/late', + $this->statusListEntryRepository->hashCredentialId('https://op.example.org/vc/late'), + 'IntegrationCredential', + null, + null, + ), + ); + + // Deactivating is what settles which worker creates the successor, so it must report a winner + // exactly once. + $this->assertFalse($this->statusListRepository->deactivate(self::LIST_ID)); + } + + /** + * Rebuilding a list reads only the entries which are not Valid, keyed on the index they carry. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testReadsBackOnlyTheEntriesWhichAreNotValid(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $updater = new DbStatusUpdater( + $this->statusListRepository, + $this->statusListEntryRepository, + new LoggerService(), + ); + + foreach ([3, 11, 40] as $idx) { + $this->statusListEntryRepository->allocate( + self::LIST_ID, + $idx, + 'https://op.example.org/vc/' . $idx, + $this->statusListEntryRepository->hashCredentialId('https://op.example.org/vc/' . $idx), + 'IntegrationCredential', + null, + null, + ); + } + + $updater->setStatus(self::LIST_ID, 11, StatusTypeEnum::Invalid); + $updater->setStatus(self::LIST_ID, 40, StatusTypeEnum::Suspended); + + // Index 3 is allocated but Valid, so it is absent, exactly like the indices never handed out. + $this->assertSame( + [11 => StatusTypeEnum::Invalid->value, 40 => StatusTypeEnum::Suspended->value], + $this->statusListEntryRepository->findNonValidStatuses(self::LIST_ID), + ); + + $this->assertSame(3, $this->statusListEntryRepository->countAllocated(self::LIST_ID)); + } + + /** + * Changing a status has to mark the published token stale, and the guard on that update means it + * only reports a change when there was one to make. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testChangingAStatusInvalidatesThePublishedToken(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $this->statusListEntryRepository->allocate( + self::LIST_ID, + 5, + 'https://op.example.org/vc/five', + $this->statusListEntryRepository->hashCredentialId('https://op.example.org/vc/five'), + 'IntegrationCredential', + null, + null, + ); + + $this->database->write( + 'UPDATE ' . $this->database->applyPrefix('oidc_status_list') . + ' SET signed_token = :token, signed_token_content_hash = :hash WHERE id = :id', + [ + 'token' => 'header.payload.signature', + 'hash' => str_repeat('a', 64), + 'id' => self::LIST_ID, + ], + ); + + $this->assertTrue($this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->hasPublishedToken()); + + $updater = new DbStatusUpdater( + $this->statusListRepository, + $this->statusListEntryRepository, + new LoggerService(), + ); + $updater->setStatus(self::LIST_ID, 5, StatusTypeEnum::Invalid); + + $statusList = $this->statusListRepository->findByIdOnPrimary(self::LIST_ID); + $this->assertSame('', $statusList?->getSignedTokenContentHash()); + $this->assertFalse($statusList?->hasPublishedToken()); + } + + /** + * A list still being seeded is found by the in-preparation lookup while it is recent, and ignored + * once it is old enough to count as abandoned. The comparison is against a datetime bound as a + * string, which is the part that differs between drivers. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testFindsAListBeingPreparedOnlyWhileItIsRecent(string $database): void + { + $this->useDatabase(self::$$database); + + // Created but never opened, which is what a seed in progress looks like. + $this->statusListRepository->create( + self::LIST_ID, + 'https://op.example.org/module.php/oidc/statuslist/' . self::LIST_ID, + 'integration-pool', + 'integration-fingerprint', + 1, + 2, + self::CAPACITY, + '0,1,2', + 43200, + 604800, + 3600, + 'integration-signing-key', + StatusListKeyProfileEnum::DidJwk, + ); + + $helpers = new Helpers(); + + $this->assertCount( + 1, + $this->statusListRepository->findBeingPreparedForPolicy( + 'integration-pool', + 'integration-fingerprint', + $helpers->dateTime()->getUtc()->sub(new \DateInterval('PT2M')), + ), + ); + + // Same list, asked about with a cut-off it is older than. + $this->assertCount( + 0, + $this->statusListRepository->findBeingPreparedForPolicy( + 'integration-pool', + 'integration-fingerprint', + $helpers->dateTime()->getUtc()->add(new \DateInterval('PT2M')), + ), + ); + + // A list which is already open is not one being prepared. + $this->statusListRepository->activate(self::LIST_ID); + $this->assertCount( + 0, + $this->statusListRepository->findBeingPreparedForPolicy( + 'integration-pool', + 'integration-fingerprint', + $helpers->dateTime()->getUtc()->sub(new \DateInterval('PT2M')), + ), + ); + } + + /** + * Only a list which was never opened may be removed, so that one a credential could point at is + * untouchable. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testRemovesOnlyAListWhichWasNeverOpened(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $this->assertFalse($this->statusListRepository->deleteUnopened(self::LIST_ID)); + $this->assertNotNull($this->statusListRepository->findByIdOnPrimary(self::LIST_ID)); + } + + /** + * Removing an unopened list takes its entries with it on every driver. + * + * The foreign key is declared ON DELETE CASCADE, but SQLite enforces foreign keys only when the + * connection asks it to and this module's database wrapper does not, so the cascade does nothing + * there. The entries are therefore removed explicitly, and this asserts that they are actually gone + * rather than that the constraint fired. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testRemovingAnUnopenedListRemovesItsEntries(string $database): void + { + $this->useDatabase(self::$$database); + + $this->statusListRepository->create( + self::LIST_ID, + 'https://op.example.org/module.php/oidc/statuslist/' . self::LIST_ID, + 'integration-pool', + 'integration-fingerprint', + 1, + 2, + self::CAPACITY, + '0,1,2', + 43200, + 604800, + 3600, + 'integration-signing-key', + StatusListKeyProfileEnum::DidJwk, + ); + $this->statusListEntryRepository->seed(self::LIST_ID, self::CAPACITY); + + $this->assertTrue($this->statusListRepository->deleteUnopened(self::LIST_ID)); + $this->assertNull($this->statusListRepository->findByIdOnPrimary(self::LIST_ID)); + + $remaining = $this->database->readPrimary( + 'SELECT COUNT(*) AS total FROM ' . $this->database->applyPrefix('oidc_status_list_entry') . + ' WHERE status_list_id = :id', + ['id' => self::LIST_ID], + )->fetchAll(); + + $this->assertSame(0, (int)($remaining[0]['total'] ?? -1)); + } + + /** + * Migrations must be re-runnable, because a version is recorded only once its whole method has + * succeeded and nothing rolls back what it managed before failing. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testMigrationsAreIdempotent(string $database): void + { + $this->useDatabase(self::$$database); + + $migration = new DatabaseMigration($this->database); + $migration->migrate(); + $migration->migrate(); + + $this->assertTrue($migration->isMigrated()); + $this->assertSame([], $migration->getNotImplementedVersions()); + } + + /** + * @return array + */ + public static function databaseToTest(): array + { + return DatabaseContainers::all(); + } +} diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 66278d85..f609e834 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -12,8 +12,10 @@ use SimpleSAML\Configuration; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; use SimpleSAML\OpenID\SupportedAlgorithms; @@ -725,4 +727,159 @@ public function testGetValidatedSignatureKeyPairArrayThrowsOnEmptyKeyIdString(): $this->sut()->getValidatedSignatureKeyPairArray($value); } + + /***************************************************************************************************************** + * Token Status List + ****************************************************************************************************************/ + + /** + * Off unless a deployment opts in, since it changes what is stored about issued credentials. + * + * @throws \Exception + */ + public function testStatusListsAreDisabledByDefault(): void + { + $this->assertFalse($this->sut()->getVciStatusListEnabled()); + } + + /** + * @throws \Exception + */ + public function testStatusListsCanBeEnabled(): void + { + $this->assertTrue( + $this->sut(overrides: [ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED => true]) + ->getVciStatusListEnabled(), + ); + } + + /** + * SimpleSAMLphp is a development dependency here, so nothing stops this module being installed into + * a host which lacks the primary read. Deciding whether a credential has been revoked off a lagging + * secondary can publish a revoked credential as valid, so the capability is required rather than + * degraded to a replica read. + */ + public function testPrimaryDatabaseReadCapabilityIsDetectedRatherThanAssumed(): void + { + $this->assertSame( + method_exists(\SimpleSAML\Database::class, ModuleConfig::SSP_PRIMARY_READ_METHOD), + ModuleConfig::hasPrimaryDatabaseReadCapability(), + ); + + // The installed SimpleSAMLphp must provide it, otherwise the capability could never be enabled. + $this->assertTrue(ModuleConfig::hasPrimaryDatabaseReadCapability()); + } + + /** + * @throws \Exception + */ + public function testStatusListKeyProfileDefaultsToDidJwk(): void + { + $this->assertSame( + StatusListKeyProfileEnum::DidJwk, + $this->sut()->getVciStatusListKeyProfile(), + ); + } + + /** + * @throws \Exception + */ + public function testStatusListKeyProfileAcceptsAnEnumCaseOrItsValue(): void + { + $this->assertSame( + StatusListKeyProfileEnum::Jwks, + $this->sut(overrides: [ + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => StatusListKeyProfileEnum::Jwks, + ])->getVciStatusListKeyProfile(), + ); + + $this->assertSame( + StatusListKeyProfileEnum::Jwks, + $this->sut(overrides: [ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => 'jwks']) + ->getVciStatusListKeyProfile(), + ); + } + + /** + * @throws \Exception + */ + public function testStatusListKeyProfileRejectsAnUnknownValue(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: [ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => 'x509']) + ->getVciStatusListKeyProfile(); + } + + /** + * A typo here would otherwise be silent: the pool would never be allocated from, and the + * credentials which were meant to be revocable would be issued without a status claim. + * + * @throws \Exception + */ + public function testStatusListPoolsRejectAnUnknownCredentialConfiguration(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('NoSuchCredential'); + + $this->sut(overrides: [ + ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS => [ + 'default' => [ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['NoSuchCredential'], + ], + ], + ])->getVciStatusListPoolBag(); + } + + /** + * @throws \Exception + */ + public function testResolvesTheStatusListPoolForACredentialConfiguration(): void + { + $sut = $this->sut(overrides: $this->withStatusListPool(true)); + + $this->assertSame('default', $sut->getVciStatusListPoolFor('TestCredential')?->getId()); + + // A configuration in no pool is not an error: its credentials are issued without a status + // claim, and so can not be revoked. + $this->assertNull($sut->getVciStatusListPoolFor('SomethingElse')); + } + + /** + * With the capability off, nothing allocates, so no credential configuration resolves to a pool + * even when one is configured. + * + * @throws \Exception + */ + public function testResolvesNoStatusListPoolWhileTheCapabilityIsDisabled(): void + { + $sut = $this->sut(overrides: $this->withStatusListPool(false)); + + $this->assertNull($sut->getVciStatusListPoolFor('TestCredential')); + + // The pool itself is still readable, so the administration screens can show what is configured + // even while it is inert. + $this->assertSame('default', $sut->getVciStatusListPoolBag()->getById('default')?->getId()); + } + + /** + * @return array + */ + protected function withStatusListPool(bool $isEnabled): array + { + return array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED => [ + 'TestCredential' => [], + ], + ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED => $isEnabled, + ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS => [ + 'default' => [ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['TestCredential'], + ], + ], + ], + ); + } } diff --git a/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php b/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php new file mode 100644 index 00000000..ea3c7d04 --- /dev/null +++ b/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php @@ -0,0 +1,1133 @@ + 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ], + '', + 'simplesaml', + ); + + (new DatabaseMigration())->migrate(); + } + + /** + * @throws \Exception + */ + protected function setUp(): void + { + $this->database = Database::getInstance(); + + // A shared in-memory database persists across tests, so each one starts from a clean slate. + $this->database->write('DELETE FROM ' . $this->database->applyPrefix('oidc_status_list_entry')); + $this->database->write('DELETE FROM ' . $this->database->applyPrefix('oidc_status_list')); + + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $helpers = new Helpers(); + + $this->statusListRepository = new StatusListRepository( + $moduleConfigMock, + $this->database, + $protocolCacheMock, + $helpers, + ); + + $this->statusListEntryRepository = new StatusListEntryRepository( + $moduleConfigMock, + $this->database, + $protocolCacheMock, + $helpers, + ); + + $this->keyResolverMock = $this->createMock(StatusListKeyResolver::class); + $this->keyResolverMock->method('getCurrentKeyId') + ->willReturnCallback(fn(): string => $this->signingKeyId); + + $this->routesMock = $this->createMock(Routes::class); + $this->routesMock->method('urlStatusList') + ->willReturnCallback( + static fn(string $id): string => 'https://op.example.org/module.php/oidc/statuslist/' . $id, + ); + + $this->loggerServiceMock = $this->createMock(LoggerService::class); + } + + protected function sut(): DbStatusIndexAllocator + { + return new DbStatusIndexAllocator( + $this->statusListRepository, + $this->statusListEntryRepository, + $this->keyResolverMock, + new TokenStatusList(), + $this->routesMock, + new Helpers(), + $this->loggerServiceMock, + ); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function pool(int $capacity = self::CAPACITY): StatusListPool + { + return StatusListPool::fromConfig( + self::POOL_ID, + [ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => [self::CREDENTIAL_CONFIGURATION_ID], + StatusListPool::KEY_CAPACITY => $capacity, + ], + StatusListKeyProfileEnum::DidJwk, + ); + } + + /** + * @throws \Exception + */ + protected function allocate(string $credentialId): StatusAllocation + { + return $this->sut()->allocateFor( + $this->pool(), + $credentialId, + self::CREDENTIAL_CONFIGURATION_ID, + 'subject-ref-hash', + null, + ); + } + + /** + * @return array + */ + protected function statusListRows(): array + { + return $this->database->readPrimary( + 'SELECT * FROM ' . $this->database->applyPrefix('oidc_status_list') . ' ORDER BY generation', + )->fetchAll(); + } + + /** + * @throws \Exception + */ + public function testCreatesSeedsAndActivatesAListOnTheFirstAllocation(): void + { + $allocation = $this->allocate('https://op.example.org/vc/abc'); + + $rows = $this->statusListRows(); + $this->assertCount(1, $rows); + + $statusList = $this->statusListRepository->findByIdOnPrimary($allocation->getStatusListId()); + $this->assertNotNull($statusList); + $this->assertTrue($statusList->isActive(), 'A seeded list must end up open for allocation.'); + $this->assertSame(1, $statusList->getGeneration()); + $this->assertSame(self::CAPACITY, $statusList->getCapacity()); + $this->assertSame($this->signingKeyId, $statusList->getSigningKeyId()); + $this->assertSame(StatusListKeyProfileEnum::DidJwk, $statusList->getKeyProfile()); + + // Every index exists from the outset, which is what makes claiming one a conditional update. + $seeded = $this->database->readPrimary( + 'SELECT COUNT(*) AS total FROM ' . $this->database->applyPrefix('oidc_status_list_entry') . + ' WHERE status_list_id = :id', + ['id' => $allocation->getStatusListId()], + )->fetchAll(); + + $this->assertSame(self::CAPACITY, (int)$seeded[0]['total']); + } + + /** + * The reference handed back is what goes into the credential, so it has to be the URI which was + * stored, byte for byte, rather than one rebuilt later from the current base URL. + * + * @throws \Exception + */ + public function testReturnsTheStoredUriAndAnInRangeIndex(): void + { + $allocation = $this->allocate('https://op.example.org/vc/abc'); + + $statusList = $this->statusListRepository->findByIdOnPrimary($allocation->getStatusListId()); + + $this->assertSame($statusList?->getUri(), $allocation->getUri()); + $this->assertSame($allocation->getUri(), $allocation->getStatusReference()->getUri()); + $this->assertGreaterThanOrEqual(0, $allocation->getIdx()); + $this->assertLessThan(self::CAPACITY, $allocation->getIdx()); + } + + /** + * Claiming the index and recording what claimed it are one statement, so there is never a moment + * where an index is taken but unattributable. + * + * @throws \Exception + */ + public function testRecordsTheCredentialLinkageWithTheClaim(): void + { + $credentialId = 'https://op.example.org/vc/abc'; + $allocation = $this->allocate($credentialId); + + $entry = $this->statusListEntryRepository->findByListAndIdx( + $allocation->getStatusListId(), + $allocation->getIdx(), + ); + + $this->assertNotNull($entry); + $this->assertTrue($entry->isAllocated()); + $this->assertSame($credentialId, $entry->getCredentialId()); + $this->assertSame( + $this->statusListEntryRepository->hashCredentialId($credentialId), + $entry->getCredentialIdHash(), + ); + $this->assertSame(self::CREDENTIAL_CONFIGURATION_ID, $entry->getCredentialConfigurationId()); + $this->assertSame('subject-ref-hash', $entry->getSubjectRef()); + $this->assertSame(0, $entry->getStatus()); + // Null expiry marks a credential which never expires, which keeps its list from being retired. + $this->assertNull($entry->getExpiresAt()); + } + + /** + * @throws \Exception + */ + public function testNeverHandsOutTheSameIndexTwice(): void + { + $seen = []; + + // Four is the load factor limit for this capacity, so this stays within one list. + for ($i = 0; $i < 4; $i++) { + $allocation = $this->allocate('https://op.example.org/vc/' . $i); + $key = $allocation->getStatusListId() . '#' . $allocation->getIdx(); + + $this->assertArrayNotHasKey($key, $seen, 'An index was handed out twice.'); + $seen[$key] = true; + } + + $this->assertCount(4, $seen); + } + + /** + * A claim on an index someone else already took simply affects no rows. Nothing has to classify a + * database error to discover that, which matters because this module can not do so reliably. + * + * @throws \Exception + */ + public function testClaimingAnAlreadyTakenIndexAffectsNoRowsRatherThanRaising(): void + { + $allocation = $this->allocate('https://op.example.org/vc/first'); + + $wasClaimed = $this->statusListEntryRepository->allocate( + $allocation->getStatusListId(), + $allocation->getIdx(), + 'https://op.example.org/vc/second', + $this->statusListEntryRepository->hashCredentialId('https://op.example.org/vc/second'), + self::CREDENTIAL_CONFIGURATION_ID, + null, + null, + ); + + $this->assertFalse($wasClaimed); + + // The first credential still holds it. + $entry = $this->statusListEntryRepository->findByListAndIdx( + $allocation->getStatusListId(), + $allocation->getIdx(), + ); + $this->assertSame('https://op.example.org/vc/first', $entry?->getCredentialId()); + } + + /** + * A list which stopped accepting allocations must not take any more, even for an index which is + * still free, otherwise a credential could land in a list already on its way to retirement. + * + * @throws \Exception + */ + public function testWillNotClaimAnIndexInADeactivatedList(): void + { + $allocation = $this->allocate('https://op.example.org/vc/first'); + $statusListId = $allocation->getStatusListId(); + + $this->assertTrue($this->statusListRepository->deactivate($statusListId)); + + $freeIdx = ($allocation->getIdx() + 1) % self::CAPACITY; + + $wasClaimed = $this->statusListEntryRepository->allocate( + $statusListId, + $freeIdx, + 'https://op.example.org/vc/second', + $this->statusListEntryRepository->hashCredentialId('https://op.example.org/vc/second'), + self::CREDENTIAL_CONFIGURATION_ID, + null, + null, + ); + + $this->assertFalse($wasClaimed); + } + + /** + * Deactivating is what settles which of several workers goes on to create the successor. + * + * @throws \Exception + */ + public function testOnlyOneCallerWinsDeactivation(): void + { + $allocation = $this->allocate('https://op.example.org/vc/first'); + + $this->assertTrue($this->statusListRepository->deactivate($allocation->getStatusListId())); + $this->assertFalse($this->statusListRepository->deactivate($allocation->getStatusListId())); + } + + /** + * Running out of picks must never surface as a failure to issue a credential. The list is closed, a + * successor is started, and the credential is allocated there. + * + * @throws \Exception + */ + public function testRunningOutOfPicksRotatesInsteadOfFailing(): void + { + $first = $this->allocate('https://op.example.org/vc/first'); + $firstListId = $first->getStatusListId(); + + // Fill every remaining index directly, so the advisory counter stays low and the allocator + // still considers this list worth trying. That is exactly the undercount the design tolerates. + for ($idx = 0; $idx < self::CAPACITY; $idx++) { + $this->statusListEntryRepository->allocate( + $firstListId, + $idx, + 'https://op.example.org/vc/filler-' . $idx, + $this->statusListEntryRepository->hashCredentialId('filler-' . $idx), + self::CREDENTIAL_CONFIGURATION_ID, + null, + null, + ); + } + + $second = $this->allocate('https://op.example.org/vc/second'); + + $this->assertNotSame($firstListId, $second->getStatusListId(), 'Allocation should have rotated.'); + + $rows = $this->statusListRows(); + $this->assertCount(2, $rows); + $this->assertSame(2, (int)$rows[1]['generation']); + + // The full list is closed rather than left to be picked again. + $this->assertFalse($this->statusListRepository->findByIdOnPrimary($firstListId)?->isActive()); + } + + /** + * The advisory counter is what triggers rotating early, before picks start colliding. + * + * @throws \Exception + */ + public function testRotatesOnceTheLoadFactorIsReached(): void + { + $listIds = []; + + for ($i = 0; $i < 5; $i++) { + $listIds[] = $this->allocate('https://op.example.org/vc/' . $i)->getStatusListId(); + } + + // Capacity 8 at a load factor of one half means the fifth allocation starts a second list. + $this->assertCount(1, array_unique(array_slice($listIds, 0, 4))); + $this->assertNotSame($listIds[3], $listIds[4]); + $this->assertCount(2, $this->statusListRows()); + } + + /** + * During a key rotation the issuer signs credentials with the current key. A list bound to the + * previous one must stop being selected, or the profile saying the two are the same key breaks. + * + * @throws \Exception + */ + public function testDoesNotAllocateIntoAListBoundToASupersededSigningKey(): void + { + $first = $this->allocate('https://op.example.org/vc/first'); + + $this->signingKeyId = 'signing-key-2'; + + $second = $this->allocate('https://op.example.org/vc/second'); + + $this->assertNotSame($first->getStatusListId(), $second->getStatusListId()); + + $newList = $this->statusListRepository->findByIdOnPrimary($second->getStatusListId()); + $this->assertSame('signing-key-2', $newList?->getSigningKeyId()); + + // The superseded list stays open and served: credentials already point at it, and it is only + // ineligible for new allocations, not retired. + $this->assertTrue($this->statusListRepository->findByIdOnPrimary($first->getStatusListId())?->isActive()); + } + + /** + * Changing a pool setting must likewise not leave lists created under the old policy eligible. + * + * @throws \Exception + */ + public function testDoesNotAllocateIntoAListCreatedUnderADifferentPolicy(): void + { + $first = $this->allocate('https://op.example.org/vc/first'); + + $widerPool = StatusListPool::fromConfig( + self::POOL_ID, + [ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => [self::CREDENTIAL_CONFIGURATION_ID], + StatusListPool::KEY_CAPACITY => self::CAPACITY, + StatusListPool::KEY_BITS => 2, + ], + StatusListKeyProfileEnum::DidJwk, + ); + + $second = $this->sut()->allocateFor( + $widerPool, + 'https://op.example.org/vc/second', + self::CREDENTIAL_CONFIGURATION_ID, + ); + + $this->assertNotSame($first->getStatusListId(), $second->getStatusListId()); + $this->assertSame(2, $this->statusListRepository->findByIdOnPrimary($second->getStatusListId())?->getBits()); + } + + /** + * Successive lists in a pool take successive generations, which is what the unique constraint uses + * to let exactly one of several workers create the successor. + * + * @throws \Exception + */ + public function testASecondListForTheSamePoolAndGenerationCannotBeCreated(): void + { + $allocation = $this->allocate('https://op.example.org/vc/first'); + $existing = $this->statusListRepository->findByIdOnPrimary($allocation->getStatusListId()); + + $this->expectException(\Exception::class); + + $this->statusListRepository->create( + 'some-other-id', + 'https://op.example.org/module.php/oidc/statuslist/some-other-id', + self::POOL_ID, + (string)$existing?->getPolicyFingerprint(), + (int)$existing?->getGeneration(), + 1, + self::CAPACITY, + '0,1', + 43200, + 604800, + 3600, + $this->signingKeyId, + StatusListKeyProfileEnum::DidJwk, + ); + } + + /** + * A list which was closed is not picked again; the next allocation starts the next generation. + * + * @throws \Exception + */ + public function testStartsTheNextGenerationOnceTheOpenListIsClosed(): void + { + $first = $this->allocate('https://op.example.org/vc/first'); + + $this->assertTrue($this->statusListRepository->deactivate($first->getStatusListId())); + + $second = $this->allocate('https://op.example.org/vc/second'); + + $this->assertNotSame($first->getStatusListId(), $second->getStatusListId()); + $this->assertSame( + 2, + $this->statusListRepository->findByIdOnPrimary($second->getStatusListId())?->getGeneration(), + ); + $this->assertCount(2, $this->statusListRows()); + } + + /** + * Losing the race to create a successor is recoverable without working out why the insert failed. + * + * The recovery is the same whatever the cause, which is the point: the database wrapper reports the + * connection's error rather than the failing statement's and rethrows without the original, so a + * unique constraint violation can not reliably be told apart from anything else. This stands in for + * another worker having inserted the same generation between this request's read and its own write. + * + * @throws \Exception + */ + public function testAdoptsAListAnotherRequestCreatedWhenItsOwnInsertFails(): void + { + // Seed a list which the racing worker is taken to have created and opened. + $winner = $this->allocate('https://op.example.org/vc/winner'); + $winningListId = $winner->getStatusListId(); + + $repositoryStub = new class ( + $this->createMock(ModuleConfig::class), + $this->database, + $this->createMock(ProtocolCache::class), + new Helpers(), + ) extends StatusListRepository { + public bool $pretendNoListIsOpen = true; + + public function findActiveForPolicy(string $poolId, string $policyFingerprint): array + { + // Empty on the first look, so the allocator decides a successor is needed. Populated + // afterwards, which is what the racing worker's list becoming visible looks like. + if ($this->pretendNoListIsOpen) { + $this->pretendNoListIsOpen = false; + + return []; + } + + return parent::findActiveForPolicy($poolId, $policyFingerprint); + } + + public function create( + string $id, + string $uri, + string $poolId, + string $policyFingerprint, + int $generation, + int $bits, + int $capacity, + string $allowedStatuses, + int $ttlSeconds, + int $tokenValiditySeconds, + int $refreshIntervalSeconds, + string $signingKeyId, + StatusListKeyProfileEnum $keyProfile, + ): void { + throw new \Exception('Database error: duplicate generation.'); + } + }; + + $allocator = new DbStatusIndexAllocator( + $repositoryStub, + $this->statusListEntryRepository, + $this->keyResolverMock, + new TokenStatusList(), + $this->routesMock, + new Helpers(), + $this->loggerServiceMock, + ); + + $allocation = $allocator->allocateFor( + $this->pool(), + 'https://op.example.org/vc/loser', + self::CREDENTIAL_CONFIGURATION_ID, + ); + + // The credential landed in the winner's list rather than the request failing. + $this->assertSame($winningListId, $allocation->getStatusListId()); + $this->assertCount(1, $this->statusListRows()); + } + + /** + * A request arriving while another is still seeding a list must join that list rather than start a + * second one. Privacy comes from many credentials sharing a list, so splitting a pool across + * sparse lists is a real cost, not just wasted work. + * + * @throws \Exception + */ + public function testJoinsAListAnotherRequestIsStillSeedingRatherThanStartingASecondOne(): void + { + // A list which exists, has its entries, but was never opened is what a seed in progress looks + // like from another request's point of view. + $this->statusListRepository->create( + 'being-seeded', + 'https://op.example.org/module.php/oidc/statuslist/being-seeded', + self::POOL_ID, + $this->pool()->getPolicyFingerprint($this->signingKeyId), + 1, + 1, + self::CAPACITY, + '0,1', + 43200, + 604800, + 3600, + $this->signingKeyId, + StatusListKeyProfileEnum::DidJwk, + ); + $this->statusListEntryRepository->seed('being-seeded', self::CAPACITY); + + $repositoryStub = new class ( + $this->createMock(ModuleConfig::class), + $this->database, + $this->createMock(ProtocolCache::class), + new Helpers(), + ) extends StatusListRepository { + public int $activeLookups = 0; + + public function findActiveForPolicy(string $poolId, string $policyFingerprint): array + { + $this->activeLookups++; + + // Stands in for the other request finishing its seed while this one is waiting: the + // first look finds nothing open, the next finds the list it was waiting for. + if ($this->activeLookups === 2) { + $this->activate('being-seeded'); + } + + return parent::findActiveForPolicy($poolId, $policyFingerprint); + } + }; + + $allocator = new DbStatusIndexAllocator( + $repositoryStub, + $this->statusListEntryRepository, + $this->keyResolverMock, + new TokenStatusList(), + $this->routesMock, + new Helpers(), + $this->loggerServiceMock, + ); + + $allocation = $allocator->allocateFor( + $this->pool(), + 'https://op.example.org/vc/second', + self::CREDENTIAL_CONFIGURATION_ID, + ); + + $this->assertSame('being-seeded', $allocation->getStatusListId()); + $this->assertGreaterThanOrEqual(2, $repositoryStub->activeLookups, 'The wait path was not taken.'); + $this->assertCount(1, $this->statusListRows(), 'A second list should not have been started.'); + } + + /** + * A list left unopened by a request which died mid-seed must not stall every later request. Past + * its staleness window it is ignored and a fresh list is started, so issuance still succeeds. + * + * @throws \Exception + */ + public function testIgnoresAnAbandonedHalfSeededListInsteadOfWaitingForever(): void + { + $this->statusListRepository->create( + 'abandoned', + 'https://op.example.org/module.php/oidc/statuslist/abandoned', + self::POOL_ID, + $this->pool()->getPolicyFingerprint($this->signingKeyId), + 1, + 1, + self::CAPACITY, + '0,1', + 43200, + 604800, + 3600, + $this->signingKeyId, + StatusListKeyProfileEnum::DidJwk, + ); + + // Backdate it well past the window in which a seed could still be running. + $this->database->write( + 'UPDATE ' . $this->database->applyPrefix('oidc_status_list') . + " SET created_at = '2020-01-01 00:00:00' WHERE id = 'abandoned'", + ); + + $started = microtime(true); + $allocation = $this->allocate('https://op.example.org/vc/first'); + $elapsed = microtime(true) - $started; + + $this->assertNotSame('abandoned', $allocation->getStatusListId()); + $this->assertLessThan(1.0, $elapsed, 'An abandoned list must not be waited on.'); + + // The abandoned list keeps its generation, so the new one takes the next. + $this->assertSame( + 2, + $this->statusListRepository->findByIdOnPrimary($allocation->getStatusListId())?->getGeneration(), + ); + } + + /** + * The unique constraint on (pool_id, generation) only settles a race between requests which picked + * the same generation. Two requests reading the highest generation a moment apart pick different + * ones, so both inserts succeed and nothing collides. Whoever holds the higher generation has to + * notice and stand down, or the pool ends up with two half empty lists and half the herd each. + * + * @throws \Exception + */ + public function testStandsDownWhenAnotherRequestIsPreparingAnEarlierGeneration(): void + { + $fingerprint = $this->pool()->getPolicyFingerprint($this->signingKeyId); + + // The other request got in first and is still seeding its generation 1. + $this->statusListRepository->create( + 'earlier-generation', + 'https://op.example.org/module.php/oidc/statuslist/earlier-generation', + self::POOL_ID, + $fingerprint, + 1, + 1, + self::CAPACITY, + '0,1', + 43200, + 604800, + 3600, + $this->signingKeyId, + StatusListKeyProfileEnum::DidJwk, + ); + $this->statusListEntryRepository->seed('earlier-generation', self::CAPACITY); + + $repositoryStub = new class ( + $this->createMock(ModuleConfig::class), + $this->database, + $this->createMock(ProtocolCache::class), + new Helpers(), + ) extends StatusListRepository { + public int $activeLookups = 0; + + public function findBeingPreparedForPolicy( + string $poolId, + string $policyFingerprint, + \DateTimeImmutable $createdAfter, + ?int $belowGeneration = null, + ): array { + // Hide the in-progress list from the pre-creation check only, so this request goes + // ahead and creates its own generation 2 -- which is exactly the interleaving where the + // unique constraint does not fire. The post-creation check still sees it. + if ($belowGeneration === null) { + return []; + } + + return parent::findBeingPreparedForPolicy( + $poolId, + $policyFingerprint, + $createdAfter, + $belowGeneration, + ); + } + + public function findActiveForPolicy(string $poolId, string $policyFingerprint): array + { + $this->activeLookups++; + + // The other request finishes its seed while this one is standing down. + if ($this->activeLookups >= 3) { + $this->activate('earlier-generation'); + } + + return parent::findActiveForPolicy($poolId, $policyFingerprint); + } + }; + + $allocator = new DbStatusIndexAllocator( + $repositoryStub, + $this->statusListEntryRepository, + $this->keyResolverMock, + new TokenStatusList(), + $this->routesMock, + new Helpers(), + $this->loggerServiceMock, + ); + + $allocation = $allocator->allocateFor( + $this->pool(), + 'https://op.example.org/vc/second', + self::CREDENTIAL_CONFIGURATION_ID, + ); + + $this->assertSame('earlier-generation', $allocation->getStatusListId()); + + // The redundant generation 2 was removed rather than left behind half seeded. + $this->assertCount(1, $this->statusListRows()); + } + + /** + * Standing down must never leave the redundant list behind, and it must only ever remove one which + * was never opened, so that a list a credential could point at is untouchable. + * + * @throws \Exception + */ + public function testOnlyRemovesAListWhichWasNeverOpened(): void + { + $allocation = $this->allocate('https://op.example.org/vc/first'); + + $this->assertFalse( + $this->statusListRepository->deleteUnopened($allocation->getStatusListId()), + 'An open list must not be removable.', + ); + $this->assertNotNull($this->statusListRepository->findByIdOnPrimary($allocation->getStatusListId())); + + $this->statusListRepository->create( + 'never-opened', + 'https://op.example.org/module.php/oidc/statuslist/never-opened', + self::POOL_ID, + 'some-fingerprint', + 99, + 1, + self::CAPACITY, + '0,1', + 43200, + 604800, + 3600, + $this->signingKeyId, + StatusListKeyProfileEnum::DidJwk, + ); + + $this->assertTrue($this->statusListRepository->deleteUnopened('never-opened')); + $this->assertNull($this->statusListRepository->findByIdOnPrimary('never-opened')); + } + + /** + * A request which died after creating a list but before opening it must not take the pool down + * with it. Standing down repeatedly for a list nobody is finishing would fail every issuance until + * the abandoned row aged out, so the next request takes over instead. + * + * @throws \Exception + */ + public function testTakesOverWhenTheListItStoodDownForIsNeverOpened(): void + { + // Created moments ago and never opened, and its creator is gone, so nothing will ever open it. + $this->statusListRepository->create( + 'never-finished', + 'https://op.example.org/module.php/oidc/statuslist/never-finished', + self::POOL_ID, + $this->pool()->getPolicyFingerprint($this->signingKeyId), + 1, + 1, + self::CAPACITY, + '0,1', + 43200, + 604800, + 3600, + $this->signingKeyId, + StatusListKeyProfileEnum::DidJwk, + ); + + $started = microtime(true); + $allocation = $this->allocate('https://op.example.org/vc/first'); + $elapsed = microtime(true) - $started; + + // Issuance succeeded rather than failing while the abandoned row was still recent. + $this->assertNotSame('never-finished', $allocation->getStatusListId()); + $this->assertTrue( + $this->statusListRepository->findByIdOnPrimary($allocation->getStatusListId())?->isActive(), + ); + + // One wait, not one per creation attempt until the budget ran out. + $this->assertLessThan(8.0, $elapsed); + } + + /** + * A list which has reached the point where a successor is started must be closed, not merely + * skipped. Left open it would come back in every candidate query for ever, and retirement waits on + * the moment it was closed. + * + * @throws \Exception + */ + public function testClosesAListOnceItIsFullRatherThanLeavingItOpen(): void + { + $firstListId = null; + + for ($i = 0; $i < 5; $i++) { + $allocation = $this->allocate('https://op.example.org/vc/' . $i); + $firstListId ??= $allocation->getStatusListId(); + } + + $first = $this->statusListRepository->findByIdOnPrimary((string)$firstListId); + + $this->assertFalse($first?->isActive(), 'A full list must not be left open.'); + $this->assertNotNull($first?->getDeactivatedAt(), 'Retirement needs to know when it was closed.'); + + // And it is gone from the candidate set, so later allocations do not keep re-reading it. + $this->assertNotContains( + $firstListId, + array_map( + static fn($record): string => $record->getId(), + $this->statusListRepository->findActiveForPolicy( + self::POOL_ID, + $this->pool()->getPolicyFingerprint($this->signingKeyId), + ), + ), + ); + } + + /** + * The allocation counter is advisory and may undercount, so failing to bump it must not undo an + * index which is already durably claimed. Throwing here would consume the slot and then fail the + * credential, and retrying it would collide on its unique hash. + * + * @throws \Exception + */ + public function testStillReturnsTheAllocationWhenTheAdvisoryCounterCannotBeUpdated(): void + { + $repositoryStub = new class ( + $this->createMock(ModuleConfig::class), + $this->database, + $this->createMock(ProtocolCache::class), + new Helpers(), + ) extends StatusListRepository { + public function incrementAllocatedCount(string $id): void + { + throw new \Exception('Database error: deadlock found.'); + } + }; + + $allocator = new DbStatusIndexAllocator( + $repositoryStub, + $this->statusListEntryRepository, + $this->keyResolverMock, + new TokenStatusList(), + $this->routesMock, + new Helpers(), + $this->loggerServiceMock, + ); + + $allocation = $allocator->allocateFor( + $this->pool(), + 'https://op.example.org/vc/counter', + self::CREDENTIAL_CONFIGURATION_ID, + ); + + $entry = $this->statusListEntryRepository->findByListAndIdx( + $allocation->getStatusListId(), + $allocation->getIdx(), + ); + + $this->assertTrue($entry?->isAllocated()); + $this->assertSame('https://op.example.org/vc/counter', $entry?->getCredentialId()); + } + + /** + * The most common way to lose the race is on a cold start, where the winner is still seeding at the + * moment the loser's insert fails -- that is *why* it failed. Giving up on the winner at that point + * and starting the next generation would give the pool two half empty lists and each of them half + * the herd, so the loser waits for the winner to open instead. + * + * @throws \Exception + */ + public function testWaitsForAWinnerWhichIsStillSeedingWhenItsOwnInsertFails(): void + { + $fingerprint = $this->pool()->getPolicyFingerprint($this->signingKeyId); + + // The winner's list: inserted, entries written, not yet opened. + $this->statusListRepository->create( + 'winner', + 'https://op.example.org/module.php/oidc/statuslist/winner', + self::POOL_ID, + $fingerprint, + 1, + 1, + self::CAPACITY, + '0,1', + 43200, + 604800, + 3600, + $this->signingKeyId, + StatusListKeyProfileEnum::DidJwk, + ); + $this->statusListEntryRepository->seed('winner', self::CAPACITY); + + $repositoryStub = new class ( + $this->createMock(ModuleConfig::class), + $this->database, + $this->createMock(ProtocolCache::class), + new Helpers(), + ) extends StatusListRepository { + public int $activeLookups = 0; + + public function findBeingPreparedForPolicy( + string $poolId, + string $policyFingerprint, + \DateTimeImmutable $createdAfter, + ?int $belowGeneration = null, + ): array { + // Hidden from the check before creating, so this request goes ahead and tries to insert + // the same generation the winner already took. + if ($belowGeneration === null && $this->activeLookups < 1) { + return []; + } + + return parent::findBeingPreparedForPolicy( + $poolId, + $policyFingerprint, + $createdAfter, + $belowGeneration, + ); + } + + public function findActiveForPolicy(string $poolId, string $policyFingerprint): array + { + $this->activeLookups++; + + // The winner finishes seeding partway through this request's wait. + if ($this->activeLookups >= 3) { + $this->activate('winner'); + } + + return parent::findActiveForPolicy($poolId, $policyFingerprint); + } + + public function create( + string $id, + string $uri, + string $poolId, + string $policyFingerprint, + int $generation, + int $bits, + int $capacity, + string $allowedStatuses, + int $ttlSeconds, + int $tokenValiditySeconds, + int $refreshIntervalSeconds, + string $signingKeyId, + StatusListKeyProfileEnum $keyProfile, + ): void { + throw new \Exception('Database error: duplicate generation.'); + } + }; + + $allocator = new DbStatusIndexAllocator( + $repositoryStub, + $this->statusListEntryRepository, + $this->keyResolverMock, + new TokenStatusList(), + $this->routesMock, + new Helpers(), + $this->loggerServiceMock, + ); + + $allocation = $allocator->allocateFor( + $this->pool(), + 'https://op.example.org/vc/loser', + self::CREDENTIAL_CONFIGURATION_ID, + ); + + $this->assertSame('winner', $allocation->getStatusListId()); + $this->assertCount(1, $this->statusListRows(), 'The pool should have converged on one list.'); + } + + /** + * When the insert fails and there is genuinely no other list, there is nothing to fall back to. + * + * @throws \Exception + */ + public function testRaisesWhenItsInsertFailsAndNoOtherListExists(): void + { + $repositoryStub = new class ( + $this->createMock(ModuleConfig::class), + $this->database, + $this->createMock(ProtocolCache::class), + new Helpers(), + ) extends StatusListRepository { + public function create( + string $id, + string $uri, + string $poolId, + string $policyFingerprint, + int $generation, + int $bits, + int $capacity, + string $allowedStatuses, + int $ttlSeconds, + int $tokenValiditySeconds, + int $refreshIntervalSeconds, + string $signingKeyId, + StatusListKeyProfileEnum $keyProfile, + ): void { + throw new \Exception('Database error: disk full.'); + } + }; + + $allocator = new DbStatusIndexAllocator( + $repositoryStub, + $this->statusListEntryRepository, + $this->keyResolverMock, + new TokenStatusList(), + $this->routesMock, + new Helpers(), + $this->loggerServiceMock, + ); + + $this->expectException(StatusListException::class); + + $allocator->allocateFor( + $this->pool(), + 'https://op.example.org/vc/x', + self::CREDENTIAL_CONFIGURATION_ID, + ); + } + + /** + * @throws \Exception + */ + public function testCountsAllocationsForTheAdvisoryCounter(): void + { + $allocation = $this->allocate('https://op.example.org/vc/first'); + + $this->assertSame( + 1, + $this->statusListRepository->findByIdOnPrimary($allocation->getStatusListId())?->getAllocatedCount(), + ); + $this->assertSame(1, $this->statusListEntryRepository->countAllocated($allocation->getStatusListId())); + } + + /** + * Only a failure to obtain any list at all is an error. + * + * @throws \Exception + */ + public function testRaisesWhenNoListCanBeObtained(): void + { + $keyResolverMock = $this->createMock(StatusListKeyResolver::class); + $keyResolverMock->method('getCurrentKeyId') + ->willThrowException(new StatusListException('No signing key.')); + + $allocator = new DbStatusIndexAllocator( + $this->statusListRepository, + $this->statusListEntryRepository, + $keyResolverMock, + new TokenStatusList(), + $this->routesMock, + new Helpers(), + $this->loggerServiceMock, + ); + + $this->expectException(StatusListException::class); + + $allocator->allocateFor($this->pool(), 'https://op.example.org/vc/x', self::CREDENTIAL_CONFIGURATION_ID); + } +} diff --git a/tests/unit/src/StatusList/DbStatusUpdaterTest.php b/tests/unit/src/StatusList/DbStatusUpdaterTest.php new file mode 100644 index 00000000..2ae02e48 --- /dev/null +++ b/tests/unit/src/StatusList/DbStatusUpdaterTest.php @@ -0,0 +1,429 @@ + 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ], + '', + 'simplesaml', + ); + + (new DatabaseMigration())->migrate(); + } + + /** + * @throws \Exception + */ + protected function setUp(): void + { + $this->database = Database::getInstance(); + $this->database->write('DELETE FROM ' . $this->database->applyPrefix('oidc_status_list_entry')); + $this->database->write('DELETE FROM ' . $this->database->applyPrefix('oidc_status_list')); + + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $helpers = new Helpers(); + + $this->statusListRepository = new StatusListRepository( + $moduleConfigMock, + $this->database, + $protocolCacheMock, + $helpers, + ); + $this->statusListEntryRepository = new StatusListEntryRepository( + $moduleConfigMock, + $this->database, + $protocolCacheMock, + $helpers, + ); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + } + + protected function sut(): DbStatusUpdater + { + return new DbStatusUpdater( + $this->statusListRepository, + $this->statusListEntryRepository, + $this->loggerServiceMock, + ); + } + + /** + * @throws \Exception + */ + protected function givenList(int $bits = 2, string $allowedStatuses = '0,1,2'): void + { + $this->statusListRepository->create( + self::LIST_ID, + 'https://op.example.org/module.php/oidc/statuslist/' . self::LIST_ID, + 'pool', + 'fingerprint', + 1, + $bits, + self::CAPACITY, + $allowedStatuses, + 43200, + 604800, + 3600, + 'signing-key-1', + StatusListKeyProfileEnum::DidJwk, + ); + + $this->statusListEntryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->statusListRepository->activate(self::LIST_ID); + } + + /** + * @throws \Exception + */ + protected function givenAllocatedEntry(int $idx = 3): void + { + $this->statusListEntryRepository->allocate( + self::LIST_ID, + $idx, + 'https://op.example.org/vc/abc', + $this->statusListEntryRepository->hashCredentialId('https://op.example.org/vc/abc'), + 'TestCredential', + null, + null, + ); + } + + /** + * @throws \Exception + */ + protected function givenPublishedToken(string $contentHash = 'published-hash'): void + { + $this->database->write( + 'UPDATE ' . $this->database->applyPrefix('oidc_status_list') . + ' SET signed_token = :token, signed_token_content_hash = :hash WHERE id = :id', + ['token' => 'a.b.c', 'hash' => $contentHash, 'id' => self::LIST_ID], + ); + } + + /** + * @throws \Exception + */ + public function testChangesTheStatusOfAnAllocatedEntry(): void + { + $this->givenList(); + $this->givenAllocatedEntry(); + + $this->assertTrue($this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid)); + + $this->assertSame( + StatusTypeEnum::Invalid->value, + $this->statusListEntryRepository->findByListAndIdx(self::LIST_ID, 3)?->getStatus(), + ); + $this->assertSame(StatusTypeEnum::Invalid->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); + } + + /** + * The published token has to stop counting as current the moment the content it was signed over + * changes, or the endpoint would keep serving a token reporting the old status. + * + * @throws \Exception + */ + public function testInvalidatesThePublishedTokenAfterAChange(): void + { + $this->givenList(); + $this->givenAllocatedEntry(); + $this->givenPublishedToken(); + + $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid); + + $this->assertSame( + '', + $this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->getSignedTokenContentHash(), + ); + } + + /** + * Repeating a revocation is expected to be harmless, and must not cost a re-sign of the whole list. + * + * @throws \Exception + */ + public function testSettingTheStatusItAlreadyHoldsChangesNothing(): void + { + $this->givenList(); + $this->givenAllocatedEntry(); + $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid); + $this->givenPublishedToken(); + + $this->assertFalse($this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid)); + + // The published token is left alone, since what it was signed over did not change. + $this->assertSame( + 'published-hash', + $this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->getSignedTokenContentHash(), + ); + } + + /** + * An index which was never handed out does not describe any credential, so setting its status would + * be making a statement about something which does not exist. + * + * @throws \Exception + */ + public function testRefusesToChangeAnUnallocatedEntry(): void + { + $this->givenList(); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('never allocated'); + + $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid); + } + + /** + * @throws \Exception + */ + public function testReportsNoStatusForAnUnallocatedEntry(): void + { + $this->givenList(); + + $this->assertNull($this->sut()->getStatusValue(self::LIST_ID, 3)); + } + + /** + * @throws \Exception + */ + public function testRaisesForAnIndexOutsideTheList(): void + { + $this->givenList(); + + $this->expectException(StatusListException::class); + + $this->sut()->setStatus(self::LIST_ID, self::CAPACITY + 1, StatusTypeEnum::Invalid); + } + + /** + * @throws \Exception + */ + public function testRaisesForAListWhichDoesNotExist(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('not found'); + + $this->sut()->setStatus('no-such-list', 0, StatusTypeEnum::Invalid); + } + + /** + * The number of bits per entry is fixed when a list is created and can not be retrofitted, so a + * status which does not fit is a permanent property of that list rather than a transient failure. + * + * @throws \Exception + */ + public function testRefusesAStatusWhichDoesNotFitTheListsBits(): void + { + $this->givenList(1, '0,1'); + $this->givenAllocatedEntry(); + + $this->expectException(UnsupportedStatusException::class); + $this->expectExceptionMessage('Suspended'); + + $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Suspended); + } + + /** + * A list records the statuses it was created allowing, so widening a pool later does not widen + * lists which already exist. + * + * @throws \Exception + */ + public function testRefusesAStatusTheListWasNotCreatedAllowing(): void + { + // Room for the value, but it was not among the statuses this list was created for. + $this->givenList(2, '0,1'); + $this->givenAllocatedEntry(); + + $this->expectException(UnsupportedStatusException::class); + + $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Suspended); + } + + /** + * @throws \Exception + */ + public function testAllowsSuspensionOnAListCreatedForIt(): void + { + $this->givenList(2, '0,1,2'); + $this->givenAllocatedEntry(); + + $this->assertTrue($this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Suspended)); + $this->assertSame(StatusTypeEnum::Suspended->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); + } + + /** + * @throws \Exception + */ + public function testCanReinstateARevokedEntry(): void + { + $this->givenList(); + $this->givenAllocatedEntry(); + + $this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid); + + $this->assertTrue($this->sut()->setStatus(self::LIST_ID, 3, StatusTypeEnum::Valid)); + $this->assertSame(StatusTypeEnum::Valid->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); + } + + /** + * A change which lands between another caller's read and its write must stand, rather than being + * silently overwritten by the value that caller had already decided on. + * + * @throws \Exception + */ + public function testDoesNotOverwriteAChangeMadeSinceTheStatusWasRead(): void + { + $this->givenList(); + $this->givenAllocatedEntry(); + + // Someone observed Valid; by the time they write, the entry has been revoked. + $wasApplied = $this->statusListEntryRepository->updateStatus( + self::LIST_ID, + 3, + StatusTypeEnum::Invalid->value, + StatusTypeEnum::Suspended->value, + ); + + $this->assertFalse($wasApplied); + $this->assertSame(StatusTypeEnum::Valid->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); + } + + /** + * Losing one round of the compare-and-set is not a failure: the retry reads what actually got + * there and applies the change on top, so the caller still ends up with what it asked for. + * + * @throws \Exception + */ + public function testRetriesAgainstTheValueAnotherChangeLeftBehind(): void + { + $this->givenList(); + $this->givenAllocatedEntry(); + + $entryRepositoryStub = new class ( + $this->createMock(ModuleConfig::class), + $this->database, + $this->createMock(ProtocolCache::class), + new Helpers(), + ) extends StatusListEntryRepository { + public int $updateAttempts = 0; + + public function updateStatus( + string $statusListId, + int $idx, + int $observedStatus, + int $newStatus, + ): bool { + $this->updateAttempts++; + + // Stands in for another request changing the entry between this one's read and write. + if ($this->updateAttempts === 1) { + parent::updateStatus($statusListId, $idx, $observedStatus, StatusTypeEnum::Suspended->value); + + return false; + } + + return parent::updateStatus($statusListId, $idx, $observedStatus, $newStatus); + } + }; + + $updater = new DbStatusUpdater( + $this->statusListRepository, + $entryRepositoryStub, + $this->loggerServiceMock, + ); + + $this->assertTrue($updater->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid)); + $this->assertSame(2, $entryRepositoryStub->updateAttempts); + $this->assertSame(StatusTypeEnum::Invalid->value, $this->sut()->getStatusValue(self::LIST_ID, 3)); + } + + /** + * Losing every round has to be reported as a conflict rather than as the no-op that a false return + * value means, since the entry ends up holding somebody else's value rather than the requested one. + * + * @throws \Exception + */ + public function testRaisesAConflictWhenItKeepsLosingRatherThanReportingANoOp(): void + { + $this->givenList(); + $this->givenAllocatedEntry(); + + $entryRepositoryStub = new class ( + $this->createMock(ModuleConfig::class), + $this->database, + $this->createMock(ProtocolCache::class), + new Helpers(), + ) extends StatusListEntryRepository { + public function updateStatus( + string $statusListId, + int $idx, + int $observedStatus, + int $newStatus, + ): bool { + return false; + } + }; + + $updater = new DbStatusUpdater( + $this->statusListRepository, + $entryRepositoryStub, + $this->loggerServiceMock, + ); + + $this->expectException(StatusConflictException::class); + + $updater->setStatus(self::LIST_ID, 3, StatusTypeEnum::Invalid); + } +} diff --git a/tests/unit/src/StatusList/SubjectRefHasherTest.php b/tests/unit/src/StatusList/SubjectRefHasherTest.php new file mode 100644 index 00000000..23171273 --- /dev/null +++ b/tests/unit/src/StatusList/SubjectRefHasherTest.php @@ -0,0 +1,91 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getEncryptionKey')->willReturn('a-secret-salt'); + } + + protected function sut(?ModuleConfig $moduleConfig = null): SubjectRefHasher + { + return new SubjectRefHasher($moduleConfig ?? $this->moduleConfigMock); + } + + public function testProducesAValueSizedForItsColumn(): void + { + $hash = $this->sut()->hash('student@example.org'); + + $this->assertSame(64, strlen($hash)); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash); + } + + public function testIsStableForTheSameIdentifier(): void + { + $this->assertSame( + $this->sut()->hash('student@example.org'), + $this->sut()->hash('student@example.org'), + ); + } + + public function testDistinguishesIdentifiers(): void + { + $this->assertNotSame( + $this->sut()->hash('student@example.org'), + $this->sut()->hash('teacher@example.org'), + ); + } + + /** + * The point of keying the hash is that an identifier with little entropy, such as an email address, + * can not be confirmed by guessing it and hashing. Whoever holds the database but not the key must + * get nothing, so a different key has to produce a different value for the same identifier. + */ + public function testDependsOnTheKeyAndNotOnlyOnTheIdentifier(): void + { + $otherConfig = $this->createMock(ModuleConfig::class); + $otherConfig->method('getEncryptionKey')->willReturn('a-different-secret-salt'); + + $this->assertNotSame( + $this->sut()->hash('student@example.org'), + $this->sut($otherConfig)->hash('student@example.org'), + ); + } + + /** + * A plain SHA-256 of the identifier is exactly what this must not be. + */ + public function testIsNotAnUnkeyedDigestOfTheIdentifier(): void + { + $this->assertNotSame( + hash('sha256', 'student@example.org'), + $this->sut()->hash('student@example.org'), + ); + } + + /** + * Deriving the key from the module's encryption key means there is no separate secret to manage, + * but it must not be usable as, or derivable back to, that key. + */ + public function testDoesNotKeyTheHashWithTheEncryptionKeyDirectly(): void + { + $this->assertNotSame( + hash_hmac('sha256', 'student@example.org', 'a-secret-salt'), + $this->sut()->hash('student@example.org'), + ); + } +} diff --git a/tests/unit/src/StatusList/Values/StatusListPoolBagTest.php b/tests/unit/src/StatusList/Values/StatusListPoolBagTest.php new file mode 100644 index 00000000..c2d8b9d6 --- /dev/null +++ b/tests/unit/src/StatusList/Values/StatusListPoolBagTest.php @@ -0,0 +1,103 @@ + $config + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function sut(array $config): StatusListPoolBag + { + return StatusListPoolBag::fromConfig($config, StatusListKeyProfileEnum::DidJwk); + } + + public function testAnEmptyConfigurationYieldsAnEmptyBag(): void + { + $bag = $this->sut([]); + + $this->assertTrue($bag->isEmpty()); + $this->assertSame([], $bag->getAll()); + $this->assertNull($bag->getForCredentialConfigurationId('Anything')); + } + + public function testResolvesACredentialConfigurationToItsPool(): void + { + $bag = $this->sut([ + 'degrees' => [StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['UniversityDegree', 'Diploma']], + 'badges' => [StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['EmployeeBadge']], + ]); + + $this->assertFalse($bag->isEmpty()); + $this->assertCount(2, $bag->getAll()); + $this->assertSame('degrees', $bag->getForCredentialConfigurationId('Diploma')?->getId()); + $this->assertSame('badges', $bag->getForCredentialConfigurationId('EmployeeBadge')?->getId()); + $this->assertSame('degrees', $bag->getById('degrees')?->getId()); + } + + /** + * A configuration in no pool is not an error: its credentials are simply issued without a status + * claim, and so can not be revoked. + */ + public function testACredentialConfigurationInNoPoolResolvesToNothing(): void + { + $bag = $this->sut([ + 'degrees' => [StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['UniversityDegree']], + ]); + + $this->assertNull($bag->getForCredentialConfigurationId('EmployeeBadge')); + } + + /** + * Allocation needs one answer to which policy a credential is issued under, so two pools claiming + * the same configuration is a configuration error rather than something to resolve by precedence. + */ + public function testRejectsACredentialConfigurationListedInTwoPools(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('UniversityDegree'); + + $this->sut([ + 'degrees' => [StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['UniversityDegree']], + 'others' => [StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['UniversityDegree']], + ]); + } + + public function testRejectsAPoolWhoseSettingsAreNotAnArray(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(['degrees' => 'UniversityDegree']); + } + + public function testRejectsAPoolWithoutAnIdentifier(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut([[StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['UniversityDegree']]]); + } + + public function testListsEveryCredentialConfigurationItCovers(): void + { + $bag = $this->sut([ + 'degrees' => [StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['UniversityDegree', 'Diploma']], + 'badges' => [StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['EmployeeBadge']], + ]); + + $covered = $bag->getAllCredentialConfigurationIds(); + sort($covered); + + $this->assertSame(['Diploma', 'EmployeeBadge', 'UniversityDegree'], $covered); + } +} diff --git a/tests/unit/src/StatusList/Values/StatusListPoolTest.php b/tests/unit/src/StatusList/Values/StatusListPoolTest.php new file mode 100644 index 00000000..b922fed7 --- /dev/null +++ b/tests/unit/src/StatusList/Values/StatusListPoolTest.php @@ -0,0 +1,386 @@ + $overrides + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function sut( + array $overrides = [], + StatusListKeyProfileEnum $defaultKeyProfile = StatusListKeyProfileEnum::DidJwk, + ): StatusListPool { + return StatusListPool::fromConfig( + self::POOL_ID, + array_merge( + [StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['SomeCredential']], + $overrides, + ), + $defaultKeyProfile, + ); + } + + public function testAppliesDefaultsForEverythingNotConfigured(): void + { + $pool = $this->sut(); + + $this->assertSame(StatusListPool::DEFAULT_BITS, $pool->getBits()); + $this->assertSame(StatusListPool::DEFAULT_CAPACITY, $pool->getCapacity()); + $this->assertSame(43200, $pool->getTtlInSeconds()); + $this->assertSame(604800, $pool->getTokenValidityInSeconds()); + $this->assertSame(3600, $pool->getRefreshIntervalInSeconds()); + $this->assertSame(StatusListKeyProfileEnum::DidJwk, $pool->getKeyProfile()); + $this->assertSame(['SomeCredential'], $pool->getCredentialConfigurationIds()); + } + + public function testDefaultCapacityIsDivisibleByEight(): void + { + // The specification recommends this for the list size, and it is what keeps the number of + // indices the list conveys a status for equal to the capacity which was asked for. + $this->assertSame(0, StatusListPool::DEFAULT_CAPACITY % 8); + } + + public function testTakesTheGlobalKeyProfileAndAllowsAPoolToOverrideIt(): void + { + $this->assertSame( + StatusListKeyProfileEnum::Jwks, + $this->sut([], StatusListKeyProfileEnum::Jwks)->getKeyProfile(), + ); + + $this->assertSame( + StatusListKeyProfileEnum::Jwks, + $this->sut( + [StatusListPool::KEY_KEY_PROFILE => StatusListKeyProfileEnum::Jwks], + StatusListKeyProfileEnum::DidJwk, + )->getKeyProfile(), + ); + + // Also accepted as its string value, which is how a hand written config is likely to spell it. + $this->assertSame( + StatusListKeyProfileEnum::Jwks, + $this->sut( + [StatusListPool::KEY_KEY_PROFILE => 'jwks'], + StatusListKeyProfileEnum::DidJwk, + )->getKeyProfile(), + ); + } + + public function testRejectsAnUnknownKeyProfile(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage(StatusListPool::KEY_KEY_PROFILE); + + $this->sut([StatusListPool::KEY_KEY_PROFILE => 'x509']); + } + + public function testRejectsAPoolWithNoCredentialConfigurations(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage(StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS); + + StatusListPool::fromConfig(self::POOL_ID, [], StatusListKeyProfileEnum::DidJwk); + } + + /** + * @return array + */ + public static function invalidBitsProvider(): array + { + return ['zero' => [0], 'three' => [3], 'five' => [5], 'sixteen' => [16], 'negative' => [-1]]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidBitsProvider')] + public function testRejectsBitsWhichAreNotOneOfTheAllowedValues(int $bits): void + { + $this->expectException(ConfigurationError::class); + + $this->sut([StatusListPool::KEY_BITS => $bits]); + } + + public function testRejectsACapacityWhichIsNotAPositiveMultipleOfEight(): void + { + $this->expectException(ConfigurationError::class); + $this->sut([StatusListPool::KEY_CAPACITY => 100]); + } + + public function testRejectsANonPositiveCapacity(): void + { + $this->expectException(ConfigurationError::class); + $this->sut([StatusListPool::KEY_CAPACITY => 0]); + } + + /** + * The number of bits fixes the largest status a list can ever carry, and reconfiguring it later + * can not retrofit lists which already exist. A pool which may suspend has to say so up front. + */ + public function testRejectsAStatusWhichDoesNotFitTheConfiguredBits(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Suspended'); + + $this->sut([ + StatusListPool::KEY_BITS => 1, + StatusListPool::KEY_ALLOWED_STATUSES => [StatusTypeEnum::Suspended], + ]); + } + + public function testAcceptsSuspendedOnceThereAreEnoughBits(): void + { + $pool = $this->sut([ + StatusListPool::KEY_BITS => 2, + StatusListPool::KEY_ALLOWED_STATUSES => [StatusTypeEnum::Invalid, StatusTypeEnum::Suspended], + ]); + + $this->assertTrue($pool->isStatusAllowed(StatusTypeEnum::Suspended)); + } + + /** + * An entry which can be revoked has to be able to be reinstated, and an index which was never + * allocated reads as Valid regardless of configuration. + */ + public function testAlwaysAllowsValidEvenWhenItWasNotConfigured(): void + { + $pool = $this->sut([StatusListPool::KEY_ALLOWED_STATUSES => [StatusTypeEnum::Invalid]]); + + $this->assertTrue($pool->isStatusAllowed(StatusTypeEnum::Valid)); + $this->assertSame('0,1', $pool->getAllowedStatusesAsString()); + } + + public function testAcceptsAStatusGivenAsItsRegisteredIntegerValue(): void + { + $pool = $this->sut([ + StatusListPool::KEY_BITS => 2, + StatusListPool::KEY_ALLOWED_STATUSES => [1, 2], + ]); + + $this->assertTrue($pool->isStatusAllowed(StatusTypeEnum::Suspended)); + $this->assertSame('0,1,2', $pool->getAllowedStatusesAsString()); + } + + /** + * Casting a string to an integer turns every typo into 0, which is Valid, so a misspelt status + * would silently configure the pool to allow nothing rather than being reported. + */ + public function testRejectsAStatusGivenAsAString(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut([StatusListPool::KEY_ALLOWED_STATUSES => ['invalid']]); + } + + public function testRejectsAnUnregisteredStatusValue(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut([StatusListPool::KEY_BITS => 4, StatusListPool::KEY_ALLOWED_STATUSES => [7]]); + } + + /** + * Getting this the wrong way round leaves a recurring window in every cycle where the published + * token has expired and its replacement has not been produced yet. + */ + public function testRejectsARefreshIntervalWhichDoesNotFitInsideTheTokenValidity(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage(StatusListPool::KEY_TOKEN_VALIDITY); + + $this->sut([ + StatusListPool::KEY_REFRESH_INTERVAL => 'PT1H', + StatusListPool::KEY_TOKEN_VALIDITY => 'PT1H', + ]); + } + + public function testRejectsARefreshIntervalLeavingLessThanTheSafetyMargin(): void + { + $this->expectException(ConfigurationError::class); + + // Ten minutes of headroom, where the safety margin asks for fifteen. + $this->sut([ + StatusListPool::KEY_REFRESH_INTERVAL => 'PT50M', + StatusListPool::KEY_TOKEN_VALIDITY => 'PT1H', + ]); + } + + public function testAcceptsARefreshIntervalWithEnoughHeadroom(): void + { + $pool = $this->sut([ + StatusListPool::KEY_REFRESH_INTERVAL => 'PT30M', + StatusListPool::KEY_TOKEN_VALIDITY => 'PT2H', + ]); + + $this->assertSame(1800, $pool->getRefreshIntervalInSeconds()); + } + + public function testRejectsAnUnparsableDuration(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut([StatusListPool::KEY_TTL => 'twelve hours']); + } + + public function testRejectsANonIntegerBitsValue(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut([StatusListPool::KEY_BITS => '2']); + } + + public function testTellsWhichCredentialConfigurationsItServes(): void + { + $pool = $this->sut([ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['A', 'B', 'A'], + ]); + + $this->assertSame(['A', 'B'], $pool->getCredentialConfigurationIds()); + $this->assertTrue($pool->hasCredentialConfigurationId('B')); + $this->assertFalse($pool->hasCredentialConfigurationId('C')); + } + + public function testPolicyFingerprintIsStableForTheSamePolicy(): void + { + $this->assertSame( + $this->sut()->getPolicyFingerprint(self::KEY_ID), + $this->sut()->getPolicyFingerprint(self::KEY_ID), + ); + } + + /** + * A duration has no length until something anchors it, and anchoring it to "now" in a timezone + * which observes daylight saving makes P7D worth an hour more or less at certain times of year. + * That number goes into the policy fingerprint, so an unchanged configuration would fingerprint + * differently across a transition and quietly move every pool onto fresh lists. + */ + public function testDurationsDoNotDependOnTheServerTimezoneOrTheCurrentDate(): void + { + $originalTimezone = date_default_timezone_get(); + + try { + $seenTtl = []; + $seenValidity = []; + + // Zones on both sides of UTC, one of which is deep in a daylight saving change window. + foreach (['UTC', 'Europe/Zagreb', 'America/Santiago', 'Pacific/Chatham'] as $timezone) { + date_default_timezone_set($timezone); + + $pool = $this->sut(); + $seenTtl[] = $pool->getTtlInSeconds(); + $seenValidity[] = $pool->getTokenValidityInSeconds(); + } + + $this->assertSame([43200, 43200, 43200, 43200], $seenTtl); + $this->assertSame([604800, 604800, 604800, 604800], $seenValidity); + } finally { + date_default_timezone_set($originalTimezone); + } + } + + /** + * The same, seen through the value which actually matters: the fingerprint allocation filters on. + */ + public function testPolicyFingerprintDoesNotDependOnTheServerTimezone(): void + { + $originalTimezone = date_default_timezone_get(); + + try { + date_default_timezone_set('UTC'); + $inUtc = $this->sut()->getPolicyFingerprint(self::KEY_ID); + + date_default_timezone_set('America/Santiago'); + $inSantiago = $this->sut()->getPolicyFingerprint(self::KEY_ID); + + $this->assertSame($inUtc, $inSantiago); + } finally { + date_default_timezone_set($originalTimezone); + } + } + + /** + * @return array}> + */ + public static function policyChangingOverrideProvider(): array + { + return [ + 'bits' => [[StatusListPool::KEY_BITS => 2]], + 'capacity' => [[StatusListPool::KEY_CAPACITY => 256]], + 'ttl' => [[StatusListPool::KEY_TTL => 'PT6H']], + 'token validity' => [[StatusListPool::KEY_TOKEN_VALIDITY => 'P14D']], + 'allowed statuses' => [[ + StatusListPool::KEY_BITS => 2, + StatusListPool::KEY_ALLOWED_STATUSES => [StatusTypeEnum::Suspended], + ]], + 'key profile' => [[StatusListPool::KEY_KEY_PROFILE => StatusListKeyProfileEnum::Jwks]], + ]; + } + + /** + * @param array $override + */ + #[\PHPUnit\Framework\Attributes\DataProvider('policyChangingOverrideProvider')] + public function testPolicyFingerprintChangesWithAnySettingBakedIntoALists(array $override): void + { + $this->assertNotSame( + $this->sut()->getPolicyFingerprint(self::KEY_ID), + $this->sut($override)->getPolicyFingerprint(self::KEY_ID), + ); + } + + /** + * During a key rotation the issuer signs credentials with the current key, so a list still bound to + * the previous one must stop being selected, or the profile saying the two are the same key breaks. + */ + public function testPolicyFingerprintChangesWithTheSigningKey(): void + { + $this->assertNotSame( + $this->sut()->getPolicyFingerprint(self::KEY_ID), + $this->sut()->getPolicyFingerprint('signing-key-2'), + ); + } + + /** + * The refresh interval governs when a token is re-signed, not what any credential resolves to, so + * changing it must not strand a half filled list. + */ + public function testPolicyFingerprintIgnoresTheRefreshInterval(): void + { + $this->assertSame( + $this->sut([StatusListPool::KEY_REFRESH_INTERVAL => 'PT1H']) + ->getPolicyFingerprint(self::KEY_ID), + $this->sut([StatusListPool::KEY_REFRESH_INTERVAL => 'PT30M']) + ->getPolicyFingerprint(self::KEY_ID), + ); + } + + /** + * The pool a credential belongs to is not part of what a list carries, but two pools sharing a + * fingerprint would let one pool's credentials be allocated into the other's list. + */ + public function testPolicyFingerprintIsNotSharedBetweenPoolsWithDifferentAllowedStatuses(): void + { + $narrow = $this->sut([StatusListPool::KEY_BITS => 2]); + $wide = $this->sut([ + StatusListPool::KEY_BITS => 2, + StatusListPool::KEY_ALLOWED_STATUSES => [StatusTypeEnum::Invalid, StatusTypeEnum::Suspended], + ]); + + $this->assertNotSame( + $narrow->getPolicyFingerprint(self::KEY_ID), + $wide->getPolicyFingerprint(self::KEY_ID), + ); + } +} From fd78470b9d786f0d02dad69891830d23c7da59a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 4 Aug 2026 10:10:52 +0200 Subject: [PATCH 3/9] Publish Status List Tokens and serve them from an endpoint --- config/module_oidc.php.dist | 24 + hooks/hook_cron.php | 23 + locales/en/LC_MESSAGES/oidc.po | 19 + locales/es/LC_MESSAGES/oidc.po | 19 + locales/fr/LC_MESSAGES/oidc.po | 19 + locales/hr/LC_MESSAGES/oidc.po | 19 + locales/it/LC_MESSAGES/oidc.po | 19 + locales/nl/LC_MESSAGES/oidc.po | 19 + routing/routes/routes.php | 7 + routing/services/services.yml | 7 + .../ConfigOverview/VciOverviewBuilder.php | 26 + src/Controllers/StatusListController.php | 220 ++++++++ src/ModuleConfig.php | 32 ++ src/Repositories/StatusListRepository.php | 181 ++++++- src/Services/DatabaseMigration.php | 83 +++ .../StatusListTokenProviderInterface.php | 27 + src/StatusList/DbStatusListTokenProvider.php | 348 ++++++++++++ src/StatusList/StatusListContentHasher.php | 68 +++ src/StatusList/StatusListRateLimiter.php | 91 ++++ src/StatusList/StatusListReconciler.php | 151 ++++++ .../Values/DatabaseRowValuesTrait.php | 15 + .../StatusListReconciliationCandidate.php | 69 +++ src/StatusList/Values/StatusListRecord.php | 17 + .../Values/StatusListTokenResult.php | 83 +++ src/Utils/HttpContentNegotiator.php | 196 +++++++ .../src/StatusList/StatusListStorageTest.php | 291 ++++++++++ .../Controllers/StatusListControllerTest.php | 297 ++++++++++ .../DbStatusListTokenProviderTest.php | 506 ++++++++++++++++++ .../StatusListContentHasherTest.php | 98 ++++ .../StatusList/StatusListRateLimiterTest.php | 162 ++++++ .../StatusList/StatusListReconcilerTest.php | 197 +++++++ .../Values/StatusListTokenResultTest.php | 88 +++ .../src/Utils/HttpContentNegotiatorTest.php | 131 +++++ 33 files changed, 3548 insertions(+), 4 deletions(-) create mode 100644 src/Controllers/StatusListController.php create mode 100644 src/StatusList/Contracts/StatusListTokenProviderInterface.php create mode 100644 src/StatusList/DbStatusListTokenProvider.php create mode 100644 src/StatusList/StatusListContentHasher.php create mode 100644 src/StatusList/StatusListRateLimiter.php create mode 100644 src/StatusList/StatusListReconciler.php create mode 100644 src/StatusList/Values/StatusListReconciliationCandidate.php create mode 100644 src/StatusList/Values/StatusListTokenResult.php create mode 100644 src/Utils/HttpContentNegotiator.php create mode 100644 tests/unit/src/Controllers/StatusListControllerTest.php create mode 100644 tests/unit/src/StatusList/DbStatusListTokenProviderTest.php create mode 100644 tests/unit/src/StatusList/StatusListContentHasherTest.php create mode 100644 tests/unit/src/StatusList/StatusListRateLimiterTest.php create mode 100644 tests/unit/src/StatusList/StatusListReconcilerTest.php create mode 100644 tests/unit/src/StatusList/Values/StatusListTokenResultTest.php create mode 100644 tests/unit/src/Utils/HttpContentNegotiatorTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 2f477f27..d46483a6 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1639,6 +1639,30 @@ $config = [ // ], // ], + /** + * (optional) How many requests per minute a single client may make to + * the Status List endpoint. The default, 0, means no limit at all. + * + * The endpoint is unauthenticated and one response can reach a couple of + * hundred kilobytes, so putting a ceiling on how much a single client can + * pull is worth considering. There are two things to check before you do. + * + * First, clients are told apart only by the IP address that reaches PHP + * (REMOTE_ADDR). If a reverse proxy, load balancer or CDN sits in front + * of SimpleSAMLphp, then that address is the proxy's own, and it is the + * same one for every request. All clients would share a single counter, + * which their combined traffic exhausts quickly, and the endpoint would + * start refusing legitimate requests. That matters more here than on + * other endpoints: wallets and verifiers read this endpoint to tell a + * valid credential from a revoked one, so refusing them makes already + * issued credentials unverifiable. Confirm which address actually + * arrives at PHP before setting a limit. + * + * Second, counting requires a protocol cache. If none is configured this + * option has no effect: nothing is counted and no request is refused. + */ +// ModuleConfig::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE => 60, + /** * Map of authentication sources and user's email attribute names. This * enables you to define a specific attribute name which contains the diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php index a823cf35..72b29885 100644 --- a/hooks/hook_cron.php +++ b/hooks/hook_cron.php @@ -19,6 +19,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\ExpiredEntriesCleaner; +use SimpleSAML\Module\oidc\StatusList\StatusListReconciler; /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -58,4 +59,26 @@ function oidc_hook_cron(array &$croninfo): void Logger::warning($message); $croninfo['summary'][] = $message; } + + // Kept apart from the clean-up above rather than folded into the same try. The two are unrelated, + // and a failure of one says nothing about whether the other should run. + try { + $kernel = new Kernel(ModuleConfig::MODULE_NAME); + $kernel->boot(); + /** @var \SimpleSAML\Module\oidc\StatusList\StatusListReconciler $reconciler */ + $reconciler = $kernel->getContainer()->get(StatusListReconciler::class); + $invalidated = $reconciler->reconcile(); + + if ($invalidated > 0) { + $croninfo['summary'][] = sprintf( + 'Module `oidc` Status List reconciliation. Invalidated %d published token(s) which no ' . + 'longer described their list.', + $invalidated, + ); + } + } catch (Throwable $e) { + $message = 'Module `oidc` Status List reconciliation cron script failed: ' . $e->getMessage(); + Logger::warning($message); + $croninfo['summary'][] = $message; + } } diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index d08737c1..a0338f13 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -1591,3 +1591,22 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" + +msgid "Status List Requests Per Minute" +msgstr "" + +msgid "No limit" +msgstr "" + +msgid "" +"Applied to the address the request appears to come from, which behind a " +"reverse proxy is the proxy unless it is trusted. Confirm which address " +"arrives here, since one shared bucket would refuse every client. Needs a " +"protocol cache; without one nothing is counted." +msgstr "" + +msgid "" +"The Status List endpoint accepts any number of requests. It is " +"unauthenticated and its response can reach a couple of hundred " +"kilobytes." +msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index b6261991..54db2540 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -1591,3 +1591,22 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" + +msgid "Status List Requests Per Minute" +msgstr "" + +msgid "No limit" +msgstr "" + +msgid "" +"Applied to the address the request appears to come from, which behind a " +"reverse proxy is the proxy unless it is trusted. Confirm which address " +"arrives here, since one shared bucket would refuse every client. Needs a " +"protocol cache; without one nothing is counted." +msgstr "" + +msgid "" +"The Status List endpoint accepts any number of requests. It is " +"unauthenticated and its response can reach a couple of hundred " +"kilobytes." +msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index b95d0a71..1f9ec229 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -1591,3 +1591,22 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" + +msgid "Status List Requests Per Minute" +msgstr "" + +msgid "No limit" +msgstr "" + +msgid "" +"Applied to the address the request appears to come from, which behind a " +"reverse proxy is the proxy unless it is trusted. Confirm which address " +"arrives here, since one shared bucket would refuse every client. Needs a " +"protocol cache; without one nothing is counted." +msgstr "" + +msgid "" +"The Status List endpoint accepts any number of requests. It is " +"unauthenticated and its response can reach a couple of hundred " +"kilobytes." +msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index d9c04046..34a690d2 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -1639,3 +1639,22 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" + +msgid "Status List Requests Per Minute" +msgstr "" + +msgid "No limit" +msgstr "" + +msgid "" +"Applied to the address the request appears to come from, which behind a " +"reverse proxy is the proxy unless it is trusted. Confirm which address " +"arrives here, since one shared bucket would refuse every client. Needs a " +"protocol cache; without one nothing is counted." +msgstr "" + +msgid "" +"The Status List endpoint accepts any number of requests. It is " +"unauthenticated and its response can reach a couple of hundred " +"kilobytes." +msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index ccf51446..f7aec40f 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -1591,3 +1591,22 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" + +msgid "Status List Requests Per Minute" +msgstr "" + +msgid "No limit" +msgstr "" + +msgid "" +"Applied to the address the request appears to come from, which behind a " +"reverse proxy is the proxy unless it is trusted. Confirm which address " +"arrives here, since one shared bucket would refuse every client. Needs a " +"protocol cache; without one nothing is counted." +msgstr "" + +msgid "" +"The Status List endpoint accepts any number of requests. It is " +"unauthenticated and its response can reach a couple of hundred " +"kilobytes." +msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 55d93859..4a76dd8b 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -1545,3 +1545,22 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" + +msgid "Status List Requests Per Minute" +msgstr "" + +msgid "No limit" +msgstr "" + +msgid "" +"Applied to the address the request appears to come from, which behind a " +"reverse proxy is the proxy unless it is trusted. Confirm which address " +"arrives here, since one shared bucket would refuse every client. Needs a " +"protocol cache; without one nothing is counted." +msgstr "" + +msgid "" +"The Status List endpoint accepts any number of requests. It is " +"unauthenticated and its response can reach a couple of hundred " +"kilobytes." +msgstr "" diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 7aef7e10..7dde72db 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\Controllers\OAuth2\TokenIntrospectionController; use SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController; use SimpleSAML\Module\oidc\Controllers\RegistrationController; +use SimpleSAML\Module\oidc\Controllers\StatusListController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; @@ -160,6 +161,12 @@ ->controller([CredentialJsonLdContextController::class, 'context']) ->methods([HttpMethodsEnum::GET->value]); + // Not registered under the Verifiable Credential Issuance switch, on purpose: credentials already + // issued resolve their status here, so this has to keep answering after issuance is turned off. + $routes->add(RoutesEnum::StatusList->name, RoutesEnum::StatusList->value) + ->controller([StatusListController::class, 'statusList']) + ->methods([HttpMethodsEnum::GET->value]); + /***************************************************************************************************************** * SD-JWT-based Verifiable Credentials (SD-JWT VC) ****************************************************************************************************************/ diff --git a/routing/services/services.yml b/routing/services/services.yml index fdd549b1..ec9ce26b 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -45,6 +45,12 @@ services: alias: SimpleSAML\Module\oidc\StatusList\DbStatusIndexAllocator SimpleSAML\Module\oidc\StatusList\Contracts\StatusUpdaterInterface: alias: SimpleSAML\Module\oidc\StatusList\DbStatusUpdater + SimpleSAML\Module\oidc\StatusList\Contracts\StatusListTokenProviderInterface: + alias: SimpleSAML\Module\oidc\StatusList\DbStatusListTokenProvider + + # Fetched from the (otherwise private) container by the cron hook after booting the module Kernel. + SimpleSAML\Module\oidc\StatusList\StatusListReconciler: + public: true SimpleSAML\Module\oidc\Factories\: resource: '../../src/Factories/*' @@ -123,6 +129,7 @@ services: SimpleSAML\Module\oidc\Utils\UiLocalesResolver: ~ SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~ SimpleSAML\Module\oidc\Utils\DateIntervalFormatter: ~ + SimpleSAML\Module\oidc\Utils\HttpContentNegotiator: ~ SimpleSAML\Module\oidc\Utils\JwksResolver: ~ SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: ~ SimpleSAML\Module\oidc\Utils\VciContextResolver: ~ diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index ba9880f0..1d9be34b 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -149,6 +149,32 @@ function () use ($isEnabled): Row { ); }, ), + $this->guardRow( + Translate::noop('Status List Requests Per Minute'), + ModuleConfig::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE, + function (): Row { + $limit = $this->moduleConfig->getVciStatusListRequestsPerMinute(); + + return new Row( + Translate::noop('Status List Requests Per Minute'), + $limit > 0 ? (string)$limit : Translate::noop('No limit'), + $limit > 0 ? ConfigOverviewValueTypeEnum::RawText : ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE, + $limit > 0 ? + Translate::noop( + 'Applied to the address the request appears to come from, which behind a ' . + 'reverse proxy is the proxy unless it is trusted. Confirm which address ' . + 'arrives here, since one shared bucket would refuse every client. Needs a ' . + 'protocol cache; without one nothing is counted.', + ) : + Translate::noop( + 'The Status List endpoint accepts any number of requests. It is ' . + 'unauthenticated and its response can reach a couple of hundred ' . + 'kilobytes.', + ), + ); + }, + ), ]; return new Section(Translate::noop('Status Lists'), 'statusLists', ...$rows); diff --git a/src/Controllers/StatusListController.php b/src/Controllers/StatusListController.php new file mode 100644 index 00000000..8d988de2 --- /dev/null +++ b/src/Controllers/StatusListController.php @@ -0,0 +1,220 @@ +value; + + /** + * The only content coding offered. + * + * `deflate` is deliberately not offered. RFC 9110 defines it as the zlib format, but enough clients + * historically sent and expected raw DEFLATE that which of the two a given client will accept is not + * knowable from the header alone. gzip has no such ambiguity and is universally supported. + */ + final public const string CONTENT_CODING_GZIP = 'gzip'; + + /** Seconds a client is asked to wait after a request this endpoint could not answer. */ + protected const int RETRY_AFTER_SECONDS = 30; + + public function __construct( + protected readonly StatusListTokenProviderInterface $statusListTokenProvider, + protected readonly HttpContentNegotiator $httpContentNegotiator, + protected readonly StatusListRateLimiter $statusListRateLimiter, + protected readonly Routes $routes, + protected readonly Helpers $helpers, + protected readonly LoggerService $loggerService, + ) { + } + + /** + * @param string $statusListId URL path parameter injected by the router. + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function statusList(Request $request, string $statusListId): Response + { + // Asking for the status as at some past moment is a distinct capability which this issuer does + // not have. Saying so is the point: ignoring the parameter would answer with the current status + // and let the caller believe it was a historical one. + if ($request->query->has('time')) { + return $this->routes->newResponse(null, Response::HTTP_NOT_IMPLEMENTED, $this->baseHeaders()); + } + + if (!$this->statusListRateLimiter->allows($request->getClientIp())) { + return $this->routes->newResponse( + null, + Response::HTTP_TOO_MANY_REQUESTS, + $this->baseHeaders(['Retry-After' => (string)StatusListRateLimiter::WINDOW_SECONDS]), + ); + } + + if (!$this->httpContentNegotiator->acceptsMediaType($request->headers->get('Accept'), self::MEDIA_TYPE)) { + return $this->routes->newResponse(null, Response::HTTP_NOT_ACCEPTABLE, $this->baseHeaders()); + } + + try { + $result = $this->statusListTokenProvider->getToken($statusListId); + } catch (Throwable $throwable) { + // Fail closed. A token which no longer describes its list reports revoked credentials as + // valid, so when one can not be produced the honest answer is that this is unavailable + // right now -- never the last one that was produced. + $this->loggerService->error( + 'Unable to produce a Status List Token, so nothing was served.', + ['statusListId' => $statusListId, 'error' => $throwable->getMessage()], + ); + + return $this->routes->newResponse( + null, + Response::HTTP_SERVICE_UNAVAILABLE, + $this->baseHeaders(['Retry-After' => (string)self::RETRY_AFTER_SECONDS]), + ); + } + + if (!$result instanceof StatusListTokenResult) { + return $this->routes->newResponse(null, Response::HTTP_NOT_FOUND, $this->baseHeaders()); + } + + return $this->respondWith($request, $result); + } + + /** + * @throws \Exception + */ + protected function respondWith(Request $request, StatusListTokenResult $result): Response + { + // Decided before anything is compressed, because the entity tag has to name the representation + // which would be served, and a request answered with 304 must not pay for compressing a body + // that is then thrown away. + // + // Null here means "send it unencoded", and deliberately does not distinguish a client which + // expressed no preference from one which excluded every coding including identity, with + // `*;q=0` or an explicit `identity;q=0`. RFC 9110 would allow answering that second case with + // 406. It is not answered that way on purpose: the request is self-defeating, no real client + // makes it, and the cost of being wrong in that direction is a credential which cannot be + // verified -- whereas the cost of serving the token anyway is nothing at all. + $contentCoding = $this->httpContentNegotiator->preferredContentCoding( + $request->headers->get('Accept-Encoding'), + self::CONTENT_CODING_GZIP, + ); + + $entityTag = $result->getEntityTag($contentCoding); + $now = $this->helpers->dateTime()->getUtc(); + + $headers = $this->baseHeaders([ + 'Cache-Control' => 'public, max-age=' . $result->getMaxAgeSeconds($now), + 'ETag' => $entityTag, + // Accept-Encoding because the body differs by content coding, so a shared cache must not + // hand one client's encoded copy to a client which asked for none. + // + // Accept because this response is publicly cacheable while an Accept which excludes the + // media type is answered with 406. Without it a cache would serve a stored token to a + // request the origin would have refused, so the refusal would hold only for requests which + // reached the origin. It costs some cache efficiency, since Accept varies more widely than + // Accept-Encoding does. + 'Vary' => 'Accept, Accept-Encoding', + ]); + + if ($this->isCurrent($request->headers->get('If-None-Match'), $entityTag)) { + return $this->routes->newResponse(null, Response::HTTP_NOT_MODIFIED, $headers); + } + + $body = $result->getToken(); + $headers['Content-Type'] = self::MEDIA_TYPE; + + if ($contentCoding === self::CONTENT_CODING_GZIP) { + $compressed = gzencode($body); + + // Failing to compress is not a reason to fail the request; the body is simply sent as it is, + // without claiming an encoding it does not have. Announcing a coding not actually applied + // would leave the client unable to read a perfectly good token. + if (is_string($compressed)) { + $body = $compressed; + $headers['Content-Encoding'] = self::CONTENT_CODING_GZIP; + } else { + // The validator named the compressed representation, and this is not it. Leaving it + // would give the same strong tag to two different bodies, so a client which cached this + // one would later be told 304 for the compressed one and keep the wrong bytes. + $headers['ETag'] = $result->getEntityTag(); + } + } + + return $this->routes->newResponse($body, Response::HTTP_OK, $headers); + } + + /** + * Whether the copy the client already holds is the one which would be served. + * + * If-None-Match uses the weak comparison function, so a tag the client received and stored as weak + * still matches the strong tag it came from. + */ + protected function isCurrent(?string $ifNoneMatch, string $entityTag): bool + { + $ifNoneMatch = trim((string)$ifNoneMatch); + + if ($ifNoneMatch === '') { + return false; + } + + if ($ifNoneMatch === '*') { + return true; + } + + foreach (explode(',', $ifNoneMatch) as $candidate) { + $candidate = trim($candidate); + + if (str_starts_with($candidate, 'W/')) { + $candidate = substr($candidate, 2); + } + + if ($candidate === $entityTag) { + return true; + } + } + + return false; + } + + /** + * Headers every response from here carries. + * + * Cross origin reads are allowed on every outcome and not only on success, so that a browser based + * Relying Party can tell a 404 from a 503 rather than seeing both as an opaque network failure. + * + * @param array $headers + * @return array + */ + protected function baseHeaders(array $headers = []): array + { + return array_merge(['Access-Control-Allow-Origin' => '*'], $headers); + } +} diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index e49cf383..4bc4d6c1 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -155,6 +155,7 @@ class ModuleConfig final public const string OPTION_VCI_STATUS_LIST_ENABLED = 'vci_status_list_enabled'; final public const string OPTION_VCI_STATUS_LIST_KEY_PROFILE = 'vci_status_list_key_profile'; final public const string OPTION_VCI_STATUS_LIST_POOLS = 'vci_status_list_pools'; + final public const string OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE = 'vci_status_list_requests_per_minute'; final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; final public const string OPTION_DCR_INITIAL_ACCESS_TOKENS = 'dcr_initial_access_tokens'; @@ -1287,6 +1288,37 @@ public static function hasPrimaryDatabaseReadCapability(): bool return method_exists(Database::class, self::SSP_PRIMARY_READ_METHOD); } + /** + * How many Status List requests one client may make per minute, or 0 for no limit. + * + * Off by default, and deliberately so. The limit can only be applied to whatever the request appears + * to come from, which behind a reverse proxy is the proxy itself unless SimpleSAMLphp has been told + * to trust it -- so a limit switched on by default would, in exactly that common deployment, put + * every client in one bucket and start refusing a public endpoint that credentials in wallets depend + * on. An operator turning this on is stating that they know which address arrives here. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciStatusListRequestsPerMinute(): int + { + $configured = $this->config()->getOptionalInteger( + self::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE, + 0, + ); + + if ($configured < 0) { + throw new ConfigurationError( + sprintf( + 'Option "%s" can not be negative. Use 0 to apply no limit.', + self::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE, + ), + self::DEFAULT_FILE_NAME, + ); + } + + return $configured; + } + /** * Key profile used for Status List Tokens which do not have one set on their own pool. * diff --git a/src/Repositories/StatusListRepository.php b/src/Repositories/StatusListRepository.php index 37ed6767..5411d415 100644 --- a/src/Repositories/StatusListRepository.php +++ b/src/Repositories/StatusListRepository.php @@ -12,6 +12,7 @@ use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\StatusList\Values\StatusListReconciliationCandidate; use SimpleSAML\Module\oidc\StatusList\Values\StatusListRecord; use SimpleSAML\Module\oidc\Utils\ProtocolCache; @@ -401,8 +402,11 @@ public function incrementAllocatedCount(string $id): void * optimistic, whereas invalidating first and crashing before the change would lose the change * entirely. A spurious re-sign is cheap; a lost revocation is not. * - * The guard keeps this a real modification when it matches, so that a driver reporting changed - * rather than matched rows still reports it accurately. + * Unconditional, and the counter is why. Clearing the hash says nothing when it is already clear, + * so a signer which observed the empty hash before this call would still match it afterwards and + * publish a token built before this change -- and since this writer has finished, nothing would + * clear it again. Bumping a counter every time always changes the row, so that signer does not + * match and rebuilds instead. * * @throws \Exception */ @@ -410,14 +414,183 @@ public function invalidatePublishedToken(string $id): void { $this->database->write( sprintf( - "UPDATE %s SET signed_token_content_hash = '' " . - "WHERE id = :id AND signed_token_content_hash <> ''", + "UPDATE %s SET signed_token_content_hash = '', " . + 'invalidation_counter = invalidation_counter + 1 WHERE id = :id', $this->getTableName(), ), ['id' => $id], ); } + /** + * Publishes a freshly signed token, provided the content it was signed over is still the content + * which is published. + * + * The comparison against the hash the signer observed before it began is what makes this + * single-flight: of several requests which all found the token stale and all signed one, the first + * to arrive here matches and the rest do not, so the row never takes a token built from a snapshot + * that a revocation has already superseded. + * + * The invalidation counter is compared alongside the hash and is what makes this sound while the + * hash is empty. An empty hash is both "never published" and "invalidated", so on its own it cannot + * distinguish a signer whose snapshot is still current from one which a revocation superseded after + * it took its snapshot. + * + * @param string $observedContentHash The hash which was on the row when the signer decided to + * re-sign, being '' when there was no published token at that point. + * @param int $observedInvalidationCounter The counter read at the same moment. + * @return bool Whether this token was the one published. False means another request published + * first, or a status changed in between, and the caller must re-read rather than retry blindly. + * @throws \Exception + */ + public function publishToken( + string $id, + string $observedContentHash, + int $observedInvalidationCounter, + string $contentHash, + string $signedToken, + DateTimeImmutable $issuedAt, + DateTimeImmutable $expiresAt, + ): bool { + $params = [ + 'signed_token' => $signedToken, + 'content_hash' => $contentHash, + 'issued_at' => $this->nowForDatabase($issuedAt), + 'expires_at' => $this->nowForDatabase($expiresAt), + 'id' => $id, + 'observed_content_hash' => $observedContentHash, + 'observed_invalidation_counter' => [$observedInvalidationCounter, PDO::PARAM_INT], + ]; + + // Re-signing content which has not changed compares a value against itself, which settles + // nothing: two nodes refreshing the same unchanged list would both match. Requiring the new + // issuance time to be later restores a single winner. + // + // Only on this path, deliberately. Applied to every publication, a node whose clock runs behind + // another's could not publish a content *change* until its clock caught up -- so a revocation + // would sit unpublished for the length of the skew, which is exactly the outcome all of this + // exists to prevent. + $issuedAtGuard = ''; + + if ($observedContentHash === $contentHash) { + $issuedAtGuard = ' AND (signed_token_iat IS NULL OR signed_token_iat < :guard_issued_at)'; + // Under its own name because a repeated placeholder is not portable across drivers. + $params['guard_issued_at'] = $this->nowForDatabase($issuedAt); + } + + $affected = $this->database->write( + sprintf( + 'UPDATE %s SET + signed_token = :signed_token, + signed_token_content_hash = :content_hash, + signed_token_iat = :issued_at, + signed_token_exp = :expires_at + WHERE id = :id + AND signed_token_content_hash = :observed_content_hash + AND invalidation_counter = :observed_invalidation_counter%s', + $this->getTableName(), + $issuedAtGuard, + ), + $params, + ); + + return is_int($affected) && $affected > 0; + } + + /** + * Lists which have a published token, in batches, for the reconciler to check. + * + * Paged by the last identifier seen rather than by an offset. The caller invalidates some of the + * rows it is given, which removes them from this result set, so an offset would step over exactly + * as many unexamined lists as were invalidated. A cursor is unaffected by rows leaving the set + * behind it, and it does not re-count rows the database has already skipped past. + * + * Only the columns the reconciler uses are read. A row carries its published token, which for an + * 8 bit list at the default capacity is a couple of hundred kilobytes, and reading whole rows would + * move tens of megabytes per batch for a check which never looks at the token. + * + * @param ?string $afterId Resume after this list, or null to start from the beginning. + * @return \SimpleSAML\Module\oidc\StatusList\Values\StatusListReconciliationCandidate[] + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public function findPublished(int $limit, ?string $afterId = null): array + { + $params = []; + $cursorCondition = ''; + + if ($afterId !== null) { + $cursorCondition = ' AND id > :after_id'; + $params['after_id'] = $afterId; + } + + // Ordered by the primary key, which is arbitrary but stable -- all a cursor needs is that every + // row is visited exactly once, not that they arrive in any meaningful order. + // + // The limit is interpolated rather than bound: MySQL rejects a bound LIMIT when PDO emulates + // prepared statements, because the value arrives quoted as a string. It is an integer here, so + // there is nothing to inject. + $rows = $this->readPrimary( + sprintf( + 'SELECT id, bits, capacity, signed_token_content_hash, invalidation_counter FROM %s ' . + "WHERE signed_token_content_hash <> '' AND retired_at IS NULL%s ORDER BY id LIMIT %d", + $this->getTableName(), + $cursorCondition, + max(0, $limit), + ), + $params, + ); + + $candidates = []; + + /** @var mixed $row */ + foreach ($rows as $row) { + if (is_array($row)) { + $candidates[] = StatusListReconciliationCandidate::fromRow($row); + } + } + + return $candidates; + } + + /** + * Invalidates a published token, but only while it is still the one which was examined. + * + * The reconciler decides a token is stale by comparing what it read a moment ago against the + * entries as they are now. Between those two, a signer may have published a token which is + * perfectly correct -- and clearing that would be pure churn, and could keep defeating an in-flight + * signer indefinitely. Requiring both the hash and the counter to be unchanged means only the token + * actually found wanting is cleared. + * + * This is why the unconditional invalidation exists separately: the path which changes a status + * must always invalidate, since what it observed is by definition already superseded. + * + * @return bool Whether the token examined was still the published one, and was cleared. + * @throws \Exception + */ + public function invalidatePublishedTokenIfUnchanged( + string $id, + string $observedContentHash, + int $observedInvalidationCounter, + ): bool { + $affected = $this->database->write( + sprintf( + "UPDATE %s SET signed_token_content_hash = '', " . + 'invalidation_counter = invalidation_counter + 1 ' . + 'WHERE id = :id + AND signed_token_content_hash = :observed_content_hash + AND invalidation_counter = :observed_invalidation_counter', + $this->getTableName(), + ), + [ + 'id' => $id, + 'observed_content_hash' => $observedContentHash, + 'observed_invalidation_counter' => [$observedInvalidationCounter, PDO::PARAM_INT], + ], + ); + + return is_int($affected) && $affected > 0; + } + /** * @param array $rows * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 580cddc5..fd6dc328 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -36,6 +36,8 @@ class DatabaseMigration { /** Driver name reported for MySQL and MariaDB, the one driver needing its own DDL below. */ private const string DRIVER_MYSQL = 'mysql'; + private const string DRIVER_SQLITE = 'sqlite'; + private const string DRIVER_PGSQL = 'pgsql'; private readonly Database $database; @@ -257,6 +259,11 @@ public function migrate(): void $this->version20260801000003(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260801000003')"); } + + if (!in_array('20260801000004', $versions, true)) { + $this->version20260801000004(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260801000004')"); + } } private function versionsTableName(): string @@ -990,6 +997,82 @@ private function version20260801000003(): void $this->createIndex($idxCredentialIdHash, $auditTableName, 'credential_id_hash'); } + /** + * Count of how many times a Status List's published token has been invalidated. + * + * The content hash alone cannot settle publication. Its "nothing is published" value is the empty + * string, and an invalidation which finds it already empty leaves it empty -- so a signer which + * observed the empty string still matches afterwards and publishes a token built before that + * invalidation. Since the invalidating writer has already finished, nothing clears it again, and a + * revoked credential reads as valid until the refresh interval elapses or the reconciler runs. + * + * A counter which every invalidation increments always changes, so a signer which observed the + * earlier value no longer matches and rebuilds instead. + * + * The existence check is what makes this re-runnable. Being a single statement is not enough: the + * version is recorded by a *separate* statement afterwards, so a process which dies in between + * leaves the column added and the version still pending, and every later migration run would fail + * on a duplicate column until somebody repaired the schema by hand. + */ + private function version20260801000004(): void + { + $statusListTableName = $this->database->applyPrefix(StatusListRepository::TABLE_NAME); + + if ($this->hasColumn($statusListTableName, 'invalidation_counter')) { + return; + } + + $this->database->write(<<< EOT + ALTER TABLE {$statusListTableName} + ADD invalidation_counter INT NOT NULL DEFAULT 0 +EOT + ,); + } + + /** + * Whether a table already has a column. + * + * None of the three drivers offers ADD COLUMN IF NOT EXISTS across the board -- PostgreSQL does, + * MySQL and SQLite do not -- so the catalog is consulted instead. MySQL and PostgreSQL both expose + * information_schema; SQLite has its own table of table definitions. + */ + private function hasColumn(string $tableName, string $columnName): bool + { + if ($this->database->getDriver() === self::DRIVER_SQLITE) { + // The pragma cannot take a bound parameter, and the name here is assembled from a constant + // and the configured prefix rather than from anything a request supplies. + $statement = sprintf('SELECT 1 FROM pragma_table_info(%s) WHERE name = :columnName', "'$tableName'"); + $params = ['columnName' => $columnName]; + } else { + $isPostgres = $this->database->getDriver() === self::DRIVER_PGSQL; + + // Restricted to the database being migrated: MySQL's information_schema spans every schema + // on the server, so an unrestricted lookup could find a like-named column elsewhere. + $schemaFunction = $isPostgres ? 'CURRENT_SCHEMA()' : 'DATABASE()'; + $statement = 'SELECT 1 FROM information_schema.columns ' . + "WHERE table_schema = $schemaFunction AND table_name = :tableName AND column_name = :columnName"; + + // PostgreSQL folds unquoted identifiers to lower case, and nothing here quotes them, so a + // table created from a prefix with any upper case in it is stored lower cased. Comparing + // the name as configured would find nothing, conclude the column is missing, and turn a + // retry into the duplicate column error this check exists to prevent. MySQL keeps the case + // it was given, so its names are compared as they are. + $params = $isPostgres ? + ['tableName' => strtolower($tableName), 'columnName' => strtolower($columnName)] : + ['tableName' => $tableName, 'columnName' => $columnName]; + } + + // Every statement around this writes to the primary, so asking a secondary whether the column + // is there can get a stale no and turn a retry into the duplicate column error this exists to + // avoid. Migrations run on hosts which may predate the primary read, so it is used only where + // it is available. + $existing = ModuleConfig::hasPrimaryDatabaseReadCapability() ? + $this->database->readPrimary($statement, $params)->fetchAll() : + $this->database->read($statement, $params)->fetchAll(); + + return $existing !== []; + } + /** * Column type for a value which can reach a few hundred kilobytes. * diff --git a/src/StatusList/Contracts/StatusListTokenProviderInterface.php b/src/StatusList/Contracts/StatusListTokenProviderInterface.php new file mode 100644 index 00000000..263e206c --- /dev/null +++ b/src/StatusList/Contracts/StatusListTokenProviderInterface.php @@ -0,0 +1,27 @@ +statusListRepository->findById($statusListId); + + // A list which is no longer served, and one which never existed, are the same answer to a + // Relying Party. Being closed to new allocations is *not* one of those: a list stops accepting + // credentials long before the ones already in it stop needing a status, so only retirement ends + // publication. + if (!$statusList instanceof StatusListRecord || $statusList->isRetired()) { + return null; + } + + $result = $this->publishedResult($statusList, $this->helpers->dateTime()->getUtc()); + + // The overwhelmingly common case: the published token still describes the list and is not near + // enough to its own expiry to want replacing, so nothing is read beyond the single row above. + if ($result instanceof StatusListTokenResult) { + return $result; + } + + return $this->publish($statusList->getId()); + } + + /** + * Builds, signs and publishes a token, or adopts one another request published in the meantime. + * + * @return ?StatusListTokenResult Null when the list turned out to be gone or retired. + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function publish(string $statusListId): ?StatusListTokenResult + { + for ($attempt = 1; $attempt <= self::MAX_PUBLISH_ATTEMPTS; $attempt++) { + // On the primary, unlike the read which got us here. What is about to be compared and set + // has to be the current value, and a secondary's copy of it is not. + $statusList = $this->statusListRepository->findByIdOnPrimary($statusListId); + + if (!$statusList instanceof StatusListRecord || $statusList->isRetired()) { + return null; + } + + $now = $this->helpers->dateTime()->getUtc(); + + // Another request may have published between the read which decided to re-sign and this + // one, in which case there is nothing left to do. + $published = $this->publishedResult($statusList, $now); + + if ($published instanceof StatusListTokenResult) { + return $published; + } + + $observedContentHash = $statusList->getSignedTokenContentHash(); + // Read alongside the hash and compared alongside it below. The hash on its own cannot tell + // a still-current snapshot from one a revocation superseded while it was being signed, + // because both leave it empty. + $observedInvalidationCounter = $statusList->getInvalidationCounter(); + $statuses = $this->statusListEntryRepository->findNonValidStatuses($statusListId); + $contentHash = $this->statusListContentHasher->hash( + $statusList->getBits(), + $statusList->getCapacity(), + $statuses, + ); + + $expiresAt = $now->add( + new DateInterval('PT' . $statusList->getTokenValiditySeconds() . 'S'), + ); + + $token = $this->sign($statusList, $statuses, $now, $expiresAt); + + // Signing takes long enough for a revocation to land in the middle of it, and the + // compare-and-set below would not notice: it compares the list row, which changing an + // entry's status does not touch until the revoker's own invalidation lands. Re-reading the + // entries and re-hashing is what catches that, at the cost of one indexed query. + // + // This narrows the window to the gap between this check and the update; it does not close + // it, and closing it would need locking this design deliberately does without. That is + // proportionate: a Relying Party is entitled to cache the result for `ttl`, which is hours, + // so a token which is a few milliseconds behind is far inside the staleness the protocol + // already sanctions. + $currentContentHash = $this->statusListContentHasher->hash( + $statusList->getBits(), + $statusList->getCapacity(), + $this->statusListEntryRepository->findNonValidStatuses($statusListId), + ); + + if ($currentContentHash !== $contentHash) { + $this->loggerService->debug( + 'Status List content changed while its token was being signed, so the token was ' . + 'discarded and will be rebuilt.', + ['statusListId' => $statusListId, 'attempt' => $attempt], + ); + + continue; + } + + if ( + $this->statusListRepository->publishToken( + $statusListId, + $observedContentHash, + $observedInvalidationCounter, + $contentHash, + $token, + $now, + $expiresAt, + ) + ) { + $this->loggerService->info('Published a Status List Token.', [ + 'statusListId' => $statusListId, + 'expiresAt' => $expiresAt->getTimestamp(), + ]); + + return new StatusListTokenResult( + $token, + $statusList->getTtlSeconds(), + $now, + $expiresAt, + ); + } + + // Another request published first. The next pass re-reads and will normally find its token + // and serve that, rather than signing again. + $this->loggerService->debug( + 'Another request published a Status List Token first.', + ['statusListId' => $statusListId, 'attempt' => $attempt], + ); + } + + throw new StatusListException( + sprintf( + 'Unable to publish a Status List Token for "%s" after %d attempts, because its content ' . + 'kept changing. Nothing was served, since a token which is out of date would report a ' . + 'revoked credential as valid.', + $statusListId, + self::MAX_PUBLISH_ATTEMPTS, + ), + ); + } + + /** + * The published token, if there is one and it is still worth serving. + * + * Three ways it is not. It may have been invalidated by a status change, which is what an empty + * content hash records. It may be old enough that the refresh interval has elapsed, which bounds how + * long a change lost to a crash between the entry update and its invalidation can go unnoticed. Or + * it may be close enough to its own expiry that a Relying Party caching it for the advertised `ttl` + * would still be holding it after it expired. + */ + protected function publishedResult(StatusListRecord $statusList, DateTimeImmutable $now): ?StatusListTokenResult + { + $token = $statusList->getSignedToken(); + $issuedAt = $statusList->getSignedTokenIssuedAt(); + $expiresAt = $statusList->getSignedTokenExpiresAt(); + + if ( + !$statusList->hasPublishedToken() || + !is_string($token) || + !$issuedAt instanceof DateTimeImmutable || + !$expiresAt instanceof DateTimeImmutable + ) { + return null; + } + + if ($now->getTimestamp() - $issuedAt->getTimestamp() > $statusList->getRefreshIntervalSeconds()) { + return null; + } + + if ($expiresAt->getTimestamp() - $now->getTimestamp() < $this->safetyMarginSeconds()) { + return null; + } + + return new StatusListTokenResult($token, $statusList->getTtlSeconds(), $issuedAt, $expiresAt); + } + + /** + * Builds the list from its entries and signs it. + * + * @param array $statuses + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function sign( + StatusListRecord $statusList, + array $statuses, + DateTimeImmutable $issuedAt, + DateTimeImmutable $expiresAt, + ): string { + // Deliberately the key this list was created with rather than the current one, and a failure to + // find it is fatal here. Signing with today's key instead would produce a token which verifies + // for nobody holding a credential bound to the old one, while looking like success. + $signatureKeyPair = $this->statusListKeyResolver->getByKeyId($statusList->getSigningKeyId()); + $keyPair = $signatureKeyPair->getKeyPair(); + $identity = $this->identityFor($statusList->getKeyProfile(), $keyPair); + + try { + $list = $this->tokenStatusList->statusListFactory()->fromEntries( + $statuses, + $statusList->getBits(), + $statusList->getCapacity(), + ); + + return $this->tokenStatusList->statusListTokenFactory()->forStatusList( + $list, + // The stored URI, verbatim. A Relying Party checks that it matches the `uri` its + // credential carries byte for byte, so rebuilding it from the current base URL would + // break every credential issued before that URL last changed. + $statusList->getUri(), + $keyPair->getPrivateKey(), + $signatureKeyPair->getSignatureAlgorithm(), + $issuedAt, + $expiresAt, + new DateInterval('PT' . $statusList->getTtlSeconds() . 'S'), + $identity['issuer'], + [], + [ClaimsEnum::Kid->value => $identity['keyId']], + )->getToken(); + } catch (Throwable $throwable) { + throw new StatusListException( + sprintf( + 'Unable to sign a Status List Token for "%s": %s', + $statusList->getId(), + $throwable->getMessage(), + ), + (int)$throwable->getCode(), + $throwable, + ); + } + } + + /** + * How the token says who signed it and with which key. + * + * The specification mandates no key resolution method, so this is the deployment's profile rather + * than anything derivable from the spec, and it is read from the list's own row so that changing the + * configured profile never alters a token wallets are already verifying. + * + * @return array{issuer: string, keyId: string} + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function identityFor(StatusListKeyProfileEnum $keyProfile, KeyPair $keyPair): array + { + if ($keyProfile === StatusListKeyProfileEnum::Jwks) { + return [ + 'issuer' => $this->moduleConfig->getIssuer(), + 'keyId' => $keyPair->getKeyId(), + ]; + } + + try { + $didJwk = $this->did->didJwkResolver()->generateDidJwkFromJwk( + $keyPair->getPublicKey()->jwk()->all(), + ); + } catch (Throwable $throwable) { + throw new StatusListException( + 'Unable to derive the did:jwk identifier for the Status List signing key: ' . + $throwable->getMessage(), + (int)$throwable->getCode(), + $throwable, + ); + } + + // The same shape the module already uses when signing Verifiable Credentials, so that a wallet + // which can verify the credential can verify the Status List Token it points at. + return [ + 'issuer' => $didJwk, + 'keyId' => $didJwk . '#0', + ]; + } + + /** + * How close to expiry a published token is replaced rather than served. + * + * The same margin the pool validates its refresh interval against, so a token is always replaced + * with time to spare rather than at the moment it becomes useless. + */ + protected function safetyMarginSeconds(): int + { + return (new DateTimeImmutable('@0')) + ->add(new DateInterval(StatusListPool::SAFETY_MARGIN)) + ->getTimestamp(); + } +} diff --git a/src/StatusList/StatusListContentHasher.php b/src/StatusList/StatusListContentHasher.php new file mode 100644 index 00000000..c0ee4542 --- /dev/null +++ b/src/StatusList/StatusListContentHasher.php @@ -0,0 +1,68 @@ + $nonValidStatuses Index to status for every entry which is not Valid. Every + * index absent from this map is Valid, including the ones never allocated, which is the same + * convention the published list itself follows. + */ + public function hash(int $bits, int $capacity, array $nonValidStatuses): string + { + // Sorted here rather than assumed of the caller. The query producing this orders by index, but + // the hash has to come out identical in every process and on every driver for a compare-and-set + // against it to mean anything, and a map which arrived in another order would otherwise hash + // differently while describing exactly the same list. + ksort($nonValidStatuses); + + $entries = []; + + foreach ($nonValidStatuses as $idx => $status) { + $entries[] = $idx . ':' . $status; + } + + // Every part is labelled and delimited rather than run together. Plain concatenation is + // ambiguous -- 1 bit with a capacity of 12 and 11 bits with a capacity of 2 would produce the + // same input -- so two lists saying different things could agree on a hash, and one of them + // would then keep serving a token which does not describe it. + return hash('sha256', sprintf( + '%s|bits=%d|capacity=%d|%s', + self::VERSION, + $bits, + $capacity, + implode(',', $entries), + )); + } +} diff --git a/src/StatusList/StatusListRateLimiter.php b/src/StatusList/StatusListRateLimiter.php new file mode 100644 index 00000000..31596b45 --- /dev/null +++ b/src/StatusList/StatusListRateLimiter.php @@ -0,0 +1,91 @@ +moduleConfig->getVciStatusListRequestsPerMinute(); + + if ( + $limit < 1 || + $clientIdentifier === null || + $clientIdentifier === '' || + !$this->protocolCache instanceof ProtocolCache + ) { + return true; + } + + $window = intdiv($this->helpers->dateTime()->getUtc()->getTimestamp(), self::WINDOW_SECONDS); + + // Hashed, so that a record of who asked for what does not accumulate in the cache. The counter + // needs the address only to tell one client from another, never to report it. + $keyElements = [self::CACHE_KEY, (string)$window, hash('sha256', $clientIdentifier)]; + + try { + /** @var mixed $used */ + $used = $this->protocolCache->get(0, ...$keyElements); + $used = is_numeric($used) ? (int)$used : 0; + + if ($used >= $limit) { + return false; + } + + // Held for two windows rather than one, since an entry written at the very end of a window + // would otherwise be evicted while that window is still current on a slower clock. + $this->protocolCache->set($used + 1, self::WINDOW_SECONDS * 2, ...$keyElements); + } catch (Throwable $throwable) { + // A cache which is down must not take a public endpoint down with it. + $this->loggerService->warning( + 'Unable to apply the Status List rate limit, so the request was allowed: ' . + $throwable->getMessage(), + ); + + return true; + } + + return true; + } +} diff --git a/src/StatusList/StatusListReconciler.php b/src/StatusList/StatusListReconciler.php new file mode 100644 index 00000000..e8449d8e --- /dev/null +++ b/src/StatusList/StatusListReconciler.php @@ -0,0 +1,151 @@ +statusListRepository->findPublished(self::BATCH_SIZE, $cursor); + + if ($statusLists === []) { + $isExhausted = true; + + break; + } + + foreach ($statusLists as $statusList) { + if ($this->isPublishedTokenCurrent($statusList)) { + continue; + } + + // Conditional on the token still being the one examined. A signer may have published a + // correct token since this batch was read, and clearing that would be churn -- worse, + // repeated runs could keep defeating a signer which is doing exactly the right thing. + $wasCleared = $this->statusListRepository->invalidatePublishedTokenIfUnchanged( + $statusList->getId(), + $statusList->getSignedTokenContentHash(), + $statusList->getInvalidationCounter(), + ); + + if (!$wasCleared) { + continue; + } + + $invalidated++; + + $this->loggerService->warning( + 'A published Status List Token did not describe its list and was invalidated. This ' . + 'indicates a status change whose invalidation did not complete; the next request ' . + 'for this list will publish a corrected token.', + ['statusListId' => $statusList->getId()], + ); + } + + // Resume after the last list seen rather than at a numeric offset. Invalidating a list + // takes it out of the set being paged through, so an offset would step over exactly as many + // unexamined lists as were invalidated. + $cursor = $statusLists[array_key_last($statusLists)]->getId(); + + if (count($statusLists) < self::BATCH_SIZE) { + $isExhausted = true; + + break; + } + } + + if (!$isExhausted) { + $this->loggerService->warning( + sprintf( + 'Status List reconciliation stopped after %d lists without reaching the end. The ' . + 'lists beyond that point are examined by no run, since every run starts from the ' . + 'beginning, so a published token which stopped describing its list would go ' . + 'uncorrected there until its refresh interval elapses.', + self::MAX_BATCHES * self::BATCH_SIZE, + ), + ); + } + + return $invalidated; + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function isPublishedTokenCurrent(StatusListReconciliationCandidate $statusList): bool + { + return $this->statusListContentHasher->hash( + $statusList->getBits(), + $statusList->getCapacity(), + $this->statusListEntryRepository->findNonValidStatuses($statusList->getId()), + ) === $statusList->getSignedTokenContentHash(); + } +} diff --git a/src/StatusList/Values/DatabaseRowValuesTrait.php b/src/StatusList/Values/DatabaseRowValuesTrait.php index 96f721c4..4a723b61 100644 --- a/src/StatusList/Values/DatabaseRowValuesTrait.php +++ b/src/StatusList/Values/DatabaseRowValuesTrait.php @@ -62,6 +62,21 @@ protected static function asInt(array $row, string $key): int return (int)$value; } + /** + * @param array $row + */ + protected static function asNullableInt(array $row, string $key): ?int + { + /** @var mixed $value */ + $value = $row[$key] ?? null; + + if (is_int($value)) { + return $value; + } + + return is_string($value) && preg_match('/^-?\d+$/', $value) === 1 ? (int)$value : null; + } + /** * @param array $row */ diff --git a/src/StatusList/Values/StatusListReconciliationCandidate.php b/src/StatusList/Values/StatusListReconciliationCandidate.php new file mode 100644 index 00000000..34cc68ab --- /dev/null +++ b/src/StatusList/Values/StatusListReconciliationCandidate.php @@ -0,0 +1,69 @@ +id; + } + + public function getBits(): int + { + return $this->bits; + } + + public function getCapacity(): int + { + return $this->capacity; + } + + public function getSignedTokenContentHash(): string + { + return $this->signedTokenContentHash; + } + + public function getInvalidationCounter(): int + { + return $this->invalidationCounter; + } + + /** + * @param array $row + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + public static function fromRow(array $row): self + { + return new self( + self::asString($row, 'id'), + self::asInt($row, 'bits'), + self::asInt($row, 'capacity'), + self::asNullableString($row, 'signed_token_content_hash') ?? '', + self::asNullableInt($row, 'invalidation_counter') ?? 0, + ); + } +} diff --git a/src/StatusList/Values/StatusListRecord.php b/src/StatusList/Values/StatusListRecord.php index f0218751..a407cbe2 100644 --- a/src/StatusList/Values/StatusListRecord.php +++ b/src/StatusList/Values/StatusListRecord.php @@ -30,6 +30,10 @@ class StatusListRecord * @param string $signedTokenContentHash Hash of the content the published token was signed over. * An empty string means there is no valid published token, whether because none was ever produced * or because a status change invalidated it. Never null, so that a compare-and-set can match it. + * @param int $invalidationCounter How many times the published token has been invalidated. The + * hash alone cannot settle publication, because an invalidation arriving while it is already empty + * leaves it empty and a signer which observed the empty value would still match. This always + * changes, so that signer no longer does. */ public function __construct( protected readonly string $id, @@ -54,6 +58,7 @@ public function __construct( protected readonly ?DateTimeImmutable $signedTokenIssuedAt, protected readonly ?DateTimeImmutable $signedTokenExpiresAt, protected readonly ?DateTimeImmutable $createdAt, + protected readonly int $invalidationCounter = 0, ) { } @@ -199,6 +204,14 @@ public function getCreatedAt(): ?DateTimeImmutable return $this->createdAt; } + /** + * The value a signer must still find on the row for its token to be publishable. + */ + public function getInvalidationCounter(): int + { + return $this->invalidationCounter; + } + /** * Whether a published token exists which can be served as-is. */ @@ -240,6 +253,10 @@ public static function fromRow(array $row): self self::asNullableDateTime($row, 'signed_token_iat'), self::asNullableDateTime($row, 'signed_token_exp'), self::asNullableDateTime($row, 'created_at'), + // Added by a later migration than the table itself, so a row read while that migration has + // not yet run has no such column. Zero is the value the column defaults to, which makes + // such a read behave exactly as a list which has never been invalidated. + self::asNullableInt($row, 'invalidation_counter') ?? 0, ); } diff --git a/src/StatusList/Values/StatusListTokenResult.php b/src/StatusList/Values/StatusListTokenResult.php new file mode 100644 index 00000000..31771dd7 --- /dev/null +++ b/src/StatusList/Values/StatusListTokenResult.php @@ -0,0 +1,83 @@ +token; + } + + public function getTtlSeconds(): int + { + return $this->ttlSeconds; + } + + public function getIssuedAt(): DateTimeImmutable + { + return $this->issuedAt; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + /** + * A strong validator over the exact bytes served. + * + * Derived from the token rather than from the list's content hash or a change counter, both of + * which would tell a client something about how often the list changes. Two responses carrying the + * same token are byte identical whatever produced them, which is precisely what a strong entity tag + * asserts. + * + * @param ?string $contentCoding Coding the body was encoded with, which forms part of the tag. An + * encoded body and an unencoded one are different representations, so a shared cache holding both + * needs to be able to tell them apart even though the token inside is the same. + */ + public function getEntityTag(?string $contentCoding = null): string + { + return sprintf( + '"%s%s"', + hash('sha256', $this->token), + $contentCoding === null ? '' : '-' . $contentCoding, + ); + } + + /** + * How long a cache may hold this response. + * + * The token's `ttl` is what the specification offers Relying Parties, so it is the ceiling -- but a + * cached copy must never outlive the token's own expiry, since past that point it is not merely + * stale but invalid. Never negative: an already expired token is served with no reuse allowed at + * all, rather than with a nonsensical header. + */ + public function getMaxAgeSeconds(DateTimeImmutable $now): int + { + $secondsUntilExpiry = $this->expiresAt->getTimestamp() - $now->getTimestamp(); + + return max(0, min($this->ttlSeconds, $secondsUntilExpiry)); + } +} diff --git a/src/Utils/HttpContentNegotiator.php b/src/Utils/HttpContentNegotiator.php new file mode 100644 index 00000000..ac35c26d --- /dev/null +++ b/src/Utils/HttpContentNegotiator.php @@ -0,0 +1,196 @@ +splitMediaType(strtolower($mediaType)); + + if ($wanted === null) { + return false; + } + + $bestPrecedence = -1; + $bestWeight = 0.0; + + foreach (explode(',', $accept) as $rangeSpecification) { + $range = $this->parseWeighted($rangeSpecification); + + if ($range === null) { + continue; + } + + $precedence = $this->precedenceOf($range['value'], $wanted); + + if ($precedence < 0) { + continue; + } + + // The most specific matching range decides, whatever its weight -- that is the whole point + // of being able to write `*/*;q=1, application/x;q=0`, which accepts everything except one + // thing. Weight only breaks ties between ranges of equal specificity. + if ($precedence > $bestPrecedence || ($precedence === $bestPrecedence && $range['weight'] > $bestWeight)) { + $bestPrecedence = $precedence; + $bestWeight = $range['weight']; + } + } + + return $bestWeight > 0.0; + } + + /** + * The content coding to encode the body with, in the client's order of preference. + * + * @param string ...$supported Codings this response can produce, most preferred first. Ties go to + * the earlier one. + * @return ?string Null when the body should be sent unencoded, either because the client expressed + * no preference or because it wants none of what is on offer. + */ + public function preferredContentCoding(?string $acceptEncoding, string ...$supported): ?string + { + $acceptEncoding = trim((string)$acceptEncoding); + + if ($acceptEncoding === '') { + return null; + } + + $weights = []; + $wildcardWeight = null; + + foreach (explode(',', $acceptEncoding) as $codingSpecification) { + $coding = $this->parseWeighted($codingSpecification); + + if ($coding === null) { + continue; + } + + if ($coding['value'] === '*') { + $wildcardWeight = $coding['weight']; + + continue; + } + + $weights[$coding['value']] = $coding['weight']; + } + + $best = null; + $bestWeight = 0.0; + + foreach ($supported as $coding) { + $weight = $weights[strtolower($coding)] ?? $wildcardWeight ?? 0.0; + + if ($weight > $bestWeight) { + $best = $coding; + $bestWeight = $weight; + } + } + + return $best; + } + + /** + * Splits one comma separated element into its value and its weight. + * + * @return ?array{value: string, weight: float} + */ + protected function parseWeighted(string $specification): ?array + { + $parameters = explode(';', $specification); + $value = strtolower(trim(array_shift($parameters))); + + if ($value === '') { + return null; + } + + $weight = 1.0; + + foreach ($parameters as $parameter) { + [$name, $parameterValue] = array_pad(explode('=', $parameter, 2), 2, ''); + + if (strtolower(trim($name)) !== 'q') { + continue; + } + + $parameterValue = trim($parameterValue, " \t\""); + $weight = is_numeric($parameterValue) ? max(0.0, min(1.0, (float)$parameterValue)) : 1.0; + + // Everything past the weight is accept-ext, not a media type parameter, so there is no + // second `q` to find. + break; + } + + return ['value' => $value, 'weight' => $weight]; + } + + /** + * How specifically a media range names the given media type: 3 exact, 2 by type, 1 by wildcard, and + * -1 for no match at all. + * + * @param array{type: string, subtype: string} $wanted + */ + protected function precedenceOf(string $range, array $wanted): int + { + $parsed = $this->splitMediaType($range); + + if ($parsed === null) { + return -1; + } + + if ($parsed['type'] === '*' && $parsed['subtype'] === '*') { + return 1; + } + + if ($parsed['subtype'] === '*') { + return $parsed['type'] === $wanted['type'] ? 2 : -1; + } + + return $parsed['type'] === $wanted['type'] && $parsed['subtype'] === $wanted['subtype'] ? 3 : -1; + } + + /** + * @return ?array{type: string, subtype: string} + */ + protected function splitMediaType(string $mediaType): ?array + { + $separator = strpos($mediaType, '/'); + + if ($separator === false) { + return null; + } + + return [ + 'type' => substr($mediaType, 0, $separator), + 'subtype' => substr($mediaType, $separator + 1), + ]; + } +} diff --git a/tests/integration/src/StatusList/StatusListStorageTest.php b/tests/integration/src/StatusList/StatusListStorageTest.php index 8ef08eec..c9b87851 100644 --- a/tests/integration/src/StatusList/StatusListStorageTest.php +++ b/tests/integration/src/StatusList/StatusListStorageTest.php @@ -420,6 +420,252 @@ public function testRemovingAnUnopenedListRemovesItsEntries(string $database): v $this->assertSame(0, (int)($remaining[0]['total'] ?? -1)); } + /** + * Publishing is a compare-and-set against the hash the signer observed, and the number of rows it + * affected is the whole answer to whether this signer won. That only works if every driver reports + * it the same way, and if the initial empty hash survives a round trip well enough to be matched. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testPublishingIsSettledByTheObservedContentHash(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $issuedAt = (new Helpers())->dateTime()->getUtc(); + $expiresAt = $issuedAt->add(new \DateInterval('P7D')); + $firstHash = str_repeat('a', 64); + + // The first publication observes the empty hash a newly created list carries. + $this->assertTrue( + $this->statusListRepository->publishToken( + self::LIST_ID, + '', + 0, + $firstHash, + 'first.published.token', + $issuedAt, + $expiresAt, + ), + ); + + // A second signer which began from that same empty hash no longer matches, so its token is not + // published over the one already there. + $this->assertFalse( + $this->statusListRepository->publishToken( + self::LIST_ID, + '', + 0, + str_repeat('b', 64), + 'second.published.token', + $issuedAt, + $expiresAt, + ), + ); + + $statusList = $this->statusListRepository->findByIdOnPrimary(self::LIST_ID); + $this->assertSame('first.published.token', $statusList?->getSignedToken()); + $this->assertSame($firstHash, $statusList?->getSignedTokenContentHash()); + $this->assertTrue($statusList?->hasPublishedToken()); + } + + /** + * Re-signing unchanged content compares a hash against itself, which settles nothing on its own, so + * the issuance time has to be the tie-break. A signer whose token is not newer than what is + * published must lose. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testRefreshingUnchangedContentRequiresANewerIssuanceTime(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $hash = str_repeat('c', 64); + $issuedAt = (new Helpers())->dateTime()->getUtc(); + $expiresAt = $issuedAt->add(new \DateInterval('P7D')); + + $this->assertTrue( + $this->statusListRepository->publishToken( + self::LIST_ID, + '', + 0, + $hash, + 'first.published.token', + $issuedAt, + $expiresAt, + ), + ); + + // Same content, and no later than what is published: nothing to do. + $this->assertFalse( + $this->statusListRepository->publishToken( + self::LIST_ID, + $hash, + 0, + $hash, + 'stale.refresh.token', + $issuedAt->sub(new \DateInterval('PT1H')), + $expiresAt, + ), + ); + + // Same content, genuinely later: this is the refresh. + $this->assertTrue( + $this->statusListRepository->publishToken( + self::LIST_ID, + $hash, + 0, + $hash, + 'refreshed.token', + $issuedAt->add(new \DateInterval('PT1H')), + $expiresAt->add(new \DateInterval('PT1H')), + ), + ); + + $this->assertSame( + 'refreshed.token', + $this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->getSignedToken(), + ); + } + + /** + * The case the content hash cannot settle on its own. A signer which observed the empty hash, and + * whose snapshot a revocation superseded while it was signing, must not publish -- and the + * revocation leaves the hash exactly as it found it, empty, so only the counter distinguishes them. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testAnInvalidationDuringSigningBlocksPublicationEvenWhileTheHashIsEmpty( + string $database, + ): void { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $issuedAt = (new Helpers())->dateTime()->getUtc(); + $expiresAt = $issuedAt->add(new \DateInterval('P7D')); + + // What a signer reads before it starts: nothing published, and the counter as it stands. + $observed = $this->statusListRepository->findByIdOnPrimary(self::LIST_ID); + $this->assertSame('', $observed?->getSignedTokenContentHash()); + $observedCounter = (int)$observed?->getInvalidationCounter(); + + // A revocation lands while that signer is signing. The hash it clears is already clear. + $this->statusListRepository->invalidatePublishedToken(self::LIST_ID); + + $this->assertSame( + '', + $this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->getSignedTokenContentHash(), + ); + $this->assertSame( + $observedCounter + 1, + $this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->getInvalidationCounter(), + ); + + $this->assertFalse( + $this->statusListRepository->publishToken( + self::LIST_ID, + '', + $observedCounter, + str_repeat('e', 64), + 'token.built.before.the.revocation', + $issuedAt, + $expiresAt, + ), + ); + + $this->assertNull($this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->getSignedToken()); + } + + /** + * Only lists with something published are candidates for reconciliation, and retired ones are not + * served at all. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testFindsOnlyListsWhichHaveAPublishedToken(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $this->assertSame([], $this->statusListRepository->findPublished(10)); + + $issuedAt = (new Helpers())->dateTime()->getUtc(); + + $this->statusListRepository->publishToken( + self::LIST_ID, + '', + 0, + str_repeat('d', 64), + 'a.published.token', + $issuedAt, + $issuedAt->add(new \DateInterval('P7D')), + ); + + $published = $this->statusListRepository->findPublished(10); + $this->assertCount(1, $published); + $this->assertSame(self::LIST_ID, $published[0]->getId()); + + // A list whose token has been invalidated has nothing to reconcile against. + $this->statusListRepository->invalidatePublishedToken(self::LIST_ID); + $this->assertSame([], $this->statusListRepository->findPublished(10)); + } + + /** + * The reconciler clears a token only while it is still the one it examined, so that a token + * published in the meantime -- which is by definition current -- is left alone rather than + * needlessly re-signed. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testGuardedInvalidationOnlyClearsTheTokenItExamined(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $issuedAt = (new Helpers())->dateTime()->getUtc(); + $examinedHash = str_repeat('f', 64); + + $this->statusListRepository->publishToken( + self::LIST_ID, + '', + 0, + $examinedHash, + 'the.examined.token', + $issuedAt, + $issuedAt->add(new \DateInterval('P7D')), + ); + + // A hash which is not the one on the row: this token is not the one that was examined. + $this->assertFalse( + $this->statusListRepository->invalidatePublishedTokenIfUnchanged( + self::LIST_ID, + str_repeat('0', 64), + 0, + ), + ); + $this->assertTrue($this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->hasPublishedToken()); + + // The right hash but a counter which has moved on: likewise not the one examined. + $this->assertFalse( + $this->statusListRepository->invalidatePublishedTokenIfUnchanged(self::LIST_ID, $examinedHash, 7), + ); + $this->assertTrue($this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->hasPublishedToken()); + + $this->assertTrue( + $this->statusListRepository->invalidatePublishedTokenIfUnchanged(self::LIST_ID, $examinedHash, 0), + ); + + $statusList = $this->statusListRepository->findByIdOnPrimary(self::LIST_ID); + $this->assertSame('', $statusList?->getSignedTokenContentHash()); + $this->assertSame(1, $statusList?->getInvalidationCounter()); + } + /** * Migrations must be re-runnable, because a version is recorded only once its whole method has * succeeded and nothing rolls back what it managed before failing. @@ -439,6 +685,51 @@ public function testMigrationsAreIdempotent(string $database): void $this->assertSame([], $migration->getNotImplementedVersions()); } + /** + * A migration interrupted between doing its work and recording that it did. + * + * The version is written by a separate statement afterwards, so a process dying in between leaves + * the column added and the version still pending. Every later run would then fail on a duplicate + * column until somebody repaired the schema by hand, which is why the migration checks the catalog + * first. Removing the version row is exactly what that interruption leaves behind. + * + * Run with a prefix which is not all lower case, because that is what can make the check answer + * wrongly: PostgreSQL folds unquoted identifiers, so the table is stored under a name different + * from the one asked for, and a catalog lookup for the name as written finds nothing. A lower case + * prefix cannot show that up, since folding it changes nothing. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testAnInterruptedColumnMigrationCanBeRerun(string $database): void + { + $config = self::$$database; + $config['database.prefix'] = 'PhpUnitMixed_'; + + $this->useDatabase($config); + + $migration = new DatabaseMigration($this->database); + $migration->migrate(); + $this->assertTrue($migration->isMigrated()); + + // The column is there, but as far as the versions table is concerned the migration never ran. + $this->database->write( + 'DELETE FROM ' . $this->database->applyPrefix('oidc_migration_versions') . + ' WHERE version = :version', + ['version' => '20260801000004'], + ); + // Reported as the method which would run, and keyed by its position among the class's methods. + $this->assertSame( + ['version20260801000004'], + array_values($migration->getNotImplementedVersions()), + ); + + $migration->migrate(); + + $this->assertTrue($migration->isMigrated()); + $this->assertSame([], $migration->getNotImplementedVersions()); + } + /** * @return array */ diff --git a/tests/unit/src/Controllers/StatusListControllerTest.php b/tests/unit/src/Controllers/StatusListControllerTest.php new file mode 100644 index 00000000..78981e27 --- /dev/null +++ b/tests/unit/src/Controllers/StatusListControllerTest.php @@ -0,0 +1,297 @@ +statusListTokenProviderMock = $this->createMock(StatusListTokenProviderInterface::class); + $this->statusListRateLimiterMock = $this->createMock(StatusListRateLimiter::class); + $this->statusListRateLimiterMock->method('allows')->willReturn(true); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->helpers = new Helpers(); + + $this->routesMock = $this->createMock(Routes::class); + $this->routesMock->method('newResponse')->willReturnCallback( + static fn(?string $content = '', int $status = 200, array $headers = []): Response => + new Response($content, $status, $headers), + ); + + $this->statusListTokenProviderMock->method('getToken')->willReturn($this->tokenResult()); + } + + /** + * @throws \Exception + */ + protected function tokenResult(): StatusListTokenResult + { + $issuedAt = $this->helpers->dateTime()->getUtc(); + + return new StatusListTokenResult( + self::TOKEN, + 43200, + $issuedAt, + $issuedAt->add(new DateInterval('P7D')), + ); + } + + protected function sut(): StatusListController + { + return new StatusListController( + $this->statusListTokenProviderMock, + new HttpContentNegotiator(), + $this->statusListRateLimiterMock, + $this->routesMock, + $this->helpers, + $this->loggerServiceMock, + ); + } + + /** + * @param array $headers + * @param array $query + */ + protected function request(array $headers = [], array $query = []): Request + { + $server = []; + + foreach ($headers as $name => $value) { + $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + + return new Request($query, [], [], [], [], $server); + } + + public function testServesTheToken(): void + { + $response = $this->sut()->statusList($this->request(), self::LIST_ID); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $this->assertSame(self::TOKEN, $response->getContent()); + $this->assertSame(StatusListController::MEDIA_TYPE, $response->headers->get('Content-Type')); + } + + /** + * The specification recommends cross origin reads, and a browser based Relying Party which can not + * read the response can not distinguish a revoked credential from a network failure. + */ + public function testAlwaysAllowsCrossOriginReads(): void + { + $this->assertSame( + '*', + $this->sut()->statusList($this->request(), self::LIST_ID)->headers->get('Access-Control-Allow-Origin'), + ); + } + + public function testAnnouncesHowLongTheResponseMayBeCached(): void + { + $response = $this->sut()->statusList($this->request(), self::LIST_ID); + + // Asserted as directives rather than as a string, since the header bag normalises and reorders + // what it is given. + $this->assertSame(43200, $response->getMaxAge()); + $this->assertTrue($response->headers->hasCacheControlDirective('public')); + // Accept as well as Accept-Encoding: the response is publicly cacheable and an Accept which + // excludes the media type is refused, so a cache keyed only on the encoding would serve a + // stored token to a request the origin would have turned down. + $this->assertSame('Accept, Accept-Encoding', $response->headers->get('Vary')); + $this->assertMatchesRegularExpression('/^"[0-9a-f]{64}"$/', (string)$response->headers->get('ETag')); + } + + /** + * A list which is not served and one which never existed are the same answer. + */ + public function testRespondsNotFoundForAnUnknownList(): void + { + $provider = $this->createMock(StatusListTokenProviderInterface::class); + $provider->method('getToken')->willReturn(null); + $this->statusListTokenProviderMock = $provider; + + $this->assertSame( + Response::HTTP_NOT_FOUND, + $this->sut()->statusList($this->request(), self::LIST_ID)->getStatusCode(), + ); + } + + /** + * Failing closed is the whole point: a token which no longer describes its list reports revoked + * credentials as valid, so nothing at all is served when a fresh one can not be produced. + */ + public function testRespondsServiceUnavailableWhenATokenCanNotBeProduced(): void + { + $provider = $this->createMock(StatusListTokenProviderInterface::class); + $provider->method('getToken')->willThrowException(new StatusListException('signing key is gone')); + $this->statusListTokenProviderMock = $provider; + + $response = $this->sut()->statusList($this->request(), self::LIST_ID); + + $this->assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode()); + $this->assertNotNull($response->headers->get('Retry-After')); + $this->assertEmpty($response->getContent()); + } + + /** + * Answering a historical query with the current status would be worse than refusing it. + */ + public function testRespondsNotImplementedForAHistoricalQuery(): void + { + $this->assertSame( + Response::HTTP_NOT_IMPLEMENTED, + $this->sut()->statusList($this->request([], ['time' => '1700000000']), self::LIST_ID)->getStatusCode(), + ); + } + + public function testRespondsNotAcceptableWhenTheMediaTypeIsRefused(): void + { + $this->assertSame( + Response::HTTP_NOT_ACCEPTABLE, + $this->sut()->statusList($this->request(['Accept' => 'text/html']), self::LIST_ID)->getStatusCode(), + ); + } + + public function testServesTheTokenWhenTheMediaTypeIsAccepted(): void + { + foreach (['*/*', 'application/*', StatusListController::MEDIA_TYPE] as $accept) { + $this->assertSame( + Response::HTTP_OK, + $this->sut()->statusList($this->request(['Accept' => $accept]), self::LIST_ID)->getStatusCode(), + ); + } + } + + public function testRespondsTooManyRequestsWhenTheLimitIsReached(): void + { + $rateLimiter = $this->createMock(StatusListRateLimiter::class); + $rateLimiter->method('allows')->willReturn(false); + $this->statusListRateLimiterMock = $rateLimiter; + + $response = $this->sut()->statusList($this->request(), self::LIST_ID); + + $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); + $this->assertSame('60', $response->headers->get('Retry-After')); + } + + public function testCompressesTheBodyWhenTheClientAsksForIt(): void + { + $response = $this->sut()->statusList($this->request(['Accept-Encoding' => 'gzip']), self::LIST_ID); + + $this->assertSame('gzip', $response->headers->get('Content-Encoding')); + $this->assertSame(self::TOKEN, gzdecode((string)$response->getContent())); + } + + public function testSendsTheBodyUnencodedWhenNoCodingIsAcceptable(): void + { + $response = $this->sut()->statusList($this->request(['Accept-Encoding' => 'br']), self::LIST_ID); + + $this->assertNull($response->headers->get('Content-Encoding')); + $this->assertSame(self::TOKEN, $response->getContent()); + } + + /** + * The compressed and uncompressed responses are different representations of the same token, so a + * cache which holds both must not confuse them. + */ + public function testTheEntityTagDistinguishesTheEncodedResponse(): void + { + $plain = $this->sut()->statusList($this->request(), self::LIST_ID); + $compressed = $this->sut()->statusList($this->request(['Accept-Encoding' => 'gzip']), self::LIST_ID); + + $this->assertNotSame($plain->headers->get('ETag'), $compressed->headers->get('ETag')); + } + + public function testRespondsNotModifiedWhenTheClientAlreadyHasTheToken(): void + { + $entityTag = (string)$this->sut()->statusList($this->request(), self::LIST_ID)->headers->get('ETag'); + + $response = $this->sut()->statusList($this->request(['If-None-Match' => $entityTag]), self::LIST_ID); + + $this->assertSame(Response::HTTP_NOT_MODIFIED, $response->getStatusCode()); + $this->assertSame($entityTag, $response->headers->get('ETag')); + $this->assertSame(43200, $response->getMaxAge()); + } + + /** + * If-None-Match compares weakly, so a tag the client stored as weak still matches. + */ + public function testRespondsNotModifiedForAWeakenedEntityTag(): void + { + $entityTag = (string)$this->sut()->statusList($this->request(), self::LIST_ID)->headers->get('ETag'); + + $this->assertSame( + Response::HTTP_NOT_MODIFIED, + $this->sut()->statusList( + $this->request(['If-None-Match' => 'W/' . $entityTag]), + self::LIST_ID, + )->getStatusCode(), + ); + } + + public function testRespondsNotModifiedForAWildcardValidator(): void + { + $this->assertSame( + Response::HTTP_NOT_MODIFIED, + $this->sut()->statusList($this->request(['If-None-Match' => '*']), self::LIST_ID)->getStatusCode(), + ); + } + + public function testServesTheTokenWhenTheClientHoldsADifferentOne(): void + { + $this->assertSame( + Response::HTTP_OK, + $this->sut()->statusList( + $this->request(['If-None-Match' => '"something-else"']), + self::LIST_ID, + )->getStatusCode(), + ); + } + + /** + * A client which stored the uncompressed copy and now asks for a compressed one is not holding the + * representation which would be served. + */ + public function testDoesNotReuseAValidatorAcrossContentCodings(): void + { + $plainTag = (string)$this->sut()->statusList($this->request(), self::LIST_ID)->headers->get('ETag'); + + $this->assertSame( + Response::HTTP_OK, + $this->sut()->statusList( + $this->request(['If-None-Match' => $plainTag, 'Accept-Encoding' => 'gzip']), + self::LIST_ID, + )->getStatusCode(), + ); + } +} diff --git a/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php b/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php new file mode 100644 index 00000000..f27c5e80 --- /dev/null +++ b/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php @@ -0,0 +1,506 @@ +statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListContentHasher = new StatusListContentHasher(); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->helpers = new Helpers(); + + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getIssuer')->willReturn('https://op.example.org'); + + $this->statusListKeyResolverMock = $this->createMock(StatusListKeyResolver::class); + $this->statusListKeyResolverMock->method('getByKeyId')->willReturn($this->signatureKeyPair()); + + $this->statusListTokenFactoryMock = $this->createMock(StatusListTokenFactory::class); + $this->statusListTokenFactoryMock->method('forStatusList')->willReturn($this->signedTokenStub()); + + $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); + $this->didJwkResolverMock->method('generateDidJwkFromJwk')->willReturn(self::DID_JWK); + } + + /** + * Assembled here rather than in setUp so that a test can put its own token factory in place first, + * which is the only way to assert what a token was signed over: a second stub of an already stubbed + * method never gets reached. + * + * @throws \PHPUnit\Framework\MockObject\Exception + */ + protected function sut(): DbStatusListTokenProvider + { + $didMock = $this->createMock(Did::class); + $didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + + $tokenStatusListMock = $this->createMock(TokenStatusList::class); + $tokenStatusListMock->method('statusListFactory') + ->willReturn(new StatusListFactory(new OpenIdHelpers())); + $tokenStatusListMock->method('statusListTokenFactory') + ->willReturn($this->statusListTokenFactoryMock); + + return new DbStatusListTokenProvider( + $this->statusListRepositoryMock, + $this->statusListEntryRepositoryMock, + $this->statusListContentHasher, + $this->statusListKeyResolverMock, + $tokenStatusListMock, + $this->moduleConfigMock, + $didMock, + $this->helpers, + $this->loggerServiceMock, + ); + } + + /** + * @throws \PHPUnit\Framework\MockObject\Exception + */ + protected function signatureKeyPair(): SignatureKeyPair + { + $publicKey = $this->createMock(JwkDecorator::class); + $publicKey->method('jwk')->willReturn(new JWK(['kty' => 'EC', 'crv' => 'P-256', 'x' => 'x', 'y' => 'y'])); + + $keyPair = $this->createMock(KeyPair::class); + $keyPair->method('getKeyId')->willReturn(self::SIGNING_KEY_ID); + $keyPair->method('getPrivateKey')->willReturn($this->createMock(JwkDecorator::class)); + $keyPair->method('getPublicKey')->willReturn($publicKey); + + $signatureKeyPair = $this->createMock(SignatureKeyPair::class); + $signatureKeyPair->method('getKeyPair')->willReturn($keyPair); + $signatureKeyPair->method('getSignatureAlgorithm')->willReturn(SignatureAlgorithmEnum::RS256); + + return $signatureKeyPair; + } + + /** + * @throws \PHPUnit\Framework\MockObject\Exception + */ + protected function signedTokenStub(): StatusListToken + { + $statusListToken = $this->createMock(StatusListToken::class); + $statusListToken->method('getToken')->willReturn(self::SIGNED_TOKEN); + + return $statusListToken; + } + + /** + * @throws \Exception + */ + protected function record( + ?string $signedToken = null, + string $signedTokenContentHash = '', + ?string $signedTokenIssuedAt = null, + ?string $signedTokenExpiresAt = null, + ?string $retiredAt = null, + StatusListKeyProfileEnum $keyProfile = StatusListKeyProfileEnum::DidJwk, + int $invalidationCounter = 4, + ): StatusListRecord { + return new StatusListRecord( + self::LIST_ID, + self::LIST_URI, + 'default', + 'a-policy-fingerprint', + 1, + 2, + 64, + '0,1,2', + 43200, + 604800, + 3600, + self::SIGNING_KEY_ID, + $keyProfile, + 0, + true, + null, + $this->moment($retiredAt), + $signedToken, + $signedTokenContentHash, + $this->moment($signedTokenIssuedAt), + $this->moment($signedTokenExpiresAt), + $this->moment('now'), + $invalidationCounter, + ); + } + + /** + * @throws \Exception + */ + protected function moment(?string $moment): ?DateTimeImmutable + { + return $moment === null ? null : new DateTimeImmutable($moment, new DateTimeZone('UTC')); + } + + /** + * @param array $statuses + */ + protected function contentHashFor(array $statuses): string + { + return $this->statusListContentHasher->hash(2, 64, $statuses); + } + + /** + * A published token signed just now, with its full life ahead of it. + * + * @param array $statuses + * @throws \Exception + */ + protected function freshlyPublishedRecord(array $statuses = []): StatusListRecord + { + return $this->record( + self::PUBLISHED_TOKEN, + $this->contentHashFor($statuses), + 'now', + '+7 days', + ); + } + + /** + * @throws \Exception + */ + public function testReturnsNothingForAnUnknownList(): void + { + $this->statusListRepositoryMock->method('findById')->willReturn(null); + + $this->assertNull($this->sut()->getToken(self::LIST_ID)); + } + + /** + * Retirement, not deactivation, is what ends publication: a list stops taking new credentials long + * before the ones already in it stop needing a status. + * + * @throws \Exception + */ + public function testReturnsNothingForARetiredList(): void + { + $this->statusListRepositoryMock->method('findById') + ->willReturn($this->record(self::PUBLISHED_TOKEN, 'a-hash', 'now', '+7 days', 'now')); + + $this->assertNull($this->sut()->getToken(self::LIST_ID)); + } + + /** + * The common path: one row is read and nothing else, which is what makes serving a list of a hundred + * thousand entries cheap. + * + * @throws \Exception + */ + public function testServesThePublishedTokenWithoutReadingTheEntries(): void + { + $this->statusListRepositoryMock->method('findById')->willReturn($this->freshlyPublishedRecord()); + $this->statusListEntryRepositoryMock->expects($this->never())->method('findNonValidStatuses'); + $this->statusListRepositoryMock->expects($this->never())->method('publishToken'); + + $result = $this->sut()->getToken(self::LIST_ID); + + $this->assertInstanceOf(StatusListTokenResult::class, $result); + $this->assertSame(self::PUBLISHED_TOKEN, $result->getToken()); + $this->assertSame(43200, $result->getTtlSeconds()); + } + + /** + * @return array + */ + public static function staleTokens(): array + { + return [ + 'never published' => [null, '', null, null], + 'invalidated by a status change' => [self::PUBLISHED_TOKEN, '', 'now', '+7 days'], + 'older than the refresh interval' => [self::PUBLISHED_TOKEN, 'a-hash', '-2 hours', '+7 days'], + 'close to its own expiry' => [self::PUBLISHED_TOKEN, 'a-hash', 'now', '+5 minutes'], + 'already expired' => [self::PUBLISHED_TOKEN, 'a-hash', '-8 days', '-1 day'], + ]; + } + + /** + * @throws \Exception + */ + #[DataProvider('staleTokens')] + public function testPublishesAFreshTokenWhenThePublishedOneWillNotDo( + ?string $signedToken, + string $contentHash, + ?string $issuedAt, + ?string $expiresAt, + ): void { + $record = $this->record($signedToken, $contentHash, $issuedAt, $expiresAt); + + $this->statusListRepositoryMock->method('findById')->willReturn($record); + $this->statusListRepositoryMock->method('findByIdOnPrimary')->willReturn($record); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([5 => 1]); + $this->statusListRepositoryMock->expects($this->once())->method('publishToken')->willReturn(true); + + $result = $this->sut()->getToken(self::LIST_ID); + + $this->assertInstanceOf(StatusListTokenResult::class, $result); + $this->assertSame(self::SIGNED_TOKEN, $result->getToken()); + } + + /** + * The compare-and-set has to be given the hash which was on the row, so that a signer whose snapshot + * was superseded fails to publish rather than overwriting a newer token. + * + * @throws \Exception + */ + public function testPublishesAgainstTheHashItObserved(): void + { + $observed = $this->contentHashFor([5 => 1]); + $record = $this->record(self::PUBLISHED_TOKEN, $observed, '-2 hours', '+7 days'); + + $this->statusListRepositoryMock->method('findById')->willReturn($record); + $this->statusListRepositoryMock->method('findByIdOnPrimary')->willReturn($record); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([5 => 1]); + + $this->statusListRepositoryMock->expects($this->once())->method('publishToken') + ->with(self::LIST_ID, $observed, 4, $observed, self::SIGNED_TOKEN) + ->willReturn(true); + + $this->assertInstanceOf(StatusListTokenResult::class, $this->sut()->getToken(self::LIST_ID)); + } + + /** + * While the content hash is empty it cannot settle publication on its own: an invalidation arriving + * after this signer took its snapshot finds the hash already empty and leaves it empty, so the + * signer would still match and publish a token built before that revocation -- with nothing left to + * clear it. The counter is what the compare-and-set has to distinguish them by. + * + * @throws \Exception + */ + public function testPublishesAgainstTheInvalidationCounterItObserved(): void + { + $record = $this->record(self::PUBLISHED_TOKEN, '', 'now', '+7 days', invalidationCounter: 9); + + $this->statusListRepositoryMock->method('findById')->willReturn($record); + $this->statusListRepositoryMock->method('findByIdOnPrimary')->willReturn($record); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([]); + + $this->statusListRepositoryMock->expects($this->once())->method('publishToken') + ->with(self::LIST_ID, '', 9) + ->willReturn(true); + + $this->assertInstanceOf(StatusListTokenResult::class, $this->sut()->getToken(self::LIST_ID)); + } + + /** + * Fail closed. A token signed with a key the credential's holder never bound to is not one they can + * verify, and reaching for the current key instead would look like success. + * + * @throws \Exception + */ + public function testFailsWhenTheListsSigningKeyIsGone(): void + { + $record = $this->record(); + $this->statusListRepositoryMock->method('findById')->willReturn($record); + $this->statusListRepositoryMock->method('findByIdOnPrimary')->willReturn($record); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([]); + + $keyResolver = $this->createMock(StatusListKeyResolver::class); + $keyResolver->method('getByKeyId')->willThrowException(new StatusListException('key is gone')); + $this->statusListKeyResolverMock = $keyResolver; + + $this->statusListRepositoryMock->expects($this->never())->method('publishToken'); + + $this->expectException(StatusListException::class); + + $this->sut()->getToken(self::LIST_ID); + } + + /** + * A revocation landing while the token is being signed is not visible to the compare-and-set, which + * looks at the list row rather than at the entries. Re-reading them is what catches it. + * + * @throws \Exception + */ + public function testDiscardsATokenSupersededWhileItWasBeingSigned(): void + { + $record = $this->record(); + $this->statusListRepositoryMock->method('findById')->willReturn($record); + $this->statusListRepositoryMock->method('findByIdOnPrimary')->willReturn($record); + + // Each pass reads the entries twice, and here the second read differs from the first every time. + $this->statusListEntryRepositoryMock->method('findNonValidStatuses') + ->willReturnOnConsecutiveCalls( + [], + [5 => 1], + [5 => 1], + [5 => 1, 6 => 1], + [5 => 1, 6 => 1], + [5 => 1, 6 => 1, 7 => 1], + ); + + $this->statusListRepositoryMock->expects($this->never())->method('publishToken'); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessageMatches('/kept changing/'); + + $this->sut()->getToken(self::LIST_ID); + } + + /** + * Losing the race is not a failure. The winner's token describes the same list, so it is served + * rather than signed again. + * + * @throws \Exception + */ + public function testServesTheTokenAnotherRequestPublishedFirst(): void + { + $this->statusListRepositoryMock->method('findById')->willReturn($this->record()); + $this->statusListRepositoryMock->method('findByIdOnPrimary') + ->willReturnOnConsecutiveCalls($this->record(), $this->freshlyPublishedRecord()); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([]); + $this->statusListRepositoryMock->expects($this->once())->method('publishToken')->willReturn(false); + + $result = $this->sut()->getToken(self::LIST_ID); + + $this->assertInstanceOf(StatusListTokenResult::class, $result); + $this->assertSame(self::PUBLISHED_TOKEN, $result->getToken()); + } + + /** + * A list retired between the read which decided to re-sign and the authoritative one is gone, not + * broken. + * + * @throws \Exception + */ + public function testReturnsNothingWhenTheListIsRetiredWhileRepublishing(): void + { + $this->statusListRepositoryMock->method('findById')->willReturn($this->record()); + $this->statusListRepositoryMock->method('findByIdOnPrimary') + ->willReturn($this->record(null, '', null, null, 'now')); + + $this->assertNull($this->sut()->getToken(self::LIST_ID)); + } + + /** + * The token has to name the list by the URI which was stored, since a Relying Party compares it byte + * for byte with the one its credential carries. + * + * @throws \Exception + */ + public function testSignsWithTheStoredUriAndTheDidJwkIdentity(): void + { + $this->givenAListWhichNeedsPublishing(); + $this->statusListTokenFactoryMock = $this->createMock(StatusListTokenFactory::class); + + $this->statusListTokenFactoryMock->expects($this->once())->method('forStatusList') + ->with( + $this->anything(), + self::LIST_URI, + $this->anything(), + SignatureAlgorithmEnum::RS256, + $this->anything(), + $this->anything(), + $this->anything(), + self::DID_JWK, + [], + ['kid' => self::DID_JWK . '#0'], + ) + ->willReturn($this->signedTokenStub()); + + $this->sut()->getToken(self::LIST_ID); + } + + /** + * Under the JWKS profile the key is resolved through the issuer's published key set instead, so the + * token names the issuer and the plain key identifier. + * + * @throws \Exception + */ + public function testSignsWithTheIssuerAndKeyIdUnderTheJwksProfile(): void + { + $this->givenAListWhichNeedsPublishing(StatusListKeyProfileEnum::Jwks); + $this->statusListTokenFactoryMock = $this->createMock(StatusListTokenFactory::class); + + $this->statusListTokenFactoryMock->expects($this->once())->method('forStatusList') + ->with( + $this->anything(), + self::LIST_URI, + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + 'https://op.example.org', + [], + ['kid' => self::SIGNING_KEY_ID], + ) + ->willReturn($this->signedTokenStub()); + + $this->sut()->getToken(self::LIST_ID); + } + + /** + * @throws \Exception + */ + protected function givenAListWhichNeedsPublishing( + StatusListKeyProfileEnum $keyProfile = StatusListKeyProfileEnum::DidJwk, + ): void { + $record = $this->record(keyProfile: $keyProfile); + + $this->statusListRepositoryMock->method('findById')->willReturn($record); + $this->statusListRepositoryMock->method('findByIdOnPrimary')->willReturn($record); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([]); + $this->statusListRepositoryMock->method('publishToken')->willReturn(true); + } +} diff --git a/tests/unit/src/StatusList/StatusListContentHasherTest.php b/tests/unit/src/StatusList/StatusListContentHasherTest.php new file mode 100644 index 00000000..aab130d0 --- /dev/null +++ b/tests/unit/src/StatusList/StatusListContentHasherTest.php @@ -0,0 +1,98 @@ +assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $this->sut()->hash(1, 64, [])); + } + + /** + * A list with nothing revoked still has a hash, and it must not be the empty string, which is + * reserved for "there is no published token". + */ + public function testAListWithNothingRevokedStillHashesToSomething(): void + { + $hash = $this->sut()->hash(1, 64, []); + + $this->assertNotSame('', $hash); + $this->assertSame($hash, $this->sut()->hash(1, 64, [])); + } + + /** + * The compare-and-set which publishes a token compares hashes produced in different processes, so + * the order the entries happened to arrive in must not change the result. + */ + public function testIsIndependentOfTheOrderTheEntriesArriveIn(): void + { + $this->assertSame( + $this->sut()->hash(2, 64, [3 => 1, 11 => 2, 40 => 1]), + $this->sut()->hash(2, 64, [40 => 1, 3 => 1, 11 => 2]), + ); + } + + public function testChangesWhenAStatusChanges(): void + { + $this->assertNotSame( + $this->sut()->hash(2, 64, [3 => 1]), + $this->sut()->hash(2, 64, [3 => 2]), + ); + } + + public function testChangesWhenAnEntryIsAdded(): void + { + $this->assertNotSame( + $this->sut()->hash(2, 64, [3 => 1]), + $this->sut()->hash(2, 64, [3 => 1, 4 => 1]), + ); + } + + public function testChangesWhenAnEntryMoves(): void + { + $this->assertNotSame( + $this->sut()->hash(2, 64, [3 => 1]), + $this->sut()->hash(2, 64, [4 => 1]), + ); + } + + public function testDistinguishesListsOfDifferentShape(): void + { + $this->assertNotSame($this->sut()->hash(1, 64, []), $this->sut()->hash(2, 64, [])); + $this->assertNotSame($this->sut()->hash(1, 64, []), $this->sut()->hash(1, 128, [])); + } + + /** + * The parts are labelled and delimited precisely so that two different lists can not produce the + * same input by running together. Bits of 1 with a capacity of 12 and bits of 11 with a capacity of + * 2 are the pair that plain concatenation would collapse. + */ + public function testDoesNotCollideOnAmbiguouslyConcatenatedParts(): void + { + $this->assertNotSame($this->sut()->hash(1, 12, []), $this->sut()->hash(11, 2, [])); + } + + /** + * Likewise for the entries: index 1 with status 12 and index 11 with status 2 have to differ. + */ + public function testDoesNotCollideOnAmbiguouslyConcatenatedEntries(): void + { + $this->assertNotSame( + $this->sut()->hash(8, 64, [1 => 12]), + $this->sut()->hash(8, 64, [11 => 2]), + ); + } +} diff --git a/tests/unit/src/StatusList/StatusListRateLimiterTest.php b/tests/unit/src/StatusList/StatusListRateLimiterTest.php new file mode 100644 index 00000000..e8660cfb --- /dev/null +++ b/tests/unit/src/StatusList/StatusListRateLimiterTest.php @@ -0,0 +1,162 @@ + */ + protected array $cached = []; + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciStatusListRequestsPerMinute')->willReturn(3); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->helpers = new Helpers(); + + // Stands in for a cache which actually keeps what it is given, so that counting can be observed + // across calls rather than only asserted one call at a time. + $this->protocolCacheMock = $this->createMock(ProtocolCache::class); + $this->protocolCacheMock->method('get')->willReturnCallback( + fn(mixed $default, string ...$keyElements): mixed => + $this->cached[implode('|', $keyElements)] ?? $default, + ); + $this->protocolCacheMock->method('set')->willReturnCallback( + function (mixed $value, mixed $ttl, string ...$keyElements): void { + $this->cached[implode('|', $keyElements)] = (int)$value; + }, + ); + } + + protected function sut(?ProtocolCache $protocolCache = null): StatusListRateLimiter + { + return new StatusListRateLimiter( + $this->moduleConfigMock, + $protocolCache ?? $this->protocolCacheMock, + $this->helpers, + $this->loggerServiceMock, + ); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testAllowsUpToTheLimitAndThenRefuses(): void + { + $sut = $this->sut(); + + $this->assertTrue($sut->allows('198.51.100.7')); + $this->assertTrue($sut->allows('198.51.100.7')); + $this->assertTrue($sut->allows('198.51.100.7')); + $this->assertFalse($sut->allows('198.51.100.7')); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testCountsEachClientSeparately(): void + { + $sut = $this->sut(); + + foreach (range(1, 3) as $ignored) { + $sut->allows('198.51.100.7'); + } + + $this->assertFalse($sut->allows('198.51.100.7')); + $this->assertTrue($sut->allows('198.51.100.8')); + } + + /** + * The address is only needed to tell one client from another, never to report who asked for what. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testDoesNotKeepTheClientAddressInTheCache(): void + { + $this->sut()->allows('198.51.100.7'); + + $this->assertNotEmpty($this->cached); + + foreach (array_keys($this->cached) as $key) { + $this->assertStringNotContainsString('198.51.100.7', $key); + } + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testAppliesNoLimitWhenNoneIsConfigured(): void + { + $moduleConfig = $this->createMock(ModuleConfig::class); + $moduleConfig->method('getVciStatusListRequestsPerMinute')->willReturn(0); + $this->moduleConfigMock = $moduleConfig; + + $sut = $this->sut(); + + foreach (range(1, 10) as $ignored) { + $this->assertTrue($sut->allows('198.51.100.7')); + } + } + + /** + * Without somewhere to count, there is nothing to count -- and refusing on that basis would take a + * public endpoint down for the sake of a limit which was never being applied anyway. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testAllowsEverythingWithoutACache(): void + { + $sut = new StatusListRateLimiter( + $this->moduleConfigMock, + null, + $this->helpers, + $this->loggerServiceMock, + ); + + foreach (range(1, 10) as $ignored) { + $this->assertTrue($sut->allows('198.51.100.7')); + } + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testAllowsWhenThereIsNothingToCountAgainst(): void + { + $this->assertTrue($this->sut()->allows(null)); + $this->assertTrue($this->sut()->allows('')); + } + + /** + * A cache which is down must not take the endpoint down with it. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testAllowsWhenTheCacheFails(): void + { + $protocolCache = $this->createMock(ProtocolCache::class); + $protocolCache->method('get')->willThrowException(new RuntimeException('cache is down')); + + $this->loggerServiceMock->expects($this->once())->method('warning'); + + $this->assertTrue($this->sut($protocolCache)->allows('198.51.100.7')); + } +} diff --git a/tests/unit/src/StatusList/StatusListReconcilerTest.php b/tests/unit/src/StatusList/StatusListReconcilerTest.php new file mode 100644 index 00000000..8010d105 --- /dev/null +++ b/tests/unit/src/StatusList/StatusListReconcilerTest.php @@ -0,0 +1,197 @@ +statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->statusListContentHasher = new StatusListContentHasher(); + } + + protected function sut(): StatusListReconciler + { + return new StatusListReconciler( + $this->statusListRepositoryMock, + $this->statusListEntryRepositoryMock, + $this->statusListContentHasher, + $this->loggerServiceMock, + ); + } + + protected function record( + string $id, + string $signedTokenContentHash, + int $invalidationCounter = 0, + ): StatusListReconciliationCandidate { + return new StatusListReconciliationCandidate($id, 2, 64, $signedTokenContentHash, $invalidationCounter); + } + + /** + * @param array $statuses + */ + protected function hashFor(array $statuses): string + { + return $this->statusListContentHasher->hash(2, 64, $statuses); + } + + /** + * @throws \Exception + */ + public function testDoesNothingWhenNothingIsPublished(): void + { + $this->statusListRepositoryMock->method('findPublished')->willReturn([]); + $this->statusListRepositoryMock->expects($this->never())->method('invalidatePublishedTokenIfUnchanged'); + + $this->assertSame(0, $this->sut()->reconcile()); + } + + /** + * @throws \Exception + */ + public function testLeavesATokenWhichStillDescribesItsList(): void + { + $this->statusListRepositoryMock->method('findPublished') + ->willReturn([$this->record('list-a', $this->hashFor([5 => 1]))]); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([5 => 1]); + $this->statusListRepositoryMock->expects($this->never())->method('invalidatePublishedTokenIfUnchanged'); + + $this->assertSame(0, $this->sut()->reconcile()); + } + + /** + * The failure this exists for: the entry update landed and the invalidation which should have + * followed it did not, leaving a published token which reports a revoked credential as valid. + * + * @throws \Exception + */ + public function testInvalidatesATokenWhichNoLongerDescribesItsList(): void + { + $this->statusListRepositoryMock->method('findPublished') + ->willReturn([$this->record('list-a', $this->hashFor([]))]); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([5 => 1]); + + $this->statusListRepositoryMock->expects($this->once())->method('invalidatePublishedTokenIfUnchanged') + ->with('list-a', $this->hashFor([]), 0) + ->willReturn(true); + $this->loggerServiceMock->expects($this->once())->method('warning'); + + $this->assertSame(1, $this->sut()->reconcile()); + } + + /** + * A signer may publish a correct token between the batch being read and this decision. Clearing + * that would be churn, and repeated runs could keep defeating a signer doing the right thing, so + * the invalidation is conditional and a no-op is not counted or reported as a repair. + * + * @throws \Exception + */ + public function testLeavesATokenPublishedSinceTheBatchWasRead(): void + { + $this->statusListRepositoryMock->method('findPublished') + ->willReturn([$this->record('list-a', $this->hashFor([]))]); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([5 => 1]); + + // The guarded update matched nothing, meaning the token examined is no longer the published one. + $this->statusListRepositoryMock->method('invalidatePublishedTokenIfUnchanged')->willReturn(false); + $this->loggerServiceMock->expects($this->never())->method('warning'); + + $this->assertSame(0, $this->sut()->reconcile()); + } + + /** + * A short page means there is no next one, so nothing more is asked for. + * + * @throws \Exception + */ + public function testStopsOnceAPageIsNotFull(): void + { + $this->statusListRepositoryMock->expects($this->once())->method('findPublished') + ->willReturn([$this->record('list-a', $this->hashFor([]))]); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([]); + + $this->assertSame(0, $this->sut()->reconcile()); + } + + /** + * Invalidating a list takes it out of the set being paged through, so a numeric offset would step + * over exactly as many unexamined lists as were invalidated. Resuming after the last identifier + * seen is unaffected by rows leaving the set behind it. + * + * @throws \Exception + */ + public function testResumesAfterTheLastListItSawRatherThanByCounting(): void + { + $full = []; + + for ($position = 0; $position < 100; $position++) { + // Half are current and half are not, so half the page drops out of the set. + $statuses = $position % 2 === 0 ? [] : [5 => 1]; + $full[] = $this->record(sprintf('list-%03d', $position), $this->hashFor($statuses)); + } + + $cursors = []; + $pages = [$full, []]; + + $this->statusListRepositoryMock->method('findPublished')->willReturnCallback( + function (int $limit, ?string $afterId = null) use (&$cursors, &$pages): array { + $cursors[] = $afterId; + + return array_shift($pages) ?? []; + }, + ); + + // Every list is read as holding one revoked entry, so the ones whose stored hash says otherwise + // are the fifty which get invalidated. + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([5 => 1]); + $this->statusListRepositoryMock->method('invalidatePublishedTokenIfUnchanged')->willReturn(true); + + $this->assertSame(50, $this->sut()->reconcile()); + $this->assertSame([null, 'list-099'], $cursors); + } + + /** + * Stopping short is not a normal outcome, because every run starts from the beginning: the lists + * past the ceiling are examined by no run at all. It has to be said out loud rather than passed + * over. + * + * @throws \Exception + */ + public function testReportsStoppingBeforeTheEnd(): void + { + $full = []; + + for ($position = 0; $position < 100; $position++) { + $full[] = $this->record(sprintf('list-%03d', $position), $this->hashFor([])); + } + + $this->statusListRepositoryMock->method('findPublished')->willReturn($full); + $this->statusListEntryRepositoryMock->method('findNonValidStatuses')->willReturn([]); + + $this->loggerServiceMock->expects($this->once())->method('warning') + ->with($this->stringContains('without reaching the end')); + + $this->assertSame(0, $this->sut()->reconcile()); + } +} diff --git a/tests/unit/src/StatusList/Values/StatusListTokenResultTest.php b/tests/unit/src/StatusList/Values/StatusListTokenResultTest.php new file mode 100644 index 00000000..d10f5013 --- /dev/null +++ b/tests/unit/src/StatusList/Values/StatusListTokenResultTest.php @@ -0,0 +1,88 @@ +sut(); + + $this->assertSame('header.payload.signature', $result->getToken()); + $this->assertSame(43200, $result->getTtlSeconds()); + $this->assertSame('2026-08-01 12:00:00', $result->getIssuedAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-08-08 12:00:00', $result->getExpiresAt()->format('Y-m-d H:i:s')); + } + + public function testTheEntityTagIsQuotedAndDerivedFromTheToken(): void + { + $this->assertMatchesRegularExpression('/^"[0-9a-f]{64}"$/', $this->sut()->getEntityTag()); + + $this->assertSame($this->sut()->getEntityTag(), $this->sut()->getEntityTag()); + $this->assertNotSame($this->sut()->getEntityTag(), $this->sut('other.token.here')->getEntityTag()); + } + + /** + * An encoded body and an unencoded one are different representations, so a shared cache holding both + * has to be able to tell them apart. + */ + public function testTheEntityTagNamesTheContentCoding(): void + { + $this->assertNotSame($this->sut()->getEntityTag(), $this->sut()->getEntityTag('gzip')); + $this->assertStringEndsWith('-gzip"', $this->sut()->getEntityTag('gzip')); + } + + /** + * The `ttl` is what the specification offers a Relying Party, so it is the ceiling while the token + * has longer to live than that. + */ + public function testCachesForTheTimeToLiveWhileThereIsRoomForIt(): void + { + $this->assertSame(43200, $this->sut()->getMaxAgeSeconds($this->moment('2026-08-01 12:00:00'))); + } + + /** + * Close to expiry the token's own remaining life is shorter than the `ttl`, and a cached copy must + * not outlive the token: past expiry it is not stale but invalid. + */ + public function testNeverCachesPastTheTokensOwnExpiry(): void + { + $this->assertSame( + 3600, + $this->sut()->getMaxAgeSeconds($this->moment('2026-08-08 11:00:00')), + ); + } + + public function testAnAlreadyExpiredTokenIsNotCacheableAtAll(): void + { + $this->assertSame(0, $this->sut()->getMaxAgeSeconds($this->moment('2026-08-09 12:00:00'))); + } +} diff --git a/tests/unit/src/Utils/HttpContentNegotiatorTest.php b/tests/unit/src/Utils/HttpContentNegotiatorTest.php new file mode 100644 index 00000000..9f7f329f --- /dev/null +++ b/tests/unit/src/Utils/HttpContentNegotiatorTest.php @@ -0,0 +1,131 @@ + + */ + public static function acceptHeaders(): array + { + return [ + 'absent' => [null, true], + 'empty' => ['', true], + 'wildcard' => ['*/*', true], + 'type wildcard' => ['application/*', true], + 'exact' => ['application/statuslist+jwt', true], + 'exact among others' => ['text/html, application/statuslist+jwt, */*', true], + 'unrelated type only' => ['text/html', false], + 'unrelated type wildcard' => ['text/*', false], + 'with a weight' => ['application/statuslist+jwt;q=0.5', true], + 'refused outright' => ['application/statuslist+jwt;q=0', false], + 'case insensitive' => ['APPLICATION/STATUSLIST+JWT', true], + 'untidy whitespace' => [" text/html ,\tapplication/statuslist+jwt ", true], + 'malformed element is skipped' => ['nonsense, application/statuslist+jwt', true], + 'malformed element alone' => ['nonsense', false], + 'parameters on the range are ignored' => ['application/statuslist+jwt;version=2', true], + ]; + } + + #[DataProvider('acceptHeaders')] + public function testAcceptsMediaType(?string $accept, bool $expected): void + { + $this->assertSame($expected, $this->sut()->acceptsMediaType($accept, self::MEDIA_TYPE)); + } + + /** + * The specific range wins over the general one whichever way round the weights fall, which is what + * lets a client say "anything but this" -- and, the other way round, "only this". + */ + public function testTheMoreSpecificRangeDecidesRegardlessOfWeight(): void + { + $this->assertFalse( + $this->sut()->acceptsMediaType('*/*;q=1, application/statuslist+jwt;q=0', self::MEDIA_TYPE), + ); + + $this->assertTrue( + $this->sut()->acceptsMediaType('*/*;q=0, application/statuslist+jwt;q=1', self::MEDIA_TYPE), + ); + + // Between the two wildcards, the one naming the type is the more specific. + $this->assertFalse( + $this->sut()->acceptsMediaType('*/*;q=1, application/*;q=0', self::MEDIA_TYPE), + ); + } + + /** + * Everything past the weight is accept-ext rather than a second weight. + */ + public function testIgnoresAcceptExtensionsAfterTheWeight(): void + { + $this->assertTrue( + $this->sut()->acceptsMediaType('application/statuslist+jwt;q=1;ext=0', self::MEDIA_TYPE), + ); + } + + public function testNoAcceptEncodingMeansNoEncoding(): void + { + $this->assertNull($this->sut()->preferredContentCoding(null, 'gzip')); + $this->assertNull($this->sut()->preferredContentCoding('', 'gzip')); + } + + public function testChoosesAnOfferedCoding(): void + { + $this->assertSame('gzip', $this->sut()->preferredContentCoding('gzip, deflate', 'gzip')); + $this->assertSame('gzip', $this->sut()->preferredContentCoding('GZIP', 'gzip')); + } + + public function testDeclinesACodingWhichIsNotOffered(): void + { + $this->assertNull($this->sut()->preferredContentCoding('br, zstd', 'gzip')); + } + + public function testHonoursAWildcard(): void + { + $this->assertSame('gzip', $this->sut()->preferredContentCoding('*', 'gzip')); + } + + /** + * A weight of zero is a refusal, and an explicit refusal beats a permissive wildcard. + */ + public function testARefusedCodingIsNotUsed(): void + { + $this->assertNull($this->sut()->preferredContentCoding('gzip;q=0', 'gzip')); + $this->assertNull($this->sut()->preferredContentCoding('*, gzip;q=0', 'gzip')); + } + + public function testPrefersTheHigherWeightedOfSeveralOfferedCodings(): void + { + $this->assertSame( + 'deflate', + $this->sut()->preferredContentCoding('gzip;q=0.2, deflate;q=0.9', 'gzip', 'deflate'), + ); + } + + /** + * Where the client has no preference between two codings, the order they are offered in decides. + */ + public function testTiesGoToTheFirstOfferedCoding(): void + { + $this->assertSame( + 'gzip', + $this->sut()->preferredContentCoding('deflate, gzip', 'gzip', 'deflate'), + ); + } +} From 6d6977587e0a0a4d0bc8360693c3c7b96ac48b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 7 Aug 2026 10:15:04 +0200 Subject: [PATCH 4/9] Issue Verifiable Credentials with a Status List reference --- config/module_oidc.php.dist | 24 + locales/en/LC_MESSAGES/oidc.po | 14 + locales/es/LC_MESSAGES/oidc.po | 14 + locales/fr/LC_MESSAGES/oidc.po | 14 + locales/hr/LC_MESSAGES/oidc.po | 14 + locales/it/LC_MESSAGES/oidc.po | 14 + locales/nl/LC_MESSAGES/oidc.po | 14 + .../ConfigOverview/VciOverviewBuilder.php | 36 ++ .../CredentialIssuerCredentialController.php | 193 ++++++-- src/ModuleConfig.php | 114 +++++ src/StatusList/CredentialStatusIssuer.php | 94 ++++ ...edentialIssuerCredentialControllerTest.php | 453 +++++++++++++++--- tests/unit/src/ModuleConfigTest.php | 83 ++++ .../StatusList/CredentialStatusIssuerTest.php | 168 +++++++ 14 files changed, 1135 insertions(+), 114 deletions(-) create mode 100644 src/StatusList/CredentialStatusIssuer.php create mode 100644 tests/unit/src/StatusList/CredentialStatusIssuerTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index d46483a6..c9582ec5 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1539,6 +1539,30 @@ $config = [ */ ModuleConfig::OPTION_VCI_NONCE_TTL => 'PT5M', // 5 minutes + /** + * (optional) How long an issued credential stays valid, per credential + * configuration. Configurations which are not listed here issue + * credentials which never expire, which is the default. + * + * Think before enabling this for a configuration that is already in use: + * credentials issued from now on will stop working on their own, while + * the ones already in wallets will not. For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php + * + * An expiry is also what makes retiring a Status List possible at all. A + * list holding even one credential that never expires has to go on being + * served, since a wallet may present that credential at any time. + * + * This is a top-level option rather than something inside the credential + * configurations, because those are published verbatim as Credential + * Issuer metadata and anything placed among them becomes visible to every + * wallet. + */ +// ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS => [ +// 'UniversityDegreeCredential' => 'P1Y', // 1 year +// 'EmployeeBadgeCredential' => 'P90D', // 90 days +// ], + /** * (optional) Whether issued Verifiable Credentials get a Token Status List * entry allocated to them, which is what makes them revocable and diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index a0338f13..94e2c631 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -1610,3 +1610,17 @@ msgid "" "unauthenticated and its response can reach a couple of hundred " "kilobytes." msgstr "" + +msgid "Credential Lifetimes" +msgstr "" + +msgid "" +"Issued credentials never expire, which is the long standing default. A " +"Status List holding one can never be retired, so its storage is kept for " +"good." +msgstr "" + +msgid "" +"How long a credential of each configuration stays valid. Configurations " +"which are not listed issue credentials which never expire." +msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index 54db2540..f81f71aa 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -1610,3 +1610,17 @@ msgid "" "unauthenticated and its response can reach a couple of hundred " "kilobytes." msgstr "" + +msgid "Credential Lifetimes" +msgstr "" + +msgid "" +"Issued credentials never expire, which is the long standing default. A " +"Status List holding one can never be retired, so its storage is kept for " +"good." +msgstr "" + +msgid "" +"How long a credential of each configuration stays valid. Configurations " +"which are not listed issue credentials which never expire." +msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index 1f9ec229..1ac3a327 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -1610,3 +1610,17 @@ msgid "" "unauthenticated and its response can reach a couple of hundred " "kilobytes." msgstr "" + +msgid "Credential Lifetimes" +msgstr "" + +msgid "" +"Issued credentials never expire, which is the long standing default. A " +"Status List holding one can never be retired, so its storage is kept for " +"good." +msgstr "" + +msgid "" +"How long a credential of each configuration stays valid. Configurations " +"which are not listed issue credentials which never expire." +msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index 34a690d2..ab18851c 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -1658,3 +1658,17 @@ msgid "" "unauthenticated and its response can reach a couple of hundred " "kilobytes." msgstr "" + +msgid "Credential Lifetimes" +msgstr "" + +msgid "" +"Issued credentials never expire, which is the long standing default. A " +"Status List holding one can never be retired, so its storage is kept for " +"good." +msgstr "" + +msgid "" +"How long a credential of each configuration stays valid. Configurations " +"which are not listed issue credentials which never expire." +msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index f7aec40f..3bf06c30 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -1610,3 +1610,17 @@ msgid "" "unauthenticated and its response can reach a couple of hundred " "kilobytes." msgstr "" + +msgid "Credential Lifetimes" +msgstr "" + +msgid "" +"Issued credentials never expire, which is the long standing default. A " +"Status List holding one can never be retired, so its storage is kept for " +"good." +msgstr "" + +msgid "" +"How long a credential of each configuration stays valid. Configurations " +"which are not listed issue credentials which never expire." +msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 4a76dd8b..47428b2b 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -1564,3 +1564,17 @@ msgid "" "unauthenticated and its response can reach a couple of hundred " "kilobytes." msgstr "" + +msgid "Credential Lifetimes" +msgstr "" + +msgid "" +"Issued credentials never expire, which is the long standing default. A " +"Status List holding one can never be retired, so its storage is kept for " +"good." +msgstr "" + +msgid "" +"How long a credential of each configuration stays valid. Configurations " +"which are not listed issue credentials which never expire." +msgstr "" diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index 1d9be34b..3d1c93ff 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Admin\ConfigOverview; +use DateInterval; use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Codebooks\ConfigOverviewValueTypeEnum; use SimpleSAML\Module\oidc\ModuleConfig; @@ -607,6 +608,41 @@ protected function buildDurationsSection(): Section ), ), ), + $this->guardRow( + Translate::noop('Credential Lifetimes'), + ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS, + function (): Row { + $ttls = $this->moduleConfig->getVciCredentialTtls(); + + if ($ttls === []) { + return new Row( + Translate::noop('Credential Lifetimes'), + Translate::noop('None configured'), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS, + Translate::noop( + 'Issued credentials never expire, which is the long standing default. ' . + 'A Status List holding one can never be retired, so its storage is ' . + 'kept for good.', + ), + ); + } + + return new Row( + Translate::noop('Credential Lifetimes'), + array_map( + fn(DateInterval $ttl): array => [$this->dateIntervalFormatter->toDurationSpec($ttl)], + $ttls, + ), + ConfigOverviewValueTypeEnum::StringMap, + ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS, + Translate::noop( + 'How long a credential of each configuration stays valid. Configurations ' . + 'which are not listed issue credentials which never expire.', + ), + ); + }, + ), ); } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 5b115ba2..fb52c647 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -4,9 +4,12 @@ namespace SimpleSAML\Module\oidc\Controllers\VerifiableCredentials; +use DateInterval; +use DateTimeImmutable; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; +use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; @@ -15,6 +18,7 @@ use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\NonceService; +use SimpleSAML\Module\oidc\StatusList\CredentialStatusIssuer; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\Module\oidc\Utils\VciContextResolver; @@ -26,10 +30,12 @@ use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Exceptions\OpenId4VciProofException; use SimpleSAML\OpenID\Exceptions\OpenIdException; +use SimpleSAML\OpenID\TokenStatusList\StatusClaim; use SimpleSAML\OpenID\VerifiableCredentials; use SimpleSAML\OpenID\VerifiableCredentials\OpenId4VciProof; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Throwable; class CredentialIssuerCredentialController { @@ -38,6 +44,16 @@ class CredentialIssuerCredentialController CredentialFormatIdentifiersEnum::VcSdJwt->value, ]; + /** + * Bytes of randomness in the identifier a credential carries as its `jti`. + * + * The identifier is what revocation is keyed on, so it has to be unguessable: anyone able to work + * out the identifier of a credential they were never issued could ask for it to be revoked. It is + * also a URI, since the parsers enforce that, so the randomness is a suffix on a fixed base rather + * than the whole value. + */ + protected const int CREDENTIAL_ID_RANDOM_BYTES = 32; + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -55,6 +71,8 @@ public function __construct( protected readonly IssuerStateRepository $issuerStateRepository, protected readonly NonceService $nonceService, protected readonly VciContextResolver $vciContextResolver, + protected readonly CredentialStatusIssuer $credentialStatusIssuer, + protected readonly Helpers $helpers, ) { if (!$this->moduleConfig->getVciEnabled()) { $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); @@ -690,66 +708,127 @@ public function credential(Request $request): Response $issuerDid = $this->did->didJwkResolver()->generateDidJwkFromJwk($publicKey->jwk()->all()); - $issuedAt = new \DateTimeImmutable(); + $issuedAt = new DateTimeImmutable(); - $vcId = $this->moduleConfig->getIssuer() . '/vc/' . uniqid(); + // Unguessable rather than sequential or time based, since this is what revocation is keyed + // on. Still in URI form, because the credential parsers enforce that of a `jti`. + $vcId = $this->moduleConfig->getIssuer() . '/vc/' . + $this->helpers->random()->getIdentifier(self::CREDENTIAL_ID_RANDOM_BYTES); $signatureAlgorithm = $vciSignatureKeyPair->getSignatureAlgorithm(); + $credentialTtl = $this->moduleConfig->getVciCredentialTtlFor($resolvedCredentialIdentifier); + $expiresAt = $credentialTtl instanceof DateInterval ? $issuedAt->add($credentialTtl) : null; + + // Inside the loop rather than outside it: a request carrying several proofs is issued + // several credentials, and each one needs an entry of its own to be revocable separately. + try { + $statusClaim = $this->credentialStatusIssuer->issueFor( + $resolvedCredentialIdentifier, + $vcId, + $userId, + $expiresAt, + ); + } catch (Throwable $exception) { + // Refusing to issue is the point. This configuration is set up to produce revocable + // credentials, and one issued without a status claim could never be withdrawn -- with + // nothing on it to say that it is the exception. + $this->loggerService->error( + 'Could not allocate a Status List entry, so no credential was issued.', + [ + 'credentialConfigurationId' => $resolvedCredentialIdentifier, + 'error' => $exception->getMessage(), + ], + ); + + return $this->routes->newJsonErrorResponse( + 'server_error', + 'Unable to issue a revocable credential at this time.', + 500, + ); + } + $this->loggerService->info('Signing and issuing verifiable credential.', [ 'vcId' => $vcId, 'format' => $credentialFormatId, 'issuerDid' => $issuerDid, 'sub' => $sub, 'algorithm' => $signatureAlgorithm->value, + 'expiresAt' => $expiresAt?->getTimestamp(), + 'hasStatusClaim' => $statusClaim instanceof StatusClaim, ]); + // Both are merged into every format below. The status claim sits at the top level of the + // payload, which is where the Status List specification puts it for a JOSE Referenced + // Token, and not inside the credential body of the W3C formats. + /** @var array $commonClaims */ + $commonClaims = $statusClaim instanceof StatusClaim ? $statusClaim->jsonSerialize() : []; + + if ($expiresAt instanceof DateTimeImmutable) { + $commonClaims[ClaimsEnum::Exp->value] = $expiresAt->getTimestamp(); + } + $verifiableCredential = null; if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { + $verifiableCredentialBody = [ + ClaimsEnum::AtContext->value => [ + AtContextsEnum::W3Org2018CredentialsV1->value, + ], + /** @psalm-suppress MixedArrayAccess */ + ClaimsEnum::Type->value => + $resolvedCredentialConfiguration[ClaimsEnum::CredentialDefinition->value] + [ClaimsEnum::Type->value] ?? [ + CredentialTypesEnum::VerifiableCredential->value, + $resolvedCredentialIdentifier, + ], + //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Issuer->value => $issuerDid, + ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Id->value => $vcId, + ClaimsEnum::Credential_Subject->value => + $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], + ]; + + // Stated in the credential body as well as in the JWT `exp` claim above, mirroring the + // issuance date, which this format already states as both `iat` and `issuanceDate`. + if ($expiresAt instanceof DateTimeImmutable) { + $verifiableCredentialBody[ClaimsEnum::Expiration_Date->value] = + $expiresAt->format(\DateTimeInterface::RFC3339); + } + $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( $signingKey, $signatureAlgorithm, - [ - ClaimsEnum::Vc->value => [ - ClaimsEnum::AtContext->value => [ - AtContextsEnum::W3Org2018CredentialsV1->value, + array_merge( + [ + ClaimsEnum::Vc->value => $verifiableCredentialBody, + //ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $sub, + ClaimsEnum::Jti->value => $vcId, ], - /** @psalm-suppress MixedArrayAccess */ - ClaimsEnum::Type->value => - $resolvedCredentialConfiguration[ClaimsEnum::CredentialDefinition->value] - [ClaimsEnum::Type->value] ?? [ - CredentialTypesEnum::VerifiableCredential->value, - $resolvedCredentialIdentifier, - ], - //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), - ClaimsEnum::Issuer->value => $issuerDid, - ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), - ClaimsEnum::Id->value => $vcId, - ClaimsEnum::Credential_Subject->value => - $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], + $commonClaims, + ), + [ + ClaimsEnum::Kid->value => $issuerDid . '#0', ], - //ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ); + } + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::DcSdJwt->value) { + $sdJwtPayload = array_merge( + [ ClaimsEnum::Iss->value => $issuerDid, ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $sub, ClaimsEnum::Jti->value => $vcId, + ClaimsEnum::Vct->value => $resolvedCredentialIdentifier, ], - [ - ClaimsEnum::Kid->value => $issuerDid . '#0', - ], + $commonClaims, ); - } - - if ($credentialFormatId === CredentialFormatIdentifiersEnum::DcSdJwt->value) { - $sdJwtPayload = [ - ClaimsEnum::Iss->value => $issuerDid, - ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), - ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), - ClaimsEnum::Sub->value => $sub, - ClaimsEnum::Jti->value => $vcId, - ClaimsEnum::Vct->value => $resolvedCredentialIdentifier, - ]; if ($proof instanceof OpenId4VciProof && is_string($proofKeyId = $proof->getKeyId())) { $sdJwtPayload[ClaimsEnum::Cnf->value] = [ @@ -774,25 +853,35 @@ public function credential(Request $request): Response $resolvedCredentialConfiguration, ); - $sdJwtPayload = [ - ClaimsEnum::AtContext->value => $atContext, - ClaimsEnum::Id->value => $vcId, - /** @psalm-suppress MixedArrayAccess */ - ClaimsEnum::Type->value => $resolvedCredentialConfiguration[ClaimsEnum::CredentialDefinition->value] - [ClaimsEnum::Type->value] ?? [ - CredentialTypesEnum::VerifiableCredential->value, - $resolvedCredentialIdentifier, + $sdJwtPayload = array_merge( + [ + ClaimsEnum::AtContext->value => $atContext, + ClaimsEnum::Id->value => $vcId, + /** @psalm-suppress MixedArrayAccess */ + ClaimsEnum::Type->value => + $resolvedCredentialConfiguration[ClaimsEnum::CredentialDefinition->value] + [ClaimsEnum::Type->value] ?? [ + CredentialTypesEnum::VerifiableCredential->value, + $resolvedCredentialIdentifier, + ], + ClaimsEnum::Issuer->value => $issuerDid, + ClaimsEnum::ValidFrom->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Credential_Subject->value => + $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], + ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $sub, + ClaimsEnum::Jti->value => $vcId, ], - ClaimsEnum::Issuer->value => $issuerDid, - ClaimsEnum::ValidFrom->value => $issuedAt->format(\DateTimeInterface::RFC3339), - ClaimsEnum::Credential_Subject->value => - $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], - ClaimsEnum::Iss->value => $issuerDid, - ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), - ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), - ClaimsEnum::Sub->value => $sub, - ClaimsEnum::Jti->value => $vcId, - ]; + $commonClaims, + ); + + // The Verifiable Credentials Data Model 2.0 names the end of a credential's validity + // `validUntil`, alongside the `validFrom` above, so this format states it both ways. + if ($expiresAt instanceof DateTimeImmutable) { + $sdJwtPayload[ClaimsEnum::ValidUntil->value] = $expiresAt->format(\DateTimeInterface::RFC3339); + } if ($proof instanceof OpenId4VciProof && is_string($proofKeyId = $proof->getKeyId())) { $sdJwtPayload[ClaimsEnum::Cnf->value] = [ diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 4bc4d6c1..b92747a2 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -17,6 +17,7 @@ namespace SimpleSAML\Module\oidc; use DateInterval; +use DateTimeImmutable; use Defuse\Crypto\Exception\CryptoException; use Defuse\Crypto\Key; use SimpleSAML\Configuration; @@ -47,6 +48,7 @@ use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfig; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag; +use Throwable; class ModuleConfig { @@ -156,6 +158,7 @@ class ModuleConfig final public const string OPTION_VCI_STATUS_LIST_KEY_PROFILE = 'vci_status_list_key_profile'; final public const string OPTION_VCI_STATUS_LIST_POOLS = 'vci_status_list_pools'; final public const string OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE = 'vci_status_list_requests_per_minute'; + final public const string OPTION_VCI_CREDENTIAL_TTLS = 'vci_credential_ttls'; final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; final public const string OPTION_DCR_INITIAL_ACCESS_TOKENS = 'dcr_initial_access_tokens'; @@ -208,6 +211,9 @@ class ModuleConfig protected ?SignatureKeyPairConfigBag $vciSignatureKeyPairConfigBag = null; protected ?StatusListPoolBag $vciStatusListPoolBag = null; + /** @var ?array Credential configuration ID to how long its credentials live. */ + protected ?array $vciCredentialTtls = null; + /** * @throws \Exception */ @@ -1416,6 +1422,114 @@ public function getVciStatusListPoolFor(string $credentialConfigurationId): ?Sta return $this->getVciStatusListPoolBag()->getForCredentialConfigurationId($credentialConfigurationId); } + /** + * How long credentials of each configuration remain valid. + * + * A separate top-level option for the same reason the pools are: the credential configurations are + * published wholesale as Credential Issuer metadata, so anything placed among them becomes visible + * to every wallet. + * + * Configurations absent from this map issue credentials which never expire, which is what this + * module has always done and stays the default. Adding an expiry changes the meaning of credentials + * that are already being issued, so it is something an operator opts into per configuration rather + * than something that arrives with an upgrade. + * + * @return array + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciCredentialTtls(): array + { + if (is_array($this->vciCredentialTtls)) { + return $this->vciCredentialTtls; + } + + $supportedIds = $this->getVciCredentialConfigurationIdsSupported(); + $ttls = []; + + /** @var mixed $value */ + foreach ($this->config()->getOptionalArray(self::OPTION_VCI_CREDENTIAL_TTLS, []) as $key => $value) { + $credentialConfigurationId = (string)$key; + + if (!in_array($credentialConfigurationId, $supportedIds, true)) { + // Silently ignoring this would leave credentials which were meant to expire being + // issued without an expiry, and nothing would say so. + throw new ConfigurationError( + sprintf( + 'Option "%s" sets a lifetime for the credential configuration "%s", which is not ' . + 'one of the configurations declared under "%s".', + self::OPTION_VCI_CREDENTIAL_TTLS, + $credentialConfigurationId, + self::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED, + ), + self::DEFAULT_FILE_NAME, + ); + } + + $ttls[$credentialConfigurationId] = $this->resolveCredentialTtl($credentialConfigurationId, $value); + } + + return $this->vciCredentialTtls = $ttls; + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function resolveCredentialTtl(string $credentialConfigurationId, mixed $value): DateInterval + { + if (!is_string($value)) { + throw new ConfigurationError( + sprintf( + 'Option "%s" must give the lifetime of "%s" as a duration string, %s given.', + self::OPTION_VCI_CREDENTIAL_TTLS, + $credentialConfigurationId, + get_debug_type($value), + ), + self::DEFAULT_FILE_NAME, + ); + } + + try { + $ttl = new DateInterval($value); + } catch (Throwable $throwable) { + throw new ConfigurationError( + sprintf( + 'Option "%s" gives "%s" a lifetime which is not a valid duration: %s', + self::OPTION_VCI_CREDENTIAL_TTLS, + $credentialConfigurationId, + $throwable->getMessage(), + ), + self::DEFAULT_FILE_NAME, + ); + } + + // Anchored to the epoch rather than to now, so the answer does not depend on the server's + // timezone or on which side of a daylight saving transition the configuration is read. + if ((new DateTimeImmutable('@0'))->add($ttl)->getTimestamp() < 1) { + throw new ConfigurationError( + sprintf( + 'Option "%s" gives "%s" a lifetime of no time at all, so its credentials would ' . + 'expire the moment they are issued. Remove the entry to issue credentials which ' . + 'do not expire.', + self::OPTION_VCI_CREDENTIAL_TTLS, + $credentialConfigurationId, + ), + self::DEFAULT_FILE_NAME, + ); + } + + return $ttl; + } + + /** + * How long a credential of this configuration is valid for, or null if it does not expire. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciCredentialTtlFor(string $credentialConfigurationId): ?DateInterval + { + return $this->getVciCredentialTtls()[$credentialConfigurationId] ?? null; + } + /***************************************************************************************************************** * OpenID Connect Dynamic Client Registration related config. diff --git a/src/StatusList/CredentialStatusIssuer.php b/src/StatusList/CredentialStatusIssuer.php new file mode 100644 index 00000000..3ab13f95 --- /dev/null +++ b/src/StatusList/CredentialStatusIssuer.php @@ -0,0 +1,94 @@ +moduleConfig->getVciStatusListPoolFor($credentialConfigurationId); + + if (!$pool instanceof StatusListPool) { + return null; + } + + // The index is claimed before the credential is signed, because the claim has to be in the + // payload which gets signed. Should signing then fail, the index stays claimed by a credential + // which was never handed out: one slot in a list of many thousands, sitting at the Valid status + // it was seeded with, which is harmless and not worth the machinery it would take to reclaim. + $allocation = $this->statusIndexAllocator->allocateFor( + $pool, + $credentialId, + $credentialConfigurationId, + $this->subjectRefHasher->hash($userIdentifier), + $expiresAt, + ); + + $this->loggerService->debug( + 'Issuing a credential with a Status List reference.', + [ + 'credentialConfigurationId' => $credentialConfigurationId, + 'poolId' => $pool->getId(), + 'statusListId' => $allocation->getStatusListId(), + 'idx' => $allocation->getIdx(), + ], + ); + + return $this->tokenStatusList->statusReferenceFactory()->buildClaim( + $allocation->getUri(), + $allocation->getIdx(), + ); + } +} diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php index be9ac57e..d7313890 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php @@ -4,6 +4,9 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers\VerifiableCredentials; +use DateInterval; +use DateTimeImmutable; +use DateTimeZone; use Jose\Component\Core\JWK; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -13,6 +16,8 @@ use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; +use SimpleSAML\Module\oidc\Exceptions\StatusListException; +use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; @@ -20,29 +25,44 @@ use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\NonceService; +use SimpleSAML\Module\oidc\StatusList\CredentialStatusIssuer; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\Module\oidc\Utils\VciContextResolver; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Did\DidJwkResolver; use SimpleSAML\OpenID\Helpers as VcHelpers; use SimpleSAML\OpenID\Helpers\Arr as VcArr; use SimpleSAML\OpenID\Jwk\JwkDecorator; +use SimpleSAML\OpenID\TokenStatusList\StatusClaim; +use SimpleSAML\OpenID\TokenStatusList\StatusReference; use SimpleSAML\OpenID\ValueAbstracts\KeyPair; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; use SimpleSAML\OpenID\VerifiableCredentials as VerifiableCredentialsService; use SimpleSAML\OpenID\VerifiableCredentials\Factories\OpenId4VciProofFactory; use SimpleSAML\OpenID\VerifiableCredentials\OpenId4VciProof; +use SimpleSAML\OpenID\VerifiableCredentials\SdJwtVc\Factories\SdJwtVcFactory; +use SimpleSAML\OpenID\VerifiableCredentials\SdJwtVc\SdJwtVc; use SimpleSAML\OpenID\VerifiableCredentials\VcDataModel\Factories\JwtVcJsonFactory; use SimpleSAML\OpenID\VerifiableCredentials\VcDataModel\JwtVcJson; +use SimpleSAML\OpenID\VerifiableCredentials\VcDataModel2\Factories\VcSdJwtFactory; +use SimpleSAML\OpenID\VerifiableCredentials\VcDataModel2\VcSdJwt; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; class CredentialIssuerCredentialControllerTest extends TestCase { + protected const string CONFIGURATION_ID = 'test_id'; + + protected const string ISSUER = 'https://issuer.com'; + + protected const string STATUS_LIST_URI = 'https://issuer.com/module.php/oidc/statuslist/list-1'; + protected MockObject $resourceServerMock; protected MockObject $accessTokenRepositoryMock; protected MockObject $moduleConfigMock; @@ -56,6 +76,11 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected MockObject $issuerStateRepositoryMock; protected MockObject $nonceServiceMock; protected MockObject $vciContextResolverMock; + protected MockObject $credentialStatusIssuerMock; + protected Helpers $helpers; + + /** @var array> Payloads handed to whichever credential factory was used. */ + protected array $signedPayloads = []; public function setUp(): void { @@ -72,70 +97,66 @@ public function setUp(): void $this->issuerStateRepositoryMock = $this->createMock(IssuerStateRepository::class); $this->nonceServiceMock = $this->createMock(NonceService::class); $this->vciContextResolverMock = $this->createMock(VciContextResolver::class); + $this->credentialStatusIssuerMock = $this->createMock(CredentialStatusIssuer::class); + $this->helpers = new Helpers(); + $this->signedPayloads = []; // VCI must be enabled in constructor $this->moduleConfigMock->method('getVciEnabled')->willReturn(true); + $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + $this->moduleConfigMock->method('getVciValidCredentialClaimPathsFor')->willReturn([]); + $this->moduleConfigMock->method('getVciUserAttributeToCredentialClaimPathMapFor')->willReturn([]); + + $this->prepareRequestPipeline(); + $this->prepareUser(); + $this->prepareSigningKey(); + $this->prepareCredentialFactories(); } - public function testCredentialWithMultipleProofs(): void + protected function prepareRequestPipeline(): void { - $requestData = [ - 'credential_configuration_id' => 'test_id', - 'proofs' => [ - 'jwt' => ['jwt1', 'jwt2'], - ], - ]; - $request = new Request([], [], [], [], [], [], json_encode($requestData)); - $request->setMethod('POST'); - - // Mock PsrHttpBridge $psrRequestMock = $this->createMock(ServerRequestInterface::class); $psrFactoryMock = $this->createMock(PsrHttpFactory::class); $psrFactoryMock->method('createRequest')->willReturn($psrRequestMock); $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($psrFactoryMock); - // Mock RequestParamsResolver - $this->requestParamsResolverMock->method('getAllFromRequestBasedOnAllowedMethods')->willReturn($requestData); - - // Mock ResourceServer validation $authorizationMock = $this->createMock(ServerRequestInterface::class); $authorizationMock->method('getAttribute')->with('oauth_access_token_id')->willReturn('token_id'); $this->resourceServerMock->method('validateAuthenticatedRequest')->willReturn($authorizationMock); $accessToken = $this->createMock(AccessTokenEntity::class); - $this->accessTokenRepositoryMock->method('findById')->with('token_id')->willReturn($accessToken); - $accessToken->method('getFlowTypeEnum')->willReturn(FlowTypeEnum::VciPreAuthorizedCode); $accessToken->method('getUserIdentifier')->willReturn('user123'); $accessToken->method('getAuthorizationDetails')->willReturn(null); $accessToken->method('getIssuerState')->willReturn(null); $accessToken->method('isRevoked')->willReturn(false); + $this->accessTokenRepositoryMock->method('findById')->with('token_id')->willReturn($accessToken); + } - $this->moduleConfigMock->method('getVciCredentialConfiguration')->willReturn(['format' => 'jwt_vc_json']); - $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.com'); - $this->moduleConfigMock->method('getVciValidCredentialClaimPathsFor')->willReturn([]); - $this->moduleConfigMock->method('getVciUserAttributeToCredentialClaimPathMapFor')->willReturn([]); - + protected function prepareUser(): void + { $userEntity = $this->createMock(UserEntity::class); $userEntity->method('getClaims')->willReturn([]); $this->userRepositoryMock->method('getUserEntityByIdentifier')->willReturn($userEntity); + } - $proofFactoryMock = $this->createMock(OpenId4VciProofFactory::class); - $this->verifiableCredentialsMock->method('openId4VciProofFactory')->willReturn($proofFactoryMock); - - $proofMock1 = $this->createMock(OpenId4VciProof::class); - $proofMock1->method('getAudience')->willReturn(['https://issuer.com']); - $proofMock1->method('getJsonWebKey')->willReturn(['kty' => 'EC']); - $proofMock1->method('getNonce')->willReturn(null); + protected function prepareSigningKey(): void + { + $keyPairMock = $this->createMock(KeyPair::class); + $keyPairMock->method('getPrivateKey')->willReturn($this->createMock(JwkDecorator::class)); + $publicKeyMock = $this->createMock(JwkDecorator::class); + $jwkMock = $this->createMock(JWK::class); + $jwkMock->method('all')->willReturn(['kty' => 'EC']); + $publicKeyMock->method('jwk')->willReturn($jwkMock); + $keyPairMock->method('getPublicKey')->willReturn($publicKeyMock); - $proofMock2 = $this->createMock(OpenId4VciProof::class); - $proofMock2->method('getAudience')->willReturn(['https://issuer.com']); - $proofMock2->method('getJsonWebKey')->willReturn(['kty' => 'EC']); - $proofMock2->method('getNonce')->willReturn(null); + $signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $signatureKeyPairMock->method('getKeyPair')->willReturn($keyPairMock); + $signatureKeyPairMock->method('getSignatureAlgorithm')->willReturn(SignatureAlgorithmEnum::ES256); - $proofFactoryMock->expects($this->exactly(2)) - ->method('fromToken') - ->willReturnOnConsecutiveCalls($proofMock1, $proofMock2); + $signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $signatureKeyPairBagMock->method('getFirstOrFail')->willReturn($signatureKeyPairMock); + $this->moduleConfigMock->method('getVciSignatureKeyPairBag')->willReturn($signatureKeyPairBagMock); $didJwkResolverMock = $this->createMock(DidJwkResolver::class); $this->didMock->method('didJwkResolver')->willReturn($didJwkResolverMock); @@ -143,40 +164,91 @@ public function testCredentialWithMultipleProofs(): void $vcHelpersMock = $this->createMock(VcHelpers::class); $this->verifiableCredentialsMock->method('helpers')->willReturn($vcHelpersMock); - $vcArrMock = $this->createMock(VcArr::class); - $vcHelpersMock->method('arr')->willReturn($vcArrMock); + $vcHelpersMock->method('arr')->willReturn($this->createMock(VcArr::class)); + } + /** + * Every credential factory records the payload it was asked to sign, which is where the claims + * under test end up. + */ + protected function prepareCredentialFactories(): void + { + $jwtVcJsonMock = $this->createMock(JwtVcJson::class); + $jwtVcJsonMock->method('getToken')->willReturn('vc_token'); $jwtVcJsonFactoryMock = $this->createMock(JwtVcJsonFactory::class); + $jwtVcJsonFactoryMock->method('fromData')->willReturnCallback( + function (mixed $key, mixed $algorithm, array $payload) use ($jwtVcJsonMock): JwtVcJson { + $this->signedPayloads[] = $payload; + + return $jwtVcJsonMock; + }, + ); $this->verifiableCredentialsMock->method('jwtVcJsonFactory')->willReturn($jwtVcJsonFactoryMock); - $vcMock = $this->createMock(JwtVcJson::class); - $vcMock->method('getToken')->willReturn('vc_token'); - $jwtVcJsonFactoryMock->method('fromData')->willReturn($vcMock); + $sdJwtVcMock = $this->createMock(SdJwtVc::class); + $sdJwtVcMock->method('getToken')->willReturn('sd_jwt_token'); + $sdJwtVcFactoryMock = $this->createMock(SdJwtVcFactory::class); + $sdJwtVcFactoryMock->method('fromData')->willReturnCallback( + function (mixed $key, mixed $algorithm, array $payload) use ($sdJwtVcMock): SdJwtVc { + $this->signedPayloads[] = $payload; - $keyPairMock = $this->createMock(KeyPair::class); - $keyPairMock->method('getPrivateKey')->willReturn($this->createMock(JwkDecorator::class)); - $publicKeyMock = $this->createMock(JwkDecorator::class); - $jwkMock = $this->createMock(JWK::class); - $jwkMock->method('all')->willReturn(['kty' => 'EC']); - $publicKeyMock->method('jwk')->willReturn($jwkMock); - $keyPairMock->method('getPublicKey')->willReturn($publicKeyMock); + return $sdJwtVcMock; + }, + ); + $this->verifiableCredentialsMock->method('sdJwtVcFactory')->willReturn($sdJwtVcFactoryMock); - $signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); - $signatureKeyPairMock->method('getKeyPair')->willReturn($keyPairMock); - $signatureKeyPairMock->method('getSignatureAlgorithm')->willReturn(SignatureAlgorithmEnum::ES256); + $vcSdJwtMock = $this->createMock(VcSdJwt::class); + $vcSdJwtMock->method('getToken')->willReturn('vc_sd_jwt_token'); + $vcSdJwtFactoryMock = $this->createMock(VcSdJwtFactory::class); + $vcSdJwtFactoryMock->method('fromData')->willReturnCallback( + function (mixed $key, mixed $algorithm, array $payload) use ($vcSdJwtMock): VcSdJwt { + $this->signedPayloads[] = $payload; - $signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); - $signatureKeyPairBagMock->method('getFirstOrFail')->willReturn($signatureKeyPairMock); - $this->moduleConfigMock->method('getVciSignatureKeyPairBag')->willReturn($signatureKeyPairBagMock); + return $vcSdJwtMock; + }, + ); + $this->verifiableCredentialsMock->method('vcSdJwtFactory')->willReturn($vcSdJwtFactoryMock); + } - $this->routesMock->expects($this->once()) - ->method('newJsonResponse') - ->with($this->callback(function ($data) { - return isset($data['credentials']) && count($data['credentials']) === 2; - })) - ->willReturn($this->createMock(JsonResponse::class)); + /** + * @param string[] $proofJwts + */ + protected function issue( + string $format = CredentialFormatIdentifiersEnum::JwtVcJson->value, + array $proofJwts = ['jwt1'], + ): void { + $this->moduleConfigMock->method('getVciCredentialConfiguration') + ->willReturn([ClaimsEnum::Format->value => $format]); + + $requestData = [ + 'credential_configuration_id' => self::CONFIGURATION_ID, + 'proofs' => ['jwt' => $proofJwts], + ]; + $this->requestParamsResolverMock->method('getAllFromRequestBasedOnAllowedMethods') + ->willReturn($requestData); + + $proofFactoryMock = $this->createMock(OpenId4VciProofFactory::class); + $this->verifiableCredentialsMock->method('openId4VciProofFactory')->willReturn($proofFactoryMock); + + $proofMocks = []; + foreach ($proofJwts as $ignored) { + $proofMock = $this->createMock(OpenId4VciProof::class); + $proofMock->method('getAudience')->willReturn([self::ISSUER]); + $proofMock->method('getJsonWebKey')->willReturn(['kty' => 'EC']); + $proofMock->method('getNonce')->willReturn(null); + $proofMocks[] = $proofMock; + } + $proofFactoryMock->method('fromToken')->willReturnOnConsecutiveCalls(...$proofMocks); + + $request = new Request([], [], [], [], [], [], json_encode($requestData)); + $request->setMethod('POST'); + + $this->sut()->credential($request); + } - $sut = new CredentialIssuerCredentialController( + protected function sut(): CredentialIssuerCredentialController + { + return new CredentialIssuerCredentialController( $this->resourceServerMock, $this->accessTokenRepositoryMock, $this->moduleConfigMock, @@ -190,8 +262,265 @@ public function testCredentialWithMultipleProofs(): void $this->issuerStateRepositoryMock, $this->nonceServiceMock, $this->vciContextResolverMock, + $this->credentialStatusIssuerMock, + $this->helpers, ); + } - $sut->credential($request); + /** + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + protected function statusClaim(int $idx = 42): StatusClaim + { + return new StatusClaim(new StatusReference(self::STATUS_LIST_URI, $idx)); + } + + public function testCredentialWithMultipleProofs(): void + { + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback( + fn(array $data): bool => isset($data['credentials']) && count($data['credentials']) === 2, + )) + ->willReturn($this->createMock(JsonResponse::class)); + + $this->issue(proofJwts: ['jwt1', 'jwt2']); + } + + /** + * The identifier is the key revocation is later requested by, so anyone able to guess one could ask + * for a credential they were never issued to be withdrawn. + */ + public function testTheCredentialIdentifierIsUnpredictable(): void + { + $this->issue(proofJwts: ['jwt1', 'jwt2']); + + $identifiers = array_column($this->signedPayloads, ClaimsEnum::Jti->value); + + $this->assertCount(2, $identifiers); + $this->assertNotSame($identifiers[0], $identifiers[1]); + + foreach ($identifiers as $identifier) { + // Still a URI, since the credential parsers enforce that of a `jti`. + $this->assertMatchesRegularExpression( + '#^' . preg_quote(self::ISSUER, '#') . '/vc/[0-9a-f]{64}$#', + (string)$identifier, + ); + } + } + + /** + * A request carrying several proofs is issued several credentials, and each one has to be + * revocable on its own. + */ + public function testAllocatesAStatusListEntryForEachIssuedCredential(): void + { + $allocatedFor = []; + + $this->credentialStatusIssuerMock->expects($this->exactly(2)) + ->method('issueFor') + ->willReturnCallback( + function ( + string $credentialConfigurationId, + string $credentialId, + ) use (&$allocatedFor): StatusClaim { + $allocatedFor[] = $credentialId; + + return $this->statusClaim(count($allocatedFor)); + }, + ); + + $this->issue(proofJwts: ['jwt1', 'jwt2']); + + $this->assertCount(2, $allocatedFor); + $this->assertNotSame($allocatedFor[0], $allocatedFor[1]); + // The identifier is minted before allocation, because claiming the index and recording what it + // was claimed for are one operation. + $this->assertSame( + $allocatedFor, + array_column($this->signedPayloads, ClaimsEnum::Jti->value), + ); + } + + public function testMergesTheStatusClaimIntoTheCredential(): void + { + $this->credentialStatusIssuerMock->method('issueFor')->willReturn($this->statusClaim(7)); + + $this->issue(); + + $this->assertSame( + [ + ClaimsEnum::StatusList->value => [ + ClaimsEnum::Idx->value => 7, + ClaimsEnum::Uri->value => self::STATUS_LIST_URI, + ], + ], + $this->signedPayloads[0][ClaimsEnum::Status->value] ?? null, + ); + } + + /** + * The Status List specification places the claim at the top level of a JOSE Referenced Token, not + * inside the credential body of the W3C formats. + */ + public function testTheStatusClaimIsNotPlacedInsideTheCredentialBody(): void + { + $this->credentialStatusIssuerMock->method('issueFor')->willReturn($this->statusClaim()); + + $this->issue(); + + $this->assertArrayHasKey(ClaimsEnum::Status->value, $this->signedPayloads[0]); + $this->assertArrayNotHasKey( + ClaimsEnum::Status->value, + (array)($this->signedPayloads[0][ClaimsEnum::Vc->value] ?? []), + ); + } + + public function testCarriesTheStatusClaimInEverySupportedFormat(): void + { + foreach ( + [ + CredentialFormatIdentifiersEnum::JwtVcJson->value, + CredentialFormatIdentifiersEnum::DcSdJwt->value, + CredentialFormatIdentifiersEnum::VcSdJwt->value, + ] as $format + ) { + $this->setUp(); + $this->credentialStatusIssuerMock->method('issueFor')->willReturn($this->statusClaim()); + + $this->issue($format); + + $this->assertArrayHasKey( + ClaimsEnum::Status->value, + $this->signedPayloads[0], + sprintf('Format "%s" was issued without a status claim.', $format), + ); + } + } + + /** + * A configuration which belongs to no pool was never meant to be revocable, and its credentials are + * issued exactly as before. + */ + public function testIssuesWithoutAStatusClaimWhenTheConfigurationHasNoPool(): void + { + $this->credentialStatusIssuerMock->method('issueFor')->willReturn(null); + + $this->routesMock->expects($this->once())->method('newJsonResponse') + ->willReturn($this->createMock(JsonResponse::class)); + + $this->issue(); + + $this->assertArrayNotHasKey(ClaimsEnum::Status->value, $this->signedPayloads[0]); + } + + /** + * Issuing anyway would hand out a credential which can never be withdrawn, with nothing on it to + * say so, which is worse than refusing the request. + */ + public function testRefusesToIssueWhenAStatusListEntryCanNotBeAllocated(): void + { + $this->credentialStatusIssuerMock->method('issueFor') + ->willThrowException(new StatusListException('no list available')); + + $this->routesMock->expects($this->never())->method('newJsonResponse'); + $this->routesMock->expects($this->once()) + ->method('newJsonErrorResponse') + ->with('server_error', $this->anything(), 500) + ->willReturn($this->createMock(JsonResponse::class)); + + $this->issue(); + + $this->assertSame([], $this->signedPayloads); + } + + /** + * No lifetime is configured by default, and adding one changes what already issued credentials + * mean, so nothing expires unless an operator asks for it. + */ + public function testCredentialsDoNotExpireByDefault(): void + { + $this->moduleConfigMock->method('getVciCredentialTtlFor')->willReturn(null); + + $this->issue(); + + $this->assertArrayNotHasKey(ClaimsEnum::Exp->value, $this->signedPayloads[0]); + $this->assertArrayNotHasKey( + ClaimsEnum::Expiration_Date->value, + (array)($this->signedPayloads[0][ClaimsEnum::Vc->value] ?? []), + ); + } + + public function testAppliesTheConfiguredCredentialLifetime(): void + { + $this->moduleConfigMock->method('getVciCredentialTtlFor')->willReturn(new DateInterval('P30D')); + + $this->issue(); + + $payload = $this->signedPayloads[0]; + + // The expectation adds the interval the same way issuance does, in the same timezone, rather + // than assuming 30 days is 30 times 86400 seconds. It is not, on either side of a daylight + // saving transition, and asserting the arithmetic would make this test fail seasonally. + $expiresAt = (new DateTimeImmutable('@' . $payload[ClaimsEnum::Iat->value])) + ->setTimezone(new DateTimeZone(date_default_timezone_get())) + ->add(new DateInterval('P30D')); + + $this->assertSame($expiresAt->getTimestamp(), $payload[ClaimsEnum::Exp->value] ?? null); + + // Stated in the credential body too, the way the issuance date already is, and naming the + // same moment as the claim above rather than a second, differently derived one. + $verifiableCredentialBody = (array)($payload[ClaimsEnum::Vc->value] ?? []); + $expirationDate = $verifiableCredentialBody[ClaimsEnum::Expiration_Date->value] ?? null; + + $this->assertIsString($expirationDate); + $this->assertSame( + $expiresAt->getTimestamp(), + (new DateTimeImmutable($expirationDate))->getTimestamp(), + ); + } + + /** + * The Verifiable Credentials Data Model 2.0 names the end of validity `validUntil`, alongside the + * `validFrom` this format already emits. + */ + public function testTheDataModelTwoFormatAlsoStatesTheLifetimeAsValidUntil(): void + { + $this->moduleConfigMock->method('getVciCredentialTtlFor')->willReturn(new DateInterval('P30D')); + + $this->issue(CredentialFormatIdentifiersEnum::VcSdJwt->value); + + $payload = $this->signedPayloads[0]; + + $this->assertArrayHasKey(ClaimsEnum::ValidUntil->value, $payload); + $this->assertArrayHasKey(ClaimsEnum::Exp->value, $payload); + } + + public function testTheStatusListEntryIsAllocatedWithTheCredentialLifetime(): void + { + $this->moduleConfigMock->method('getVciCredentialTtlFor')->willReturn(new DateInterval('P30D')); + + $expiresAt = null; + $this->credentialStatusIssuerMock->method('issueFor')->willReturnCallback( + function ( + string $credentialConfigurationId, + string $credentialId, + string $userIdentifier, + mixed $configuredExpiresAt, + ) use (&$expiresAt): StatusClaim { + $expiresAt = $configuredExpiresAt; + + return $this->statusClaim(); + }, + ); + + $this->issue(); + + // Otherwise the entry could never be cleaned up, and its list could never be retired. + $this->assertSame( + $this->signedPayloads[0][ClaimsEnum::Exp->value] ?? null, + $expiresAt?->getTimestamp(), + ); } } diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index f609e834..a056b680 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -882,4 +882,87 @@ protected function withStatusListPool(bool $isEnabled): array ], ); } + + /** + * Not expiring is what this module has always done, and an expiry changes what already issued + * credentials mean, so it stays something an operator asks for. + * + * @throws \Exception + */ + public function testCredentialsHaveNoLifetimeUnlessOneIsConfigured(): void + { + $sut = $this->sut(); + + $this->assertSame([], $sut->getVciCredentialTtls()); + $this->assertNull($sut->getVciCredentialTtlFor('TestCredential')); + } + + /** + * @throws \Exception + */ + public function testResolvesTheConfiguredCredentialLifetime(): void + { + $sut = $this->sut(overrides: $this->withCredentialTtl('P30D')); + + $this->assertSame(30, $sut->getVciCredentialTtlFor('TestCredential')?->d); + // Configurations which are not listed keep issuing credentials which never expire. + $this->assertNull($sut->getVciCredentialTtlFor('SomethingElse')); + } + + /** + * As with the pools, a typo would otherwise be silent: credentials which were meant to expire + * would go on being issued without an expiry and nothing would say so. + * + * @throws \Exception + */ + public function testCredentialLifetimesRejectAnUnknownCredentialConfiguration(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('NoSuchCredential'); + + $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED => ['TestCredential' => []], + ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS => ['NoSuchCredential' => 'P30D'], + ], + ))->getVciCredentialTtls(); + } + + /** + * @throws \Exception + */ + public function testCredentialLifetimesRejectAnUnparseableDuration(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withCredentialTtl('thirty days'))->getVciCredentialTtls(); + } + + /** + * A zero lifetime would issue credentials which have already expired, which is never what was + * meant. Leaving the entry out is how a configuration says its credentials do not expire. + * + * @throws \Exception + */ + public function testCredentialLifetimesRejectADurationOfNoTime(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withCredentialTtl('PT0S'))->getVciCredentialTtls(); + } + + /** + * @return array + */ + protected function withCredentialTtl(mixed $ttl): array + { + return array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED => ['TestCredential' => []], + ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS => ['TestCredential' => $ttl], + ], + ); + } } diff --git a/tests/unit/src/StatusList/CredentialStatusIssuerTest.php b/tests/unit/src/StatusList/CredentialStatusIssuerTest.php new file mode 100644 index 00000000..75594a27 --- /dev/null +++ b/tests/unit/src/StatusList/CredentialStatusIssuerTest.php @@ -0,0 +1,168 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->statusIndexAllocatorMock = $this->createMock(StatusIndexAllocatorInterface::class); + $this->subjectRefHasherMock = $this->createMock(SubjectRefHasher::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + + // The real reference factory, since the claim it builds is the value under test. + $this->tokenStatusList = $this->createMock(TokenStatusList::class); + $this->tokenStatusList->method('statusReferenceFactory') + ->willReturn(new StatusReferenceFactory(new OpenIdHelpers())); + } + + protected function sut(): CredentialStatusIssuer + { + return new CredentialStatusIssuer( + $this->moduleConfigMock, + $this->statusIndexAllocatorMock, + $this->subjectRefHasherMock, + $this->tokenStatusList, + $this->loggerServiceMock, + ); + } + + /** + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + protected function expectAllocation(int $idx = 5): void + { + $this->moduleConfigMock->method('getVciStatusListPoolFor') + ->willReturn($this->createMock(StatusListPool::class)); + $this->statusIndexAllocatorMock->method('allocateFor') + ->willReturn(new StatusAllocation('list-1', new StatusReference(self::LIST_URI, $idx))); + } + + /** + * @throws \Exception + */ + public function testBuildsTheClaimFromTheAllocation(): void + { + $this->expectAllocation(7); + + $claim = $this->sut()->issueFor(self::CONFIGURATION_ID, self::CREDENTIAL_ID, self::USER_IDENTIFIER); + + $this->assertSame( + [ + ClaimsEnum::Status->value => [ + ClaimsEnum::StatusList->value => [ + ClaimsEnum::Idx->value => 7, + ClaimsEnum::Uri->value => self::LIST_URI, + ], + ], + ], + $claim?->jsonSerialize(), + ); + } + + /** + * A configuration which belongs to no pool was never set up to be revocable, so its credentials + * are issued exactly as they were before. + * + * @throws \Exception + */ + public function testIssuesNoClaimForAConfigurationWithoutAPool(): void + { + $this->moduleConfigMock->method('getVciStatusListPoolFor')->willReturn(null); + $this->statusIndexAllocatorMock->expects($this->never())->method('allocateFor'); + + $this->assertNull( + $this->sut()->issueFor(self::CONFIGURATION_ID, self::CREDENTIAL_ID, self::USER_IDENTIFIER), + ); + } + + /** + * Swallowing this would produce a credential which can never be withdrawn, with nothing on it to + * say that it is the exception. + * + * @throws \Exception + */ + public function testRaisesAFailureToAllocateRatherThanIssuingWithoutAClaim(): void + { + $this->moduleConfigMock->method('getVciStatusListPoolFor') + ->willReturn($this->createMock(StatusListPool::class)); + $this->statusIndexAllocatorMock->method('allocateFor') + ->willThrowException(new StatusListException('no list available')); + + $this->expectException(StatusListException::class); + + $this->sut()->issueFor(self::CONFIGURATION_ID, self::CREDENTIAL_ID, self::USER_IDENTIFIER); + } + + /** + * The user identifier itself never reaches storage: what is recorded is a keyed hash of it. + * + * @throws \Exception + */ + public function testStoresAHashOfTheUserIdentifierRatherThanTheIdentifier(): void + { + $this->moduleConfigMock->method('getVciStatusListPoolFor') + ->willReturn($this->createMock(StatusListPool::class)); + $this->subjectRefHasherMock->expects($this->once()) + ->method('hash') + ->with(self::USER_IDENTIFIER) + ->willReturn('hashed-subject'); + + $expiresAt = new DateTimeImmutable('+30 days'); + + $this->statusIndexAllocatorMock->expects($this->once()) + ->method('allocateFor') + ->with( + $this->anything(), + self::CREDENTIAL_ID, + self::CONFIGURATION_ID, + 'hashed-subject', + $expiresAt, + ) + ->willReturn(new StatusAllocation('list-1', new StatusReference(self::LIST_URI, 1))); + + $this->sut()->issueFor( + self::CONFIGURATION_ID, + self::CREDENTIAL_ID, + self::USER_IDENTIFIER, + $expiresAt, + ); + } +} From f4259a2f945fcecede81fd3b722d64c3ee67ba3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 7 Aug 2026 13:04:31 +0200 Subject: [PATCH 5/9] Add credential status service and revocation API endpoint --- config/module_oidc.php.dist | 32 ++ locales/en/LC_MESSAGES/oidc.po | 27 + locales/es/LC_MESSAGES/oidc.po | 27 + locales/fr/LC_MESSAGES/oidc.po | 27 + locales/hr/LC_MESSAGES/oidc.po | 27 + locales/it/LC_MESSAGES/oidc.po | 27 + locales/nl/LC_MESSAGES/oidc.po | 27 + routing/routes/routes.php | 7 + .../ConfigOverview/VciOverviewBuilder.php | 49 ++ src/Codebooks/ApiScopesEnum.php | 1 + src/Codebooks/RoutesEnum.php | 1 + .../Api/VciCredentialStatusApiController.php | 255 ++++++++++ src/Exceptions/InsufficientScopeException.php | 18 + src/Exceptions/MissingTokenException.php | 17 + src/ModuleConfig.php | 124 ++++- src/Repositories/StatusAuditRepository.php | 23 +- .../Api/ApiTokenPrincipalResolver.php | 151 ++++++ src/Services/Api/Authorization.php | 62 +++ .../Contracts/StatusUpdaterInterface.php | 15 + src/StatusList/CredentialStatusService.php | 196 ++++++++ src/StatusList/DbStatusUpdater.php | 31 +- .../Values/CredentialStatusChange.php | 61 +++ src/Utils/Routes.php | 5 + .../VciCredentialStatusApiControllerTest.php | 467 ++++++++++++++++++ tests/unit/src/ModuleConfigTest.php | 177 +++++++ .../StatusAuditRepositoryTest.php | 212 ++++++++ .../Api/ApiTokenPrincipalResolverTest.php | 182 +++++++ .../src/Services/Api/AuthorizationTest.php | 232 +++++++++ .../CredentialStatusServiceTest.php | 351 +++++++++++++ 29 files changed, 2819 insertions(+), 12 deletions(-) create mode 100644 src/Controllers/Api/VciCredentialStatusApiController.php create mode 100644 src/Exceptions/InsufficientScopeException.php create mode 100644 src/Exceptions/MissingTokenException.php create mode 100644 src/Services/Api/ApiTokenPrincipalResolver.php create mode 100644 src/StatusList/CredentialStatusService.php create mode 100644 src/StatusList/Values/CredentialStatusChange.php create mode 100644 tests/unit/src/Controllers/Api/VciCredentialStatusApiControllerTest.php create mode 100644 tests/unit/src/Repositories/StatusAuditRepositoryTest.php create mode 100644 tests/unit/src/Services/Api/ApiTokenPrincipalResolverTest.php create mode 100644 tests/unit/src/Services/Api/AuthorizationTest.php create mode 100644 tests/unit/src/StatusList/CredentialStatusServiceTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index c9582ec5..41e830e9 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1729,6 +1729,24 @@ $config = [ */ ModuleConfig::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED => false, + /** + * (optional) API Enable VCI Credential Status API endpoint, through which + * an issued credential can be revoked, suspended or reinstated. Default is + * disabled (false). Only relevant if API capabilities are enabled. + * + * Separate from the Status List capability itself, which governs whether + * credentials get an entry allocated at all. This governs whether that + * entry can be changed over the network, which is a decision of its own. + * + * Unlike the rest of this API, this endpoint accepts a bearer token in the + * Authorization header only. It does not accept an administrator's session + * and it does not read the token from a request parameter: the first would + * let an administrator's browser be driven into revoking a credential from + * another site, and the second would leave a bearer secret in access logs. + * Administrators revoke through the administration screens instead. + */ + ModuleConfig::OPTION_API_VCI_CREDENTIAL_STATUS_ENDPOINT_ENABLED => false, + /** * (optional) API Enable OAuth2 Token Introspection API endpoint. Default * is disabled (false). Only relevant if API capabilities are enabled. @@ -1738,6 +1756,14 @@ $config = [ /** * List of API tokens which can be used to access API endpoints based on * given scopes. The format is: ['token' => [ApiScopesEnum]] + * + * A token may instead be given a settings array, which is the same thing + * plus a name: ['token' => ['name' => '...', 'scopes' => [ApiScopesEnum]]] + * The name is what the audit trail records as the actor behind a status + * change, so it is worth setting for any token allowed to revoke a + * credential. A token without one is recorded as an opaque fingerprint + * instead, which keeps separate callers apart in the trail but says + * nothing about who they are. The token itself is never recorded. */ ModuleConfig::OPTION_API_TOKENS => [ // 'strong-random-token-string' => [ @@ -1749,6 +1775,12 @@ $config = [ // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2All, // Gives access to all OAuth2-related endpoints. // \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2TokenIntrospection, // Gives access to the token introspection endpoint. +// ], +// 'strong-random-token-string-3' => [ +// 'name' => 'HR system', // Recorded in the status change audit trail. +// 'scopes' => [ +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialStatus, // Gives access to the credential status endpoint. +// ], // ], ], ]; diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index 94e2c631..354af826 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -1624,3 +1624,30 @@ msgid "" "How long a credential of each configuration stays valid. Configurations " "which are not listed issue credentials which never expire." msgstr "" + +msgid "Credential Status (API)" +msgstr "" + +msgid "Credential Status Endpoint Enabled" +msgstr "" + +msgid "" +"Issued credentials can be revoked, suspended and reinstated over the " +"network. This endpoint takes a bearer token from the Authorization header " +"only, never an administrator session, and every change made through it is " +"recorded against the token which asked for it." +msgstr "" + +msgid "" +"Not served, so credential statuses can only be changed from the " +"administration screens. Also requires the module API to be enabled." +msgstr "" + +msgid "" +"Status Lists are disabled, so credentials being issued now have no entry to " +"change. Ones issued while they were enabled can still be revoked through " +"this endpoint." +msgstr "" + +msgid "Authorization token not provided in the Authorization header." +msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index f81f71aa..27475574 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -1624,3 +1624,30 @@ msgid "" "How long a credential of each configuration stays valid. Configurations " "which are not listed issue credentials which never expire." msgstr "" + +msgid "Credential Status (API)" +msgstr "" + +msgid "Credential Status Endpoint Enabled" +msgstr "" + +msgid "" +"Issued credentials can be revoked, suspended and reinstated over the " +"network. This endpoint takes a bearer token from the Authorization header " +"only, never an administrator session, and every change made through it is " +"recorded against the token which asked for it." +msgstr "" + +msgid "" +"Not served, so credential statuses can only be changed from the " +"administration screens. Also requires the module API to be enabled." +msgstr "" + +msgid "" +"Status Lists are disabled, so credentials being issued now have no entry to " +"change. Ones issued while they were enabled can still be revoked through " +"this endpoint." +msgstr "" + +msgid "Authorization token not provided in the Authorization header." +msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index 1ac3a327..2699744f 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -1624,3 +1624,30 @@ msgid "" "How long a credential of each configuration stays valid. Configurations " "which are not listed issue credentials which never expire." msgstr "" + +msgid "Credential Status (API)" +msgstr "" + +msgid "Credential Status Endpoint Enabled" +msgstr "" + +msgid "" +"Issued credentials can be revoked, suspended and reinstated over the " +"network. This endpoint takes a bearer token from the Authorization header " +"only, never an administrator session, and every change made through it is " +"recorded against the token which asked for it." +msgstr "" + +msgid "" +"Not served, so credential statuses can only be changed from the " +"administration screens. Also requires the module API to be enabled." +msgstr "" + +msgid "" +"Status Lists are disabled, so credentials being issued now have no entry to " +"change. Ones issued while they were enabled can still be revoked through " +"this endpoint." +msgstr "" + +msgid "Authorization token not provided in the Authorization header." +msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index ab18851c..d14e6f03 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -1672,3 +1672,30 @@ msgid "" "How long a credential of each configuration stays valid. Configurations " "which are not listed issue credentials which never expire." msgstr "" + +msgid "Credential Status (API)" +msgstr "" + +msgid "Credential Status Endpoint Enabled" +msgstr "" + +msgid "" +"Issued credentials can be revoked, suspended and reinstated over the " +"network. This endpoint takes a bearer token from the Authorization header " +"only, never an administrator session, and every change made through it is " +"recorded against the token which asked for it." +msgstr "" + +msgid "" +"Not served, so credential statuses can only be changed from the " +"administration screens. Also requires the module API to be enabled." +msgstr "" + +msgid "" +"Status Lists are disabled, so credentials being issued now have no entry to " +"change. Ones issued while they were enabled can still be revoked through " +"this endpoint." +msgstr "" + +msgid "Authorization token not provided in the Authorization header." +msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index 3bf06c30..0d3a24dc 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -1624,3 +1624,30 @@ msgid "" "How long a credential of each configuration stays valid. Configurations " "which are not listed issue credentials which never expire." msgstr "" + +msgid "Credential Status (API)" +msgstr "" + +msgid "Credential Status Endpoint Enabled" +msgstr "" + +msgid "" +"Issued credentials can be revoked, suspended and reinstated over the " +"network. This endpoint takes a bearer token from the Authorization header " +"only, never an administrator session, and every change made through it is " +"recorded against the token which asked for it." +msgstr "" + +msgid "" +"Not served, so credential statuses can only be changed from the " +"administration screens. Also requires the module API to be enabled." +msgstr "" + +msgid "" +"Status Lists are disabled, so credentials being issued now have no entry to " +"change. Ones issued while they were enabled can still be revoked through " +"this endpoint." +msgstr "" + +msgid "Authorization token not provided in the Authorization header." +msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 47428b2b..96f62280 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -1578,3 +1578,30 @@ msgid "" "How long a credential of each configuration stays valid. Configurations " "which are not listed issue credentials which never expire." msgstr "" + +msgid "Credential Status (API)" +msgstr "" + +msgid "Credential Status Endpoint Enabled" +msgstr "" + +msgid "" +"Issued credentials can be revoked, suspended and reinstated over the " +"network. This endpoint takes a bearer token from the Authorization header " +"only, never an administrator session, and every change made through it is " +"recorded against the token which asked for it." +msgstr "" + +msgid "" +"Not served, so credential statuses can only be changed from the " +"administration screens. Also requires the module API to be enabled." +msgstr "" + +msgid "" +"Status Lists are disabled, so credentials being issued now have no entry to " +"change. Ones issued while they were enabled can still be revoked through " +"this endpoint." +msgstr "" + +msgid "Authorization token not provided in the Authorization header." +msgstr "" diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 7dde72db..7669e44e 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -13,6 +13,7 @@ use SimpleSAML\Module\oidc\Controllers\Admin\FederationTestController; use SimpleSAML\Module\oidc\Controllers\Admin\VerifiableCredentailsTestController; use SimpleSAML\Module\oidc\Controllers\Api\VciCredentialOfferApiController; +use SimpleSAML\Module\oidc\Controllers\Api\VciCredentialStatusApiController; use SimpleSAML\Module\oidc\Controllers\AuthorizationController; use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; use SimpleSAML\Module\oidc\Controllers\EndSessionController; @@ -185,6 +186,12 @@ )->controller([VciCredentialOfferApiController::class, 'credentialOffer']) ->methods([HttpMethodsEnum::POST->value]); + $routes->add( + RoutesEnum::ApiVciCredentialStatus->name, + RoutesEnum::ApiVciCredentialStatus->value, + )->controller([VciCredentialStatusApiController::class, 'credentialStatus']) + ->methods([HttpMethodsEnum::POST->value]); + $routes->add( RoutesEnum::ApiOAuth2TokenIntrospection->name, RoutesEnum::ApiOAuth2TokenIntrospection->value, diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index 3d1c93ff..ab6334a2 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -176,6 +176,36 @@ function (): Row { ); }, ), + $this->guardRow( + Translate::noop('Credential Status Endpoint Enabled'), + ModuleConfig::OPTION_API_VCI_CREDENTIAL_STATUS_ENDPOINT_ENABLED, + function () use ($isEnabled): Row { + $isStatusEndpointEnabled = $this->moduleConfig->getApiVciCredentialStatusEndpointEnabled(); + + return new Row( + Translate::noop('Credential Status Endpoint Enabled'), + $this->yesNo($isStatusEndpointEnabled), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_API_VCI_CREDENTIAL_STATUS_ENDPOINT_ENABLED, + $isStatusEndpointEnabled ? + Translate::noop( + 'Issued credentials can be revoked, suspended and reinstated over the ' . + 'network. This endpoint takes a bearer token from the Authorization ' . + 'header only, never an administrator session, and every change made ' . + 'through it is recorded against the token which asked for it.', + ) : + Translate::noop( + 'Not served, so credential statuses can only be changed from the ' . + 'administration screens. Also requires the module API to be enabled.', + ), + ($isStatusEndpointEnabled && !$isEnabled) ? Translate::noop( + 'Status Lists are disabled, so credentials being issued now have no ' . + 'entry to change. Ones issued while they were enabled can still be ' . + 'revoked through this endpoint.', + ) : null, + ); + }, + ), ]; return new Section(Translate::noop('Status Lists'), 'statusLists', ...$rows); @@ -311,6 +341,25 @@ protected function buildEndpointsSection(): Section ); } + // Deliberately not conditioned on the issuance switch, because the controller is not either: + // credentials already in wallets stay revocable while issuance is off. Requiring it here + // would hide the URL of an endpoint which is live, and hide it precisely during the incident + // that made someone turn issuance off. + try { + $isStatusEndpointServed = $this->moduleConfig->getApiEnabled() && + $this->moduleConfig->getApiVciCredentialStatusEndpointEnabled(); + } catch (Throwable) { + $isStatusEndpointServed = false; + } + + if ($isStatusEndpointServed) { + $rows[] = new Row( + Translate::noop('Credential Status (API)'), + $this->routes->urlApiVciCredentialStatus(), + ConfigOverviewValueTypeEnum::Url, + ); + } + return new Section(Translate::noop('Endpoints'), 'endpoints', ...$rows); } diff --git a/src/Codebooks/ApiScopesEnum.php b/src/Codebooks/ApiScopesEnum.php index b87ba692..2f6504fa 100644 --- a/src/Codebooks/ApiScopesEnum.php +++ b/src/Codebooks/ApiScopesEnum.php @@ -11,6 +11,7 @@ enum ApiScopesEnum: string // Verifiable Credential Issuance related scopes. case VciAll = 'vci_all'; // Gives access to all VCI-related endpoints. case VciCredentialOffer = 'vci_credential_offer'; // Gives access to the credential offer endpoint. + case VciCredentialStatus = 'vci_credential_status'; // Gives access to the credential status endpoint. // OAuth2 related scopes. case OAuth2All = 'oauth2_all'; // Gives access to all OAuth2-related endpoints. diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 903185e8..c8911dbf 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -91,5 +91,6 @@ enum RoutesEnum: string ****************************************************************************************************************/ case ApiVciCredentialOffer = 'api/vci/credential-offer'; + case ApiVciCredentialStatus = 'api/vci/credential-status'; case ApiOAuth2TokenIntrospection = 'api/oauth2/token-introspection'; } diff --git a/src/Controllers/Api/VciCredentialStatusApiController.php b/src/Controllers/Api/VciCredentialStatusApiController.php new file mode 100644 index 00000000..1a78e35e --- /dev/null +++ b/src/Controllers/Api/VciCredentialStatusApiController.php @@ -0,0 +1,255 @@ +moduleConfig->getApiEnabled()) { + $this->loggerService->warning('API capabilities not enabled.'); + throw OidcServerException::forbidden('API capabilities not enabled.'); + } + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function credentialStatus(Request $request): Response + { + if (!$this->moduleConfig->getApiVciCredentialStatusEndpointEnabled()) { + $this->loggerService->warning('Credential Status API endpoint not enabled.'); + throw OidcServerException::forbidden('Credential Status API endpoint not enabled.'); + } + + try { + $actorRef = $this->authorization->requireBearerTokenForAnyOfScope( + $request, + [ApiScopesEnum::VciCredentialStatus, ApiScopesEnum::VciAll, ApiScopesEnum::All], + ); + } catch (InsufficientScopeException $exception) { + // The caller is who it says it is and this is not theirs to do. Answering 401 would invite + // it to rotate a token that is working perfectly well; the fix is a scope in the + // configuration, which only an operator can make. + $this->loggerService->error( + 'VciCredentialStatusApiController: InsufficientScopeException: ' . $exception->getMessage(), + ); + + return $this->routes->newJsonErrorResponse( + error: 'insufficient_scope', + description: $exception->getMessage(), + httpCode: Response::HTTP_FORBIDDEN, + headers: [self::HEADER_WWW_AUTHENTICATE => sprintf( + 'Bearer error="insufficient_scope", scope="%s"', + ApiScopesEnum::VciCredentialStatus->value, + )], + ); + } catch (AuthorizationException $exception) { + $this->loggerService->error( + 'VciCredentialStatusApiController: AuthorizationException: ' . $exception->getMessage(), + ); + + // The challenge is what tells a client this endpoint wants a bearer token, rather than + // leaving it to guess from a bare 401. A request which carried no token gets the bare + // challenge: RFC 6750 keeps `invalid_token` for a token which actually arrived, and a + // client told its token was rejected when it never sent one may rotate a working one. + return $this->routes->newJsonErrorResponse( + error: 'unauthorized', + description: $exception->getMessage(), + httpCode: Response::HTTP_UNAUTHORIZED, + headers: [self::HEADER_WWW_AUTHENTICATE => $exception instanceof MissingTokenException ? + 'Bearer' : + 'Bearer error="invalid_token"', + ], + ); + } + + try { + $input = $request->getPayload()->all(); + } catch (Throwable) { + // A body which is not parseable at all. Left to propagate it would surface as a 500, + // telling the caller the server broke when in fact its request did. + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Request body could not be read.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + /** @var mixed $credentialId */ + $credentialId = $input[self::PARAM_CREDENTIAL_ID] ?? null; + + if (!is_string($credentialId) || trim($credentialId) === '') { + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: sprintf('No credential identifier (%s) provided.', self::PARAM_CREDENTIAL_ID), + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + /** @var mixed $requestedStatus */ + $requestedStatus = $input[self::PARAM_STATUS] ?? null; + $status = is_string($requestedStatus) ? $this->resolveStatus($requestedStatus) : null; + + if (!$status instanceof StatusTypeEnum) { + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: sprintf( + 'Status (%s) must be one of: %s.', + self::PARAM_STATUS, + implode(', ', $this->supportedStatuses()), + ), + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + try { + $change = $this->credentialStatusService->setStatus( + trim($credentialId), + $status, + StatusChangeSourceEnum::Api, + $actorRef, + ); + } catch (UnsupportedStatusException $exception) { + // Permanent: the number of bits per entry is fixed when a list is created, so no amount of + // retrying will make this list able to carry the status. Saying so is more use than a 500. + $this->loggerService->error( + 'VciCredentialStatusApiController: requested status can not be represented: ' . + $exception->getMessage(), + ); + + return $this->routes->newJsonErrorResponse( + error: 'unsupported_status', + description: $exception->getMessage(), + httpCode: Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } catch (StatusConflictException $exception) { + // The credential ended up holding something other than what was asked for, so reporting + // success would say a credential was withdrawn when it was not. + $this->loggerService->error( + 'VciCredentialStatusApiController: status change lost to concurrent changes: ' . + $exception->getMessage(), + ); + + return $this->routes->newJsonErrorResponse( + error: 'conflict', + description: $exception->getMessage(), + httpCode: Response::HTTP_CONFLICT, + ); + } catch (Throwable $exception) { + $this->loggerService->error( + 'VciCredentialStatusApiController: unable to change the credential status: ' . + $exception->getMessage(), + ); + + return $this->routes->newJsonErrorResponse( + error: 'server_error', + description: 'Unable to change the credential status.', + httpCode: Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + if (!$change instanceof CredentialStatusChange) { + // One that was never issued here, one issued without a status claim and one which has + // expired are all answered this way. Telling them apart would let a caller enumerate which + // credential identifiers exist. + return $this->routes->newJsonErrorResponse( + error: 'not_found', + description: 'No credential with that identifier can have its status changed.', + httpCode: Response::HTTP_NOT_FOUND, + ); + } + + return $this->routes->newJsonResponse([ + self::PARAM_STATUS => strtolower($change->getStatus()->name), + // Distinguishes doing it from finding it already done, so a caller retrying a request it + // never saw the answer to can tell which happened. + 'changed' => $change->isChanged(), + ]); + } + + /** + * The status names this endpoint accepts, which are the Status Type names in lower case. + * + * Matched case insensitively, since the names are the interface and quibbling over capitalization + * would only produce confusing rejections. + */ + protected function resolveStatus(string $status): ?StatusTypeEnum + { + foreach (StatusTypeEnum::cases() as $case) { + if (strcasecmp($case->name, trim($status)) === 0) { + return $case; + } + } + + return null; + } + + /** + * @return string[] + */ + protected function supportedStatuses(): array + { + return array_map( + static fn(StatusTypeEnum $case): string => strtolower($case->name), + StatusTypeEnum::cases(), + ); + } +} diff --git a/src/Exceptions/InsufficientScopeException.php b/src/Exceptions/InsufficientScopeException.php new file mode 100644 index 00000000..d1e44b83 --- /dev/null +++ b/src/Exceptions/InsufficientScopeException.php @@ -0,0 +1,18 @@ +config()->getOptionalBoolean(self::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED, false); } + /** + * Whether the endpoint through which a credential's status can be changed is served. + * + * Separate from the Status List capability itself, which only governs whether entries are + * allocated. Serving this endpoint means accepting revocation requests over the network, which is + * a decision of its own, and it is off until an operator makes it. + */ + public function getApiVciCredentialStatusEndpointEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_VCI_CREDENTIAL_STATUS_ENDPOINT_ENABLED, false); + } + public function getApiOAuth2TokenIntrospectionEndpointEnabled(): bool { return $this->config()->getOptionalBoolean(self::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED, false); @@ -1936,14 +1964,100 @@ public function getApiTokens(): ?array */ public function getApiTokenScopes(string $token): ?array { - /** @psalm-suppress MixedAssignment */ - $tokenScopes = $this->getApiTokens()[$token] ?? null; + /** @var mixed $entry */ + $entry = $this->getApiTokens()[$token] ?? null; - if (is_array($tokenScopes)) { - return $tokenScopes; + if (!is_array($entry)) { + return null; } - return null; + // Two shapes are accepted. A token may be given a bare list of scopes, which is how this + // option has always been written, or a settings array which names the token as well. Either + // of the settings keys marks the second shape, and a bare list can carry neither, since its + // entries are values rather than keys. + if (!$this->isApiTokenSettingsShape($entry)) { + return $entry; + } + + /** @var mixed $scopes */ + $scopes = $entry[self::KEY_API_TOKEN_SCOPES] ?? null; + + if (is_array($scopes)) { + return $scopes; + } + + // Named, but not declaring its scopes under the key for them. They may still be there + // positionally, which is how a token someone had already annotated with a name would be + // written, and that token authorized before this option grew a second shape. The settings + // keys are dropped rather than the whole array returned, since handing back the name would + // give the token a scope called after itself. + $positionalScopes = array_diff_key( + $entry, + array_flip([self::KEY_API_TOKEN_NAME, self::KEY_API_TOKEN_SCOPES]), + ); + + // Settings which name a token and grant it nothing authorize nothing. + return $positionalScopes === [] ? null : $positionalScopes; + } + + /** + * The name an API token is configured under, or null when it has none. + * + * This is what the audit trail records as the actor behind a change. The token itself must never + * go anywhere near it: it is a bearer secret, and an audit table is exactly the sort of place it + * would outlive its rotation. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getApiTokenName(string $token): ?string + { + /** @var mixed $entry */ + $entry = $this->getApiTokens()[$token] ?? null; + + if (!is_array($entry)) { + return null; + } + + /** @var mixed $name */ + $name = $entry[self::KEY_API_TOKEN_NAME] ?? null; + + if (!is_string($name) || trim($name) === '') { + return null; + } + + $name = trim($name); + + // Refused here rather than truncated. A name too long for the column would otherwise make + // every change this token asks for fail at the point of recording it, on the databases which + // check, and shortening it silently could quietly merge two principals into one. + if (mb_strlen($name) > self::MAX_API_TOKEN_NAME_LENGTH) { + throw new ConfigurationError( + sprintf( + 'An API token is named with %d characters, which is more than the %d the status ' . + 'change audit trail can record. Give it a shorter name.', + mb_strlen($name), + self::MAX_API_TOKEN_NAME_LENGTH, + ), + self::DEFAULT_FILE_NAME, + ); + } + + return $name; + } + + /** + * Whether an API token entry is written as a settings array rather than as a bare list of scopes. + * + * @param array $entry + */ + protected function isApiTokenSettingsShape(array $entry): bool + { + // The keys have to hold what the settings shape would hold, not merely be present. A bare + // list is read by value rather than by key, so one which happens to carry an entry under a + // key of 'scopes' or 'name' authorized before this option grew a second shape, and quietly + // ceasing to would be an authorization change nobody asked for. + return is_array($entry[self::KEY_API_TOKEN_SCOPES] ?? null) || + is_string($entry[self::KEY_API_TOKEN_NAME] ?? null); } public function getAuthSourcesToUsersEmailAttributeMap(): array diff --git a/src/Repositories/StatusAuditRepository.php b/src/Repositories/StatusAuditRepository.php index 3cdff6d5..bdef71b5 100644 --- a/src/Repositories/StatusAuditRepository.php +++ b/src/Repositories/StatusAuditRepository.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Repositories; use DateTimeImmutable; +use DateTimeZone; use PDO; use SimpleSAML\Database; use SimpleSAML\Module\oidc\Codebooks\DateFormatsEnum; @@ -20,6 +21,13 @@ * atomic, so the ordering picks which way they can disagree: an audit row for a change which did not * take effect is visible and reconcilable, whereas a change with no audit row is a silent one. * + * **A row here is a request, not a receipt.** There is no outcome column, and there deliberately is + * not one: writing the outcome would be a third write which can fail in its own right, leaving rows + * stuck in a "pending" state that says even less than no column at all. So a row records that an actor + * asked for a transition at a moment, and nothing more. Whether it took effect is answered by the entry + * itself, which is the only authority on what a credential's status is. Two actors asking for the same + * transition at once therefore leave two rows, both true, though only one of them changed anything. + * * The old status recorded is the one the caller observed, not an authoritative before-image. Two * concurrent changes can interleave between the observation and the update, so this is a record of what * each actor believed and asked for, which is what an audit trail is for. @@ -51,6 +59,8 @@ public function getTableName(): string * @param ?string $actorRef Who asked for the change: an API token principal's name, an * administrator's identifier, or null for an unattended one. Never the API token itself, which * would put a bearer secret in the audit trail. + * @return string The identifier of the row written, so that a caller whose change then fails can + * name the row it left behind rather than leaving someone to find it by timestamp. * @throws \Exception */ public function record( @@ -62,7 +72,9 @@ public function record( StatusChangeSourceEnum $source, ?string $actorRef = null, ?DateTimeImmutable $createdAt = null, - ): void { + ): string { + $id = $this->helpers->random()->getIdentifier(); + $this->database->write( sprintf( 'INSERT INTO %s ( @@ -75,7 +87,7 @@ public function record( $this->getTableName(), ), [ - 'id' => $this->helpers->random()->getIdentifier(), + 'id' => $id, 'credential_id_hash' => $credentialIdHash, 'status_list_id' => $statusListId, 'idx' => [$idx, PDO::PARAM_INT], @@ -83,9 +95,16 @@ public function record( 'new_status' => [$newStatus, PDO::PARAM_INT], 'actor_ref' => $actorRef, 'source' => $source->value, + // Normalised rather than formatted as given. The column carries no timezone, so a + // moment written in the server's local time is indistinguishable from one written in + // UTC, and the two would be ordered and pruned against each other as though they were + // the same scale. Every other Status List table stores UTC; so does this one. 'created_at' => ($createdAt ?? $this->helpers->dateTime()->getUtc()) + ->setTimezone(new DateTimeZone('UTC')) ->format(DateFormatsEnum::DB_DATETIME->value), ], ); + + return $id; } } diff --git a/src/Services/Api/ApiTokenPrincipalResolver.php b/src/Services/Api/ApiTokenPrincipalResolver.php new file mode 100644 index 00000000..5b905c22 --- /dev/null +++ b/src/Services/Api/ApiTokenPrincipalResolver.php @@ -0,0 +1,151 @@ +moduleConfig->getApiTokenName($token); + + // The invariant is that no bearer secret this module knows about can reach the value returned + // here, which is logged and written to the audit trail. Nothing stops an operator writing one + // into a name: "HR system (abc123)" reads like a helpful label and hands the secret to + // everyone who can read the table. + // + // Checked against every configured token rather than only the one being resolved, because a + // name is just as dangerous when the secret buried in it belongs to a different token -- and + // that one would authenticate perfectly well while leaking somebody else's. + // + // Ignored rather than refused: this is the operator's mistake to fix, and refusing would take + // revocation away until they did, which is not a trade worth making for a naming slip. + if (is_string($name) && $this->carriesAToken($name)) { + $this->loggerService->warning( + 'An API token is named with something containing an API token, which would put a ' . + 'bearer secret in the status change audit trail. The name is being ignored; give the ' . + 'token a name which does not carry one.', + ); + + $name = null; + } + + return $name ?? $this->fingerprint($token); + } + + /** + * Whether a name has any configured API token buried in it. + * + * A token short enough for a name to contain it by chance is already far too weak to be worth + * protecting, and being wrong here costs a fingerprint in place of a label. + */ + protected function carriesAToken(string $name): bool + { + foreach (array_keys($this->moduleConfig->getApiTokens() ?? []) as $configuredToken) { + if (is_string($configuredToken) && $configuredToken !== '' && str_contains($name, $configuredToken)) { + return true; + } + } + + return false; + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function fingerprint(string $token): string + { + return self::FINGERPRINT_PREFIX . substr( + hash_hmac(self::HASH_ALGORITHM, $token, $this->deriveKey()), + 0, + self::FINGERPRINT_LENGTH, + ); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function deriveKey(): string + { + if (is_string($this->derivedKey)) { + return $this->derivedKey; + } + + $encryptionKey = $this->moduleConfig->getEncryptionKey(); + + $inputKeyMaterial = $encryptionKey instanceof Key ? + $encryptionKey->getRawBytes() : + $encryptionKey; + + if ($inputKeyMaterial === '') { + throw new ConfigurationError( + 'Unable to derive the API token fingerprint key: neither a module encryption key nor a ' . + 'SimpleSAMLphp secret salt is set. Naming the token in the API token configuration ' . + 'avoids needing one at all.', + ); + } + + return $this->derivedKey = hash_hkdf( + self::HASH_ALGORITHM, + $inputKeyMaterial, + self::DERIVED_KEY_BYTES, + self::HKDF_INFO, + ); + } +} diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index 9f6b72c6..c32619a9 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -7,6 +7,8 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; +use SimpleSAML\Module\oidc\Exceptions\InsufficientScopeException; +use SimpleSAML\Module\oidc\Exceptions\MissingTokenException; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -25,6 +27,7 @@ public function __construct( protected readonly SspBridge $sspBridge, protected readonly RequestParamsResolver $requestParamsResolver, protected readonly Helpers $helpers, + protected readonly ApiTokenPrincipalResolver $apiTokenPrincipalResolver, ) { } @@ -79,6 +82,65 @@ public function requireTokenForAnyOfScope(Request $request, array $requiredScope } } + /** + * Authorize a state changing request, and say who it is being made by. + * + * Deliberately not {@see requireTokenForAnyOfScope()}, which is right for the endpoints it serves + * and wrong for this kind. That method accepts an authenticated SimpleSAMLphp admin session before + * it ever looks at a token, which means a request carrying an administrator's cookies is authorized + * whatever caused the browser to send it; and it falls back to reading the token from a request + * parameter, which puts a bearer secret in access logs, browser history and any Referer that + * follows. Neither matters much for reading, and both matter for withdrawing a credential. + * + * So: the Authorization header, and nothing else. A signed in administrator acts through the + * administration screens, which have their own authorization and their own cross-site protection. + * + * @param \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum[] $requiredScopes + * @return string Who the request is made by, for recording against what it changes. Never the + * token itself. + * @throws \SimpleSAML\Module\oidc\Exceptions\MissingTokenException When the request carried no + * token at all, which calls for a different challenge than one that arrived and was refused. + * @throws \SimpleSAML\Module\oidc\Exceptions\InsufficientScopeException When the token is known + * and does not cover this action, which no amount of retrying will change. + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException When the token is not usable. + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function requireBearerTokenForAnyOfScope(Request $request, array $requiredScopes): string + { + $token = $this->helpers->http()->getBearerToken($request->headers->get(self::KEY_AUTHORIZATION)); + + if ($token === null || trim($token) === '') { + throw new MissingTokenException( + Translate::noop('Authorization token not provided in the Authorization header.'), + ); + } + + $tokenScopes = $this->moduleConfig->getApiTokenScopes($token); + + // No scopes at all covers both a token which is not configured here and one which is + // configured without any, and the two are answered the same way on purpose: saying which is + // which would let a caller test whether a token exists. + if (empty($tokenScopes)) { + throw new AuthorizationException(Translate::noop('Authorization token does not have defined scopes.')); + } + + $hasAny = !empty(array_filter($tokenScopes, fn(mixed $tokenScope): bool => in_array( + $tokenScope, + $requiredScopes, + true, + ))); + + if (!$hasAny) { + // A good token which does not cover this. Distinct from the two failures above, because + // the caller is authenticated and rotating its token would not help. + throw new InsufficientScopeException( + Translate::noop('Authorization token is not authorized for this action.'), + ); + } + + return $this->apiTokenPrincipalResolver->resolve($token); + } + protected function findToken(Request $request): ?string { $bearerToken = $this->helpers->http()->getBearerToken($request->headers->get(self::KEY_AUTHORIZATION)); diff --git a/src/StatusList/Contracts/StatusUpdaterInterface.php b/src/StatusList/Contracts/StatusUpdaterInterface.php index 330a9044..5ce01b9d 100644 --- a/src/StatusList/Contracts/StatusUpdaterInterface.php +++ b/src/StatusList/Contracts/StatusUpdaterInterface.php @@ -28,6 +28,21 @@ interface StatusUpdaterInterface */ public function setStatus(string $statusListId, int $idx, StatusTypeEnum $status): bool; + /** + * Check that a list could hold this status, without changing anything. + * + * Exists so that a caller which records what it is about to do can find out first. Whether a list + * can carry a status is fixed when the list is created and can never become true later, so a + * request for one it cannot carry is not a change which failed -- it is a change which was never + * possible, and writing it into an audit trail would leave a permanent record of a transition + * that could not have happened. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\UnsupportedStatusException When the list can not + * represent the status. + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException When the list does not exist. + */ + public function enforceCanRepresent(string $statusListId, StatusTypeEnum $status): void; + /** * The status currently recorded, or null when the entry does not exist or was never allocated. * diff --git a/src/StatusList/CredentialStatusService.php b/src/StatusList/CredentialStatusService.php new file mode 100644 index 00000000..de00a0c9 --- /dev/null +++ b/src/StatusList/CredentialStatusService.php @@ -0,0 +1,196 @@ +statusListEntryRepository->hashCredentialId($credentialId); + $entry = $this->statusListEntryRepository->findByCredentialIdHash($credentialIdHash); + + if (!$this->isActionable($entry)) { + $this->loggerService->info( + 'A status change was requested for a credential which can not be acted on.', + ['credentialIdHash' => $credentialIdHash, 'source' => $source->value, 'actorRef' => $actorRef], + ); + + return null; + } + + /** @var \SimpleSAML\Module\oidc\StatusList\Values\StatusListEntryRecord $entry */ + $observedStatus = $entry->getStatus(); + + // Already there. Repeating a request is how a caller which never saw an answer recovers, so it + // is a success rather than an error, and it writes nothing: the change it is repeating was + // recorded when it first happened, and recording it again would fill the trail with rows for + // things that did not occur. + if ($observedStatus === $status->value) { + return new CredentialStatusChange( + $entry->getStatusListId(), + $entry->getIdx(), + $observedStatus, + $status, + false, + ); + } + + // Asked before anything is written, because this is the one failure which is not a failure at + // all: whether a list can carry a status is fixed when the list is created and can never + // become true later. Recording it and then refusing it would leave a permanent row describing + // a transition that was never possible. + $this->statusUpdater->enforceCanRepresent($entry->getStatusListId(), $status); + + // Audit first. These are separate tables and there are no transactions here, so the two writes + // can not be made atomic and the ordering decides which way they are allowed to disagree. + // Recording first means a crash in between leaves a row describing a change which did not take + // effect, which reconciliation against the entry can find. The other order loses the record of + // a change which did, and nothing would ever reveal it. This is not atomicity and is not + // claimed to be. + $auditId = $this->statusAuditRepository->record( + $credentialIdHash, + $entry->getStatusListId(), + $entry->getIdx(), + $observedStatus, + $status->value, + $source, + $actorRef, + ); + + try { + $isChanged = $this->statusUpdater->setStatus($entry->getStatusListId(), $entry->getIdx(), $status); + } catch (Throwable $throwable) { + $this->loggerService->error( + 'A recorded status change could not be applied, so the audit trail holds a transition ' . + 'which did not take effect. The row is named here so it can be found.', + [ + 'auditId' => $auditId, + 'credentialIdHash' => $credentialIdHash, + 'statusListId' => $entry->getStatusListId(), + 'idx' => $entry->getIdx(), + 'observedStatus' => $observedStatus, + 'requestedStatus' => $status->value, + 'error' => $throwable->getMessage(), + ], + ); + + throw $throwable; + } + + $this->loggerService->info( + 'Changed the status of a credential.', + [ + 'credentialIdHash' => $credentialIdHash, + 'statusListId' => $entry->getStatusListId(), + 'idx' => $entry->getIdx(), + 'observedStatus' => $observedStatus, + 'newStatus' => $status->value, + 'source' => $source->value, + 'actorRef' => $actorRef, + ], + ); + + return new CredentialStatusChange( + $entry->getStatusListId(), + $entry->getIdx(), + $observedStatus, + $status, + $isChanged, + ); + } + + /** + * The status a credential currently holds, or null when there is none to report. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + public function getStatusValue(string $credentialId): ?int + { + $entry = $this->statusListEntryRepository->findByCredentialIdHash( + $this->statusListEntryRepository->hashCredentialId($credentialId), + ); + + return $this->isActionable($entry) ? $entry?->getStatus() : null; + } + + /** + * Whether a status change against this entry would mean anything. + * + * Three cases are answered the same way on purpose. A credential which was never issued here has + * no entry. One issued by a configuration which does not use Status Lists has no entry either. And + * an expired one is already refused by every Relying Party on its own claims, so withdrawing it + * changes nothing that is not already true -- besides which, the linkage that finds it is deleted + * once it expires, so this would become a lookup miss anyway. Distinguishing them in the answer + * would tell a caller which identifiers exist here, which is not theirs to learn. + * + * @throws \Exception + */ + protected function isActionable(?StatusListEntryRecord $entry): bool + { + if (!$entry instanceof StatusListEntryRecord || !$entry->isAllocated()) { + return false; + } + + $expiresAt = $entry->getExpiresAt(); + + if (!$expiresAt instanceof DateTimeImmutable) { + return true; + } + + return $expiresAt > $this->helpers->dateTime()->getUtc(); + } +} diff --git a/src/StatusList/DbStatusUpdater.php b/src/StatusList/DbStatusUpdater.php index 01c412b0..487e07ef 100644 --- a/src/StatusList/DbStatusUpdater.php +++ b/src/StatusList/DbStatusUpdater.php @@ -54,11 +54,7 @@ public function __construct( */ public function setStatus(string $statusListId, int $idx, StatusTypeEnum $status): bool { - $statusList = $this->statusListRepository->findByIdOnPrimary($statusListId); - - if (!$statusList instanceof StatusListRecord) { - throw new StatusListException(sprintf('Status List "%s" was not found.', $statusListId)); - } + $statusList = $this->requireList($statusListId); $this->enforceStatusFits($statusList, $status); @@ -130,6 +126,31 @@ public function setStatus(string $statusListId, int $idx, StatusTypeEnum $status ); } + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\UnsupportedStatusException + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + public function enforceCanRepresent(string $statusListId, StatusTypeEnum $status): void + { + $this->enforceStatusFits($this->requireList($statusListId), $status); + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + protected function requireList(string $statusListId): StatusListRecord + { + $statusList = $this->statusListRepository->findByIdOnPrimary($statusListId); + + if (!$statusList instanceof StatusListRecord) { + throw new StatusListException(sprintf('Status List "%s" was not found.', $statusListId)); + } + + return $statusList; + } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException */ diff --git a/src/StatusList/Values/CredentialStatusChange.php b/src/StatusList/Values/CredentialStatusChange.php new file mode 100644 index 00000000..3674c193 --- /dev/null +++ b/src/StatusList/Values/CredentialStatusChange.php @@ -0,0 +1,61 @@ +statusListId; + } + + public function getIdx(): int + { + return $this->idx; + } + + /** + * The status observed immediately before the change, as a raw value. + * + * Not an authoritative before-image: nothing here holds a lock, so another change can land between + * the observation and the write. It is what this caller saw, which is what an audit trail records. + */ + public function getPreviousStatus(): int + { + return $this->previousStatus; + } + + public function getStatus(): StatusTypeEnum + { + return $this->status; + } + + /** + * Whether this call is what put the credential into that status, as opposed to finding it there. + */ + public function isChanged(): bool + { + return $this->isChanged; + } +} diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 466e1495..f269e31b 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -299,6 +299,11 @@ public function urlApiVciCredentialOffer(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::ApiVciCredentialOffer->value, $parameters); } + public function urlApiVciCredentialStatus(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::ApiVciCredentialStatus->value, $parameters); + } + public function urlApiOAuth2TokenIntrospection(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::ApiOAuth2TokenIntrospection->value, $parameters); diff --git a/tests/unit/src/Controllers/Api/VciCredentialStatusApiControllerTest.php b/tests/unit/src/Controllers/Api/VciCredentialStatusApiControllerTest.php new file mode 100644 index 00000000..4546dfdd --- /dev/null +++ b/tests/unit/src/Controllers/Api/VciCredentialStatusApiControllerTest.php @@ -0,0 +1,467 @@ + Body of the JSON response the controller produced. */ + protected array $responseData = []; + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getVciEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiVciCredentialStatusEndpointEnabled')->willReturn(true); + + $this->authorizationMock = $this->createMock(Authorization::class); + $this->authorizationMock->method('requireBearerTokenForAnyOfScope')->willReturn(self::ACTOR); + + $this->credentialStatusServiceMock = $this->createMock(CredentialStatusService::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->responseData = []; + + $this->routesMock = $this->createMock(Routes::class); + $this->routesMock->method('newJsonResponse')->willReturnCallback( + function (array $data): JsonResponse { + $this->responseData = $data; + + return new JsonResponse($data); + }, + ); + $this->routesMock->method('newJsonErrorResponse')->willReturnCallback( + static fn( + string $error, + string $description, + int $httpCode = 500, + array $headers = [], + ): JsonResponse => new JsonResponse( + ['error' => $error, 'error_description' => $description], + $httpCode, + $headers, + ), + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function sut(): VciCredentialStatusApiController + { + return new VciCredentialStatusApiController( + $this->moduleConfigMock, + $this->authorizationMock, + $this->credentialStatusServiceMock, + $this->routesMock, + $this->loggerServiceMock, + ); + } + + /** + * @param array $body + */ + protected function request(array $body = []): Request + { + $body = $body === [] ? [ + 'credential_id' => self::CREDENTIAL_ID, + 'status' => 'invalid', + ] : $body; + + $request = new Request([], [], [], [], [], [], (string)json_encode($body)); + $request->setMethod('POST'); + $request->headers->set('Content-Type', 'application/json'); + + return $request; + } + + protected function change(bool $isChanged = true, StatusTypeEnum $status = StatusTypeEnum::Invalid): void + { + $this->credentialStatusServiceMock->method('setStatus')->willReturn( + new CredentialStatusChange('list-1', 42, StatusTypeEnum::Valid->value, $status, $isChanged), + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testChangesTheStatus(): void + { + $this->change(); + + $response = $this->sut()->credentialStatus($this->request()); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $this->assertSame('invalid', $this->responseData['status'] ?? null); + $this->assertTrue($this->responseData['changed'] ?? null); + } + + /** + * A caller retrying a request it never saw the answer to needs to be told the credential is + * revoked, not that it just revoked it a second time. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testARepeatedRequestSucceedsAndSaysNothingChanged(): void + { + $this->change(isChanged: false); + + $response = $this->sut()->credentialStatus($this->request()); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $this->assertFalse($this->responseData['changed'] ?? null); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testPassesTheAuthorizedPrincipalThroughToTheAuditTrail(): void + { + $this->credentialStatusServiceMock->expects($this->once()) + ->method('setStatus') + ->with( + self::CREDENTIAL_ID, + StatusTypeEnum::Invalid, + StatusChangeSourceEnum::Api, + self::ACTOR, + ) + ->willReturn(new CredentialStatusChange('list-1', 42, 0, StatusTypeEnum::Invalid, true)); + + $this->sut()->credentialStatus($this->request()); + } + + /** + * The endpoint's own authorization path, which unlike the rest of this API accepts nothing but a + * bearer token in the Authorization header. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRequiresABearerTokenCarryingAStatusScope(): void + { + $authorization = $this->createMock(Authorization::class); + $authorization->expects($this->once()) + ->method('requireBearerTokenForAnyOfScope') + ->with( + $this->anything(), + [ApiScopesEnum::VciCredentialStatus, ApiScopesEnum::VciAll, ApiScopesEnum::All], + ) + ->willReturn(self::ACTOR); + $this->authorizationMock = $authorization; + $this->change(); + + $this->sut()->credentialStatus($this->request()); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRefusesAnUnauthorizedRequestWithoutTouchingAnyStatus(): void + { + $authorization = $this->createMock(Authorization::class); + $authorization->method('requireBearerTokenForAnyOfScope') + ->willThrowException(new AuthorizationException('Authorization token not provided.')); + $this->authorizationMock = $authorization; + + $this->credentialStatusServiceMock->expects($this->never())->method('setStatus'); + + $this->assertSame( + Response::HTTP_UNAUTHORIZED, + $this->sut()->credentialStatus($this->request())->getStatusCode(), + ); + } + + /** + * One never issued here, one issued without a status claim and one which has expired are all + * answered the same way, so that a caller can not learn which identifiers exist. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRespondsNotFoundWhenThereIsNothingToActOn(): void + { + $this->credentialStatusServiceMock->method('setStatus')->willReturn(null); + + $this->assertSame( + Response::HTTP_NOT_FOUND, + $this->sut()->credentialStatus($this->request())->getStatusCode(), + ); + } + + /** + * The number of bits per entry is fixed when a list is created, so this can never succeed and + * saying so is more use than a 500. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRespondsUnprocessableWhenTheListCanNotCarryTheStatus(): void + { + $this->credentialStatusServiceMock->method('setStatus') + ->willThrowException(new UnsupportedStatusException('one bit per entry')); + + $this->assertSame( + Response::HTTP_UNPROCESSABLE_ENTITY, + $this->sut()->credentialStatus($this->request( + ['credential_id' => self::CREDENTIAL_ID, 'status' => 'suspended'], + ))->getStatusCode(), + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRespondsConflictWhenTheChangeLostToAnother(): void + { + $this->credentialStatusServiceMock->method('setStatus') + ->willThrowException(new StatusConflictException('kept losing')); + + $this->assertSame( + Response::HTTP_CONFLICT, + $this->sut()->credentialStatus($this->request())->getStatusCode(), + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRespondsServerErrorForAnythingElse(): void + { + $this->credentialStatusServiceMock->method('setStatus') + ->willThrowException(new StatusListException('the database is gone')); + + $response = $this->sut()->credentialStatus($this->request()); + + $this->assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + // Whatever went wrong internally is not the caller's to read. + $this->assertStringNotContainsString('database', (string)$response->getContent()); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRefusesARequestWithoutACredentialIdentifier(): void + { + $this->credentialStatusServiceMock->expects($this->never())->method('setStatus'); + + $this->assertSame( + Response::HTTP_BAD_REQUEST, + $this->sut()->credentialStatus($this->request(['status' => 'invalid']))->getStatusCode(), + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRefusesAStatusItDoesNotRecognise(): void + { + $this->credentialStatusServiceMock->expects($this->never())->method('setStatus'); + + $this->assertSame( + Response::HTTP_BAD_REQUEST, + $this->sut()->credentialStatus($this->request( + ['credential_id' => self::CREDENTIAL_ID, 'status' => 'revoked'], + ))->getStatusCode(), + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testAcceptsEveryStatusTypeByName(): void + { + foreach (StatusTypeEnum::cases() as $case) { + $this->setUp(); + + // Captured rather than assumed. Returning the expected status regardless of the argument + // would let a mapping which turned every suspension into a revocation still pass. + $requested = null; + $this->credentialStatusServiceMock->method('setStatus')->willReturnCallback( + function (string $credentialId, StatusTypeEnum $status) use (&$requested): CredentialStatusChange { + $requested = $status; + + return new CredentialStatusChange('list-1', 42, 0, $status, true); + }, + ); + + $response = $this->sut()->credentialStatus($this->request( + ['credential_id' => self::CREDENTIAL_ID, 'status' => strtoupper($case->name)], + )); + + $this->assertSame( + Response::HTTP_OK, + $response->getStatusCode(), + sprintf('Status "%s" was not accepted.', $case->name), + ); + $this->assertSame($case, $requested, sprintf('Status "%s" was mapped to something else.', $case->name)); + $this->assertSame(strtolower($case->name), $this->responseData['status'] ?? null); + } + } + + /** + * A good token which does not cover this action. Answering 401 would tell the caller its token is + * bad and invite it to rotate one which is working perfectly well; the fix is a scope only an + * operator can grant. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRespondsForbiddenWhenTheTokenLacksTheScope(): void + { + $authorization = $this->createMock(Authorization::class); + $authorization->method('requireBearerTokenForAnyOfScope') + ->willThrowException(new InsufficientScopeException('not authorized for this action')); + $this->authorizationMock = $authorization; + + $this->credentialStatusServiceMock->expects($this->never())->method('setStatus'); + + $response = $this->sut()->credentialStatus($this->request()); + + $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + $this->assertStringContainsString( + 'insufficient_scope', + (string)$response->headers->get(VciCredentialStatusApiController::HEADER_WWW_AUTHENTICATE), + ); + } + + /** + * Without the challenge a client is left to guess that this endpoint wants a bearer token. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testChallengesForABearerTokenWhenNoneWasUsable(): void + { + $authorization = $this->createMock(Authorization::class); + $authorization->method('requireBearerTokenForAnyOfScope') + ->willThrowException(new AuthorizationException('Authorization token has no scopes.')); + $this->authorizationMock = $authorization; + + $response = $this->sut()->credentialStatus($this->request()); + + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + $this->assertSame( + 'Bearer error="invalid_token"', + $response->headers->get(VciCredentialStatusApiController::HEADER_WWW_AUTHENTICATE), + ); + } + + /** + * RFC 6750 keeps `invalid_token` for a token which actually arrived. A client told its token was + * rejected when it never sent one may go and rotate a token which was working. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testChallengesWithoutAnErrorCodeWhenNoTokenWasSent(): void + { + $authorization = $this->createMock(Authorization::class); + $authorization->method('requireBearerTokenForAnyOfScope') + ->willThrowException(new MissingTokenException('Authorization token not provided.')); + $this->authorizationMock = $authorization; + + $response = $this->sut()->credentialStatus($this->request()); + + $this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + $this->assertSame( + 'Bearer', + $response->headers->get(VciCredentialStatusApiController::HEADER_WWW_AUTHENTICATE), + ); + } + + public function testIsNotServedWhileTheEndpointIsDisabled(): void + { + $moduleConfig = $this->createMock(ModuleConfig::class); + $moduleConfig->method('getApiEnabled')->willReturn(true); + $moduleConfig->method('getVciEnabled')->willReturn(true); + $moduleConfig->method('getApiVciCredentialStatusEndpointEnabled')->willReturn(false); + $this->moduleConfigMock = $moduleConfig; + + $this->expectException(OidcServerException::class); + + $this->sut()->credentialStatus($this->request()); + } + + public function testIsNotServedWhileTheApiIsDisabled(): void + { + $moduleConfig = $this->createMock(ModuleConfig::class); + $moduleConfig->method('getApiEnabled')->willReturn(false); + $moduleConfig->method('getVciEnabled')->willReturn(true); + $this->moduleConfigMock = $moduleConfig; + + $this->expectException(OidcServerException::class); + + $this->sut(); + } + + /** + * Turning issuance off must stop new credentials being issued, not strand the ones already in + * wallets as impossible to withdraw. Switching issuance off in a hurry is what an operator does + * during an incident, which is exactly when revocation is needed. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testStillRevokesWhileCredentialIssuanceIsDisabled(): void + { + $moduleConfig = $this->createMock(ModuleConfig::class); + $moduleConfig->method('getApiEnabled')->willReturn(true); + $moduleConfig->method('getVciEnabled')->willReturn(false); + $moduleConfig->method('getApiVciCredentialStatusEndpointEnabled')->willReturn(true); + $this->moduleConfigMock = $moduleConfig; + $this->change(); + + $this->assertSame( + Response::HTTP_OK, + $this->sut()->credentialStatus($this->request())->getStatusCode(), + ); + } + + /** + * The request is what is broken, not the server, and a 500 says the opposite. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function testRefusesABodyItCanNotRead(): void + { + $this->credentialStatusServiceMock->expects($this->never())->method('setStatus'); + + $request = new Request([], [], [], [], [], [], '{"credential_id": '); + $request->setMethod('POST'); + $request->headers->set('Content-Type', 'application/json'); + + $this->assertSame( + Response::HTTP_BAD_REQUEST, + $this->sut()->credentialStatus($request)->getStatusCode(), + ); + } +} diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index a056b680..85822be7 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -12,6 +12,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -952,6 +953,182 @@ public function testCredentialLifetimesRejectADurationOfNoTime(): void $this->sut(overrides: $this->withCredentialTtl('PT0S'))->getVciCredentialTtls(); } + /** + * The shape this option has always had, which has to go on working. + * + * @throws \Exception + */ + public function testReadsApiTokenScopesGivenAsABareList(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ModuleConfig::OPTION_API_TOKENS => ['a-token' => [ApiScopesEnum::VciAll]]], + )); + + $this->assertSame([ApiScopesEnum::VciAll], $sut->getApiTokenScopes('a-token')); + $this->assertNull($sut->getApiTokenName('a-token')); + } + + /** + * A bare list is read by value rather than by key, so one which happens to carry an entry under + * a key of 'scopes' or 'name' authorized before this option grew a second shape. Quietly ceasing + * to would be an authorization change nobody asked for. + * + * @throws \Exception + */ + public function testStillReadsABareListWhoseKeysLookLikeSettings(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_API_TOKENS => [ + 'a-token' => [ + ModuleConfig::KEY_API_TOKEN_SCOPES => ApiScopesEnum::VciAll, + ModuleConfig::KEY_API_TOKEN_NAME => ApiScopesEnum::All, + ], + ], + ], + )); + + $this->assertSame( + [ + ModuleConfig::KEY_API_TOKEN_SCOPES => ApiScopesEnum::VciAll, + ModuleConfig::KEY_API_TOKEN_NAME => ApiScopesEnum::All, + ], + $sut->getApiTokenScopes('a-token'), + ); + } + + /** + * @throws \Exception + */ + public function testReadsApiTokenScopesAndNameGivenAsSettings(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_API_TOKENS => [ + 'a-token' => [ + ModuleConfig::KEY_API_TOKEN_NAME => ' HR system ', + ModuleConfig::KEY_API_TOKEN_SCOPES => [ApiScopesEnum::VciCredentialStatus], + ], + ], + ], + )); + + $this->assertSame([ApiScopesEnum::VciCredentialStatus], $sut->getApiTokenScopes('a-token')); + $this->assertSame('HR system', $sut->getApiTokenName('a-token')); + } + + /** + * A token someone had already annotated with a name, listing its scopes positionally, authorized + * before this option grew a second shape. Ceasing to would take its access away on upgrade, and + * the name must not become a scope of its own in the process. + * + * @throws \Exception + */ + public function testReadsApiTokenScopesListedAlongsideAName(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_API_TOKENS => [ + 'a-token' => [ + ModuleConfig::KEY_API_TOKEN_NAME => 'legacy label', + ApiScopesEnum::VciAll, + ], + ], + ], + )); + + $this->assertSame([ApiScopesEnum::VciAll], array_values((array)$sut->getApiTokenScopes('a-token'))); + $this->assertNotContains('legacy label', (array)$sut->getApiTokenScopes('a-token')); + $this->assertSame('legacy label', $sut->getApiTokenName('a-token')); + } + + /** + * A name on its own authorizes nothing. Reading the settings shape as though the whole array were + * a list of scopes would hand the token a scope named after its own name. + * + * @throws \Exception + */ + public function testReadsNoApiTokenScopesFromSettingsWhichDeclareNone(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_API_TOKENS => [ + 'a-token' => [ModuleConfig::KEY_API_TOKEN_NAME => 'HR system'], + ], + ], + )); + + $this->assertNull($sut->getApiTokenScopes('a-token')); + $this->assertSame('HR system', $sut->getApiTokenName('a-token')); + } + + /** + * The name goes into a fixed width column in the audit trail. Left unchecked, an over-long one + * would make every status change that token asks for fail at the point of recording it, on the + * databases which enforce the width, and truncating it could quietly merge two principals. + * + * @throws \Exception + */ + public function testRejectsAnApiTokenNameTooLongToRecord(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_API_TOKENS => [ + 'a-token' => [ + ModuleConfig::KEY_API_TOKEN_NAME => str_repeat( + 'a', + ModuleConfig::MAX_API_TOKEN_NAME_LENGTH + 1, + ), + ModuleConfig::KEY_API_TOKEN_SCOPES => [ApiScopesEnum::All], + ], + ], + ], + )); + + $this->expectException(ConfigurationError::class); + + $sut->getApiTokenName('a-token'); + } + + /** + * @throws \Exception + */ + public function testAcceptsAnApiTokenNameOfTheGreatestRecordableLength(): void + { + $name = str_repeat('a', ModuleConfig::MAX_API_TOKEN_NAME_LENGTH); + + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_API_TOKENS => [ + 'a-token' => [ModuleConfig::KEY_API_TOKEN_NAME => $name], + ], + ], + )); + + $this->assertSame($name, $sut->getApiTokenName('a-token')); + } + + /** + * @throws \Exception + */ + public function testReadsNothingForAnUnknownApiToken(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ModuleConfig::OPTION_API_TOKENS => ['a-token' => [ApiScopesEnum::All]]], + )); + + $this->assertNull($sut->getApiTokenScopes('some-other-token')); + $this->assertNull($sut->getApiTokenName('some-other-token')); + } + /** * @return array */ diff --git a/tests/unit/src/Repositories/StatusAuditRepositoryTest.php b/tests/unit/src/Repositories/StatusAuditRepositoryTest.php new file mode 100644 index 00000000..fc2bedd9 --- /dev/null +++ b/tests/unit/src/Repositories/StatusAuditRepositoryTest.php @@ -0,0 +1,212 @@ + 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ], + '', + 'simplesaml', + ); + + (new DatabaseMigration())->migrate(); + } + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->helpers = new Helpers(); + + $this->repository = new StatusAuditRepository( + $this->moduleConfigMock, + Database::getInstance(), + null, + $this->helpers, + ); + + Database::getInstance()->write(sprintf('DELETE FROM %s', $this->repository->getTableName())); + } + + public function testGetTableName(): void + { + $this->assertSame('phpunit_oidc_status_audit', $this->repository->getTableName()); + } + + /** + * @return array> + */ + protected function readRows(): array + { + return Database::getInstance() + ->read(sprintf('SELECT * FROM %s ORDER BY created_at', $this->repository->getTableName())) + ->fetchAll(); + } + + /** + * @throws \Exception + */ + public function testRecordsATransition(): void + { + $this->repository->record( + self::CREDENTIAL_ID_HASH, + self::LIST_ID, + 42, + StatusTypeEnum::Valid->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Api, + 'HR system', + ); + + $rows = $this->readRows(); + + $this->assertCount(1, $rows); + $this->assertSame(self::CREDENTIAL_ID_HASH, $rows[0]['credential_id_hash']); + $this->assertSame(self::LIST_ID, $rows[0]['status_list_id']); + $this->assertSame(42, (int)$rows[0]['idx']); + $this->assertSame(StatusTypeEnum::Valid->value, (int)$rows[0]['old_status']); + $this->assertSame(StatusTypeEnum::Invalid->value, (int)$rows[0]['new_status']); + $this->assertSame('HR system', $rows[0]['actor_ref']); + $this->assertSame(StatusChangeSourceEnum::Api->value, $rows[0]['source']); + } + + /** + * A trail whose rows overwrite each other is not a trail. Every change against the same credential + * has to survive alongside the ones before it. + * + * @throws \Exception + */ + public function testKeepsEveryTransitionForTheSameCredential(): void + { + $this->repository->record( + self::CREDENTIAL_ID_HASH, + self::LIST_ID, + 42, + StatusTypeEnum::Valid->value, + StatusTypeEnum::Suspended->value, + StatusChangeSourceEnum::Admin, + 'admin', + ); + $this->repository->record( + self::CREDENTIAL_ID_HASH, + self::LIST_ID, + 42, + StatusTypeEnum::Suspended->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Api, + 'HR system', + ); + + $rows = $this->readRows(); + + $this->assertCount(2, $rows); + $this->assertNotSame($rows[0]['id'], $rows[1]['id']); + } + + /** + * A scheduled task has no human or API principal behind it, and inventing one would be worse than + * recording that there was none. + * + * @throws \Exception + */ + public function testRecordsAnUnattendedChangeWithNoActor(): void + { + $this->repository->record( + self::CREDENTIAL_ID_HASH, + self::LIST_ID, + 1, + StatusTypeEnum::Valid->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Cron, + ); + + $this->assertNull($this->readRows()[0]['actor_ref']); + } + + /** + * @throws \Exception + */ + public function testStoresTheMomentInUtc(): void + { + $createdAt = new DateTimeImmutable('2026-08-07 12:00:00', new DateTimeZone('Europe/Zagreb')); + + $this->repository->record( + self::CREDENTIAL_ID_HASH, + self::LIST_ID, + 1, + StatusTypeEnum::Valid->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Api, + 'HR system', + $createdAt, + ); + + $this->assertSame( + $createdAt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'), + $this->readRows()[0]['created_at'], + ); + } + + /** + * Every row needs its own identifier, and they are generated rather than handed out by the + * database, so a collision would silently replace an earlier record of a change. + * + * @throws \Exception + */ + public function testGivesEachRowADistinctIdentifier(): void + { + for ($i = 0; $i < 25; $i++) { + $this->repository->record( + self::CREDENTIAL_ID_HASH, + self::LIST_ID, + $i, + StatusTypeEnum::Valid->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Api, + 'HR system', + ); + } + + $identifiers = array_column($this->readRows(), 'id'); + + $this->assertCount(25, $identifiers); + $this->assertSame($identifiers, array_unique($identifiers)); + } +} diff --git a/tests/unit/src/Services/Api/ApiTokenPrincipalResolverTest.php b/tests/unit/src/Services/Api/ApiTokenPrincipalResolverTest.php new file mode 100644 index 00000000..8c932d41 --- /dev/null +++ b/tests/unit/src/Services/Api/ApiTokenPrincipalResolverTest.php @@ -0,0 +1,182 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getEncryptionKey')->willReturn('a-secret-salt'); + $this->moduleConfigMock->method('getApiTokens')->willReturn([self::TOKEN => [], self::OTHER_TOKEN => []]); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + } + + protected function sut(?ModuleConfig $moduleConfig = null): ApiTokenPrincipalResolver + { + return new ApiTokenPrincipalResolver( + $moduleConfig ?? $this->moduleConfigMock, + $this->loggerServiceMock, + ); + } + + /** + * Nothing stops an operator writing the token as its own display name, and the resulting audit + * row would hold the bearer secret this whole class exists to keep out of it. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testIgnoresANameWhichIsTheTokenItself(): void + { + $this->moduleConfigMock->method('getApiTokenName')->willReturn(self::TOKEN); + $this->loggerServiceMock->expects($this->once())->method('warning'); + + $principal = $this->sut()->resolve(self::TOKEN); + + $this->assertStringNotContainsString(self::TOKEN, $principal); + $this->assertMatchesRegularExpression('/^token:[0-9a-f]{16}$/', $principal); + } + + /** + * A name the token is buried in is no safer than one which is the token, and reads far more like + * something an operator would write on purpose. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testIgnoresANameWhichMerelyCarriesTheToken(): void + { + $this->moduleConfigMock->method('getApiTokenName') + ->willReturn(sprintf('HR system (%s)', self::TOKEN)); + $this->loggerServiceMock->expects($this->once())->method('warning'); + + $this->assertStringNotContainsString(self::TOKEN, $this->sut()->resolve(self::TOKEN)); + } + + /** + * A name is just as dangerous when the secret buried in it belongs to a different token, and that + * token would authenticate perfectly well while leaking somebody else's. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testIgnoresANameCarryingSomeOtherConfiguredToken(): void + { + $this->moduleConfigMock->method('getApiTokenName') + ->willReturn(sprintf('HR system, replaces %s', self::OTHER_TOKEN)); + $this->loggerServiceMock->expects($this->once())->method('warning'); + + $this->assertStringNotContainsString(self::OTHER_TOKEN, $this->sut()->resolve(self::TOKEN)); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testUsesTheConfiguredName(): void + { + $this->moduleConfigMock->method('getApiTokenName')->willReturn('HR system'); + + $this->assertSame('HR system', $this->sut()->resolve(self::TOKEN)); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testFallsBackToAFingerprintWhenThereIsNoName(): void + { + $this->moduleConfigMock->method('getApiTokenName')->willReturn(null); + + $this->assertMatchesRegularExpression('/^token:[0-9a-f]{16}$/', $this->sut()->resolve(self::TOKEN)); + } + + /** + * An audit trail whose actor is the same for every caller records nothing worth having. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testFingerprintsDistinguishTokens(): void + { + $this->moduleConfigMock->method('getApiTokenName')->willReturn(null); + + $this->assertNotSame( + $this->sut()->resolve(self::TOKEN), + $this->sut()->resolve('a-different-token'), + ); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testFingerprintsAreStable(): void + { + $this->moduleConfigMock->method('getApiTokenName')->willReturn(null); + + $this->assertSame($this->sut()->resolve(self::TOKEN), $this->sut()->resolve(self::TOKEN)); + } + + /** + * The fingerprint goes into a database table. An unkeyed hash of a token an operator chose badly + * could be confirmed by guessing it, which would make the audit trail an oracle for the very + * secret it exists to keep out of the record. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testTheFingerprintIsKeyedRatherThanAPlainHash(): void + { + $this->moduleConfigMock->method('getApiTokenName')->willReturn(null); + + $otherConfig = $this->createMock(ModuleConfig::class); + $otherConfig->method('getEncryptionKey')->willReturn('a-different-secret-salt'); + $otherConfig->method('getApiTokenName')->willReturn(null); + + $this->assertNotSame( + $this->sut()->resolve(self::TOKEN), + $this->sut($otherConfig)->resolve(self::TOKEN), + ); + + $this->assertStringNotContainsString( + substr(hash('sha256', self::TOKEN), 0, 16), + $this->sut()->resolve(self::TOKEN), + ); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testNeverReturnsTheTokenItself(): void + { + $this->moduleConfigMock->method('getApiTokenName')->willReturn(null); + + $this->assertStringNotContainsString(self::TOKEN, $this->sut()->resolve(self::TOKEN)); + } + + /** + * Naming the token is the way out of this, and the message says so. + */ + public function testRefusesToFingerprintWithoutAnyKeyMaterial(): void + { + $moduleConfig = $this->createMock(ModuleConfig::class); + $moduleConfig->method('getEncryptionKey')->willReturn(''); + $moduleConfig->method('getApiTokenName')->willReturn(null); + + $this->expectException(ConfigurationError::class); + + $this->sut($moduleConfig)->resolve(self::TOKEN); + } +} diff --git a/tests/unit/src/Services/Api/AuthorizationTest.php b/tests/unit/src/Services/Api/AuthorizationTest.php new file mode 100644 index 00000000..f0a46e3c --- /dev/null +++ b/tests/unit/src/Services/Api/AuthorizationTest.php @@ -0,0 +1,232 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->sspBridgeMock = $this->createMock(SspBridge::class); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->apiTokenPrincipalResolverMock = $this->createMock(ApiTokenPrincipalResolver::class); + $this->apiTokenPrincipalResolverMock->method('resolve')->willReturn('HR system'); + $this->helpers = new Helpers(); + } + + protected function sut(): Authorization + { + return new Authorization( + $this->moduleConfigMock, + $this->sspBridgeMock, + $this->requestParamsResolverMock, + $this->helpers, + $this->apiTokenPrincipalResolverMock, + ); + } + + /** + * @param array $headers + * @param array $query + */ + protected function request(array $headers = [], array $query = []): Request + { + $server = []; + + foreach ($headers as $name => $value) { + $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + + return new Request($query, [], [], [], [], $server); + } + + /** + * @return \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum[] + */ + protected function requiredScopes(): array + { + return [ApiScopesEnum::VciCredentialStatus, ApiScopesEnum::VciAll, ApiScopesEnum::All]; + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testAuthorizesABearerTokenHoldingARequiredScope(): void + { + $this->moduleConfigMock->method('getApiTokenScopes')->willReturn([ApiScopesEnum::VciAll]); + + $this->assertSame( + 'HR system', + $this->sut()->requireBearerTokenForAnyOfScope( + $this->request(['Authorization' => 'Bearer ' . self::TOKEN]), + $this->requiredScopes(), + ), + ); + } + + /** + * It returns who the caller is, never the secret they proved it with, so that nothing downstream + * can put a bearer token into a log line or an audit row. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function testReturnsAPrincipalRatherThanTheToken(): void + { + $this->moduleConfigMock->method('getApiTokenScopes')->willReturn([ApiScopesEnum::All]); + + // Resolved from the token which actually authorized, and returned as resolved. Stubbing a + // constant and checking it does not contain the token would pass just as well against an + // implementation which attributed every caller to the same principal. + $resolver = $this->createMock(ApiTokenPrincipalResolver::class); + $resolver->expects($this->once()) + ->method('resolve') + ->with(self::TOKEN) + ->willReturn('the-resolved-principal'); + $this->apiTokenPrincipalResolverMock = $resolver; + + $this->assertSame( + 'the-resolved-principal', + $this->sut()->requireBearerTokenForAnyOfScope( + $this->request(['Authorization' => 'Bearer ' . self::TOKEN]), + $this->requiredScopes(), + ), + ); + } + + /** + * A request with no token at all is a different answer from one whose token was refused, since + * the challenge sent back differs. + */ + public function testDistinguishesAMissingTokenFromARefusedOne(): void + { + $this->expectException(MissingTokenException::class); + + $this->sut()->requireBearerTokenForAnyOfScope($this->request(), $this->requiredScopes()); + } + + /** + * The reason this method exists at all. requireTokenForAnyOfScope() authorizes an administrator's + * session before it examines any token, which means a request carrying an administrator's cookies + * is authorized whatever caused the browser to send it. For an endpoint which withdraws + * credentials that is a cross-site request away from being someone else's decision. + */ + public function testDoesNotAcceptAnAdministratorSessionInPlaceOfAToken(): void + { + $auth = $this->createMock(SspAuth::class); + $auth->method('isAdmin')->willReturn(true); + $utils = $this->createMock(SspBridge\Utils::class); + $utils->method('auth')->willReturn($auth); + $this->sspBridgeMock->method('utils')->willReturn($utils); + + $this->expectException(AuthorizationException::class); + + $this->sut()->requireBearerTokenForAnyOfScope($this->request(), $this->requiredScopes()); + } + + /** + * A token in the query string ends up in access logs, in browser history and in the Referer of + * whatever the response links to. + */ + public function testDoesNotAcceptTheTokenAsARequestParameter(): void + { + $this->moduleConfigMock->method('getApiTokenScopes')->willReturn([ApiScopesEnum::All]); + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::TOKEN); + + $this->expectException(AuthorizationException::class); + + $this->sut()->requireBearerTokenForAnyOfScope( + $this->request([], ['token' => self::TOKEN]), + $this->requiredScopes(), + ); + } + + public function testRefusesARequestWithNoAuthorizationHeader(): void + { + $this->expectException(AuthorizationException::class); + + $this->sut()->requireBearerTokenForAnyOfScope($this->request(), $this->requiredScopes()); + } + + public function testRefusesATokenWithNoConfiguredScopes(): void + { + $this->moduleConfigMock->method('getApiTokenScopes')->willReturn(null); + + $this->expectException(AuthorizationException::class); + + $this->sut()->requireBearerTokenForAnyOfScope( + $this->request(['Authorization' => 'Bearer ' . self::TOKEN]), + $this->requiredScopes(), + ); + } + + /** + * A known token which does not cover this action is a different answer from an unusable one: the + * caller is authenticated, and rotating its token would not help. + */ + public function testRefusesATokenWhoseScopesDoNotCoverTheAction(): void + { + $this->moduleConfigMock->method('getApiTokenScopes') + ->willReturn([ApiScopesEnum::OAuth2TokenIntrospection]); + + $this->expectException(InsufficientScopeException::class); + + $this->sut()->requireBearerTokenForAnyOfScope( + $this->request(['Authorization' => 'Bearer ' . self::TOKEN]), + $this->requiredScopes(), + ); + } + + /** + * A token which is not configured and one configured without scopes are answered the same way, so + * that a caller can not use the difference to test whether a token exists. + */ + public function testDoesNotDistinguishAnUnknownTokenFromOneWithoutScopes(): void + { + $this->moduleConfigMock->method('getApiTokenScopes')->willReturn(null); + + try { + $this->sut()->requireBearerTokenForAnyOfScope( + $this->request(['Authorization' => 'Bearer ' . self::TOKEN]), + $this->requiredScopes(), + ); + + $this->fail('An unusable token was accepted.'); + } catch (AuthorizationException $exception) { + $this->assertNotInstanceOf( + InsufficientScopeException::class, + $exception, + 'An unusable token was reported as a scope problem, which reveals that it exists.', + ); + } + } +} diff --git a/tests/unit/src/StatusList/CredentialStatusServiceTest.php b/tests/unit/src/StatusList/CredentialStatusServiceTest.php new file mode 100644 index 00000000..0181a5fd --- /dev/null +++ b/tests/unit/src/StatusList/CredentialStatusServiceTest.php @@ -0,0 +1,351 @@ +statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('hashCredentialId')->willReturn(self::CREDENTIAL_ID_HASH); + $this->statusUpdaterMock = $this->createMock(StatusUpdaterInterface::class); + $this->statusAuditRepositoryMock = $this->createMock(StatusAuditRepository::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->helpers = new Helpers(); + } + + protected function sut(): CredentialStatusService + { + return new CredentialStatusService( + $this->statusListEntryRepositoryMock, + $this->statusUpdaterMock, + $this->statusAuditRepositoryMock, + $this->helpers, + $this->loggerServiceMock, + ); + } + + protected function entry( + int $status = StatusTypeEnum::Valid->value, + bool $isAllocated = true, + ?DateTimeImmutable $expiresAt = null, + ): MockObject { + $entry = $this->createMock(StatusListEntryRecord::class); + $entry->method('getStatusListId')->willReturn(self::LIST_ID); + $entry->method('getIdx')->willReturn(self::IDX); + $entry->method('getStatus')->willReturn($status); + $entry->method('isAllocated')->willReturn($isAllocated); + $entry->method('getExpiresAt')->willReturn($expiresAt); + + return $entry; + } + + /** + * @throws \Exception + */ + public function testChangesTheStatusOfTheEntryTheCredentialSitsIn(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn($this->entry()); + $this->statusUpdaterMock->expects($this->once()) + ->method('setStatus') + ->with(self::LIST_ID, self::IDX, StatusTypeEnum::Invalid) + ->willReturn(true); + + $change = $this->sut()->setStatus( + self::CREDENTIAL_ID, + StatusTypeEnum::Invalid, + StatusChangeSourceEnum::Api, + 'HR system', + ); + + $this->assertTrue($change?->isChanged()); + $this->assertSame(StatusTypeEnum::Invalid, $change->getStatus()); + $this->assertSame(StatusTypeEnum::Valid->value, $change->getPreviousStatus()); + } + + /** + * Nothing here is atomic, so the ordering decides which way the two writes may disagree. Recording + * first leaves a row for a change which did not take effect, which reconciliation can find. The + * other order loses the record of one which did, and nothing would ever reveal it. + * + * @throws \Exception + */ + public function testRecordsTheChangeBeforeApplyingIt(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn($this->entry()); + + $order = []; + $this->statusAuditRepositoryMock->method('record')->willReturnCallback( + function () use (&$order): string { + $order[] = 'audit'; + + return 'an-audit-row-id'; + }, + ); + $this->statusUpdaterMock->method('setStatus')->willReturnCallback( + function () use (&$order): bool { + $order[] = 'update'; + + return true; + }, + ); + + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api); + + $this->assertSame(['audit', 'update'], $order); + } + + /** + * @throws \Exception + */ + public function testRecordsWhoAskedAndTheStatusItObserved(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash') + ->willReturn($this->entry(StatusTypeEnum::Suspended->value)); + $this->statusUpdaterMock->method('setStatus')->willReturn(true); + + $this->statusAuditRepositoryMock->expects($this->once()) + ->method('record') + ->with( + self::CREDENTIAL_ID_HASH, + self::LIST_ID, + self::IDX, + StatusTypeEnum::Suspended->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Api, + 'HR system', + ); + + $this->sut()->setStatus( + self::CREDENTIAL_ID, + StatusTypeEnum::Invalid, + StatusChangeSourceEnum::Api, + 'HR system', + ); + } + + /** + * The credential identifier is a durable, externally held value; the trail stores only its hash so + * that it does not outlive the linkage which is deliberately deleted at expiry. + * + * @throws \Exception + */ + public function testTheAuditTrailNeverSeesTheCredentialIdentifier(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn($this->entry()); + $this->statusUpdaterMock->method('setStatus')->willReturn(true); + + $this->statusAuditRepositoryMock->expects($this->once()) + ->method('record') + ->with($this->logicalNot($this->equalTo(self::CREDENTIAL_ID))); + + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api); + } + + /** + * A caller which never saw the answer to its request repeats it. That is a success, and it writes + * nothing: the change it is repeating was recorded when it first happened. + * + * @throws \Exception + */ + public function testRepeatingARequestChangesNothingAndRecordsNothing(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash') + ->willReturn($this->entry(StatusTypeEnum::Invalid->value)); + + $this->statusAuditRepositoryMock->expects($this->never())->method('record'); + $this->statusUpdaterMock->expects($this->never())->method('setStatus'); + + $change = $this->sut()->setStatus( + self::CREDENTIAL_ID, + StatusTypeEnum::Invalid, + StatusChangeSourceEnum::Api, + ); + + // Still the status that was asked for, so still a success -- just not this call's doing. + $this->assertFalse($change?->isChanged()); + $this->assertSame(StatusTypeEnum::Invalid, $change->getStatus()); + } + + /** + * @throws \Exception + */ + public function testReportsNothingToActOnForAnUnknownCredential(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn(null); + + $this->assertNull( + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api), + ); + } + + /** + * Every index of a list exists as a row from the moment the list is created, so an unallocated row + * describes no credential at all. + * + * @throws \Exception + */ + public function testReportsNothingToActOnForAnUnallocatedEntry(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash') + ->willReturn($this->entry(isAllocated: false)); + + $this->assertNull( + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api), + ); + } + + /** + * An expired credential is already refused on its own claims, so withdrawing it changes nothing + * that is not already true. It is answered the same way as an unknown one, which is also what will + * happen once the linkage is deleted at expiry. + * + * @throws \Exception + */ + public function testReportsNothingToActOnForAnExpiredCredential(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn( + $this->entry(expiresAt: $this->helpers->dateTime()->getUtc()->sub(new DateInterval('PT1S'))), + ); + + $this->statusUpdaterMock->expects($this->never())->method('setStatus'); + + $this->assertNull( + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api), + ); + } + + /** + * @throws \Exception + */ + public function testActsOnACredentialWhichHasNotExpiredYet(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn( + $this->entry(expiresAt: $this->helpers->dateTime()->getUtc()->add(new DateInterval('P1D'))), + ); + $this->statusUpdaterMock->method('setStatus')->willReturn(true); + + $this->assertNotNull( + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api), + ); + } + + /** + * Raised, not swallowed: the caller has to be told the credential does not hold what it asked for. + * + * @throws \Exception + */ + public function testRaisesAFailureToApplyTheChange(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn($this->entry()); + $this->statusUpdaterMock->method('setStatus') + ->willThrowException(new StatusConflictException('kept losing')); + + $this->expectException(StatusConflictException::class); + + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api); + } + + /** + * Whether a list can carry a status is fixed when the list is created, so this is not a change + * which failed but one which was never possible. Recording it would leave a permanent row + * describing a transition that could not have happened, indistinguishable from one that did. + * + * @throws \Exception + */ + public function testRecordsNothingForAStatusTheListCouldNeverCarry(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn($this->entry()); + $this->statusUpdaterMock->method('enforceCanRepresent') + ->willThrowException(new UnsupportedStatusException('one bit per entry')); + + $this->statusAuditRepositoryMock->expects($this->never())->method('record'); + $this->statusUpdaterMock->expects($this->never())->method('setStatus'); + + $this->expectException(UnsupportedStatusException::class); + + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Suspended, StatusChangeSourceEnum::Api); + } + + /** + * A change which is recorded and then lost leaves a row someone has to find. Naming it in the log + * is the difference between finding it and hunting by timestamp. + * + * @throws \Exception + */ + public function testNamesTheAuditRowItLeftBehindWhenTheChangeFails(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn($this->entry()); + $this->statusAuditRepositoryMock->method('record')->willReturn('an-audit-row-id'); + $this->statusUpdaterMock->method('setStatus') + ->willThrowException(new StatusConflictException('kept losing')); + + $this->loggerServiceMock->expects($this->once()) + ->method('error') + ->with( + $this->anything(), + $this->callback( + static fn(array $context): bool => ($context['auditId'] ?? null) === 'an-audit-row-id', + ), + ); + + $this->expectException(StatusConflictException::class); + + $this->sut()->setStatus(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Api); + } + + /** + * @throws \Exception + */ + public function testReportsTheStatusACredentialHolds(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash') + ->willReturn($this->entry(StatusTypeEnum::Suspended->value)); + + $this->assertSame(StatusTypeEnum::Suspended->value, $this->sut()->getStatusValue(self::CREDENTIAL_ID)); + } + + /** + * @throws \Exception + */ + public function testReportsNoStatusForACredentialItCanNotActOn(): void + { + $this->statusListEntryRepositoryMock->method('findByCredentialIdHash')->willReturn(null); + + $this->assertNull($this->sut()->getStatusValue(self::CREDENTIAL_ID)); + } +} From 18d6a2f749a7b13815e294cdfd366820f222490d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 7 Aug 2026 15:29:08 +0200 Subject: [PATCH 6/9] Change credential statuses from the administration screens --- locales/en/LC_MESSAGES/oidc.po | 96 +++ locales/es/LC_MESSAGES/oidc.po | 96 +++ locales/fr/LC_MESSAGES/oidc.po | 96 +++ locales/hr/LC_MESSAGES/oidc.po | 96 +++ locales/it/LC_MESSAGES/oidc.po | 96 +++ locales/nl/LC_MESSAGES/oidc.po | 96 +++ routing/routes/routes.php | 10 + src/Codebooks/RoutesEnum.php | 5 + .../Admin/CredentialStatusController.php | 329 +++++++++++ src/Factories/TemplateFactory.php | 7 + src/Forms/CredentialStatusForm.php | 97 +++ .../StatusListEntryRepository.php | 132 +++++ src/Services/DatabaseMigration.php | 35 ++ src/Utils/Routes.php | 12 + templates/credential-status.twig | 167 ++++++ .../Admin/CredentialStatusControllerTest.php | 553 ++++++++++++++++++ .../src/Forms/CredentialStatusFormTest.php | 127 ++++ .../StatusListEntryRepositoryTest.php | 379 ++++++++++++ 18 files changed, 2429 insertions(+) create mode 100644 src/Controllers/Admin/CredentialStatusController.php create mode 100644 src/Forms/CredentialStatusForm.php create mode 100644 templates/credential-status.twig create mode 100644 tests/unit/src/Controllers/Admin/CredentialStatusControllerTest.php create mode 100644 tests/unit/src/Forms/CredentialStatusFormTest.php create mode 100644 tests/unit/src/Repositories/StatusListEntryRepositoryTest.php diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index 354af826..0fe86add 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -1651,3 +1651,99 @@ msgstr "" msgid "Authorization token not provided in the Authorization header." msgstr "" + +msgid "Credential Status" +msgstr "" + +msgid "Credential or user identifier" +msgstr "" + +msgid "" +"Enter a whole credential identifier, or the identifier of the person " +"credentials were issued to. Both are matched exactly, since the subject is " +"stored only as a keyed hash." +msgstr "" + +msgid "Credentials" +msgstr "" + +msgid "Status Lists which can never be retired" +msgstr "" + +msgid "" +"Credentials which never expire keep the Status List holding them alive for " +"good, since it has to stay resolvable for as long as any credential in it " +"can be presented. Setting a lifetime for a credential configuration applies " +"to credentials issued from then on." +msgstr "" + +msgid "No credential matches that identifier." +msgstr "" + +msgid "No credentials with a status list entry have been issued." +msgstr "" + +msgid "Change status" +msgstr "" + +msgid "Configuration" +msgstr "" + +msgid "Subject" +msgstr "" + +msgid "Issued at" +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Apply" +msgstr "" + +msgid "Are you sure you want to change the status of this credential?" +msgstr "" + +msgid "Valid" +msgstr "" + +msgid "Revoked" +msgstr "" + +msgid "Suspended" +msgstr "" + +msgid "Credential identifier is missing." +msgstr "" + +msgid "Status to set is missing." +msgstr "" + +msgid "The credential status change was not accepted. Please try again." +msgstr "" + +msgid "" +"This credential belongs to a Status List which was created without room for " +"that status. Credentials issued from now on can carry it once the pool is " +"configured for it, but the ones already in this list can not." +msgstr "" + +msgid "" +"The status of this credential was changed by something else at the same " +"time, so it now holds something other than what was asked for. Check what it " +"says and try again." +msgstr "" + +msgid "The credential status could not be changed." +msgstr "" + +msgid "" +"No credential which can have its status changed was found. It may have " +"expired, in which case it is already refused on its own claims." +msgstr "" + +msgid "The credential status has been changed." +msgstr "" + +msgid "The credential already had that status, so nothing was changed." +msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index 27475574..02d6ad84 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -1651,3 +1651,99 @@ msgstr "" msgid "Authorization token not provided in the Authorization header." msgstr "" + +msgid "Credential Status" +msgstr "" + +msgid "Credential or user identifier" +msgstr "" + +msgid "" +"Enter a whole credential identifier, or the identifier of the person " +"credentials were issued to. Both are matched exactly, since the subject is " +"stored only as a keyed hash." +msgstr "" + +msgid "Credentials" +msgstr "" + +msgid "Status Lists which can never be retired" +msgstr "" + +msgid "" +"Credentials which never expire keep the Status List holding them alive for " +"good, since it has to stay resolvable for as long as any credential in it " +"can be presented. Setting a lifetime for a credential configuration applies " +"to credentials issued from then on." +msgstr "" + +msgid "No credential matches that identifier." +msgstr "" + +msgid "No credentials with a status list entry have been issued." +msgstr "" + +msgid "Change status" +msgstr "" + +msgid "Configuration" +msgstr "" + +msgid "Subject" +msgstr "" + +msgid "Issued at" +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Apply" +msgstr "" + +msgid "Are you sure you want to change the status of this credential?" +msgstr "" + +msgid "Valid" +msgstr "" + +msgid "Revoked" +msgstr "" + +msgid "Suspended" +msgstr "" + +msgid "Credential identifier is missing." +msgstr "" + +msgid "Status to set is missing." +msgstr "" + +msgid "The credential status change was not accepted. Please try again." +msgstr "" + +msgid "" +"This credential belongs to a Status List which was created without room for " +"that status. Credentials issued from now on can carry it once the pool is " +"configured for it, but the ones already in this list can not." +msgstr "" + +msgid "" +"The status of this credential was changed by something else at the same " +"time, so it now holds something other than what was asked for. Check what it " +"says and try again." +msgstr "" + +msgid "The credential status could not be changed." +msgstr "" + +msgid "" +"No credential which can have its status changed was found. It may have " +"expired, in which case it is already refused on its own claims." +msgstr "" + +msgid "The credential status has been changed." +msgstr "" + +msgid "The credential already had that status, so nothing was changed." +msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index 2699744f..048f8b72 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -1651,3 +1651,99 @@ msgstr "" msgid "Authorization token not provided in the Authorization header." msgstr "" + +msgid "Credential Status" +msgstr "" + +msgid "Credential or user identifier" +msgstr "" + +msgid "" +"Enter a whole credential identifier, or the identifier of the person " +"credentials were issued to. Both are matched exactly, since the subject is " +"stored only as a keyed hash." +msgstr "" + +msgid "Credentials" +msgstr "" + +msgid "Status Lists which can never be retired" +msgstr "" + +msgid "" +"Credentials which never expire keep the Status List holding them alive for " +"good, since it has to stay resolvable for as long as any credential in it " +"can be presented. Setting a lifetime for a credential configuration applies " +"to credentials issued from then on." +msgstr "" + +msgid "No credential matches that identifier." +msgstr "" + +msgid "No credentials with a status list entry have been issued." +msgstr "" + +msgid "Change status" +msgstr "" + +msgid "Configuration" +msgstr "" + +msgid "Subject" +msgstr "" + +msgid "Issued at" +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Apply" +msgstr "" + +msgid "Are you sure you want to change the status of this credential?" +msgstr "" + +msgid "Valid" +msgstr "" + +msgid "Revoked" +msgstr "" + +msgid "Suspended" +msgstr "" + +msgid "Credential identifier is missing." +msgstr "" + +msgid "Status to set is missing." +msgstr "" + +msgid "The credential status change was not accepted. Please try again." +msgstr "" + +msgid "" +"This credential belongs to a Status List which was created without room for " +"that status. Credentials issued from now on can carry it once the pool is " +"configured for it, but the ones already in this list can not." +msgstr "" + +msgid "" +"The status of this credential was changed by something else at the same " +"time, so it now holds something other than what was asked for. Check what it " +"says and try again." +msgstr "" + +msgid "The credential status could not be changed." +msgstr "" + +msgid "" +"No credential which can have its status changed was found. It may have " +"expired, in which case it is already refused on its own claims." +msgstr "" + +msgid "The credential status has been changed." +msgstr "" + +msgid "The credential already had that status, so nothing was changed." +msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index d14e6f03..429b7415 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -1699,3 +1699,99 @@ msgstr "" msgid "Authorization token not provided in the Authorization header." msgstr "" + +msgid "Credential Status" +msgstr "" + +msgid "Credential or user identifier" +msgstr "" + +msgid "" +"Enter a whole credential identifier, or the identifier of the person " +"credentials were issued to. Both are matched exactly, since the subject is " +"stored only as a keyed hash." +msgstr "" + +msgid "Credentials" +msgstr "" + +msgid "Status Lists which can never be retired" +msgstr "" + +msgid "" +"Credentials which never expire keep the Status List holding them alive for " +"good, since it has to stay resolvable for as long as any credential in it " +"can be presented. Setting a lifetime for a credential configuration applies " +"to credentials issued from then on." +msgstr "" + +msgid "No credential matches that identifier." +msgstr "" + +msgid "No credentials with a status list entry have been issued." +msgstr "" + +msgid "Change status" +msgstr "" + +msgid "Configuration" +msgstr "" + +msgid "Subject" +msgstr "" + +msgid "Issued at" +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Apply" +msgstr "" + +msgid "Are you sure you want to change the status of this credential?" +msgstr "" + +msgid "Valid" +msgstr "" + +msgid "Revoked" +msgstr "" + +msgid "Suspended" +msgstr "" + +msgid "Credential identifier is missing." +msgstr "" + +msgid "Status to set is missing." +msgstr "" + +msgid "The credential status change was not accepted. Please try again." +msgstr "" + +msgid "" +"This credential belongs to a Status List which was created without room for " +"that status. Credentials issued from now on can carry it once the pool is " +"configured for it, but the ones already in this list can not." +msgstr "" + +msgid "" +"The status of this credential was changed by something else at the same " +"time, so it now holds something other than what was asked for. Check what it " +"says and try again." +msgstr "" + +msgid "The credential status could not be changed." +msgstr "" + +msgid "" +"No credential which can have its status changed was found. It may have " +"expired, in which case it is already refused on its own claims." +msgstr "" + +msgid "The credential status has been changed." +msgstr "" + +msgid "The credential already had that status, so nothing was changed." +msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index 0d3a24dc..fc0fb746 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -1651,3 +1651,99 @@ msgstr "" msgid "Authorization token not provided in the Authorization header." msgstr "" + +msgid "Credential Status" +msgstr "" + +msgid "Credential or user identifier" +msgstr "" + +msgid "" +"Enter a whole credential identifier, or the identifier of the person " +"credentials were issued to. Both are matched exactly, since the subject is " +"stored only as a keyed hash." +msgstr "" + +msgid "Credentials" +msgstr "" + +msgid "Status Lists which can never be retired" +msgstr "" + +msgid "" +"Credentials which never expire keep the Status List holding them alive for " +"good, since it has to stay resolvable for as long as any credential in it " +"can be presented. Setting a lifetime for a credential configuration applies " +"to credentials issued from then on." +msgstr "" + +msgid "No credential matches that identifier." +msgstr "" + +msgid "No credentials with a status list entry have been issued." +msgstr "" + +msgid "Change status" +msgstr "" + +msgid "Configuration" +msgstr "" + +msgid "Subject" +msgstr "" + +msgid "Issued at" +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Apply" +msgstr "" + +msgid "Are you sure you want to change the status of this credential?" +msgstr "" + +msgid "Valid" +msgstr "" + +msgid "Revoked" +msgstr "" + +msgid "Suspended" +msgstr "" + +msgid "Credential identifier is missing." +msgstr "" + +msgid "Status to set is missing." +msgstr "" + +msgid "The credential status change was not accepted. Please try again." +msgstr "" + +msgid "" +"This credential belongs to a Status List which was created without room for " +"that status. Credentials issued from now on can carry it once the pool is " +"configured for it, but the ones already in this list can not." +msgstr "" + +msgid "" +"The status of this credential was changed by something else at the same " +"time, so it now holds something other than what was asked for. Check what it " +"says and try again." +msgstr "" + +msgid "The credential status could not be changed." +msgstr "" + +msgid "" +"No credential which can have its status changed was found. It may have " +"expired, in which case it is already refused on its own claims." +msgstr "" + +msgid "The credential status has been changed." +msgstr "" + +msgid "The credential already had that status, so nothing was changed." +msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 96f62280..2a228862 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -1605,3 +1605,99 @@ msgstr "" msgid "Authorization token not provided in the Authorization header." msgstr "" + +msgid "Credential Status" +msgstr "" + +msgid "Credential or user identifier" +msgstr "" + +msgid "" +"Enter a whole credential identifier, or the identifier of the person " +"credentials were issued to. Both are matched exactly, since the subject is " +"stored only as a keyed hash." +msgstr "" + +msgid "Credentials" +msgstr "" + +msgid "Status Lists which can never be retired" +msgstr "" + +msgid "" +"Credentials which never expire keep the Status List holding them alive for " +"good, since it has to stay resolvable for as long as any credential in it " +"can be presented. Setting a lifetime for a credential configuration applies " +"to credentials issued from then on." +msgstr "" + +msgid "No credential matches that identifier." +msgstr "" + +msgid "No credentials with a status list entry have been issued." +msgstr "" + +msgid "Change status" +msgstr "" + +msgid "Configuration" +msgstr "" + +msgid "Subject" +msgstr "" + +msgid "Issued at" +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Apply" +msgstr "" + +msgid "Are you sure you want to change the status of this credential?" +msgstr "" + +msgid "Valid" +msgstr "" + +msgid "Revoked" +msgstr "" + +msgid "Suspended" +msgstr "" + +msgid "Credential identifier is missing." +msgstr "" + +msgid "Status to set is missing." +msgstr "" + +msgid "The credential status change was not accepted. Please try again." +msgstr "" + +msgid "" +"This credential belongs to a Status List which was created without room for " +"that status. Credentials issued from now on can carry it once the pool is " +"configured for it, but the ones already in this list can not." +msgstr "" + +msgid "" +"The status of this credential was changed by something else at the same " +"time, so it now holds something other than what was asked for. Check what it " +"says and try again." +msgstr "" + +msgid "The credential status could not be changed." +msgstr "" + +msgid "" +"No credential which can have its status changed was found. It may have " +"expired, in which case it is already refused on its own claims." +msgstr "" + +msgid "The credential status has been changed." +msgstr "" + +msgid "The credential already had that status, so nothing was changed." +msgstr "" diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 7669e44e..4bfd3863 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\oidc\Controllers\AccessTokenController; use SimpleSAML\Module\oidc\Controllers\Admin\ClientController; use SimpleSAML\Module\oidc\Controllers\Admin\ConfigController; +use SimpleSAML\Module\oidc\Controllers\Admin\CredentialStatusController; use SimpleSAML\Module\oidc\Controllers\Admin\FederationTestController; use SimpleSAML\Module\oidc\Controllers\Admin\VerifiableCredentailsTestController; use SimpleSAML\Module\oidc\Controllers\Api\VciCredentialOfferApiController; @@ -75,6 +76,15 @@ ->controller([ClientController::class, 'delete']) ->methods([HttpMethodsEnum::POST->value]); + // Credential status management + + $routes->add(RoutesEnum::AdminCredentialStatus->name, RoutesEnum::AdminCredentialStatus->value) + ->controller([CredentialStatusController::class, 'index']) + ->methods([HttpMethodsEnum::GET->value]); + $routes->add(RoutesEnum::AdminCredentialStatusChange->name, RoutesEnum::AdminCredentialStatusChange->value) + ->controller([CredentialStatusController::class, 'change']) + ->methods([HttpMethodsEnum::POST->value]); + // Testing $routes->add(RoutesEnum::AdminTestTrustChainResolution->name, RoutesEnum::AdminTestTrustChainResolution->value) diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index c8911dbf..43922929 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -26,6 +26,11 @@ enum RoutesEnum: string case AdminClientsResetSecret = 'admin/clients/reset-secret'; case AdminClientsDelete = 'admin/clients/delete'; + // Credential status management + + case AdminCredentialStatus = 'admin/credential-status'; + case AdminCredentialStatusChange = 'admin/credential-status/change'; + // Testing case AdminTestTrustChainResolution = 'admin/test/trust-chain-resolution'; case AdminTestTrustMarkValidation = 'admin/test/trust-mark-validation'; diff --git a/src/Controllers/Admin/CredentialStatusController.php b/src/Controllers/Admin/CredentialStatusController.php new file mode 100644 index 00000000..ed47a73d --- /dev/null +++ b/src/Controllers/Admin/CredentialStatusController.php @@ -0,0 +1,329 @@ +authorization->requireAdmin(true); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Error\Exception + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + public function index(Request $request): Response + { + $query = trim($request->query->getString(self::PARAM_QUERY)); + + // One box, matched against both stored forms of what was typed. An administrator has either a + // credential identifier, taken from the credential itself, or the identifier of the person it + // was issued to, and cannot be expected to tell the interface which of the two it is. + $credentialIdHash = $query === '' ? + null : + $this->statusListEntryRepository->hashCredentialId($query); + $subjectRef = $query === '' ? null : $this->subjectRefHasher->hash($query); + + $pagination = $this->statusListEntryRepository->findAllocatedPaginated( + $request->query->getInt(self::PARAM_PAGE, 1), + $credentialIdHash, + $subjectRef, + ); + + return $this->templateFactory->build( + 'oidc:credential-status.twig', + [ + 'entries' => $pagination['items'], + 'total' => $pagination['total'], + 'numPages' => $pagination['numPages'], + 'currentPage' => $pagination['currentPage'], + 'query' => $query, + 'allowedStatuses' => $this->resolveAllowedStatuses($pagination['items']), + 'statusLabels' => CredentialStatusForm::statusOptions(), + 'neverRetiringListCount' => $this->statusListEntryRepository->countNeverRetiringLists(), + 'form' => $this->formFactory->build(CredentialStatusForm::class), + 'actionRoute' => $this->routes->urlAdminCredentialStatusChange(), + ], + RoutesEnum::AdminCredentialStatus->value, + ); + } + + /** + * @throws \SimpleSAML\Error\Exception + */ + public function change(Request $request): Response + { + $form = $this->formFactory->build(CredentialStatusForm::class); + + if (!$form->isSuccess()) { + // Everything this form validates is either present or the request did not come from the + // listing: a missing or stale CSRF token, a status which is not a status. There is nothing + // for the administrator to correct field by field, so the errors are not rendered back. + $this->logger->warning( + 'CredentialStatusController: a credential status change was not accepted.', + ['errors' => $form->getErrors()], + ); + + return $this->redirectToListing( + $request, + Translate::noop('The credential status change was not accepted. Please try again.'), + ); + } + + $values = $form->getValues('array'); + $credentialId = is_string($values[CredentialStatusForm::FIELD_CREDENTIAL_ID] ?? null) ? + trim((string)$values[CredentialStatusForm::FIELD_CREDENTIAL_ID]) : + ''; + // Cast rather than matched, since the form has already established this is one of the values + // offered; only its type is left open, the select carrying integer keys. + $status = is_numeric($values[CredentialStatusForm::FIELD_STATUS] ?? null) ? + StatusTypeEnum::tryFrom((int)$values[CredentialStatusForm::FIELD_STATUS]) : + null; + + if ($credentialId === '' || !$status instanceof StatusTypeEnum) { + return $this->redirectToListing( + $request, + Translate::noop('The credential status change was not accepted. Please try again.'), + ); + } + + return $this->redirectToListing($request, $this->applyStatus($credentialId, $status)); + } + + /** + * @return string What to tell the administrator, which is the only answer this surface gives: the + * listing it returns to shows the outcome regardless. + */ + protected function applyStatus(string $credentialId, StatusTypeEnum $status): string + { + try { + $change = $this->credentialStatusService->setStatus( + $credentialId, + $status, + StatusChangeSourceEnum::Admin, + $this->resolveActorRef(), + ); + } catch (UnsupportedStatusException $exception) { + // Fixed when the list was created and not something retrying can change, so this says what + // would have to be different rather than inviting another attempt. + $this->logger->error( + 'CredentialStatusController: requested status can not be represented: ' . $exception->getMessage(), + ); + + return Translate::noop( + 'This credential belongs to a Status List which was created without room for that ' . + 'status. Credentials issued from now on can carry it once the pool is configured for ' . + 'it, but the ones already in this list can not.', + ); + } catch (StatusConflictException $exception) { + $this->logger->error( + 'CredentialStatusController: status change lost to concurrent changes: ' . $exception->getMessage(), + ); + + return Translate::noop( + 'The status of this credential was changed by something else at the same time, so it ' . + 'now holds something other than what was asked for. Check what it says and try again.', + ); + } catch (Throwable $exception) { + $this->logger->error( + 'CredentialStatusController: unable to change the credential status: ' . $exception->getMessage(), + ); + + return Translate::noop('The credential status could not be changed.'); + } + + if (!$change instanceof CredentialStatusChange) { + return Translate::noop( + 'No credential which can have its status changed was found. It may have expired, in ' . + 'which case it is already refused on its own claims.', + ); + } + + return $change->isChanged() ? + Translate::noop('The credential status has been changed.') : + Translate::noop('The credential already had that status, so nothing was changed.'); + } + + /** + * Who to record as having asked for a change. + * + * SimpleSAMLphp's administrator authentication is a shared password in most deployments, which + * names nobody, and a trail claiming otherwise would be worse than one which does not. Where a + * deployment has pointed that login at a real authentication source instead, the identifier it + * releases is a genuine answer to who did this, and is recorded. + * + * Never allowed to fail: this is a label on an audit row, and a credential must not stay in a + * wallet because its administrator could not be named. + */ + protected function resolveActorRef(): string + { + try { + $adminAuth = $this->authSimpleFactory->forAuthSourceId(self::ADMIN_AUTH_SOURCE_ID); + + if ($adminAuth->isAuthenticated()) { + $userId = $this->userIdentifierResolver->resolve( + $this->moduleConfig->getUserIdentifierAttributes(), + $adminAuth->getAttributes(), + ); + + if (is_string($userId) && $userId !== '') { + return mb_substr($userId, 0, self::ACTOR_REF_MAX_LENGTH); + } + } + } catch (Throwable $throwable) { + $this->logger->debug( + 'CredentialStatusController: could not resolve the administrator identity: ' . + $throwable->getMessage(), + ); + } + + return self::ACTOR_REF_ADMIN; + } + + /** + * Which statuses each listed credential can actually be moved to, keyed by the list it sits in. + * + * How many bits an entry occupies is fixed when its list is created, so a list can be unable to + * carry a status permanently. Offering one anyway would put a button on the page whose only + * possible outcome is an error message. + * + * @param \SimpleSAML\Module\oidc\StatusList\Values\StatusListEntryRecord[] $entries + * @return array> List ID to status value to label. + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function resolveAllowedStatuses(array $entries): array + { + $allowed = []; + + foreach ($entries as $entry) { + $statusListId = $entry->getStatusListId(); + + if (array_key_exists($statusListId, $allowed)) { + continue; + } + + $statusList = $this->statusListRepository->findById($statusListId); + $options = []; + + foreach (StatusTypeEnum::cases() as $status) { + // A list which cannot be read at all is offered everything rather than nothing. The + // foreign key makes its absence a broken database rather than a state to design for, + // and between showing an administrator no way to withdraw a credential and showing one + // which reports why it did not work, the second is the one that can be acted on. + if (!$statusList instanceof StatusListRecord || $statusList->isStatusValueAllowed($status->value)) { + $options[$status->value] = CredentialStatusForm::labelFor($status); + } + } + + $allowed[$statusListId] = $options; + } + + return $allowed; + } + + /** + * Back to the listing the change was made from, on the page and search it was made from. + */ + protected function redirectToListing(Request $request, string $message): Response + { + $this->sessionMessagesService->addMessage($message); + + $parameters = []; + $query = trim($request->request->getString(self::PARAM_QUERY)); + $page = $request->request->getInt(self::PARAM_PAGE, 1); + + if ($query !== '') { + $parameters[self::PARAM_QUERY] = $query; + } + + if ($page > 1) { + $parameters[self::PARAM_PAGE] = $page; + } + + return $this->routes->newRedirectResponseToModuleUrl( + RoutesEnum::AdminCredentialStatus->value, + $parameters, + ); + } +} diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 42bd9a66..758514bb 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -170,6 +170,13 @@ protected function includeDefaultMenuItems(): void ), ); + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->routes->getModuleUrl(RoutesEnum::AdminCredentialStatus->value), + Translate::noop('Credential Status'), + ), + ); + $this->oidcMenu->addItem( $this->oidcMenu->buildItem( $this->routes->getModuleUrl(RoutesEnum::AdminTestVerifiableCredentialIssuance->value), diff --git a/src/Forms/CredentialStatusForm.php b/src/Forms/CredentialStatusForm.php new file mode 100644 index 00000000..b286380a --- /dev/null +++ b/src/Forms/CredentialStatusForm.php @@ -0,0 +1,97 @@ +buildForm(); + } + + /** + * The statuses this form accepts, as submitted value to label. + * + * @return array + */ + public static function statusOptions(): array + { + $options = []; + + foreach (StatusTypeEnum::cases() as $status) { + $options[$status->value] = self::labelFor($status); + } + + return $options; + } + + /** + * Wording an administrator can act on, rather than the specification's own terms. + * + * "Invalid" in particular is what the specification calls a status which every other document in + * this space calls revoked, and an administrator looking for the way to withdraw a credential + * should not have to know that. + */ + public static function labelFor(StatusTypeEnum $status): string + { + return match ($status) { + StatusTypeEnum::Valid => Translate::noop('Valid'), + StatusTypeEnum::Invalid => Translate::noop('Revoked'), + StatusTypeEnum::Suspended => Translate::noop('Suspended'), + }; + } + + /** + * @throws \Exception + */ + protected function buildForm(): void + { + $this->setMethod('POST'); + $this->addComponent($this->csrfProtection, Form::ProtectorId); + + $this->addHidden(self::FIELD_CREDENTIAL_ID) + ->setRequired(Translate::noop('Credential identifier is missing.')); + + $this->addSelect(self::FIELD_STATUS, Translate::noop('Status'), self::statusOptions()) + ->setRequired(Translate::noop('Status to set is missing.')); + } +} diff --git a/src/Repositories/StatusListEntryRepository.php b/src/Repositories/StatusListEntryRepository.php index 73643966..b217e625 100644 --- a/src/Repositories/StatusListEntryRepository.php +++ b/src/Repositories/StatusListEntryRepository.php @@ -298,6 +298,138 @@ public function countAllocated(string $statusListId): int return is_numeric($total) ? (int)$total : 0; } + /** + * A page of issued credentials, newest first, for the administration screens. + * + * Only allocated rows are listed. Every index of a list exists as a row from the moment the list is + * created, so the unallocated ones are the bulk of this table and none of them is anything that was + * issued. + * + * Both search terms are the stored forms of the one thing an administrator typed, and either + * matching is a hit: they will have entered a credential identifier or a user identifier and cannot + * be expected to say which. Both are exact comparisons against indexed columns rather than a + * pattern match, which is not a limitation but the only thing available -- the credential ID is + * unindexed text, and the subject is stored as a keyed hash which nothing can be matched against + * partially. + * + * Read from the primary, so that an administrator who has just changed a status is not sent back to + * a page which a lagging secondary still shows the old one on. + * + * @param ?string $credentialIdHash Hash of a credential identifier searched for, or null. + * @param ?string $subjectRef Keyed hash of a user identifier searched for, or null. + * @return array{ + * items: \SimpleSAML\Module\oidc\StatusList\Values\StatusListEntryRecord[], + * total: int, + * numPages: int, + * currentPage: int + * } + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + public function findAllocatedPaginated( + int $page = 1, + ?string $credentialIdHash = null, + ?string $subjectRef = null, + ): array { + $condition = 'allocated = :allocated'; + $params = ['allocated' => [true, PDO::PARAM_BOOL]]; + + if (is_string($credentialIdHash) || is_string($subjectRef)) { + $condition .= ' AND (credential_id_hash = :credential_id_hash OR subject_ref = :subject_ref)'; + // Null never equals anything, so the half of the comparison which was not searched for + // simply never matches, and the two cases need no separate statement. + $params['credential_id_hash'] = $credentialIdHash; + $params['subject_ref'] = $subjectRef; + } + + $total = $this->countWhere($condition, $params); + $itemsPerPage = $this->getItemsPerPage(); + $numPages = max((int)ceil($total / $itemsPerPage), 1); + $currentPage = min(max($page, 1), $numPages); + $offset = ($currentPage - 1) * $itemsPerPage; + + $rows = $this->readPrimary( + sprintf( + // The list and index are in the ordering as a tie break, not for their own sake. A + // batch issuance stamps the same issued_at on every credential in it, and an order + // which leaves those rows free to come back in any sequence would show one of them + // twice and another not at all as the administrator pages through. + 'SELECT * FROM %s WHERE %s ORDER BY issued_at DESC, status_list_id ASC, idx ASC ' . + 'LIMIT %d OFFSET %d', + $this->getTableName(), + $condition, + $itemsPerPage, + $offset, + ), + $params, + ); + + $items = []; + + /** @var mixed $row */ + foreach ($rows as $row) { + if (is_array($row)) { + $items[] = StatusListEntryRecord::fromRow($row); + } + } + + return [ + 'items' => $items, + 'total' => $total, + 'numPages' => $numPages, + 'currentPage' => $currentPage, + ]; + } + + /** + * How many Status Lists hold a credential which never expires, and can therefore never be retired. + * + * Surfaced to administrators because credential expiry is opt in and off by default, which makes + * permanent storage growth the quiet consequence of leaving it that way. + */ + public function countNeverRetiringLists(): int + { + $rows = $this->readPrimary( + "SELECT COUNT(DISTINCT status_list_id) AS list_total FROM {$this->getTableName()} " . + 'WHERE allocated = :allocated AND expires_at IS NULL', + ['allocated' => [true, PDO::PARAM_BOOL]], + ); + + /** @var mixed $total */ + $total = $rows[0]['list_total'] ?? null; + + return is_numeric($total) ? (int)$total : 0; + } + + /** + * @param array $params + */ + protected function countWhere(string $condition, array $params): int + { + $rows = $this->readPrimary( + sprintf('SELECT COUNT(*) AS entry_total FROM %s WHERE %s', $this->getTableName(), $condition), + $params, + ); + + /** @var mixed $total */ + $total = $rows[0]['entry_total'] ?? null; + + return is_numeric($total) ? (int)$total : 0; + } + + /** + * @throws \Exception + */ + protected function getItemsPerPage(): int + { + return $this->moduleConfig->config()->getOptionalIntegerRange( + ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE, + 1, + 100, + 20, + ); + } + /** * @param array $rows * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index fd6dc328..6d61c310 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -264,6 +264,11 @@ public function migrate(): void $this->version20260801000004(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260801000004')"); } + + if (!in_array('20260801000005', $versions, true)) { + $this->version20260801000005(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260801000005')"); + } } private function versionsTableName(): string @@ -1029,6 +1034,36 @@ private function version20260801000004(): void ,); } + /** + * Indexes the administration screens read Status List entries by. + * + * Both answer questions no other caller asks. Everything else addresses an entry by its list and + * index, or by the hash of one credential ID; an administrator arrives with neither, and has to be + * able to page through what was issued and to find every credential belonging to one person. + */ + private function version20260801000005(): void + { + $entryTableName = $this->database->applyPrefix(StatusListEntryRepository::TABLE_NAME); + + // Paging through issued credentials, newest first. Allocation leads, because the unallocated + // rows are the bulk of the table and are never listed; the timestamp follows so the ordering + // comes out of the index rather than out of a sort over everything it selected. + $this->createIndex( + $this->generateIdentifierName([$entryTableName, 'allocated_issued_at'], 'idx'), + $entryTableName, + 'allocated, issued_at', + ); + + // Every credential issued to one subject, which is how a lost device or a departing member of + // staff is dealt with. The stored value is a keyed hash, so this is an equality lookup on a + // value the administrator never sees and the search box derives. + $this->createIndex( + $this->generateIdentifierName([$entryTableName, 'subject_ref'], 'idx'), + $entryTableName, + 'subject_ref', + ); + } + /** * Whether a table already has a column. * diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index f269e31b..84f3e728 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -139,6 +139,18 @@ public function urlAdminClientsDelete(string $clientId, array $parameters = []): return $this->getModuleUrl(RoutesEnum::AdminClientsDelete->value, $parameters); } + // Credential status management + + public function urlAdminCredentialStatus(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminCredentialStatus->value, $parameters); + } + + public function urlAdminCredentialStatusChange(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminCredentialStatusChange->value, $parameters); + } + // Testing public function urlAdminTestTrustChainResolution(array $parameters = []): string diff --git a/templates/credential-status.twig b/templates/credential-status.twig new file mode 100644 index 00000000..44e70abb --- /dev/null +++ b/templates/credential-status.twig @@ -0,0 +1,167 @@ +{% set subPageTitle = 'Credential Status'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + + {% set csrfControl = form['_token_'].control %} + +
+
+
+ + + {{ 'Reset'|trans }} +
+ + {% trans %}Enter a whole credential identifier, or the identifier of the person credentials were issued to. Both are matched exactly, since the subject is stored only as a keyed hash.{% endtrans %} + +
+
+
+ + {{ 'Credentials'|trans }}: {{ total }} + {% if neverRetiringListCount > 0 %} +
+ + {{ 'Status Lists which can never be retired'|trans }}: {{ neverRetiringListCount }} + {% endif %} +
+
+
+
+ +
+ + {% if neverRetiringListCount > 0 %} +

+ + {% trans %}Credentials which never expire keep the Status List holding them alive for good, since it has to stay resolvable for as long as any credential in it can be presented. Setting a lifetime for a credential configuration applies to credentials issued from then on.{% endtrans %} + +

+ {% endif %} + + {% if entries is empty %} +

+ {% if query is not empty %} + {{ 'No credential matches that identifier.'|trans }} + {% else %} + {{ 'No credentials with a status list entry have been issued.'|trans }} + {% endif %} +

+ {% else %} +
+ + + + + + + + + + {% for entry in entries %} + + + + + + {% endfor %} + +
{{ 'Credential'|trans }}{{ 'Status'|trans }}{{ 'Change status'|trans }}
+ {{ entry.credentialId }} +
+ + {{ 'Configuration'|trans }}: {{ entry.credentialConfigurationId|default('n/a') }} | + {{ 'Subject'|trans }}: + {{ entry.subjectRef ? entry.subjectRef|slice(0, 16) ~ '…' : 'n/a' }} | + {{ 'Issued at'|trans }}: {{ entry.issuedAt ? entry.issuedAt|date() : 'n/a' }} | + {{ 'Expires at'|trans }}: {{ entry.expiresAt ? entry.expiresAt|date() : 'never'|trans }} + +
+ {% if entry.status == 0 %} + + {% else %} + + {% endif %} + {% if statusLabels[entry.status] is defined %} + {{ statusLabels[entry.status]|trans }} + {% else %} + {{ 'Unknown'|trans }} ({{ entry.status }}) + {% endif %} + +
+ {{ csrfControl|raw }} + + + + + +
+
+ +
+
+ {# A window rather than every page. A deployment issuing credentials in any volume runs + to thousands of pages, and a link for each would be most of what this page weighs. #} + {% set windowStart = max(1, currentPage - 3) %} + {% set windowEnd = min(numPages, currentPage + 3) %} +
+ + + + {% if windowStart > 1 %} + + 1 + + + {% endif %} + {% for i in range(windowStart, windowEnd) %} + + {{ i }} + + {% endfor %} + {% if windowEnd < numPages %} + + + {{ numPages }} + + {% endif %} + + + +
+
+
+ {% endif %} + +{% endblock oidcContent -%} diff --git a/tests/unit/src/Controllers/Admin/CredentialStatusControllerTest.php b/tests/unit/src/Controllers/Admin/CredentialStatusControllerTest.php new file mode 100644 index 00000000..0b097226 --- /dev/null +++ b/tests/unit/src/Controllers/Admin/CredentialStatusControllerTest.php @@ -0,0 +1,553 @@ + Data the controller handed to the template. */ + protected array $templateData = []; + + /** @var string[] Messages the controller left for the administrator. */ + protected array $messages = []; + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getUserIdentifierAttributes')->willReturn(['uid']); + + $this->templateData = []; + $this->templateFactoryMock = $this->createMock(TemplateFactory::class); + $this->templateFactoryMock->method('build')->willReturnCallback( + function (string $templateName, array $data = []): Template { + $this->templateData = $data; + + return $this->createMock(Template::class); + }, + ); + + $this->authorizationMock = $this->createMock(Authorization::class); + + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('hashCredentialId')->willReturn(self::CREDENTIAL_ID_HASH); + $this->statusListEntryRepositoryMock->method('findAllocatedPaginated')->willReturn( + ['items' => [], 'total' => 0, 'numPages' => 1, 'currentPage' => 1], + ); + $this->statusListEntryRepositoryMock->method('countNeverRetiringLists')->willReturn(0); + + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->credentialStatusServiceMock = $this->createMock(CredentialStatusService::class); + + $this->subjectRefHasherMock = $this->createMock(SubjectRefHasher::class); + $this->subjectRefHasherMock->method('hash')->willReturn(self::SUBJECT_REF); + + $this->formMock = $this->createMock(CredentialStatusForm::class); + $this->formMock->method('isSuccess')->willReturn(true); + $this->formMock->method('getValues')->willReturn([ + CredentialStatusForm::FIELD_CREDENTIAL_ID => self::CREDENTIAL_ID, + CredentialStatusForm::FIELD_STATUS => StatusTypeEnum::Invalid->value, + ]); + + $this->formFactoryMock = $this->createMock(FormFactory::class); + $this->formFactoryMock->method('build')->willReturn($this->formMock); + + $this->messages = []; + $this->sessionMessagesServiceMock = $this->createMock(SessionMessagesService::class); + $this->sessionMessagesServiceMock->method('addMessage')->willReturnCallback( + function (string $message): void { + $this->messages[] = $message; + }, + ); + + $this->authSimpleMock = $this->createMock(Simple::class); + $this->authSimpleMock->method('isAuthenticated')->willReturn(false); + $this->authSimpleFactoryMock = $this->createMock(AuthSimpleFactory::class); + $this->authSimpleFactoryMock->method('forAuthSourceId')->willReturn($this->authSimpleMock); + + $this->routesMock = $this->createMock(Routes::class); + $this->routesMock->method('urlAdminCredentialStatusChange')->willReturn('https://op.example.org/change'); + $this->routesMock->method('newRedirectResponseToModuleUrl')->willReturnCallback( + static fn(string $resource = '', array $parameters = []): RedirectResponse => new RedirectResponse( + 'https://op.example.org/' . $resource . '?' . http_build_query($parameters), + ), + ); + + $this->loggerMock = $this->createMock(LoggerService::class); + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + */ + protected function sut(): CredentialStatusController + { + return new CredentialStatusController( + $this->moduleConfigMock, + $this->templateFactoryMock, + $this->authorizationMock, + $this->statusListEntryRepositoryMock, + $this->statusListRepositoryMock, + $this->credentialStatusServiceMock, + $this->subjectRefHasherMock, + $this->formFactoryMock, + $this->sessionMessagesServiceMock, + $this->authSimpleFactoryMock, + $this->userIdentifierResolver(), + $this->routesMock, + $this->loggerMock, + ); + } + + protected function userIdentifierResolver(): UserIdentifierResolver + { + return new UserIdentifierResolver(); + } + + protected function entry(string $statusListId = self::LIST_ID, int $status = 0): StatusListEntryRecord + { + return new StatusListEntryRecord( + $statusListId, + 7, + true, + $status, + null, + self::CREDENTIAL_ID, + self::CREDENTIAL_ID_HASH, + 'UniversityDegree', + self::SUBJECT_REF, + null, + null, + ); + } + + protected function statusListRecord(int ...$allowedStatuses): MockObject + { + $statusList = $this->createMock(StatusListRecord::class); + $statusList->method('isStatusValueAllowed')->willReturnCallback( + static fn(int $status): bool => in_array($status, $allowedStatuses, true), + ); + + return $statusList; + } + + /** + * Enforced where a method added later is covered by existing rather than by being remembered. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + */ + public function testRequiresAdminBeforeAnythingElse(): void + { + $this->authorizationMock->expects($this->once())->method('requireAdmin')->with(true); + + $this->assertInstanceOf(CredentialStatusController::class, $this->sut()); + } + + /** + * @throws \Throwable + */ + public function testListsEntries(): void + { + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('findAllocatedPaginated')->willReturn( + ['items' => [$this->entry()], 'total' => 1, 'numPages' => 1, 'currentPage' => 1], + ); + $this->statusListEntryRepositoryMock->method('countNeverRetiringLists')->willReturn(3); + $this->statusListRepositoryMock->method('findById')->willReturn($this->statusListRecord(0, 1)); + + $this->sut()->index(new Request()); + + $this->assertCount(1, $this->templateData['entries']); + $this->assertSame(1, $this->templateData['total']); + $this->assertSame(3, $this->templateData['neverRetiringListCount']); + $this->assertSame('', $this->templateData['query']); + } + + /** + * An administrator has either a credential identifier or the identifier of the person it was + * issued to, and cannot be expected to tell the interface which of the two they typed. + * + * @throws \Throwable + */ + public function testSearchesForBothStoredFormsOfWhatWasTyped(): void + { + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('hashCredentialId')->willReturn(self::CREDENTIAL_ID_HASH); + $this->statusListEntryRepositoryMock->expects($this->once()) + ->method('findAllocatedPaginated') + ->with(1, self::CREDENTIAL_ID_HASH, self::SUBJECT_REF) + ->willReturn(['items' => [], 'total' => 0, 'numPages' => 1, 'currentPage' => 1]); + + $this->sut()->index(new Request(['q' => ' someone@example.org '])); + + $this->assertSame('someone@example.org', $this->templateData['query']); + } + + /** + * @throws \Throwable + */ + public function testDoesNotSearchWithoutATerm(): void + { + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->expects($this->never())->method('hashCredentialId'); + $this->subjectRefHasherMock->expects($this->never())->method('hash'); + $this->statusListEntryRepositoryMock->expects($this->once()) + ->method('findAllocatedPaginated') + ->with(1, null, null) + ->willReturn(['items' => [], 'total' => 0, 'numPages' => 1, 'currentPage' => 1]); + + $this->sut()->index(new Request()); + } + + /** + * How many bits an entry occupies is fixed when its list is created, so offering a status the list + * can never carry would put a button on the page whose only possible outcome is an error. + * + * @throws \Throwable + */ + public function testOffersOnlyStatusesTheListCanCarry(): void + { + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('findAllocatedPaginated')->willReturn( + ['items' => [$this->entry()], 'total' => 1, 'numPages' => 1, 'currentPage' => 1], + ); + $this->statusListRepositoryMock->method('findById')->willReturn( + $this->statusListRecord(StatusTypeEnum::Valid->value, StatusTypeEnum::Invalid->value), + ); + + $this->sut()->index(new Request()); + + $this->assertSame( + [StatusTypeEnum::Valid->value, StatusTypeEnum::Invalid->value], + array_keys($this->templateData['allowedStatuses'][self::LIST_ID]), + ); + } + + /** + * Between showing an administrator no way to withdraw a credential and showing one which reports + * why it did not work, the second is the one which can be acted on. + * + * @throws \Throwable + */ + public function testOffersEveryStatusWhenTheListCannotBeRead(): void + { + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('findAllocatedPaginated')->willReturn( + ['items' => [$this->entry()], 'total' => 1, 'numPages' => 1, 'currentPage' => 1], + ); + $this->statusListRepositoryMock->method('findById')->willReturn(null); + + $this->sut()->index(new Request()); + + $this->assertCount( + count(StatusTypeEnum::cases()), + $this->templateData['allowedStatuses'][self::LIST_ID], + ); + } + + /** + * @throws \Throwable + */ + public function testLooksUpEachListOnlyOnce(): void + { + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('findAllocatedPaginated')->willReturn( + [ + 'items' => [$this->entry(), $this->entry(), $this->entry('another-list')], + 'total' => 3, + 'numPages' => 1, + 'currentPage' => 1, + ], + ); + $this->statusListRepositoryMock->expects($this->exactly(2)) + ->method('findById') + ->willReturn($this->statusListRecord(0, 1)); + + $this->sut()->index(new Request()); + } + + /** + * @throws \Throwable + */ + public function testAppliesTheRequestedStatus(): void + { + $this->credentialStatusServiceMock->expects($this->once()) + ->method('setStatus') + ->with(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Admin, 'admin') + ->willReturn(new CredentialStatusChange(self::LIST_ID, 7, 0, StatusTypeEnum::Invalid, true)); + + $this->assertInstanceOf(RedirectResponse::class, $this->sut()->change(new Request())); + $this->assertSame(['The credential status has been changed.'], $this->messages); + } + + /** + * Repeating a request is how somebody who never saw an answer recovers, so it is reported as + * having already been done rather than as having been done again. + * + * @throws \Throwable + */ + public function testReportsAStatusWhichWasAlreadyHeld(): void + { + $this->credentialStatusServiceMock->method('setStatus')->willReturn( + new CredentialStatusChange( + self::LIST_ID, + 7, + StatusTypeEnum::Invalid->value, + StatusTypeEnum::Invalid, + false, + ), + ); + + $this->sut()->change(new Request()); + + $this->assertSame( + ['The credential already had that status, so nothing was changed.'], + $this->messages, + ); + } + + /** + * @throws \Throwable + */ + public function testReportsNothingWhichCanBeActedOn(): void + { + $this->credentialStatusServiceMock->method('setStatus')->willReturn(null); + + $this->sut()->change(new Request()); + + $this->assertStringContainsString('No credential', $this->messages[0]); + } + + /** + * @throws \Throwable + */ + public function testReportsAStatusTheListCannotCarry(): void + { + $this->credentialStatusServiceMock->method('setStatus') + ->willThrowException(new UnsupportedStatusException('Two bits are needed.')); + + $this->sut()->change(new Request()); + + $this->assertStringContainsString('without room for that status', $this->messages[0]); + } + + /** + * @throws \Throwable + */ + public function testReportsAChangeLostToAConcurrentOne(): void + { + $this->credentialStatusServiceMock->method('setStatus') + ->willThrowException(new StatusConflictException('Somebody else got there first.')); + + $this->sut()->change(new Request()); + + $this->assertStringContainsString('changed by something else at the same time', $this->messages[0]); + } + + /** + * @throws \Throwable + */ + public function testReportsAFailureWithoutRepeatingItsDetail(): void + { + $this->credentialStatusServiceMock->method('setStatus') + ->willThrowException(new StatusListException('The database is on fire.')); + + $this->sut()->change(new Request()); + + $this->assertSame(['The credential status could not be changed.'], $this->messages); + $this->assertStringNotContainsString('on fire', $this->messages[0]); + } + + /** + * A stale or missing CSRF token lands here, and nothing is asked of the service. + * + * @throws \Throwable + */ + public function testChangesNothingWhenTheFormIsNotAccepted(): void + { + $this->formMock = $this->createMock(CredentialStatusForm::class); + $this->formMock->method('isSuccess')->willReturn(false); + $this->formMock->method('getErrors')->willReturn(['Security token has expired.']); + $this->formFactoryMock = $this->createMock(FormFactory::class); + $this->formFactoryMock->method('build')->willReturn($this->formMock); + + $this->credentialStatusServiceMock->expects($this->never())->method('setStatus'); + + $this->sut()->change(new Request()); + + $this->assertSame(['The credential status change was not accepted. Please try again.'], $this->messages); + } + + /** + * @throws \Throwable + */ + public function testChangesNothingWithoutACredentialIdentifier(): void + { + $this->formMock = $this->createMock(CredentialStatusForm::class); + $this->formMock->method('isSuccess')->willReturn(true); + $this->formMock->method('getValues')->willReturn([ + CredentialStatusForm::FIELD_CREDENTIAL_ID => ' ', + CredentialStatusForm::FIELD_STATUS => StatusTypeEnum::Invalid->value, + ]); + $this->formFactoryMock = $this->createMock(FormFactory::class); + $this->formFactoryMock->method('build')->willReturn($this->formMock); + + $this->credentialStatusServiceMock->expects($this->never())->method('setStatus'); + + $this->sut()->change(new Request()); + + $this->assertSame(['The credential status change was not accepted. Please try again.'], $this->messages); + } + + /** + * @throws \Throwable + */ + public function testChangesNothingForAStatusWhichIsNotOne(): void + { + $this->formMock = $this->createMock(CredentialStatusForm::class); + $this->formMock->method('isSuccess')->willReturn(true); + $this->formMock->method('getValues')->willReturn([ + CredentialStatusForm::FIELD_CREDENTIAL_ID => self::CREDENTIAL_ID, + CredentialStatusForm::FIELD_STATUS => 99, + ]); + $this->formFactoryMock = $this->createMock(FormFactory::class); + $this->formFactoryMock->method('build')->willReturn($this->formMock); + + $this->credentialStatusServiceMock->expects($this->never())->method('setStatus'); + + $this->sut()->change(new Request()); + + $this->assertSame(['The credential status change was not accepted. Please try again.'], $this->messages); + } + + /** + * SimpleSAMLphp's administrator authentication is a shared password in most deployments, which + * names nobody. Where it has been pointed at a real authentication source, the identifier it + * releases is a genuine answer to who did this. + * + * @throws \Throwable + */ + public function testRecordsTheAdministratorWhenTheLoginKnowsWhoTheyAre(): void + { + $this->authSimpleMock = $this->createMock(Simple::class); + $this->authSimpleMock->method('isAuthenticated')->willReturn(true); + $this->authSimpleMock->method('getAttributes')->willReturn(['uid' => ['jane.doe']]); + $this->authSimpleFactoryMock = $this->createMock(AuthSimpleFactory::class); + $this->authSimpleFactoryMock->method('forAuthSourceId')->willReturn($this->authSimpleMock); + + $this->credentialStatusServiceMock->expects($this->once()) + ->method('setStatus') + ->with(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Admin, 'jane.doe') + ->willReturn(new CredentialStatusChange(self::LIST_ID, 7, 0, StatusTypeEnum::Invalid, true)); + + $this->sut()->change(new Request()); + } + + /** + * A credential must not stay in a wallet because the administrator behind the request could not + * be named. + * + * @throws \Throwable + */ + public function testStillChangesTheStatusWhenTheAdministratorCannotBeIdentified(): void + { + $this->authSimpleFactoryMock = $this->createMock(AuthSimpleFactory::class); + $this->authSimpleFactoryMock->method('forAuthSourceId') + ->willThrowException(new \RuntimeException('No such authentication source.')); + + $this->credentialStatusServiceMock->expects($this->once()) + ->method('setStatus') + ->with(self::CREDENTIAL_ID, StatusTypeEnum::Invalid, StatusChangeSourceEnum::Admin, 'admin') + ->willReturn(new CredentialStatusChange(self::LIST_ID, 7, 0, StatusTypeEnum::Invalid, true)); + + $this->sut()->change(new Request()); + } + + /** + * @throws \Throwable + */ + public function testReturnsToThePageTheChangeWasMadeFrom(): void + { + $this->credentialStatusServiceMock->method('setStatus')->willReturn( + new CredentialStatusChange(self::LIST_ID, 7, 0, StatusTypeEnum::Invalid, true), + ); + + $this->routesMock->expects($this->once()) + ->method('newRedirectResponseToModuleUrl') + ->with('admin/credential-status', ['q' => 'someone@example.org', 'page' => 3]) + ->willReturn(new RedirectResponse('https://op.example.org/back')); + + $this->sut()->change(new Request([], ['q' => 'someone@example.org', 'page' => '3'])); + } + + /** + * @throws \Throwable + */ + public function testDoesNotCarryAnEmptySearchOrTheFirstPageBack(): void + { + $this->credentialStatusServiceMock->method('setStatus')->willReturn( + new CredentialStatusChange(self::LIST_ID, 7, 0, StatusTypeEnum::Invalid, true), + ); + + $this->routesMock->expects($this->once()) + ->method('newRedirectResponseToModuleUrl') + ->with('admin/credential-status', []) + ->willReturn(new RedirectResponse('https://op.example.org/back')); + + $this->sut()->change(new Request([], ['q' => '', 'page' => '1'])); + } +} diff --git a/tests/unit/src/Forms/CredentialStatusFormTest.php b/tests/unit/src/Forms/CredentialStatusFormTest.php new file mode 100644 index 00000000..4e90c920 --- /dev/null +++ b/tests/unit/src/Forms/CredentialStatusFormTest.php @@ -0,0 +1,127 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->csrfProtectionMock = $this->createMock(CsrfProtection::class); + $this->sspBridgeMock = $this->createMock(SspBridge::class); + $this->helpers = new Helpers(); + } + + /** + * @throws \Exception + */ + protected function sut(): CredentialStatusForm + { + return new CredentialStatusForm( + $this->moduleConfigMock, + $this->csrfProtectionMock, + $this->sspBridgeMock, + $this->helpers, + ); + } + + /** + * @throws \Exception + */ + public function testCarriesTheFieldsTheListingSubmits(): void + { + $form = $this->sut(); + + $this->assertNotNull($form->getComponent(CredentialStatusForm::FIELD_CREDENTIAL_ID)); + $this->assertNotNull($form->getComponent(CredentialStatusForm::FIELD_STATUS)); + } + + /** + * CSRF protection is why this class exists at all: the markup is written out in the template, and + * validating what comes back is what is left. + * + * @throws \Exception + */ + public function testAttachesTheCsrfProtector(): void + { + $this->assertSame($this->csrfProtectionMock, $this->sut()->getComponent(Form::ProtectorId)); + } + + /** + * @throws \Exception + */ + public function testIsSubmittedByPost(): void + { + $this->assertSame(Form::Post, $this->sut()->getMethod()); + } + + /** + * The listing shows fewer options per row, since a list can be unable to carry a status. This + * decides only whether what came back is a status at all. + * + * @throws \Exception + */ + public function testAcceptsEveryStatus(): void + { + $status = $this->sut()->getComponent(CredentialStatusForm::FIELD_STATUS); + + $this->assertInstanceOf(SelectBox::class, $status); + $this->assertSame( + [StatusTypeEnum::Valid->value, StatusTypeEnum::Invalid->value, StatusTypeEnum::Suspended->value], + array_keys($status->getItems()), + ); + } + + /** + * The submitted value has to come back as the Status Type's own backing value, since that is what + * the controller turns back into a Status Type. + * + * @throws \Exception + */ + public function testKeepsStatusValuesAsIntegers(): void + { + $form = $this->sut(); + $status = $form->getComponent(CredentialStatusForm::FIELD_STATUS); + $this->assertInstanceOf(SelectBox::class, $status); + + $status->setValue(StatusTypeEnum::Suspended->value); + + $this->assertSame(StatusTypeEnum::Suspended->value, $status->getValue()); + } + + /** + * "Invalid" is what the specification calls a status which every other document in this space + * calls revoked, and an administrator should not have to know that to withdraw a credential. + */ + public function testLabelsTheInvalidStatusAsRevoked(): void + { + $this->assertSame('Revoked', CredentialStatusForm::labelFor(StatusTypeEnum::Invalid)); + $this->assertSame('Valid', CredentialStatusForm::labelFor(StatusTypeEnum::Valid)); + $this->assertSame('Suspended', CredentialStatusForm::labelFor(StatusTypeEnum::Suspended)); + } + + public function testOffersOneOptionPerStatus(): void + { + $this->assertCount(count(StatusTypeEnum::cases()), CredentialStatusForm::statusOptions()); + } +} diff --git a/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php b/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php new file mode 100644 index 00000000..0e5a477a --- /dev/null +++ b/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php @@ -0,0 +1,379 @@ + 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ], + '', + 'simplesaml', + ); + + (new DatabaseMigration())->migrate(); + } + + /** + * @throws \Exception + */ + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + // Resolved at call time rather than at stub time, so a test can change the page size after the + // mock has been set up. + $this->moduleConfigMock->method('config')->willReturnCallback( + fn(): Configuration => Configuration::loadFromArray( + [ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE => $this->itemsPerPage], + ), + ); + $this->itemsPerPage = 20; + $this->helpers = new Helpers(); + + $this->repository = new StatusListEntryRepository( + $this->moduleConfigMock, + Database::getInstance(), + null, + $this->helpers, + ); + + $this->statusListRepository = new StatusListRepository( + $this->moduleConfigMock, + Database::getInstance(), + null, + $this->helpers, + ); + + Database::getInstance()->write(sprintf('DELETE FROM %s', $this->repository->getTableName())); + Database::getInstance()->write(sprintf('DELETE FROM %s', $this->statusListRepository->getTableName())); + } + + protected function setItemsPerPage(int $itemsPerPage): void + { + $this->itemsPerPage = $itemsPerPage; + } + + /** + * @throws \Exception + */ + protected function createList(string $id = self::LIST_ID, int $generation = 1): void + { + $this->statusListRepository->create( + $id, + 'https://op.example.org/statuslist/' . $id, + 'default', + 'a-policy-fingerprint', + $generation, + 1, + self::CAPACITY, + implode(',', [StatusTypeEnum::Valid->value, StatusTypeEnum::Invalid->value]), + 43200, + 604800, + 3600, + 'a-signing-key-id', + StatusListKeyProfileEnum::DidJwk, + ); + + $this->repository->seed($id, self::CAPACITY); + // Allocation only claims an index while the list accepts them, and a list is created closed. + $this->statusListRepository->activate($id); + } + + /** + * @throws \Exception + */ + protected function allocate( + int $idx, + string $credentialId, + ?string $subjectRef = null, + ?DateTimeImmutable $issuedAt = null, + ?DateTimeImmutable $expiresAt = null, + string $statusListId = self::LIST_ID, + ): void { + $this->repository->allocate( + $statusListId, + $idx, + $credentialId, + $this->repository->hashCredentialId($credentialId), + self::CONFIGURATION_ID, + $subjectRef, + $expiresAt, + $issuedAt ?? new DateTimeImmutable('2026-08-07 12:00:00'), + ); + } + + /** + * @param \SimpleSAML\Module\oidc\StatusList\Values\StatusListEntryRecord[] $entries + * @return string[] + */ + protected function credentialIdsOf(array $entries): array + { + return array_map( + static fn(StatusListEntryRecord $entry): string => (string)$entry->getCredentialId(), + $entries, + ); + } + + /** + * Every index exists as a row from the moment a list is created, so a listing which did not filter + * on allocation would show tens of thousands of things nobody was ever issued. + * + * @throws \Exception + */ + public function testListsOnlyAllocatedEntries(): void + { + $this->createList(); + $this->allocate(0, 'urn:vc:one'); + + $page = $this->repository->findAllocatedPaginated(); + + $this->assertSame(1, $page['total']); + $this->assertSame(['urn:vc:one'], $this->credentialIdsOf($page['items'])); + } + + /** + * @throws \Exception + */ + public function testListsNewestFirst(): void + { + $this->createList(); + $this->allocate(0, 'urn:vc:older', null, new DateTimeImmutable('2026-08-01 09:00:00')); + $this->allocate(1, 'urn:vc:newer', null, new DateTimeImmutable('2026-08-06 09:00:00')); + + $this->assertSame( + ['urn:vc:newer', 'urn:vc:older'], + $this->credentialIdsOf($this->repository->findAllocatedPaginated()['items']), + ); + } + + /** + * A batch issuance stamps the same moment on every credential in it. An order which left those + * rows free to come back in any sequence would show one of them twice and another not at all as an + * administrator pages through. + * + * @throws \Exception + */ + public function testPagesThroughEntriesIssuedAtTheSameMomentWithoutRepeatingOrLosingAny(): void + { + $this->setItemsPerPage(2); + $this->createList(); + + $issuedAt = new DateTimeImmutable('2026-08-07 12:00:00'); + + for ($idx = 0; $idx < 5; $idx++) { + $this->allocate($idx, 'urn:vc:' . $idx, null, $issuedAt); + } + + $seen = array_merge( + $this->credentialIdsOf($this->repository->findAllocatedPaginated(1)['items']), + $this->credentialIdsOf($this->repository->findAllocatedPaginated(2)['items']), + $this->credentialIdsOf($this->repository->findAllocatedPaginated(3)['items']), + ); + + sort($seen); + + $this->assertSame(['urn:vc:0', 'urn:vc:1', 'urn:vc:2', 'urn:vc:3', 'urn:vc:4'], $seen); + } + + /** + * @throws \Exception + */ + public function testReportsThePageCount(): void + { + $this->setItemsPerPage(2); + $this->createList(); + + for ($idx = 0; $idx < 5; $idx++) { + $this->allocate($idx, 'urn:vc:' . $idx); + } + + $page = $this->repository->findAllocatedPaginated(); + + $this->assertSame(5, $page['total']); + $this->assertSame(3, $page['numPages']); + $this->assertSame(1, $page['currentPage']); + $this->assertCount(2, $page['items']); + } + + /** + * A page number out of range is a bookmark or a typo, not something to answer with an empty table. + * + * @throws \Exception + */ + public function testClampsThePageToWhatExists(): void + { + $this->setItemsPerPage(2); + $this->createList(); + $this->allocate(0, 'urn:vc:one'); + + $this->assertSame(1, $this->repository->findAllocatedPaginated(99)['currentPage']); + $this->assertSame(1, $this->repository->findAllocatedPaginated(-5)['currentPage']); + } + + /** + * @throws \Exception + */ + public function testFindsByCredentialIdentifier(): void + { + $this->createList(); + $this->allocate(0, 'urn:vc:one'); + $this->allocate(1, 'urn:vc:two'); + + $page = $this->repository->findAllocatedPaginated( + 1, + $this->repository->hashCredentialId('urn:vc:two'), + null, + ); + + $this->assertSame(1, $page['total']); + $this->assertSame(['urn:vc:two'], $this->credentialIdsOf($page['items'])); + } + + /** + * Every credential issued to one person, which is what a lost device or a leaver comes down to. + * + * @throws \Exception + */ + public function testFindsEveryCredentialOfOneSubject(): void + { + $this->createList(); + $this->allocate(0, 'urn:vc:one', 'a-subject-ref'); + $this->allocate(1, 'urn:vc:two', 'a-subject-ref'); + $this->allocate(2, 'urn:vc:three', 'another-subject-ref'); + + $page = $this->repository->findAllocatedPaginated(1, null, 'a-subject-ref'); + + $found = $this->credentialIdsOf($page['items']); + sort($found); + + $this->assertSame(2, $page['total']); + $this->assertSame(['urn:vc:one', 'urn:vc:two'], $found); + } + + /** + * One box, both stored forms of what was typed, either matching being a hit. + * + * @throws \Exception + */ + public function testMatchesEitherStoredFormOfTheSearchTerm(): void + { + $this->createList(); + $this->allocate(0, 'urn:vc:one', 'a-subject-ref'); + + $this->assertSame( + 1, + $this->repository->findAllocatedPaginated( + 1, + $this->repository->hashCredentialId('urn:vc:one'), + 'no-such-subject-ref', + )['total'], + ); + + $this->assertSame( + 1, + $this->repository->findAllocatedPaginated( + 1, + $this->repository->hashCredentialId('urn:vc:nothing'), + 'a-subject-ref', + )['total'], + ); + } + + /** + * @throws \Exception + */ + public function testFindsNothingWhenNeitherFormMatches(): void + { + $this->createList(); + $this->allocate(0, 'urn:vc:one', 'a-subject-ref'); + + $page = $this->repository->findAllocatedPaginated( + 1, + $this->repository->hashCredentialId('urn:vc:nothing'), + 'no-such-subject-ref', + ); + + $this->assertSame(0, $page['total']); + $this->assertSame([], $page['items']); + } + + /** + * An unallocated row has no expiry either, and counting those would report every list a deployment + * has as permanent from the moment it was created. + * + * @throws \Exception + */ + public function testCountsOnlyListsHoldingAnAllocatedCredentialWhichNeverExpires(): void + { + $this->createList(); + $this->createList(self::OTHER_LIST_ID, 2); + + $this->assertSame(0, $this->repository->countNeverRetiringLists()); + + $this->allocate(0, 'urn:vc:expiring', null, null, new DateTimeImmutable('2027-08-07 12:00:00')); + + $this->assertSame(0, $this->repository->countNeverRetiringLists()); + + $this->allocate(1, 'urn:vc:permanent'); + + $this->assertSame(1, $this->repository->countNeverRetiringLists()); + } + + /** + * @throws \Exception + */ + public function testCountsEachListOnceHoweverManyPermanentCredentialsItHolds(): void + { + $this->createList(); + $this->createList(self::OTHER_LIST_ID, 2); + + $this->allocate(0, 'urn:vc:one'); + $this->allocate(1, 'urn:vc:two'); + $this->allocate(0, 'urn:vc:three', null, null, null, self::OTHER_LIST_ID); + + $this->assertSame(2, $this->repository->countNeverRetiringLists()); + } +} From 30d45d1d871eb2413ef64ce916c252435f8fd756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 8 Aug 2026 10:43:30 +0200 Subject: [PATCH 7/9] Retire spent Status Lists and forget expired credential linkage --- config/module_oidc.php.dist | 56 ++ docs/3-oidc-configuration.md | 207 ++++++ docs/8-api.md | 145 +++++ hooks/hook_cron.php | 40 ++ locales/en/LC_MESSAGES/oidc.po | 47 ++ locales/es/LC_MESSAGES/oidc.po | 47 ++ locales/fr/LC_MESSAGES/oidc.po | 47 ++ locales/hr/LC_MESSAGES/oidc.po | 47 ++ locales/it/LC_MESSAGES/oidc.po | 47 ++ locales/nl/LC_MESSAGES/oidc.po | 47 ++ routing/services/services.yml | 2 + .../ConfigOverview/VciOverviewBuilder.php | 49 ++ src/ModuleConfig.php | 135 ++++ src/Repositories/StatusAuditRepository.php | 83 ++- .../StatusListEntryRepository.php | 162 ++++- src/Repositories/StatusListRepository.php | 237 +++++++ src/StatusList/StatusListLifecycle.php | 439 +++++++++++++ .../Values/StatusListLifecycleReport.php | 80 +++ .../src/StatusList/StatusListStorageTest.php | 319 +++++++++ tests/unit/src/ModuleConfigTest.php | 189 ++++++ .../StatusAuditRepositoryTest.php | 91 +++ .../StatusListEntryRepositoryTest.php | 204 ++++++ .../Repositories/StatusListRepositoryTest.php | 608 ++++++++++++++++++ .../StatusList/StatusListLifecycleTest.php | 412 ++++++++++++ .../Values/StatusListLifecycleReportTest.php | 51 ++ 25 files changed, 3784 insertions(+), 7 deletions(-) create mode 100644 src/StatusList/StatusListLifecycle.php create mode 100644 src/StatusList/Values/StatusListLifecycleReport.php create mode 100644 tests/unit/src/Repositories/StatusListRepositoryTest.php create mode 100644 tests/unit/src/StatusList/StatusListLifecycleTest.php create mode 100644 tests/unit/src/StatusList/Values/StatusListLifecycleReportTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 41e830e9..2407655a 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1687,6 +1687,62 @@ $config = [ */ // ModuleConfig::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE => 60, + /** + * (optional) How long a Status List is left alone before it may be + * retired. Defaults to P30D (30 days). + * + * This waiting period is applied twice, to the two things which have to + * have settled: a list is not looked at until this long after it stopped + * accepting new credentials, and it is not retired until this long after + * the last credential in it expired. + * + * Retiring a list makes its URI answer 404, and that URI is written into + * every credential which was issued from it. By then those credentials + * have all expired, so nothing that should verify stops verifying, but a + * Relying Party working from a cached response, or a wallet showing a + * credential it has not noticed is expired, would see the fetch fail. The + * wait is what keeps that from happening the moment the last credential + * lapses. Lengthen it if your Relying Parties cache aggressively. + * + * It can not be set below one hour. The first of the two waits has to + * outlast an issuance which was already under way when the list stopped + * accepting credentials, and nothing in this module can serialise those + * two instead: the retiring statement and the allocating one write + * different rows, so neither conflicts with the other. A shorter wait can + * let such an issuance produce a credential naming a list which has since + * been retired, and that credential can never be verified. + * + * The same wait passes again before a retired list's entry rows are + * removed, so that if the above ever did happen there is still a record + * that the credential was issued. + * + * Retirement is run by the module's cron hook, so it only happens if the + * cron tag below is configured and SimpleSAMLphp's cron is running. + */ +// ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE => 'P30D', // 30 days + + /** + * (optional) How long rows in the status audit trail are kept. Not set by + * default, which keeps them indefinitely. + * + * The trail records who asked for which credential's status to change, and + * when. There is no default retention because how long that needs keeping + * follows from your own obligations rather than from anything this module + * can work out. It is a row per status change, not a row per credential, + * so keeping it costs little in storage. + * + * It is not, however, free of personal data. The credential is recorded + * only as a hash of its identifier, but the actor is recorded as it is: an + * API token principal's configured name, or the identifier the `admin` + * authentication source released, which in a deployment where that points + * at a real authentication source names a person. Setting a retention is + * how you put a limit on how long that is kept. + * + * Pruning is run by the module's cron hook, so it only happens if the cron + * tag below is configured and SimpleSAMLphp's cron is running. + */ +// ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION => 'P1Y', // 1 year + /** * Map of authentication sources and user's email attribute names. This * enables you to define a specific attribute name which contains the diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index a3f903eb..7fddf04f 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -15,6 +15,7 @@ It complements the inline comments in `config/module_oidc.php`. - Auth Proc filters (OIDC) - Client registration permissions - OpenID Connect Dynamic Client Registration +- Token Status Lists (credential revocation) - Running multiple OPs on one server ## Caching protocol artifacts @@ -53,6 +54,12 @@ the SimpleSAMLphp admin area: That screen also reports when cleanup can never run, for example when no cron tag is set or when the cron module is not enabled. +If you issue Verifiable Credentials with Token Status Lists, cron does considerably +more than purge expired tokens: it is what deletes the record of who was issued which +credential once that credential expires, and what eventually retires lists nobody can +still be holding. See +[Token Status Lists](#token-status-lists-credential-revocation). + ## Endpoint locations and well-known URLs After deployment, visit the SimpleSAMLphp admin area: @@ -403,6 +410,206 @@ details and defaults): > the endpoint with rate limiting at the web-server level, or require an Initial > Access Token. +## Token Status Lists (credential revocation) + +A Verifiable Credential this module issues is, by default, valid for as long as it says it is and +there is nothing you can do about it afterwards. Token Status Lists +([draft-ietf-oauth-status-list](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/)) are +what make one revocable: each issued credential carries a `status` claim naming a list and an index +inside it, and a Relying Party checks the credential by fetching that list. + +The list is one compressed bit array covering many credentials, published as a signed token. That is +the design's privacy property, and the reason lists are shared rather than per-credential: fetching +the list tells the issuer that *somebody's* credential is being verified, but not whose. + +### Requirements + +- **A SimpleSAMLphp providing `SimpleSAML\Database::readPrimary()`.** Deciding whether a credential is + revoked from a lagging database secondary could publish a revoked credential as valid, so the module + refuses to enable the feature rather than fall back to a replica read. This is available from v2.5.3 + of SimpleSAMLphp. +- **Verifiable Credential Issuance configured**, since there is nothing to give a status to otherwise. +- **The cron module running**, with a cron tag configured for this module (see + [Cron integration](#cron-integration)). Without it, credentials are still issued, still expire on + their own, still get served and can still be revoked — but nothing is ever cleaned up: the record of + who was issued which credential is kept past that credential's expiry, no list is ever retired, and + the audit trail is never pruned. See [Lifecycle and cron](#lifecycle-and-cron) below. + +### Enabling + +```php +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; + +ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED => true, + +ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS => [ + 'default' => [ + 'credential_configurations' => [ + 'UniversityDegreeCredential', + ], + ], + // A pool which can also suspend, so it needs at least 2 bits per entry. + 'suspendable' => [ + 'credential_configurations' => [ + 'EmployeeBadgeCredential', + ], + 'bits' => 2, + 'allowed_statuses' => [ + StatusTypeEnum::Invalid, + StatusTypeEnum::Suspended, + ], + ], +], +``` + +Credentials of a configuration which is in no pool are issued without a `status` claim, and can never +be revoked or suspended. Turning the switch on does not change credentials which have already been +issued — they carry no `status` claim and nothing can add one. + +### Pools + +A pool, not a credential configuration, is the unit which shares a list, and several configurations can +map onto one pool. Splitting configurations into separate pools costs herd privacy, so it is worth +doing only when their policies genuinely differ. A configuration must appear in at most one pool. + +Per-pool settings, all optional except `credential_configurations`: + +| Setting | Default | Notes | +|:----------------------------|:---------------|:----------------------------------------------------------------------------------------| +| `credential_configurations` | — | Required. Each must be declared under `OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED`. | +| `bits` | `1` | Bits per entry, one of 1, 2, 4, 8. **Cannot be changed for lists which already exist.** | +| `capacity` | `131072` | Entries per list; a positive multiple of 8. | +| `allowed_statuses` | `Invalid` | Statuses besides `Valid`, which is always allowed. | +| `ttl` | `PT12H` | How long a Relying Party may cache a fetched list. | +| `token_validity` | `P7D` | Lifetime of a published Status List Token. | +| `refresh_interval` | `PT1H` | How old a published token may get before it is re-signed. | +| `key_profile` | global setting | Overrides `OPTION_VCI_STATUS_LIST_KEY_PROFILE` for this pool. | + +Two of these deserve reading twice. + +**`bits` decides what the pool can ever say.** One bit holds `Valid` and `Invalid` and nothing else, so +a pool left at the default can never suspend a credential — and the number of bits is fixed when a list +is created, so this cannot be corrected later for credentials already issued from it. A pool which may +ever suspend needs at least 2 bits *before* it issues anything. Asking for a status a list cannot carry +is refused, with `422` from the API and a message on the administration screen. Note that `bits` +affects transfer size, never herd size. + +**`ttl` is the revocation latency you are offering.** It is how long a conforming Relying Party may go +on using a cached copy of the list, so with the default a credential revoked now may still be accepted +for up to 12 hours. Lower it if that is too long, bearing in mind that it is also what keeps verifiers +from fetching the list on every presentation. + +### Key profile + +The specification deliberately mandates no key resolution method, so how a Status List Token names the +key it was signed with is a deployment choice: + +- `did_jwk` (default): `kid` is the issuer's `did:jwk:...#0` and `iss` is the same `did:jwk:...`. The + token carries the key with it and verifies without any external lookup. +- `jwks`: `iss` is this module's issuer URL and `kid` is a JWKS key ID, so the key is resolved through + the published JWKS. Use this for Relying Parties which will not accept a `did:jwk` key identifier. + +Each list records the profile it was created under. Changing the setting therefore routes newly issued +credentials to newly created lists, while existing lists keep being served under the profile their +holders already resolved them by — so changing it never invalidates anything already in a wallet. + +### Credential expiry + +Credentials this module issues do not expire unless you say so: + +```php +ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS => [ + 'UniversityDegreeCredential' => 'P1Y', +], +``` + +It has a consequence worth understanding before deciding. **A list holding even one credential which +never expires can never be retired**, because that credential can be presented at any point in the +future and a verifier asked about it has to be able to fetch the list. With expiry off, the module's +Status List storage grows for as long as the deployment runs and never gives anything back. The +administration screen reports how many lists are in that position. + +### Serving the lists + +Lists are published at `/statuslist/{id}`, unauthenticated, and the URI of the list is written into +every credential issued from it. + +This endpoint keeps serving when `OPTION_VCI_STATUS_LIST_ENABLED` is switched off. Turning the switch +off stops new credentials getting an entry allocated; it does not, and must not, strand the credentials +already in wallets as unverifiable. + +`OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE` puts a ceiling on how much one client can pull, and is off +by default. Before setting it, check what address actually reaches PHP: behind a reverse proxy or CDN it +is the proxy's own, identically for every request, so all clients would share one counter and the +endpoint would start refusing the verifiers that credentials depend on. It also needs a protocol cache +configured; without one, nothing is counted and no request is refused. + +### Changing a status + +Two ways, both of which write to the same audit trail: + +- **OIDC > Credential Status** in the SimpleSAMLphp admin area. Lists issued credentials, searches by + credential identifier or by user identifier, and changes the status of one credential at a time. The + search is exact rather than a substring match — the user identifier is stored as a keyed hash, which + nothing can be matched against partially. +- **The credential status API endpoint**, documented in [API](8-api.md#credential-status). This is what + to use from an IdM / HR / helpdesk tool... + +The audit trail records the credential (as a hash of its identifier), the list and index, the status +asked for, who asked, and when. It names an actor when it can: an API token's configured `name`, or the +identifier the `admin` authentication source releases. SimpleSAMLphp's admin login is a shared password +in most deployments and names nobody, in which case the trail says `admin` rather than inventing +someone — but where that source is pointed at a real one, the trail names a person, which is worth +knowing when deciding the retention below. The bearer token itself is never recorded anywhere. + +### Lifecycle and cron + +The module's cron hook does five things for Status Lists, all of them bounded so that no single run has +to finish the job — whatever one run leaves, the next picks up: + +1. **Forgets which credential held which index, once that credential has expired.** The credential ID, + its hash, its configuration ID and the subject reference are deleted together; the index and the + status it ended on stay, because those are what the published list is built from and what stops the + index being handed out to a second credential. This is why credential expiry is also a privacy + setting: with no expiry, the record of who was issued what is kept indefinitely. +2. **Deactivates lists the current configuration would no longer allocate into**, which happens when a + pool's settings change or the signing key is rotated. This changes nothing observable — those lists + were already unreachable — but nothing else would ever start their clock. +3. **Retires lists nothing can still be holding**, meaning every credential issued from them expired, + and the grace period elapsed both since the list stopped accepting allocations and since that last + expiry. A retired list answers `404` and gives back its published token. +4. **Removes the entry rows behind retired lists** once the grace has passed again since retirement. + This is where retirement actually recovers storage: a list at the default capacity has 131072 of + them. +5. **Prunes the audit trail** to the configured retention. + +```php +// How long to wait before retiring a list. Default P30D. +ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE => 'P30D', + +// How long to keep audit rows. Not set by default, which keeps them indefinitely. +ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION => 'P1Y', +``` + +The retirement grace exists because retiring a list makes a URI which is written into real credentials +answer `404`. Those credentials have all expired by then, so nothing which should verify stops +verifying — but a Relying Party working from a cached response, or a wallet showing a credential it has +not noticed is expired, would see the fetch fail rather than get an answer. Lengthen it if your +verifiers cache aggressively. + +**It cannot be set below one hour.** The wait counted from deactivation has a second job: outlasting an +issuance that was already under way when the list stopped accepting credentials. Nothing here can +serialise those two instead — the statement that retires a list and the one that claims an index write +different rows, so neither conflicts with the other — and a wait shorter than a request can take does +not outlast one. A credential issued into a list that has since been retired can never be verified. + +The same wait passes again before a retired list's entry rows are removed, so that if that ever did +happen there is still a record the credential was issued. + +Each step reports what it got through in the cron summary, and reports separately if it failed. A step +which keeps failing is worth acting on: one of them is what deletes personal data on time. + ## Running multiple OPs on one server A single module instance is designed to serve exactly one OpenID Provider diff --git a/docs/8-api.md b/docs/8-api.md index 7444dc31..986cfc31 100644 --- a/docs/8-api.md +++ b/docs/8-api.md @@ -32,9 +32,35 @@ Scopes determine which endpoints are accessible by the API access token. The fol * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All`: Access to all endpoints. * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll`: Access to all VCI-related endpoints. * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialOffer`: Access to credential offer endpoint. +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialStatus`: Access to the credential status endpoint. * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2All`: Access to all OAuth2-related endpoints. * `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2TokenIntrospection`: Access to the OAuth2 token introspection endpoint. +### Naming a token + +A token may instead be configured as an array with a `name` and a `scopes` key: + +```php +use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; +use SimpleSAML\Module\oidc\ModuleConfig; + +ModuleConfig::OPTION_API_TOKENS => [ + 'strong-random-token-string' => [ + 'name' => 'HR system', + 'scopes' => [ + ApiScopesEnum::VciCredentialStatus, + ], + ], +], +``` + +The name is what gets recorded in the status change audit trail when this token revokes or suspends a +credential, so the trail says which system asked rather than only that something did. Without a name, +an audit row records no actor at all — the token itself is never written anywhere, since that would +put a bearer secret in the database. + +Both shapes work, and a token configured as a plain list of scopes keeps working unchanged. + ## API Endpoints Note that all endpoints will have a path prefix based on the SimpleSAMLphp base path and `oidc` module path. @@ -146,6 +172,125 @@ Response: } ``` +### Credential Status + +Withdraws, suspends or reinstates a Verifiable Credential which has already been issued, by moving its +Token Status List entry to a new status. See +[Token Status Lists](3-oidc-configuration.md#token-status-lists-credential-revocation) for what has to +be configured before a credential has an entry to move. + +Enable it in `config/module_oidc.php`: + +```php +use SimpleSAML\Module\oidc\ModuleConfig; + +ModuleConfig::OPTION_API_VCI_CREDENTIAL_STATUS_ENDPOINT_ENABLED => true, +``` + +#### Path + +`/api/vci/credential-status` + +#### Method + +`POST` + +#### Authorization + +`Bearer Token`, and only from the `Authorization` header. + +This endpoint deliberately does not accept the two other ways the rest of this API can be authorized. A +token passed as a request parameter would end up in web server access logs and browser history, which +for a token that can revoke credentials is worse than for one that reads them. An administrator's +SimpleSAMLphp session is not accepted either, because a request authorized by a session cookie can be +made by any page the administrator happens to be visiting. Use the administration screens for +session-authenticated changes; they carry their own protection against that. + +#### Request + +The request is sent as a JSON object in the body with the following parameters: + +* __credential_id__ (string, mandatory): The credential identifier, being the `jti` (or `id`) of the +issued credential. +* __status__ (string, mandatory): The status to set. Matched case insensitively. Allowed values are: + * `valid`: the credential is in force. Use this to reinstate a suspended one. + * `invalid`: the credential is revoked. This is permanent in practice; a wallet holding it should + stop presenting it. + * `suspended`: the credential is temporarily out of force and can be reinstated. + +#### Response + +The response is a JSON object with the following fields: + +* __status__ (string): The status the credential now holds. +* __changed__ (boolean): Whether this request is what changed it. `false` means the credential already +held that status, so a caller retrying a request it never saw the answer to can tell which happened. + +Errors: + +* `400 invalid_request`: the body could not be read, the credential identifier was missing, or the +status was not one of the three. +* `401 unauthorized`: no bearer token, or one which is not configured. +* `403 insufficient_scope`: the token is configured but has none of the scopes this endpoint accepts. +* `404 not_found`: no credential with that identifier can have its status changed. A credential which +was never issued here, one issued without a `status` claim, and one which has expired are all answered +the same way, so that the endpoint can not be used to find out which identifiers exist. +* `409 conflict`: another change landed at the same moment and the credential ended up holding +something other than what was asked for. Read the current status and decide again. +* `422 unsupported_status`: the Status List this credential belongs to was created without room for +that status. Bits per entry are fixed when a list is created, so this will not succeed on retry — a +pool which may suspend has to be configured with at least 2 bits before its credentials are issued. + +#### Sample 1 + +Revoke a credential. + +Request: + +```shell +curl --location 'https://idp.example.org/ssp/module.php/oidc/api/vci/credential-status' \ +--header 'Content-Type: application/json' \ +--header 'Authorization: Bearer ***' \ +--data-raw '{ + "credential_id": "https://idp.example.org/vc/mBS4Zt9wDRe-8sYcJUEBiZ4bGDsYY3rMHOB2Xdw4t1c", + "status": "invalid" +}' +``` + +Response: + +```json +{ + "status": "invalid", + "changed": true +} +``` + +#### Sample 2 + +Reinstate a suspended credential which somebody else has already reinstated. + +Request: + +```shell +curl --location 'https://idp.example.org/ssp/module.php/oidc/api/vci/credential-status' \ +--header 'Content-Type: application/json' \ +--header 'Authorization: Bearer ***' \ +--data-raw '{ + "credential_id": "https://idp.example.org/vc/mBS4Zt9wDRe-8sYcJUEBiZ4bGDsYY3rMHOB2Xdw4t1c", + "status": "valid" +}' +``` + +Response: + +```json +{ + "status": "valid", + "changed": false +} +``` + ### Token Introspection Enables token introspection for OAuth2 access tokens and refresh tokens as per diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php index 72b29885..b63a3bb4 100644 --- a/hooks/hook_cron.php +++ b/hooks/hook_cron.php @@ -19,6 +19,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\ExpiredEntriesCleaner; +use SimpleSAML\Module\oidc\StatusList\StatusListLifecycle; use SimpleSAML\Module\oidc\StatusList\StatusListReconciler; /** @@ -81,4 +82,43 @@ function oidc_hook_cron(array &$croninfo): void Logger::warning($message); $croninfo['summary'][] = $message; } + + // Again on its own, for the same reason. This one is also the only place which notices that a + // credential has expired, which is a privacy undertaking rather than housekeeping, so it must not be + // skipped because something before it went wrong. + // Gathered here and appended in one go below, since this step has more than one thing to say. + $lifecycleSummary = []; + + try { + $kernel = new Kernel(ModuleConfig::MODULE_NAME); + $kernel->boot(); + /** @var \SimpleSAML\Module\oidc\StatusList\StatusListLifecycle $lifecycle */ + $lifecycle = $kernel->getContainer()->get(StatusListLifecycle::class); + $report = $lifecycle->run(); + + if ($report->hasChanges()) { + $lifecycleSummary[] = sprintf( + 'Module `oidc` Status List lifecycle. Forgot which credential held which index for %d ' . + 'expired credential(s), deactivated %d superseded list(s), retired %d list(s), removed ' . + '%d entry row(s) belonging to retired list(s), pruned %d audit row(s).', + $report->getClearedLinkages(), + $report->getDeactivatedStatusLists(), + $report->getRetiredStatusLists(), + $report->getPurgedEntries(), + $report->getPrunedAuditRows(), + ); + } + + // Reported as well as logged. A step which keeps failing is invisible in a log nobody reads, + // and one of them stops personal data being deleted on time. + foreach ($report->getFailures() as $failure) { + $lifecycleSummary[] = 'Module `oidc` Status List lifecycle. ' . $failure; + } + } catch (Throwable $e) { + $message = 'Module `oidc` Status List lifecycle cron script failed: ' . $e->getMessage(); + Logger::warning($message); + $lifecycleSummary[] = $message; + } + + $croninfo['summary'] = array_merge($croninfo['summary'], $lifecycleSummary); } diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index 0fe86add..d2bb0f1e 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -1747,3 +1747,50 @@ msgstr "" msgid "The credential already had that status, so nothing was changed." msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired. Retiring makes a URI written into real " +"credentials answer 404, so this is the margin for anything still " +"working from a cached response. Retirement runs from the cron hook." +msgstr "" + +msgid "Kept indefinitely" +msgstr "" + +msgid "" +"Rows recording who asked for which credential status change are " +"pruned once they reach this age. Pruning runs from the cron hook." +msgstr "" + +msgid "Status Audit Retention" +msgstr "" + +msgid "Status List Retirement Grace" +msgstr "" + +msgid "" +"The record of who asked for which credential status change is never " +"pruned. It is a row per status change rather than per credential, " +"and names no person, so this is a retention decision rather than a " +"storage one." +msgstr "" + +msgid "" +"The record of who asked for which credential status change is kept " +"for good. Credentials appear in it only as a hash, but the actor is " +"recorded as given, which where the admin authentication source " +"releases an identifier is a person." +msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired, and once more before its entries are " +"removed. Retiring makes a URI written into real credentials answer " +"404, so this is the margin for anything still working from a cached " +"response, and for an issuance which was under way when the list " +"closed. Runs from the cron hook." +msgstr "" + diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index 02d6ad84..aded41e5 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -1747,3 +1747,50 @@ msgstr "" msgid "The credential already had that status, so nothing was changed." msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired. Retiring makes a URI written into real " +"credentials answer 404, so this is the margin for anything still " +"working from a cached response. Retirement runs from the cron hook." +msgstr "" + +msgid "Kept indefinitely" +msgstr "" + +msgid "" +"Rows recording who asked for which credential status change are " +"pruned once they reach this age. Pruning runs from the cron hook." +msgstr "" + +msgid "Status Audit Retention" +msgstr "" + +msgid "Status List Retirement Grace" +msgstr "" + +msgid "" +"The record of who asked for which credential status change is never " +"pruned. It is a row per status change rather than per credential, " +"and names no person, so this is a retention decision rather than a " +"storage one." +msgstr "" + +msgid "" +"The record of who asked for which credential status change is kept " +"for good. Credentials appear in it only as a hash, but the actor is " +"recorded as given, which where the admin authentication source " +"releases an identifier is a person." +msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired, and once more before its entries are " +"removed. Retiring makes a URI written into real credentials answer " +"404, so this is the margin for anything still working from a cached " +"response, and for an issuance which was under way when the list " +"closed. Runs from the cron hook." +msgstr "" + diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index 048f8b72..94294c21 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -1747,3 +1747,50 @@ msgstr "" msgid "The credential already had that status, so nothing was changed." msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired. Retiring makes a URI written into real " +"credentials answer 404, so this is the margin for anything still " +"working from a cached response. Retirement runs from the cron hook." +msgstr "" + +msgid "Kept indefinitely" +msgstr "" + +msgid "" +"Rows recording who asked for which credential status change are " +"pruned once they reach this age. Pruning runs from the cron hook." +msgstr "" + +msgid "Status Audit Retention" +msgstr "" + +msgid "Status List Retirement Grace" +msgstr "" + +msgid "" +"The record of who asked for which credential status change is never " +"pruned. It is a row per status change rather than per credential, " +"and names no person, so this is a retention decision rather than a " +"storage one." +msgstr "" + +msgid "" +"The record of who asked for which credential status change is kept " +"for good. Credentials appear in it only as a hash, but the actor is " +"recorded as given, which where the admin authentication source " +"releases an identifier is a person." +msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired, and once more before its entries are " +"removed. Retiring makes a URI written into real credentials answer " +"404, so this is the margin for anything still working from a cached " +"response, and for an issuance which was under way when the list " +"closed. Runs from the cron hook." +msgstr "" + diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index 429b7415..b11c703c 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -1795,3 +1795,50 @@ msgstr "" msgid "The credential already had that status, so nothing was changed." msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired. Retiring makes a URI written into real " +"credentials answer 404, so this is the margin for anything still " +"working from a cached response. Retirement runs from the cron hook." +msgstr "" + +msgid "Kept indefinitely" +msgstr "" + +msgid "" +"Rows recording who asked for which credential status change are " +"pruned once they reach this age. Pruning runs from the cron hook." +msgstr "" + +msgid "Status Audit Retention" +msgstr "" + +msgid "Status List Retirement Grace" +msgstr "" + +msgid "" +"The record of who asked for which credential status change is never " +"pruned. It is a row per status change rather than per credential, " +"and names no person, so this is a retention decision rather than a " +"storage one." +msgstr "" + +msgid "" +"The record of who asked for which credential status change is kept " +"for good. Credentials appear in it only as a hash, but the actor is " +"recorded as given, which where the admin authentication source " +"releases an identifier is a person." +msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired, and once more before its entries are " +"removed. Retiring makes a URI written into real credentials answer " +"404, so this is the margin for anything still working from a cached " +"response, and for an issuance which was under way when the list " +"closed. Runs from the cron hook." +msgstr "" + diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index fc0fb746..ea7c3aab 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -1747,3 +1747,50 @@ msgstr "" msgid "The credential already had that status, so nothing was changed." msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired. Retiring makes a URI written into real " +"credentials answer 404, so this is the margin for anything still " +"working from a cached response. Retirement runs from the cron hook." +msgstr "" + +msgid "Kept indefinitely" +msgstr "" + +msgid "" +"Rows recording who asked for which credential status change are " +"pruned once they reach this age. Pruning runs from the cron hook." +msgstr "" + +msgid "Status Audit Retention" +msgstr "" + +msgid "Status List Retirement Grace" +msgstr "" + +msgid "" +"The record of who asked for which credential status change is never " +"pruned. It is a row per status change rather than per credential, " +"and names no person, so this is a retention decision rather than a " +"storage one." +msgstr "" + +msgid "" +"The record of who asked for which credential status change is kept " +"for good. Credentials appear in it only as a hash, but the actor is " +"recorded as given, which where the admin authentication source " +"releases an identifier is a person." +msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired, and once more before its entries are " +"removed. Retiring makes a URI written into real credentials answer " +"404, so this is the margin for anything still working from a cached " +"response, and for an issuance which was under way when the list " +"closed. Runs from the cron hook." +msgstr "" + diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 2a228862..511b097c 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -1701,3 +1701,50 @@ msgstr "" msgid "The credential already had that status, so nothing was changed." msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired. Retiring makes a URI written into real " +"credentials answer 404, so this is the margin for anything still " +"working from a cached response. Retirement runs from the cron hook." +msgstr "" + +msgid "Kept indefinitely" +msgstr "" + +msgid "" +"Rows recording who asked for which credential status change are " +"pruned once they reach this age. Pruning runs from the cron hook." +msgstr "" + +msgid "Status Audit Retention" +msgstr "" + +msgid "Status List Retirement Grace" +msgstr "" + +msgid "" +"The record of who asked for which credential status change is never " +"pruned. It is a row per status change rather than per credential, " +"and names no person, so this is a retention decision rather than a " +"storage one." +msgstr "" + +msgid "" +"The record of who asked for which credential status change is kept " +"for good. Credentials appear in it only as a hash, but the actor is " +"recorded as given, which where the admin authentication source " +"releases an identifier is a person." +msgstr "" + +msgid "" +"How long a list is left alone before it may be retired, counted both " +"from when it stopped accepting credentials and from when the last " +"credential in it expired, and once more before its entries are " +"removed. Retiring makes a URI written into real credentials answer " +"404, so this is the margin for anything still working from a cached " +"response, and for an issuance which was under way when the list " +"closed. Runs from the cron hook." +msgstr "" + diff --git a/routing/services/services.yml b/routing/services/services.yml index ec9ce26b..c09e1ef8 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -51,6 +51,8 @@ services: # Fetched from the (otherwise private) container by the cron hook after booting the module Kernel. SimpleSAML\Module\oidc\StatusList\StatusListReconciler: public: true + SimpleSAML\Module\oidc\StatusList\StatusListLifecycle: + public: true SimpleSAML\Module\oidc\Factories\: resource: '../../src/Factories/*' diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index ab6334a2..1f4cc5f8 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -206,6 +206,55 @@ function () use ($isEnabled): Row { ); }, ), + $this->guardRow( + Translate::noop('Status List Retirement Grace'), + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + fn(): Row => new Row( + Translate::noop('Status List Retirement Grace'), + $this->dateIntervalFormatter->toDurationSpec( + $this->moduleConfig->getVciStatusListRetirementGrace(), + ), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + Translate::noop( + 'How long a list is left alone before it may be retired, counted both from ' . + 'when it stopped accepting credentials and from when the last credential in ' . + 'it expired, and once more before its entries are removed. Retiring makes a ' . + 'URI written into real credentials answer 404, so this is the margin for ' . + 'anything still working from a cached response, and for an issuance which ' . + 'was under way when the list closed. Runs from the cron hook.', + ), + ), + ), + $this->guardRow( + Translate::noop('Status Audit Retention'), + ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, + function (): Row { + $retention = $this->moduleConfig->getVciStatusListAuditRetention(); + + return new Row( + Translate::noop('Status Audit Retention'), + $retention instanceof DateInterval ? + $this->dateIntervalFormatter->toDurationSpec($retention) : + Translate::noop('Kept indefinitely'), + $retention instanceof DateInterval ? + ConfigOverviewValueTypeEnum::RawText : + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, + $retention instanceof DateInterval ? + Translate::noop( + 'Rows recording who asked for which credential status change are pruned ' . + 'once they reach this age. Pruning runs from the cron hook.', + ) : + Translate::noop( + 'The record of who asked for which credential status change is kept for ' . + 'good. Credentials appear in it only as a hash, but the actor is ' . + 'recorded as given, which where the admin authentication source ' . + 'releases an identifier is a person.', + ), + ); + }, + ), ]; return new Section(Translate::noop('Status Lists'), 'statusLists', ...$rows); diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index dce05870..c6b65c8a 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -67,6 +67,14 @@ class ModuleConfig */ final public const string SSP_PRIMARY_READ_METHOD = 'readPrimary'; + /** + * Shortest Status List retirement grace which still does its job. + * + * See getVciStatusListRetirementGrace(): the wait has to outlast a credential issuance which was + * already in flight, and nothing here can serialise the two instead. + */ + final public const int MINIMUM_STATUS_LIST_RETIREMENT_GRACE_SECONDS = 3600; + final public const string OPTION_PKI_PRIVATE_KEY_PASSPHRASE = 'pass_phrase'; final public const string DEFAULT_PKI_PRIVATE_KEY_FILENAME = 'oidc_module.key'; final public const string DEFAULT_PKI_CERTIFICATE_FILENAME = 'oidc_module.crt'; @@ -174,6 +182,8 @@ class ModuleConfig final public const string OPTION_VCI_STATUS_LIST_KEY_PROFILE = 'vci_status_list_key_profile'; final public const string OPTION_VCI_STATUS_LIST_POOLS = 'vci_status_list_pools'; final public const string OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE = 'vci_status_list_requests_per_minute'; + final public const string OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE = 'vci_status_list_retirement_grace'; + final public const string OPTION_VCI_STATUS_LIST_AUDIT_RETENTION = 'vci_status_list_audit_retention'; final public const string OPTION_VCI_CREDENTIAL_TTLS = 'vci_credential_ttls'; final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; @@ -1341,6 +1351,131 @@ public function getVciStatusListRequestsPerMinute(): int return $configured; } + /** + * How long a Status List is left alone before it may be retired. + * + * Applied twice over, to the two things which have to have settled down: a list is not looked at + * until this long after it stopped accepting allocations, and it is not retired until this long + * after the last credential in it expired. Both are the same waiting period because they answer the + * same question -- has everything which might still be holding this list finished with it. + * + * Retiring a list makes its URI answer 404, and that URI is written into every credential which was + * issued from it. Those credentials have all expired by then, so nothing which should verify stops + * verifying, but a Relying Party which caches responses, or a wallet showing a credential it has not + * noticed is expired, sees a fetch fail rather than a status come back. The wait is what keeps that + * from happening the moment the last credential lapses. + * + * There is a floor under it, and this is the part which is not merely conservative. The first of the + * two waits exists to outlast an issuance which was already under way when the list stopped + * accepting allocations -- a request whose statement has read the list as open but has not yet + * written the row claiming an index. Nothing available here can serialise those two: there are no + * transactions, and the retiring statement and the allocating one write different rows, so neither + * conflicts with the other however each is guarded. Outlasting the request is the only defence, and + * a wait shorter than a request can take is not one. An hour is some two orders of magnitude beyond + * PHP's default execution limit, and still a small fraction of the default wait. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciStatusListRetirementGrace(): DateInterval + { + $grace = $this->resolveDurationOption( + self::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + $this->config()->getOptionalValue(self::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, 'P30D'), + ) ?? new DateInterval('P30D'); + + $epoch = new DateTimeImmutable('@0'); + + if ($epoch->add($grace)->getTimestamp() < self::MINIMUM_STATUS_LIST_RETIREMENT_GRACE_SECONDS) { + throw new ConfigurationError( + sprintf( + 'Option "%s" must be at least one hour. It is what a Status List which stopped ' . + 'accepting credentials waits before it may be retired, and its job is to outlast a ' . + 'credential issuance which was already under way at that moment. A shorter wait can ' . + 'let such an issuance produce a credential naming a list which has since been ' . + 'retired, and that credential can never be verified.', + self::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + ), + self::DEFAULT_FILE_NAME, + ); + } + + return $grace; + } + + /** + * How long rows in the status audit trail are kept, or null to keep them indefinitely. + * + * No default, deliberately. What an audit trail is for is answering questions later, and how much + * later is a matter of the deployment's own obligations rather than something this module can guess + * -- so nothing is discarded unless an operator says how long is long enough. The trail is small + * (one row per status change, never one per credential), so keeping it costs little. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciStatusListAuditRetention(): ?DateInterval + { + return $this->resolveDurationOption( + self::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, + $this->config()->getOptionalValue(self::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, null), + ); + } + + /** + * Reads an option which is a duration, is allowed to be absent, and has to be a length of time. + * + * Both options this serves are subtracted from now to get a cut-off, and both are the only thing + * standing between a cut-off and something being deleted or retired. A duration of no time gives a + * cut-off of now, and a negative one -- which a DateInterval can be, though a duration string can + * not -- gives a cut-off in the future, at which point the option is not delaying anything but + * bringing it forward. So neither is accepted, and the option being absent is how a deployment says + * it does not want the behaviour at all. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function resolveDurationOption(string $option, mixed $value): ?DateInterval + { + if ($value === null) { + return null; + } + + if ($value instanceof DateInterval) { + // Checked like any other, rather than trusted for having arrived as the right type. The + // configuration file is PHP, so an interval can be constructed there and then inverted, + // which no duration string can express and which the check below is what catches. + $duration = $value; + } elseif (is_string($value)) { + try { + $duration = new DateInterval($value); + } catch (Throwable $throwable) { + throw new ConfigurationError( + sprintf('Option "%s" is not a valid duration: %s', $option, $throwable->getMessage()), + self::DEFAULT_FILE_NAME, + ); + } + } else { + throw new ConfigurationError( + sprintf('Option "%s" must be a duration string, %s given.', $option, get_debug_type($value)), + self::DEFAULT_FILE_NAME, + ); + } + + // Anchored to the epoch rather than to now, so the answer does not depend on the server's + // timezone or on which side of a daylight saving transition the configuration is read. An + // inverted interval lands before the epoch and is caught by the same comparison. + if ((new DateTimeImmutable('@0'))->add($duration)->getTimestamp() < 1) { + throw new ConfigurationError( + sprintf( + 'Option "%s" is set to no time at all, or to a negative duration. Remove the option ' . + 'instead.', + $option, + ), + self::DEFAULT_FILE_NAME, + ); + } + + return $duration; + } + /** * Key profile used for Status List Tokens which do not have one set on their own pool. * diff --git a/src/Repositories/StatusAuditRepository.php b/src/Repositories/StatusAuditRepository.php index bdef71b5..3540990a 100644 --- a/src/Repositories/StatusAuditRepository.php +++ b/src/Repositories/StatusAuditRepository.php @@ -99,12 +99,89 @@ public function record( // moment written in the server's local time is indistinguishable from one written in // UTC, and the two would be ordered and pruned against each other as though they were // the same scale. Every other Status List table stores UTC; so does this one. - 'created_at' => ($createdAt ?? $this->helpers->dateTime()->getUtc()) - ->setTimezone(new DateTimeZone('UTC')) - ->format(DateFormatsEnum::DB_DATETIME->value), + 'created_at' => $this->formatForDatabase($createdAt ?? $this->helpers->dateTime()->getUtc()), ], ); return $id; } + + /** + * Removes trail rows older than a cut-off, up to a bound. + * + * Unlike the linkage deletion, which happens whether anyone asked for it or not, this is a policy + * rather than an obligation, and there is no default: nothing is removed unless an operator says + * how long is long enough. The rows are not free of personal data even so -- a credential appears + * only as a hash of its identifier, but the actor is recorded as given, and where the admin + * authentication source releases an identifier that names a person. + * + * @param \DateTimeImmutable $createdBefore Rows recorded before this moment are eligible. + * @param int $limit Most rows to remove in this call. + * @return int How many rows were removed, so the caller can tell an exhausted batch from a full one. + * @throws \Exception + */ + public function removeOlderThan(DateTimeImmutable $createdBefore, int $limit): int + { + if ($limit < 1) { + return 0; + } + + // Selected first and then deleted by identifier, rather than one DELETE with a LIMIT. MySQL + // accepts a LIMIT on a DELETE and PostgreSQL rejects it outright, so the bound has to be applied + // where every driver allows one, which is the SELECT. + $rows = $this->database->readPrimary( + sprintf( + 'SELECT id FROM %s WHERE created_at < :created_before ORDER BY created_at LIMIT %d', + $this->getTableName(), + $limit, + ), + ['created_before' => $this->formatForDatabase($createdBefore)], + )->fetchAll(); + + $placeholders = []; + $params = []; + $position = 0; + + /** @var mixed $row */ + foreach ($rows as $row) { + /** @var mixed $id */ + $id = is_array($row) ? ($row['id'] ?? null) : null; + + if (!is_scalar($id)) { + continue; + } + + // Each identifier under its own placeholder name, since a repeated one is not portable + // across drivers. + $placeholders[] = ':id_' . $position; + $params['id_' . $position] = (string)$id; + $position++; + } + + if ($placeholders === []) { + return 0; + } + + $affected = $this->database->write( + sprintf( + 'DELETE FROM %s WHERE id IN (%s)', + $this->getTableName(), + implode(', ', $placeholders), + ), + $params, + ); + + return is_int($affected) ? $affected : 0; + } + + /** + * Timestamps are stored without a zone and read back as UTC, so a moment is converted to UTC on the + * way in rather than having its wall clock written as-is. A cut-off handed in as a local time would + * otherwise be compared against values on a different scale, and would prune either too much or too + * little by the size of the offset. + */ + protected function formatForDatabase(DateTimeImmutable $moment): string + { + return $moment->setTimezone(new DateTimeZone('UTC'))->format(DateFormatsEnum::DB_DATETIME->value); + } } diff --git a/src/Repositories/StatusListEntryRepository.php b/src/Repositories/StatusListEntryRepository.php index b217e625..7abe798b 100644 --- a/src/Repositories/StatusListEntryRepository.php +++ b/src/Repositories/StatusListEntryRepository.php @@ -301,9 +301,11 @@ public function countAllocated(string $statusListId): int /** * A page of issued credentials, newest first, for the administration screens. * - * Only allocated rows are listed. Every index of a list exists as a row from the moment the list is - * created, so the unallocated ones are the bulk of this table and none of them is anything that was - * issued. + * Only allocated rows which still carry their linkage are listed. Every index of a list exists as a + * row from the moment the list is created, so the unallocated ones are the bulk of this table and + * none of them is anything that was issued; and a row whose credential has expired has had that + * linkage deleted, leaving an allocated row which names no credential and which nothing can be + * asked about or done to. * * Both search terms are the stored forms of the one thing an administrator typed, and either * matching is a hit: they will have entered a credential identifier or a user identifier and cannot @@ -331,7 +333,7 @@ public function findAllocatedPaginated( ?string $credentialIdHash = null, ?string $subjectRef = null, ): array { - $condition = 'allocated = :allocated'; + $condition = 'allocated = :allocated AND credential_id_hash IS NOT NULL'; $params = ['allocated' => [true, PDO::PARAM_BOOL]]; if (is_string($credentialIdHash) || is_string($subjectRef)) { @@ -401,6 +403,158 @@ public function countNeverRetiringLists(): int return is_numeric($total) ? (int)$total : 0; } + /** + * Deletes the linkage of credentials which have expired, keeping the index and its status. + * + * This is the second half of the bargain the linkage was stored under. Recording which credential + * holds which index is what makes a credential revocable at all, and it is also a record of who was + * issued what -- so it is kept for exactly as long as the credential it describes can be presented, + * and no longer. What survives is the index and the status it ended on, which is the part the + * published token is built from and the part which stops the index being handed out a second time. + * + * The four columns go together in one statement, because they are one fact. Clearing some of them + * would leave a row which still says who was issued a credential while claiming not to know which + * credential it was. + * + * Bounded, and batched by the caller. A deployment which switched credential expiry on some time ago + * can have a great many rows come due at once, and a single unbounded statement over them is a long + * lock held on the table which serves every issuance. + * + * The linkage test is repeated in the update, not only in the select which chose the rows. Two runs + * overlapping would otherwise each clear and each count the same rows, which changes nothing about + * what is stored but reports twice the work actually done -- and those counts are what an operator + * reads to decide whether the cron is keeping up. + * + * @param \DateTimeImmutable $expiredBefore Cut-off, normally simply now. + * @param int $limit Most rows to clear in this call. + * @return int How many rows were cleared, so the caller can tell an exhausted batch from a full one. + * @throws \Exception + */ + public function clearExpiredLinkage(DateTimeImmutable $expiredBefore, int $limit): int + { + if ($limit < 1) { + return 0; + } + + // Selected first and then updated by key, rather than one UPDATE with a LIMIT. MySQL accepts a + // LIMIT on an UPDATE and PostgreSQL rejects it outright, so the bound has to be applied where + // every driver allows one, which is the SELECT. + $rows = $this->readPrimary( + sprintf( + 'SELECT status_list_id, idx FROM %s ' . + 'WHERE credential_id_hash IS NOT NULL AND expires_at IS NOT NULL AND expires_at <= :expired_before ' . + 'ORDER BY expires_at LIMIT %d', + $this->getTableName(), + $limit, + ), + ['expired_before' => $this->formatForDatabase($expiredBefore)], + ); + + $conditions = []; + $params = []; + $position = 0; + + /** @var mixed $row */ + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + /** @var mixed $statusListId */ + $statusListId = $row['status_list_id'] ?? null; + /** @var mixed $idx */ + $idx = $row['idx'] ?? null; + + if (!is_scalar($statusListId) || !is_numeric($idx)) { + continue; + } + + // Each value under its own placeholder name: PDO turns named placeholders into positional + // ones for some drivers, and a repeated name then binds only its first occurrence. + $conditions[] = sprintf('(status_list_id = :list_%d AND idx = :idx_%d)', $position, $position); + $params['list_' . $position] = (string)$statusListId; + $params['idx_' . $position] = [(int)$idx, PDO::PARAM_INT]; + $position++; + } + + if ($conditions === []) { + return 0; + } + + $affected = $this->database->write( + sprintf( + 'UPDATE %s SET + credential_id = NULL, + credential_id_hash = NULL, + credential_configuration_id = NULL, + subject_ref = NULL, + updated_at = :updated_at + WHERE credential_id_hash IS NOT NULL AND (%s)', + $this->getTableName(), + implode(' OR ', $conditions), + ), + ['updated_at' => $this->formatForDatabase($this->helpers->dateTime()->getUtc())] + $params, + ); + + return is_int($affected) ? $affected : 0; + } + + /** + * Removes a bounded run of entries belonging to a list which has been retired. + * + * Only ever called for a retired list, and that is what makes it safe. Rows are otherwise kept for + * the life of the list however long ago the credential in them expired, because an index which was + * handed out once must never be handed out again -- but a retired list is not served and is not + * allocated into, so its indices can not be handed out at all. + * + * This is where retirement actually recovers anything. A retired list gives back its published token + * immediately, which is a couple of hundred kilobytes; the entries are the hundred thousand rows + * behind it. + * + * Deleted from the lowest index upwards in a bounded run, so that a list of default capacity is + * worked through over several cron runs rather than in one statement holding a lock over a hundred + * thousand rows. + * + * @return int How many rows were removed. Zero means this list has none left. + * @throws \Exception + */ + public function deleteRetiredEntries(string $statusListId, int $limit): int + { + if ($limit < 1) { + return 0; + } + + $rows = $this->readPrimary( + sprintf( + 'SELECT MIN(idx) AS lowest_idx FROM %s WHERE status_list_id = :status_list_id', + $this->getTableName(), + ), + ['status_list_id' => $statusListId], + ); + + /** @var mixed $lowestIdx */ + $lowestIdx = $rows[0]['lowest_idx'] ?? null; + + if (!is_numeric($lowestIdx)) { + return 0; + } + + // A range rather than a list of keys, which the primary key answers directly and which needs no + // second round trip to find out which rows to name. + $affected = $this->database->write( + sprintf( + 'DELETE FROM %s WHERE status_list_id = :status_list_id AND idx < :below_idx', + $this->getTableName(), + ), + [ + 'status_list_id' => $statusListId, + 'below_idx' => [(int)$lowestIdx + $limit, PDO::PARAM_INT], + ], + ); + + return is_int($affected) ? $affected : 0; + } + /** * @param array $params */ diff --git a/src/Repositories/StatusListRepository.php b/src/Repositories/StatusListRepository.php index 5411d415..97a68f0f 100644 --- a/src/Repositories/StatusListRepository.php +++ b/src/Repositories/StatusListRepository.php @@ -591,6 +591,243 @@ public function invalidatePublishedTokenIfUnchanged( return is_int($affected) && $affected > 0; } + /** + * Stops lists accepting allocations which they were never going to receive again anyway. + * + * A list is only selected for allocation while its pool and its policy fingerprint both match the + * current configuration, so changing a pool's settings or rotating the signing key leaves the lists + * created under the previous policy active but unreachable. Nothing would ever fill them, so nothing + * would ever deactivate them, and retirement begins with deactivation -- they would go on being + * served for ever while holding credentials which all expired years ago. + * + * This changes nothing an issuer or a wallet can observe. The lists were already never going to be + * allocated into; all this does is start the clock which lets them eventually be retired. + * + * @param array $currentPolicyByPoolId Pool identifier to the policy fingerprint + * lists of that pool are currently created under. An empty map means no pool is configured to + * allocate at all, in which case every active list is superseded. + * @return int How many lists were deactivated. + * @throws \Exception + */ + public function deactivateSuperseded(array $currentPolicyByPoolId): int + { + $params = [ + 'deactivated_at' => $this->nowForDatabase(), + 'new_is_active' => [false, PDO::PARAM_BOOL], + 'current_is_active' => [true, PDO::PARAM_BOOL], + ]; + + $currentPolicies = []; + $position = 0; + + foreach ($currentPolicyByPoolId as $poolId => $policyFingerprint) { + // Each value under its own placeholder name, since a repeated one is not portable across + // drivers. + $currentPolicies[] = sprintf( + '(pool_id = :pool_%d AND policy_fingerprint = :policy_%d)', + $position, + $position, + ); + $params['pool_' . $position] = $poolId; + $params['policy_' . $position] = $policyFingerprint; + $position++; + } + + $supersededCondition = $currentPolicies === [] ? + '' : + sprintf(' AND NOT (%s)', implode(' OR ', $currentPolicies)); + + $affected = $this->database->write( + sprintf( + 'UPDATE %s SET is_active = :new_is_active, deactivated_at = :deactivated_at ' . + 'WHERE is_active = :current_is_active AND retired_at IS NULL%s', + $this->getTableName(), + $supersededCondition, + ), + $params, + ); + + return is_int($affected) ? $affected : 0; + } + + /** + * Lists which stopped accepting allocations long enough ago to be worth examining for retirement. + * + * Deactivation is only the first of the two waits. Whether a list has served out the second one + * depends on when the credentials inside it expire, which this does not work out -- that is an + * aggregate per list, and one worth avoiding for the lists which have not even served out the first. + * + * It does exclude the lists which can never serve it out, which is a different thing. A list holding + * a credential without an expiry is not waiting for anything; it is permanently ineligible, and + * leaving it in would let a deployment with enough of them fill every batch a run is willing to work + * through and starve the eligible lists behind them. Since every run starts from the beginning, that + * would not correct itself. + * + * The `deactivated_at IS NOT NULL` test is what keeps this away from lists which are inactive for the + * other reason: a list is created inactive and stays that way while its entries are being seeded, and + * one abandoned midway through that is dealt with by deleting it, not by retiring it. + * + * Paged by the last identifier seen rather than by an offset, because the caller retires some of the + * rows it is given and that takes them out of this result set. + * + * @param \DateTimeImmutable $deactivatedBefore Ignore anything deactivated more recently than this. + * @param ?string $afterId Resume after this list, or null to start from the beginning. + * @return string[] Identifiers, in the arbitrary but stable order of the primary key. + */ + public function findRetirementCandidates( + DateTimeImmutable $deactivatedBefore, + int $limit, + ?string $afterId = null, + ): array { + $params = [ + 'is_active' => [false, PDO::PARAM_BOOL], + 'deactivated_before' => $this->nowForDatabase($deactivatedBefore), + 'allocated' => [true, PDO::PARAM_BOOL], + ]; + $cursorCondition = ''; + + if ($afterId !== null) { + $cursorCondition = ' AND id > :after_id'; + $params['after_id'] = $afterId; + } + + // Only the identifier is read. A row carries its published token, which for a list at the + // default capacity is a couple of hundred kilobytes, and none of that is looked at here. + // + // The limit is interpolated rather than bound: MySQL rejects a bound LIMIT when PDO emulates + // prepared statements, because the value arrives quoted as a string. It is an integer here, so + // there is nothing to inject. + return $this->readIdentifiers( + sprintf( + 'SELECT id FROM %1$s WHERE is_active = :is_active AND retired_at IS NULL ' . + 'AND deactivated_at IS NOT NULL AND deactivated_at <= :deactivated_before ' . + 'AND NOT EXISTS (SELECT 1 FROM %2$s WHERE status_list_id = %1$s.id ' . + 'AND allocated = :allocated AND expires_at IS NULL)%3$s ' . + 'ORDER BY id LIMIT %4$d', + $this->getTableName(), + $this->database->applyPrefix(StatusListEntryRepository::TABLE_NAME), + $cursorCondition, + max(0, $limit), + ), + $params, + ); + } + + /** + * Stops a list being served, and gives back the token it was being served from. + * + * The last step of the lifecycle, and the only irreversible one: the list's URI is written into every + * credential which was issued from it, and from here on fetching it answers 404. That is why nothing + * arrives here until every one of those credentials has expired and a further grace period has + * passed on top. + * + * That condition is tested here rather than by the caller, and this is the point of the method. A + * caller which reads the entries, decides they have all expired, and then retires the list in a + * second statement has a gap between the two, and an issuance which was already in flight can land + * in it -- leaving a credential which is perfectly valid naming a list which now answers 404. There + * are no transactions to close that gap with, so the test and the retirement are the same statement, + * and a list which stopped qualifying in the meantime simply matches no rows. + * + * The published token is dropped in the same statement too, since a retired list is never served + * from it again. Its content hash goes back to the empty string and the invalidation counter moves, + * which together keep a signer that is mid-flight from publishing a token onto a list which has just + * been retired out from under it. + * + * @param \DateTimeImmutable $spentBefore Every credential in the list must have expired before this + * moment. A credential with no expiry at all never qualifies, however far ahead this is. + * @return bool Whether this call is the one which retired it. False means another worker got there + * first, or the list turned out to still be holding something. + * @throws \Exception + */ + public function retire(string $id, DateTimeImmutable $spentBefore): bool + { + $affected = $this->database->write( + sprintf( + "UPDATE %1\$s SET + retired_at = :retired_at, + signed_token = NULL, + signed_token_content_hash = '', + signed_token_iat = NULL, + signed_token_exp = NULL, + invalidation_counter = invalidation_counter + 1 + WHERE id = :id AND is_active = :is_active AND retired_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM %2\$s WHERE status_list_id = :guarded_id + AND allocated = :allocated + AND (expires_at IS NULL OR expires_at >= :spent_before) + )", + $this->getTableName(), + $this->database->applyPrefix(StatusListEntryRepository::TABLE_NAME), + ), + [ + 'retired_at' => $this->nowForDatabase(), + 'id' => $id, + 'is_active' => [false, PDO::PARAM_BOOL], + // The same value as :id, under its own name because a repeated placeholder is not + // portable across drivers. + 'guarded_id' => $id, + 'allocated' => [true, PDO::PARAM_BOOL], + 'spent_before' => $this->nowForDatabase($spentBefore), + ], + ); + + return is_int($affected) && $affected > 0; + } + + /** + * Retired lists which still have entries behind them, and have been retired long enough for that to + * be safe to act on. + * + * Removing those entries is bounded per run, so a list of default capacity takes several runs to + * clear and has to be found again by each of them. The existence test is what distinguishes a list + * still being worked through from the ones already dealt with, which accumulate for as long as the + * deployment runs and would otherwise be re-examined for ever. + * + * The wait since retirement is the point of the cut-off, and it is not the same wait as the one + * before retirement. Retiring a list can not be serialised against an issuance which was already in + * flight -- the two statements write different rows, so neither conflicts with the other -- so a + * credential can in principle be written into a list moments after it was retired. Retirement alone + * only makes that credential unverifiable; removing the entries as well destroys the record that it + * exists at all. Leaving the rows a while longer keeps them there to be found. + * + * @param \DateTimeImmutable $retiredBefore Ignore lists retired more recently than this. + * @return string[] + */ + public function findRetiredWithEntries(int $limit, DateTimeImmutable $retiredBefore): array + { + return $this->readIdentifiers( + sprintf( + 'SELECT id FROM %1$s WHERE retired_at IS NOT NULL AND retired_at <= :retired_before ' . + 'AND EXISTS (SELECT 1 FROM %2$s WHERE status_list_id = %1$s.id) ORDER BY id LIMIT %3$d', + $this->getTableName(), + $this->database->applyPrefix(StatusListEntryRepository::TABLE_NAME), + max(0, $limit), + ), + ['retired_before' => $this->nowForDatabase($retiredBefore)], + ); + } + + /** + * @param array $params + * @return string[] + */ + protected function readIdentifiers(string $statement, array $params = []): array + { + $identifiers = []; + + /** @var mixed $row */ + foreach ($this->readPrimary($statement, $params) as $row) { + /** @var mixed $id */ + $id = is_array($row) ? ($row['id'] ?? null) : null; + + if (is_scalar($id)) { + $identifiers[] = (string)$id; + } + } + + return $identifiers; + } + /** * @param array $rows * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException diff --git a/src/StatusList/StatusListLifecycle.php b/src/StatusList/StatusListLifecycle.php new file mode 100644 index 00000000..f905bdfb --- /dev/null +++ b/src/StatusList/StatusListLifecycle.php @@ -0,0 +1,439 @@ +attempt( + 'Clearing the linkage of expired credentials', + fn(): int => $this->clearExpiredCredentialLinkage(), + $failures, + ); + $deactivatedStatusLists = $this->attempt( + 'Deactivating superseded Status Lists', + fn(): int => $this->deactivateSupersededStatusLists(), + $failures, + ); + $retiredStatusLists = $this->attempt( + 'Retiring spent Status Lists', + fn(): int => $this->retireSpentStatusLists(), + $failures, + ); + // Which never touches a list retired in the step above: this one waits out a further grace + // period before removing anything, so its ordering here is a matter of reading rather than of + // what it does. + $purgedEntries = $this->attempt( + 'Removing the entries of retired Status Lists', + fn(): int => $this->purgeRetiredStatusListEntries(), + $failures, + ); + $prunedAuditRows = $this->attempt( + 'Pruning the status audit trail', + fn(): int => $this->pruneStatusAuditTrail(), + $failures, + ); + + return new StatusListLifecycleReport( + $clearedLinkages, + $deactivatedStatusLists, + $retiredStatusLists, + $purgedEntries, + $prunedAuditRows, + $failures, + ); + } + + /** + * Runs one step, and turns anything it throws into something the caller can carry on past. + * + * @param callable():int $step + * @param string[] $failures Appended to when the step does not complete. + */ + protected function attempt(string $description, callable $step, array &$failures): int + { + try { + return $step(); + } catch (Throwable $throwable) { + $failure = sprintf('%s failed: %s', $description, $throwable->getMessage()); + $failures[] = $failure; + $this->loggerService->error('Status List lifecycle: ' . $failure); + + return 0; + } + } + + /** + * Forgets which credential held which index, once that credential has expired. + * + * The index and the status it ended on stay. Those are what the published token is built from, and + * what stops the index being handed out to a second credential -- but they say nothing about who was + * issued what, which is the part which is not kept a moment longer than revoking the credential + * required. + * + * @return int How many rows were cleared. + * @throws \Exception + */ + public function clearExpiredCredentialLinkage(): int + { + $now = $this->helpers->dateTime()->getUtc(); + $cleared = 0; + + for ($batch = 0; $batch < self::MAX_LINKAGE_BATCHES; $batch++) { + $inBatch = $this->statusListEntryRepository->clearExpiredLinkage($now, self::LINKAGE_BATCH_SIZE); + $cleared += $inBatch; + + if ($inBatch < self::LINKAGE_BATCH_SIZE) { + return $cleared; + } + } + + $this->loggerService->info( + sprintf( + 'Status List lifecycle cleared the linkage of %d expired credential(s) and stopped at ' . + 'its ceiling for one run. The rest is cleared by the next run.', + $cleared, + ), + ); + + return $cleared; + } + + /** + * Stops lists being allocation targets when the configuration they were created under is no longer + * the current one. + * + * A list is only selected for allocation while its pool and policy fingerprint match what the module + * is configured with now, so changing a pool's settings or rotating the signing key silently retires + * a list from service without recording that anything happened to it. Nothing would ever fill it, so + * nothing would ever deactivate it, and every later step begins with deactivation. + * + * Skipped entirely while Status Lists are switched off. An operator who has turned the feature off + * for a moment has not asked for every list they have to start winding down, and turning it back on + * would not undo it. + * + * @return int How many lists were deactivated. + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \Exception + */ + public function deactivateSupersededStatusLists(): int + { + if (!$this->moduleConfig->getVciStatusListEnabled()) { + return 0; + } + + $signingKeyId = $this->statusListKeyResolver->getCurrentKeyId(); + $currentPolicyByPoolId = []; + + foreach ($this->moduleConfig->getVciStatusListPoolBag()->getAll() as $pool) { + $currentPolicyByPoolId[$pool->getId()] = $pool->getPolicyFingerprint($signingKeyId); + } + + $deactivated = $this->statusListRepository->deactivateSuperseded($currentPolicyByPoolId); + + if ($deactivated > 0) { + $this->loggerService->info( + sprintf( + 'Status List lifecycle deactivated %d Status List(s) which the current configuration ' . + 'would no longer allocate into. They go on being served until every credential in ' . + 'them has expired.', + $deactivated, + ), + ); + } + + return $deactivated; + } + + /** + * Retires the lists which nothing can still be holding. + * + * Two waits have to have elapsed, and they are the same length because they ask the same question. + * The first runs from the moment the list stopped accepting allocations, which covers an issuance + * which was in flight at that moment. The second runs from the expiry of the last credential in the + * list, which covers a Relying Party still working from a response it cached, or a wallet presenting + * a credential it has not noticed is expired. + * + * A list holding even one credential without an expiry is never retired, however long it waits. + * + * @return int How many lists were retired. + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + * @throws \Exception + */ + public function retireSpentStatusLists(): int + { + $grace = $this->moduleConfig->getVciStatusListRetirementGrace(); + $now = $this->helpers->dateTime()->getUtc(); + $graceExpiredBefore = $now->sub($grace); + + $retired = 0; + $cursor = null; + $isExhausted = false; + + for ($batch = 0; $batch < self::MAX_RETIREMENT_BATCHES; $batch++) { + $candidateIds = $this->statusListRepository->findRetirementCandidates( + $graceExpiredBefore, + self::RETIREMENT_BATCH_SIZE, + $cursor, + ); + + if ($candidateIds === []) { + $isExhausted = true; + + break; + } + + foreach ($candidateIds as $candidateId) { + // Whether the list has anything left in it is decided by the statement which retires + // it, not here. Reading the entries first and retiring second would leave a gap an + // issuance already in flight could land in, and there are no transactions to close it + // with -- so the list simply matches no rows if it stopped qualifying. + if (!$this->statusListRepository->retire($candidateId, $graceExpiredBefore)) { + continue; + } + + $retired++; + + $this->loggerService->info( + 'A Status List was retired and no longer answers requests. Every credential issued ' . + 'from it had expired, and the retirement grace period had passed on top of that.', + ['statusListId' => $candidateId], + ); + } + + // Resume after the last list seen rather than at a numeric offset. Retiring a list takes it + // out of the set being paged through, so an offset would step over exactly as many + // unexamined lists as were retired. + $cursor = $candidateIds[array_key_last($candidateIds)]; + + if (count($candidateIds) < self::RETIREMENT_BATCH_SIZE) { + $isExhausted = true; + + break; + } + } + + if (!$isExhausted) { + $this->loggerService->warning( + sprintf( + 'Status List retirement stopped after %d lists without reaching the end. Every run ' . + 'starts from the beginning, so the lists beyond that point are examined by no run ' . + 'at all and will not be retired until the ones before them are.', + self::MAX_RETIREMENT_BATCHES * self::RETIREMENT_BATCH_SIZE, + ), + ); + } + + return $retired; + } + + /** + * Removes the entry rows of lists which have been retired. + * + * This is where retirement gets anything back. Retiring a list gives up its published token, which is + * a couple of hundred kilobytes; the entries behind it are a row per index, which at the default + * capacity is a hundred and thirty thousand of them, and they are why leaving credential expiry + * switched off means storage that only ever grows. + * + * Safe only because the list is retired. Entries are otherwise kept for the whole life of the list + * however long ago the credential in them expired, since an index which was handed out once must + * never be handed out again -- but a retired list is neither served nor allocated into, so its + * indices can not be handed out at all. + * + * A list retired a moment ago is left alone even so. Retirement can not be serialised against an + * issuance which was already in flight, so a credential can in principle be written into a list just + * after it was retired; retirement alone leaves that credential unverifiable, while removing the + * entries as well would leave nothing to show it was ever issued. The rows stay until the same wait + * which precedes retirement has passed again. + * + * @return int How many entry rows were removed. + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \Exception + */ + public function purgeRetiredStatusListEntries(): int + { + $purged = 0; + $retiredBefore = $this->helpers->dateTime()->getUtc() + ->sub($this->moduleConfig->getVciStatusListRetirementGrace()); + + $statusListIds = $this->statusListRepository->findRetiredWithEntries( + self::PURGE_LIST_LIMIT, + $retiredBefore, + ); + + foreach ($statusListIds as $statusListId) { + $purgedForList = 0; + + while ($purgedForList < self::MAX_PURGED_ENTRIES_PER_LIST) { + $inBatch = $this->statusListEntryRepository->deleteRetiredEntries( + $statusListId, + self::PURGE_BATCH_SIZE, + ); + + if ($inBatch < 1) { + break; + } + + $purgedForList += $inBatch; + } + + $purged += $purgedForList; + } + + return $purged; + } + + /** + * Prunes the status audit trail to the configured retention. + * + * Absent by default, in which case nothing is removed. How long a record of who revoked what needs + * keeping is a question about the deployment's own obligations rather than one this module can + * answer, and the trail is a row per status change rather than a row per credential, so keeping it + * indefinitely costs little in storage. It is not free of personal data, though: the credential + * appears only as a hash, but the actor is recorded as given, and where the admin authentication + * source releases an identifier that names a person. Which is the other reason to set a retention. + * + * @return int How many rows were removed. + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \Exception + */ + public function pruneStatusAuditTrail(): int + { + $retention = $this->moduleConfig->getVciStatusListAuditRetention(); + + if ($retention === null) { + return 0; + } + + $createdBefore = $this->helpers->dateTime()->getUtc()->sub($retention); + $pruned = 0; + + for ($batch = 0; $batch < self::MAX_AUDIT_BATCHES; $batch++) { + $inBatch = $this->statusAuditRepository->removeOlderThan($createdBefore, self::AUDIT_BATCH_SIZE); + $pruned += $inBatch; + + if ($inBatch < self::AUDIT_BATCH_SIZE) { + return $pruned; + } + } + + return $pruned; + } +} diff --git a/src/StatusList/Values/StatusListLifecycleReport.php b/src/StatusList/Values/StatusListLifecycleReport.php new file mode 100644 index 00000000..45c51ee1 --- /dev/null +++ b/src/StatusList/Values/StatusListLifecycleReport.php @@ -0,0 +1,80 @@ +clearedLinkages; + } + + public function getDeactivatedStatusLists(): int + { + return $this->deactivatedStatusLists; + } + + public function getRetiredStatusLists(): int + { + return $this->retiredStatusLists; + } + + public function getPurgedEntries(): int + { + return $this->purgedEntries; + } + + public function getPrunedAuditRows(): int + { + return $this->prunedAuditRows; + } + + /** + * @return string[] + */ + public function getFailures(): array + { + return $this->failures; + } + + /** + * Whether the run changed anything at all, so that a cron which has nothing to do stays quiet. + */ + public function hasChanges(): bool + { + return $this->clearedLinkages > 0 || + $this->deactivatedStatusLists > 0 || + $this->retiredStatusLists > 0 || + $this->purgedEntries > 0 || + $this->prunedAuditRows > 0; + } +} diff --git a/tests/integration/src/StatusList/StatusListStorageTest.php b/tests/integration/src/StatusList/StatusListStorageTest.php index c9b87851..7439ddda 100644 --- a/tests/integration/src/StatusList/StatusListStorageTest.php +++ b/tests/integration/src/StatusList/StatusListStorageTest.php @@ -4,14 +4,18 @@ namespace SimpleSAML\Test\Module\oidc\integration\StatusList; +use DateTimeImmutable; +use DateTimeZone; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Database; +use SimpleSAML\Module\oidc\Codebooks\StatusChangeSourceEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\StatusAuditRepository; use SimpleSAML\Module\oidc\Repositories\StatusListEntryRepository; use SimpleSAML\Module\oidc\Repositories\StatusListRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; @@ -33,6 +37,7 @@ */ #[CoversClass(StatusListRepository::class)] #[CoversClass(StatusListEntryRepository::class)] +#[CoversClass(StatusAuditRepository::class)] class StatusListStorageTest extends TestCase { protected const string LIST_ID = 'integration-status-list-0000000000000000000000000000000000000000'; @@ -666,6 +671,320 @@ public function testGuardedInvalidationOnlyClearsTheTokenItExamined(string $data $this->assertSame(1, $statusList?->getInvalidationCounter()); } + /** + * @throws \Exception + */ + protected function givenAllocatedEntry( + int $idx, + string $credentialId, + ?DateTimeImmutable $expiresAt, + ): void { + $this->statusListEntryRepository->allocate( + self::LIST_ID, + $idx, + $credentialId, + $this->statusListEntryRepository->hashCredentialId($credentialId), + 'IntegrationCredential', + 'a-subject-ref', + $expiresAt, + ); + } + + /** + * @return array + */ + protected function readEntry(int $idx): array + { + $rows = $this->database->readPrimary( + sprintf( + 'SELECT * FROM %s WHERE status_list_id = :status_list_id AND idx = :idx', + $this->database->applyPrefix('oidc_status_list_entry'), + ), + [ + 'status_list_id' => self::LIST_ID, + 'idx' => $idx, + ], + )->fetchAll(); + + $this->assertIsArray($rows[0] ?? null); + + return $rows[0]; + } + + /** + * Clearing the linkage of an expired credential is bounded, and the bound has to be applied in the + * SELECT: MySQL accepts a LIMIT on an UPDATE and PostgreSQL rejects it outright. The update which + * follows names its rows by composite key, one placeholder per value, since a repeated named + * placeholder binds only its first occurrence on some drivers. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testClearsTheLinkageOfExpiredCredentialsInBoundedBatches(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + for ($idx = 0; $idx < 3; $idx++) { + $this->givenAllocatedEntry($idx, 'urn:vc:expired:' . $idx, new DateTimeImmutable('2020-01-01 09:00:00')); + } + $this->givenAllocatedEntry(9, 'urn:vc:live', new DateTimeImmutable('2099-01-01 09:00:00')); + $this->givenAllocatedEntry(10, 'urn:vc:permanent', null); + + $now = new DateTimeImmutable('2026-08-07 12:00:00'); + + $this->assertSame(2, $this->statusListEntryRepository->clearExpiredLinkage($now, 2)); + $this->assertSame(1, $this->statusListEntryRepository->clearExpiredLinkage($now, 2)); + $this->assertSame(0, $this->statusListEntryRepository->clearExpiredLinkage($now, 2)); + + $cleared = $this->readEntry(0); + $this->assertNull($cleared['credential_id']); + $this->assertNull($cleared['credential_id_hash']); + $this->assertNull($cleared['credential_configuration_id']); + $this->assertNull($cleared['subject_ref']); + // What the published token is built from, and what stops the index being handed out again. + $this->assertSame(0, (int)$cleared['idx']); + $this->assertNotNull($cleared['expires_at']); + + $this->assertSame('urn:vc:live', $this->readEntry(9)['credential_id']); + $this->assertSame('urn:vc:permanent', $this->readEntry(10)['credential_id']); + } + + /** + * Whether anything is still holding the list is tested inside the statement which retires it, as a + * correlated NOT EXISTS across the two tables. Asserted per driver because that is where a caller + * reading first and retiring second would leave a gap, and because a guard which did not filter on + * allocation would refuse every list there is -- each of them has a whole capacity of unallocated + * indices, none of which has an expiry. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testRetirementItselfRefusesAListWhichIsStillHoldingSomething(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + // Allocated before the list is deactivated, since a deactivated list takes no more claims. + $this->givenAllocatedEntry(0, 'urn:vc:live', new DateTimeImmutable('2027-02-01 09:00:00')); + $this->givenAllocatedEntry(1, 'urn:vc:permanent', null); + $this->statusListRepository->deactivate(self::LIST_ID); + + $cutOff = new DateTimeImmutable('2026-08-07 12:00:00'); + + $this->assertFalse($this->statusListRepository->retire(self::LIST_ID, $cutOff)); + + // Both of those brought forward to before the cut-off, leaving only unallocated indices without + // an expiry -- which must not keep the list from retiring. + $this->database->write( + sprintf( + 'UPDATE %s SET expires_at = :expires_at WHERE status_list_id = :status_list_id ' . + 'AND allocated = :allocated', + $this->database->applyPrefix('oidc_status_list_entry'), + ), + [ + 'expires_at' => '2021-01-01 09:00:00', + 'status_list_id' => self::LIST_ID, + 'allocated' => true, + ], + ); + + $this->assertTrue($this->statusListRepository->retire(self::LIST_ID, $cutOff)); + } + + /** + * The candidate query joins the two tables through a correlated NOT EXISTS, which is what keeps a + * list that can never be retired from occupying the batches a run is willing to work through. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testOffersOnlyRetirementCandidatesWhichCouldActuallyBeRetired(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + $this->givenAllocatedEntry(0, 'urn:vc:permanent', null); + $this->statusListRepository->deactivate(self::LIST_ID); + + $this->database->write( + sprintf( + 'UPDATE %s SET deactivated_at = :deactivated_at WHERE id = :id', + $this->database->applyPrefix('oidc_status_list'), + ), + [ + 'deactivated_at' => '2020-01-01 09:00:00', + 'id' => self::LIST_ID, + ], + ); + + $cutOff = new DateTimeImmutable('2026-08-07 12:00:00'); + + $this->assertSame([], $this->statusListRepository->findRetirementCandidates($cutOff, 10)); + + // The same list once the credential which never expires has an expiry after all. + $this->database->write( + sprintf( + 'UPDATE %s SET expires_at = :expires_at WHERE status_list_id = :status_list_id AND idx = :idx', + $this->database->applyPrefix('oidc_status_list_entry'), + ), + [ + 'expires_at' => '2021-01-01 09:00:00', + 'status_list_id' => self::LIST_ID, + 'idx' => 0, + ], + ); + + $this->assertSame([self::LIST_ID], $this->statusListRepository->findRetirementCandidates($cutOff, 10)); + } + + /** + * Retirement gives back the published token in the same statement which stamps the list, and moves + * the invalidation counter so a signer mid-flight can not publish onto a list retired underneath it. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testRetirementStampsTheListAndGivesBackItsToken(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $this->statusListRepository->publishToken( + self::LIST_ID, + '', + 0, + str_repeat('a', 64), + 'a.signed.token', + new DateTimeImmutable('2026-08-01 09:00:00'), + new DateTimeImmutable('2026-08-08 09:00:00'), + ); + $this->statusListRepository->deactivate(self::LIST_ID); + + $spentBefore = new DateTimeImmutable('2099-01-01 00:00:00'); + + $this->assertTrue($this->statusListRepository->retire(self::LIST_ID, $spentBefore)); + // Only the first caller reports it, so of several workers deciding at once one acts. + $this->assertFalse($this->statusListRepository->retire(self::LIST_ID, $spentBefore)); + + $statusList = $this->statusListRepository->findByIdOnPrimary(self::LIST_ID); + + $this->assertTrue($statusList?->isRetired()); + $this->assertNull($statusList?->getSignedToken()); + $this->assertSame('', $statusList?->getSignedTokenContentHash()); + $this->assertSame(1, $statusList?->getInvalidationCounter()); + } + + /** + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testRemovesTheEntriesOfARetiredListInBoundedRuns(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + $this->statusListRepository->deactivate(self::LIST_ID); + $this->statusListRepository->retire(self::LIST_ID, new DateTimeImmutable('2099-01-01 00:00:00')); + + $this->assertSame([self::LIST_ID], $this->statusListRepository->findRetiredWithEntries( + 10, + new DateTimeImmutable('2099-01-01 00:00:00'), + )); + + $removed = 0; + + while (($inBatch = $this->statusListEntryRepository->deleteRetiredEntries(self::LIST_ID, 25)) > 0) { + $removed += $inBatch; + } + + $this->assertSame(self::CAPACITY, $removed); + // Has to stop being offered, or every list a deployment ever retired is re-examined for ever. + $this->assertSame([], $this->statusListRepository->findRetiredWithEntries( + 10, + new DateTimeImmutable('2099-01-01 00:00:00'), + )); + } + + /** + * Deactivating the lists a changed configuration would no longer allocate into is one statement + * whose exclusion is built from a placeholder pair per pool, and an empty configuration drops the + * exclusion altogether rather than emitting a `NOT IN ()` no driver accepts. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testDeactivatesOnlyTheListsTheCurrentPolicyWouldNotAllocateInto(string $database): void + { + $this->useDatabase(self::$$database); + $this->givenSeededList(); + + $this->assertSame( + 0, + $this->statusListRepository->deactivateSuperseded(['integration-pool' => 'integration-fingerprint']), + ); + $this->assertTrue($this->statusListRepository->findByIdOnPrimary(self::LIST_ID)?->isActive()); + + $this->assertSame( + 1, + $this->statusListRepository->deactivateSuperseded(['integration-pool' => 'a-rotated-fingerprint']), + ); + + $statusList = $this->statusListRepository->findByIdOnPrimary(self::LIST_ID); + $this->assertFalse($statusList?->isActive()); + $this->assertNotNull($statusList?->getDeactivatedAt()); + } + + /** + * The audit prune is bounded the same way the linkage clearing is, for the same reason, and deletes + * by identifier because that is the only shape every driver takes. + * + * @throws \Exception + */ + #[DataProvider('databaseToTest')] + public function testPrunesTheAuditTrailInBoundedBatches(string $database): void + { + $this->useDatabase(self::$$database); + + $auditRepository = new StatusAuditRepository(new ModuleConfig(), $this->database, null, new Helpers()); + $this->database->write('DELETE FROM ' . $auditRepository->getTableName()); + + for ($idx = 0; $idx < 3; $idx++) { + $auditRepository->record( + str_repeat((string)$idx, 64), + self::LIST_ID, + $idx, + StatusTypeEnum::Valid->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Api, + 'HR system', + new DateTimeImmutable('2020-01-01 09:00:00', new DateTimeZone('UTC')), + ); + } + + $auditRepository->record( + str_repeat('f', 64), + self::LIST_ID, + 9, + StatusTypeEnum::Valid->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Admin, + 'jane.doe', + new DateTimeImmutable('2026-08-01 09:00:00', new DateTimeZone('UTC')), + ); + + $cutOff = new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')); + + $this->assertSame(2, $auditRepository->removeOlderThan($cutOff, 2)); + $this->assertSame(1, $auditRepository->removeOlderThan($cutOff, 2)); + $this->assertSame(0, $auditRepository->removeOlderThan($cutOff, 2)); + + $remaining = $this->database->readPrimary( + 'SELECT idx FROM ' . $auditRepository->getTableName(), + )->fetchAll(); + + $this->assertCount(1, $remaining); + $this->assertSame(9, (int)$remaining[0]['idx']); + } + /** * Migrations must be re-runnable, because a version is recorded only once its whole method has * succeeded and nothing rolls back what it managed before failing. diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 85822be7..bd32b75b 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -1142,4 +1142,193 @@ protected function withCredentialTtl(mixed $ttl): array ], ); } + + /** + * @return array + */ + protected function withOption(string $option, mixed $value): array + { + return array_merge($this->overrides, [$option => $value]); + } + + /** + * @throws \Exception + */ + public function testRetirementGraceDefaultsToAMonth(): void + { + $this->assertSame(30, $this->sut()->getVciStatusListRetirementGrace()->d); + } + + /** + * @throws \Exception + */ + public function testReadsTheConfiguredRetirementGrace(): void + { + $sut = $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + 'P90D', + )); + + $this->assertSame(90, $sut->getVciStatusListRetirementGrace()->d); + } + + /** + * The first of the two waits has to outlast an issuance which was already under way when the list + * stopped accepting allocations, and nothing available can serialise the two instead. A wait shorter + * than a request can take is not a wait, so there is a floor rather than only a ban on zero. + * + * @throws \Exception + */ + public function testRejectsARetirementGraceOfNoTime(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + 'PT0S', + ))->getVciStatusListRetirementGrace(); + } + + /** + * @throws \Exception + */ + public function testRejectsARetirementGraceShorterThanAnHour(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('at least one hour'); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + 'PT30M', + ))->getVciStatusListRetirementGrace(); + } + + /** + * @throws \Exception + */ + public function testAcceptsTheShortestRetirementGraceThereIs(): void + { + $sut = $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + 'PT1H', + )); + + $this->assertSame(1, $sut->getVciStatusListRetirementGrace()->h); + } + + /** + * @throws \Exception + */ + public function testRejectsAnUnparseableRetirementGrace(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + 'thirty days', + ))->getVciStatusListRetirementGrace(); + } + + /** + * The configuration file is PHP, so a duration can arrive as an object rather than a string. That + * has to be checked like any other value: an interval can be inverted, which no duration string can + * express, and a negative grace subtracted from now gives a cut-off in the future -- retiring lists + * whose credentials are still live. + * + * @throws \Exception + */ + public function testRejectsAnInvertedRetirementGraceGivenAsAnInterval(): void + { + $inverted = new DateInterval('P30D'); + $inverted->invert = 1; + + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + $inverted, + ))->getVciStatusListRetirementGrace(); + } + + /** + * @throws \Exception + */ + public function testAcceptsARetirementGraceGivenAsAnInterval(): void + { + $sut = $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + new DateInterval('P14D'), + )); + + $this->assertSame(14, $sut->getVciStatusListRetirementGrace()->d); + } + + /** + * How long a record of who revoked what needs keeping follows from the deployment's own + * obligations, so nothing is discarded unless an operator says how long is long enough. + * + * @throws \Exception + */ + public function testTheAuditTrailIsKeptIndefinitelyUnlessARetentionIsSet(): void + { + $this->assertNull($this->sut()->getVciStatusListAuditRetention()); + } + + /** + * @throws \Exception + */ + public function testReadsTheConfiguredAuditRetention(): void + { + $sut = $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, + 'P1Y', + )); + + $this->assertSame(1, $sut->getVciStatusListAuditRetention()?->y); + } + + /** + * A retention of no time would delete every row the moment it was written, which is a way of asking + * for no trail at all rather than a retention policy. Leaving the option out is how that is said. + * + * @throws \Exception + */ + public function testRejectsAnAuditRetentionOfNoTime(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, + 'PT0S', + ))->getVciStatusListAuditRetention(); + } + + /** + * @throws \Exception + */ + public function testRejectsAnAuditRetentionWhichIsNotADuration(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, + 365, + ))->getVciStatusListAuditRetention(); + } + + /** + * @throws \Exception + */ + public function testRejectsAnInvertedAuditRetentionGivenAsAnInterval(): void + { + $inverted = new DateInterval('P1Y'); + $inverted->invert = 1; + + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, + $inverted, + ))->getVciStatusListAuditRetention(); + } } diff --git a/tests/unit/src/Repositories/StatusAuditRepositoryTest.php b/tests/unit/src/Repositories/StatusAuditRepositoryTest.php index fc2bedd9..b905864d 100644 --- a/tests/unit/src/Repositories/StatusAuditRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusAuditRepositoryTest.php @@ -209,4 +209,95 @@ public function testGivesEachRowADistinctIdentifier(): void $this->assertCount(25, $identifiers); $this->assertSame($identifiers, array_unique($identifiers)); } + + /** + * @throws \Exception + */ + protected function recordAt(string $createdAt, int $idx = 0): void + { + $this->repository->record( + self::CREDENTIAL_ID_HASH, + self::LIST_ID, + $idx, + StatusTypeEnum::Valid->value, + StatusTypeEnum::Invalid->value, + StatusChangeSourceEnum::Api, + 'HR system', + new DateTimeImmutable($createdAt, new DateTimeZone('UTC')), + ); + } + + /** + * @throws \Exception + */ + public function testRemovesRowsOlderThanTheCutOff(): void + { + $this->recordAt('2025-01-01 09:00:00', 1); + $this->recordAt('2026-08-01 09:00:00', 2); + + $removed = $this->repository->removeOlderThan( + new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')), + 10, + ); + + $this->assertSame(1, $removed); + + $rows = $this->readRows(); + + $this->assertCount(1, $rows); + $this->assertSame(2, (int)$rows[0]['idx']); + } + + /** + * @throws \Exception + */ + public function testRemovesNoMoreThanTheGivenNumberOfRows(): void + { + for ($i = 0; $i < 5; $i++) { + $this->recordAt('2025-01-01 09:00:00', $i); + } + + $cutOff = new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')); + + $this->assertSame(2, $this->repository->removeOlderThan($cutOff, 2)); + $this->assertSame(2, $this->repository->removeOlderThan($cutOff, 2)); + $this->assertSame(1, $this->repository->removeOlderThan($cutOff, 2)); + $this->assertSame(0, $this->repository->removeOlderThan($cutOff, 2)); + } + + /** + * @throws \Exception + */ + public function testRemovesNothingWhenEveryRowIsWithinRetention(): void + { + $this->recordAt('2026-08-01 09:00:00'); + + $removed = $this->repository->removeOlderThan( + new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')), + 10, + ); + + $this->assertSame(0, $removed); + $this->assertCount(1, $this->readRows()); + } + + /** + * The cut-off is compared against a column which carries no timezone and is written in UTC, so one + * handed in on another scale would prune either too much or too little by the size of the offset. + * + * @throws \Exception + */ + public function testComparesTheCutOffInUtc(): void + { + $this->recordAt('2026-08-07 10:00:00'); + + // 11:00 in a zone two hours ahead is 09:00 UTC, which is before the row was written. + $removed = $this->repository->removeOlderThan( + new DateTimeImmutable('2026-08-07 11:00:00', new DateTimeZone('Europe/Zagreb')), + 10, + ); + + $this->assertSame(0, $removed); + $this->assertCount(1, $this->readRows()); + } } diff --git a/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php b/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php index 0e5a477a..59d1a959 100644 --- a/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php @@ -376,4 +376,208 @@ public function testCountsEachListOnceHoweverManyPermanentCredentialsItHolds(): $this->assertSame(2, $this->repository->countNeverRetiringLists()); } + + /** + * @return array + */ + protected function readEntry(int $idx, string $statusListId = self::LIST_ID): array + { + $rows = Database::getInstance()->readPrimary( + sprintf( + 'SELECT * FROM %s WHERE status_list_id = :status_list_id AND idx = :idx', + $this->repository->getTableName(), + ), + [ + 'status_list_id' => $statusListId, + 'idx' => $idx, + ], + )->fetchAll(); + + $this->assertIsArray($rows[0] ?? null); + + return $rows[0]; + } + + /** + * The four linkage columns are one fact, so they go together. Keeping any of them would leave a row + * which still says somebody was issued a credential while claiming not to know which one. + * + * @throws \Exception + */ + public function testClearsTheWholeLinkageOfAnExpiredCredential(): void + { + $this->createList(); + $this->allocate( + 0, + 'urn:vc:expired', + 'a-subject-ref', + new DateTimeImmutable('2026-01-01 09:00:00'), + new DateTimeImmutable('2026-06-01 09:00:00'), + ); + + $this->assertSame(1, $this->repository->clearExpiredLinkage(new DateTimeImmutable('2026-08-07 12:00:00'), 10)); + + $entry = $this->readEntry(0); + + $this->assertNull($entry['credential_id']); + $this->assertNull($entry['credential_id_hash']); + $this->assertNull($entry['credential_configuration_id']); + $this->assertNull($entry['subject_ref']); + } + + /** + * What is kept is what the published token is built from, and what stops the index being handed out + * to a second credential. + * + * @throws \Exception + */ + public function testKeepsTheIndexItsStatusAndItsExpiryWhenClearingLinkage(): void + { + $this->createList(); + $this->allocate( + 3, + 'urn:vc:expired', + null, + new DateTimeImmutable('2026-01-01 09:00:00'), + new DateTimeImmutable('2026-06-01 09:00:00'), + ); + $this->repository->updateStatus(self::LIST_ID, 3, StatusTypeEnum::Valid->value, StatusTypeEnum::Invalid->value); + + $this->repository->clearExpiredLinkage(new DateTimeImmutable('2026-08-07 12:00:00'), 10); + + $entry = $this->readEntry(3); + + $this->assertSame(3, (int)$entry['idx']); + $this->assertSame(StatusTypeEnum::Invalid->value, (int)$entry['status']); + $this->assertNotNull($entry['expires_at']); + // Still allocated, so the index can never be claimed again. + $this->assertNotEmpty($entry['allocated']); + } + + /** + * @throws \Exception + */ + public function testLeavesCredentialsWhichHaveNotExpiredAlone(): void + { + $this->createList(); + $this->allocate( + 0, + 'urn:vc:live', + null, + new DateTimeImmutable('2026-01-01 09:00:00'), + new DateTimeImmutable('2027-06-01 09:00:00'), + ); + + $this->assertSame(0, $this->repository->clearExpiredLinkage(new DateTimeImmutable('2026-08-07 12:00:00'), 10)); + $this->assertSame('urn:vc:live', $this->readEntry(0)['credential_id']); + } + + /** + * A credential without an expiry is one which can be presented at any point in the future, so the + * linkage which makes it revocable has to outlive every cut-off. + * + * @throws \Exception + */ + public function testNeverClearsTheLinkageOfACredentialWithoutAnExpiry(): void + { + $this->createList(); + $this->allocate(0, 'urn:vc:permanent'); + + $this->assertSame(0, $this->repository->clearExpiredLinkage(new DateTimeImmutable('2099-01-01 00:00:00'), 10)); + $this->assertSame('urn:vc:permanent', $this->readEntry(0)['credential_id']); + } + + /** + * @throws \Exception + */ + public function testClearsNoMoreThanTheGivenNumberOfLinkages(): void + { + $this->createList(); + + for ($idx = 0; $idx < 5; $idx++) { + $this->allocate( + $idx, + 'urn:vc:' . $idx, + null, + new DateTimeImmutable('2026-01-01 09:00:00'), + new DateTimeImmutable('2026-06-01 09:00:00'), + ); + } + + $now = new DateTimeImmutable('2026-08-07 12:00:00'); + + $this->assertSame(2, $this->repository->clearExpiredLinkage($now, 2)); + $this->assertSame(2, $this->repository->clearExpiredLinkage($now, 2)); + $this->assertSame(1, $this->repository->clearExpiredLinkage($now, 2)); + $this->assertSame(0, $this->repository->clearExpiredLinkage($now, 2)); + } + + /** + * An allocated row whose credential has expired names no credential any more, so there is nothing an + * administrator could ask about it or do to it. + * + * @throws \Exception + */ + public function testStopsListingEntriesWhoseLinkageHasBeenCleared(): void + { + $this->createList(); + $this->allocate( + 0, + 'urn:vc:expired', + null, + new DateTimeImmutable('2026-01-01 09:00:00'), + new DateTimeImmutable('2026-06-01 09:00:00'), + ); + $this->allocate(1, 'urn:vc:live', null, new DateTimeImmutable('2026-01-02 09:00:00')); + + $this->repository->clearExpiredLinkage(new DateTimeImmutable('2026-08-07 12:00:00'), 10); + + $page = $this->repository->findAllocatedPaginated(); + + $this->assertSame(1, $page['total']); + $this->assertSame(['urn:vc:live'], $this->credentialIdsOf($page['items'])); + } + + protected function countEntriesOf(string $statusListId): int + { + $rows = Database::getInstance()->readPrimary( + sprintf( + 'SELECT COUNT(*) AS entry_total FROM %s WHERE status_list_id = :status_list_id', + $this->repository->getTableName(), + ), + ['status_list_id' => $statusListId], + )->fetchAll(); + + return (int)$rows[0]['entry_total']; + } + + /** + * @throws \Exception + */ + public function testRemovesRetiredEntriesInBoundedRuns(): void + { + $this->createList(); + + $this->assertSame(3, $this->repository->deleteRetiredEntries(self::LIST_ID, 3)); + $this->assertSame(self::CAPACITY - 3, $this->countEntriesOf(self::LIST_ID)); + + $this->assertSame(3, $this->repository->deleteRetiredEntries(self::LIST_ID, 3)); + $this->assertSame(2, $this->repository->deleteRetiredEntries(self::LIST_ID, 3)); + $this->assertSame(0, $this->repository->deleteRetiredEntries(self::LIST_ID, 3)); + $this->assertSame(0, $this->countEntriesOf(self::LIST_ID)); + } + + /** + * @throws \Exception + */ + public function testRemovesEntriesOfOneListOnly(): void + { + $this->createList(); + $this->createList(self::OTHER_LIST_ID, 2); + + $this->repository->deleteRetiredEntries(self::LIST_ID, self::CAPACITY); + + $this->assertSame(0, $this->countEntriesOf(self::LIST_ID)); + $this->assertSame(self::CAPACITY, $this->countEntriesOf(self::OTHER_LIST_ID)); + } } diff --git a/tests/unit/src/Repositories/StatusListRepositoryTest.php b/tests/unit/src/Repositories/StatusListRepositoryTest.php new file mode 100644 index 00000000..dc52c4f9 --- /dev/null +++ b/tests/unit/src/Repositories/StatusListRepositoryTest.php @@ -0,0 +1,608 @@ + 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ], + '', + 'simplesaml', + ); + + (new DatabaseMigration())->migrate(); + } + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->helpers = new Helpers(); + + $this->repository = new StatusListRepository( + $this->moduleConfigMock, + Database::getInstance(), + null, + $this->helpers, + ); + + $this->entryRepository = new StatusListEntryRepository( + $this->moduleConfigMock, + Database::getInstance(), + null, + $this->helpers, + ); + + Database::getInstance()->write(sprintf('DELETE FROM %s', $this->entryRepository->getTableName())); + Database::getInstance()->write(sprintf('DELETE FROM %s', $this->repository->getTableName())); + } + + /** + * @throws \Exception + */ + protected function createList( + string $id = self::LIST_ID, + int $generation = 1, + string $poolId = self::POOL_ID, + string $policyFingerprint = self::POLICY, + ): void { + $this->repository->create( + $id, + 'https://op.example.org/statuslist/' . $id, + $poolId, + $policyFingerprint, + $generation, + 1, + self::CAPACITY, + implode(',', [StatusTypeEnum::Valid->value, StatusTypeEnum::Invalid->value]), + 43200, + 604800, + 3600, + 'a-signing-key-id', + StatusListKeyProfileEnum::DidJwk, + ); + $this->repository->activate($id); + } + + /** + * Deactivation is stamped with the moment it happened, which is now, and the retirement candidate + * query looks for lists deactivated before a cut-off. Backdating the column is how a test says a + * list has been sitting deactivated for a while. + * + * @throws \Exception + */ + protected function backdateDeactivation(string $id, string $deactivatedAt): void + { + Database::getInstance()->write( + sprintf('UPDATE %s SET deactivated_at = :deactivated_at WHERE id = :id', $this->repository->getTableName()), + [ + 'deactivated_at' => $deactivatedAt, + 'id' => $id, + ], + ); + } + + /** + * A moment far enough ahead that any expiry a test sets is behind it, for the cases which are not + * about the expiry guard itself. + */ + protected function spentBefore(): DateTimeImmutable + { + return new DateTimeImmutable('2099-01-01 00:00:00'); + } + + /** + * A list is only ever selected for allocation while its pool and policy fingerprint match the + * current configuration, so one created under a policy which has since changed is unreachable. It + * would never fill, so nothing would ever deactivate it, and every later step of the lifecycle + * begins with deactivation. + * + * @throws \Exception + */ + public function testDeactivatesListsCreatedUnderASupersededPolicy(): void + { + $this->createList(self::LIST_ID, 1, self::POOL_ID, 'the-old-policy'); + + $this->assertSame(1, $this->repository->deactivateSuperseded([self::POOL_ID => 'the-current-policy'])); + + $statusList = $this->repository->findByIdOnPrimary(self::LIST_ID); + + $this->assertFalse($statusList?->isActive()); + $this->assertInstanceOf(DateTimeImmutable::class, $statusList?->getDeactivatedAt()); + } + + /** + * @throws \Exception + */ + public function testLeavesListsOfTheCurrentPolicyActive(): void + { + $this->createList(); + + $this->assertSame(0, $this->repository->deactivateSuperseded([self::POOL_ID => self::POLICY])); + $this->assertTrue($this->repository->findByIdOnPrimary(self::LIST_ID)?->isActive()); + } + + /** + * @throws \Exception + */ + public function testDeactivatesListsOfAPoolWhichIsNoLongerConfigured(): void + { + $this->createList(self::LIST_ID, 1, 'a-removed-pool'); + + $this->assertSame(1, $this->repository->deactivateSuperseded([self::POOL_ID => self::POLICY])); + $this->assertFalse($this->repository->findByIdOnPrimary(self::LIST_ID)?->isActive()); + } + + /** + * @throws \Exception + */ + public function testDeactivatesEverythingWhenNoPoolIsConfigured(): void + { + $this->createList(); + $this->createList(self::OTHER_LIST_ID, 2); + + $this->assertSame(2, $this->repository->deactivateSuperseded([])); + } + + /** + * A list is created inactive and stays that way while its entries are seeded. Stamping one of those + * as deactivated would move it out of the path which deletes an abandoned seed and into the one + * which retires a list that was served. + * + * @throws \Exception + */ + public function testLeavesListsWhichAreStillBeingSeededAlone(): void + { + $this->repository->create( + self::LIST_ID, + 'https://op.example.org/statuslist/' . self::LIST_ID, + self::POOL_ID, + 'the-old-policy', + 1, + 1, + self::CAPACITY, + (string)StatusTypeEnum::Invalid->value, + 43200, + 604800, + 3600, + 'a-signing-key-id', + StatusListKeyProfileEnum::DidJwk, + ); + + $this->assertSame(0, $this->repository->deactivateSuperseded([self::POOL_ID => 'the-current-policy'])); + $this->assertNull($this->repository->findByIdOnPrimary(self::LIST_ID)?->getDeactivatedAt()); + } + + /** + * @throws \Exception + */ + public function testFindsListsDeactivatedBeforeTheCutOff(): void + { + $this->createList(); + $this->repository->deactivate(self::LIST_ID); + $this->backdateDeactivation(self::LIST_ID, '2026-01-01 09:00:00'); + + $this->assertSame( + [self::LIST_ID], + $this->repository->findRetirementCandidates(new DateTimeImmutable('2026-08-07 12:00:00'), 10), + ); + } + + /** + * @throws \Exception + */ + public function testDoesNotOfferListsDeactivatedTooRecently(): void + { + $this->createList(); + $this->repository->deactivate(self::LIST_ID); + + $this->assertSame( + [], + $this->repository->findRetirementCandidates(new DateTimeImmutable('2020-01-01 00:00:00'), 10), + ); + } + + /** + * @throws \Exception + */ + public function testDoesNotOfferListsWhichAreStillActive(): void + { + $this->createList(); + + $this->assertSame( + [], + $this->repository->findRetirementCandidates(new DateTimeImmutable('2099-01-01 00:00:00'), 10), + ); + } + + /** + * Inactive with no deactivation stamp is a list whose entries are still being seeded, or one whose + * seeding was abandoned. Neither is retired; an abandoned one is deleted outright. + * + * @throws \Exception + */ + public function testDoesNotOfferListsWhichWereNeverOpened(): void + { + $this->repository->create( + self::LIST_ID, + 'https://op.example.org/statuslist/' . self::LIST_ID, + self::POOL_ID, + self::POLICY, + 1, + 1, + self::CAPACITY, + (string)StatusTypeEnum::Invalid->value, + 43200, + 604800, + 3600, + 'a-signing-key-id', + StatusListKeyProfileEnum::DidJwk, + ); + + $this->assertSame( + [], + $this->repository->findRetirementCandidates(new DateTimeImmutable('2099-01-01 00:00:00'), 10), + ); + } + + /** + * A list holding a credential without an expiry is not waiting for anything -- it can never be + * retired. Leaving it among the candidates would let a deployment with enough of them fill every + * batch a run works through and starve the lists behind them, and since every run starts from the + * beginning that would not correct itself. + * + * @throws \Exception + */ + public function testDoesNotOfferListsWhichCanNeverBeRetired(): void + { + $this->createList(); + $this->entryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->entryRepository->allocate( + self::LIST_ID, + 0, + 'urn:vc:permanent', + $this->entryRepository->hashCredentialId('urn:vc:permanent'), + 'UniversityDegree', + null, + null, + ); + $this->repository->deactivate(self::LIST_ID); + $this->backdateDeactivation(self::LIST_ID, '2026-01-01 09:00:00'); + + $this->assertSame( + [], + $this->repository->findRetirementCandidates(new DateTimeImmutable('2026-08-07 12:00:00'), 10), + ); + } + + /** + * Unallocated entries have no expiry either, and there is one for every index of the list from the + * moment it is created, so an exclusion which did not filter on allocation would rule out every + * list there is. + * + * @throws \Exception + */ + public function testStillOffersAListWhoseUnusedIndicesHaveNoExpiry(): void + { + $this->createList(); + $this->entryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->entryRepository->allocate( + self::LIST_ID, + 0, + 'urn:vc:expiring', + $this->entryRepository->hashCredentialId('urn:vc:expiring'), + 'UniversityDegree', + null, + new DateTimeImmutable('2026-06-01 09:00:00'), + ); + $this->repository->deactivate(self::LIST_ID); + $this->backdateDeactivation(self::LIST_ID, '2026-01-01 09:00:00'); + + $this->assertSame( + [self::LIST_ID], + $this->repository->findRetirementCandidates(new DateTimeImmutable('2026-08-07 12:00:00'), 10), + ); + } + + /** + * @throws \Exception + */ + public function testDoesNotOfferListsWhichAreAlreadyRetired(): void + { + $this->createList(); + $this->repository->deactivate(self::LIST_ID); + $this->backdateDeactivation(self::LIST_ID, '2026-01-01 09:00:00'); + $this->repository->retire(self::LIST_ID, $this->spentBefore()); + + $this->assertSame( + [], + $this->repository->findRetirementCandidates(new DateTimeImmutable('2026-08-07 12:00:00'), 10), + ); + } + + /** + * Retiring a list takes it out of the set being paged through, so an offset would step over exactly + * as many unexamined lists as were retired. + * + * @throws \Exception + */ + public function testPagesRetirementCandidatesByCursor(): void + { + $this->createList('list-a'); + $this->createList('list-b', 2); + $this->createList('list-c', 3); + + foreach (['list-a', 'list-b', 'list-c'] as $id) { + $this->repository->deactivate($id); + $this->backdateDeactivation($id, '2026-01-01 09:00:00'); + } + + $cutOff = new DateTimeImmutable('2026-08-07 12:00:00'); + + $this->assertSame(['list-a', 'list-b'], $this->repository->findRetirementCandidates($cutOff, 2)); + $this->assertSame(['list-c'], $this->repository->findRetirementCandidates($cutOff, 2, 'list-b')); + } + + /** + * @throws \Exception + */ + public function testRetirementStampsTheListAndGivesBackItsToken(): void + { + $this->createList(); + $this->repository->publishToken( + self::LIST_ID, + '', + 0, + 'a-content-hash', + 'a.signed.token', + new DateTimeImmutable('2026-08-01 09:00:00'), + new DateTimeImmutable('2026-08-08 09:00:00'), + ); + $this->repository->deactivate(self::LIST_ID); + + $this->assertTrue($this->repository->retire(self::LIST_ID, $this->spentBefore())); + + $statusList = $this->repository->findByIdOnPrimary(self::LIST_ID); + + $this->assertTrue($statusList?->isRetired()); + $this->assertNull($statusList?->getSignedToken()); + $this->assertSame('', $statusList?->getSignedTokenContentHash()); + } + + /** + * The counter is what keeps a signer which is mid-flight from publishing a token onto a list which + * has just been retired out from under it. + * + * @throws \Exception + */ + public function testRetirementMovesTheInvalidationCounter(): void + { + $this->createList(); + $this->repository->deactivate(self::LIST_ID); + + $before = $this->repository->findByIdOnPrimary(self::LIST_ID)?->getInvalidationCounter(); + + $this->repository->retire(self::LIST_ID, $this->spentBefore()); + + $this->assertSame( + (int)$before + 1, + $this->repository->findByIdOnPrimary(self::LIST_ID)?->getInvalidationCounter(), + ); + } + + /** + * @throws \Exception + */ + public function testRefusesToRetireAListWhichIsStillActive(): void + { + $this->createList(); + + $this->assertFalse($this->repository->retire(self::LIST_ID, $this->spentBefore())); + $this->assertFalse($this->repository->findByIdOnPrimary(self::LIST_ID)?->isRetired()); + } + + /** + * Of several workers deciding at the same moment, only one should report having retired it. + * + * @throws \Exception + */ + public function testOnlyTheFirstCallRetiresAList(): void + { + $this->createList(); + $this->repository->deactivate(self::LIST_ID); + + $this->assertTrue($this->repository->retire(self::LIST_ID, $this->spentBefore())); + $this->assertFalse($this->repository->retire(self::LIST_ID, $this->spentBefore())); + } + + /** + * @throws \Exception + */ + protected function allocateEntry(int $idx, string $credentialId, ?DateTimeImmutable $expiresAt): void + { + $this->entryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->entryRepository->allocate( + self::LIST_ID, + $idx, + $credentialId, + $this->entryRepository->hashCredentialId($credentialId), + 'UniversityDegree', + null, + $expiresAt, + ); + } + + /** + * The whole point of testing the expiry inside the retiring statement: a caller which read the + * entries, decided they had all expired, and retired the list in a second statement would leave a + * gap that an issuance already in flight could land in, and there are no transactions to close it. + * + * @throws \Exception + */ + public function testRefusesToRetireAListWhichStillHoldsALiveCredential(): void + { + $this->createList(); + $this->allocateEntry(0, 'urn:vc:live', new DateTimeImmutable('2027-06-01 09:00:00')); + $this->repository->deactivate(self::LIST_ID); + + $this->assertFalse( + $this->repository->retire(self::LIST_ID, new DateTimeImmutable('2026-08-07 12:00:00')), + ); + $this->assertFalse($this->repository->findByIdOnPrimary(self::LIST_ID)?->isRetired()); + } + + /** + * @throws \Exception + */ + public function testRetiresAListWhoseCredentialsHaveAllExpired(): void + { + $this->createList(); + $this->allocateEntry(0, 'urn:vc:expired', new DateTimeImmutable('2026-01-01 09:00:00')); + $this->repository->deactivate(self::LIST_ID); + + $this->assertTrue( + $this->repository->retire(self::LIST_ID, new DateTimeImmutable('2026-08-07 12:00:00')), + ); + } + + /** + * @throws \Exception + */ + public function testNeverRetiresAListHoldingACredentialWithoutAnExpiry(): void + { + $this->createList(); + $this->allocateEntry(0, 'urn:vc:permanent', null); + $this->repository->deactivate(self::LIST_ID); + + $this->assertFalse( + $this->repository->retire(self::LIST_ID, new DateTimeImmutable('2099-01-01 00:00:00')), + ); + } + + /** + * Every index exists as a row from the moment the list is created and an unallocated one has no + * expiry, so a guard which did not filter on allocation would refuse to retire any list at all. + * + * @throws \Exception + */ + public function testTheUnusedIndicesOfAListDoNotKeepItFromRetiring(): void + { + $this->createList(); + $this->entryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->repository->deactivate(self::LIST_ID); + + $this->assertTrue( + $this->repository->retire(self::LIST_ID, new DateTimeImmutable('2026-08-07 12:00:00')), + ); + } + + /** + * @throws \Exception + */ + public function testFindsRetiredListsWhichStillHaveEntries(): void + { + $this->createList(); + $this->entryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->repository->deactivate(self::LIST_ID); + $this->repository->retire(self::LIST_ID, $this->spentBefore()); + + $this->assertSame([self::LIST_ID], $this->repository->findRetiredWithEntries(10, $this->spentBefore())); + } + + /** + * Removing the entries is bounded, so the same list is found again by run after run. Once it has + * none left it has to drop out, otherwise every list a deployment ever retired would be re-examined + * for ever. + * + * @throws \Exception + */ + public function testStopsOfferingARetiredListOnceItsEntriesAreGone(): void + { + $this->createList(); + $this->entryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->repository->deactivate(self::LIST_ID); + $this->repository->retire(self::LIST_ID, $this->spentBefore()); + $this->entryRepository->deleteRetiredEntries(self::LIST_ID, self::CAPACITY); + + $this->assertSame([], $this->repository->findRetiredWithEntries(10, $this->spentBefore())); + } + + /** + * Retirement can not be serialised against an issuance which was already in flight, so a credential + * can in principle be written into a list just after it was retired. Retirement alone leaves that + * credential unverifiable; removing the entries too would leave nothing to show it was ever issued. + * + * @throws \Exception + */ + public function testDoesNotOfferAListRetiredTooRecentlyToPurge(): void + { + $this->createList(); + $this->entryRepository->seed(self::LIST_ID, self::CAPACITY); + $this->repository->deactivate(self::LIST_ID); + $this->repository->retire(self::LIST_ID, $this->spentBefore()); + + // Retired just now, so a cut-off in the past excludes it. + $this->assertSame( + [], + $this->repository->findRetiredWithEntries(10, new DateTimeImmutable('2020-01-01 00:00:00')), + ); + } + + /** + * @throws \Exception + */ + public function testDoesNotOfferEntriesOfListsWhichAreNotRetired(): void + { + $this->createList(); + $this->entryRepository->seed(self::LIST_ID, self::CAPACITY); + + $this->assertSame([], $this->repository->findRetiredWithEntries(10, $this->spentBefore())); + } +} diff --git a/tests/unit/src/StatusList/StatusListLifecycleTest.php b/tests/unit/src/StatusList/StatusListLifecycleTest.php new file mode 100644 index 00000000..36f1cc6d --- /dev/null +++ b/tests/unit/src/StatusList/StatusListLifecycleTest.php @@ -0,0 +1,412 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusAuditRepositoryMock = $this->createMock(StatusAuditRepository::class); + $this->statusListKeyResolverMock = $this->createMock(StatusListKeyResolver::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->helpers = new Helpers(); + + $this->moduleConfigMock->method('getVciStatusListEnabled')->willReturn(true); + $this->moduleConfigMock->method('getVciStatusListRetirementGrace')->willReturn(new DateInterval('P30D')); + $this->moduleConfigMock->method('getVciStatusListAuditRetention')->willReturn(null); + $this->moduleConfigMock->method('getVciStatusListPoolBag')->willReturn(new StatusListPoolBag()); + $this->statusListKeyResolverMock->method('getCurrentKeyId')->willReturn(self::SIGNING_KEY_ID); + + // Nothing to do, unless a test says otherwise. + $this->statusListEntryRepositoryMock->method('clearExpiredLinkage')->willReturn(0); + $this->statusListRepositoryMock->method('deactivateSuperseded')->willReturn(0); + $this->statusListRepositoryMock->method('findRetirementCandidates')->willReturn([]); + $this->statusListRepositoryMock->method('findRetiredWithEntries')->willReturn([]); + $this->statusAuditRepositoryMock->method('removeOlderThan')->willReturn(0); + } + + protected function sut(): StatusListLifecycle + { + return new StatusListLifecycle( + $this->moduleConfigMock, + $this->statusListRepositoryMock, + $this->statusListEntryRepositoryMock, + $this->statusAuditRepositoryMock, + $this->statusListKeyResolverMock, + $this->helpers, + $this->loggerServiceMock, + ); + } + + /** + * @throws \Exception + */ + protected function pool(string $id = 'default'): StatusListPool + { + return new StatusListPool( + $id, + ['UniversityDegree'], + 1, + 8, + [StatusTypeEnum::Valid, StatusTypeEnum::Invalid], + new DateInterval('PT12H'), + new DateInterval('P7D'), + new DateInterval('PT1H'), + StatusListKeyProfileEnum::DidJwk, + ); + } + + /** + * @throws \Exception + */ + public function testKeepsClearingLinkageWhileBatchesComeBackFull(): void + { + $batches = [500, 500, 120]; + + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('clearExpiredLinkage') + ->willReturnCallback(static function () use (&$batches): int { + return (int)(array_shift($batches) ?? 0); + }); + + $this->assertSame(1120, $this->sut()->clearExpiredCredentialLinkage()); + } + + /** + * @throws \Exception + */ + public function testStopsClearingLinkageAsSoonAsABatchComesBackShort(): void + { + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->expects($this->once()) + ->method('clearExpiredLinkage') + ->willReturn(3); + + $this->assertSame(3, $this->sut()->clearExpiredCredentialLinkage()); + } + + /** + * @throws \Exception + */ + public function testDeactivatesListsWhosePolicyIsNoLongerCurrent(): void + { + $pool = $this->pool(); + + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciStatusListEnabled')->willReturn(true); + $this->moduleConfigMock->method('getVciStatusListPoolBag')->willReturn(new StatusListPoolBag($pool)); + + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->expects($this->once()) + ->method('deactivateSuperseded') + ->with(['default' => $pool->getPolicyFingerprint(self::SIGNING_KEY_ID)]) + ->willReturn(2); + + $this->assertSame(2, $this->sut()->deactivateSupersededStatusLists()); + } + + /** + * An operator who has switched the feature off for a moment has not asked for every list they have + * to start winding down, and switching it back on would not undo it. + * + * @throws \Exception + */ + public function testDeactivatesNothingWhileStatusListsAreSwitchedOff(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciStatusListEnabled')->willReturn(false); + + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->expects($this->never())->method('deactivateSuperseded'); + + $this->assertSame(0, $this->sut()->deactivateSupersededStatusLists()); + } + + /** + * @throws \Exception + */ + public function testRetiresACandidateAndAsksForItToBeSpentAsOfTheGraceCutOff(): void + { + $spentBefore = null; + + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->method('findRetirementCandidates') + ->willReturnOnConsecutiveCalls([self::LIST_ID], []); + $this->statusListRepositoryMock->expects($this->once()) + ->method('retire') + ->willReturnCallback( + static function (string $id, DateTimeImmutable $moment) use (&$spentBefore): bool { + $spentBefore = $moment; + + return $id === self::LIST_ID; + }, + ); + + $this->assertSame(1, $this->sut()->retireSpentStatusLists()); + // A month back, since that is the configured grace, so a credential which lapsed yesterday still + // holds the list. + $this->assertInstanceOf(DateTimeImmutable::class, $spentBefore); + $this->assertLessThan($this->helpers->dateTime()->getUtc(), $spentBefore); + } + + /** + * Whether anything is still holding the list is decided by the statement which retires it, not by a + * read beforehand. A list which stopped qualifying in between -- an issuance which was already in + * flight, say -- matches no rows, and this must not be counted as a retirement. + * + * @throws \Exception + */ + public function testDoesNotCountAListTheRetiringStatementRefused(): void + { + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->method('findRetirementCandidates') + ->willReturnOnConsecutiveCalls([self::LIST_ID], []); + $this->statusListRepositoryMock->method('retire')->willReturn(false); + + $this->assertSame(0, $this->sut()->retireSpentStatusLists()); + } + + /** + * Nothing is read from the entries here. Deciding first and retiring second leaves a gap, and there + * are no transactions to close it with. + * + * @throws \Exception + */ + public function testDoesNotReadTheEntriesBeforeRetiring(): void + { + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->method('findRetirementCandidates') + ->willReturnOnConsecutiveCalls([self::LIST_ID], []); + $this->statusListRepositoryMock->method('retire')->willReturn(true); + + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->expects($this->never())->method('findNonValidStatuses'); + $this->statusListEntryRepositoryMock->expects($this->never())->method('countAllocated'); + + $this->assertSame(1, $this->sut()->retireSpentStatusLists()); + } + + /** + * Retiring a list takes it out of the set being paged through, so the next query has to resume after + * the last identifier seen rather than at a numeric offset. + * + * @throws \Exception + */ + public function testPagesRetirementCandidatesByCursor(): void + { + $seenCursors = []; + + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->method('findRetirementCandidates')->willReturnCallback( + static function (DateTimeImmutable $before, int $limit, ?string $afterId) use (&$seenCursors): array { + $seenCursors[] = $afterId; + + return $afterId === null ? array_map( + static fn(int $position): string => sprintf('list-%03d', $position), + range(1, $limit), + ) : []; + }, + ); + $this->statusListRepositoryMock->method('retire')->willReturn(false); + + $this->sut()->retireSpentStatusLists(); + + $this->assertSame([null, 'list-100'], $seenCursors); + } + + /** + * @throws \Exception + */ + public function testRemovesTheEntriesOfRetiredListsUntilNoneAreLeft(): void + { + $batches = [1000, 1000, 250]; + + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->method('findRetiredWithEntries')->willReturn([self::LIST_ID]); + + $this->statusListEntryRepositoryMock->method('deleteRetiredEntries') + ->willReturnCallback(static function () use (&$batches): int { + return (int)(array_shift($batches) ?? 0); + }); + + $this->assertSame(2250, $this->sut()->purgeRetiredStatusListEntries()); + } + + /** + * @throws \Exception + */ + public function testRemovesNoEntriesWhenNoListHasBeenRetired(): void + { + $this->statusListEntryRepositoryMock->expects($this->never())->method('deleteRetiredEntries'); + + $this->assertSame(0, $this->sut()->purgeRetiredStatusListEntries()); + } + + /** + * A list retired a moment ago is left alone. Retirement can not be serialised against an issuance + * which was already in flight, so a credential can land in a list just after it was retired -- + * unverifiable, but at least still on record until these rows go too. + * + * @throws \Exception + */ + public function testAsksOnlyForListsRetiredLongerAgoThanTheGracePeriod(): void + { + $retiredBefore = null; + + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->method('findRetiredWithEntries')->willReturnCallback( + static function (int $limit, DateTimeImmutable $moment) use (&$retiredBefore): array { + $retiredBefore = $moment; + + return []; + }, + ); + + $this->sut()->purgeRetiredStatusListEntries(); + + $this->assertInstanceOf(DateTimeImmutable::class, $retiredBefore); + // A month back, since that is the configured grace. + $this->assertLessThan($this->helpers->dateTime()->getUtc(), $retiredBefore); + } + + /** + * How long a record of who revoked what needs keeping follows from the deployment's own obligations, + * so nothing is discarded unless an operator has said how long is long enough. + * + * @throws \Exception + */ + public function testPrunesNoAuditRowsWithoutAConfiguredRetention(): void + { + $this->statusAuditRepositoryMock = $this->createMock(StatusAuditRepository::class); + $this->statusAuditRepositoryMock->expects($this->never())->method('removeOlderThan'); + + $this->assertSame(0, $this->sut()->pruneStatusAuditTrail()); + } + + /** + * @throws \Exception + */ + public function testPrunesAuditRowsOlderThanTheRetention(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciStatusListAuditRetention')->willReturn(new DateInterval('P1Y')); + + $cutOff = null; + + $this->statusAuditRepositoryMock = $this->createMock(StatusAuditRepository::class); + $this->statusAuditRepositoryMock->method('removeOlderThan')->willReturnCallback( + static function (DateTimeImmutable $createdBefore) use (&$cutOff): int { + $cutOff = $createdBefore; + + return 7; + }, + ); + + $this->assertSame(7, $this->sut()->pruneStatusAuditTrail()); + $this->assertInstanceOf(DateTimeImmutable::class, $cutOff); + $this->assertLessThan($this->helpers->dateTime()->getUtc(), $cutOff); + } + + /** + * @throws \Exception + */ + public function testRunReportsWhatEachStepGotThrough(): void + { + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->method('clearExpiredLinkage')->willReturn(4); + $this->statusListEntryRepositoryMock->method('deleteRetiredEntries') + ->willReturnOnConsecutiveCalls(6, 0); + + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->method('deactivateSuperseded')->willReturn(2); + $this->statusListRepositoryMock->method('findRetirementCandidates') + ->willReturnOnConsecutiveCalls([self::LIST_ID], []); + $this->statusListRepositoryMock->method('retire')->willReturn(true); + $this->statusListRepositoryMock->method('findRetiredWithEntries')->willReturn([self::LIST_ID]); + + $report = $this->sut()->run(); + + $this->assertSame(4, $report->getClearedLinkages()); + $this->assertSame(2, $report->getDeactivatedStatusLists()); + $this->assertSame(1, $report->getRetiredStatusLists()); + $this->assertSame(6, $report->getPurgedEntries()); + $this->assertSame(0, $report->getPrunedAuditRows()); + $this->assertSame([], $report->getFailures()); + $this->assertTrue($report->hasChanges()); + } + + /** + * The steps share tables but not purposes, and one of them is an undertaking made to the people the + * credentials were issued to. A failure elsewhere must not quietly suspend it. + * + * @throws \Exception + */ + public function testRunCarriesOnAfterAStepFails(): void + { + $this->statusListRepositoryMock = $this->createMock(StatusListRepository::class); + $this->statusListRepositoryMock->method('deactivateSuperseded') + ->willThrowException(new RuntimeException('the database went away')); + $this->statusListRepositoryMock->method('findRetirementCandidates')->willReturn([]); + $this->statusListRepositoryMock->method('findRetiredWithEntries')->willReturn([]); + + $this->statusListEntryRepositoryMock = $this->createMock(StatusListEntryRepository::class); + $this->statusListEntryRepositoryMock->expects($this->once()) + ->method('clearExpiredLinkage') + ->willReturn(9); + + $report = $this->sut()->run(); + + $this->assertSame(9, $report->getClearedLinkages()); + $this->assertSame(0, $report->getDeactivatedStatusLists()); + $this->assertCount(1, $report->getFailures()); + $this->assertStringContainsString('the database went away', $report->getFailures()[0]); + } + + /** + * @throws \Exception + */ + public function testRunOfADeploymentWithNothingToDoReportsNoChanges(): void + { + $this->assertFalse($this->sut()->run()->hasChanges()); + } +} diff --git a/tests/unit/src/StatusList/Values/StatusListLifecycleReportTest.php b/tests/unit/src/StatusList/Values/StatusListLifecycleReportTest.php new file mode 100644 index 00000000..69b80129 --- /dev/null +++ b/tests/unit/src/StatusList/Values/StatusListLifecycleReportTest.php @@ -0,0 +1,51 @@ +assertSame(1, $report->getClearedLinkages()); + $this->assertSame(2, $report->getDeactivatedStatusLists()); + $this->assertSame(3, $report->getRetiredStatusLists()); + $this->assertSame(4, $report->getPurgedEntries()); + $this->assertSame(5, $report->getPrunedAuditRows()); + $this->assertSame(['something went wrong'], $report->getFailures()); + } + + /** + * A cron which had nothing to do should say nothing, rather than adding a line reporting five zeroes + * to every run of every deployment. + */ + public function testARunWhichChangedNothingSaysSo(): void + { + $this->assertFalse((new StatusListLifecycleReport())->hasChanges()); + } + + public function testAnyStepGettingSomethingDoneCounts(): void + { + $this->assertTrue((new StatusListLifecycleReport(0, 0, 0, 0, 1))->hasChanges()); + $this->assertTrue((new StatusListLifecycleReport(1))->hasChanges()); + } + + /** + * A run which got nothing done because everything failed still has something to report. + */ + public function testFailuresAreCarriedEvenWhenNothingChanged(): void + { + $report = new StatusListLifecycleReport(failures: ['Pruning the status audit trail failed: nope']); + + $this->assertFalse($report->hasChanges()); + $this->assertCount(1, $report->getFailures()); + } +} From abfeb67e2dfcc636bcb9c6ccf8ea9f58bf750b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 8 Aug 2026 11:56:17 +0200 Subject: [PATCH 8/9] Build batched statements to the lowest driver's bound variable limit --- .../AbstractDatabaseRepository.php | 33 +++++ src/Repositories/StatusAuditRepository.php | 49 +++++--- .../StatusListEntryRepository.php | 91 ++++++++------ src/StatusList/StatusListLifecycle.php | 11 +- .../AbstractDatabaseRepositoryTest.php | 48 ++++++++ .../StatusAuditRepositoryTest.php | 73 +++++++++++ .../StatusListEntryRepositoryTest.php | 113 ++++++++++++++++++ 7 files changed, 358 insertions(+), 60 deletions(-) diff --git a/src/Repositories/AbstractDatabaseRepository.php b/src/Repositories/AbstractDatabaseRepository.php index 9434eafb..f778fb64 100644 --- a/src/Repositories/AbstractDatabaseRepository.php +++ b/src/Repositories/AbstractDatabaseRepository.php @@ -21,6 +21,21 @@ abstract class AbstractDatabaseRepository { + /** + * Bound variables a single statement may carry. + * + * SQLite refuses a statement binding more than 999 of them unless it was compiled with a higher + * ceiling, which only became the default in 3.32. MySQL and PostgreSQL both allow 65535, so the + * oldest SQLite a deployment might be running is what decides this for all three drivers: a + * statement built to this ceiling runs everywhere, and one built to MySQL's does not. + * + * The ceiling is per statement rather than per row, so it is only ever reached by statements which + * name their rows individually -- a multi row INSERT, or an UPDATE or DELETE listing keys. Those + * split themselves against it via maxRowsPerStatement() instead of each choosing a batch size and + * hoping it was chosen under the right limit. + */ + protected const int MAX_BOUND_VARIABLES = 999; + /** * ClientRepository constructor. * @throws \Exception @@ -39,5 +54,23 @@ public function getCacheKey(string $identifier): string $identifier; } + /** + * How many rows a batched statement can name before it has to be split. + * + * Deriving this from MAX_BOUND_VARIABLES rather than writing a batch size by hand is what keeps the + * two from drifting apart: a hand picked number is correct only for the statement it was picked for, + * and stays behind when a column is added to it. Callers batch by the result rather than clamping + * what they were asked for, so a bound handed in by a caller still means the number of rows worked, + * however many statements that takes. + * + * @param positive-int $perRow Bound variables each named row contributes. + * @param int $fixed Bound variables the statement carries however many rows it names. + * @return positive-int + */ + protected function maxRowsPerStatement(int $perRow, int $fixed = 0): int + { + return max(1, intdiv(self::MAX_BOUND_VARIABLES - $fixed, $perRow)); + } + abstract public function getTableName(): ?string; } diff --git a/src/Repositories/StatusAuditRepository.php b/src/Repositories/StatusAuditRepository.php index 3540990a..c639c21a 100644 --- a/src/Repositories/StatusAuditRepository.php +++ b/src/Repositories/StatusAuditRepository.php @@ -138,9 +138,8 @@ public function removeOlderThan(DateTimeImmutable $createdBefore, int $limit): i ['created_before' => $this->formatForDatabase($createdBefore)], )->fetchAll(); - $placeholders = []; - $params = []; - $position = 0; + /** @var list $ids */ + $ids = []; /** @var mixed $row */ foreach ($rows as $row) { @@ -151,27 +150,41 @@ public function removeOlderThan(DateTimeImmutable $createdBefore, int $limit): i continue; } - // Each identifier under its own placeholder name, since a repeated one is not portable - // across drivers. - $placeholders[] = ':id_' . $position; - $params['id_' . $position] = (string)$id; - $position++; + $ids[] = (string)$id; } - if ($placeholders === []) { + if ($ids === []) { return 0; } - $affected = $this->database->write( - sprintf( - 'DELETE FROM %s WHERE id IN (%s)', - $this->getTableName(), - implode(', ', $placeholders), - ), - $params, - ); + $removed = 0; + + // One placeholder per identifier named, so a bound larger than the ceiling is answered in as + // many deletes as it takes rather than being cut down to one statement's worth. + foreach (array_chunk($ids, $this->maxRowsPerStatement(1)) as $chunk) { + $placeholders = []; + $params = []; + + foreach ($chunk as $position => $id) { + // Each identifier under its own placeholder name, since a repeated one is not portable + // across drivers. + $placeholders[] = ':id_' . $position; + $params['id_' . $position] = $id; + } + + $affected = $this->database->write( + sprintf( + 'DELETE FROM %s WHERE id IN (%s)', + $this->getTableName(), + implode(', ', $placeholders), + ), + $params, + ); + + $removed += is_int($affected) ? $affected : 0; + } - return is_int($affected) ? $affected : 0; + return $removed; } /** diff --git a/src/Repositories/StatusListEntryRepository.php b/src/Repositories/StatusListEntryRepository.php index 7abe798b..26b28b31 100644 --- a/src/Repositories/StatusListEntryRepository.php +++ b/src/Repositories/StatusListEntryRepository.php @@ -35,15 +35,6 @@ class StatusListEntryRepository extends AbstractDatabaseRepository { final public const string TABLE_NAME = 'oidc_status_list_entry'; - /** - * Rows inserted per statement while seeding a list. - * - * Two placeholders per row, so this stays an order of magnitude under the 65535 a MySQL prepared - * statement allows, while keeping the number of round trips for a default sized list in the - * hundreds rather than the hundred thousands. - */ - protected const int SEED_BATCH_SIZE = 500; - public function __construct( ModuleConfig $moduleConfig, Database $database, @@ -77,12 +68,19 @@ public function hashCredentialId(string $credentialId): string * Only the two key columns are written; `allocated` and `status` take their column defaults, which * halves the statement size and keeps the defaults defined in exactly one place. * + * This is the largest statement the module builds -- a list of default capacity is a hundred and + * thirty thousand rows -- so it is also the first place a driver's limit on bound variables is met. + * * @throws \Exception */ public function seed(string $statusListId, int $capacity): void { - for ($offset = 0; $offset < $capacity; $offset += self::SEED_BATCH_SIZE) { - $batchSize = min(self::SEED_BATCH_SIZE, $capacity - $offset); + // Each row binds its list and its index, and how many of those pairs fit in one statement is + // the ceiling's answer rather than a number chosen here. + $rowsPerStatement = $this->maxRowsPerStatement(2); + + for ($offset = 0; $offset < $capacity; $offset += $rowsPerStatement) { + $batchSize = min($rowsPerStatement, $capacity - $offset); $placeholders = []; $params = []; @@ -418,7 +416,9 @@ public function countNeverRetiringLists(): int * * Bounded, and batched by the caller. A deployment which switched credential expiry on some time ago * can have a great many rows come due at once, and a single unbounded statement over them is a long - * lock held on the table which serves every issuance. + * lock held on the table which serves every issuance. The caller's bound counts rows rather than + * statements: naming that many rows can take more than one update, which is a consequence of what + * the drivers allow and not something a caller has to size its request around. * * The linkage test is repeated in the update, not only in the select which chose the rows. Two runs * overlapping would otherwise each clear and each count the same rows, which changes nothing about @@ -450,9 +450,8 @@ public function clearExpiredLinkage(DateTimeImmutable $expiredBefore, int $limit ['expired_before' => $this->formatForDatabase($expiredBefore)], ); - $conditions = []; - $params = []; - $position = 0; + /** @var list $keys */ + $keys = []; /** @var mixed $row */ foreach ($rows as $row) { @@ -469,34 +468,52 @@ public function clearExpiredLinkage(DateTimeImmutable $expiredBefore, int $limit continue; } - // Each value under its own placeholder name: PDO turns named placeholders into positional - // ones for some drivers, and a repeated name then binds only its first occurrence. - $conditions[] = sprintf('(status_list_id = :list_%d AND idx = :idx_%d)', $position, $position); - $params['list_' . $position] = (string)$statusListId; - $params['idx_' . $position] = [(int)$idx, PDO::PARAM_INT]; - $position++; + $keys[] = [(string)$statusListId, (int)$idx]; } - if ($conditions === []) { + if ($keys === []) { return 0; } - $affected = $this->database->write( - sprintf( - 'UPDATE %s SET - credential_id = NULL, - credential_id_hash = NULL, - credential_configuration_id = NULL, - subject_ref = NULL, - updated_at = :updated_at - WHERE credential_id_hash IS NOT NULL AND (%s)', - $this->getTableName(), - implode(' OR ', $conditions), - ), - ['updated_at' => $this->formatForDatabase($this->helpers->dateTime()->getUtc())] + $params, - ); + // One moment for every row this call clears, taken once. The statements below are separate only + // because of how many rows each can name, and stamping each with its own clock would make that + // split visible in the data. + $updatedAt = $this->formatForDatabase($this->helpers->dateTime()->getUtc()); + $cleared = 0; + + // Two placeholders per row named, plus the one the timestamp takes however many rows follow it. + foreach (array_chunk($keys, $this->maxRowsPerStatement(2, 1)) as $chunk) { + $conditions = []; + $params = ['updated_at' => $updatedAt]; + + foreach ($chunk as $position => [$statusListId, $idx]) { + // Each value under its own placeholder name: PDO turns named placeholders into + // positional ones for some drivers, and a repeated name then binds only its first + // occurrence. + $conditions[] = sprintf('(status_list_id = :list_%d AND idx = :idx_%d)', $position, $position); + $params['list_' . $position] = $statusListId; + $params['idx_' . $position] = [$idx, PDO::PARAM_INT]; + } - return is_int($affected) ? $affected : 0; + $affected = $this->database->write( + sprintf( + 'UPDATE %s SET + credential_id = NULL, + credential_id_hash = NULL, + credential_configuration_id = NULL, + subject_ref = NULL, + updated_at = :updated_at + WHERE credential_id_hash IS NOT NULL AND (%s)', + $this->getTableName(), + implode(' OR ', $conditions), + ), + $params, + ); + + $cleared += is_int($affected) ? $affected : 0; + } + + return $cleared; } /** diff --git a/src/StatusList/StatusListLifecycle.php b/src/StatusList/StatusListLifecycle.php index f905bdfb..38d66aea 100644 --- a/src/StatusList/StatusListLifecycle.php +++ b/src/StatusList/StatusListLifecycle.php @@ -46,11 +46,12 @@ class StatusListLifecycle { /** - * Rows whose linkage is cleared per statement. + * Rows whose linkage is cleared per round of the loop. * - * Two placeholders per row plus one for the timestamp, so the statement stays under the 999 bound - * variables which SQLite allowed before 3.32 and which a build can still be compiled with. The other - * two drivers permit far more, but the lowest ceiling is the one which decides this. + * A round's size, not a statement's. How many rows one statement can name is a question about what + * the drivers allow, which the repository answers by splitting the rows it is handed; this only + * decides how much work a round asks for and therefore how often the loop looks again. It sits under + * a single statement's worth as it happens, so a round is normally one update. */ protected const int LINKAGE_BATCH_SIZE = 400; @@ -85,7 +86,7 @@ class StatusListLifecycle */ protected const int MAX_PURGED_ENTRIES_PER_LIST = 20000; - /** Audit rows removed per statement. */ + /** Audit rows removed per round of the loop. */ protected const int AUDIT_BATCH_SIZE = 500; /** Ceiling on how many batches of audit rows one run will remove. */ diff --git a/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php b/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php index c54f34a0..47c4a69c 100644 --- a/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php +++ b/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php @@ -48,4 +48,52 @@ public function testCanGetCacheKey(): void { $this->assertSame('sut_something', $this->sut()->getCacheKey('something')); } + + /** + * The ceiling is SQLite's pre 3.32 default of 999, which is the lowest of the three drivers and so + * the one every statement has to be built to. + */ + public function testWorksOutHowManyRowsOneStatementCanName(): void + { + $this->assertSame(999, $this->rowsPerStatement(1)); + $this->assertSame(499, $this->rowsPerStatement(2)); + $this->assertSame(333, $this->rowsPerStatement(3)); + } + + public function testCountsWhatTheStatementBindsBesidesItsRows(): void + { + // A statement carrying a timestamp of its own has one fewer variable to spend on rows, and at + // two per row that costs a whole row rather than half of one. + $this->assertSame(499, $this->rowsPerStatement(2, 1)); + $this->assertSame(498, $this->rowsPerStatement(2, 3)); + } + + /** + * A statement can not name a fraction of a row, and answering zero would leave a caller chunking by + * nothing, which never advances. + */ + public function testNamesAtLeastOneRowHoweverWideTheRowIs(): void + { + $this->assertSame(1, $this->rowsPerStatement(1000)); + $this->assertSame(1, $this->rowsPerStatement(2, 999)); + } + + protected function rowsPerStatement(int $perRow, int $fixed = 0): int + { + return (new class ( + $this->createMock(ModuleConfig::class), + $this->createMock(Database::class), + $this->createMock(ProtocolCache::class), + ) extends AbstractDatabaseRepository { + public function getTableName(): ?string + { + return 'sut'; + } + + public function rows(int $perRow, int $fixed): int + { + return $this->maxRowsPerStatement($perRow, $fixed); + } + })->rows($perRow, $fixed); + } } diff --git a/tests/unit/src/Repositories/StatusAuditRepositoryTest.php b/tests/unit/src/Repositories/StatusAuditRepositoryTest.php index b905864d..386d8f65 100644 --- a/tests/unit/src/Repositories/StatusAuditRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusAuditRepositoryTest.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeZone; +use PDOStatement; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -25,6 +26,14 @@ class StatusAuditRepositoryTest extends TestCase protected const string LIST_ID = 'a-status-list-id'; + /** + * What a statement may bind before the oldest supported driver refuses it. + * + * Stated here rather than read from the repository, so that the test asserts the limit the drivers + * impose and not merely that the code agrees with itself. + */ + protected const int MAX_BOUND_VARIABLES = 999; + protected MockObject $moduleConfigMock; protected Helpers $helpers; protected StatusAuditRepository $repository; @@ -300,4 +309,68 @@ public function testComparesTheCutOffInUtc(): void $this->assertSame(0, $removed); $this->assertCount(1, $this->readRows()); } + + /** + * The tests above run against a real SQLite, which has allowed 32766 bound variables since 3.32, so + * a delete naming more identifiers than an older build accepts passes there regardless. Counting + * what each statement binds is what shows the limit a deployment's own driver might impose. + * + * @throws \Exception + */ + public function testRemovesInStatementsEveryDriverAccepts(): void + { + $selected = []; + + for ($id = 0; $id < 2500; $id++) { + $selected[] = ['id' => 'audit-' . $id]; + } + + $bindings = []; + + $databaseMock = $this->createMock(Database::class); + $databaseMock->method('applyPrefix')->willReturnCallback( + static fn(string $table): string => 'phpunit_' . $table, + ); + + $statementMock = $this->createMock(PDOStatement::class); + $statementMock->method('fetchAll')->willReturn($selected); + $databaseMock->method('readPrimary')->willReturn($statementMock); + + // Answering with the row count of each statement rather than of the whole call, so a method + // returning only its last statement's total would be caught here. + $databaseMock->method('write')->willReturnCallback( + /** + * @param array $params + */ + function (string $statement, array $params = []) use (&$bindings): int { + $bindings[] = $params; + + return count($params); + }, + ); + + $repository = new StatusAuditRepository($this->moduleConfigMock, $databaseMock, null, $this->helpers); + + $removed = $repository->removeOlderThan( + new DateTimeImmutable('2026-01-01 00:00:00', new DateTimeZone('UTC')), + 2500, + ); + + $this->assertSame(2500, $removed); + $this->assertGreaterThan(1, count($bindings)); + + $named = []; + + foreach ($bindings as $params) { + $this->assertLessThanOrEqual(self::MAX_BOUND_VARIABLES, count($params)); + + foreach ($params as $value) { + $named[] = $value; + } + } + + // Splitting the deletion across statements must remove exactly the rows which were selected, + // neither leaving one behind nor naming one twice. + $this->assertSame(array_column($selected, 'id'), $named); + } } diff --git a/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php b/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php index 59d1a959..f6992cff 100644 --- a/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusListEntryRepositoryTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; use DateTimeImmutable; +use PDOStatement; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -30,6 +31,14 @@ class StatusListEntryRepositoryTest extends TestCase protected const int CAPACITY = 8; + /** + * What a statement may bind before the oldest supported driver refuses it. + * + * Stated here rather than read from the repository, so that the tests assert the limit the drivers + * impose and not merely that the code agrees with itself. + */ + protected const int MAX_BOUND_VARIABLES = 999; + protected MockObject $moduleConfigMock; protected Helpers $helpers; protected StatusListEntryRepository $repository; @@ -580,4 +589,108 @@ public function testRemovesEntriesOfOneListOnly(): void $this->assertSame(0, $this->countEntriesOf(self::LIST_ID)); $this->assertSame(self::CAPACITY, $this->countEntriesOf(self::OTHER_LIST_ID)); } + + /** + * A repository whose statements are collected instead of run. + * + * The tests above use a real SQLite, which has allowed 32766 bound variables since 3.32 -- so a + * statement binding more than an older build accepts runs perfectly well there and the tests pass + * on a database the deployment might not have. Counting what each statement binds is the only way + * to see that limit from a test at all. + * + * The write returns how many rows the statement named, so a caller summing across statements is + * checked as well: a method which returned the count of only its last statement would be caught. + * + * @param list> $bindings Filled with the parameters of every statement written. + * @param array> $selected Rows the repository is told it selected. + */ + protected function repositoryCollectingStatements( + array &$bindings, + array $selected = [], + ): StatusListEntryRepository { + $databaseMock = $this->createMock(Database::class); + $databaseMock->method('applyPrefix')->willReturnCallback( + static fn(string $table): string => 'phpunit_' . $table, + ); + + $statementMock = $this->createMock(PDOStatement::class); + $statementMock->method('fetchAll')->willReturn($selected); + $databaseMock->method('readPrimary')->willReturn($statementMock); + + $databaseMock->method('write')->willReturnCallback( + /** + * @param array $params + */ + function (string $statement, array $params = []) use (&$bindings): int { + $bindings[] = $params; + + return count(array_filter( + array_keys($params), + static fn(string $name): bool => str_starts_with($name, 'idx_'), + )); + }, + ); + + return new StatusListEntryRepository($this->moduleConfigMock, $databaseMock, null, $this->helpers); + } + + /** + * @throws \Exception + */ + public function testSeedsInStatementsEveryDriverAccepts(): void + { + $bindings = []; + // More than one statement's worth at two bound variables a row, and not a multiple of it, so a + // short final statement is exercised too. + $this->repositoryCollectingStatements($bindings)->seed(self::LIST_ID, 1200); + + $this->assertGreaterThan(1, count($bindings)); + + $seeded = []; + + foreach ($bindings as $params) { + $this->assertLessThanOrEqual(self::MAX_BOUND_VARIABLES, count($params)); + + foreach ($params as $name => $value) { + if (str_starts_with($name, 'idx_') && is_array($value)) { + $seeded[] = $value[0]; + } + } + } + + // Splitting a list across statements must neither skip an index nor hand one out twice, so the + // whole run is compared rather than counted. + $this->assertSame(range(0, 1199), $seeded); + } + + /** + * @throws \Exception + */ + public function testClearsLinkageInStatementsEveryDriverAccepts(): void + { + $selected = []; + + for ($idx = 0; $idx < 1200; $idx++) { + $selected[] = ['status_list_id' => self::LIST_ID, 'idx' => $idx]; + } + + $bindings = []; + $cleared = $this->repositoryCollectingStatements($bindings, $selected) + ->clearExpiredLinkage(new DateTimeImmutable('2026-08-07 12:00:00'), 1200); + + $this->assertSame(1200, $cleared); + $this->assertGreaterThan(1, count($bindings)); + + $moments = []; + + foreach ($bindings as $params) { + $this->assertLessThanOrEqual(self::MAX_BOUND_VARIABLES, count($params)); + $this->assertArrayHasKey('updated_at', $params); + $moments[] = $params['updated_at']; + } + + // The rows are in several statements only because of how many one can bind, so they are still + // one clearing and still carry one moment. + $this->assertCount(1, array_unique($moments)); + } } From eed0cf49a07c75de40ec616f62a51468a405dcb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 8 Aug 2026 13:07:51 +0200 Subject: [PATCH 9/9] Assert Status List controls stay out of published metadata, and document key and origin hazards --- docs/3-oidc-configuration.md | 26 ++ ...ntialIssuerConfigurationControllerTest.php | 236 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 7fddf04f..fe15b1a4 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -514,6 +514,25 @@ Each list records the profile it was created under. Changing the setting therefo credentials to newly created lists, while existing lists keep being served under the profile their holders already resolved them by — so changing it never invalidates anything already in a wallet. +**A signing key has to outlive every list signed with it.** Each list records the key it was created +with and is re-signed from that key alone, never from whichever key is current. Rotating keys is +therefore safe in itself: new lists take the new key, existing lists keep theirs. Removing the old key +from the configuration is what breaks things — and it breaks them on a delay. + +A published token is served from storage without the key being consulted at all, so a list whose token +is still fresh goes on answering `200` after its key is gone. The failure arrives only when that list +next needs re-signing: when its contents change, when its refresh interval comes round, or as its token +nears expiry. The module will not sign a list with a key its holders never bound to, so it answers `503` +for that list instead. **Checking the endpoint just after removing a key therefore proves nothing** — +and since a revocation is the most likely thing to force a re-sign, the breakage tends to appear exactly +when the list matters most. + +Whether credentials stay verifiable in the meantime depends on the profile. A `did_jwk` token carries +its own key, so tokens already published keep verifying. Under `jwks` the key is resolved through this +module's published JWKS, which the same removal empties, so already published tokens stop verifying too +once Relying Parties refetch it. A key is only safe to discard once every list it signed has been +retired, which the lifecycle below does only after the last credential in those lists has expired. + ### Credential expiry Credentials this module issues do not expire unless you say so: @@ -535,6 +554,13 @@ administration screen reports how many lists are in that position. Lists are published at `/statuslist/{id}`, unauthenticated, and the URI of the list is written into every credential issued from it. +That URI is absolute and fixed at the moment of issuance, so **changing the deployment's base URL +strands every credential issued before the change**: the wallet resolves the URI it was given, which +still names the old origin. There is nothing to rewrite — the credential is signed, and the copy that +matters is in someone else's wallet. If the base URL has to change, keep the old origin answering, by +alias or redirect, for as long as any credential issued under it can still be presented. The same +applies to moving the module to a different path. + This endpoint keeps serving when `OPTION_VCI_STATUS_LIST_ENABLED` is switched off. Turning the switch off stops new credentials getting an entry allocated; it does not, and must not, strand the credentials already in wallets as unverifiable. diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php new file mode 100644 index 00000000..902173fe --- /dev/null +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php @@ -0,0 +1,236 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->routesMock = $this->createMock(Routes::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->vciContextResolverMock = $this->createMock(VciContextResolver::class); + + $this->moduleConfigMock->method('getVciEnabled')->willReturn(true); + $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + $this->moduleConfigMock->method('getOrganizationName')->willReturn('Example University'); + $this->moduleConfigMock->method('getDescription')->willReturn('Example credentials'); + $this->moduleConfigMock->method('getLogoUri')->willReturn('https://issuer.com/logo.png'); + $this->moduleConfigMock->method('getVciCredentialConfigurationsSupported') + ->willReturn($this->credentialConfigurations()); + + $signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $signatureKeyPairMock->method('getSignatureAlgorithm')->willReturn(SignatureAlgorithmEnum::ES256); + $signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $signatureKeyPairBagMock->method('getFirstOrFail')->willReturn($signatureKeyPairMock); + $this->moduleConfigMock->method('getVciSignatureKeyPairBag')->willReturn($signatureKeyPairBagMock); + + $this->routesMock->method('urlCredentialIssuerCredential')->willReturn(self::CREDENTIAL_ENDPOINT); + $this->routesMock->method('urlCredentialIssuerNonce')->willReturn(self::NONCE_ENDPOINT); + $this->routesMock->method('newJsonResponse')->willReturnCallback( + /** + * @param ?array $data + */ + static fn(?array $data = null): JsonResponse => new JsonResponse($data), + ); + } + + /** + * @return array> + */ + protected function credentialConfigurations(): array + { + return [ + self::CONFIGURATION_ID => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + ClaimsEnum::Scope->value => 'UniversityDegree', + ], + ]; + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function sut(): CredentialIssuerConfigurationController + { + return new CredentialIssuerConfigurationController( + $this->moduleConfigMock, + $this->routesMock, + $this->loggerServiceMock, + $this->vciContextResolverMock, + ); + } + + /** + * @return array + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + protected function publishedMetadata(): array + { + $content = $this->sut()->configuration()->getContent(); + + $this->assertIsString($content); + + /** @var array $decoded */ + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testPublishesTheIssuerAndItsEndpoints(): void + { + $metadata = $this->publishedMetadata(); + + $this->assertSame(self::ISSUER, $metadata[ClaimsEnum::CredentialIssuer->value]); + $this->assertSame(self::CREDENTIAL_ENDPOINT, $metadata[ClaimsEnum::CredentialEndpoint->value]); + $this->assertSame(self::NONCE_ENDPOINT, $metadata[ClaimsEnum::NonceEndpoint->value]); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testDescribesWhatEachConfigurationCanBeProvedAndSignedWith(): void + { + $metadata = $this->publishedMetadata(); + + /** @var array> $configurations */ + $configurations = $metadata[ClaimsEnum::CredentialConfigurationsSupported->value]; + $configuration = $configurations[self::CONFIGURATION_ID]; + + $this->assertSame( + [SignatureAlgorithmEnum::ES256->value], + $configuration[ClaimsEnum::CredentialSigningAlgValuesSupported->value], + ); + $this->assertSame( + ['did:key', 'did:jwk'], + $configuration[ClaimsEnum::CryptographicBindingMethodsSupported->value], + ); + $this->assertArrayHasKey(ClaimsEnum::ProofTypesSupported->value, $configuration); + // What the operator configured is still there, with the above added rather than substituted. + $this->assertSame('UniversityDegree', $configuration[ClaimsEnum::Scope->value]); + } + + /** + * The document goes to wallets, so nothing about how this deployment runs its Status Lists may be + * in it. + * + * `credential_configurations_supported` is republished wholesale, so a private control placed + * inside one would be handed out with it. That is why they are top level options instead, and this + * asserts the arrangement rather than trusting it: no getter which could carry one is reached + * while the document is built. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testPublishesNoStatusListControls(): void + { + foreach (self::PRIVATE_STATUS_LIST_GETTERS as $getter) { + $this->moduleConfigMock->expects($this->never())->method($getter); + } + + $metadata = $this->publishedMetadata(); + + $encoded = json_encode($metadata, JSON_THROW_ON_ERROR); + + foreach ( + [ + ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED, + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE, + ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS, + ModuleConfig::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE, + ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE, + ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION, + ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS, + ] as $option + ) { + $this->assertStringNotContainsString($option, $encoded); + } + + // The specification registers no "status lists are supported" parameter, and support is + // discovered from the `status` claim of an issued credential instead. Anything resembling one + // here would be invented rather than published. + $this->assertStringNotContainsString('status_list', $encoded); + } + + /** + * The constructor is the gate: with Verifiable Credentials switched off there is no metadata to + * publish, and nothing further in this controller should be reachable. + */ + public function testRefusesToPublishAnythingWhenCredentialsAreDisabled(): void + { + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getVciEnabled')->willReturn(false); + $moduleConfigMock->expects($this->never())->method('getVciCredentialConfigurationsSupported'); + + $this->loggerServiceMock->expects($this->once())->method('warning'); + + $this->expectException(OidcServerException::class); + + new CredentialIssuerConfigurationController( + $moduleConfigMock, + $this->routesMock, + $this->loggerServiceMock, + $this->vciContextResolverMock, + ); + } +}