From 1d771570602676b2434672edfda0547ea5bcc471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 28 Aug 2026 11:25:16 +0200 Subject: [PATCH 01/15] Fail closed on OpenID4VCI key proofs --- config/module_oidc.php.dist | 39 ++ locales/en/LC_MESSAGES/oidc.po | 21 + locales/es/LC_MESSAGES/oidc.po | 21 + locales/fr/LC_MESSAGES/oidc.po | 21 + locales/hr/LC_MESSAGES/oidc.po | 21 + locales/it/LC_MESSAGES/oidc.po | 21 + locales/nl/LC_MESSAGES/oidc.po | 21 + routing/services/services.yml | 6 + .../ConfigOverview/VciOverviewBuilder.php | 55 ++ .../VciCredentialBindingPolicyEnum.php | 34 + ...redentialIssuerConfigurationController.php | 58 +- .../CredentialIssuerCredentialController.php | 170 +---- src/Exceptions/CredentialRequestException.php | 38 ++ src/ModuleConfig.php | 124 ++++ .../OpenId4VciProofValidator.php | 538 +++++++++++++++ .../Values/ValidatedOpenId4VciProof.php | 47 ++ ...ntialIssuerConfigurationControllerTest.php | 102 +++ ...edentialIssuerCredentialControllerTest.php | 148 +++- tests/unit/src/ModuleConfigTest.php | 91 +++ .../OpenId4VciProofValidatorTest.php | 644 ++++++++++++++++++ 20 files changed, 2053 insertions(+), 167 deletions(-) create mode 100644 src/Codebooks/VciCredentialBindingPolicyEnum.php create mode 100644 src/Exceptions/CredentialRequestException.php create mode 100644 src/VerifiableCredentials/OpenId4VciProofValidator.php create mode 100644 src/VerifiableCredentials/Values/ValidatedOpenId4VciProof.php create mode 100644 tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index be02545f..a68a30fb 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1636,6 +1636,45 @@ $config = [ // ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS => [ // 'UniversityDegreeCredential' => 'P1Y', // 1 year // 'EmployeeBadgeCredential' => 'P90D', // 90 days +// ], + + /** + * (optional) Whether each credential configuration binds its credentials to + * a key the wallet proves it holds. Configurations which are not listed + * here are proof-bound, which is the default. + * + * VciCredentialBindingPolicyEnum::ProofBound (the default) requires the + * Credential Request to carry a `proofs` parameter, verifies the key proof + * inside it, and issues the credential to the holder identifier that proof + * resolves to. The configuration advertises both + * `cryptographic_binding_methods_supported` and `proof_types_supported`. + * + * VciCredentialBindingPolicyEnum::Proofless issues credentials which are + * not bound to any wallet key, to a subject identifier derived from the + * authenticated user. The configuration then advertises neither of those + * two metadata fields, and a key proof sent anyway is refused - nothing + * told the wallet which proof type or signing algorithm to build one with. + * + * OpenID4VCI ties these together, which is why one option governs the + * advertisement and the issuance at once: `proof_types_supported` must be + * present wherever `cryptographic_binding_methods_supported` is, and a + * Credential Request must carry `proofs` wherever `proof_types_supported` + * is. Advertising binding and then issuing without it tells a wallet its + * credential is held to its key when nothing of the sort was checked. + * + * Note that a proofless configuration can not be conformant to profiles + * which require holder binding, such as DIIP. + * + * 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_BINDING_POLICIES => [ +// 'UniversityDegreeCredential' => +// \SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum::ProofBound, +// 'EmployeeBadgeCredential' => +// \SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum::Proofless, // ], /** diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index 25be66c0..cf632a6c 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -2246,3 +2246,24 @@ msgid "" "You will be presented with a Credential Offer which you can use to test " "credential issuance." msgstr "" + +msgid "Credential Binding Policies" +msgstr "" + +msgid "Every configuration requires a key proof" +msgstr "" + +msgid "" +"Every credential configuration binds its credentials to a key the wallet " +"proves it holds, which is the default. Each one advertises the binding " +"methods and proof types it accepts, and a Credential Request carrying no " +"valid key proof is refused." +msgstr "" + +msgid "" +"These credential configurations issue credentials which are not bound to " +"any wallet key, so nothing ties an issued credential to whoever presents it " +"later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused. Configurations which are not listed require " +"a key proof." +msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index e4d8ea5c..9ca36d7b 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -2246,3 +2246,24 @@ msgid "" "You will be presented with a Credential Offer which you can use to test " "credential issuance." msgstr "" + +msgid "Credential Binding Policies" +msgstr "" + +msgid "Every configuration requires a key proof" +msgstr "" + +msgid "" +"Every credential configuration binds its credentials to a key the wallet " +"proves it holds, which is the default. Each one advertises the binding " +"methods and proof types it accepts, and a Credential Request carrying no " +"valid key proof is refused." +msgstr "" + +msgid "" +"These credential configurations issue credentials which are not bound to " +"any wallet key, so nothing ties an issued credential to whoever presents it " +"later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused. Configurations which are not listed require " +"a key proof." +msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index c6ca7956..2b6d9890 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -2246,3 +2246,24 @@ msgid "" "You will be presented with a Credential Offer which you can use to test " "credential issuance." msgstr "" + +msgid "Credential Binding Policies" +msgstr "" + +msgid "Every configuration requires a key proof" +msgstr "" + +msgid "" +"Every credential configuration binds its credentials to a key the wallet " +"proves it holds, which is the default. Each one advertises the binding " +"methods and proof types it accepts, and a Credential Request carrying no " +"valid key proof is refused." +msgstr "" + +msgid "" +"These credential configurations issue credentials which are not bound to " +"any wallet key, so nothing ties an issued credential to whoever presents it " +"later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused. Configurations which are not listed require " +"a key proof." +msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index 8c865ea4..dec0d672 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -2294,3 +2294,24 @@ msgid "" "You will be presented with a Credential Offer which you can use to test " "credential issuance." msgstr "" + +msgid "Credential Binding Policies" +msgstr "" + +msgid "Every configuration requires a key proof" +msgstr "" + +msgid "" +"Every credential configuration binds its credentials to a key the wallet " +"proves it holds, which is the default. Each one advertises the binding " +"methods and proof types it accepts, and a Credential Request carrying no " +"valid key proof is refused." +msgstr "" + +msgid "" +"These credential configurations issue credentials which are not bound to " +"any wallet key, so nothing ties an issued credential to whoever presents it " +"later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused. Configurations which are not listed require " +"a key proof." +msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index 8ba77c14..3db79027 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -2246,3 +2246,24 @@ msgid "" "You will be presented with a Credential Offer which you can use to test " "credential issuance." msgstr "" + +msgid "Credential Binding Policies" +msgstr "" + +msgid "Every configuration requires a key proof" +msgstr "" + +msgid "" +"Every credential configuration binds its credentials to a key the wallet " +"proves it holds, which is the default. Each one advertises the binding " +"methods and proof types it accepts, and a Credential Request carrying no " +"valid key proof is refused." +msgstr "" + +msgid "" +"These credential configurations issue credentials which are not bound to " +"any wallet key, so nothing ties an issued credential to whoever presents it " +"later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused. Configurations which are not listed require " +"a key proof." +msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 91d4cbfd..e7d148ab 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -2200,3 +2200,24 @@ msgid "" "You will be presented with a Credential Offer which you can use to test " "credential issuance." msgstr "" + +msgid "Credential Binding Policies" +msgstr "" + +msgid "Every configuration requires a key proof" +msgstr "" + +msgid "" +"Every credential configuration binds its credentials to a key the wallet " +"proves it holds, which is the default. Each one advertises the binding " +"methods and proof types it accepts, and a Credential Request carrying no " +"valid key proof is refused." +msgstr "" + +msgid "" +"These credential configurations issue credentials which are not bound to " +"any wallet key, so nothing ties an issued credential to whoever presents it " +"later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused. Configurations which are not listed require " +"a key proof." +msgstr "" diff --git a/routing/services/services.yml b/routing/services/services.yml index e6eb686c..d43d0df3 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -54,6 +54,12 @@ services: SimpleSAML\Module\oidc\StatusList\StatusListLifecycle: public: true + # Verifiable Credential Issuance. Values carry what a validated request resolved to rather than being + # autowired, so they are excluded the same way the Token Status List ones are. + SimpleSAML\Module\oidc\VerifiableCredentials\: + resource: '../../src/VerifiableCredentials/*' + exclude: '../../src/VerifiableCredentials/{Values}' + SimpleSAML\Module\oidc\Factories\: resource: '../../src/Factories/*' diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index 400d9ac1..ca7aaa05 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -7,6 +7,7 @@ use DateInterval; use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Codebooks\ConfigOverviewValueTypeEnum; +use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; @@ -557,6 +558,51 @@ protected function buildCredentialConfigurationsSection(): Section ) : null, $error, ), + $this->guardRow( + Translate::noop('Credential Binding Policies'), + ModuleConfig::OPTION_VCI_CREDENTIAL_BINDING_POLICIES, + function (): Row { + // Only the exceptions are listed. Requiring a key proof is the default, so naming + // every configuration which does would bury the ones which do not, and it is those + // an administrator needs to recognise on sight. + $proofless = array_keys( + array_filter( + $this->moduleConfig->getVciCredentialBindingPolicies(), + $this->isProofless(...), + ), + ); + + if ($proofless === []) { + return new Row( + Translate::noop('Credential Binding Policies'), + Translate::noop('Every configuration requires a key proof'), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_VCI_CREDENTIAL_BINDING_POLICIES, + Translate::noop( + 'Every credential configuration binds its credentials to a key the ' . + 'wallet proves it holds, which is the default. Each one advertises ' . + 'the binding methods and proof types it accepts, and a Credential ' . + 'Request carrying no valid key proof is refused.', + ), + ); + } + + return new Row( + Translate::noop('Credential Binding Policies'), + $proofless, + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_VCI_CREDENTIAL_BINDING_POLICIES, + null, + Translate::noop( + 'These credential configurations issue credentials which are not bound ' . + 'to any wallet key, so nothing ties an issued credential to whoever ' . + 'presents it later. They advertise no binding methods and no proof ' . + 'types, and a key proof sent to them is refused. Configurations which ' . + 'are not listed require a key proof.', + ), + ); + }, + ), new Row( Translate::noop('Attribute to Claim Path Mappings'), $attributeMap, @@ -1007,6 +1053,15 @@ protected function normalizeRedirectUriPrefix(mixed $prefix): ?string } + /** + * Whether a credential configuration issues credentials which are not bound to a holder key. + */ + protected function isProofless(VciCredentialBindingPolicyEnum $bindingPolicy): bool + { + return $bindingPolicy === VciCredentialBindingPolicyEnum::Proofless; + } + + /** * Whether any credential configuration declares a format which cannot be issued. */ diff --git a/src/Codebooks/VciCredentialBindingPolicyEnum.php b/src/Codebooks/VciCredentialBindingPolicyEnum.php new file mode 100644 index 00000000..6d7704ef --- /dev/null +++ b/src/Codebooks/VciCredentialBindingPolicyEnum.php @@ -0,0 +1,34 @@ +moduleConfig->getVciCredentialConfigurationsSupported(); + $isAnyConfigurationProofBound = false; + // Every credential configuration advertises the one algorithm the active signing key uses, // because that is the only one issuance will actually sign with. Advertising the algorithms of // the other configured pairs would invite a wallet to ask for a credential this issuer would @@ -59,18 +63,36 @@ public function configuration(): Response $credentialConfiguration[ClaimsEnum::CredentialSigningAlgValuesSupported->value] = [ $signatureKeyPair->getSignatureAlgorithm()->value, ]; - $credentialConfiguration[ClaimsEnum::CryptographicBindingMethodsSupported->value] = [ - 'did:key', - 'did:jwk', - ]; - $credentialConfiguration[ClaimsEnum::ProofTypesSupported->value] = [ - 'jwt' => [ - ClaimsEnum::ProofSigningAlgValuesSupported->value => $this->moduleConfig - ->getSupportedAlgorithms() - ->getSignatureAlgorithmBag() - ->getAllNamesUnique(), - ], - ]; + + $bindingPolicy = $this->moduleConfig->getVciCredentialBindingPolicyFor($credentialConfigurationId); + + if ($bindingPolicy === VciCredentialBindingPolicyEnum::ProofBound) { + $isAnyConfigurationProofBound = true; + + $credentialConfiguration[ClaimsEnum::CryptographicBindingMethodsSupported->value] = [ + 'did:key', + 'did:jwk', + ]; + $credentialConfiguration[ClaimsEnum::ProofTypesSupported->value] = [ + OpenId4VciProofValidator::PROOF_TYPE_JWT => [ + ClaimsEnum::ProofSigningAlgValuesSupported->value => $this->moduleConfig + ->getSupportedAlgorithms() + ->getSignatureAlgorithmBag() + ->getAllNamesUnique(), + ], + ]; + } else { + // Both fields go, not just one. OpenID4VCI requires `proof_types_supported` wherever + // `cryptographic_binding_methods_supported` appears, and requires a `proofs` + // parameter wherever `proof_types_supported` appears, so leaving either in place + // would promise a wallet a binding this configuration does not perform. Unset rather + // than skipped, because the credential configurations are published as the operator + // wrote them and may state either field themselves. + unset( + $credentialConfiguration[ClaimsEnum::CryptographicBindingMethodsSupported->value], + $credentialConfiguration[ClaimsEnum::ProofTypesSupported->value], + ); + } $credentialFormatId = $credentialConfiguration[ClaimsEnum::Format->value] ?? null; @@ -115,9 +137,6 @@ public function configuration(): Response // OPTIONAL // credential_response_encryption - // OPTIONAL - // batch_credential_issuance - // OPTIONAL // signed_metadata @@ -139,6 +158,15 @@ public function configuration(): Response ]; + // The cap the credential endpoint enforces on a `proofs` array, stated where a wallet can read + // it before it builds one. Batching only happens where key proofs do, so an issuer whose every + // configuration issues unbound credentials advertises no batch size at all. + if ($isAnyConfigurationProofBound) { + $configuration[ClaimsEnum::BatchCredentialIssuance->value] = [ + ClaimsEnum::BatchSize->value => ModuleConfig::VCI_BATCH_SIZE, + ]; + } + return $this->routes->newJsonResponse($configuration); } } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index def4edce..1f79ee34 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -7,10 +7,10 @@ use DateInterval; use DateTimeImmutable; use DateTimeInterface; -use Exception; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; +use SimpleSAML\Module\oidc\Exceptions\CredentialRequestException; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; @@ -19,22 +19,20 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; 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\Module\oidc\VerifiableCredentials\OpenId4VciProofValidator; use SimpleSAML\OpenID\Codebooks\AtContextsEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; 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; @@ -72,7 +70,7 @@ public function __construct( protected readonly UserRepository $userRepository, protected readonly Did $did, protected readonly IssuerStateRepository $issuerStateRepository, - protected readonly NonceService $nonceService, + protected readonly OpenId4VciProofValidator $openId4VciProofValidator, protected readonly VciContextResolver $vciContextResolver, protected readonly CredentialStatusIssuer $credentialStatusIssuer, protected readonly Helpers $helpers, @@ -413,141 +411,39 @@ public function credential(Request $request): Response } $this->loggerService->info('Issuing credential for user.', ['userId' => $userId]); - // Extract all proofs from the request. - $proofsToProcess = []; - /** @psalm-suppress MixedAssignment */ - if (isset($requestData['proof']) && is_array($requestData['proof'])) { - $proofsToProcess[] = $requestData['proof']; - } - /** @psalm-suppress MixedAssignment */ - if (isset($requestData['proofs']) && is_array($requestData['proofs'])) { - /** @var mixed $proofValues */ - foreach ($requestData['proofs'] as $proofType => $proofValues) { - if (is_array($proofValues)) { - foreach ($proofValues as $proofValue) { - $proofsToProcess[] = [ - 'proof_type' => $proofType, - $proofType => $proofValue, - ]; - } - } - } - } + // Every key proof is validated before anything at all is issued. Validating and issuing in one + // pass would let a request whose last proof turns out to be bad still leave behind the Status + // List entries its earlier proofs allocated, spent on credentials no wallet ever receives. + try { + $validatedProofs = $this->openId4VciProofValidator->validateRequest( + $requestData, + $this->moduleConfig->getVciCredentialBindingPolicyFor($resolvedCredentialIdentifier), + $accessToken, + ); + } catch (CredentialRequestException $credentialRequestException) { + $this->loggerService->warning( + 'Credential request refused.', + [ + 'error' => $credentialRequestException->getErrorCode(), + 'reason' => $credentialRequestException->getMessage(), + 'credentialConfigurationId' => $resolvedCredentialIdentifier, + ], + ); - // If no proofs are provided, we still proceed with a single null proof to maintain - // existing behavior where proofs are optional. - if (empty($proofsToProcess)) { - $this->loggerService->debug('No proofs provided in request (optional).'); - $proofsToProcess[] = null; - } else { - $this->loggerService->debug('Proofs extracted from request.', ['count' => count($proofsToProcess)]); + return $this->routes->newJsonErrorResponse( + $credentialRequestException->getErrorCode(), + $credentialRequestException->getMessage(), + 400, + ); } $issuedCredentialsData = []; - $proofIndex = 0; - foreach ($proofsToProcess as $proofData) { - $proofIndex++; - if (count($proofsToProcess) > 1) { - $this->loggerService->debug( - sprintf('Processing proof %d of %d.', $proofIndex, count($proofsToProcess)), - ); - } - // Placeholder sub identifier. Will do if proof is not provided. - $sub = $this->moduleConfig->getIssuer() . '/sub/' . $userId; - - $proof = null; - // Validate proof, if provided. - /** @psalm-suppress MixedAssignment */ - if ( - is_array($proofData) && - isset($proofData['proof_type']) && - isset($proofData['jwt']) && - $proofData['proof_type'] === 'jwt' && - is_string($proofJwt = $proofData['jwt']) && - $proofJwt !== '' - ) { - $this->loggerService->debug('Verifying proof JWT.'); - - try { - $proof = $this->verifiableCredentials->openId4VciProofFactory()->fromToken($proofJwt); - if (! in_array($this->moduleConfig->getIssuer(), $proof->getAudience())) { - $this->loggerService->error( - 'Invalid Proof audience.', - ['audience' => $proof->getAudience(), 'issuer' => $this->moduleConfig->getIssuer()], - ); - throw new OpenId4VciProofException('Invalid Proof audience.'); - } - - $jwk = $proof->getJsonWebKey(); - $resolvedDid = null; - - if (is_array($jwk)) { - $resolvedDid = $this->did->didJwkResolver()->generateDidJwkFromJwk($jwk); - } else { - $kid = $proof->getKeyId(); - if (is_string($kid) && str_starts_with($kid, 'did:key:z')) { - // The fragment (#z2dmzD...) typically points to a specific verification method within the DID's - // context. For did:key, since the DID is the key, this fragment often just refers to the key - // itself. - ($resolvedDid = strtok($kid, '#')) || throw new OpenId4VciProofException( - 'Error getting did:key without fragment. Value was: ' . $kid, - ); - - $jwk = $this->did->didKeyResolver()->extractJwkFromDidKey($resolvedDid); - } elseif (is_string($kid) && str_starts_with($kid, 'did:jwk:')) { - ($resolvedDid = strtok($kid, '#')) || throw new OpenId4VciProofException( - 'Error getting did:jwk without fragment. Value was: ' . $kid, - ); - - $jwk = $this->did->didJwkResolver()->extractJwkFromDidJwk($resolvedDid); - } - } - - if ($jwk !== null && $resolvedDid !== null) { - $proof->verifyWithKey($jwk); - - $this->loggerService->debug('Proof verified successfully.', ['did' => $resolvedDid]); - - // Verify nonce - $nonce = $proof->getNonce(); - if (is_string($nonce) && $nonce !== '') { - $this->loggerService->debug('Validating proof nonce.', ['nonce' => $nonce]); - - if (!$this->nonceService->validateNonce($nonce)) { - $this->loggerService->warning( - 'Proof nonce is invalid or expired. Nonce was: ' . $nonce, - ); - return $this->routes->newJsonErrorResponse( - error: 'invalid_nonce', - description: 'c_nonce is invalid or expired.', - httpCode: 400, - ); - } - - $this->loggerService->debug('Proof nonce validated successfully.'); - } else { - $this->loggerService->debug('No nonce present in proof, skipping validation.'); - } - - // Set it as a subject identifier (bind it). - $sub = $resolvedDid; - } else { - $this->loggerService->warning( - 'Proof binding currently not supported for this key/DID type.', - ['kid' => $proof->getKeyId(), 'jwk' => $proof->getJsonWebKey()], - ); - } - } catch (Exception $e) { - $message = 'Error processing proof JWT: ' . $e->getMessage(); - $this->loggerService->error($message); - return $this->routes->newJsonErrorResponse( - 'invalid_proof', - $message, - 400, - ); - } - } + foreach ($validatedProofs as $validatedProof) { + // A configuration which issues credentials that are not bound to a holder key has no wallet + // key to name here, so the subject is one this issuer derives from the authenticated user. + $sub = $validatedProof?->getSubject() ?? + ($this->moduleConfig->getIssuer() . '/sub/' . $userId); $userAttributes = $userEntity->getClaims(); @@ -833,7 +729,7 @@ public function credential(Request $request): Response $commonClaims, ); - if ($proof instanceof OpenId4VciProof && is_string($proofKeyId = $proof->getKeyId())) { + if ($validatedProof !== null && is_string($proofKeyId = $validatedProof->getKeyId())) { $sdJwtPayload[ClaimsEnum::Cnf->value] = [ ClaimsEnum::Kid->value => $proofKeyId, ]; @@ -886,7 +782,7 @@ public function credential(Request $request): Response $sdJwtPayload[ClaimsEnum::ValidUntil->value] = $expiresAt->format(DateTimeInterface::RFC3339); } - if ($proof instanceof OpenId4VciProof && is_string($proofKeyId = $proof->getKeyId())) { + if ($validatedProof !== null && is_string($proofKeyId = $validatedProof->getKeyId())) { $sdJwtPayload[ClaimsEnum::Cnf->value] = [ ClaimsEnum::Kid->value => $proofKeyId, ]; diff --git a/src/Exceptions/CredentialRequestException.php b/src/Exceptions/CredentialRequestException.php new file mode 100644 index 00000000..51ed79b2 --- /dev/null +++ b/src/Exceptions/CredentialRequestException.php @@ -0,0 +1,38 @@ +errorCode; + } +} diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index b6c0c488..c4b7da89 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -15,6 +15,7 @@ use SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListExpiryLaneEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; +use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; @@ -74,6 +75,17 @@ class ModuleConfig */ final public const int MINIMUM_STATUS_LIST_RETIREMENT_GRACE_SECONDS = 3600; + /** + * The most Key Proofs one Credential Request may carry. + * + * Each proof issues a credential of its own and claims a Status List entry of its own, so an + * uncapped `proofs` array lets a single authenticated request spend an arbitrary amount of storage + * and signing work. Fixed rather than configurable: the number is published in Credential Issuer + * metadata as `batch_credential_issuance.batch_size`, and a wallet which read it there has to be + * able to rely on it. + */ + final public const int VCI_BATCH_SIZE = 8; + final public const string OPTION_PKI_PRIVATE_KEY_PASSPHRASE = 'pass_phrase'; final public const string DEFAULT_PKI_PRIVATE_KEY_FILENAME = 'oidc_module.key'; @@ -266,6 +278,8 @@ class ModuleConfig final public const string OPTION_VCI_CREDENTIAL_TTLS = 'vci_credential_ttls'; + final public const string OPTION_VCI_CREDENTIAL_BINDING_POLICIES = 'vci_credential_binding_policies'; + final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; @@ -349,6 +363,12 @@ class ModuleConfig /** @var ?array Credential configuration ID to how long its credentials live. */ protected ?array $vciCredentialTtls = null; + /** + * @var ?array Credential + * configuration ID to whether its credentials are bound to a holder key. + */ + protected ?array $vciCredentialBindingPolicies = null; + /** * @throws \Exception @@ -1962,6 +1982,110 @@ public function getVciCredentialTtlFor(string $credentialConfigurationId): ?Date } + /** + * Whether each credential configuration binds its credentials to a key the wallet proves it holds. + * + * A top-level option for the same reason the lifetimes 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 are proof-bound, which is what the metadata this module + * publishes has always claimed. A deployment which relies on issuing without a Key Proof names its + * configurations here, and their metadata then stops advertising a binding they never performed. + * + * @return array + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciCredentialBindingPolicies(): array + { + if (is_array($this->vciCredentialBindingPolicies)) { + return $this->vciCredentialBindingPolicies; + } + + $supportedIds = $this->getVciCredentialConfigurationIdsSupported(); + $bindingPolicies = []; + + /** @var mixed $value */ + foreach ( + $this->config()->getOptionalArray(self::OPTION_VCI_CREDENTIAL_BINDING_POLICIES, []) as $key => $value + ) { + $credentialConfigurationId = (string)$key; + + if (!in_array($credentialConfigurationId, $supportedIds, true)) { + // Silently ignoring this would leave a configuration meant to issue without a Key Proof + // demanding one, and nothing would say so. + throw new ConfigurationError( + sprintf( + 'Option "%s" sets a binding policy for the credential configuration "%s", which ' . + 'is not one of the configurations declared under "%s".', + self::OPTION_VCI_CREDENTIAL_BINDING_POLICIES, + $credentialConfigurationId, + self::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED, + ), + self::DEFAULT_FILE_NAME, + ); + } + + $bindingPolicies[$credentialConfigurationId] = $this->resolveCredentialBindingPolicy( + $credentialConfigurationId, + $value, + ); + } + + return $this->vciCredentialBindingPolicies = $bindingPolicies; + } + + + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function resolveCredentialBindingPolicy( + string $credentialConfigurationId, + mixed $value, + ): VciCredentialBindingPolicyEnum { + if ($value instanceof VciCredentialBindingPolicyEnum) { + return $value; + } + + if ( + is_string($value) && + ($bindingPolicy = VciCredentialBindingPolicyEnum::tryFrom($value)) instanceof + VciCredentialBindingPolicyEnum + ) { + return $bindingPolicy; + } + + throw new ConfigurationError( + sprintf( + 'Option "%s" gives "%s" a binding policy which is not one of: %s.', + self::OPTION_VCI_CREDENTIAL_BINDING_POLICIES, + $credentialConfigurationId, + implode( + ', ', + array_map( + fn(VciCredentialBindingPolicyEnum $case): string => $case->value, + VciCredentialBindingPolicyEnum::cases(), + ), + ), + ), + self::DEFAULT_FILE_NAME, + ); + } + + + /** + * Whether this credential configuration binds its credentials to a key the wallet proves it holds. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciCredentialBindingPolicyFor( + string $credentialConfigurationId, + ): VciCredentialBindingPolicyEnum { + return $this->getVciCredentialBindingPolicies()[$credentialConfigurationId] ?? + VciCredentialBindingPolicyEnum::ProofBound; + } + + /** * Which Status List expiry lanes a pool would currently allocate into. * diff --git a/src/VerifiableCredentials/OpenId4VciProofValidator.php b/src/VerifiableCredentials/OpenId4VciProofValidator.php new file mode 100644 index 00000000..cdfccf25 --- /dev/null +++ b/src/VerifiableCredentials/OpenId4VciProofValidator.php @@ -0,0 +1,538 @@ +> + */ + protected const array PUBLIC_JWK_MEMBERS_BY_KEY_TYPE = [ + 'EC' => ['crv', 'x', 'y'], + 'RSA' => ['n', 'e'], + 'OKP' => ['crv', 'x'], + ]; + + + public function __construct( + protected readonly ModuleConfig $moduleConfig, + protected readonly VerifiableCredentials $verifiableCredentials, + protected readonly Did $did, + protected readonly NonceService $nonceService, + protected readonly LoggerService $loggerService, + ) { + } + + + /** + * Validate everything a Credential Request says about holder binding. + * + * @param array $requestData + * @return list One + * entry per credential the request is to be issued, in the order the proofs arrived. A null entry + * means an unbound credential, which only a proofless configuration produces. + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException When the request is refused. + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \JsonException + */ + public function validateRequest( + array $requestData, + VciCredentialBindingPolicyEnum $bindingPolicy, + AccessTokenEntity $accessToken, + ): array { + if ($bindingPolicy === VciCredentialBindingPolicyEnum::Proofless) { + $this->refuseSuppliedProofs($requestData); + + return [null]; + } + + $proofJwts = $this->extractProofJwts($requestData); + + $this->loggerService->debug( + 'Validating key proofs before issuing anything.', + ['count' => count($proofJwts)], + ); + + $validatedProofs = []; + foreach ($proofJwts as $proofJwt) { + $validatedProofs[] = $this->validateProof($proofJwt, $accessToken); + } + + return $validatedProofs; + } + + + /** + * A configuration which advertises no proof type has nothing to validate a proof against. + * + * Silently ignoring one would be worse than refusing it: the wallet built a proof, sent it, and got + * back a credential bound to something else, with nothing on it to say its key went unused. + * + * @param array $requestData + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + */ + protected function refuseSuppliedProofs(array $requestData): void + { + if ( + array_key_exists(ClaimsEnum::Proof->value, $requestData) || + array_key_exists(ClaimsEnum::Proofs->value, $requestData) + ) { + throw new CredentialRequestException( + 'invalid_credential_request', + 'This credential configuration issues credentials which are not bound to a holder key, ' . + 'and advertises no proof type, so a key proof can not be accepted.', + ); + } + } + + + /** + * @param array $requestData + * @return list + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + */ + protected function extractProofJwts(array $requestData): array + { + // The singular parameter is the pre-final shape of this request. Accepting it would mean + // accepting a request the metadata this configuration publishes does not describe. + if (array_key_exists(ClaimsEnum::Proof->value, $requestData)) { + throw new CredentialRequestException( + 'invalid_proof', + 'The "proof" parameter is not supported. Send the key proof in the "proofs" parameter.', + ); + } + + /** @psalm-suppress MixedAssignment */ + $proofs = $requestData[ClaimsEnum::Proofs->value] ?? null; + + if (!is_array($proofs) || $proofs === []) { + throw new CredentialRequestException( + 'invalid_proof', + 'The "proofs" parameter is required for this credential configuration.', + ); + } + + if (count($proofs) !== 1) { + throw new CredentialRequestException( + 'invalid_proof', + 'The "proofs" parameter must name exactly one proof type.', + ); + } + + if ((string)array_key_first($proofs) !== self::PROOF_TYPE_JWT) { + throw new CredentialRequestException( + 'invalid_proof', + sprintf('The only supported proof type is "%s".', self::PROOF_TYPE_JWT), + ); + } + + /** @psalm-suppress MixedAssignment */ + $proofValues = reset($proofs); + + if (!is_array($proofValues) || !array_is_list($proofValues) || $proofValues === []) { + throw new CredentialRequestException( + 'invalid_proof', + 'The "proofs" parameter must carry a non-empty array of key proofs.', + ); + } + + // Each proof issues a credential and claims a Status List entry of its own, so an uncapped array + // lets one request spend an arbitrary amount of storage and signing work. The same number is + // published as `batch_credential_issuance.batch_size`, so a wallet can see it beforehand. + if (count($proofValues) > ModuleConfig::VCI_BATCH_SIZE) { + throw new CredentialRequestException( + 'invalid_proof', + sprintf( + 'The "proofs" parameter carries more key proofs than the advertised batch size of %d.', + ModuleConfig::VCI_BATCH_SIZE, + ), + ); + } + + $proofJwts = []; + + /** @var mixed $proofValue */ + foreach ($proofValues as $proofValue) { + if (!is_string($proofValue) || $proofValue === '') { + throw new CredentialRequestException( + 'invalid_proof', + 'Every entry in the "proofs" parameter must be a key proof in compact serialization.', + ); + } + + $proofJwts[] = $proofValue; + } + + return $proofJwts; + } + + + /** + * @param non-empty-string $proofJwt + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \SimpleSAML\Error\ConfigurationError + */ + protected function validateProof(string $proofJwt, AccessTokenEntity $accessToken): ValidatedOpenId4VciProof + { + try { + $proof = $this->verifiableCredentials->openId4VciProofFactory()->fromToken($proofJwt); + } catch (Throwable $throwable) { + $this->loggerService->warning('Key proof could not be parsed.', ['error' => $throwable->getMessage()]); + + throw new CredentialRequestException('invalid_proof', 'Key proof could not be parsed.'); + } + + try { + $this->validateAlgorithm($proof); + $this->validateAudience($proof); + $this->validateIssuer($proof, $accessToken); + + [$jwk, $subject, $keyId] = $this->resolveKeySource($proof); + + try { + $proof->verifyWithKey($jwk); + } catch (Throwable $throwable) { + $this->loggerService->warning( + 'Key proof signature could not be verified.', + ['error' => $throwable->getMessage()], + ); + + throw new CredentialRequestException('invalid_proof', 'Key proof signature could not be verified.'); + } + + // After the signature, so a nonce is only ever reported on a proof which is otherwise sound. + $this->validateNonce($proof); + + $this->loggerService->debug('Key proof validated.', ['subject' => $subject]); + + return new ValidatedOpenId4VciProof($proof, $subject, $keyId); + } catch (CredentialRequestException $credentialRequestException) { + throw $credentialRequestException; + } catch (Throwable $throwable) { + // Whatever else went wrong here went wrong about a proof, and a proof this issuer can not + // make sense of is refused rather than allowed to surface as a server error - which would + // be the one path back to issuing without a proof having been checked. + $this->loggerService->warning( + 'Key proof could not be validated.', + ['error' => $throwable->getMessage()], + ); + + throw new CredentialRequestException('invalid_proof', 'Key proof could not be validated.'); + } + } + + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + protected function validateAlgorithm(OpenId4VciProof $proof): void + { + $advertisedAlgorithms = $this->moduleConfig->getSupportedAlgorithms() + ->getSignatureAlgorithmBag() + ->getAllNamesUnique(); + + // Advertised, not merely usable with the key. A wallet was told which algorithms this issuer + // accepts for key proofs, and honouring one it was not told about verifies a signature under + // rules nobody published. + if (!in_array($proof->getAlgorithm(), $advertisedAlgorithms, true)) { + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof is signed with an algorithm this issuer does not advertise for key proofs.', + ); + } + } + + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + protected function validateAudience(OpenId4VciProof $proof): void + { + /** @psalm-suppress MixedAssignment */ + $audience = $proof->getPayloadClaim(ClaimsEnum::Aud->value); + + // The claim as it arrived, rather than through getAudience(), which normalises a string and an + // array into the same shape. What has to hold is that this issuer is the only audience named: + // an array naming this issuer alongside somebody else is a proof built for that other place + // which this issuer merely happens to be listed in, and normalising first leaves nothing to + // tell the two apart but a count. Both spellings of a single audience are accepted, since + // RFC 7519 allows either and they say the same thing. + if (is_array($audience) && array_is_list($audience) && count($audience) === 1) { + /** @psalm-suppress MixedAssignment */ + $audience = reset($audience); + } + + if (!is_string($audience) || $audience !== $this->moduleConfig->getIssuer()) { + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof is not addressed to this Credential Issuer, or names another audience ' . + 'alongside it.', + ); + } + } + + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + protected function validateIssuer(OpenId4VciProof $proof, AccessTokenEntity $accessToken): void + { + $proofIssuer = $proof->getIssuer(); + + // A pre-authorized code redeemed without a `client_id` identifies no wallet at all, so there is + // nothing an `iss` claim could be checked against, and OpenID4VCI has the wallet omit it. + // Recognised from the flow plus the absence of a bound client id rather than from the stored + // client entity, which names the client the offer was created for either way. + if ( + $accessToken->getFlowTypeEnum() === FlowTypeEnum::VciPreAuthorizedCode && + $accessToken->getBoundClientId() === null + ) { + if ($proofIssuer !== null) { + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof must not carry an "iss" claim, because the access token it accompanies ' . + 'identifies no client.', + ); + } + + return; + } + + // Absence is accepted. OpenID4VCI constrains this claim when it is present; requiring it here + // would refuse a proof the specification permits, and it is the DIIP profile, applied per + // credential configuration, where presence becomes a requirement. + if ($proofIssuer === null) { + return; + } + + // The identifier a non-registered wallet is actually known by travels separately from the client + // entity, which in those flows is a stand-in shared by every such wallet. + $clientId = $accessToken->getBoundClientId() ?? $accessToken->getClient()->getIdentifier(); + + if ($proofIssuer !== $clientId) { + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof "iss" claim does not name the client the access token was issued to.', + ); + } + } + + + /** + * Work out which key the proof is verified against, and what its credential is bound to. + * + * @return array{0: mixed[], 1: string, 2: ?string} The key, the holder identifier, and the + * verification method the proof named. + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \JsonException + */ + protected function resolveKeySource(OpenId4VciProof $proof): array + { + $keyId = $proof->getKeyId(); + $headerJwk = $proof->getJsonWebKey(); + $certificateChain = $proof->getX509CertificateChain(); + + $keySources = array_filter( + [$keyId, $headerJwk, $certificateChain], + static fn(mixed $keySource): bool => $keySource !== null, + ); + + if (count($keySources) !== 1) { + throw new CredentialRequestException( + 'invalid_proof', + 'The key proof header must carry exactly one of "kid", "jwk" or "x5c".', + ); + } + + if ($certificateChain !== null) { + throw new CredentialRequestException( + 'invalid_proof', + 'Key proofs carrying an "x5c" header are not supported by this issuer.', + ); + } + + if ($headerJwk !== null) { + $this->assertPublicJwk($headerJwk); + + try { + $subject = $this->did->didJwkResolver()->generateDidJwkFromJwk($headerJwk); + } catch (JsonException) { + throw new CredentialRequestException( + 'invalid_proof', + 'The "jwk" header of the key proof could not be read as a key.', + ); + } + + // No verification method was named, so there is none to carry into the credential's `cnf`. + return [$headerJwk, $subject, null]; + } + + /** @var non-empty-string $keyId */ + $did = explode('#', $keyId, 2)[0]; + + try { + if (str_starts_with($keyId, 'did:key:z')) { + return [$this->did->didKeyResolver()->extractJwkFromDidKey($did), $did, $keyId]; + } + + if (str_starts_with($keyId, 'did:jwk:')) { + return [$this->did->didJwkResolver()->extractJwkFromDidJwk($did), $did, $keyId]; + } + } catch (Throwable $throwable) { + $this->loggerService->warning( + 'Key proof names a verification method which could not be resolved.', + ['error' => $throwable->getMessage()], + ); + + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof "kid" header names a verification method which could not be resolved.', + ); + } + + $this->loggerService->warning('Key proof names a verification method of an unsupported type.'); + + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof "kid" header names a verification method this issuer can not resolve.', + ); + } + + + /** + * @param mixed[] $jwk + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + */ + protected function assertPublicJwk(array $jwk): void + { + /** @psalm-suppress MixedAssignment */ + $keyType = $jwk['kty'] ?? null; + + if (!is_string($keyType) || !array_key_exists($keyType, self::PUBLIC_JWK_MEMBERS_BY_KEY_TYPE)) { + throw new CredentialRequestException( + 'invalid_proof', + 'The "jwk" header of the key proof is of a key type this issuer does not accept.', + ); + } + + $allowedMembers = array_merge( + self::COMMON_JWK_MEMBERS, + self::PUBLIC_JWK_MEMBERS_BY_KEY_TYPE[$keyType], + ); + + $unexpectedMembers = array_diff(array_keys($jwk), $allowedMembers); + + if ($unexpectedMembers !== []) { + // The member names go to the log rather than into the response. As far as this issuer knows + // they are attacker-chosen text, and echoing them back says nothing that naming the key type + // does not. + $this->loggerService->warning( + 'The "jwk" header of a key proof carried members which are not public key material.', + ['keyType' => $keyType, 'members' => array_values($unexpectedMembers)], + ); + + throw new CredentialRequestException( + 'invalid_proof', + sprintf( + 'The "jwk" header of the key proof carries members which are not part of a public ' . + '"%s" key. Private key material must never be sent.', + $keyType, + ), + ); + } + } + + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + protected function validateNonce(OpenId4VciProof $proof): void + { + $nonce = $proof->getNonce(); + + // This issuer publishes a Nonce Endpoint, which is what makes the nonce mandatory rather than + // optional. Without it a proof stays replayable for as long as it remains unexpired. + if (!is_string($nonce)) { + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof must carry a "nonce" claim obtained from the Nonce Endpoint.', + ); + } + + if (!$this->nonceService->validateNonce($nonce)) { + // Deliberately its own error code, so a wallet knows to ask for a fresh nonce and retry + // rather than to go looking for a fault in the proof it built. + throw new CredentialRequestException('invalid_nonce', 'c_nonce is invalid or expired.'); + } + } +} diff --git a/src/VerifiableCredentials/Values/ValidatedOpenId4VciProof.php b/src/VerifiableCredentials/Values/ValidatedOpenId4VciProof.php new file mode 100644 index 00000000..889541d9 --- /dev/null +++ b/src/VerifiableCredentials/Values/ValidatedOpenId4VciProof.php @@ -0,0 +1,47 @@ +proof; + } + + + public function getSubject(): string + { + return $this->subject; + } + + + public function getKeyId(): ?string + { + return $this->keyId; + } +} diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php index 798c5421..4969fa01 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -67,9 +68,12 @@ class CredentialIssuerConfigurationControllerTest extends TestCase protected SignatureKeyPairBag $vciSignatureKeyPairBag; + protected VciCredentialBindingPolicyEnum $bindingPolicy; + protected function setUp(): void { + $this->bindingPolicy = VciCredentialBindingPolicyEnum::ProofBound; $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->routesMock = $this->createMock(Routes::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); @@ -82,6 +86,8 @@ protected function setUp(): void $this->moduleConfigMock->method('getLogoUri')->willReturn('https://issuer.com/logo.png'); $this->moduleConfigMock->method('getVciCredentialConfigurationsSupported') ->willReturn($this->credentialConfigurations()); + $this->moduleConfigMock->method('getVciCredentialBindingPolicyFor') + ->willReturnCallback(fn(): VciCredentialBindingPolicyEnum => $this->bindingPolicy); // Two pairs differing in algorithm, so that an assertion on the advertised algorithm can tell // the active signing key apart from merely "one of the configured ones". @@ -202,6 +208,102 @@ public function testDescribesWhatEachConfigurationCanBeProvedAndSignedWith(): vo } + /** + * The credential endpoint refuses a `proofs` array longer than this, so a wallet has to be able to + * find out what the limit is before it builds one. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testPublishesTheBatchSizeItActuallyEnforces(): void + { + $metadata = $this->publishedMetadata(); + + $this->assertSame( + [ClaimsEnum::BatchSize->value => ModuleConfig::VCI_BATCH_SIZE], + $metadata[ClaimsEnum::BatchCredentialIssuance->value] ?? null, + ); + } + + + /** + * A configuration issuing credentials which are not bound to a wallet key must advertise neither + * binding field, not just one of them. + * + * OpenID4VCI requires `proof_types_supported` wherever `cryptographic_binding_methods_supported` + * appears, and requires a Credential Request to carry `proofs` wherever `proof_types_supported` + * appears. So leaving either behind would either produce a malformed document or promise a binding + * this configuration does not perform, which is exactly the state this module used to publish. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testAProoflessConfigurationAdvertisesNoBindingAtAll(): void + { + $this->bindingPolicy = VciCredentialBindingPolicyEnum::Proofless; + + $metadata = $this->publishedMetadata(); + + /** @var array> $configurations */ + $configurations = $metadata[ClaimsEnum::CredentialConfigurationsSupported->value]; + $configuration = $configurations[self::CONFIGURATION_ID]; + + $this->assertArrayNotHasKey(ClaimsEnum::CryptographicBindingMethodsSupported->value, $configuration); + $this->assertArrayNotHasKey(ClaimsEnum::ProofTypesSupported->value, $configuration); + + // Batching is something key proofs do, so an issuer with nothing to prove against advertises no + // batch size either. + $this->assertArrayNotHasKey(ClaimsEnum::BatchCredentialIssuance->value, $metadata); + + // The rest of the configuration is published exactly as before. + $this->assertSame('UniversityDegree', $configuration[ClaimsEnum::Scope->value]); + } + + + /** + * A binding field the operator wrote into the credential configuration itself is republished + * verbatim, so a proofless configuration has to have it taken out rather than merely not added. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testAProoflessConfigurationDropsBindingFieldsTheOperatorWroteIn(): void + { + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getVciEnabled')->willReturn(true); + $moduleConfigMock->method('getVciCredentialBindingPolicyFor') + ->willReturn(VciCredentialBindingPolicyEnum::Proofless); + $moduleConfigMock->method('getActiveVciSignatureKeyPair') + ->willReturn($this->vciSignatureKeyPairBag->getFirstOrFail()); + $moduleConfigMock->method('getVciCredentialConfigurationsSupported')->willReturn([ + self::CONFIGURATION_ID => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + ClaimsEnum::CryptographicBindingMethodsSupported->value => ['did:jwk'], + ClaimsEnum::ProofTypesSupported->value => ['jwt' => []], + ], + ]); + + $controller = new CredentialIssuerConfigurationController( + $moduleConfigMock, + $this->routesMock, + $this->loggerServiceMock, + $this->vciContextResolverMock, + ); + + $content = $controller->configuration()->getContent(); + $this->assertIsString($content); + /** @var array $metadata */ + $metadata = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + /** @var array> $configurations */ + $configurations = $metadata[ClaimsEnum::CredentialConfigurationsSupported->value]; + $configuration = $configurations[self::CONFIGURATION_ID]; + + $this->assertArrayNotHasKey(ClaimsEnum::CryptographicBindingMethodsSupported->value, $configuration); + $this->assertArrayNotHasKey(ClaimsEnum::ProofTypesSupported->value, $configuration); + } + + /** * A wallet is told which algorithm a credential will come back signed with, and that has to be the * algorithm of the key which will actually sign it. The two are separate calls made by separate diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php index 7db27515..867eccc3 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php @@ -13,9 +13,11 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; +use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; +use SimpleSAML\Module\oidc\Exceptions\CredentialRequestException; use SimpleSAML\Module\oidc\Exceptions\StatusListException; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; @@ -24,11 +26,12 @@ use SimpleSAML\Module\oidc\Repositories\UserRepository; 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\Module\oidc\VerifiableCredentials\OpenId4VciProofValidator; +use SimpleSAML\Module\oidc\VerifiableCredentials\Values\ValidatedOpenId4VciProof; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; @@ -43,7 +46,6 @@ use SimpleSAML\OpenID\ValueAbstracts\KeyPair; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; 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; @@ -64,6 +66,8 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected const string STATUS_LIST_URI = 'https://issuer.com/module.php/oidc/statuslist/list-1'; + protected const string HOLDER_DID = 'did:jwk:holder'; + protected MockObject $resourceServerMock; @@ -87,7 +91,7 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected MockObject $issuerStateRepositoryMock; - protected MockObject $nonceServiceMock; + protected MockObject $openId4VciProofValidatorMock; protected MockObject $vciContextResolverMock; @@ -105,6 +109,8 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected MockObject $vciPrivateKeyMock; + protected VciCredentialBindingPolicyEnum $bindingPolicy; + public function setUp(): void { @@ -119,7 +125,7 @@ public function setUp(): void $this->userRepositoryMock = $this->createMock(UserRepository::class); $this->didMock = $this->createMock(Did::class); $this->issuerStateRepositoryMock = $this->createMock(IssuerStateRepository::class); - $this->nonceServiceMock = $this->createMock(NonceService::class); + $this->openId4VciProofValidatorMock = $this->createMock(OpenId4VciProofValidator::class); $this->vciContextResolverMock = $this->createMock(VciContextResolver::class); $this->credentialStatusIssuerMock = $this->createMock(CredentialStatusIssuer::class); $this->helpers = new Helpers(); @@ -131,6 +137,9 @@ public function setUp(): void $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); $this->moduleConfigMock->method('getVciValidCredentialClaimPathsFor')->willReturn([]); $this->moduleConfigMock->method('getVciUserAttributeToCredentialClaimPathMapFor')->willReturn([]); + $this->bindingPolicy = VciCredentialBindingPolicyEnum::ProofBound; + $this->moduleConfigMock->method('getVciCredentialBindingPolicyFor') + ->willReturnCallback(fn(): VciCredentialBindingPolicyEnum => $this->bindingPolicy); $this->prepareRequestPipeline(); $this->prepareUser(); @@ -243,6 +252,12 @@ function (mixed $key, mixed $algorithm, array $payload) use ($vcSdJwtMock): VcSd /** + * Issue against a proof-bound configuration, with the key proofs already validated. + * + * What a key proof has to satisfy before it gets this far is OpenId4VciProofValidatorTest's + * subject; here the validator stands in for it and hands back what it resolved, so these tests are + * about what issuance does with that. + * * @param string[] $proofJwts */ protected function issue( @@ -259,19 +274,25 @@ protected function issue( $this->requestParamsResolverMock->method('getAllFromRequestBasedOnAllowedMethods') ->willReturn($requestData); - $proofFactoryMock = $this->createMock(OpenId4VciProofFactory::class); - $this->verifiableCredentialsMock->method('openId4VciProofFactory')->willReturn($proofFactoryMock); - - $proofMocks = []; + $validatedProofs = []; 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; + $validatedProofs[] = new ValidatedOpenId4VciProof( + $this->createMock(OpenId4VciProof::class), + self::HOLDER_DID, + self::HOLDER_DID . '#0', + ); } - $proofFactoryMock->method('fromToken')->willReturnOnConsecutiveCalls(...$proofMocks); + $this->openId4VciProofValidatorMock->method('validateRequest')->willReturn($validatedProofs); + $this->dispatch($requestData); + } + + + /** + * @param array $requestData + */ + protected function dispatch(array $requestData): void + { $request = new Request([], [], [], [], [], [], json_encode($requestData)); $request->setMethod('POST'); @@ -293,7 +314,7 @@ protected function sut(): CredentialIssuerCredentialController $this->userRepositoryMock, $this->didMock, $this->issuerStateRepositoryMock, - $this->nonceServiceMock, + $this->openId4VciProofValidatorMock, $this->vciContextResolverMock, $this->credentialStatusIssuerMock, $this->helpers, @@ -331,6 +352,103 @@ public function testSignsEveryCredentialWithTheActiveSigningKey(): void } + /** + * The credential is issued to the holder identifier the key proof resolved to, and says so in the + * same two places it always has. + */ + public function testBindsTheCredentialToWhatTheProofResolvedTo(): void + { + $this->issue(); + + $payload = $this->signedPayloads[0]; + + $this->assertSame(self::HOLDER_DID, $payload[ClaimsEnum::Sub->value] ?? null); + $this->assertSame( + self::HOLDER_DID, + $payload[ClaimsEnum::Vc->value][ClaimsEnum::Credential_Subject->value][ClaimsEnum::Id->value] ?? null, + ); + } + + + /** + * A configuration which advertises no proof type issues one credential, to a subject identifier of + * this issuer's own making, and asks the validator for nothing more than that. + */ + public function testAProoflessConfigurationIssuesOneUnboundCredential(): void + { + $this->bindingPolicy = VciCredentialBindingPolicyEnum::Proofless; + + $this->moduleConfigMock->method('getVciCredentialConfiguration') + ->willReturn([ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::DcSdJwt->value]); + $this->requestParamsResolverMock->method('getAllFromRequestBasedOnAllowedMethods') + ->willReturn(['credential_configuration_id' => self::CONFIGURATION_ID]); + $this->openId4VciProofValidatorMock->method('validateRequest')->willReturn([null]); + + $this->dispatch(['credential_configuration_id' => self::CONFIGURATION_ID]); + + $this->assertCount(1, $this->signedPayloads); + + $payload = $this->signedPayloads[0]; + + $this->assertSame(self::ISSUER . '/sub/user123', $payload[ClaimsEnum::Sub->value] ?? null); + // Nothing was proved, so there is no key to confirm the credential is held by. + $this->assertArrayNotHasKey(ClaimsEnum::Cnf->value, $payload); + } + + + /** + * The refusal keeps the error code the check that made it chose. Flattening every refusal to + * `invalid_proof` would tell a wallet whose nonce merely went stale to go looking for a fault in + * the proof it built, instead of fetching a fresh nonce and retrying. + */ + public function testAnswersARefusedRequestWithTheErrorCodeTheRefusalCarried(): void + { + $this->moduleConfigMock->method('getVciCredentialConfiguration') + ->willReturn([ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value]); + $this->requestParamsResolverMock->method('getAllFromRequestBasedOnAllowedMethods') + ->willReturn(['credential_configuration_id' => self::CONFIGURATION_ID]); + $this->openId4VciProofValidatorMock->method('validateRequest')->willThrowException( + new CredentialRequestException('invalid_nonce', 'c_nonce is invalid or expired.'), + ); + + $this->routesMock->expects($this->never())->method('newJsonResponse'); + $this->routesMock->expects($this->once()) + ->method('newJsonErrorResponse') + ->with('invalid_nonce', $this->anything(), 400) + ->willReturn($this->createMock(JsonResponse::class)); + + $this->dispatch(['credential_configuration_id' => self::CONFIGURATION_ID]); + + $this->assertSame([], $this->signedPayloads); + } + + + /** + * Nothing is allocated or signed until every proof in the request has passed. + * + * A Status List entry claimed for a credential which is then never issued can not be handed back: + * the index stays spent, and the list it came from can only be retired once every entry in it has + * expired. So a request refused on its last proof must leave no trace of its first. + */ + public function testAllocatesNothingWhenTheRequestIsRefused(): void + { + $this->moduleConfigMock->method('getVciCredentialConfiguration') + ->willReturn([ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value]); + $this->requestParamsResolverMock->method('getAllFromRequestBasedOnAllowedMethods') + ->willReturn(['credential_configuration_id' => self::CONFIGURATION_ID]); + $this->openId4VciProofValidatorMock->method('validateRequest')->willThrowException( + new CredentialRequestException('invalid_proof', 'Key proof signature could not be verified.'), + ); + + $this->credentialStatusIssuerMock->expects($this->never())->method('issueFor'); + $this->routesMock->method('newJsonErrorResponse')->willReturn($this->createMock(JsonResponse::class)); + + $this->dispatch(['credential_configuration_id' => self::CONFIGURATION_ID]); + + $this->assertSame([], $this->signedPayloads); + } + + public function testCredentialWithMultipleProofs(): void { $this->routesMock->expects($this->once()) diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 6bfb5b39..295bb3dd 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -18,6 +18,7 @@ use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListExpiryLaneEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; +use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; @@ -1188,6 +1189,81 @@ public function testCredentialLifetimesRejectADurationOfNoTime(): void } + /** + * The metadata this module publishes has always said a key proof is required, so requiring one is + * what an unlisted configuration keeps doing. Issuing credentials which are bound to nothing is the + * thing an operator opts into. + * + * @throws \Exception + */ + public function testCredentialsAreBoundToAHolderKeyUnlessConfiguredOtherwise(): void + { + $sut = $this->sut(); + + $this->assertSame([], $sut->getVciCredentialBindingPolicies()); + $this->assertSame( + VciCredentialBindingPolicyEnum::ProofBound, + $sut->getVciCredentialBindingPolicyFor('TestCredential'), + ); + } + + + /** + * @throws \Exception + */ + public function testResolvesTheConfiguredCredentialBindingPolicy(): void + { + // Both the enum and its backing value, since the configuration file is PHP and an operator may + // reasonably write either. + foreach ([VciCredentialBindingPolicyEnum::Proofless, 'proofless'] as $configured) { + $sut = $this->sut(overrides: $this->withCredentialBindingPolicy($configured)); + + $this->assertSame( + VciCredentialBindingPolicyEnum::Proofless, + $sut->getVciCredentialBindingPolicyFor('TestCredential'), + ); + // Configurations which are not listed keep requiring a key proof. + $this->assertSame( + VciCredentialBindingPolicyEnum::ProofBound, + $sut->getVciCredentialBindingPolicyFor('SomethingElse'), + ); + } + } + + + /** + * A typo would otherwise be silent, and the configuration it was meant for would go on demanding + * key proofs the wallet was never going to send. + * + * @throws \Exception + */ + public function testCredentialBindingPoliciesRejectAnUnknownCredentialConfiguration(): 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_BINDING_POLICIES => ['NoSuchCredential' => 'proofless'], + ], + ))->getVciCredentialBindingPolicies(); + } + + + /** + * @throws \Exception + */ + public function testCredentialBindingPoliciesRejectAnUnknownPolicy(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withCredentialBindingPolicy('sometimes')) + ->getVciCredentialBindingPolicies(); + } + + /** * The shape this option has always had, which has to go on working. * @@ -1446,6 +1522,21 @@ protected function withCredentialTtl(mixed $ttl): array } + /** + * @return array + */ + protected function withCredentialBindingPolicy(mixed $bindingPolicy): array + { + return array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED => ['TestCredential' => []], + ModuleConfig::OPTION_VCI_CREDENTIAL_BINDING_POLICIES => ['TestCredential' => $bindingPolicy], + ], + ); + } + + /** * @return array */ diff --git a/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php b/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php new file mode 100644 index 00000000..7ed70bc9 --- /dev/null +++ b/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php @@ -0,0 +1,644 @@ + A public EC key, as a wallet would send it in a `jwk` header. */ + protected const array PUBLIC_EC_JWK = [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'x-value', + 'y' => 'y-value', + ]; + + + protected MockObject $moduleConfigMock; + + protected MockObject $verifiableCredentialsMock; + + protected MockObject $didMock; + + protected MockObject $nonceServiceMock; + + protected MockObject $loggerServiceMock; + + protected MockObject $proofFactoryMock; + + protected MockObject $didJwkResolverMock; + + protected MockObject $didKeyResolverMock; + + protected MockObject $accessTokenMock; + + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->verifiableCredentialsMock = $this->createMock(VerifiableCredentialsService::class); + $this->didMock = $this->createMock(Did::class); + $this->nonceServiceMock = $this->createMock(NonceService::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + + $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + // One advertised algorithm, so a proof signed with any other is unadvertised rather than merely + // unusual. + $this->moduleConfigMock->method('getSupportedAlgorithms')->willReturn( + new SupportedAlgorithms(new SignatureAlgorithmBag(SignatureAlgorithmEnum::ES256)), + ); + + $this->proofFactoryMock = $this->createMock(OpenId4VciProofFactory::class); + $this->verifiableCredentialsMock->method('openId4VciProofFactory')->willReturn($this->proofFactoryMock); + + $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); + $this->didKeyResolverMock = $this->createMock(DidKeyJwkResolver::class); + $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + $this->didMock->method('didKeyResolver')->willReturn($this->didKeyResolverMock); + $this->didJwkResolverMock->method('extractJwkFromDidJwk')->willReturn(self::PUBLIC_EC_JWK); + $this->didJwkResolverMock->method('generateDidJwkFromJwk')->willReturn(self::HOLDER_DID); + $this->didKeyResolverMock->method('extractJwkFromDidKey')->willReturn(self::PUBLIC_EC_JWK); + + $this->nonceServiceMock->method('validateNonce')->willReturn(true); + + // An authenticated wallet by default, so the anonymous pre-authorized rules are opted into by + // the tests which are about them rather than applying everywhere. + $clientMock = $this->createMock(ClientEntityInterface::class); + $clientMock->method('getIdentifier')->willReturn(self::CLIENT_ID); + $this->accessTokenMock = $this->createMock(AccessTokenEntity::class); + $this->accessTokenMock->method('getFlowTypeEnum')->willReturn(FlowTypeEnum::VciAuthorizationCode); + $this->accessTokenMock->method('getBoundClientId')->willReturn(null); + $this->accessTokenMock->method('getClient')->willReturn($clientMock); + } + + + protected function sut(): OpenId4VciProofValidator + { + return new OpenId4VciProofValidator( + $this->moduleConfigMock, + $this->verifiableCredentialsMock, + $this->didMock, + $this->nonceServiceMock, + $this->loggerServiceMock, + ); + } + + + /** + * A key proof which passes everything, so that each test can spoil exactly one thing about it. + * + * @param array $overrides + */ + protected function proofMock(array $overrides = []): MockObject + { + $claims = array_merge( + [ + 'getAlgorithm' => SignatureAlgorithmEnum::ES256->value, + 'getIssuer' => self::CLIENT_ID, + 'getKeyId' => self::HOLDER_DID_URL, + 'getJsonWebKey' => null, + 'getX509CertificateChain' => null, + 'getNonce' => 'nonce-value', + ], + $overrides, + ); + + // The audience is read off the raw payload claim rather than through getAudience(), because its + // original type is what decides whether the proof is addressed to this issuer alone. + /** @var mixed $audience */ + $audience = array_key_exists('aud', $claims) ? $claims['aud'] : self::ISSUER; + unset($claims['aud']); + + $proofMock = $this->createMock(OpenId4VciProof::class); + $proofMock->method('getPayloadClaim')->willReturnCallback( + /** @return mixed */ + static fn(string $key): mixed => $key === ClaimsEnum::Aud->value ? $audience : null, + ); + + /** @var mixed $value */ + foreach ($claims as $method => $value) { + $proofMock->method($method)->willReturn($value); + } + + return $proofMock; + } + + + /** + * @param array $overrides + * @return array + */ + protected function requestWith(array $overrides = [], int $proofCount = 1): array + { + $this->proofFactoryMock->method('fromToken')->willReturnCallback( + fn(): OpenId4VciProof => $this->proofMock($overrides), + ); + + return ['proofs' => ['jwt' => array_fill(0, $proofCount, 'proof-jwt')]]; + } + + + /** + * @param array $requestData + */ + protected function assertRefusedWith( + string $expectedErrorCode, + array $requestData, + VciCredentialBindingPolicyEnum $bindingPolicy = VciCredentialBindingPolicyEnum::ProofBound, + ): void { + try { + $this->sut()->validateRequest($requestData, $bindingPolicy, $this->accessTokenMock); + } catch (CredentialRequestException $credentialRequestException) { + $this->assertSame($expectedErrorCode, $credentialRequestException->getErrorCode()); + + return; + } + + $this->fail(sprintf('The request was not refused, and "%s" was expected.', $expectedErrorCode)); + } + + + /***************************************************************************************************** + * Whether a proof is required at all. + ****************************************************************************************************/ + + /** + * @throws \Throwable + */ + public function testAcceptsAValidProof(): void + { + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertCount(1, $validatedProofs); + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID, $validatedProofs[0]->getSubject()); + // The verification method the wallet named, so the credential's `cnf` claim can carry it. + $this->assertSame(self::HOLDER_DID_URL, $validatedProofs[0]->getKeyId()); + } + + + /** + * The metadata this configuration publishes says a key proof is required, so issuing without one + * would be handing out a credential bound to nothing while claiming otherwise. + */ + public function testRefusesAProofBoundRequestWhichCarriesNoProof(): void + { + $this->assertRefusedWith('invalid_proof', []); + } + + + /** + * The pre-final singular parameter is not the shape a configuration advertising the final + * `proof_types_supported` describes. + */ + public function testRefusesTheSingularProofParameter(): void + { + $this->assertRefusedWith('invalid_proof', ['proof' => ['proof_type' => 'jwt', 'jwt' => 'proof-jwt']]); + } + + + /** + * @throws \Throwable + */ + public function testAProoflessConfigurationNeedsNoProof(): void + { + $validatedProofs = $this->sut()->validateRequest( + [], + VciCredentialBindingPolicyEnum::Proofless, + $this->accessTokenMock, + ); + + // One credential, bound to nothing, which is what the caller reads a null entry as. + $this->assertSame([null], $validatedProofs); + } + + + /** + * Nothing told this wallet which proof type or signing algorithm to build a proof with, so honouring + * one it sent anyway would mean accepting a proof under rules that were never published. Ignoring it + * silently is worse still: the wallet would get back a credential bound to something else, with + * nothing on it to say its key went unused. + */ + public function testAProoflessConfigurationRefusesASuppliedProof(): void + { + $this->assertRefusedWith( + 'invalid_credential_request', + ['proofs' => ['jwt' => ['proof-jwt']]], + VciCredentialBindingPolicyEnum::Proofless, + ); + + $this->assertRefusedWith( + 'invalid_credential_request', + ['proof' => ['proof_type' => 'jwt', 'jwt' => 'proof-jwt']], + VciCredentialBindingPolicyEnum::Proofless, + ); + } + + + /***************************************************************************************************** + * The shape of the `proofs` envelope, which used to be flattened before it was looked at. + ****************************************************************************************************/ + + public function testRefusesAnEnvelopeNamingMoreThanOneProofType(): void + { + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => ['a'], 'ldp_vp' => ['b']]]); + } + + + public function testRefusesAnUnsupportedProofType(): void + { + $this->assertRefusedWith('invalid_proof', ['proofs' => ['ldp_vp' => ['a']]]); + } + + + public function testRefusesAnEnvelopeWhoseProofsAreNotANonEmptyList(): void + { + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => []]]); + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => 'proof-jwt']]); + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => ['first' => 'proof-jwt']]]); + } + + + /** + * A malformed entry used to be skipped, so a request could quietly be issued fewer credentials than + * it asked for. + */ + public function testRefusesAnEnvelopeCarryingAMalformedProof(): void + { + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => ['proof-jwt', 42]]]); + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => ['proof-jwt', '']]]); + } + + + /** + * Every proof issues a credential and claims a Status List entry of its own, so an uncapped array + * lets one authenticated request spend an arbitrary amount of storage and signing work. + * + * @throws \Throwable + */ + public function testRefusesMoreProofsThanTheAdvertisedBatchSize(): void + { + $atTheLimit = $this->sut()->validateRequest( + $this->requestWith(proofCount: ModuleConfig::VCI_BATCH_SIZE), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + $this->assertCount(ModuleConfig::VCI_BATCH_SIZE, $atTheLimit); + + $this->setUp(); + $this->assertRefusedWith( + 'invalid_proof', + ['proofs' => ['jwt' => array_fill(0, ModuleConfig::VCI_BATCH_SIZE + 1, 'proof-jwt')]], + ); + } + + + /***************************************************************************************************** + * The JOSE header, whose shape used to be unconstrained. + ****************************************************************************************************/ + + public function testRefusesAHeaderNamingNoKeySource(): void + { + $this->assertRefusedWith('invalid_proof', $this->requestWith(['getKeyId' => null])); + } + + + public function testRefusesAHeaderNamingMoreThanOneKeySource(): void + { + $this->assertRefusedWith('invalid_proof', $this->requestWith(['getJsonWebKey' => self::PUBLIC_EC_JWK])); + } + + + /** + * Accepted by the parser and ignored by this module, which meant a proof presented as certificate + * bound was verified against something else entirely. + */ + public function testRefusesACertificateChainHeader(): void + { + $this->assertRefusedWith( + 'invalid_proof', + $this->requestWith(['getKeyId' => null, 'getX509CertificateChain' => ['cert']]), + ); + } + + + /** + * @throws \Throwable + */ + public function testAcceptsAProofCarryingItsPublicKeyInline(): void + { + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(['getKeyId' => null, 'getJsonWebKey' => self::PUBLIC_EC_JWK]), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID, $validatedProofs[0]->getSubject()); + // No verification method was named, so there is none to carry into the credential. + $this->assertNull($validatedProofs[0]->getKeyId()); + } + + + /** + * A `jwk` header goes straight into the `did:jwk` this module synthesises for the credential's + * subject, so a private member left in one would be published inside the credential the wallet + * itself asked for. + * + * The members are refused by allowing only what a public key of that type is made of, so this holds + * for a member nobody thought to list, not just for the ones named here. + */ + public function testRefusesPrivateKeyMaterialInAnInlineKey(): void + { + foreach (['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k', 'invented_member'] as $privateMember) { + $this->setUp(); + + $this->assertRefusedWith( + 'invalid_proof', + $this->requestWith([ + 'getKeyId' => null, + 'getJsonWebKey' => array_merge(self::PUBLIC_EC_JWK, [$privateMember => 'secret']), + ]), + ); + } + } + + + /** + * A shared secret proves possession to nobody, so it is not something a key proof can be built on. + */ + public function testRefusesASymmetricInlineKey(): void + { + $this->assertRefusedWith( + 'invalid_proof', + $this->requestWith(['getKeyId' => null, 'getJsonWebKey' => ['kty' => 'oct']]), + ); + } + + + /** + * A wallet was told which algorithms this issuer accepts for key proofs, so verifying under one it + * was never told about applies rules nobody published. + */ + public function testRefusesAnUnadvertisedSigningAlgorithm(): void + { + $this->assertRefusedWith( + 'invalid_proof', + $this->requestWith(['getAlgorithm' => SignatureAlgorithmEnum::RS256->value]), + ); + } + + + /** + * `did:web` arrives in a later step; until then an unresolvable method has to be refused rather than + * fallen through, which is what left proofs unverified. + */ + public function testRefusesAVerificationMethodItCanNotResolve(): void + { + $this->assertRefusedWith('invalid_proof', $this->requestWith(['getKeyId' => 'did:web:example.org#0'])); + $this->assertRefusedWith('invalid_proof', $this->requestWith(['getKeyId' => 'not-a-did'])); + } + + + public function testRefusesAVerificationMethodWhichFailsToResolve(): void + { + $didJwkResolverMock = $this->createMock(DidJwkResolver::class); + $didJwkResolverMock->method('extractJwkFromDidJwk')->willThrowException(new DidException('malformed')); + $this->didMock = $this->createMock(Did::class); + $this->didMock->method('didJwkResolver')->willReturn($didJwkResolverMock); + + $this->assertRefusedWith('invalid_proof', $this->requestWith()); + } + + + /***************************************************************************************************** + * The claims. + ****************************************************************************************************/ + + /** + * A membership test accepts a proof addressed to this issuer and to somewhere else at the same time, + * which is a proof built for that other place which this issuer merely happens to be named in. + */ + public function testRefusesAnAudienceWhichNamesAnyoneElseAsWell(): void + { + $this->assertRefusedWith( + 'invalid_proof', + $this->requestWith(['aud' => [self::ISSUER, 'https://somewhere-else.example.org']]), + ); + } + + + public function testRefusesAnAudienceWhichDoesNotNameThisIssuer(): void + { + $this->assertRefusedWith('invalid_proof', $this->requestWith(['aud' => 'https://somewhere-else.example.org'])); + } + + + /** + * RFC 7519 lets an audience be written either way, and a one-element array says exactly what the + * string does. What is refused is a second audience, not the spelling. + * + * @throws \Throwable + */ + public function testAcceptsASingleAudienceWrittenAsAnArray(): void + { + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(['aud' => [self::ISSUER]]), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertCount(1, $validatedProofs); + } + + + /** + * Why the claim is read before the library normalises it: an object is not an audience, but it + * survives normalisation as a one-element array and would then pass for one. + */ + public function testRefusesAnAudienceWhichIsNeitherAStringNorAList(): void + { + $this->assertRefusedWith('invalid_proof', $this->requestWith(['aud' => ['primary' => self::ISSUER]])); + } + + + public function testRefusesAProofCarryingNoAudience(): void + { + $this->assertRefusedWith('invalid_proof', $this->requestWith(['aud' => null])); + } + + + /** + * OpenID4VCI constrains this claim when it is present rather than requiring it, so refusing a proof + * without one would refuse a proof the specification permits. + * + * @throws \Throwable + */ + public function testAcceptsAProofWithNoIssuerClaim(): void + { + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(['getIssuer' => null]), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertCount(1, $validatedProofs); + } + + + public function testRefusesAnIssuerClaimNamingAnotherClient(): void + { + $this->assertRefusedWith( + 'invalid_proof', + $this->requestWith(['getIssuer' => 'https://another-wallet.example.org']), + ); + } + + + /** + * A wallet which is not a registered client is still identified, by the `client_id` it sent, and + * that identifier is kept apart from the client entity - which for those flows is a stand-in shared + * by every such wallet. Comparing against the entity would refuse every non-registered wallet. + * + * @throws \Throwable + */ + public function testComparesTheIssuerClaimAgainstTheBoundClientId(): void + { + $clientMock = $this->createMock(ClientEntityInterface::class); + $clientMock->method('getIdentifier')->willReturn('generic-vci-client'); + $this->accessTokenMock = $this->createMock(AccessTokenEntity::class); + $this->accessTokenMock->method('getFlowTypeEnum')->willReturn(FlowTypeEnum::VciPreAuthorizedCode); + $this->accessTokenMock->method('getBoundClientId')->willReturn(self::CLIENT_ID); + $this->accessTokenMock->method('getClient')->willReturn($clientMock); + + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertCount(1, $validatedProofs); + } + + + /** + * A pre-authorized code redeemed without a `client_id` identifies no wallet, so there is nothing an + * `iss` claim could be checked against and OpenID4VCI has the wallet leave it out. + */ + public function testRefusesAnIssuerClaimWhenTheAccessTokenIdentifiesNoClient(): void + { + $this->accessTokenMock = $this->createMock(AccessTokenEntity::class); + $this->accessTokenMock->method('getFlowTypeEnum')->willReturn(FlowTypeEnum::VciPreAuthorizedCode); + $this->accessTokenMock->method('getBoundClientId')->willReturn(null); + + $this->assertRefusedWith('invalid_proof', $this->requestWith()); + } + + + /***************************************************************************************************** + * The signature and the nonce. + ****************************************************************************************************/ + + public function testRefusesAProofWhichCanNotBeParsed(): void + { + $this->proofFactoryMock->method('fromToken')->willThrowException(new JwsException('not a JWS')); + + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => ['proof-jwt']]]); + } + + + public function testRefusesAProofWhoseSignatureDoesNotVerify(): void + { + $proofMock = $this->proofMock(); + $proofMock->method('verifyWithKey')->willThrowException(new JwsException('bad signature')); + $this->proofFactoryMock->method('fromToken')->willReturn($proofMock); + + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => ['proof-jwt']]]); + } + + + /** + * This issuer publishes a Nonce Endpoint, which is what makes the nonce mandatory. Without it the + * proof stays replayable for as long as it remains unexpired. + */ + public function testRefusesAProofCarryingNoNonce(): void + { + $this->assertRefusedWith('invalid_proof', $this->requestWith(['getNonce' => null])); + } + + + /** + * A nonce which was there and did not hold up gets its own code, so a wallet knows to ask for a + * fresh one and retry rather than to go looking for a fault in the proof it built. + */ + public function testAnswersAStaleNonceWithItsOwnErrorCode(): void + { + $this->nonceServiceMock = $this->createMock(NonceService::class); + $this->nonceServiceMock->method('validateNonce')->willReturn(false); + + $this->assertRefusedWith('invalid_nonce', $this->requestWith()); + } + + + /** + * The whole array is validated before the caller issues anything, so a request whose last proof is + * bad leaves no trace of its first. + */ + public function testRefusesTheWholeRequestWhenALaterProofIsBad(): void + { + $goodProof = $this->proofMock(); + $badProof = $this->proofMock(['getNonce' => null]); + $this->proofFactoryMock->method('fromToken')->willReturnOnConsecutiveCalls($goodProof, $badProof); + + $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => ['good-jwt', 'bad-jwt']]]); + } +} From 602b1f9d038a212d547f8d55c3560a8eacac388a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 31 Aug 2026 12:56:15 +0200 Subject: [PATCH 02/15] Require simplesamlphp/openid ~0.7.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e271d9fd..c18e7f45 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "psr/log": "^3", "psr/simple-cache": "^3", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~0.6.0", + "simplesamlphp/openid": "~0.7.0", "simplesamlphp/simplesamlphp": "^2.5.3.1", "symfony/cache": "^7.4", "symfony/expression-language": "^7.4", From a256911a932a8f42800a001a2e9bbc3703a2349a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 31 Aug 2026 13:50:39 +0200 Subject: [PATCH 03/15] Wire DID resolution with its own destination policy and cache --- config/module_oidc.php.dist | 108 +++++++++++ docs/3-oidc-configuration.md | 58 ++++++ locales/en/LC_MESSAGES/oidc.po | 68 +++++++ locales/es/LC_MESSAGES/oidc.po | 68 +++++++ locales/fr/LC_MESSAGES/oidc.po | 68 +++++++ locales/hr/LC_MESSAGES/oidc.po | 68 +++++++ locales/it/LC_MESSAGES/oidc.po | 68 +++++++ locales/nl/LC_MESSAGES/oidc.po | 68 +++++++ routing/services/services.yml | 7 +- .../ConfigOverview/VciOverviewBuilder.php | 179 ++++++++++++++++++ src/Factories/CacheFactory.php | 22 +++ src/Factories/DidFactory.php | 63 ++++++ src/ModuleConfig.php | 145 ++++++++++++++ src/Utils/VciCache.php | 21 ++ .../ConfigOverview/VciOverviewBuilderTest.php | 177 +++++++++++++++++ tests/unit/src/Factories/CacheFactoryTest.php | 109 +++++++++++ tests/unit/src/Factories/DidFactoryTest.php | 164 ++++++++++++++++ tests/unit/src/ModuleConfigTest.php | 164 ++++++++++++++++ 18 files changed, 1624 insertions(+), 1 deletion(-) create mode 100644 src/Factories/DidFactory.php create mode 100644 src/Utils/VciCache.php create mode 100644 tests/unit/src/Factories/CacheFactoryTest.php create mode 100644 tests/unit/src/Factories/DidFactoryTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index a68a30fb..54433caa 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1677,6 +1677,114 @@ $config = [ // \SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum::Proofless, // ], + /** + * (optional) Dedicated cache adapter for the Verifiable Credential Issuance + * layer. If set to null (the default), no VCI caching is performed. + * + * Kept separate from the protocol and federation cache adapters because + * what is kept here is fetched from destinations named by whoever is being + * issued a credential, rather than by this deployment. Today that means + * resolved DID documents: without a cache, every key proof naming a did:web + * identifier is another outbound fetch, so setting this is recommended in + * production. Can be set to any Symfony Cache Adapter class. If set, make + * sure to also give proper adapter arguments for its instantiation below. + * @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + */ + ModuleConfig::OPTION_VCI_CACHE_ADAPTER => null, +// ModuleConfig::OPTION_VCI_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\FilesystemAdapter::class, + + /** + * VCI cache adapter arguments used for adapter instantiation, in the order + * of constructor arguments. Refer to the documentation for a particular + * adapter on which arguments are needed to create its instance. + */ + ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS => [ + // Adapter arguments here... + ], + // Example for FileSystemAdapter: +// ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS => [ +// 'openidVci', // Namespace, subdirectory of main cache directory +// 60 * 60 * 6, // Default lifetime in seconds (used when a particular cache item doesn't define its own lifetime) +// '/path/to/main/cache/directory' // Must be writable. Can be set to null to use the system temporary directory. +// ], + + /** + * (optional) How long a resolved DID document may be reused. Defaults to + * 'PT6H' (6 hours). Only has an effect when a VCI cache adapter is set. + * + * A DID document states no expiry of its own, so unlike a fetched + * federation artifact this is not a ceiling over a duration the issuer + * chose - it is the whole of the freshness rule. A holder rotating a key is + * not seen until this runs out. + */ +// ModuleConfig::OPTION_VCI_DID_CACHE_MAX_DURATION => 'PT6H', + + /** + * (optional) Hosts that DID resolution may reach whatever they resolve to. + * Empty by default, so a DID may only resolve to a public destination. + * + * These are deliberately NOT OPTION_OUTBOUND_ALLOWED_HOSTS, and that option + * is never read here. Those exemptions are granted so this deployment can + * reach addresses it operates itself; a did:web identifier arrives in a + * wallet's key proof and names its own destination, so sharing them would + * let whoever sends a credential request send this deployment to any of + * them. + * + * What is said about the general option applies here too: the address check + * and the address pinning are both skipped for a host listed here, so keep + * this to destinations the deployment operates itself. The usual reason to + * set it is a test environment in which the DID host resolves to a + * container address rather than to its public one. + */ +// ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS => [ +// 'wallet.internal.example', +// ], + + /** + * (optional) Address ranges DID resolution may reach alongside the public + * ones, as CIDR. Empty by default. Separate from + * OPTION_OUTBOUND_ALLOWED_CIDRS on the same reasoning as the hosts above. + * + * Use the narrowest range that covers the destination ('10.1.2.3/32' rather + * than '10.0.0.0/8'), so that permitting one internal endpoint does not + * permit the whole private network. An unusable range is refused when the + * destination policy is built, rather than quietly never matching, and is + * reported on the VCI configuration overview screen. + */ +// ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS => [ +// '10.1.2.3/32', +// ], + + /** + * (optional) How strictly DID resolution insists on fetching from the + * address that was validated. Defaults to + * AddressPinningModeEnum::Required. + * + * The mechanics are the ones described for + * OPTION_OUTBOUND_ADDRESS_PINNING_MODE above, but the default is stricter + * here and Preferred is refused outright, because the destination is chosen + * by whoever supplies the DID and Preferred carries on unpinned wherever + * pinning turns out to be unavailable. + * + * - Required (default): refuse the fetch when the address cannot be pinned. + * Pinning needs the cURL extension, the cURL handler, and a connection + * this deployment is the one making. + * - Disabled: never pin. Meant for a deployment reaching the internet + * through a forward proxy: the proxy resolves the destination itself, so + * there is nothing to pin, and the proxy is doing the egress control that + * pinning approximates. This is only honest while the proxy carries EVERY + * DID destination. An exclusion list (NO_PROXY, or a 'no' entry) sends + * matching hosts direct, and a proxy configured for plain http alone is + * not used for the https fetch a did:web identifier resolves to. Wherever + * a fetch goes direct, this removes the protection rather than delegating + * it. + * - Preferred: refused when the configuration is read. A deployment that + * merely lacks the cURL extension is not the proxy case, and should + * install the extension instead. + */ +// ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE => +// \SimpleSAML\OpenID\Codebooks\AddressPinningModeEnum::Required, + /** * (optional) Whether issued Verifiable Credentials get a Token Status List * entry allocated to them, which is what makes them revocable and diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 371afc8c..a67d7bcc 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -171,6 +171,64 @@ not through an HTTP handler supplied by configuration. The current settings are shown in the admin area under `OIDC` > `Configuration`, in the Protocol screen's outbound HTTP section. +### DID resolution has a policy of its own + +Resolving a holder's `did:web` identifier is also an outbound fetch, but it is +the only one whose destination is named by whoever is being issued a credential: +the identifier arrives inside a wallet's key proof and the URL is derived from +it. It therefore gets its own settings, and **the `OPTION_OUTBOUND_*` options +above are never applied to it.** Those exemptions were granted so the deployment +could reach addresses it operates itself; sharing them here would let a wallet +name any of them. + +```php +ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS => ['wallet.internal.example'], +ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS => ['10.1.2.3/32'], +``` + +Both are empty by default, so a DID may only resolve to a public `https` +destination. The usual reason to set either is a test environment in which the +DID host resolves to a container address rather than to its public one. + +`OPTION_VCI_DID_ADDRESS_PINNING_MODE` works as described above with two +differences: it defaults to `Required` rather than `Preferred`, and `Preferred` +is **refused** when the configuration is read, since proceeding unpinned is +exactly what should not happen on a fetch driven from outside. + +That leaves `Disabled` for the deployment that cannot pin and is not thereby +unprotected — one reaching the internet through a forward proxy. The proxy +resolves the destination itself, so there is nothing to pin, and the proxy is +doing the egress control that pinning approximates: + +```php +ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE => + \SimpleSAML\OpenID\Codebooks\AddressPinningModeEnum::Disabled, +``` + +**This is only honest while the proxy carries every DID destination.** An +exclusion list (`NO_PROXY`, or a `no` entry) sends matching hosts direct, and a +proxy configured for plain `http` alone is not used for the `https` fetch a +`did:web` identifier resolves to. Wherever a fetch goes direct, turning pinning +off removes the protection rather than delegating it. A deployment that simply +lacks the cURL extension is not this case and should install the extension. + +Resolved documents are cached in the VCI cache, which is configured separately +from the protocol and federation ones: + +```php +ModuleConfig::OPTION_VCI_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\FilesystemAdapter::class, +ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS => ['openidVci', 60 * 60 * 6, '/path/to/cache'], +``` + +Without an adapter, every key proof naming a `did:web` identifier is another +outbound fetch. `OPTION_VCI_DID_CACHE_MAX_DURATION` (default `PT6H`) is how long +a document is reused; a DID document states no expiry of its own, so this is the +whole of its freshness rule, and a holder rotating a key is not seen until it +runs out. + +These settings are shown in the admin area under `OIDC` > `Configuration`, on +the VCI screen. + ## Pushed Authorization Requests (PAR) and Request Objects A client can send authorization request parameters in several ways: diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index cf632a6c..bbb019cd 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -206,12 +206,37 @@ msgstr "" msgid "Description" msgstr "" +msgid "DID Address Pinning" +msgstr "" + +msgid "DID Document Cache Duration" +msgstr "" + +msgid "DID Outbound Allowed Address Ranges" +msgstr "" + +msgid "DID Outbound Allowed Hosts" +msgstr "" + +msgid "DID resolution" +msgstr "" + msgid "Disabled" msgstr "" msgid "Discovery URL" msgstr "" +msgid "" +"Each of these is a destination that whoever supplies a DID can send this " +"deployment to." +msgstr "" + +msgid "" +"Each of these is a range that whoever supplies a DID can send this " +"deployment into." +msgstr "" + msgid "Edit" msgstr "" @@ -263,6 +288,12 @@ msgstr "" msgid "Homepage URI" msgstr "" +msgid "" +"How long a resolved DID document is reused. A DID document states no expiry " +"of its own, so this is the whole of its freshness rule: a holder rotating a " +"key is not seen until it runs out." +msgstr "" + msgid "Identifier" msgstr "" @@ -359,11 +390,27 @@ msgstr "" msgid "No entries." msgstr "" +msgid "None, so a DID may only resolve to a public address." +msgstr "" + +msgid "" +"None, so a DID may only resolve to a public destination. Separate from the " +"general outbound exemptions, which are never applied here." +msgstr "" + msgid "" "Note that this will first resolve Trust Chain between given entity and Trust " "Anchor, and only then do the Trust Mark validation." msgstr "" +msgid "" +"Not set, so every key proof naming a did:web identifier is another outbound " +"fetch. Setting a cache adapter is recommended in production." +msgstr "" + +msgid "Not used, since no VCI cache adapter is configured." +msgstr "" + #: /var/www/projects/simplesamlphp/simplesamlphp-2.3/modules/oidc/hooks/hook_adminmenu.php:24 msgid "OIDC" msgstr "" @@ -389,6 +436,13 @@ msgstr "" msgid "Owner" msgstr "" +msgid "" +"Pinning is off, so a host named by a DID can resolve to a permitted address " +"for the check and to another one for the fetch. Only sound where a forward " +"proxy carries every DID destination; an exclusion list or a proxy set for " +"plain http alone leaves some going direct." +msgstr "" + msgid "PKI" msgstr "" @@ -414,6 +468,14 @@ msgstr "" msgid "Public Key" msgstr "" +msgid "Reachable alongside the public addresses." +msgstr "" + +msgid "" +"Reachable whatever they resolve to. The address check and the address " +"pinning are both skipped for these." +msgstr "" + msgid "Redirect URI" msgstr "" @@ -540,6 +602,12 @@ msgstr "" msgid "User not authorized." msgstr "" +msgid "" +"Whether a DID document must be fetched from the address that was validated, " +"rather than the host being resolved a second time when the connection is " +"made." +msgstr "" + msgid "Yes" msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index 9ca36d7b..99fdc6fb 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -206,12 +206,37 @@ msgstr "" msgid "Description" msgstr "" +msgid "DID Address Pinning" +msgstr "" + +msgid "DID Document Cache Duration" +msgstr "" + +msgid "DID Outbound Allowed Address Ranges" +msgstr "" + +msgid "DID Outbound Allowed Hosts" +msgstr "" + +msgid "DID resolution" +msgstr "" + msgid "Disabled" msgstr "" msgid "Discovery URL" msgstr "" +msgid "" +"Each of these is a destination that whoever supplies a DID can send this " +"deployment to." +msgstr "" + +msgid "" +"Each of these is a range that whoever supplies a DID can send this " +"deployment into." +msgstr "" + msgid "Edit" msgstr "" @@ -263,6 +288,12 @@ msgstr "" msgid "Homepage URI" msgstr "" +msgid "" +"How long a resolved DID document is reused. A DID document states no expiry " +"of its own, so this is the whole of its freshness rule: a holder rotating a " +"key is not seen until it runs out." +msgstr "" + msgid "Identifier" msgstr "" @@ -359,11 +390,27 @@ msgstr "" msgid "No entries." msgstr "" +msgid "None, so a DID may only resolve to a public address." +msgstr "" + +msgid "" +"None, so a DID may only resolve to a public destination. Separate from the " +"general outbound exemptions, which are never applied here." +msgstr "" + msgid "" "Note that this will first resolve Trust Chain between given entity and Trust " "Anchor, and only then do the Trust Mark validation." msgstr "" +msgid "" +"Not set, so every key proof naming a did:web identifier is another outbound " +"fetch. Setting a cache adapter is recommended in production." +msgstr "" + +msgid "Not used, since no VCI cache adapter is configured." +msgstr "" + #: /var/www/projects/simplesamlphp/simplesamlphp-2.3/modules/oidc/hooks/hook_adminmenu.php:24 msgid "OIDC" msgstr "" @@ -389,6 +436,13 @@ msgstr "" msgid "Owner" msgstr "" +msgid "" +"Pinning is off, so a host named by a DID can resolve to a permitted address " +"for the check and to another one for the fetch. Only sound where a forward " +"proxy carries every DID destination; an exclusion list or a proxy set for " +"plain http alone leaves some going direct." +msgstr "" + msgid "PKI" msgstr "" @@ -414,6 +468,14 @@ msgstr "" msgid "Public Key" msgstr "" +msgid "Reachable alongside the public addresses." +msgstr "" + +msgid "" +"Reachable whatever they resolve to. The address check and the address " +"pinning are both skipped for these." +msgstr "" + msgid "Redirect URI" msgstr "" @@ -540,6 +602,12 @@ msgstr "" msgid "User not authorized." msgstr "" +msgid "" +"Whether a DID document must be fetched from the address that was validated, " +"rather than the host being resolved a second time when the connection is " +"made." +msgstr "" + msgid "Yes" msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index 2b6d9890..082f56bd 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -206,12 +206,37 @@ msgstr "" msgid "Description" msgstr "" +msgid "DID Address Pinning" +msgstr "" + +msgid "DID Document Cache Duration" +msgstr "" + +msgid "DID Outbound Allowed Address Ranges" +msgstr "" + +msgid "DID Outbound Allowed Hosts" +msgstr "" + +msgid "DID resolution" +msgstr "" + msgid "Disabled" msgstr "" msgid "Discovery URL" msgstr "" +msgid "" +"Each of these is a destination that whoever supplies a DID can send this " +"deployment to." +msgstr "" + +msgid "" +"Each of these is a range that whoever supplies a DID can send this " +"deployment into." +msgstr "" + msgid "Edit" msgstr "" @@ -263,6 +288,12 @@ msgstr "" msgid "Homepage URI" msgstr "" +msgid "" +"How long a resolved DID document is reused. A DID document states no expiry " +"of its own, so this is the whole of its freshness rule: a holder rotating a " +"key is not seen until it runs out." +msgstr "" + msgid "Identifier" msgstr "" @@ -359,11 +390,27 @@ msgstr "" msgid "No entries." msgstr "" +msgid "None, so a DID may only resolve to a public address." +msgstr "" + +msgid "" +"None, so a DID may only resolve to a public destination. Separate from the " +"general outbound exemptions, which are never applied here." +msgstr "" + msgid "" "Note that this will first resolve Trust Chain between given entity and Trust " "Anchor, and only then do the Trust Mark validation." msgstr "" +msgid "" +"Not set, so every key proof naming a did:web identifier is another outbound " +"fetch. Setting a cache adapter is recommended in production." +msgstr "" + +msgid "Not used, since no VCI cache adapter is configured." +msgstr "" + #: /var/www/projects/simplesamlphp/simplesamlphp-2.3/modules/oidc/hooks/hook_adminmenu.php:24 msgid "OIDC" msgstr "" @@ -389,6 +436,13 @@ msgstr "" msgid "Owner" msgstr "" +msgid "" +"Pinning is off, so a host named by a DID can resolve to a permitted address " +"for the check and to another one for the fetch. Only sound where a forward " +"proxy carries every DID destination; an exclusion list or a proxy set for " +"plain http alone leaves some going direct." +msgstr "" + msgid "PKI" msgstr "" @@ -414,6 +468,14 @@ msgstr "" msgid "Public Key" msgstr "" +msgid "Reachable alongside the public addresses." +msgstr "" + +msgid "" +"Reachable whatever they resolve to. The address check and the address " +"pinning are both skipped for these." +msgstr "" + msgid "Redirect URI" msgstr "" @@ -540,6 +602,12 @@ msgstr "" msgid "User not authorized." msgstr "" +msgid "" +"Whether a DID document must be fetched from the address that was validated, " +"rather than the host being resolved a second time when the connection is " +"made." +msgstr "" + msgid "Yes" msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index dec0d672..876ea6b9 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -225,12 +225,37 @@ msgstr "Obriši" msgid "Description" msgstr "Opis" +msgid "DID Address Pinning" +msgstr "" + +msgid "DID Document Cache Duration" +msgstr "" + +msgid "DID Outbound Allowed Address Ranges" +msgstr "" + +msgid "DID Outbound Allowed Hosts" +msgstr "" + +msgid "DID resolution" +msgstr "" + msgid "Disabled" msgstr "Onemogućeno" msgid "Discovery URL" msgstr "URL za otkrivanje" +msgid "" +"Each of these is a destination that whoever supplies a DID can send this " +"deployment to." +msgstr "" + +msgid "" +"Each of these is a range that whoever supplies a DID can send this " +"deployment into." +msgstr "" + msgid "Edit" msgstr "Uredi" @@ -286,6 +311,12 @@ msgstr "Forsirani ACR za autentikaciju putem kolačića" msgid "Homepage URI" msgstr "URI početne stranice" +msgid "" +"How long a resolved DID document is reused. A DID document states no expiry " +"of its own, so this is the whole of its freshness rule: a holder rotating a " +"key is not seen until it runs out." +msgstr "" + msgid "Identifier" msgstr "Identifikator" @@ -390,6 +421,14 @@ msgstr "Nema registriranih klijenata" msgid "No entries." msgstr "Nema unosa." +msgid "None, so a DID may only resolve to a public address." +msgstr "" + +msgid "" +"None, so a DID may only resolve to a public destination. Separate from the " +"general outbound exemptions, which are never applied here." +msgstr "" + msgid "" "Note that this will first resolve Trust Chain between given entity and Trust " "Anchor, and only then do the Trust Mark validation." @@ -397,6 +436,14 @@ msgstr "" "Imajte na umu da će ovo prvo razriješiti lanac povjerenja između danog entiteta i sidra povjerenja, " "a tek onda izvršiti provjeru oznaku povjerenja." +msgid "" +"Not set, so every key proof naming a did:web identifier is another outbound " +"fetch. Setting a cache adapter is recommended in production." +msgstr "" + +msgid "Not used, since no VCI cache adapter is configured." +msgstr "" + #: /var/www/projects/simplesamlphp/simplesamlphp-2.3/modules/oidc/hooks/hook_adminmenu.php:24 msgid "OIDC" msgstr "OIDC" @@ -422,6 +469,13 @@ msgstr "Ime organizacije" msgid "Owner" msgstr "Vlasnik" +msgid "" +"Pinning is off, so a host named by a DID can resolve to a permitted address " +"for the check and to another one for the fetch. Only sound where a forward " +"proxy carries every DID destination; an exclusion list or a proxy set for " +"plain http alone leaves some going direct." +msgstr "" + msgid "PKI" msgstr "PKI" @@ -447,6 +501,14 @@ msgstr "Javan" msgid "Public Key" msgstr "Javni ključ" +msgid "Reachable alongside the public addresses." +msgstr "" + +msgid "" +"Reachable whatever they resolve to. The address check and the address " +"pinning are both skipped for these." +msgstr "" + msgid "Redirect URI" msgstr "URI za preusmjeravanje" @@ -585,6 +647,12 @@ msgstr "Atribut identifikator korisnika" msgid "User not authorized." msgstr "Korisnik nije autoriziran." +msgid "" +"Whether a DID document must be fetched from the address that was validated, " +"rather than the host being resolved a second time when the connection is " +"made." +msgstr "" + msgid "Yes" msgstr "Da" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index 3db79027..cdb8a8fb 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -206,12 +206,37 @@ msgstr "" msgid "Description" msgstr "" +msgid "DID Address Pinning" +msgstr "" + +msgid "DID Document Cache Duration" +msgstr "" + +msgid "DID Outbound Allowed Address Ranges" +msgstr "" + +msgid "DID Outbound Allowed Hosts" +msgstr "" + +msgid "DID resolution" +msgstr "" + msgid "Disabled" msgstr "" msgid "Discovery URL" msgstr "" +msgid "" +"Each of these is a destination that whoever supplies a DID can send this " +"deployment to." +msgstr "" + +msgid "" +"Each of these is a range that whoever supplies a DID can send this " +"deployment into." +msgstr "" + msgid "Edit" msgstr "" @@ -263,6 +288,12 @@ msgstr "" msgid "Homepage URI" msgstr "" +msgid "" +"How long a resolved DID document is reused. A DID document states no expiry " +"of its own, so this is the whole of its freshness rule: a holder rotating a " +"key is not seen until it runs out." +msgstr "" + msgid "Identifier" msgstr "" @@ -359,11 +390,27 @@ msgstr "" msgid "No entries." msgstr "" +msgid "None, so a DID may only resolve to a public address." +msgstr "" + +msgid "" +"None, so a DID may only resolve to a public destination. Separate from the " +"general outbound exemptions, which are never applied here." +msgstr "" + msgid "" "Note that this will first resolve Trust Chain between given entity and Trust " "Anchor, and only then do the Trust Mark validation." msgstr "" +msgid "" +"Not set, so every key proof naming a did:web identifier is another outbound " +"fetch. Setting a cache adapter is recommended in production." +msgstr "" + +msgid "Not used, since no VCI cache adapter is configured." +msgstr "" + #: /var/www/projects/simplesamlphp/simplesamlphp-2.3/modules/oidc/hooks/hook_adminmenu.php:24 msgid "OIDC" msgstr "" @@ -389,6 +436,13 @@ msgstr "" msgid "Owner" msgstr "" +msgid "" +"Pinning is off, so a host named by a DID can resolve to a permitted address " +"for the check and to another one for the fetch. Only sound where a forward " +"proxy carries every DID destination; an exclusion list or a proxy set for " +"plain http alone leaves some going direct." +msgstr "" + msgid "PKI" msgstr "" @@ -414,6 +468,14 @@ msgstr "" msgid "Public Key" msgstr "" +msgid "Reachable alongside the public addresses." +msgstr "" + +msgid "" +"Reachable whatever they resolve to. The address check and the address " +"pinning are both skipped for these." +msgstr "" + msgid "Redirect URI" msgstr "" @@ -540,6 +602,12 @@ msgstr "" msgid "User not authorized." msgstr "" +msgid "" +"Whether a DID document must be fetched from the address that was validated, " +"rather than the host being resolved a second time when the connection is " +"made." +msgstr "" + msgid "Yes" msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index e7d148ab..be4480d2 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -187,12 +187,37 @@ msgstr "Verwijderen" msgid "Description" msgstr "Omschrijving" +msgid "DID Address Pinning" +msgstr "" + +msgid "DID Document Cache Duration" +msgstr "" + +msgid "DID Outbound Allowed Address Ranges" +msgstr "" + +msgid "DID Outbound Allowed Hosts" +msgstr "" + +msgid "DID resolution" +msgstr "" + msgid "Disabled" msgstr "Gehandicapt" msgid "Discovery URL" msgstr "Ontdekkings-URL" +msgid "" +"Each of these is a destination that whoever supplies a DID can send this " +"deployment to." +msgstr "" + +msgid "" +"Each of these is a range that whoever supplies a DID can send this " +"deployment into." +msgstr "" + msgid "Edit" msgstr "Bewerken" @@ -240,6 +265,12 @@ msgstr "Geforceerde ACR voor cookie-authenticatie" msgid "Homepage URI" msgstr "Startpagina-URI" +msgid "" +"How long a resolved DID document is reused. A DID document states no expiry " +"of its own, so this is the whole of its freshness rule: a holder rotating a " +"key is not seen until it runs out." +msgstr "" + msgid "Identifier" msgstr "Identificatie" @@ -324,9 +355,25 @@ msgstr "Er zijn geen klanten geregistreerd." msgid "No entries." msgstr "Geen invoer." +msgid "None, so a DID may only resolve to a public address." +msgstr "" + +msgid "" +"None, so a DID may only resolve to a public destination. Separate from the " +"general outbound exemptions, which are never applied here." +msgstr "" + msgid "Note that this will first resolve Trust Chain between given entity and Trust Anchor, and only then do the Trust Mark validation." msgstr "Houd er rekening mee dat hiermee eerst de Trust Chain tussen de gegeven entiteit en het Trust Anchor wordt opgelost en pas daarna de Trust Mark-validatie wordt uitgevoerd." +msgid "" +"Not set, so every key proof naming a did:web identifier is another outbound " +"fetch. Setting a cache adapter is recommended in production." +msgstr "" + +msgid "Not used, since no VCI cache adapter is configured." +msgstr "" + #: /var/www/projects/simplesamlphp/simplesamlphp-2.3/modules/oidc/hooks/hook_adminmenu.php:24 msgid "OIDC" msgstr "OIDC" @@ -352,6 +399,13 @@ msgstr "Organisatienaam" msgid "Owner" msgstr "Eigenaar" +msgid "" +"Pinning is off, so a host named by a DID can resolve to a permitted address " +"for the check and to another one for the fetch. Only sound where a forward " +"proxy carries every DID destination; an exclusion list or a proxy set for " +"plain http alone leaves some going direct." +msgstr "" + msgid "PKI" msgstr "PKI" @@ -380,6 +434,14 @@ msgstr "Openbaar" msgid "Public Key" msgstr "Publieke sleutel" +msgid "Reachable alongside the public addresses." +msgstr "" + +msgid "" +"Reachable whatever they resolve to. The address check and the address " +"pinning are both skipped for these." +msgstr "" + msgid "Redirect URI" msgstr "Omleidings-URI" @@ -497,6 +559,12 @@ msgstr "Gebruikers-ID-kenmerk" msgid "User not authorized." msgstr "Gebruiker niet geautoriseerd." +msgid "" +"Whether a DID document must be fetched from the address that was validated, " +"rather than the host being resolved a second time when the connection is " +"made." +msgstr "" + msgid "Yes" msgstr "Ja" diff --git a/routing/services/services.yml b/routing/services/services.yml index d43d0df3..442529e7 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -147,6 +147,8 @@ services: factory: ['@SimpleSAML\Module\oidc\Factories\CacheFactory', 'forFederation'] # Can return null SimpleSAML\Module\oidc\Utils\ProtocolCache: factory: ['@SimpleSAML\Module\oidc\Factories\CacheFactory', 'forProtocol'] # Can return null + SimpleSAML\Module\oidc\Utils\VciCache: + factory: ['@SimpleSAML\Module\oidc\Factories\CacheFactory', 'forVci'] # Can return null # Use Nyholm\Psr7 package as PSR HTTP Factories. Nyholm\Psr7\Factory\Psr17Factory: ~ @@ -170,7 +172,10 @@ services: SimpleSAML\OpenID\Jwks: factory: [ '@SimpleSAML\Module\oidc\Factories\JwksFactory', 'build' ] SimpleSAML\OpenID\Jwk: ~ - SimpleSAML\OpenID\Did: ~ + # Resolution is driven by identifiers supplied from outside, so this gets a destination policy of its + # own rather than the shared one above. + SimpleSAML\OpenID\Did: + factory: [ '@SimpleSAML\Module\oidc\Factories\DidFactory', 'build' ] SimpleSAML\OpenID\Jws: factory: [ '@SimpleSAML\Module\oidc\Factories\JwsFactory', 'build' ] SimpleSAML\OpenID\RequestObject: diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index ca7aaa05..cc19bbb8 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -11,9 +11,11 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; +use SimpleSAML\OpenID\Codebooks\AddressPinningModeEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; +use SimpleSAML\OpenID\Network\DestinationPolicy; use Stringable; use Throwable; @@ -58,6 +60,8 @@ public function build(): array $this->buildCredentialConfigurationsSection(), $this->buildNonRegisteredClientsSection(), $this->buildStatusListsSection(), + $this->buildDidResolutionSection(), + $this->buildCacheSection(), $this->buildDurationsSection(), $this->buildCredentialOfferSection(), ]; @@ -736,6 +740,181 @@ protected function buildNonRegisteredClientsSection(): Section } + /** + * Where resolving a holder's Decentralized Identifier is allowed to send this deployment. + * + * Every row here describes a destination chosen by whoever presents the identifier, not by this + * deployment, which is why these settings exist separately from the general outbound ones and never + * inherit from them. + * + * @throws \Exception + */ + protected function buildDidResolutionSection(): Section + { + return new Section( + Translate::noop('DID resolution'), + 'did-resolution', + $this->guardRow( + Translate::noop('DID Outbound Allowed Hosts'), + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS, + function (): Row { + $allowedHosts = $this->moduleConfig->getVciDidOutboundAllowedHosts(); + // The getter only establishes that these are strings; whether they are usable is the + // policy's own judgement, made in its constructor. Built bare rather than through + // DidWebResolver::buildDestinationPolicy(), which would also log the notice that + // belongs to the resolution path rather than to rendering this screen. + new DestinationPolicy(allowedHosts: $allowedHosts); + + return new Row( + Translate::noop('DID Outbound Allowed Hosts'), + $allowedHosts, + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS, + $allowedHosts === [] ? + Translate::noop( + 'None, so a DID may only resolve to a public destination. Separate from the ' . + 'general outbound exemptions, which are never applied here.', + ) : + Translate::noop( + 'Reachable whatever they resolve to. The address check and the address ' . + 'pinning are both skipped for these.', + ), + $allowedHosts === [] ? + null : + Translate::noop( + 'Each of these is a destination that whoever supplies a DID can send this ' . + 'deployment to.', + ), + ); + }, + ), + $this->guardRow( + Translate::noop('DID Outbound Allowed Address Ranges'), + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS, + function (): Row { + $allowedCidrs = $this->moduleConfig->getVciDidOutboundAllowedCidrs(); + // A range that can never match would otherwise be shown as a working exemption. + new DestinationPolicy(allowedCidrs: $allowedCidrs); + + return new Row( + Translate::noop('DID Outbound Allowed Address Ranges'), + $allowedCidrs, + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS, + $allowedCidrs === [] ? + Translate::noop('None, so a DID may only resolve to a public address.') : + Translate::noop('Reachable alongside the public addresses.'), + $allowedCidrs === [] ? + null : + Translate::noop( + 'Each of these is a range that whoever supplies a DID can send this ' . + 'deployment into.', + ), + ); + }, + ), + $this->guardRow( + Translate::noop('DID Address Pinning'), + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + function (): Row { + $pinningMode = $this->moduleConfig->getVciDidAddressPinningMode(); + + return new Row( + Translate::noop('DID Address Pinning'), + $pinningMode->value, + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + Translate::noop( + 'Whether a DID document must be fetched from the address that was ' . + 'validated, rather than the host being resolved a second time when the ' . + 'connection is made.', + ), + $pinningMode === AddressPinningModeEnum::Disabled ? + Translate::noop( + 'Pinning is off, so a host named by a DID can resolve to a permitted ' . + 'address for the check and to another one for the fetch. Only sound where a ' . + 'forward proxy carries every DID destination; an exclusion list or a ' . + 'proxy set for plain http alone leaves some going direct.', + ) : + null, + ); + }, + ), + ); + } + + + /** + * @throws \Exception + */ + protected function buildCacheSection(): Section + { + $isCachingActive = false; + + try { + $isCachingActive = !is_null($this->moduleConfig->getVciCacheAdapterClass()); + } catch (Throwable) { + // Reported on its own row below, where the option which failed to resolve is named. + } + + return new Section( + Translate::noop('Cache'), + 'cache', + $this->guardRow( + Translate::noop('Cache Adapter'), + ModuleConfig::OPTION_VCI_CACHE_ADAPTER, + function () use ($isCachingActive): Row { + // Resolved inside the guard: the option is only asserted to be a string when it is + // read, so a value of another type throws, and this screen is where an administrator + // goes to find that out. + $adapterClass = $this->moduleConfig->getVciCacheAdapterClass(); + + return new Row( + Translate::noop('Cache Adapter'), + $adapterClass ?? Translate::noop('N/A'), + $isCachingActive ? + ConfigOverviewValueTypeEnum::RawText : + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_VCI_CACHE_ADAPTER, + $isCachingActive ? null : Translate::noop( + 'Not set, so every key proof naming a did:web identifier is another ' . + 'outbound fetch. Setting a cache adapter is recommended in production.', + ), + ); + }, + ), + $this->guardRow( + Translate::noop('Cache Adapter Arguments'), + ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS, + fn(): Row => $this->buildSecretCountRow( + Translate::noop('Cache Adapter Arguments'), + count($this->moduleConfig->getVciCacheAdapterArguments()), + ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS, + Translate::noop( + 'Values are not shown, since adapter arguments can carry connection credentials.', + ), + ), + ), + $this->guardRow( + Translate::noop('DID Document Cache Duration'), + ModuleConfig::OPTION_VCI_DID_CACHE_MAX_DURATION, + fn(): Row => $this->buildDurationRow( + Translate::noop('DID Document Cache Duration'), + $this->moduleConfig->getVciDidCacheMaxDuration(), + ModuleConfig::OPTION_VCI_DID_CACHE_MAX_DURATION, + $isCachingActive ? + Translate::noop( + 'How long a resolved DID document is reused. A DID document states no expiry ' . + 'of its own, so this is the whole of its freshness rule: a holder rotating a ' . + 'key is not seen until it runs out.', + ) : + Translate::noop('Not used, since no VCI cache adapter is configured.'), + ), + ), + ); + } + + /** * @throws \Exception */ diff --git a/src/Factories/CacheFactory.php b/src/Factories/CacheFactory.php index f2620270..00c6d02e 100644 --- a/src/Factories/CacheFactory.php +++ b/src/Factories/CacheFactory.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\ProtocolCache; +use SimpleSAML\Module\oidc\Utils\VciCache; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Psr16Cache; use Throwable; @@ -69,6 +70,27 @@ public function forFederation(): ?FederationCache } + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + * @throws \Exception + */ + public function forVci(): ?VciCache + { + $class = $this->moduleConfig->getVciCacheAdapterClass(); + + if (is_null($class)) { + return null; + } + + $adapter = $this->buildAdapterInstance( + $class, + $this->moduleConfig->getVciCacheAdapterArguments(), + ); + + return new VciCache(new Psr16Cache($adapter)); + } + + /** * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ diff --git a/src/Factories/DidFactory.php b/src/Factories/DidFactory.php new file mode 100644 index 00000000..9acfefd2 --- /dev/null +++ b/src/Factories/DidFactory.php @@ -0,0 +1,63 @@ +moduleConfig->getVciDidCacheMaxDuration(), + cache: $this->vciCache?->cache, + logger: $this->loggerService, + // Through the library's own helper rather than assembled here: it is what refuses a pinning + // mode DID resolution can not run under, and building a DestinationPolicy by hand would walk + // around that refusal. The resolver behind this facade must likewise never be constructed + // directly - its constructor takes any HTTP client, and so bypasses this policy, the pinning + // requirement and the response size cap alike. + destinationPolicy: DidWebResolver::buildDestinationPolicy( + logger: $this->loggerService, + addressPinningMode: $this->moduleConfig->getVciDidAddressPinningMode(), + allowedHosts: $this->moduleConfig->getVciDidOutboundAllowedHosts(), + allowedCidrs: $this->moduleConfig->getVciDidOutboundAllowedCidrs(), + ), + ); + } +} diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index c4b7da89..77dd458e 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -280,6 +280,18 @@ class ModuleConfig final public const string OPTION_VCI_CREDENTIAL_BINDING_POLICIES = 'vci_credential_binding_policies'; + final public const string OPTION_VCI_CACHE_ADAPTER = 'vci_cache_adapter'; + + final public const string OPTION_VCI_CACHE_ADAPTER_ARGUMENTS = 'vci_cache_adapter_arguments'; + + final public const string OPTION_VCI_DID_CACHE_MAX_DURATION = 'vci_did_cache_max_duration'; + + final public const string OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS = 'vci_did_outbound_allowed_hosts'; + + final public const string OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS = 'vci_did_outbound_allowed_cidrs'; + + final public const string OPTION_VCI_DID_ADDRESS_PINNING_MODE = 'vci_did_address_pinning_mode'; + final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; @@ -2514,6 +2526,139 @@ public function getVciAllowedRedirectUriPrefixesForNonRegisteredClients(): array } + /** + * Cache adapter class for the Verifiable Credential Issuance layer. Kept apart from the protocol and + * federation caches, since what is kept here is fetched from destinations named by whoever is being + * issued a credential rather than by this deployment. + * + * @throws \Exception + */ + public function getVciCacheAdapterClass(): ?string + { + return $this->config()->getOptionalString(self::OPTION_VCI_CACHE_ADAPTER, null); + } + + + /** + * @throws \Exception + */ + public function getVciCacheAdapterArguments(): array + { + return $this->config()->getOptionalArray(self::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS, []); + } + + + /** + * How long a resolved DID document may be reused. + * + * A DID document carries no expiry of its own, so unlike a fetched federation artifact there is + * nothing for this to be a ceiling over - it is the whole of the freshness rule. Lowering it makes a + * holder's key rotation take effect sooner, at the cost of a fetch per resolution. + * + * @throws \Exception + */ + public function getVciDidCacheMaxDuration(): DateInterval + { + return new DateInterval( + $this->config()->getOptionalString(self::OPTION_VCI_DID_CACHE_MAX_DURATION, 'PT6H'), + ); + } + + + /** + * Hosts DID resolution may reach whatever they resolve to. + * + * Deliberately separate from OPTION_OUTBOUND_ALLOWED_HOSTS, and never read from it. Those exemptions + * are granted so this deployment can reach addresses it operates itself for federation; a DID names + * its own destination and is supplied by whoever is being authenticated, so handing them over would + * let that party send this deployment to any of them. + * + * @return list + * @throws \Exception + */ + public function getVciDidOutboundAllowedHosts(): array + { + $hosts = $this->config()->getOptionalArray(self::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS, []); + + return array_values(array_filter($hosts, 'is_string')); + } + + + /** + * Address ranges DID resolution may reach alongside the public ones, as CIDR. Separate from + * OPTION_OUTBOUND_ALLOWED_CIDRS on the same reasoning as the hosts above. + * + * @return list + * @throws \Exception + */ + public function getVciDidOutboundAllowedCidrs(): array + { + $cidrs = $this->config()->getOptionalArray(self::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS, []); + + return array_values(array_filter($cidrs, 'is_string')); + } + + + /** + * How strictly DID resolution insists on connecting to the address that was validated. + * + * Required by default, rather than the Preferred which the general outbound option defaults to, and + * Preferred is refused outright here. Preferred proceeds unpinned wherever the cURL handler is + * unavailable, which leaves the DNS rebinding window open on precisely the fetches whose destination + * is chosen by whoever supplies the DID. + * + * Refused while the configuration is read rather than when the resolver is built, so that an + * unusable value is reported by the admin Configuration screens instead of surfacing later as a + * failed credential issuance. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciDidAddressPinningMode(): AddressPinningModeEnum + { + /** @psalm-suppress MixedAssignment */ + $mode = $this->config()->getOptionalValue( + self::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + AddressPinningModeEnum::Required, + ); + + $case = $mode instanceof AddressPinningModeEnum ? $mode : null; + + // Accepting the backing value as well keeps a configuration written as a plain string working, + // which is easy to reach for when every neighbouring option in the file is a scalar. + if (is_null($case) && is_string($mode)) { + $case = AddressPinningModeEnum::tryFrom($mode); + } + + if (!$case instanceof AddressPinningModeEnum) { + throw new ConfigurationError( + sprintf( + 'Invalid value for %s. Expected a %s case or one of "%s", got "%s".', + self::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + AddressPinningModeEnum::class, + implode('", "', array_column(AddressPinningModeEnum::cases(), 'value')), + var_export($mode, true), + ), + ); + } + + if ($case === AddressPinningModeEnum::Preferred) { + throw new ConfigurationError( + sprintf( + '%s can not be %s, which proceeds unpinned whenever pinning turns out to be ' . + 'unavailable. Use %s, or %s where a forward proxy is doing the egress control that ' . + 'pinning would otherwise approximate.', + self::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + AddressPinningModeEnum::Preferred->value, + AddressPinningModeEnum::Required->value, + AddressPinningModeEnum::Disabled->value, + ), + ); + } + + return $case; + } + + /** * Get the full map of a credential configuration ID => JSON-LD context * document (as a PHP array). diff --git a/src/Utils/VciCache.php b/src/Utils/VciCache.php new file mode 100644 index 00000000..edece0ac --- /dev/null +++ b/src/Utils/VciCache.php @@ -0,0 +1,21 @@ +assertNull($row->getValue()); $this->assertNotNull($row->getWarning()); } + + + /** + * Turning pinning off is the one DID setting which weakens a protection rather than merely + * widening it, so it has to stay visible to whoever opens this screen. + */ + public function testWarnsWhenDidAddressPinningIsDisabled(): void + { + $requiredRow = $this->findRowForOption( + $this->buildVciOverviewBuilder()->build(), + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + ); + + $this->assertNotNull($requiredRow); + $this->assertSame(AddressPinningModeEnum::Required->value, $requiredRow->getValue()); + $this->assertNull($requiredRow->getWarning()); + + $disabledRow = $this->findRowForOption( + $this->buildVciOverviewBuilder([ + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE => AddressPinningModeEnum::Disabled, + ])->build(), + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + ); + + $this->assertNotNull($disabledRow); + $this->assertSame(AddressPinningModeEnum::Disabled->value, $disabledRow->getValue()); + $this->assertNotNull($disabledRow->getWarning()); + } + + + /** + * Preferred is refused when the configuration is read, and this screen is what an administrator + * opens to find out why, so the refusal has to be reported on the row rather than take the page + * down. + */ + public function testReportsARefusedDidAddressPinningModeInPlace(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder([ + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE => AddressPinningModeEnum::Preferred, + ])->build(), + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + ); + + $this->assertNotNull($row); + $this->assertNotNull($row->getWarning()); + } + + + /** + * Each exemption is a destination that whoever supplies a DID can send this deployment to. + */ + public function testWarnsAboutDidDestinationExemptions(): void + { + $sections = $this->buildVciOverviewBuilder()->build(); + + $hostsRow = $this->findRowForOption( + $sections, + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS, + ); + $this->assertNotNull($hostsRow); + $this->assertSame([], $hostsRow->getValue()); + $this->assertNull($hostsRow->getWarning()); + + $cidrsRow = $this->findRowForOption( + $sections, + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS, + ); + $this->assertNotNull($cidrsRow); + $this->assertSame([], $cidrsRow->getValue()); + $this->assertNull($cidrsRow->getWarning()); + + $exemptedSections = $this->buildVciOverviewBuilder([ + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS => ['wallet.internal.example'], + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS => ['10.1.2.3/32'], + ])->build(); + + $exemptedHostsRow = $this->findRowForOption( + $exemptedSections, + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS, + ); + $this->assertNotNull($exemptedHostsRow); + $this->assertSame(['wallet.internal.example'], $exemptedHostsRow->getValue()); + $this->assertNotNull($exemptedHostsRow->getWarning()); + + $exemptedCidrsRow = $this->findRowForOption( + $exemptedSections, + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS, + ); + $this->assertNotNull($exemptedCidrsRow); + $this->assertSame(['10.1.2.3/32'], $exemptedCidrsRow->getValue()); + $this->assertNotNull($exemptedCidrsRow->getWarning()); + } + + + /** + * An unusable range would otherwise be shown as a working exemption. + */ + public function testReportsAnUnusableDidAddressRangeInPlace(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder([ + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS => ['not-a-range'], + ])->build(), + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS, + ); + + $this->assertNotNull($row); + $this->assertNotNull($row->getWarning()); + } + + + public function testNotesWhenNoVciCacheIsConfigured(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder()->build(), + ModuleConfig::OPTION_VCI_CACHE_ADAPTER, + ); + + $this->assertNotNull($row); + $this->assertNotNull($row->getNote()); + } + + + /** + * A malformed cache option must be reported on its row rather than take the screen down. The + * getters only assert the value's type when it is read, and this screen is where an administrator + * goes to find out that a value is wrong. + */ + #[DataProvider('malformedCacheOptionProvider')] + public function testReportsAMalformedCacheOptionInPlace(string $option, mixed $value): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder([$option => $value])->build(), + $option, + ); + + $this->assertNotNull($row); + $this->assertNotNull($row->getWarning()); + } + + + public static function malformedCacheOptionProvider(): array + { + return [ + 'adapter class is not a string' => [ModuleConfig::OPTION_VCI_CACHE_ADAPTER, 123], + 'adapter arguments are not an array' => [ + ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS, + 'not-an-array', + ], + 'cache duration is not a duration' => [ + ModuleConfig::OPTION_VCI_DID_CACHE_MAX_DURATION, + 'not-a-duration', + ], + ]; + } + + + /** + * Adapter arguments can carry connection credentials, so the row counts them instead of showing + * them. + */ + public function testDoesNotRenderVciCacheAdapterArguments(): void + { + $sections = $this->buildVciOverviewBuilder([ + ModuleConfig::OPTION_VCI_CACHE_ADAPTER => ArrayAdapter::class, + ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS => ['openidVci', 'super-secret-dsn'], + ])->build(); + + $row = $this->findRowForOption($sections, ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS); + $this->assertNotNull($row); + $this->assertSame('2', $row->getValue()); + + $this->assertStringNotContainsString('super-secret-dsn', $this->renderableContent($sections)); + } } diff --git a/tests/unit/src/Factories/CacheFactoryTest.php b/tests/unit/src/Factories/CacheFactoryTest.php new file mode 100644 index 00000000..2396879f --- /dev/null +++ b/tests/unit/src/Factories/CacheFactoryTest.php @@ -0,0 +1,109 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->classInstanceBuilderMock = $this->createMock(ClassInstanceBuilder::class); + } + + + protected function sut(): CacheFactory + { + return new CacheFactory( + $this->moduleConfigMock, + $this->loggerServiceMock, + $this->classInstanceBuilderMock, + ); + } + + + /** + * No adapter configured means no VCI caching, not a broken container. + */ + public function testForVciReturnsNullWhenNoAdapterIsConfigured(): void + { + $this->moduleConfigMock->method('getVciCacheAdapterClass')->willReturn(null); + $this->classInstanceBuilderMock->expects($this->never())->method('build'); + + $this->assertNull($this->sut()->forVci()); + } + + + public function testForVciBuildsTheConfiguredAdapter(): void + { + $this->moduleConfigMock->method('getVciCacheAdapterClass')->willReturn(ArrayAdapter::class); + $this->moduleConfigMock->method('getVciCacheAdapterArguments')->willReturn(['argument']); + + $this->classInstanceBuilderMock->expects($this->once()) + ->method('build') + ->with(ArrayAdapter::class, ['argument']) + ->willReturn(new ArrayAdapter()); + + $this->assertInstanceOf(VciCache::class, $this->sut()->forVci()); + } + + + /** + * A class which is not a cache adapter must be refused rather than reaching a caller which will + * only find out when it tries to cache something. + */ + public function testForVciRefusesAnAdapterOfTheWrongType(): void + { + $this->moduleConfigMock->method('getVciCacheAdapterClass')->willReturn(self::class); + $this->moduleConfigMock->method('getVciCacheAdapterArguments')->willReturn([]); + + $this->classInstanceBuilderMock->method('build')->willReturn($this); + + $this->expectException(OidcException::class); + + $this->sut()->forVci(); + } + + + /** + * An adapter which cannot be constructed - wrong arguments, an unreachable server - must be + * reported rather than surfacing as whatever the adapter itself threw. + */ + public function testForVciReportsAnAdapterWhichCannotBeBuilt(): void + { + $this->moduleConfigMock->method('getVciCacheAdapterClass')->willReturn(ArrayAdapter::class); + $this->moduleConfigMock->method('getVciCacheAdapterArguments')->willReturn([]); + + $this->classInstanceBuilderMock->method('build') + ->willThrowException(new OidcException('Adapter constructor said no.')); + + $this->loggerServiceMock->expects($this->once())->method('error'); + + $this->expectException(OidcException::class); + + $this->sut()->forVci(); + } +} diff --git a/tests/unit/src/Factories/DidFactoryTest.php b/tests/unit/src/Factories/DidFactoryTest.php new file mode 100644 index 00000000..667b01f7 --- /dev/null +++ b/tests/unit/src/Factories/DidFactoryTest.php @@ -0,0 +1,164 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + + $this->moduleConfigMock->method('getVciDidCacheMaxDuration') + ->willReturn(new DateInterval('PT6H')); + $this->moduleConfigMock->method('getVciDidAddressPinningMode') + ->willReturn(AddressPinningModeEnum::Required); + $this->moduleConfigMock->method('getVciDidOutboundAllowedHosts') + ->willReturn([]); + $this->moduleConfigMock->method('getVciDidOutboundAllowedCidrs') + ->willReturn([]); + } + + + protected function sut(): DidFactory + { + return new DidFactory( + $this->moduleConfigMock, + $this->loggerServiceMock, + ); + } + + + /** + * Nothing may be resolved from configuration until a Did is built. + * + * A malformed exemption makes the destination policy throw, and the container reaches this factory + * while wiring up the admin Configuration screens - the screens whose whole purpose is to report + * such an option. Resolving in the constructor would take those screens down instead of showing the + * problem on them, which is how FederationFactory regressed once. + */ + public function testDoesNotResolveConfigurationUntilItBuilds(): void + { + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->expects($this->never())->method('getVciDidAddressPinningMode'); + $moduleConfigMock->expects($this->never())->method('getVciDidOutboundAllowedHosts'); + $moduleConfigMock->expects($this->never())->method('getVciDidOutboundAllowedCidrs'); + $moduleConfigMock->expects($this->never())->method('getVciDidCacheMaxDuration'); + + new DidFactory($moduleConfigMock, $this->loggerServiceMock); + } + + + public function testCanBuild(): void + { + $this->assertInstanceOf(Did::class, $this->sut()->build()); + } + + + /** + * The policy handed to DID resolution must be the DID one, not the deployment's general outbound + * policy: pinning required rather than merely preferred, and https alone. + */ + public function testBuildsARestrictiveDestinationPolicy(): void + { + $destinationPolicy = $this->sut()->build()->destinationPolicy(); + + $this->assertSame( + AddressPinningModeEnum::Required, + $destinationPolicy->getAddressPinningMode(), + ); + $this->assertSame( + DestinationPolicy::DEFAULT_ALLOWED_SCHEMES, + $destinationPolicy->getAllowedSchemes(), + ); + $this->assertSame([], $destinationPolicy->getAllowedHosts()); + $this->assertSame([], $destinationPolicy->getAllowedCidrs()); + } + + + /** + * The DID-specific exemptions reach the policy. Values distinct from anything defaulted, so an + * option which is not actually wired through shows up as a failure rather than passing by accident. + */ + public function testPassesConfiguredExemptionsToTheDestinationPolicy(): void + { + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getVciDidCacheMaxDuration')->willReturn(new DateInterval('PT1H')); + $moduleConfigMock->method('getVciDidAddressPinningMode') + ->willReturn(AddressPinningModeEnum::Disabled); + $moduleConfigMock->method('getVciDidOutboundAllowedHosts') + ->willReturn(['wallet.internal.example']); + $moduleConfigMock->method('getVciDidOutboundAllowedCidrs')->willReturn(['10.1.2.3/32']); + + $destinationPolicy = (new DidFactory($moduleConfigMock, $this->loggerServiceMock)) + ->build() + ->destinationPolicy(); + + $this->assertSame( + AddressPinningModeEnum::Disabled, + $destinationPolicy->getAddressPinningMode(), + ); + $this->assertSame(['wallet.internal.example'], $destinationPolicy->getAllowedHosts()); + $this->assertSame(['10.1.2.3/32'], $destinationPolicy->getAllowedCidrs()); + } + + + /** + * The cache is a decorator around the PSR-16 instance the library wants, so the factory has to + * unwrap it. Passing the decorator itself would be a type error rather than a silent miss, but a + * later change to how it is unwrapped would not be. + */ + public function testAcceptsAConfiguredCache(): void + { + $vciCache = new VciCache(new Psr16Cache(new ArrayAdapter())); + + $this->assertInstanceOf( + Did::class, + (new DidFactory($this->moduleConfigMock, $this->loggerServiceMock, $vciCache))->build(), + ); + } + + + /** + * An exemption is a destination that whoever supplies a DID may send this deployment to, so the + * library notices it. The factory has to hand over a logger for that to be recorded anywhere. + */ + public function testAnExemptionIsReportedToTheLog(): void + { + $this->loggerServiceMock->expects($this->once())->method('notice'); + + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getVciDidCacheMaxDuration')->willReturn(new DateInterval('PT6H')); + $moduleConfigMock->method('getVciDidAddressPinningMode') + ->willReturn(AddressPinningModeEnum::Required); + $moduleConfigMock->method('getVciDidOutboundAllowedHosts') + ->willReturn(['wallet.internal.example']); + $moduleConfigMock->method('getVciDidOutboundAllowedCidrs')->willReturn([]); + + (new DidFactory($moduleConfigMock, $this->loggerServiceMock))->build(); + } +} diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 295bb3dd..bcd1faf6 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -23,6 +23,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\AddressPinningModeEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; use SimpleSAML\OpenID\Exceptions\OpenIdException; use SimpleSAML\OpenID\SupportedAlgorithms; @@ -1738,4 +1739,167 @@ public function testRejectsAnInvertedAuditRetentionGivenAsAnInterval(): void $inverted, ))->getVciStatusListAuditRetention(); } + + + /** + * @throws \Exception + */ + public function testVciCacheIsNotConfiguredByDefault(): void + { + $this->assertNull($this->sut()->getVciCacheAdapterClass()); + $this->assertSame([], $this->sut()->getVciCacheAdapterArguments()); + } + + + /** + * @throws \Exception + */ + public function testCanGetVciCacheAdapterOptions(): void + { + $sut = $this->sut(overrides: array_merge($this->overrides, [ + ModuleConfig::OPTION_VCI_CACHE_ADAPTER => ArrayAdapter::class, + ModuleConfig::OPTION_VCI_CACHE_ADAPTER_ARGUMENTS => ['openidVci'], + ])); + + $this->assertSame(ArrayAdapter::class, $sut->getVciCacheAdapterClass()); + $this->assertSame(['openidVci'], $sut->getVciCacheAdapterArguments()); + } + + + /** + * @throws \Exception + */ + public function testDidCacheMaxDurationDefaultsToSixHours(): void + { + $this->assertSame(6, $this->sut()->getVciDidCacheMaxDuration()->h); + } + + + /** + * @throws \Exception + */ + public function testCanGetDidCacheMaxDuration(): void + { + $this->assertSame( + 30, + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_DID_CACHE_MAX_DURATION, + 'PT30M', + ))->getVciDidCacheMaxDuration()->i, + ); + } + + + /** + * @throws \Exception + */ + public function testDidResolutionHasNoDestinationExemptionsByDefault(): void + { + $this->assertSame([], $this->sut()->getVciDidOutboundAllowedHosts()); + $this->assertSame([], $this->sut()->getVciDidOutboundAllowedCidrs()); + } + + + /** + * @throws \Exception + */ + public function testCanGetDidDestinationExemptions(): void + { + $sut = $this->sut(overrides: array_merge($this->overrides, [ + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_HOSTS => ['wallet.internal.example', 123], + ModuleConfig::OPTION_VCI_DID_OUTBOUND_ALLOWED_CIDRS => ['10.1.2.3/32', null], + ])); + + // Non-string entries are dropped rather than handed to the policy. + $this->assertSame(['wallet.internal.example'], $sut->getVciDidOutboundAllowedHosts()); + $this->assertSame(['10.1.2.3/32'], $sut->getVciDidOutboundAllowedCidrs()); + } + + + /** + * The general outbound exemptions exist so this deployment can reach addresses it operates itself. + * A DID names its own destination and is supplied by whoever is being authenticated, so inheriting + * them here would let that party send this deployment to any of them. + * + * @throws \Exception + */ + public function testDidResolutionDoesNotInheritTheGeneralOutboundExemptions(): void + { + $sut = $this->sut(overrides: array_merge($this->overrides, [ + ModuleConfig::OPTION_OUTBOUND_ALLOWED_HOSTS => ['rp.internal.example'], + ModuleConfig::OPTION_OUTBOUND_ALLOWED_CIDRS => ['10.0.0.0/8'], + ])); + + $this->assertSame([], $sut->getVciDidOutboundAllowedHosts()); + $this->assertSame([], $sut->getVciDidOutboundAllowedCidrs()); + } + + + /** + * Stricter than the general outbound default, which is Preferred. + * + * @throws \Exception + */ + public function testDidAddressPinningModeDefaultsToRequired(): void + { + $this->assertSame( + AddressPinningModeEnum::Required, + $this->sut()->getVciDidAddressPinningMode(), + ); + } + + + /** + * @throws \Exception + */ + public function testCanGetDidAddressPinningModeFromEnumOrString(): void + { + $this->assertSame( + AddressPinningModeEnum::Disabled, + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + AddressPinningModeEnum::Disabled, + ))->getVciDidAddressPinningMode(), + ); + + $this->assertSame( + AddressPinningModeEnum::Disabled, + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + AddressPinningModeEnum::Disabled->value, + ))->getVciDidAddressPinningMode(), + ); + } + + + /** + * Preferred proceeds unpinned wherever pinning turns out to be unavailable, which is the one thing + * a fetch whose destination is chosen from outside must not do. Refused while the configuration is + * read, so the admin screens report it rather than an issuance failing later. + * + * @throws \Exception + */ + public function testRejectsPreferredDidAddressPinningMode(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + AddressPinningModeEnum::Preferred, + ))->getVciDidAddressPinningMode(); + } + + + /** + * @throws \Exception + */ + public function testRejectsAnUnknownDidAddressPinningMode(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_DID_ADDRESS_PINNING_MODE, + 'whenever-convenient', + ))->getVciDidAddressPinningMode(); + } } From a1847400bb13b0f2ff1d8dba2d993f8b2f602645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 31 Aug 2026 15:34:22 +0200 Subject: [PATCH 04/15] Report a malformed option on its row instead of taking the screen down --- .../FederationOverviewBuilder.php | 467 +++++++---- .../ProtocolOverviewBuilder.php | 779 ++++++++++++------ .../FederationOverviewBuilderTest.php | 134 +++ .../GeneralOverviewBuilderTest.php | 11 + .../ConfigOverview/OverviewTestTrait.php | 63 ++ .../ProtocolOverviewBuilderTest.php | 104 +++ .../ConfigOverview/VciOverviewBuilderTest.php | 11 + 7 files changed, 1149 insertions(+), 420 deletions(-) diff --git a/src/Admin/ConfigOverview/FederationOverviewBuilder.php b/src/Admin/ConfigOverview/FederationOverviewBuilder.php index 74ce7d33..0ca7d9b2 100644 --- a/src/Admin/ConfigOverview/FederationOverviewBuilder.php +++ b/src/Admin/ConfigOverview/FederationOverviewBuilder.php @@ -50,20 +50,27 @@ public function build(array $trustMarks = []): array */ protected function buildEntitySection(): Section { - $isEnabled = $this->moduleConfig->getFederationEnabled(); - return new Section( Translate::noop('Entity'), 'entity', - new Row( + $this->guardRow( Translate::noop('Federation Enabled'), - $this->yesNo($isEnabled), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_FEDERATION_ENABLED, - $isEnabled ? null : Translate::noop( - 'All OpenID Federation capabilities are off, and the federation endpoints are ' . - 'not served. The settings below are inert until this is enabled.', - ), + function (): Row { + $isEnabled = $this->moduleConfig->getFederationEnabled(); + + return new Row( + Translate::noop('Federation Enabled'), + $this->yesNo($isEnabled), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_FEDERATION_ENABLED, + $isEnabled ? null : Translate::noop( + 'All OpenID Federation capabilities are off, and the federation ' . + 'endpoints are not served. The settings below are inert until this is ' . + 'enabled.', + ), + ); + }, ), $this->buildIssuerRow( Translate::noop('Also this entity\'s Entity Identifier in the federation.'), @@ -73,59 +80,100 @@ protected function buildEntitySection(): Section 'reached, which breaks Trust Chain resolution.', ), ), - $this->buildOptionalTextRow( + $this->guardRow( Translate::noop('Organization Name'), - $this->moduleConfig->getOrganizationName(), ModuleConfig::OPTION_ORGANIZATION_NAME, + fn(): Row => $this->buildOptionalTextRow( + Translate::noop('Organization Name'), + $this->moduleConfig->getOrganizationName(), + ModuleConfig::OPTION_ORGANIZATION_NAME, + ), ), - $this->buildOptionalTextRow( + $this->guardRow( Translate::noop('Display Name'), - $this->moduleConfig->getDisplayName(), ModuleConfig::OPTION_DISPLAY_NAME, + fn(): Row => $this->buildOptionalTextRow( + Translate::noop('Display Name'), + $this->moduleConfig->getDisplayName(), + ModuleConfig::OPTION_DISPLAY_NAME, + ), ), - $this->buildOptionalTextRow( + $this->guardRow( Translate::noop('Description'), - $this->moduleConfig->getDescription(), ModuleConfig::OPTION_DESCRIPTION, + fn(): Row => $this->buildOptionalTextRow( + Translate::noop('Description'), + $this->moduleConfig->getDescription(), + ModuleConfig::OPTION_DESCRIPTION, + ), ), - new Row( + $this->guardRow( Translate::noop('Keywords'), - $this->moduleConfig->getKeywords() ?? [], - ConfigOverviewValueTypeEnum::StringList, ModuleConfig::OPTION_KEYWORDS, + fn(): Row => new Row( + Translate::noop('Keywords'), + $this->moduleConfig->getKeywords() ?? [], + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_KEYWORDS, + ), ), - new Row( + $this->guardRow( Translate::noop('Contacts'), - $this->moduleConfig->getContacts() ?? [], - ConfigOverviewValueTypeEnum::StringList, ModuleConfig::OPTION_CONTACTS, + fn(): Row => new Row( + Translate::noop('Contacts'), + $this->moduleConfig->getContacts() ?? [], + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_CONTACTS, + ), ), - $this->buildOptionalUrlRow( + $this->guardRow( Translate::noop('Logo URI'), - $this->moduleConfig->getLogoUri(), ModuleConfig::OPTION_LOGO_URI, + fn(): Row => $this->buildOptionalUrlRow( + Translate::noop('Logo URI'), + $this->moduleConfig->getLogoUri(), + ModuleConfig::OPTION_LOGO_URI, + ), ), - $this->buildOptionalUrlRow( + $this->guardRow( Translate::noop('Policy URI'), - $this->moduleConfig->getPolicyUri(), ModuleConfig::OPTION_POLICY_URI, + fn(): Row => $this->buildOptionalUrlRow( + Translate::noop('Policy URI'), + $this->moduleConfig->getPolicyUri(), + ModuleConfig::OPTION_POLICY_URI, + ), ), - $this->buildOptionalUrlRow( + $this->guardRow( Translate::noop('Information URI'), - $this->moduleConfig->getInformationUri(), ModuleConfig::OPTION_INFORMATION_URI, + fn(): Row => $this->buildOptionalUrlRow( + Translate::noop('Information URI'), + $this->moduleConfig->getInformationUri(), + ModuleConfig::OPTION_INFORMATION_URI, + ), ), - $this->buildOptionalUrlRow( + $this->guardRow( Translate::noop('Organization URI'), - $this->moduleConfig->getOrganizationUri(), ModuleConfig::OPTION_ORGANIZATION_URI, + fn(): Row => $this->buildOptionalUrlRow( + Translate::noop('Organization URI'), + $this->moduleConfig->getOrganizationUri(), + ModuleConfig::OPTION_ORGANIZATION_URI, + ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('Entity Statement Duration'), - $this->moduleConfig->getFederationEntityStatementDuration(), ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION, - Translate::noop( - 'Sets the Expiration Time (exp) claim on Entity Statements published by this OP.', + fn(): Row => $this->buildDurationRow( + Translate::noop('Entity Statement Duration'), + $this->moduleConfig->getFederationEntityStatementDuration(), + ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION, + Translate::noop( + 'Sets the Expiration Time (exp) claim on Entity Statements published by ' . + 'this OP.', + ), ), ), ); @@ -137,6 +185,17 @@ protected function buildEntitySection(): Section */ protected function buildEndpointsSection(): Section { + // The row itself displays a URL rather than an option, so a bad OPTION_FEDERATION_ENABLED is + // reported on its own row in the Entity section instead. Resolved defensively here only so + // that it cannot take this row down on the way past. + $isEnabled = false; + + try { + $isEnabled = $this->moduleConfig->getFederationEnabled(); + } catch (Throwable) { + // Reported on the Federation Enabled row, where the option which failed is named. + } + return new Section( Translate::noop('Endpoints'), 'endpoints', @@ -145,7 +204,7 @@ protected function buildEndpointsSection(): Section $this->routes->urlFederationConfiguration(), ConfigOverviewValueTypeEnum::Url, null, - $this->moduleConfig->getFederationEnabled() ? + $isEnabled ? null : Translate::noop('Not served, since OpenID Federation is disabled.'), ), @@ -198,7 +257,23 @@ protected function buildTrustAnchorsSection(): Section try { $trustAnchors = $this->moduleConfig->getFederationTrustAnchors(); } catch (Throwable $exception) { - $error = $this->describeResolutionError($exception, ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS); + // That getter reads OPTION_FEDERATION_ENABLED as well, to decide whether an empty list is + // an error, so a malformed switch throws from inside it too. Reporting that here would put + // an invented Trust Anchors failure next to the real one on the Federation Enabled row, so + // it is only this option's to report when the switch itself reads cleanly. If both are + // malformed at once the switch is reported and this one surfaces on the next load, which + // is the right order to fix them in anyway. + $isSwitchReadable = true; + + try { + $this->moduleConfig->getFederationEnabled(); + } catch (Throwable) { + $isSwitchReadable = false; + } + + $error = $isSwitchReadable ? + $this->describeResolutionError($exception, ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS) : + null; } $trustAnchorList = $this->buildTrustAnchorList($trustAnchors); @@ -212,8 +287,6 @@ protected function buildTrustAnchorsSection(): Section ); } - $authorityHints = $this->moduleConfig->getFederationAuthorityHints() ?? []; - return new Section( Translate::noop('Trust Anchors and Authority Hints'), 'trust-anchors', @@ -228,14 +301,18 @@ protected function buildTrustAnchorsSection(): Section ), $error, ), - new Row( + $this->guardRow( Translate::noop('Authority Hints'), - $authorityHints, - ConfigOverviewValueTypeEnum::StringList, ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS, - Translate::noop( - 'Entity Identifiers of the Intermediates or Trust Anchors directly above this ' . - 'entity. Required if this entity has a Superior.', + fn(): Row => new Row( + Translate::noop('Authority Hints'), + $this->moduleConfig->getFederationAuthorityHints() ?? [], + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS, + Translate::noop( + 'Entity Identifiers of the Intermediates or Trust Anchors directly above ' . + 'this entity. Required if this entity has a Superior.', + ), ), ), ); @@ -249,30 +326,6 @@ protected function buildTrustAnchorsSection(): Section protected function buildTrustMarksSection(array $trustMarks): Section { $trustMarkList = $this->buildTrustMarkList($trustMarks, $unreadableTrustMarkCount); - $staticTokenCount = count($this->moduleConfig->getFederationTrustMarkTokens() ?? []); - $dynamicTrustMarks = $this->moduleConfig->getFederationDynamicTrustMarks() ?? []; - $participationLimits = $this->moduleConfig->getFederationParticipationLimitByTrustMarks(); - // Warnings are fixed sentences rather than interpolated ones, so that they resolve against - // the message catalog. The offending entries are visible in the row value itself. - $hasUnknownLimitIds = $this->findUnknownParticipationLimitIds($participationLimits) !== []; - $hasMalformedLimits = $this->hasMalformedParticipationLimits($participationLimits); - - $participationLimitsWarning = match (true) { - $hasUnknownLimitIds && $hasMalformedLimits => Translate::noop( - 'Unrecognized limit identifiers and entries of an unexpected shape are configured. ' . - 'The runtime validator rejects both, so federation participation will fail for the ' . - 'affected Trust Anchors.', - ), - $hasUnknownLimitIds => Translate::noop( - 'Unrecognized limit identifiers are configured, which the runtime validator rejects. ' . - 'Only \'one_of\' and \'all_of\' are supported.', - ), - $hasMalformedLimits => Translate::noop( - 'Some entries have an unexpected shape. Each Trust Anchor must map to a list of ' . - 'limits, and each limit to a list of Trust Mark Type strings.', - ), - default => null, - }; return new Section( Translate::noop('Trust Marks'), @@ -293,46 +346,90 @@ protected function buildTrustMarksSection(array $trustMarks): Section 'written to the SimpleSAMLphp log.', ) : null, ), - $this->buildSecretCountRow( + $this->guardRow( Translate::noop('Statically Configured Trust Mark Tokens'), - $staticTokenCount, ModuleConfig::OPTION_FEDERATION_TRUST_MARK_TOKENS, - Translate::noop( - 'Signed JWTs held in configuration, intended for long lasting or non-expiring ' . - 'Trust Marks. Their decoded payloads are listed above.', + fn(): Row => $this->buildSecretCountRow( + Translate::noop('Statically Configured Trust Mark Tokens'), + count($this->moduleConfig->getFederationTrustMarkTokens() ?? []), + ModuleConfig::OPTION_FEDERATION_TRUST_MARK_TOKENS, + Translate::noop( + 'Signed JWTs held in configuration, intended for long lasting or ' . + 'non-expiring Trust Marks. Their decoded payloads are listed above.', + ), ), ), - new Row( + $this->guardRow( Translate::noop('Dynamically Fetched Trust Marks'), - $this->buildDynamicTrustMarkMap($dynamicTrustMarks), - ConfigOverviewValueTypeEnum::StringMap, ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS, - Translate::noop('Trust Mark Type, and the Trust Mark Issuer it is fetched from.'), + fn(): Row => new Row( + Translate::noop('Dynamically Fetched Trust Marks'), + $this->buildDynamicTrustMarkMap( + $this->moduleConfig->getFederationDynamicTrustMarks() ?? [], + ), + ConfigOverviewValueTypeEnum::StringMap, + ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS, + Translate::noop('Trust Mark Type, and the Trust Mark Issuer it is fetched from.'), + ), ), - new Row( + $this->guardRow( Translate::noop('Trust Mark Status Endpoint Usage Policy'), - $this->describeTrustMarkStatusPolicy( - $this->moduleConfig->getFederationTrustMarkStatusEndpointUsagePolicy(), - ), - // UI text built from message IDs, so it stays translatable. - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_FEDERATION_TRUST_MARK_STATUS_ENDPOINT_USAGE_POLICY, - Translate::noop( - 'When the Trust Mark Issuer\'s status endpoint is consulted to check whether a ' . - 'Trust Mark is still valid.', + fn(): Row => new Row( + Translate::noop('Trust Mark Status Endpoint Usage Policy'), + $this->describeTrustMarkStatusPolicy( + $this->moduleConfig->getFederationTrustMarkStatusEndpointUsagePolicy(), + ), + // UI text built from message IDs, so it stays translatable. + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_FEDERATION_TRUST_MARK_STATUS_ENDPOINT_USAGE_POLICY, + Translate::noop( + 'When the Trust Mark Issuer\'s status endpoint is consulted to check whether ' . + 'a Trust Mark is still valid.', + ), ), ), - new Row( + $this->guardRow( Translate::noop('Federation Participation Limits'), - $this->buildParticipationLimits($participationLimits), - ConfigOverviewValueTypeEnum::Json, ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS, - Translate::noop( - 'Per Trust Anchor, the Trust Marks an entity must hold in order to participate. ' . - '\'one_of\' requires at least one from the list, \'all_of\' requires them all. ' . - 'Trust Anchors which are not listed apply no Trust Mark limit.', - ), - $participationLimitsWarning, + function (): Row { + $participationLimits = $this->moduleConfig->getFederationParticipationLimitByTrustMarks(); + // Warnings are fixed sentences rather than interpolated ones, so that they + // resolve against the message catalog. The offending entries are visible in the + // row value itself. + $hasUnknownLimitIds = $this->findUnknownParticipationLimitIds($participationLimits) !== []; + $hasMalformedLimits = $this->hasMalformedParticipationLimits($participationLimits); + + return new Row( + Translate::noop('Federation Participation Limits'), + $this->buildParticipationLimits($participationLimits), + ConfigOverviewValueTypeEnum::Json, + ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS, + Translate::noop( + 'Per Trust Anchor, the Trust Marks an entity must hold in order to ' . + 'participate. \'one_of\' requires at least one from the list, ' . + '\'all_of\' requires them all. Trust Anchors which are not listed apply ' . + 'no Trust Mark limit.', + ), + match (true) { + $hasUnknownLimitIds && $hasMalformedLimits => Translate::noop( + 'Unrecognized limit identifiers and entries of an unexpected shape ' . + 'are configured. The runtime validator rejects both, so federation ' . + 'participation will fail for the affected Trust Anchors.', + ), + $hasUnknownLimitIds => Translate::noop( + 'Unrecognized limit identifiers are configured, which the runtime ' . + 'validator rejects. Only \'one_of\' and \'all_of\' are supported.', + ), + $hasMalformedLimits => Translate::noop( + 'Some entries have an unexpected shape. Each Trust Anchor must map ' . + 'to a list of limits, and each limit to a list of Trust Mark Type ' . + 'strings.', + ), + default => null, + }, + ); + }, ), ); } @@ -346,46 +443,70 @@ protected function buildTrustChainLimitsSection(): Section return new Section( Translate::noop('Trust Chain resolution limits'), 'trust-chain-limits', - new Row( + $this->guardRow( Translate::noop('Maximum Trust Chain Depth'), - (string)$this->moduleConfig->getFederationMaxTrustChainDepth(), - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_FEDERATION_MAX_TRUST_CHAIN_DEPTH, - Translate::noop('Hops from the leaf entity up to a Trust Anchor. Clamped by the library to 1..20.'), + fn(): Row => new Row( + Translate::noop('Maximum Trust Chain Depth'), + (string)$this->moduleConfig->getFederationMaxTrustChainDepth(), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_FEDERATION_MAX_TRUST_CHAIN_DEPTH, + Translate::noop( + 'Hops from the leaf entity up to a Trust Anchor. Clamped by the library to 1..20.', + ), + ), ), - new Row( + $this->guardRow( Translate::noop('Maximum Authority Hints per Entity'), - (string)$this->moduleConfig->getFederationMaxAuthorityHints(), - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_FEDERATION_MAX_AUTHORITY_HINTS, - Translate::noop('The branching factor. Clamped by the library to 1..12.'), + fn(): Row => new Row( + Translate::noop('Maximum Authority Hints per Entity'), + (string)$this->moduleConfig->getFederationMaxAuthorityHints(), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_FEDERATION_MAX_AUTHORITY_HINTS, + Translate::noop('The branching factor. Clamped by the library to 1..12.'), + ), ), - new Row( + $this->guardRow( Translate::noop('Maximum Entity Statement Fetches per Resolution'), - (string)$this->moduleConfig->getFederationMaxTrustChainFetches(), - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_FEDERATION_MAX_TRUST_CHAIN_FETCHES, - Translate::noop( - 'Depth and authority hints multiply out, so this budget and the timeout below ' . - 'are the effective bounds. Clamped by the library to 1..1000.', + fn(): Row => new Row( + Translate::noop('Maximum Entity Statement Fetches per Resolution'), + (string)$this->moduleConfig->getFederationMaxTrustChainFetches(), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_FEDERATION_MAX_TRUST_CHAIN_FETCHES, + Translate::noop( + 'Depth and authority hints multiply out, so this budget and the timeout ' . + 'below are the effective bounds. Clamped by the library to 1..1000.', + ), ), ), - new Row( + $this->guardRow( Translate::noop('Trust Chain Resolve Timeout (seconds)'), - (string)$this->moduleConfig->getFederationTrustChainResolveTimeout(), - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_FEDERATION_TRUST_CHAIN_RESOLVE_TIMEOUT, - Translate::noop('Wall-clock deadline for one resolution. Clamped by the library to 1..300.'), + fn(): Row => new Row( + Translate::noop('Trust Chain Resolve Timeout (seconds)'), + (string)$this->moduleConfig->getFederationTrustChainResolveTimeout(), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_FEDERATION_TRUST_CHAIN_RESOLVE_TIMEOUT, + Translate::noop( + 'Wall-clock deadline for one resolution. Clamped by the library to 1..300.', + ), + ), ), - new Row( + $this->guardRow( Translate::noop('Maximum Fetch Response Size (bytes)'), - $this->formatBytes($this->moduleConfig->getFederationMaxFetchSizeBytes()), - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_FEDERATION_MAX_FETCH_SIZE_BYTES, - Translate::noop( - 'These limits matter because Trust Chain resolution is reachable on an ' . - 'unauthenticated path, walking entity configurations fetched from arbitrary, ' . - 'possibly hostile, federation entities.', + fn(): Row => new Row( + Translate::noop('Maximum Fetch Response Size (bytes)'), + $this->formatBytes($this->moduleConfig->getFederationMaxFetchSizeBytes()), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_FEDERATION_MAX_FETCH_SIZE_BYTES, + Translate::noop( + 'These limits matter because Trust Chain resolution is reachable on an ' . + 'unauthenticated path, walking entity configurations fetched from arbitrary, ' . + 'possibly hostile, federation entities.', + ), ), ), ); @@ -397,50 +518,78 @@ protected function buildTrustChainLimitsSection(): Section */ protected function buildCacheSection(): Section { - $adapterClass = $this->moduleConfig->getFederationCacheAdapterClass(); - $adapterArgumentCount = count($this->moduleConfig->getFederationCacheAdapterArguments()); - $isCachingActive = !is_null($adapterClass); + // Only asserted to be a string when it is read, so a value of another type throws here. This + // screen is where an administrator goes to find that out, so it must not take it down. + $isCachingActive = false; + + try { + $isCachingActive = !is_null($this->moduleConfig->getFederationCacheAdapterClass()); + } catch (Throwable) { + // Reported on its own row below, where the option which failed to resolve is named. + } $notUsedNote = Translate::noop('Not used, since no federation cache adapter is configured.'); return new Section( Translate::noop('Cache'), 'cache', - new Row( + $this->guardRow( Translate::noop('Cache Adapter'), - $adapterClass ?? Translate::noop('N/A'), - $isCachingActive ? ConfigOverviewValueTypeEnum::RawText : ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER, - $isCachingActive ? null : Translate::noop( - 'Not set, so no federation caching is performed and every Trust Chain ' . - 'resolution refetches. Setting a cache adapter is recommended in production.', - ), - ), - $this->buildSecretCountRow( + function () use ($isCachingActive): Row { + $adapterClass = $this->moduleConfig->getFederationCacheAdapterClass(); + + return new Row( + Translate::noop('Cache Adapter'), + $adapterClass ?? Translate::noop('N/A'), + $isCachingActive ? + ConfigOverviewValueTypeEnum::RawText : + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER, + $isCachingActive ? null : Translate::noop( + 'Not set, so no federation caching is performed and every Trust Chain ' . + 'resolution refetches. Setting a cache adapter is recommended in production.', + ), + ); + }, + ), + $this->guardRow( Translate::noop('Cache Adapter Arguments'), - $adapterArgumentCount, ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS, - Translate::noop( - 'Values are not shown, since adapter arguments can carry connection credentials.', + fn(): Row => $this->buildSecretCountRow( + Translate::noop('Cache Adapter Arguments'), + count($this->moduleConfig->getFederationCacheAdapterArguments()), + ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS, + Translate::noop( + 'Values are not shown, since adapter arguments can carry connection credentials.', + ), ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('Maximum Cache Duration for Fetched Artifacts'), - $this->moduleConfig->getFederationCacheMaxDurationForFetched(), ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED, - $isCachingActive ? Translate::noop( - 'Caps how long a fetched artifact is cached, since its own expiry is set by the ' . - 'issuer and can be long. Lower values propagate federation changes faster.', - ) : $notUsedNote, + fn(): Row => $this->buildDurationRow( + Translate::noop('Maximum Cache Duration for Fetched Artifacts'), + $this->moduleConfig->getFederationCacheMaxDurationForFetched(), + ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED, + $isCachingActive ? Translate::noop( + 'Caps how long a fetched artifact is cached, since its own expiry is set by ' . + 'the issuer and can be long. Lower values propagate federation changes faster.', + ) : $notUsedNote, + ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('Cache Duration for Produced Artifacts'), - $this->moduleConfig->getFederationEntityStatementCacheDurationForProduced(), ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED, - $isCachingActive ? Translate::noop( - 'Avoids recomputing the JWS signature for statements this OP publishes on every ' . - 'request.', - ) : $notUsedNote, + fn(): Row => $this->buildDurationRow( + Translate::noop('Cache Duration for Produced Artifacts'), + $this->moduleConfig->getFederationEntityStatementCacheDurationForProduced(), + ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED, + $isCachingActive ? Translate::noop( + 'Avoids recomputing the JWS signature for statements this OP publishes on ' . + 'every request.', + ) : $notUsedNote, + ), ), ); } @@ -454,19 +603,23 @@ protected function buildOutboundHttpSection(): Section return new Section( Translate::noop('Outbound HTTP requests'), 'outbound-http', - $this->buildHttpClientOptionsRow( + $this->guardRow( Translate::noop('Federation HTTP Client Options'), - $this->moduleConfig->getFederationHttpClientOptions(), ModuleConfig::OPTION_FEDERATION_HTTP_CLIENT_OPTIONS, - Translate::noop( - 'Merged over the library\'s hardening defaults, so a value set here replaces the ' . - 'corresponding default. Of note: \'timeout\' and \'connect_timeout\' (Guzzle ' . - 'reads 0 as no timeout), and \'allow_redirects\' (the library restricts ' . - 'redirects to at most 3 https hops).', - ), - Translate::noop( - 'Not set, so the library\'s hardening defaults apply, including TLS ' . - 'verification and restricted redirects.', + fn(): Row => $this->buildHttpClientOptionsRow( + Translate::noop('Federation HTTP Client Options'), + $this->moduleConfig->getFederationHttpClientOptions(), + ModuleConfig::OPTION_FEDERATION_HTTP_CLIENT_OPTIONS, + Translate::noop( + 'Merged over the library\'s hardening defaults, so a value set here replaces ' . + 'the corresponding default. Of note: \'timeout\' and \'connect_timeout\' ' . + '(Guzzle reads 0 as no timeout), and \'allow_redirects\' (the library ' . + 'restricts redirects to at most 3 https hops).', + ), + Translate::noop( + 'Not set, so the library\'s hardening defaults apply, including TLS ' . + 'verification and restricted redirects.', + ), ), ), ); diff --git a/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php b/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php index 18f90d46..923561db 100644 --- a/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php +++ b/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php @@ -139,7 +139,18 @@ protected function buildEndpointsSection(): Section ), ]; - if ($this->moduleConfig->getDcrEnabled()) { + // These rows display URLs rather than options, so a bad OPTION_DCR_ENABLED is reported on its + // own row in the Dynamic Client Registration section instead of here. Resolved defensively + // only so that it cannot take this section down on the way past. + $isDcrEnabled = false; + + try { + $isDcrEnabled = $this->moduleConfig->getDcrEnabled(); + } catch (Throwable) { + // Reported on the Dynamic Client Registration Enabled row, where the option is named. + } + + if ($isDcrEnabled) { $rows[] = new Row( Translate::noop('Client Registration'), $this->routes->urlRegistration(), @@ -159,40 +170,60 @@ protected function buildTokensSection(): Section return new Section( Translate::noop('Tokens and cryptography'), 'tokens', - $this->buildDurationRow( + $this->guardRow( Translate::noop('Authorization Code TTL'), - $this->moduleConfig->getAuthCodeDuration(), ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL, + fn(): Row => $this->buildDurationRow( + Translate::noop('Authorization Code TTL'), + $this->moduleConfig->getAuthCodeDuration(), + ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL, + ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('Access Token TTL'), - $this->moduleConfig->getAccessTokenDuration(), ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL, + fn(): Row => $this->buildDurationRow( + Translate::noop('Access Token TTL'), + $this->moduleConfig->getAccessTokenDuration(), + ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL, + ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('Refresh Token TTL'), - $this->moduleConfig->getRefreshTokenDuration(), ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL, + fn(): Row => $this->buildDurationRow( + Translate::noop('Refresh Token TTL'), + $this->moduleConfig->getRefreshTokenDuration(), + ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL, + ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('Timestamp Validation Leeway'), - $this->moduleConfig->getTimestampValidationLeeway(), ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY, - Translate::noop( - 'Tolerance allowed when validating timestamp claims (exp, iat, nbf) on JWS artifacts.', + fn(): Row => $this->buildDurationRow( + Translate::noop('Timestamp Validation Leeway'), + $this->moduleConfig->getTimestampValidationLeeway(), + ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY, + Translate::noop( + 'Tolerance allowed when validating timestamp claims (exp, iat, nbf) on JWS artifacts.', + ), ), ), - new Row( + $this->guardRow( Translate::noop('Encryption Key'), - $this->moduleConfig->isEncryptionKeyConfigured() ? - Translate::noop('Dedicated encryption key configured') : - Translate::noop('Derived from the SimpleSAMLphp secret salt'), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_ENCRYPTION_KEY, - Translate::noop( - 'Protects issued authorization codes and refresh tokens. The value itself is a ' . - 'secret and is never shown here. Changing it invalidates all outstanding ' . - 'encrypted artifacts.', + fn(): Row => new Row( + Translate::noop('Encryption Key'), + $this->moduleConfig->isEncryptionKeyConfigured() ? + Translate::noop('Dedicated encryption key configured') : + Translate::noop('Derived from the SimpleSAMLphp secret salt'), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_ENCRYPTION_KEY, + Translate::noop( + 'Protects issued authorization codes and refresh tokens. The value itself is ' . + 'a secret and is never shown here. Changing it invalidates all outstanding ' . + 'encrypted artifacts.', + ), ), ), ); @@ -243,32 +274,47 @@ protected function buildAuthenticationSection(): Section return new Section( Translate::noop('Authentication'), 'authentication', - new Row( + $this->guardRow( Translate::noop('Default Authentication Source'), - $this->moduleConfig->getDefaultAuthSourceId(), - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_AUTH_SOURCE, - Translate::noop('Used for clients which do not have their own authentication source set.'), + fn(): Row => new Row( + Translate::noop('Default Authentication Source'), + $this->moduleConfig->getDefaultAuthSourceId(), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_AUTH_SOURCE, + Translate::noop( + 'Used for clients which do not have their own authentication source set.', + ), + ), ), - new Row( + $this->guardRow( Translate::noop('User Identifier Attributes'), - $this->moduleConfig->getUserIdentifierAttributes(), - ConfigOverviewValueTypeEnum::StringList, ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE, - Translate::noop( - 'Consulted in the order shown. The first attribute actually present in the ' . - 'released attributes is used as the user identifier, and as the default source ' . - "for the 'sub' claim.", + fn(): Row => new Row( + Translate::noop('User Identifier Attributes'), + $this->moduleConfig->getUserIdentifierAttributes(), + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE, + Translate::noop( + 'Consulted in the order shown. The first attribute actually present in the ' . + 'released attributes is used as the user identifier, and as the default ' . + "source for the 'sub' claim.", + ), ), ), - new Row( + $this->guardRow( Translate::noop('Authentication Processing Filters'), - $this->buildAuthProcFilterList(), - ConfigOverviewValueTypeEnum::StringList, ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS, - Translate::noop( - 'Run for every OIDC authentication, in the order shown, which is by priority. ' . - 'Per-client filters are merged into the same chain by priority as well.', + fn(): Row => new Row( + Translate::noop('Authentication Processing Filters'), + $this->buildAuthProcFilterList(), + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS, + Translate::noop( + 'Run for every OIDC authentication, in the order shown, which is by ' . + 'priority. Per-client filters are merged into the same chain by priority as ' . + 'well.', + ), ), ), ); @@ -280,37 +326,51 @@ protected function buildAuthenticationSection(): Section */ protected function buildAcrSection(): Section { - $forcedAcrValue = $this->moduleConfig->getForcedAcrValueForCookieAuthentication(); - return new Section( Translate::noop('Authentication Context Class References (ACRs)'), 'acrs', - new Row( + $this->guardRow( Translate::noop('Supported ACRs'), - $this->moduleConfig->getAcrValuesSupported(), - ConfigOverviewValueTypeEnum::StringList, ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED, - Translate::noop("Published in the OP discovery document as 'acr_values_supported'."), + fn(): Row => new Row( + Translate::noop('Supported ACRs'), + $this->moduleConfig->getAcrValuesSupported(), + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED, + Translate::noop("Published in the OP discovery document as 'acr_values_supported'."), + ), ), - new Row( + $this->guardRow( Translate::noop('Authentication Sources to ACRs Map'), - $this->moduleConfig->getAuthSourcesToAcrValuesMap(), - ConfigOverviewValueTypeEnum::StringMap, ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP, - Translate::noop('ACRs are listed in order of importance, most important first.'), + fn(): Row => new Row( + Translate::noop('Authentication Sources to ACRs Map'), + $this->moduleConfig->getAuthSourcesToAcrValuesMap(), + ConfigOverviewValueTypeEnum::StringMap, + ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP, + Translate::noop('ACRs are listed in order of importance, most important first.'), + ), ), - new Row( + $this->guardRow( Translate::noop('Forced ACR for Cookie Authentication'), - $forcedAcrValue ?? Translate::noop('N/A'), - // A configured ACR is data, the 'N/A' placeholder is UI text. - is_null($forcedAcrValue) ? - ConfigOverviewValueTypeEnum::Text : - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION, - is_null($forcedAcrValue) ? Translate::noop( - 'No specific ACR is forced, so the resulting ACR is one of those supported by ' . - 'the auth source used during session creation.', - ) : null, + function (): Row { + $forcedAcrValue = $this->moduleConfig->getForcedAcrValueForCookieAuthentication(); + + return new Row( + Translate::noop('Forced ACR for Cookie Authentication'), + $forcedAcrValue ?? Translate::noop('N/A'), + // A configured ACR is data, the 'N/A' placeholder is UI text. + is_null($forcedAcrValue) ? + ConfigOverviewValueTypeEnum::Text : + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION, + is_null($forcedAcrValue) ? Translate::noop( + 'No specific ACR is forced, so the resulting ACR is one of those ' . + 'supported by the auth source used during session creation.', + ) : null, + ); + }, ), ); } @@ -348,25 +408,33 @@ protected function buildScopesAndClaimsSection(): Section ) : null, $error, ), - new Row( + $this->guardRow( Translate::noop('SAML Attribute to OIDC Claim Translation'), - $this->claimTranslatorExtractor->getTranslationTable(), - ConfigOverviewValueTypeEnum::Json, ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, - Translate::noop( - 'The effective table: module defaults with the configured translation table ' . - "merged over them, the user identifier attributes prepended to the 'sub' claim, " . - 'and any per-scope claim name prefixes already applied.', + fn(): Row => new Row( + Translate::noop('SAML Attribute to OIDC Claim Translation'), + $this->claimTranslatorExtractor->getTranslationTable(), + ConfigOverviewValueTypeEnum::Json, + ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, + Translate::noop( + 'The effective table: module defaults with the configured translation table ' . + "merged over them, the user identifier attributes prepended to the 'sub' " . + 'claim, and any per-scope claim name prefixes already applied.', + ), ), ), - new Row( + $this->guardRow( Translate::noop("Publish 'claims_supported' in Discovery"), - $this->yesNo($this->moduleConfig->getProtocolDiscoveryShowClaimsSupported()), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED, - Translate::noop( - 'When enabled, the discovery document lists all claims for which a translation ' . - 'is defined.', + fn(): Row => new Row( + Translate::noop("Publish 'claims_supported' in Discovery"), + $this->yesNo($this->moduleConfig->getProtocolDiscoveryShowClaimsSupported()), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED, + Translate::noop( + 'When enabled, the discovery document lists all claims for which a ' . + 'translation is defined.', + ), ), ), ); @@ -378,95 +446,142 @@ protected function buildScopesAndClaimsSection(): Section */ protected function buildRequestObjectSection(): Section { - $requestUriParameterSupported = $this->moduleConfig->getRequestUriParameterSupported(); - $allowedPrefixes = $this->moduleConfig->getFederationRequestUriAllowedPrefixes(); - // RequestParamsResolver only takes the federation by-reference path when federation is - // enabled, so without it the allowlist is never consulted. - $isFederationEnabled = $this->moduleConfig->getFederationEnabled(); - - // null means "allow any", an empty array means "deny all" (also the default), and a - // non-empty array is the actual allowlist. - $allowedPrefixesRow = new Row( - Translate::noop('Allowed Request URI Prefixes for Federation Candidates'), - match (true) { - is_null($allowedPrefixes) => Translate::noop('Any request URI is allowed'), - $allowedPrefixes === [] => Translate::noop('None, so no such request URI is fetched'), - default => $allowedPrefixes, - }, - is_array($allowedPrefixes) && $allowedPrefixes !== [] ? - ConfigOverviewValueTypeEnum::StringList : - ConfigOverviewValueTypeEnum::Text, - ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES, - $isFederationEnabled ? Translate::noop( - 'Applies only to OpenID Federation candidates, that is, clients which are not ' . - 'registered in storage. For registered clients the request URI must match one of ' . - 'their own registered request URIs exactly.', - ) : Translate::noop( - 'Not used, since OpenID Federation is disabled and the by-reference fetch for ' . - 'federation candidates therefore never runs.', - ), - ($isFederationEnabled && $requestUriParameterSupported && is_null($allowedPrefixes)) ? - Translate::noop( - 'Any request URI supplied by an unregistered federation candidate will be fetched, ' . - 'which is a server-side request forgery surface. Configure explicit prefixes instead.', - ) : null, - ); + // Both feed rows other than their own, so they are resolved defensively rather than left to + // take the section down. Each is reported on its own row, where the option is named. + $requestUriParameterSupported = false; + $isFederationEnabled = false; + + try { + $requestUriParameterSupported = $this->moduleConfig->getRequestUriParameterSupported(); + } catch (Throwable) { + // Reported on the request_uri parameter row below. + } + + try { + // RequestParamsResolver only takes the federation by-reference path when federation is + // enabled, so without it the allowlist is never consulted. + $isFederationEnabled = $this->moduleConfig->getFederationEnabled(); + } catch (Throwable) { + // Reported on the federation screen, which owns OPTION_FEDERATION_ENABLED. + } return new Section( Translate::noop('Request Object and Pushed Authorization Requests'), 'request-object', - new Row( + $this->guardRow( Translate::noop('Require Pushed Authorization Requests (PAR)'), - $this->yesNo($this->moduleConfig->getRequirePushedAuthorizationRequests()), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS, - Translate::noop( - 'When required, authorization requests which do not reference a previously ' . - 'pushed request are rejected.', + fn(): Row => new Row( + Translate::noop('Require Pushed Authorization Requests (PAR)'), + $this->yesNo($this->moduleConfig->getRequirePushedAuthorizationRequests()), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS, + Translate::noop( + 'When required, authorization requests which do not reference a previously ' . + 'pushed request are rejected.', + ), ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('PAR Request URI TTL'), - $this->moduleConfig->getParRequestUriTtl(), ModuleConfig::OPTION_PAR_REQUEST_URI_TTL, + fn(): Row => $this->buildDurationRow( + Translate::noop('PAR Request URI TTL'), + $this->moduleConfig->getParRequestUriTtl(), + ModuleConfig::OPTION_PAR_REQUEST_URI_TTL, + ), ), - new Row( + $this->guardRow( Translate::noop('Require Signed Request Object'), - $this->yesNo($this->moduleConfig->getRequireSignedRequestObject()), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT, - Translate::noop( - 'Requires every Relying Party to sign its Request Objects, and the OP to have ' . - 'their signing keys available.', + fn(): Row => new Row( + Translate::noop('Require Signed Request Object'), + $this->yesNo($this->moduleConfig->getRequireSignedRequestObject()), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT, + Translate::noop( + 'Requires every Relying Party to sign its Request Objects, and the OP to ' . + 'have their signing keys available.', + ), ), ), - new Row( + $this->guardRow( Translate::noop("Support 'request_uri' Parameter"), - $this->yesNo($requestUriParameterSupported), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED, - $requestUriParameterSupported ? - Translate::noop( - 'The OP fetches Request Objects by reference, which means outbound HTTP requests ' . - 'to URIs supplied in authorization requests. Disable it to remove that surface ' . - 'entirely. Pushed Authorization Request URIs (urn form) are not affected.', - ) : - Translate::noop('Request Objects can only be passed by value, and through PAR.'), + fn(): Row => new Row( + Translate::noop("Support 'request_uri' Parameter"), + $this->yesNo($this->moduleConfig->getRequestUriParameterSupported()), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED, + $requestUriParameterSupported ? + Translate::noop( + 'The OP fetches Request Objects by reference, which means outbound HTTP ' . + 'requests to URIs supplied in authorization requests. Disable it to remove ' . + 'that surface entirely. Pushed Authorization Request URIs (urn form) are ' . + 'not affected.', + ) : + Translate::noop('Request Objects can only be passed by value, and through PAR.'), + ), ), - $allowedPrefixesRow, - new Row( - // The unit lives in the label so that it stays translatable, while the value itself - // is rendered as configured. + $this->guardRow( + Translate::noop('Allowed Request URI Prefixes for Federation Candidates'), + ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES, + function () use ($requestUriParameterSupported, $isFederationEnabled): Row { + // null means "allow any", an empty array means "deny all" (also the default), + // and a non-empty array is the actual allowlist. + $allowedPrefixes = $this->moduleConfig->getFederationRequestUriAllowedPrefixes(); + + return new Row( + Translate::noop('Allowed Request URI Prefixes for Federation Candidates'), + match (true) { + is_null($allowedPrefixes) => Translate::noop('Any request URI is allowed'), + $allowedPrefixes === [] => Translate::noop( + 'None, so no such request URI is fetched', + ), + default => $allowedPrefixes, + }, + is_array($allowedPrefixes) && $allowedPrefixes !== [] ? + ConfigOverviewValueTypeEnum::StringList : + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES, + $isFederationEnabled ? Translate::noop( + 'Applies only to OpenID Federation candidates, that is, clients which ' . + 'are not registered in storage. For registered clients the request URI ' . + 'must match one of their own registered request URIs exactly.', + ) : Translate::noop( + 'Not used, since OpenID Federation is disabled and the by-reference ' . + 'fetch for federation candidates therefore never runs.', + ), + ($isFederationEnabled && $requestUriParameterSupported && is_null($allowedPrefixes)) ? + Translate::noop( + 'Any request URI supplied by an unregistered federation candidate will ' . + 'be fetched, which is a server-side request forgery surface. Configure ' . + 'explicit prefixes instead.', + ) : null, + ); + }, + ), + $this->guardRow( Translate::noop("'request_uri' Fetch Timeout (seconds)"), - (string)$this->moduleConfig->getRequestUriFetchTimeout(), - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_REQUEST_URI_FETCH_TIMEOUT, + fn(): Row => new Row( + // The unit lives in the label so that it stays translatable, while the value + // itself is rendered as configured. + Translate::noop("'request_uri' Fetch Timeout (seconds)"), + (string)$this->moduleConfig->getRequestUriFetchTimeout(), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_REQUEST_URI_FETCH_TIMEOUT, + ), ), - new Row( + $this->guardRow( Translate::noop("'request_uri' Maximum Response Size (bytes)"), - $this->formatBytes($this->moduleConfig->getRequestUriMaxSizeBytes()), - ConfigOverviewValueTypeEnum::RawText, ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES, + fn(): Row => new Row( + Translate::noop("'request_uri' Maximum Response Size (bytes)"), + $this->formatBytes($this->moduleConfig->getRequestUriMaxSizeBytes()), + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES, + ), ), ); } @@ -477,102 +592,168 @@ protected function buildRequestObjectSection(): Section */ protected function buildDynamicClientRegistrationSection(): Section { - $isEnabled = $this->moduleConfig->getDcrEnabled(); - $registrationAuth = $this->moduleConfig->getDcrRegistrationAuth(); - $initialAccessTokenCount = count($this->moduleConfig->getDcrInitialAccessTokens()); - $isImpersonationProtectionEnabled = $this->moduleConfig->getDcrImpersonationProtectionEnabled(); - $areDefaultScopesConfigured = $this->moduleConfig->config() - ->hasValue(ModuleConfig::OPTION_DCR_DEFAULT_SCOPES); - $hasUnusableInitialAccessTokenMode = $isEnabled && - $registrationAuth === DcrRegistrationAuthEnum::InitialAccessToken && - $initialAccessTokenCount === 0; + // Each of these decides a warning on a row other than its own, so they are resolved + // defensively here and resolved again inside the guard of the row which displays them - that + // second read is what reports a malformed value in the place an administrator will look. + $isEnabled = false; + $registrationAuth = null; + $initialAccessTokenCount = 0; - // Falls back to every supported scope when unset, which walks the same Verifiable Credential - // scope resolution that can throw on a malformed credential configuration. - $defaultScopes = []; - $defaultScopesError = null; + try { + $isEnabled = $this->moduleConfig->getDcrEnabled(); + } catch (Throwable) { + // Reported on the Enabled row below. + } try { - $defaultScopes = $this->moduleConfig->getDcrDefaultScopes(); - } catch (Throwable $exception) { - $defaultScopesError = $this->describeResolutionError( - $exception, - ModuleConfig::OPTION_DCR_DEFAULT_SCOPES, - ); + $registrationAuth = $this->moduleConfig->getDcrRegistrationAuth(); + } catch (Throwable) { + // Reported on the Registration Access Control row below. + } + + try { + $initialAccessTokenCount = count($this->moduleConfig->getDcrInitialAccessTokens()); + } catch (Throwable) { + // Reported on the Initial Access Tokens row below. } + $hasUnusableInitialAccessTokenMode = $isEnabled && + $registrationAuth === DcrRegistrationAuthEnum::InitialAccessToken && + $initialAccessTokenCount === 0; + return new Section( Translate::noop('Dynamic Client Registration'), 'dynamic-client-registration', - new Row( + $this->guardRow( Translate::noop('Enabled'), - $this->yesNo($isEnabled), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_DCR_ENABLED, - $isEnabled ? null : Translate::noop( - 'The registration and client configuration endpoints are not served, and the ' . - "'registration_endpoint' claim is not advertised in OP metadata.", - ), + function (): Row { + $isEnabled = $this->moduleConfig->getDcrEnabled(); + + return new Row( + Translate::noop('Enabled'), + $this->yesNo($isEnabled), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_DCR_ENABLED, + $isEnabled ? null : Translate::noop( + 'The registration and client configuration endpoints are not served, ' . + "and the 'registration_endpoint' claim is not advertised in OP metadata.", + ), + ); + }, ), - new Row( + $this->guardRow( Translate::noop('Registration Access Control'), - $this->describeRegistrationAuth($registrationAuth), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_DCR_REGISTRATION_AUTH, - null, - ($isEnabled && $registrationAuth === DcrRegistrationAuthEnum::Open) ? Translate::noop( - 'Registration is open, so anyone can register a client without authenticating. ' . - 'Protect the endpoint from abuse using rate limiting at the web server level.', - ) : null, + function () use ($isEnabled): Row { + $registrationAuth = $this->moduleConfig->getDcrRegistrationAuth(); + + return new Row( + Translate::noop('Registration Access Control'), + $this->describeRegistrationAuth($registrationAuth), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_DCR_REGISTRATION_AUTH, + null, + ($isEnabled && $registrationAuth === DcrRegistrationAuthEnum::Open) ? + Translate::noop( + 'Registration is open, so anyone can register a client without ' . + 'authenticating. Protect the endpoint from abuse using rate limiting at ' . + 'the web server level.', + ) : null, + ); + }, ), - $this->buildSecretCountRow( + $this->guardRow( Translate::noop('Initial Access Tokens'), - $initialAccessTokenCount, ModuleConfig::OPTION_DCR_INITIAL_ACCESS_TOKENS, - Translate::noop('The tokens themselves are secrets and are never shown here.'), - $hasUnusableInitialAccessTokenMode ? Translate::noop( - 'Registration requires an Initial Access Token, but none are configured, so ' . - 'every registration attempt will be rejected.', - ) : null, + fn(): Row => $this->buildSecretCountRow( + Translate::noop('Initial Access Tokens'), + count($this->moduleConfig->getDcrInitialAccessTokens()), + ModuleConfig::OPTION_DCR_INITIAL_ACCESS_TOKENS, + Translate::noop('The tokens themselves are secrets and are never shown here.'), + $hasUnusableInitialAccessTokenMode ? Translate::noop( + 'Registration requires an Initial Access Token, but none are configured, so ' . + 'every registration attempt will be rejected.', + ) : null, + ), ), - new Row( + $this->guardRow( Translate::noop('Impersonation Protection'), - $this->yesNo($isImpersonationProtectionEnabled), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED, - Translate::noop( - "When enabled, the host of a client's logo_uri, policy_uri and tos_uri must " . - 'match the host of one of its redirect URIs.', - ), - ($isEnabled && !$isImpersonationProtectionEnabled) ? Translate::noop( - 'Disabled, so a rogue client can reuse the branding and links of a legitimate ' . - 'one during registration.', - ) : null, + function () use ($isEnabled): Row { + $isProtectionEnabled = $this->moduleConfig->getDcrImpersonationProtectionEnabled(); + + return new Row( + Translate::noop('Impersonation Protection'), + $this->yesNo($isProtectionEnabled), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED, + Translate::noop( + "When enabled, the host of a client's logo_uri, policy_uri and tos_uri " . + 'must match the host of one of its redirect URIs.', + ), + ($isEnabled && !$isProtectionEnabled) ? Translate::noop( + 'Disabled, so a rogue client can reuse the branding and links of a ' . + 'legitimate one during registration.', + ) : null, + ); + }, ), - new Row( + $this->guardRow( Translate::noop('Registered Clients Are Enabled'), - $this->yesNo($this->moduleConfig->getDcrRegisteredClientsEnabled()), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_DCR_REGISTERED_CLIENTS_ENABLED, - Translate::noop( - 'When disabled, dynamically registered clients must be reviewed and enabled by ' . - 'an administrator before they can obtain tokens.', + fn(): Row => new Row( + Translate::noop('Registered Clients Are Enabled'), + $this->yesNo($this->moduleConfig->getDcrRegisteredClientsEnabled()), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_DCR_REGISTERED_CLIENTS_ENABLED, + Translate::noop( + 'When disabled, dynamically registered clients must be reviewed and enabled ' . + 'by an administrator before they can obtain tokens.', + ), ), ), - new Row( - Translate::noop('Default Scopes for Scope-less Registrations'), - $defaultScopes, - ConfigOverviewValueTypeEnum::StringList, + $this->buildDcrDefaultScopesRow(), + ); + } + + + /** + * The default scopes row keeps the try/catch shape rather than guardRow(), because it still shows + * the option's own note alongside the failure instead of replacing the whole row with 'N/A'. + */ + protected function buildDcrDefaultScopesRow(): Row + { + // Falls back to every supported scope when unset, which walks the same Verifiable Credential + // scope resolution that can throw on a malformed credential configuration. + $defaultScopes = []; + $defaultScopesError = null; + $areDefaultScopesConfigured = false; + + try { + $areDefaultScopesConfigured = $this->moduleConfig->config() + ->hasValue(ModuleConfig::OPTION_DCR_DEFAULT_SCOPES); + $defaultScopes = $this->moduleConfig->getDcrDefaultScopes(); + } catch (Throwable $exception) { + $defaultScopesError = $this->describeResolutionError( + $exception, ModuleConfig::OPTION_DCR_DEFAULT_SCOPES, - // Suppressed on failure: the fallback set could not be resolved, so claiming it - // contains every supported scope would contradict the warning and the empty value. - ($areDefaultScopesConfigured || !is_null($defaultScopesError)) ? null : Translate::noop( - 'Not configured, so this falls back to every scope this OP supports, meaning a ' . - "client which registers without a 'scope' may request any of them, including " . - "'offline_access'.", - ), - $defaultScopesError, + ); + } + + return new Row( + Translate::noop('Default Scopes for Scope-less Registrations'), + $defaultScopes, + ConfigOverviewValueTypeEnum::StringList, + ModuleConfig::OPTION_DCR_DEFAULT_SCOPES, + // Suppressed on failure: the fallback set could not be resolved, so claiming it + // contains every supported scope would contradict the warning and the empty value. + ($areDefaultScopesConfigured || !is_null($defaultScopesError)) ? null : Translate::noop( + 'Not configured, so this falls back to every scope this OP supports, meaning a ' . + "client which registers without a 'scope' may request any of them, including " . + "'offline_access'.", ), + $defaultScopesError, ); } @@ -582,9 +763,16 @@ protected function buildDynamicClientRegistrationSection(): Section */ protected function buildCacheSection(): Section { - $adapterClass = $this->moduleConfig->getProtocolCacheAdapterClass(); - $adapterArgumentCount = count($this->moduleConfig->getProtocolCacheAdapterArguments()); - $isCachingActive = !is_null($adapterClass); + // Only asserted to be a string when it is read, so a value of another type throws here. This + // screen is where an administrator goes to find that out, so it must not take it down. + $isCachingActive = false; + + try { + $isCachingActive = !is_null($this->moduleConfig->getProtocolCacheAdapterClass()); + } catch (Throwable) { + // Reported on its own row below, where the option which failed to resolve is named. + } + $isUserEntityCacheDurationConfigured = $this->moduleConfig->config() ->hasValue(ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION); @@ -606,36 +794,60 @@ protected function buildCacheSection(): Section return new Section( Translate::noop('Cache'), 'cache', - new Row( + $this->guardRow( Translate::noop('Cache Adapter'), - $adapterClass ?? Translate::noop('N/A'), - // A configured adapter class is data, the 'N/A' placeholder is UI text. - $isCachingActive ? ConfigOverviewValueTypeEnum::RawText : ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER, - $isCachingActive ? null : Translate::noop( - 'Not set, so no protocol caching is performed. Setting a cache adapter is ' . - 'recommended in production.', - ), + function () use ($isCachingActive): Row { + $adapterClass = $this->moduleConfig->getProtocolCacheAdapterClass(); + + return new Row( + Translate::noop('Cache Adapter'), + $adapterClass ?? Translate::noop('N/A'), + // A configured adapter class is data, the 'N/A' placeholder is UI text. + $isCachingActive ? + ConfigOverviewValueTypeEnum::RawText : + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER, + $isCachingActive ? null : Translate::noop( + 'Not set, so no protocol caching is performed. Setting a cache adapter ' . + 'is recommended in production.', + ), + ); + }, ), - $this->buildSecretCountRow( + $this->guardRow( Translate::noop('Cache Adapter Arguments'), - $adapterArgumentCount, ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS, - Translate::noop( - 'Values are not shown, since adapter arguments can carry connection credentials.', + fn(): Row => $this->buildSecretCountRow( + Translate::noop('Cache Adapter Arguments'), + count($this->moduleConfig->getProtocolCacheAdapterArguments()), + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS, + Translate::noop( + 'Values are not shown, since adapter arguments can carry connection credentials.', + ), ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('User Entity Cache Duration'), - $this->moduleConfig->getProtocolUserEntityCacheDuration(), ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION, - $userEntityCacheDurationNote, + fn(): Row => $this->buildDurationRow( + Translate::noop('User Entity Cache Duration'), + $this->moduleConfig->getProtocolUserEntityCacheDuration(), + ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION, + $userEntityCacheDurationNote, + ), ), - $this->buildDurationRow( + $this->guardRow( Translate::noop('Client Entity Cache Duration'), - $this->moduleConfig->getProtocolClientEntityCacheDuration(), ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION, - $isCachingActive ? null : Translate::noop('Not used, since no cache adapter is configured.'), + fn(): Row => $this->buildDurationRow( + Translate::noop('Client Entity Cache Duration'), + $this->moduleConfig->getProtocolClientEntityCacheDuration(), + ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION, + $isCachingActive ? + null : + Translate::noop('Not used, since no cache adapter is configured.'), + ), ), ); } @@ -649,30 +861,38 @@ protected function buildOutboundHttpSection(): Section return new Section( Translate::noop('Outbound HTTP requests'), 'outbound-http', - $this->buildHttpClientOptionsRow( + $this->guardRow( Translate::noop('Protocol HTTP Client Options'), - $this->moduleConfig->getProtocolHttpClientOptions(), ModuleConfig::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS, - Translate::noop( - "Applied to protocol-layer fetches, such as a client's 'jwks_uri' or a 'request_uri'.", - ), - Translate::noop( - "Applied to protocol-layer fetches, such as a client's 'jwks_uri' or a " . - "'request_uri'. Not set, so the library defaults apply, including TLS verification.", + fn(): Row => $this->buildHttpClientOptionsRow( + Translate::noop('Protocol HTTP Client Options'), + $this->moduleConfig->getProtocolHttpClientOptions(), + ModuleConfig::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS, + Translate::noop( + "Applied to protocol-layer fetches, such as a client's 'jwks_uri' or a 'request_uri'.", + ), + Translate::noop( + "Applied to protocol-layer fetches, such as a client's 'jwks_uri' or a " . + "'request_uri'. Not set, so the library defaults apply, including TLS verification.", + ), ), ), - $this->buildHttpClientOptionsRow( + $this->guardRow( Translate::noop('Back-Channel Logout HTTP Client Options'), - $this->moduleConfig->getBackChannelLogoutHttpClientOptions(), ModuleConfig::OPTION_BACKCHANNEL_LOGOUT_HTTP_CLIENT_OPTIONS, - Translate::noop( - "Applied to Back-Channel Logout requests sent to a client's " . - "'backchannel_logout_uri'. Merged over a 3 second connect and total timeout.", - ), - Translate::noop( - "Applied to Back-Channel Logout requests sent to a client's " . - "'backchannel_logout_uri'. Merged over a 3 second connect and total timeout. " . - 'Not set, so TLS verification stays enabled.', + fn(): Row => $this->buildHttpClientOptionsRow( + Translate::noop('Back-Channel Logout HTTP Client Options'), + $this->moduleConfig->getBackChannelLogoutHttpClientOptions(), + ModuleConfig::OPTION_BACKCHANNEL_LOGOUT_HTTP_CLIENT_OPTIONS, + Translate::noop( + "Applied to Back-Channel Logout requests sent to a client's " . + "'backchannel_logout_uri'. Merged over a 3 second connect and total timeout.", + ), + Translate::noop( + "Applied to Back-Channel Logout requests sent to a client's " . + "'backchannel_logout_uri'. Merged over a 3 second connect and total timeout. " . + 'Not set, so TLS verification stays enabled.', + ), ), ), ...$this->buildDestinationPolicyRows(), @@ -809,23 +1029,52 @@ function (): Row { */ protected function buildApiSection(): Section { - $isApiEnabled = $this->moduleConfig->getApiEnabled(); - $isIntrospectionEnabled = $this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled(); - $apiTokenCount = count($this->moduleConfig->getApiTokens() ?? []); + // All three decide something on a row other than their own - whether the endpoint row below is + // shown at all, in the first two cases - so they are resolved defensively and resolved again + // inside the guard of the row which displays them. + $isApiEnabled = false; + $isIntrospectionEnabled = false; + $apiTokenCount = 0; + + try { + $isApiEnabled = $this->moduleConfig->getApiEnabled(); + } catch (Throwable) { + // Reported on the API Enabled row below. + } + + try { + $isIntrospectionEnabled = $this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled(); + } catch (Throwable) { + // Reported on the Token Introspection Endpoint Enabled row below. + } + + try { + $apiTokenCount = count($this->moduleConfig->getApiTokens() ?? []); + } catch (Throwable) { + // Reported on the API Tokens row below. + } $rows = [ - new Row( + $this->guardRow( Translate::noop('API Enabled'), - $this->yesNo($isApiEnabled), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_API_ENABLED, - Translate::noop('Master switch for the module specific (non-protocol) API endpoints.'), + fn(): Row => new Row( + Translate::noop('API Enabled'), + $this->yesNo($this->moduleConfig->getApiEnabled()), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_API_ENABLED, + Translate::noop('Master switch for the module specific (non-protocol) API endpoints.'), + ), ), - new Row( + $this->guardRow( Translate::noop('OAuth2 Token Introspection Endpoint Enabled'), - $this->yesNo($isIntrospectionEnabled), - ConfigOverviewValueTypeEnum::Text, ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED, + fn(): Row => new Row( + Translate::noop('OAuth2 Token Introspection Endpoint Enabled'), + $this->yesNo($this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled()), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED, + ), ), $this->guardRow( Translate::noop('Token Introspection Resource Servers'), @@ -853,16 +1102,20 @@ function (): Row { ); }, ), - $this->buildSecretCountRow( + $this->guardRow( Translate::noop('API Tokens'), - $apiTokenCount, ModuleConfig::OPTION_API_TOKENS, - Translate::noop('The tokens themselves are secrets and are never shown here.'), - ($isApiEnabled && $apiTokenCount === 0) ? Translate::noop( - 'The API is enabled, but no API tokens are configured, so it can only be used ' . - 'by a logged in SimpleSAMLphp administrator. Token authenticated callers will ' . - 'be rejected.', - ) : null, + fn(): Row => $this->buildSecretCountRow( + Translate::noop('API Tokens'), + count($this->moduleConfig->getApiTokens() ?? []), + ModuleConfig::OPTION_API_TOKENS, + Translate::noop('The tokens themselves are secrets and are never shown here.'), + ($isApiEnabled && $apiTokenCount === 0) ? Translate::noop( + 'The API is enabled, but no API tokens are configured, so it can only be ' . + 'used by a logged in SimpleSAMLphp administrator. Token authenticated ' . + 'callers will be rejected.', + ) : null, + ), ), ]; diff --git a/tests/unit/src/Admin/ConfigOverview/FederationOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/FederationOverviewBuilderTest.php index aa1e6c77..9c14bb49 100644 --- a/tests/unit/src/Admin/ConfigOverview/FederationOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/FederationOverviewBuilderTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; use SimpleSAML\Module\oidc\Admin\ConfigOverview\AbstractOverviewBuilder; @@ -574,4 +575,137 @@ public function testShowsOptionalEntityMetadata(): void // The placeholder is UI text, so it stays translatable. $this->assertSame(ConfigOverviewValueTypeEnum::Text, $informationUriRow->getValueType()); } + + + /** + * A malformed option must be reported on its own row rather than take the screen down. + * + * Every one of these values throws out of the getter which reads it - SimpleSAMLphp asserts the + * type at read time, not at load time - and this screen is the one an administrator opens to find + * out which option is wrong. Building the row outside guardRow() therefore turned a typo into a + * 500 on the only page that could have explained it. + */ + #[DataProvider('malformedOptionProvider')] + public function testReportsAMalformedOptionInPlace(string $option, mixed $value): void + { + $sections = $this->buildFederationOverviewBuilder([$option => $value])->build(); + + $row = $this->findRowForOption($sections, $option); + + $this->assertNotNull($row, sprintf('No row displays %s.', $option)); + $this->assertNotNull($row->getWarning(), sprintf('%s is not reported on its row.', $option)); + } + + + /** + * A malformed federation switch is one problem, not two. + * + * getFederationTrustAnchors() reads OPTION_FEDERATION_ENABLED to decide whether an empty list is + * an error, so it throws for a malformed switch as well. Attributing that to the Trust Anchors + * option would send an administrator looking for a second fault which does not exist. + */ + public function testDoesNotBlameTrustAnchorsForAMalformedFederationSwitch(): void + { + $sections = $this->buildFederationOverviewBuilder([ + ModuleConfig::OPTION_FEDERATION_ENABLED => 'yes', + ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS => [], + ])->build(); + + $switchRow = $this->findRowForOption($sections, ModuleConfig::OPTION_FEDERATION_ENABLED); + $this->assertNotNull($switchRow); + $this->assertNotNull($switchRow->getWarning(), 'The malformed switch is not reported.'); + + $trustAnchorsRow = $this->findRowForOption($sections, ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS); + $this->assertNotNull($trustAnchorsRow); + $this->assertNull( + $trustAnchorsRow->getWarning(), + 'The malformed switch is reported a second time as a Trust Anchors failure.', + ); + } + + + /** + * The provider above names the options whose warning text is worth asserting; this covers every + * option the screen displays, including ones added after it was written. + */ + public function testNoDisplayedOptionCanTakeTheScreenDown(): void + { + $this->assertNoDisplayedOptionCanThrow( + fn(array $overrides): FederationOverviewBuilder => $this->buildFederationOverviewBuilder( + $overrides, + ), + ); + } + + + /** + * @return array + */ + public static function malformedOptionProvider(): array + { + return [ + 'federation enabled is not a boolean' => [ModuleConfig::OPTION_FEDERATION_ENABLED, 'yes'], + 'keywords are not an array' => [ModuleConfig::OPTION_KEYWORDS, 'not-an-array'], + 'contacts are not an array' => [ModuleConfig::OPTION_CONTACTS, 'not-an-array'], + 'organization name is not a string' => [ModuleConfig::OPTION_ORGANIZATION_NAME, 123], + 'logo uri is not a string' => [ModuleConfig::OPTION_LOGO_URI, 123], + 'entity statement duration is not a duration' => [ + ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION, + 'not-a-duration', + ], + 'authority hints are not an array' => [ + ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS, + 'not-an-array', + ], + 'trust mark tokens are not an array' => [ + ModuleConfig::OPTION_FEDERATION_TRUST_MARK_TOKENS, + 'not-an-array', + ], + 'dynamic trust marks are not an array' => [ + ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS, + 'not-an-array', + ], + 'participation limits are not an array' => [ + ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS, + 'not-an-array', + ], + 'max trust chain depth is not an integer' => [ + ModuleConfig::OPTION_FEDERATION_MAX_TRUST_CHAIN_DEPTH, + 'deep', + ], + 'max authority hints is not an integer' => [ + ModuleConfig::OPTION_FEDERATION_MAX_AUTHORITY_HINTS, + 'many', + ], + 'max trust chain fetches is not an integer' => [ + ModuleConfig::OPTION_FEDERATION_MAX_TRUST_CHAIN_FETCHES, + 'lots', + ], + 'resolve timeout is not an integer' => [ + ModuleConfig::OPTION_FEDERATION_TRUST_CHAIN_RESOLVE_TIMEOUT, + 'soon', + ], + 'max fetch size is not an integer' => [ + ModuleConfig::OPTION_FEDERATION_MAX_FETCH_SIZE_BYTES, + 'big', + ], + 'cache adapter is not a string' => [ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER, 123], + 'cache adapter arguments are not an array' => [ + ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS, + 'not-an-array', + ], + 'fetched cache duration is not a duration' => [ + ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED, + 'not-a-duration', + ], + 'produced cache duration is not a duration' => [ + ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED, + 'not-a-duration', + ], + 'http client options are not an array' => [ + ModuleConfig::OPTION_FEDERATION_HTTP_CLIENT_OPTIONS, + 'not-an-array', + ], + ]; + } } diff --git a/tests/unit/src/Admin/ConfigOverview/GeneralOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/GeneralOverviewBuilderTest.php index 991c9934..4991f2d0 100644 --- a/tests/unit/src/Admin/ConfigOverview/GeneralOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/GeneralOverviewBuilderTest.php @@ -493,4 +493,15 @@ public function testSurvivesMalformedPermissions(): void $this->assertSame('N/A', $row->getValue()); $this->assertStringContainsString('could not be resolved', (string)$row->getWarning()); } + + + /** + * Covers every option this screen displays, including ones added after this was written. + */ + public function testNoDisplayedOptionCanTakeTheScreenDown(): void + { + $this->assertNoDisplayedOptionCanThrow( + fn(array $overrides): GeneralOverviewBuilder => $this->buildGeneralOverviewBuilder($overrides), + ); + } } diff --git a/tests/unit/src/Admin/ConfigOverview/OverviewTestTrait.php b/tests/unit/src/Admin/ConfigOverview/OverviewTestTrait.php index 23a8ffd0..9db87233 100644 --- a/tests/unit/src/Admin/ConfigOverview/OverviewTestTrait.php +++ b/tests/unit/src/Admin/ConfigOverview/OverviewTestTrait.php @@ -13,6 +13,8 @@ use SimpleSAML\OpenID\ValueAbstracts; use SimpleSAML\Utils\Config; use SimpleSAML\Utils\HTTP; +use stdClass; +use Throwable; /** * Wiring shared by the configuration overview tests: a real ModuleConfig backed by @@ -113,6 +115,67 @@ protected function findRowByLabel(array $sections, string $label): ?Row } + /** + * No option a screen displays may take that screen down when its value has the wrong type. + * + * SimpleSAMLphp asserts an option's type when the option is READ, not when the configuration is + * loaded, and nothing catches that on the way to the template. A row built outside guardRow() + * therefore turns a mistyped option into an HTTP 500 on the one screen able to explain it. + * + * Every option the screen displays is discovered from the rows themselves rather than listed + * here, so a row added later is covered without anyone remembering to extend a data provider. + * The value used is an object, which no getter on ModuleConfig accepts. + * + * Options rejected by ModuleConfig::validate() are skipped rather than asserted on: that runs + * from the constructor, so such a value never reaches a builder at all and is reported by + * SimpleSAMLphp itself. + * + * @param callable(array): object $buildBuilder Builds the screen's builder from config overrides. + */ + protected function assertNoDisplayedOptionCanThrow(callable $buildBuilder): void + { + /** @var object $builder */ + $builder = $buildBuilder([]); + /** @var \SimpleSAML\Module\oidc\Admin\ConfigOverview\Section[] $sections */ + $sections = $builder->build(); + + $options = []; + + foreach ($this->flattenRows($sections) as $row) { + $configOption = $row->getConfigOption(); + + if (!is_null($configOption)) { + $options[$configOption] = true; + } + } + + $this->assertNotEmpty($options, 'No option is displayed, so this test proves nothing.'); + + foreach (array_keys($options) as $option) { + try { + /** @var object $builderWithBadOption */ + $builderWithBadOption = $buildBuilder([$option => new stdClass()]); + } catch (Throwable) { + // Refused while the configuration was constructed, which is a report of its own. + continue; + } + + try { + $builderWithBadOption->build(); + } catch (Throwable $exception) { + $this->fail( + sprintf( + 'A malformed "%s" escaped as %s ("%s") instead of being reported on its row.', + $option, + $exception::class, + $exception->getMessage(), + ), + ); + } + } + } + + /** * All displayable (scalar or array) row content, as one searchable string. Used to assert that * secrets never reach the screen. diff --git a/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php index 273ae820..a784c2ba 100644 --- a/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Admin\ConfigOverview\ProtocolOverviewBuilder; use SimpleSAML\Module\oidc\Admin\ConfigOverview\Row; @@ -711,4 +712,107 @@ public function testShowsRegistrationEndpointOnlyWhenDcrIsEnabled(): void $labels($this->buildProtocolOverviewBuilder([ModuleConfig::OPTION_DCR_ENABLED => true])->build()), ); } + + + /** + * A malformed option must be reported on its own row rather than take the screen down. + * + * Every one of these values throws out of the getter which reads it - SimpleSAMLphp asserts the + * type at read time, not at load time - and this screen is the one an administrator opens to find + * out which option is wrong. Building the row outside guardRow() therefore turned a typo into a + * 500 on the only page that could have explained it. + * + * The ACR options are deliberately absent below: ModuleConfig::validate() rejects those while the + * configuration is constructed, so a malformed value never reaches a builder at all and is + * reported by SimpleSAMLphp itself rather than on a row. + */ + #[DataProvider('malformedOptionProvider')] + public function testReportsAMalformedOptionInPlace(string $option, mixed $value): void + { + $sections = $this->buildProtocolOverviewBuilder([$option => $value])->build(); + + $row = $this->findRowForOption($sections, $option); + + $this->assertNotNull($row, sprintf('No row displays %s.', $option)); + $this->assertNotNull($row->getWarning(), sprintf('%s is not reported on its row.', $option)); + } + + + /** + * The provider above names the options whose warning text is worth asserting; this covers every + * option the screen displays, including ones added after it was written. + */ + public function testNoDisplayedOptionCanTakeTheScreenDown(): void + { + $this->assertNoDisplayedOptionCanThrow( + fn(array $overrides): ProtocolOverviewBuilder => $this->buildProtocolOverviewBuilder($overrides), + ); + } + + + /** + * @return array + */ + public static function malformedOptionProvider(): array + { + return [ + 'access token ttl is not a duration' => [ + ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL, + 'not-a-duration', + ], + 'authorization code ttl is not a duration' => [ + ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL, + 'not-a-duration', + ], + 'refresh token ttl is not a duration' => [ + ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL, + 'not-a-duration', + ], + 'timestamp leeway is not a duration' => [ + ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY, + 'not-a-duration', + ], + 'auth source is not a string' => [ModuleConfig::OPTION_AUTH_SOURCE, 123], + 'user identifier attributes are not an array' => [ + ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE, + 123, + ], + 'auth proc filters are not an array' => [ + ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS, + 'not-an-array', + ], + 'par request uri ttl is not a duration' => [ + ModuleConfig::OPTION_PAR_REQUEST_URI_TTL, + 'not-a-duration', + ], + 'request uri fetch timeout is not an integer' => [ + ModuleConfig::OPTION_REQUEST_URI_FETCH_TIMEOUT, + 'soon', + ], + 'request uri max size is not an integer' => [ + ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES, + 'big', + ], + 'dcr enabled is not a boolean' => [ModuleConfig::OPTION_DCR_ENABLED, 'yes'], + 'initial access tokens are not an array' => [ + ModuleConfig::OPTION_DCR_INITIAL_ACCESS_TOKENS, + 'not-an-array', + ], + 'cache adapter is not a string' => [ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER, 123], + 'cache adapter arguments are not an array' => [ + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS, + 'not-an-array', + ], + 'http client options are not an array' => [ + ModuleConfig::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS, + 'not-an-array', + ], + 'back-channel logout client options are not an array' => [ + ModuleConfig::OPTION_BACKCHANNEL_LOGOUT_HTTP_CLIENT_OPTIONS, + 'not-an-array', + ], + 'api enabled is not a boolean' => [ModuleConfig::OPTION_API_ENABLED, 'yes'], + 'api tokens are not an array' => [ModuleConfig::OPTION_API_TOKENS, 'not-an-array'], + ]; + } } diff --git a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php index ef1a36f6..74f17b24 100644 --- a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php @@ -1232,4 +1232,15 @@ public function testDoesNotRenderVciCacheAdapterArguments(): void $this->assertStringNotContainsString('super-secret-dsn', $this->renderableContent($sections)); } + + + /** + * Covers every option this screen displays, including ones added after this was written. + */ + public function testNoDisplayedOptionCanTakeTheScreenDown(): void + { + $this->assertNoDisplayedOptionCanThrow( + fn(array $overrides): VciOverviewBuilder => $this->buildVciOverviewBuilder($overrides), + ); + } } From 903c307f61869e361d48a9c57a7663f949b22877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 31 Aug 2026 16:09:36 +0200 Subject: [PATCH 05/15] Build the config screens' collaborators behind the row guards --- locales/en/LC_MESSAGES/oidc.po | 10 ++ locales/es/LC_MESSAGES/oidc.po | 10 ++ locales/fr/LC_MESSAGES/oidc.po | 10 ++ locales/hr/LC_MESSAGES/oidc.po | 10 ++ locales/it/LC_MESSAGES/oidc.po | 10 ++ locales/nl/LC_MESSAGES/oidc.po | 10 ++ .../ProtocolOverviewBuilder.php | 13 ++- src/Controllers/Admin/ConfigController.php | 39 +++++++- src/Factories/FederationFactory.php | 19 ++-- .../ProtocolOverviewBuilderTest.php | 66 ++++++++++++- .../ProtocolOverviewTestTrait.php | 19 +++- .../Admin/ConfigControllerTest.php | 64 +++++++++++++ .../src/Factories/FederationFactoryTest.php | 94 +++++++++++++++++-- 13 files changed, 353 insertions(+), 21 deletions(-) diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index bbb019cd..44185028 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -227,6 +227,11 @@ msgstr "" msgid "Discovery URL" msgstr "" +msgid "" +"Dynamically fetched Trust Marks could not be read, so they are not shown. " +"Check that option below." +msgstr "" + msgid "" "Each of these is a destination that whoever supplies a DID can send this " "deployment to." @@ -519,6 +524,11 @@ msgstr "" msgid "SimpleSAMLphp admin access required." msgstr "" +msgid "" +"Statically configured Trust Mark tokens could not be read, so they are not " +"shown. Check that option below." +msgstr "" + msgid "Status" msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index 99fdc6fb..c0b79650 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -227,6 +227,11 @@ msgstr "" msgid "Discovery URL" msgstr "" +msgid "" +"Dynamically fetched Trust Marks could not be read, so they are not shown. " +"Check that option below." +msgstr "" + msgid "" "Each of these is a destination that whoever supplies a DID can send this " "deployment to." @@ -519,6 +524,11 @@ msgstr "" msgid "SimpleSAMLphp admin access required." msgstr "" +msgid "" +"Statically configured Trust Mark tokens could not be read, so they are not " +"shown. Check that option below." +msgstr "" + msgid "Status" msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index 082f56bd..e16e0efd 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -227,6 +227,11 @@ msgstr "" msgid "Discovery URL" msgstr "" +msgid "" +"Dynamically fetched Trust Marks could not be read, so they are not shown. " +"Check that option below." +msgstr "" + msgid "" "Each of these is a destination that whoever supplies a DID can send this " "deployment to." @@ -519,6 +524,11 @@ msgstr "" msgid "SimpleSAMLphp admin access required." msgstr "" +msgid "" +"Statically configured Trust Mark tokens could not be read, so they are not " +"shown. Check that option below." +msgstr "" + msgid "Status" msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index 876ea6b9..81c7ace3 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -246,6 +246,11 @@ msgstr "Onemogućeno" msgid "Discovery URL" msgstr "URL za otkrivanje" +msgid "" +"Dynamically fetched Trust Marks could not be read, so they are not shown. " +"Check that option below." +msgstr "" + msgid "" "Each of these is a destination that whoever supplies a DID can send this " "deployment to." @@ -552,6 +557,11 @@ msgstr "Algoritam potpisivanja" msgid "SimpleSAMLphp admin access required." msgstr "Potreban SimpleSAMLphp administratorski pristup." +msgid "" +"Statically configured Trust Mark tokens could not be read, so they are not " +"shown. Check that option below." +msgstr "" + msgid "Status" msgstr "Status" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index cdb8a8fb..06f96e8c 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -227,6 +227,11 @@ msgstr "" msgid "Discovery URL" msgstr "" +msgid "" +"Dynamically fetched Trust Marks could not be read, so they are not shown. " +"Check that option below." +msgstr "" + msgid "" "Each of these is a destination that whoever supplies a DID can send this " "deployment to." @@ -519,6 +524,11 @@ msgstr "" msgid "SimpleSAMLphp admin access required." msgstr "" +msgid "" +"Statically configured Trust Mark tokens could not be read, so they are not " +"shown. Check that option below." +msgstr "" + msgid "Status" msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index be4480d2..a15e3a5e 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -208,6 +208,11 @@ msgstr "Gehandicapt" msgid "Discovery URL" msgstr "Ontdekkings-URL" +msgid "" +"Dynamically fetched Trust Marks could not be read, so they are not shown. " +"Check that option below." +msgstr "" + msgid "" "Each of these is a destination that whoever supplies a DID can send this " "deployment to." @@ -485,6 +490,11 @@ msgstr "Ondertekeningsalgoritme" msgid "SimpleSAMLphp admin access required." msgstr "SimpleSAMLphp-beheerdersrechten vereist." +msgid "" +"Statically configured Trust Mark tokens could not be read, so they are not " +"shown. Check that option below." +msgstr "" + msgid "Status" msgstr "Status" diff --git a/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php b/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php index 923561db..ce78bda1 100644 --- a/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php +++ b/src/Admin/ConfigOverview/ProtocolOverviewBuilder.php @@ -7,9 +7,9 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Codebooks\ConfigOverviewValueTypeEnum; use SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum; +use SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\DateIntervalFormatter; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\AddressPinningModeEnum; @@ -41,12 +41,19 @@ class ProtocolOverviewBuilder extends AbstractOverviewBuilder protected const string SCOPE_KEY_MULTIPLE_CLAIM_VALUES_ALLOWED = 'are_multiple_claim_values_allowed'; + /** + * Note the factory rather than the extractor itself. Building one reads the translation table and the + * user identifier attributes, and throws when either has the wrong type, so taking a built extractor + * here would make that throw happen while the container wires this screen up - the screen whose whole + * purpose is to report such an option. Deferred to the row that displays it, where guardRow() turns + * the failure into a warning in the right place. + */ public function __construct( ModuleConfig $moduleConfig, Routes $routes, DateIntervalFormatter $dateIntervalFormatter, LoggerService $logger, - protected readonly ClaimTranslatorExtractor $claimTranslatorExtractor, + protected readonly ClaimTranslatorExtractorFactory $claimTranslatorExtractorFactory, ) { parent::__construct($moduleConfig, $routes, $dateIntervalFormatter, $logger); } @@ -413,7 +420,7 @@ protected function buildScopesAndClaimsSection(): Section ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, fn(): Row => new Row( Translate::noop('SAML Attribute to OIDC Claim Translation'), - $this->claimTranslatorExtractor->getTranslationTable(), + $this->claimTranslatorExtractorFactory->build()->getTranslationTable(), ConfigOverviewValueTypeEnum::Json, ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, Translate::noop( diff --git a/src/Controllers/Admin/ConfigController.php b/src/Controllers/Admin/ConfigController.php index e916fb21..d4d92a1a 100644 --- a/src/Controllers/Admin/ConfigController.php +++ b/src/Controllers/Admin/ConfigController.php @@ -133,14 +133,49 @@ public function federationSettings(): Response ); } - if (is_array($trustMarkTokens = $this->moduleConfig->getFederationTrustMarkTokens())) { + // Both options are read here rather than by a builder, so a wrong type for either throws on the + // way to a screen whose rows would otherwise have reported it. The rows below still do; this only + // has to stop the read itself from taking the page down before they run. + // + // Guarded separately, and deliberately: one catch around both would let a failure of the first + // skip the second, hiding trust marks that are perfectly readable, and would leave a message + // saying nothing is shown next to marks the first read had already produced. + // + // Neither message renders the exception, which comes from config validation and can quote + // configured values back. The option at fault gets a warning on its own row below. + $trustMarkTokens = null; + $dynamicTrustMarks = null; + + try { + $trustMarkTokens = $this->moduleConfig->getFederationTrustMarkTokens(); + } catch (Throwable) { + $this->sessionMessagesService->addMessage( + Translate::noop( + 'Statically configured Trust Mark tokens could not be read, so they are not ' . + 'shown. Check that option below.', + ), + ); + } + + try { + $dynamicTrustMarks = $this->moduleConfig->getFederationDynamicTrustMarks(); + } catch (Throwable) { + $this->sessionMessagesService->addMessage( + Translate::noop( + 'Dynamically fetched Trust Marks could not be read, so they are not shown. Check ' . + 'that option below.', + ), + ); + } + + if (is_array($trustMarkTokens)) { $trustMarks = array_map( fn(string $token): TrustMark => $federation->trustMarkFactory()->fromToken($token), $trustMarkTokens, ); } - if (is_array($dynamicTrustMarks = $this->moduleConfig->getFederationDynamicTrustMarks())) { + if (is_array($dynamicTrustMarks)) { /** * @var non-empty-string $trustMarkType * @var non-empty-string $trustMarkIssuerId diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index 6499a26b..fdcf0207 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -6,22 +6,26 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\OpenID\Federation; class FederationFactory { /** - * Note the factory rather than the policy itself. A policy is built from configuration that can be - * malformed, and building it throws when it is. Taking one here would make that throw happen while the - * container wires up anything that reaches this factory - including the admin Configuration screens, - * which exist to report exactly such an option. Deferring it to build() keeps them reachable. + * Note the factories rather than the policy and the cache themselves. Both are built from + * configuration that can be malformed, and building either throws when it is. Taking a built one here + * would make that throw happen while the container wires up anything that reaches this factory - + * including the admin Configuration screens, which exist to report exactly such an option. Deferring + * both to build() keeps them reachable. + * + * The policy was moved behind its factory first; the cache stayed behind and reintroduced the same + * fault on its own, so a malformed federation cache adapter still took the federation screen down + * before any row could report it. Do not inject a built collaborator here. */ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly LoggerService $loggerService, protected readonly DestinationPolicyFactory $destinationPolicyFactory, - protected readonly ?FederationCache $federationCache = null, + protected readonly CacheFactory $cacheFactory, ) { } @@ -29,6 +33,7 @@ public function __construct( /** * @throws \ReflectionException * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException On a cache adapter which cannot be built. */ public function build(): Federation { @@ -37,7 +42,7 @@ public function build(): Federation maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), maxTrustChainDepth: $this->moduleConfig->getFederationMaxTrustChainDepth(), - cache: $this->federationCache?->cache, + cache: $this->cacheFactory->forFederation()?->cache, logger: $this->loggerService, defaultTrustMarkStatusEndpointUsagePolicyEnum: $this->moduleConfig->getFederationTrustMarkStatusEndpointUsagePolicy(), diff --git a/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php index a784c2ba..18ebed5f 100644 --- a/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewBuilderTest.php @@ -13,9 +13,10 @@ use SimpleSAML\Module\oidc\Admin\ConfigOverview\Section; use SimpleSAML\Module\oidc\Codebooks\ConfigOverviewValueTypeEnum; use SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum; +use SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory; +use SimpleSAML\Module\oidc\Factories\Entities\ClaimSetEntityFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\DateIntervalFormatter; use SimpleSAML\Module\oidc\Utils\Routes; @@ -263,7 +264,7 @@ public function testDoesNotExposeConfigurationErrorDetail(): void $this->createMock(Routes::class), new DateIntervalFormatter(), $loggerMock, - $this->createMock(ClaimTranslatorExtractor::class), + $this->buildClaimTranslatorExtractorFactory(), ); $row = $this->findRowForOption( @@ -738,6 +739,67 @@ public function testReportsAMalformedOptionInPlace(string $option, mixed $value) } + /** + * The claim translator must be built by this screen, not handed to it already built. + * + * ClaimTranslatorExtractorFactory::build() reads the translation table and the user identifier + * attributes, so a wrong type for either throws. While the builder took a built extractor, that + * throw happened as Symfony wired the controller up, and the screen 500'd before any row ran. The + * other tests here inject a mock factory and so could never have caught it; this one uses the real + * factory over the real configuration, which is the only shape that reproduces it. + * + * @throws \Exception + */ + #[DataProvider('malformedClaimTranslatorOptionProvider')] + public function testBuildsTheClaimTranslatorBehindTheGuard(string $option, mixed $value): void + { + // A valid configuration first, so a row which warns unconditionally cannot pass this. + $healthyRow = $this->findRowForOption( + $this->buildProtocolOverviewBuilder()->build(), + ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, + ); + $this->assertNotNull($healthyRow); + $this->assertNull($healthyRow->getWarning()); + + $builder = new ProtocolOverviewBuilder( + $this->buildOverviewModuleConfig([$option => $value]), + $this->createMock(Routes::class), + new DateIntervalFormatter(), + $this->createMock(LoggerService::class), + new ClaimTranslatorExtractorFactory( + $this->buildOverviewModuleConfig([$option => $value]), + new ClaimSetEntityFactory(), + ), + ); + + $row = $this->findRowForOption( + $builder->build(), + ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, + ); + + $this->assertNotNull($row); + $this->assertNotNull($row->getWarning()); + } + + + /** + * @return array + */ + public static function malformedClaimTranslatorOptionProvider(): array + { + return [ + 'translation table is not an array' => [ + ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, + 'not-an-array', + ], + 'user identifier attributes are not an array' => [ + ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE, + 123, + ], + ]; + } + + /** * The provider above names the options whose warning text is worth asserting; this covers every * option the screen displays, including ones added after it was written. diff --git a/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewTestTrait.php b/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewTestTrait.php index adab37b1..924fcde4 100644 --- a/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewTestTrait.php +++ b/tests/unit/src/Admin/ConfigOverview/ProtocolOverviewTestTrait.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; use SimpleSAML\Module\oidc\Admin\ConfigOverview\ProtocolOverviewBuilder; +use SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\DateIntervalFormatter; @@ -29,7 +30,23 @@ protected function buildProtocolOverviewBuilder( $this->createMock(Routes::class), new DateIntervalFormatter(), $this->createMock(LoggerService::class), - $this->createMock(ClaimTranslatorExtractor::class), + $this->buildClaimTranslatorExtractorFactory(), ); } + + + /** + * A factory whose build() succeeds, so the translation table row shows a value. + * + * The builder takes the factory rather than a built extractor, because building one reads options + * which can be malformed and doing that while the container wires the screen up is what took the + * screen down. Tests which want that failure make build() throw instead. + */ + protected function buildClaimTranslatorExtractorFactory(): ClaimTranslatorExtractorFactory + { + $factory = $this->createMock(ClaimTranslatorExtractorFactory::class); + $factory->method('build')->willReturn($this->createMock(ClaimTranslatorExtractor::class)); + + return $factory; + } } diff --git a/tests/unit/src/Controllers/Admin/ConfigControllerTest.php b/tests/unit/src/Controllers/Admin/ConfigControllerTest.php index 11c86d63..418cb255 100644 --- a/tests/unit/src/Controllers/Admin/ConfigControllerTest.php +++ b/tests/unit/src/Controllers/Admin/ConfigControllerTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Admin\ConfigOverview\FederationOverviewBuilder; use SimpleSAML\Module\oidc\Admin\ConfigOverview\GeneralOverviewBuilder; @@ -229,4 +230,67 @@ public function testCanIncludeDynamicTrustMarksInFederationSettings(): void $this->sut()->federationSettings(); } + + + /** + * These two options are read here rather than by a builder, so their guarded rows cannot protect + * them: a wrong top-level type threw on the way to the screen and returned a 500 from the one page + * able to explain it. + */ + public function testSurvivesMalformedTrustMarkOptions(): void + { + $this->moduleConfigMock->method('getFederationTrustMarkTokens') + ->willThrowException(new ConfigurationError('Not an array.')); + + $this->sessionMessagesServiceMock->expects($this->atLeastOnce())->method('addMessage'); + + $this->templateFactoryMock->expects($this->once())->method('build') + ->with('oidc:config/federation.twig'); + + $this->sut()->federationSettings(); + } + + + /** + * The second read is behind the first, so a failure there must be caught just the same. + */ + public function testSurvivesMalformedDynamicTrustMarkOptions(): void + { + $this->moduleConfigMock->method('getFederationTrustMarkTokens')->willReturn(null); + $this->moduleConfigMock->method('getFederationDynamicTrustMarks') + ->willThrowException(new ConfigurationError('Not an array.')); + + $this->sessionMessagesServiceMock->expects($this->atLeastOnce())->method('addMessage'); + + $this->templateFactoryMock->expects($this->once())->method('build') + ->with('oidc:config/federation.twig'); + + $this->sut()->federationSettings(); + } + + + /** + * One malformed Trust Mark option must not hide the other, readable one. + * + * A single catch around both reads would have skipped the static tokens when the dynamic option + * threw, and would have reported that nothing is shown while rendering marks the first read had + * already produced. Each source is guarded on its own for that reason. + */ + public function testAMalformedTrustMarkOptionDoesNotHideTheOtherSource(): void + { + $this->moduleConfigMock->method('getFederationTrustMarkTokens')->willReturn(['token']); + $this->moduleConfigMock->method('getFederationDynamicTrustMarks') + ->willThrowException(new ConfigurationError('Not an array.')); + + // The readable source is still resolved, rather than skipped along with the broken one. + $this->trustMarkFactoryMock->expects($this->once())->method('fromToken') + ->with($this->stringContains('token')); + + $this->sessionMessagesServiceMock->expects($this->atLeastOnce())->method('addMessage'); + + $this->templateFactoryMock->expects($this->once())->method('build') + ->with('oidc:config/federation.twig'); + + $this->sut()->federationSettings(); + } } diff --git a/tests/unit/src/Factories/FederationFactoryTest.php b/tests/unit/src/Factories/FederationFactoryTest.php index 59b05787..fa50016c 100644 --- a/tests/unit/src/Factories/FederationFactoryTest.php +++ b/tests/unit/src/Factories/FederationFactoryTest.php @@ -9,14 +9,19 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Configuration; +use SimpleSAML\Module\oidc\Exceptions\OidcException; +use SimpleSAML\Module\oidc\Factories\CacheFactory; use SimpleSAML\Module\oidc\Factories\DestinationPolicyFactory; use SimpleSAML\Module\oidc\Factories\FederationFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Network\DestinationPolicy; use SimpleSAML\OpenID\SupportedAlgorithms; +use Throwable; #[CoversClass(FederationFactory::class)] #[AllowMockObjectsWithoutExpectations] @@ -56,28 +61,105 @@ protected function sut(): FederationFactory $this->moduleConfigMock, $this->loggerServiceMock, $destinationPolicyFactory, + $this->createMock(CacheFactory::class), ); } /** - * The destination policy must not be built until a Federation is. + * Neither collaborator may be built until a Federation is. * - * Building one throws when the outbound configuration is malformed, and the container reaches this - * factory while wiring up the admin Configuration screens - the screens whose whole purpose is to - * report such an option. Taking the policy as a constructor dependency made a bad outbound option - * take those screens down instead of showing up on them, which is how this regressed once already. + * Building either throws when the configuration behind it is malformed, and the container reaches + * this factory while wiring up the admin Configuration screens - the screens whose whole purpose is + * to report such an option. Taking a built one as a constructor dependency made a bad option take + * those screens down instead of showing up on them. That happened twice: first with the destination + * policy, then again with the cache, which was left injected when the policy was moved behind its + * factory. */ - public function testDoesNotBuildTheDestinationPolicyUntilItBuilds(): void + public function testDoesNotBuildItsCollaboratorsUntilItBuilds(): void { $destinationPolicyFactory = $this->createMock(DestinationPolicyFactory::class); $destinationPolicyFactory->expects($this->never())->method('build'); + $cacheFactory = $this->createMock(CacheFactory::class); + $cacheFactory->expects($this->never())->method('forFederation'); + new FederationFactory( $this->moduleConfigMock, $this->loggerServiceMock, $destinationPolicyFactory, + $cacheFactory, + ); + } + + + /** + * Constructing this factory must not read the cache configuration at all. + * + * Uses a real CacheFactory over a real ModuleConfig carrying a malformed adapter option, which is + * the shape the container produces and the only one that reproduces the fault: mocked collaborators + * never read configuration, so every other test here would pass with the cache injected as before. + * + * @throws \Exception + */ + public function testAMalformedCacheAdapterDoesNotBreakConstruction(): void + { + $moduleConfig = new ModuleConfig( + ModuleConfig::DEFAULT_FILE_NAME, + [ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER => 123], + $this->createMock(Configuration::class), ); + + $cacheFactory = new CacheFactory( + $moduleConfig, + $this->loggerServiceMock, + new ClassInstanceBuilder(), + ); + + // The container reaches this constructor while wiring the admin Configuration screens up, so a + // throw here would take down the screen that exists to report exactly this option. + $sut = new FederationFactory( + $moduleConfig, + $this->loggerServiceMock, + $this->createMock(DestinationPolicyFactory::class), + $cacheFactory, + ); + + // And the deferred read still fails, where the caller catches it rather than the container. + $this->expectException(Throwable::class); + + $sut->build(); + } + + + /** + * A cache adapter which cannot be built must surface from build(), where the one caller that has to + * survive it - the federation configuration screen - already catches it. + */ + public function testACacheWhichCannotBeBuiltSurfacesFromBuild(): void + { + $this->moduleConfigMock->method('getFederationMaxTrustChainDepth')->willReturn(9); + $this->moduleConfigMock->method('getFederationMaxAuthorityHints')->willReturn(6); + $this->moduleConfigMock->method('getFederationMaxTrustChainFetches')->willReturn(100); + $this->moduleConfigMock->method('getFederationTrustChainResolveTimeout')->willReturn(30); + + $destinationPolicyFactory = $this->createMock(DestinationPolicyFactory::class); + $destinationPolicyFactory->method('build')->willReturn(new DestinationPolicy()); + + $cacheFactory = $this->createMock(CacheFactory::class); + $cacheFactory->method('forFederation') + ->willThrowException(new OidcException('Unusable cache adapter.')); + + $sut = new FederationFactory( + $this->moduleConfigMock, + $this->loggerServiceMock, + $destinationPolicyFactory, + $cacheFactory, + ); + + $this->expectException(OidcException::class); + + $sut->build(); } From 23cc1b0f56f2daa7c09aca69ff3a5c06202b82fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 2 Sep 2026 10:02:50 +0200 Subject: [PATCH 06/15] Accept did:web holders and apply the DIIP profile rules --- config/module_oidc.php.dist | 25 + docs/3-oidc-configuration.md | 96 ++++ locales/en/LC_MESSAGES/oidc.po | 26 +- locales/es/LC_MESSAGES/oidc.po | 26 +- locales/fr/LC_MESSAGES/oidc.po | 26 +- locales/hr/LC_MESSAGES/oidc.po | 27 +- locales/it/LC_MESSAGES/oidc.po | 26 +- locales/nl/LC_MESSAGES/oidc.po | 26 +- .../ConfigOverview/VciOverviewBuilder.php | 56 +- .../VciCredentialBindingPolicyEnum.php | 52 ++ ...redentialIssuerConfigurationController.php | 24 +- .../CredentialIssuerCredentialController.php | 23 +- .../OpenId4VciProofValidator.php | 306 +++++++++-- .../Values/DidResolutionBudget.php | 128 +++++ .../Values/ValidatedOpenId4VciProof.php | 42 ++ ...ntialIssuerConfigurationControllerTest.php | 34 +- ...edentialIssuerCredentialControllerTest.php | 48 +- .../OpenId4VciProofValidatorTest.php | 502 +++++++++++++++++- .../Values/DidResolutionBudgetTest.php | 147 +++++ .../Values/ValidatedOpenId4VciProofTest.php | 73 +++ 20 files changed, 1568 insertions(+), 145 deletions(-) create mode 100644 src/VerifiableCredentials/Values/DidResolutionBudget.php create mode 100644 tests/unit/src/VerifiableCredentials/Values/DidResolutionBudgetTest.php create mode 100644 tests/unit/src/VerifiableCredentials/Values/ValidatedOpenId4VciProofTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 54433caa..883087fd 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1649,6 +1649,29 @@ $config = [ * resolves to. The configuration advertises both * `cryptographic_binding_methods_supported` and `proof_types_supported`. * + * VciCredentialBindingPolicyEnum::DiipProofBound is the above plus the + * identifier rules the DIIP profile writes on top of OpenID4VCI: the key + * proof must name its key in a `kid` header which is an absolute did:jwk + * or did:web URL, resolved from the `authentication` relationship of that + * DID document. Two things follow, both about that header: + * + * - a key proof carrying its key inline in a `jwk` header is refused, + * since it names no verification method to point at; + * - a did:key holder is refused, though every other configuration keeps + * accepting one. + * + * The `iss` claim is left to OpenID4VCI, which has it name the client the + * access token was issued to, and omitted when no client is identified. + * DIIP's own text asks for the holder's DID there instead, which cannot + * hold at the same time as that rule for an anonymous pre-authorized code; + * FIDEScommunity/DIIP#83 proposes dropping it in favour of the `kid` rule + * above, and that is what this implements. A wallet identified by a DID + * still works - it is accepted, not required, and holder binding does not + * rest on it. + * + * The configuration advertises did:jwk and did:web as its binding methods + * rather than everything this deployment can resolve. + * * VciCredentialBindingPolicyEnum::Proofless issues credentials which are * not bound to any wallet key, to a subject identifier derived from the * authenticated user. The configuration then advertises neither of those @@ -1673,6 +1696,8 @@ $config = [ // ModuleConfig::OPTION_VCI_CREDENTIAL_BINDING_POLICIES => [ // 'UniversityDegreeCredential' => // \SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum::ProofBound, +// 'DiipCredential' => +// \SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum::DiipProofBound, // 'EmployeeBadgeCredential' => // \SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum::Proofless, // ], diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index a67d7bcc..d46e3128 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -229,6 +229,102 @@ runs out. These settings are shown in the admin area under `OIDC` > `Configuration`, on the VCI screen. +## Holder binding and the DIIP profile + +A credential configuration decides for itself whether the credentials it issues +are bound to a key the wallet proves it holds, and under which rules. The choice +is one option, because OpenID4VCI ties the metadata and the issuance together: +`proof_types_supported` must be present wherever +`cryptographic_binding_methods_supported` is, and a Credential Request must +carry `proofs` wherever `proof_types_supported` is. + +```php +use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; + +ModuleConfig::OPTION_VCI_CREDENTIAL_BINDING_POLICIES => [ + 'UniversityDegreeCredential' => VciCredentialBindingPolicyEnum::ProofBound, + 'DiipCredential' => VciCredentialBindingPolicyEnum::DiipProofBound, + 'EmployeeBadgeCredential' => VciCredentialBindingPolicyEnum::Proofless, +], +``` + +`ProofBound` is the default and applies the OpenID4VCI rules: a key proof is +required, its signature is verified, and the credential is issued to the holder +identifier the proof resolves to. The proof may name its key in a `kid` header, +as a DID URL of any method this deployment can resolve — `did:jwk`, `did:key` +or `did:web` — or carry the key itself in a `jwk` header. + +### What `DiipProofBound` adds + +`DiipProofBound` applies the DIIP profile's identifier rules on top of those. The +key proof must name its key in a `kid` header which is an **absolute `did:jwk` or +`did:web` URL**, and that verification method must appear in the +`authentication` relationship of the document the DID resolves to. + +Two consequences are worth knowing before switching a configuration to it, both +about that header, since it is where this profile's holder binding lives: + +- **A key proof carrying its key inline is refused.** The requirement is written + in DID URLs, and an inline key names no verification method to point at. +- **A `did:key` holder is refused**, since the profile names the other two. + +The `iss` claim is left to OpenID4VCI: the client the access token was issued to +when there is one, and absent when there is not. A wallet identified by a DID +works, but nothing requires one, and holder binding does not rest on it. + +None of this affects any other configuration. DIIP's requirements are additive, +so a deployment can offer conformant configurations alongside ones which accept +inline keys or `did:key` holders. + +The credential states which key it is held by in a `cnf` claim, in every format: +`cnf.kid` naming the verification method the proof named, or `cnf.jwk` carrying +the key itself when the proof sent one inline. `credentialSubject.id` is not +equivalent to it — a verifier checking holder binding reads `cnf`. + +### Three interpretations this module makes + +**The `iss` claim is not required to be a DID.** DIIP v5 says implementations +*"MUST support the `jwt` proof type with a `did:jwk` or `did:web` as the `iss` +value"*. Read as a rule to reject anything else, that cannot hold at the same +time as OpenID4VCI, which requires `iss` to be **absent** when the access token +was obtained through an anonymous pre-authorized code — so a DIIP configuration +could never be issued through that flow at all. + +[FIDEScommunity/DIIP#83](https://github.com/FIDEScommunity/DIIP/issues/83) +proposes resolving this by dropping the requirement on `iss` and requiring an +absolute DID URL in `kid` instead. **That issue was still open and unanswered at +the time of writing, and DIIP v5 was approved without it**, so this is an +implementation choice rather than a settled profile rule. Two things make it the +right one: the requirement is worded as *"MUST support"*, a capability rather +than a rejection rule, and this module does support a DID `iss` — it accepts one, +it just does not demand it. And nothing about holder binding rests on that claim. +What proves the holder is possession of a key their DID document lists under +`authentication`, which is checked either way. + +**The `assertionMethod` sentence.** DIIP §5.1.1 puts the proof's `kid` under the +`assertionMethod` relationship of the *Issuer's* DID document, while the next +requirement puts holder binding under `authentication` of the *Holder's*. In +OpenID4VCI the proof is produced by the wallet, so the two cannot both be read +literally at once. This module reads it as: the proof JWT resolves against the +**Holder's** DID under `authentication`, and the credential and Status List +signatures are made with the **Issuer's** key under `assertionMethod`. If your +conformance target reads it the other way, this is the place it differs. + +**Header-JWK proofs are a documented extension, not a DIIP feature.** They stay +supported for every other configuration because they work and nothing in +OpenID4VCI forbids them, but a credential issued against one is not DIIP +conformant, which is why `DiipProofBound` refuses them. + +### What one request may spend + +A Credential Request may carry up to `batch_credential_issuance.batch_size` +proofs (8), name at most **4 distinct DIDs which have to be fetched**, and has +**15 seconds** in total for all of its fetches. `did:jwk` and `did:key` resolve +without leaving the process, so they do not count against the fetch limit — +under `did:jwk` every key is its own DID, and a batch of eight proofs is eight +distinct DIDs. These are fixed limits on what a request may cost this issuer, +not settings. + ## Pushed Authorization Requests (PAR) and Request Objects A client can send authorization request parameters in several ways: diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index 44185028..c056296f 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -176,6 +176,13 @@ msgstr "" msgid "Confidential" msgstr "" +msgid "" +"Configurations listed as 'proofless' issue credentials which are not bound " +"to any wallet key, so nothing ties an issued credential to whoever presents " +"it later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused." +msgstr "" + msgid "Configuration URL" msgstr "" @@ -432,6 +439,16 @@ msgid "" "One or more values from the list. If not selected, falls back to 'automatic'" msgstr "" +msgid "" +"Only the configurations which differ from the default are listed, with the " +"policy each one uses. Under 'diip_proof_bound' the DIIP profile rules apply " +"on top of the OpenID4VCI ones: a key proof must name its key in a 'kid' " +"header which is an absolute did:jwk or did:web URL, resolved from the " +"authentication relationship of that document. A key carried inline and a " +"did:key holder are refused, though every other configuration keeps " +"accepting both." +msgstr "" + msgid "OpenID Federation Related Properties" msgstr "" @@ -1680,7 +1697,6 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" - msgid "Status List Requests Per Minute" msgstr "" @@ -2337,11 +2353,3 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" - -msgid "" -"These credential configurations issue credentials which are not bound to " -"any wallet key, so nothing ties an issued credential to whoever presents it " -"later. They advertise no binding methods and no proof types, and a key " -"proof sent to them is refused. Configurations which are not listed require " -"a key proof." -msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index c0b79650..11b48bef 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -176,6 +176,13 @@ msgstr "" msgid "Confidential" msgstr "" +msgid "" +"Configurations listed as 'proofless' issue credentials which are not bound " +"to any wallet key, so nothing ties an issued credential to whoever presents " +"it later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused." +msgstr "" + msgid "Configuration URL" msgstr "" @@ -432,6 +439,16 @@ msgid "" "One or more values from the list. If not selected, falls back to 'automatic'" msgstr "" +msgid "" +"Only the configurations which differ from the default are listed, with the " +"policy each one uses. Under 'diip_proof_bound' the DIIP profile rules apply " +"on top of the OpenID4VCI ones: a key proof must name its key in a 'kid' " +"header which is an absolute did:jwk or did:web URL, resolved from the " +"authentication relationship of that document. A key carried inline and a " +"did:key holder are refused, though every other configuration keeps " +"accepting both." +msgstr "" + msgid "OpenID Federation Related Properties" msgstr "" @@ -1680,7 +1697,6 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" - msgid "Status List Requests Per Minute" msgstr "" @@ -2337,11 +2353,3 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" - -msgid "" -"These credential configurations issue credentials which are not bound to " -"any wallet key, so nothing ties an issued credential to whoever presents it " -"later. They advertise no binding methods and no proof types, and a key " -"proof sent to them is refused. Configurations which are not listed require " -"a key proof." -msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index e16e0efd..25eb4432 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -176,6 +176,13 @@ msgstr "" msgid "Confidential" msgstr "" +msgid "" +"Configurations listed as 'proofless' issue credentials which are not bound " +"to any wallet key, so nothing ties an issued credential to whoever presents " +"it later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused." +msgstr "" + msgid "Configuration URL" msgstr "" @@ -432,6 +439,16 @@ msgid "" "One or more values from the list. If not selected, falls back to 'automatic'" msgstr "" +msgid "" +"Only the configurations which differ from the default are listed, with the " +"policy each one uses. Under 'diip_proof_bound' the DIIP profile rules apply " +"on top of the OpenID4VCI ones: a key proof must name its key in a 'kid' " +"header which is an absolute did:jwk or did:web URL, resolved from the " +"authentication relationship of that document. A key carried inline and a " +"did:key holder are refused, though every other configuration keeps " +"accepting both." +msgstr "" + msgid "OpenID Federation Related Properties" msgstr "" @@ -1680,7 +1697,6 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" - msgid "Status List Requests Per Minute" msgstr "" @@ -2337,11 +2353,3 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" - -msgid "" -"These credential configurations issue credentials which are not bound to " -"any wallet key, so nothing ties an issued credential to whoever presents it " -"later. They advertise no binding methods and no proof types, and a key " -"proof sent to them is refused. Configurations which are not listed require " -"a key proof." -msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index 81c7ace3..2e489c0c 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -97,7 +97,6 @@ msgstr "" "Autentikacijski izvor za ovog klijenta. Ako nije odabran autentikacijski " "izvor, koristit će se zadani iz konfiguracijske datoteke." - #: /var/www/projects/simplesamlphp/simplesamlphp-2.3/modules/oidc/src/Forms/ClientForm.php:369 msgid "Authentication source" msgstr "Autentikacijski izvor" @@ -195,6 +194,13 @@ msgstr "Klijent s danim identifikatorom entiteta već postoji." msgid "Confidential" msgstr "Povjerljiv" +msgid "" +"Configurations listed as 'proofless' issue credentials which are not bound " +"to any wallet key, so nothing ties an issued credential to whoever presents " +"it later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused." +msgstr "" + msgid "Configuration URL" msgstr "Konfiguracijski URL" @@ -465,6 +471,16 @@ msgid "" "One or more values from the list. If not selected, falls back to 'automatic'" msgstr "Jedna ili više vrijednosti s popisa. Ako nije odabrano, postavlja se na 'automatski'" +msgid "" +"Only the configurations which differ from the default are listed, with the " +"policy each one uses. Under 'diip_proof_bound' the DIIP profile rules apply " +"on top of the OpenID4VCI ones: a key proof must name its key in a 'kid' " +"header which is an absolute did:jwk or did:web URL, resolved from the " +"authentication relationship of that document. A key carried inline and a " +"did:key holder are refused, though every other configuration keeps " +"accepting both." +msgstr "" + msgid "OpenID Federation Related Properties" msgstr "Svojstva povezane s OpenID federacijom" @@ -1728,7 +1744,6 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" - msgid "Status List Requests Per Minute" msgstr "" @@ -2385,11 +2400,3 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" - -msgid "" -"These credential configurations issue credentials which are not bound to " -"any wallet key, so nothing ties an issued credential to whoever presents it " -"later. They advertise no binding methods and no proof types, and a key " -"proof sent to them is refused. Configurations which are not listed require " -"a key proof." -msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index 06f96e8c..4daaff10 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -176,6 +176,13 @@ msgstr "" msgid "Confidential" msgstr "" +msgid "" +"Configurations listed as 'proofless' issue credentials which are not bound " +"to any wallet key, so nothing ties an issued credential to whoever presents " +"it later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused." +msgstr "" + msgid "Configuration URL" msgstr "" @@ -432,6 +439,16 @@ msgid "" "One or more values from the list. If not selected, falls back to 'automatic'" msgstr "" +msgid "" +"Only the configurations which differ from the default are listed, with the " +"policy each one uses. Under 'diip_proof_bound' the DIIP profile rules apply " +"on top of the OpenID4VCI ones: a key proof must name its key in a 'kid' " +"header which is an absolute did:jwk or did:web URL, resolved from the " +"authentication relationship of that document. A key carried inline and a " +"did:key holder are refused, though every other configuration keeps " +"accepting both." +msgstr "" + msgid "OpenID Federation Related Properties" msgstr "" @@ -1680,7 +1697,6 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" - msgid "Status List Requests Per Minute" msgstr "" @@ -2337,11 +2353,3 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" - -msgid "" -"These credential configurations issue credentials which are not bound to " -"any wallet key, so nothing ties an issued credential to whoever presents it " -"later. They advertise no binding methods and no proof types, and a key " -"proof sent to them is refused. Configurations which are not listed require " -"a key proof." -msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index a15e3a5e..78208e0a 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -157,6 +157,13 @@ msgstr "Client met opgegeven entiteits-ID bestaat al." msgid "Confidential" msgstr "Vertrouwelijk" +msgid "" +"Configurations listed as 'proofless' issue credentials which are not bound " +"to any wallet key, so nothing ties an issued credential to whoever presents " +"it later. They advertise no binding methods and no proof types, and a key " +"proof sent to them is refused." +msgstr "" + msgid "Configuration URL" msgstr "Configuratie-URL" @@ -394,6 +401,16 @@ msgstr "OIDC-installatie" msgid "One or more values from the list. If not selected, falls back to 'automatic'" msgstr "Een of meer waarden uit de lijst. Indien niet geselecteerd, terugvallen op 'automatisch'" +msgid "" +"Only the configurations which differ from the default are listed, with the " +"policy each one uses. Under 'diip_proof_bound' the DIIP profile rules apply " +"on top of the OpenID4VCI ones: a key proof must name its key in a 'kid' " +"header which is an absolute did:jwk or did:web URL, resolved from the " +"authentication relationship of that document. A key carried inline and a " +"did:key holder are refused, though every other configuration keeps " +"accepting both." +msgstr "" + msgid "OpenID Federation Related Properties" msgstr "OpenID Federation-gerelateerde eigenschappen" @@ -1634,7 +1651,6 @@ msgstr "" msgid "These pools are inert, since Status Lists are disabled." msgstr "" - msgid "Status List Requests Per Minute" msgstr "" @@ -2291,11 +2307,3 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" - -msgid "" -"These credential configurations issue credentials which are not bound to " -"any wallet key, so nothing ties an issued credential to whoever presents it " -"later. They advertise no binding methods and no proof types, and a key " -"proof sent to them is refused. Configurations which are not listed require " -"a key proof." -msgstr "" diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index cc19bbb8..ee011423 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -566,17 +566,18 @@ protected function buildCredentialConfigurationsSection(): Section Translate::noop('Credential Binding Policies'), ModuleConfig::OPTION_VCI_CREDENTIAL_BINDING_POLICIES, function (): Row { - // Only the exceptions are listed. Requiring a key proof is the default, so naming - // every configuration which does would bury the ones which do not, and it is those - // an administrator needs to recognise on sight. - $proofless = array_keys( - array_filter( - $this->moduleConfig->getVciCredentialBindingPolicies(), - $this->isProofless(...), - ), + // Only the exceptions are listed, each with the policy it runs under. Requiring a + // key proof under the OpenID4VCI rules is the default, so naming every + // configuration which does would bury the ones which do something else, and it is + // those an administrator needs to recognise on sight. One row rather than one per + // policy, because a screen shows a given option exactly once. + $exceptions = array_filter( + $this->moduleConfig->getVciCredentialBindingPolicies(), + static fn(VciCredentialBindingPolicyEnum $bindingPolicy): bool => + $bindingPolicy !== VciCredentialBindingPolicyEnum::ProofBound, ); - if ($proofless === []) { + if ($exceptions === []) { return new Row( Translate::noop('Credential Binding Policies'), Translate::noop('Every configuration requires a key proof'), @@ -591,19 +592,33 @@ function (): Row { ); } + $listed = []; + + foreach ($exceptions as $credentialConfigurationId => $bindingPolicy) { + $listed[] = sprintf('%s: %s', $credentialConfigurationId, $bindingPolicy->value); + } + return new Row( Translate::noop('Credential Binding Policies'), - $proofless, + $listed, ConfigOverviewValueTypeEnum::StringList, ModuleConfig::OPTION_VCI_CREDENTIAL_BINDING_POLICIES, - null, Translate::noop( - 'These credential configurations issue credentials which are not bound ' . - 'to any wallet key, so nothing ties an issued credential to whoever ' . - 'presents it later. They advertise no binding methods and no proof ' . - 'types, and a key proof sent to them is refused. Configurations which ' . - 'are not listed require a key proof.', + 'Only the configurations which differ from the default are listed, with ' . + 'the policy each one uses. Under \'diip_proof_bound\' the DIIP profile ' . + 'rules apply on top of the OpenID4VCI ones: a key proof must name its ' . + 'key in a \'kid\' header which is an absolute did:jwk or did:web URL, ' . + 'resolved from the authentication relationship of that document. A key ' . + 'carried inline and a did:key holder are refused, though every other ' . + 'configuration keeps accepting both.', ), + in_array(VciCredentialBindingPolicyEnum::Proofless, $exceptions, true) ? + Translate::noop( + 'Configurations listed as \'proofless\' issue credentials which are not ' . + 'bound to any wallet key, so nothing ties an issued credential to ' . + 'whoever presents it later. They advertise no binding methods and no ' . + 'proof types, and a key proof sent to them is refused.', + ) : null, ); }, ), @@ -1232,15 +1247,6 @@ protected function normalizeRedirectUriPrefix(mixed $prefix): ?string } - /** - * Whether a credential configuration issues credentials which are not bound to a holder key. - */ - protected function isProofless(VciCredentialBindingPolicyEnum $bindingPolicy): bool - { - return $bindingPolicy === VciCredentialBindingPolicyEnum::Proofless; - } - - /** * Whether any credential configuration declares a format which cannot be issued. */ diff --git a/src/Codebooks/VciCredentialBindingPolicyEnum.php b/src/Codebooks/VciCredentialBindingPolicyEnum.php index 6d7704ef..58537a44 100644 --- a/src/Codebooks/VciCredentialBindingPolicyEnum.php +++ b/src/Codebooks/VciCredentialBindingPolicyEnum.php @@ -24,6 +24,33 @@ enum VciCredentialBindingPolicyEnum: string */ case ProofBound = 'proof_bound'; + /** + * Everything ProofBound requires, plus the identifier rules the DIIP profile writes on top of + * OpenID4VCI. The proof's `kid` header must be an absolute DID URL of a `did:jwk` or a `did:web`, + * and the verification method it names must appear in that document's `authentication` + * relationship. + * + * Two consequences, both about the `kid` header, since that is where this profile's holder binding + * lives: + * + * - A key proof carrying its key inline in a `jwk` header is refused. The requirement is written in + * DID URLs, and an inline key names no verification method to point at. + * - A `did:key` holder is refused, since the profile names the other two. Every other configuration + * keeps accepting one. + * + * The `iss` claim is left to OpenID4VCI: the client the access token was issued to when there is + * one, absent when there is not. The profile's own text asks for the holder's DID there, which + * cannot hold at the same time as OpenID4VCI's rule for an anonymous pre-authorized code; + * FIDEScommunity/DIIP#83 proposes dropping it in favour of the `kid` rule above, and that is what + * this implements. A wallet identified by a DID still works - it is accepted, not required, and no + * part of holder binding rests on it. + * + * Every other credential configuration is unaffected: DIIP's requirements are additive, so a + * deployment can offer conformant configurations alongside ones which accept inline keys or + * `did:key` holders. + */ + case DiipProofBound = 'diip_proof_bound'; + /** * Credentials are issued unbound, to a subject identifier this issuer derives from the * authenticated user. Neither binding metadata field is advertised, and a Key Proof sent anyway is @@ -31,4 +58,29 @@ enum VciCredentialBindingPolicyEnum: string * accepting it would mean honouring a proof this configuration never asked for. */ case Proofless = 'proofless'; + + + /** + * Whether this policy requires a Key Proof at all, which is what decides both halves of the binding + * metadata and whether a Credential Request has to carry `proofs`. + * + * Asked as a question rather than compared against a case, so that a policy added later has to + * answer it here instead of falling into whichever branch an `if` happened to leave open. + */ + public function requiresKeyProof(): bool + { + return match ($this) { + self::ProofBound, self::DiipProofBound => true, + self::Proofless => false, + }; + } + + + /** + * Whether the DIIP profile's identifier rules apply on top of the OpenID4VCI ones. + */ + public function requiresDiipIdentifiers(): bool + { + return $this === self::DiipProofBound; + } } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index 320a97ba..ea2fc567 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -66,13 +66,27 @@ public function configuration(): Response $bindingPolicy = $this->moduleConfig->getVciCredentialBindingPolicyFor($credentialConfigurationId); - if ($bindingPolicy === VciCredentialBindingPolicyEnum::ProofBound) { + // A match rather than a comparison, so that a binding policy added later has to state + // what it advertises here instead of falling into whichever branch was written as the + // alternative - which for this pair would have silently unadvertised binding for it. + $bindingMethods = match ($bindingPolicy) { + // `jwk` is not a DID method: OpenID4VCI defines it as the value for a credential + // bound to a key in JWK format, which is what a key proof carrying its key inline + // in a `jwk` header produces. This configuration accepts those and states the key + // in `cnf.jwk`, so leaving the value out would hide a supported path from every + // wallet which reads this metadata to decide what to send. + VciCredentialBindingPolicyEnum::ProofBound => ['did:key', 'did:jwk', 'did:web', 'jwk'], + // The profile names these two, and its rules confine a holder to them: the proof's + // key has to sit under the DID its `iss` claim states. + VciCredentialBindingPolicyEnum::DiipProofBound => ['did:jwk', 'did:web'], + VciCredentialBindingPolicyEnum::Proofless => null, + }; + + if ($bindingMethods !== null) { $isAnyConfigurationProofBound = true; - $credentialConfiguration[ClaimsEnum::CryptographicBindingMethodsSupported->value] = [ - 'did:key', - 'did:jwk', - ]; + $credentialConfiguration[ClaimsEnum::CryptographicBindingMethodsSupported->value] = + $bindingMethods; $credentialConfiguration[ClaimsEnum::ProofTypesSupported->value] = [ OpenId4VciProofValidator::PROOF_TYPE_JWT => [ ClaimsEnum::ProofSigningAlgValuesSupported->value => $this->moduleConfig diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 1f79ee34..54089078 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -666,6 +666,17 @@ public function credential(Request $request): Response $commonClaims[ClaimsEnum::Exp->value] = $expiresAt->getTimestamp(); } + // Stated once for every format, rather than in each branch which builds one. `cnf` is where + // a verifier reads what a credential is held by, so a format branch which omits it hands + // out credentials that look unbound however carefully the proof behind them was checked - + // which is what happened to this format and to inline-key proofs, each for the whole time + // the claim was assembled separately in the branches that happened to have it. + $confirmation = $validatedProof?->getConfirmation(); + + if ($confirmation !== null) { + $commonClaims[ClaimsEnum::Cnf->value] = $confirmation; + } + $verifiableCredential = null; if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { @@ -729,12 +740,6 @@ public function credential(Request $request): Response $commonClaims, ); - if ($validatedProof !== null && is_string($proofKeyId = $validatedProof->getKeyId())) { - $sdJwtPayload[ClaimsEnum::Cnf->value] = [ - ClaimsEnum::Kid->value => $proofKeyId, - ]; - } - $verifiableCredential = $this->verifiableCredentials->sdJwtVcFactory()->fromData( $signingKey, $signatureAlgorithm, @@ -782,12 +787,6 @@ public function credential(Request $request): Response $sdJwtPayload[ClaimsEnum::ValidUntil->value] = $expiresAt->format(DateTimeInterface::RFC3339); } - if ($validatedProof !== null && is_string($proofKeyId = $validatedProof->getKeyId())) { - $sdJwtPayload[ClaimsEnum::Cnf->value] = [ - ClaimsEnum::Kid->value => $proofKeyId, - ]; - } - $verifiableCredential = $this->verifiableCredentials->vcSdJwtFactory()->fromData( $signingKey, $signatureAlgorithm, diff --git a/src/VerifiableCredentials/OpenId4VciProofValidator.php b/src/VerifiableCredentials/OpenId4VciProofValidator.php index cdfccf25..98227e1b 100644 --- a/src/VerifiableCredentials/OpenId4VciProofValidator.php +++ b/src/VerifiableCredentials/OpenId4VciProofValidator.php @@ -12,9 +12,14 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\NonceService; +use SimpleSAML\Module\oidc\VerifiableCredentials\Values\DidResolutionBudget; use SimpleSAML\Module\oidc\VerifiableCredentials\Values\ValidatedOpenId4VciProof; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\VerificationRelationshipEnum; use SimpleSAML\OpenID\Did; +use SimpleSAML\OpenID\Did\DidDocument; +use SimpleSAML\OpenID\Did\DidUrl; +use SimpleSAML\OpenID\Did\ResolvedVerificationMethod; use SimpleSAML\OpenID\VerifiableCredentials; use SimpleSAML\OpenID\VerifiableCredentials\OpenId4VciProof; use Throwable; @@ -31,7 +36,13 @@ * Validation is request-wide rather than proof by proof. A `proofs` array is validated to its last entry * before the caller issues anything, because issuing along the way would let a request whose final proof * is bad still spend the Status List entries its earlier proofs allocated, on credentials no wallet ever - * receives. + * receives. It is also request-wide in what it will spend: the DIDs a request names are resolved under + * one shared budget rather than each proof getting the whole of one to itself. + * + * Two rulesets, not one. The OpenID4VCI rules apply to every key proof this issuer accepts. The DIIP + * profile's identifier rules apply on top of them, for credential configurations which are set to + * `DiipProofBound` - per configuration, because DIIP's requirements are additive and a deployment may + * offer conformant configurations alongside ones which accept inline keys or `did:key` holders. * * @see \SimpleSAML\Test\Module\oidc\unit\VerifiableCredentials\OpenId4VciProofValidatorTest */ @@ -42,6 +53,35 @@ class OpenId4VciProofValidator */ final public const string PROOF_TYPE_JWT = 'jwt'; + /** + * How many distinct DIDs one Credential Request may send this deployment out to fetch. + * + * Lower than the batch size on purpose. A batch of eight credentials for one wallet names one + * holder DID eight times over, or eight `did:jwk` identifiers which are resolved without leaving + * this process; eight distinct `did:web` hosts in one request is not a wallet collecting + * credentials, it is a request using this issuer to reach eight places. + */ + final public const int MAX_NETWORK_RESOLVED_DIDS = 4; + + /** + * How long everything one Credential Request has to fetch may take, in total. + * + * The per-fetch timeout bounds a single fetch and says nothing about a request making several, so + * this is the bound that actually holds the request open time down. It is passed into each + * resolution rather than applied afterwards, so a fetch which would overrun it is not started. + */ + final public const int REQUEST_DEADLINE_SECONDS = 15; + + /** + * The holder DID methods the DIIP profile names. + * + * Applied to the `kid` header, which is where the profile's holder binding actually lives: the + * wallet proves control of a key listed under `authentication` in the document that DID resolves + * to. `did:key` is deliberately absent - this module supports it and every non-DIIP configuration + * keeps accepting it, but the profile names these two. + */ + protected const array DIIP_HOLDER_DID_METHODS = ['jwk', 'web']; + /** * JWK members which describe a key without being part of it, so they are acceptable whatever the * key type is. @@ -104,7 +144,7 @@ public function validateRequest( VciCredentialBindingPolicyEnum $bindingPolicy, AccessTokenEntity $accessToken, ): array { - if ($bindingPolicy === VciCredentialBindingPolicyEnum::Proofless) { + if (!$bindingPolicy->requiresKeyProof()) { $this->refuseSuppliedProofs($requestData); return [null]; @@ -114,12 +154,24 @@ public function validateRequest( $this->loggerService->debug( 'Validating key proofs before issuing anything.', - ['count' => count($proofJwts)], + ['count' => count($proofJwts), 'bindingPolicy' => $bindingPolicy->value], + ); + + // Shared by every proof in the request, so that what one request may spend on resolving DIDs is + // bounded once rather than per proof - a per-proof deadline multiplies by the batch size. + $didResolutionBudget = new DidResolutionBudget( + microtime(true) + (float)self::REQUEST_DEADLINE_SECONDS, + self::MAX_NETWORK_RESOLVED_DIDS, ); $validatedProofs = []; foreach ($proofJwts as $proofJwt) { - $validatedProofs[] = $this->validateProof($proofJwt, $accessToken); + $validatedProofs[] = $this->validateProof( + $proofJwt, + $accessToken, + $bindingPolicy, + $didResolutionBudget, + ); } return $validatedProofs; @@ -237,8 +289,12 @@ protected function extractProofJwts(array $requestData): array * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException * @throws \SimpleSAML\Error\ConfigurationError */ - protected function validateProof(string $proofJwt, AccessTokenEntity $accessToken): ValidatedOpenId4VciProof - { + protected function validateProof( + string $proofJwt, + AccessTokenEntity $accessToken, + VciCredentialBindingPolicyEnum $bindingPolicy, + DidResolutionBudget $didResolutionBudget, + ): ValidatedOpenId4VciProof { try { $proof = $this->verifiableCredentials->openId4VciProofFactory()->fromToken($proofJwt); } catch (Throwable $throwable) { @@ -252,7 +308,11 @@ protected function validateProof(string $proofJwt, AccessTokenEntity $accessToke $this->validateAudience($proof); $this->validateIssuer($proof, $accessToken); - [$jwk, $subject, $keyId] = $this->resolveKeySource($proof); + [$jwk, $subject, $keyId, $holderJwk] = $this->resolveKeySource( + $proof, + $bindingPolicy, + $didResolutionBudget, + ); try { $proof->verifyWithKey($jwk); @@ -270,7 +330,7 @@ protected function validateProof(string $proofJwt, AccessTokenEntity $accessToke $this->loggerService->debug('Key proof validated.', ['subject' => $subject]); - return new ValidatedOpenId4VciProof($proof, $subject, $keyId); + return new ValidatedOpenId4VciProof($proof, $subject, $keyId, $holderJwk); } catch (CredentialRequestException $credentialRequestException) { throw $credentialRequestException; } catch (Throwable $throwable) { @@ -366,9 +426,9 @@ protected function validateIssuer(OpenId4VciProof $proof, AccessTokenEntity $acc return; } - // Absence is accepted. OpenID4VCI constrains this claim when it is present; requiring it here - // would refuse a proof the specification permits, and it is the DIIP profile, applied per - // credential configuration, where presence becomes a requirement. + // Absence is accepted. OpenID4VCI constrains this claim when it is present, and requiring it + // here would refuse a proof the specification permits. The DIIP profile does not require it + // either: its holder binding is carried by the `kid` header, not by this claim. if ($proofIssuer === null) { return; } @@ -389,14 +449,18 @@ protected function validateIssuer(OpenId4VciProof $proof, AccessTokenEntity $acc /** * Work out which key the proof is verified against, and what its credential is bound to. * - * @return array{0: mixed[], 1: string, 2: ?string} The key, the holder identifier, and the - * verification method the proof named. + * @return array{0: mixed[], 1: string, 2: ?string, 3: ?mixed[]} The key, the holder identifier, the + * verification method the proof named, and the key it carried inline. Exactly one of the last two + * is ever set, and which one decides how the credential's `cnf` claim names the holder's key. * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException * @throws \JsonException */ - protected function resolveKeySource(OpenId4VciProof $proof): array - { + protected function resolveKeySource( + OpenId4VciProof $proof, + VciCredentialBindingPolicyEnum $bindingPolicy, + DidResolutionBudget $didResolutionBudget, + ): array { $keyId = $proof->getKeyId(); $headerJwk = $proof->getJsonWebKey(); $certificateChain = $proof->getX509CertificateChain(); @@ -421,6 +485,19 @@ protected function resolveKeySource(OpenId4VciProof $proof): array } if ($headerJwk !== null) { + // The profile's requirements are written in DID URLs, and a key sent inline names no + // verification method for one to point at. Refused rather than resolved into a did:jwk of + // this issuer's own making, which would be this issuer deciding how the holder is + // identified in a credential whose whole claim is that the holder decided. + if ($bindingPolicy->requiresDiipIdentifiers()) { + throw new CredentialRequestException( + 'invalid_proof', + 'This credential configuration requires the key proof to name a verification ' . + 'method in a "kid" header, so a key carried inline in a "jwk" header can not be ' . + 'accepted.', + ); + } + $this->assertPublicJwk($headerJwk); try { @@ -432,39 +509,206 @@ protected function resolveKeySource(OpenId4VciProof $proof): array ); } - // No verification method was named, so there is none to carry into the credential's `cnf`. - return [$headerJwk, $subject, null]; + // No verification method was named, so the credential's `cnf` names the key itself. + return [$headerJwk, $subject, null, $headerJwk]; } /** @var non-empty-string $keyId */ - $did = explode('#', $keyId, 2)[0]; + $didUrl = $this->parseKeyId($keyId); - try { - if (str_starts_with($keyId, 'did:key:z')) { - return [$this->did->didKeyResolver()->extractJwkFromDidKey($did), $did, $keyId]; - } + // Before resolution, not after: a method the profile does not name is refused without this + // deployment first having gone out to fetch the DID naming it. + if ($bindingPolicy->requiresDiipIdentifiers()) { + $this->assertDiipHolderDidUrl($didUrl); + } - if (str_starts_with($keyId, 'did:jwk:')) { - return [$this->did->didJwkResolver()->extractJwkFromDidJwk($did), $did, $keyId]; - } + $resolved = $this->resolveVerificationMethod($didUrl, $didResolutionBudget); + + return [$resolved->getPublicJwk(), $resolved->getDid(), $resolved->getId()->getValue(), null]; + } + + + /** + * Read the DID URL a `kid` header carries. + * + * @param non-empty-string $keyId + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + */ + protected function parseKeyId(string $keyId): DidUrl + { + try { + $didUrl = new DidUrl($keyId); } catch (Throwable $throwable) { $this->loggerService->warning( - 'Key proof names a verification method which could not be resolved.', + 'Key proof "kid" header could not be read as a DID URL.', ['error' => $throwable->getMessage()], ); + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof "kid" header must be a DID URL naming a verification method.', + ); + } + + // A JOSE `kid` names a key. Resolving a bare DID and then picking a verification method out of + // its document would be this issuer choosing which of the holder's keys the credential is bound + // to, and writing that choice into a `cnf` claim the wallet never asserted. + if (!$didUrl->hasFragment()) { + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof "kid" header must name a verification method within a DID document, not ' . + 'just the DID itself.', + ); + } + + return $didUrl; + } + + + /** + * Resolve the verification method a `kid` header names, within what this request may spend on it. + * + * One call handles every DID method this library knows, including the ones which have to be fetched. + * It replaces a chain of `str_starts_with` branches whose fall-through was the bug this class was + * written to close: a method nobody had added a branch for left the key unresolved, and the + * credential was issued anyway. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + */ + protected function resolveVerificationMethod( + DidUrl $didUrl, + DidResolutionBudget $didResolutionBudget, + ): ResolvedVerificationMethod { + $didDocument = $didResolutionBudget->recallDocument($didUrl); + + if (!$didDocument instanceof DidDocument) { + $didDocument = $this->resolveDocument($didUrl, $didResolutionBudget); + $didResolutionBudget->rememberDocument($didUrl, $didDocument); + } + + try { + return $didDocument->resolveVerificationMethod( + $didUrl, + // The holder authenticates with this key. The other half of the DIIP requirement, the + // `assertionMethod` relationship, is about the keys this issuer signs with and is + // applied where those are published. + // + // Applied to every proof-bound configuration rather than only the DIIP ones, which a + // review asked to relax on the grounds that the relationship is an additive DIIP rule. + // Declined, for three reasons. A verification relationship is how a DID controller says + // what a key is authorized for, so honouring one listed under nothing - or under + // `keyAgreement` - would accept a key its own controller never authorized to + // authenticate with, which is the whole point of the relationship existing. Passing no + // relationship is not the looser option either: it searches the document's own + // `verificationMethod` entries only, so it would newly reject a key embedded inline + // under `authentication`, which DID Core permits and real documents use. And no wallet + // is affected, because the only method this can turn away is `did:web` - the locally + // built `did:jwk` and `did:key` documents list a signing key under `authentication` + // already - and `did:web` is not accepted at all before this step. + VerificationRelationshipEnum::Authentication, + ); + } catch (Throwable $throwable) { + $this->loggerService->warning( + 'Key proof names a verification method its DID document does not offer for ' . + 'authentication.', + ['did' => $didUrl->getDid(), 'error' => $throwable->getMessage()], + ); + throw new CredentialRequestException( 'invalid_proof', 'Key proof "kid" header names a verification method which could not be resolved.', ); } + } - $this->loggerService->warning('Key proof names a verification method of an unsupported type.'); - throw new CredentialRequestException( - 'invalid_proof', - 'Key proof "kid" header names a verification method this issuer can not resolve.', - ); + /** + * Fetch the document a DID describes, if this request can still afford to. + * + * The document rather than the single method the `kid` names, so that a batch naming several keys + * of one holder costs one fetch. Resolving per method would have made the cap count DIDs while the + * requests went out per proof, which is the bound the wrong way round. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + */ + protected function resolveDocument( + DidUrl $didUrl, + DidResolutionBudget $didResolutionBudget, + ): DidDocument { + if (!$didResolutionBudget->canResolve($didUrl)) { + $this->loggerService->warning( + 'Credential request named more DIDs needing resolution than one request may.', + ['did' => $didUrl->getDid(), 'max' => self::MAX_NETWORK_RESOLVED_DIDS], + ); + + throw new CredentialRequestException( + 'invalid_proof', + sprintf( + 'A Credential Request may name at most %d distinct DIDs which have to be resolved ' . + 'from their host.', + self::MAX_NETWORK_RESOLVED_DIDS, + ), + ); + } + + $didResolutionBudget->noteResolutionAttempt($didUrl); + + try { + return $this->did->resolveDocument( + $didUrl->getDid(), + $didResolutionBudget->getDeadlineTimestamp(), + ); + } catch (Throwable $throwable) { + // Every way this can fail becomes the same refusal. A destination refused by policy, a name + // which does not resolve, a timeout and an oversized body are told apart in the log and + // nowhere else: answering them differently would make this endpoint a way to ask which + // destinations exist inside this deployment. + $this->loggerService->warning( + 'Key proof names a DID which could not be resolved.', + ['did' => $didUrl->getDid(), 'error' => $throwable->getMessage()], + ); + + throw new CredentialRequestException( + 'invalid_proof', + 'Key proof "kid" header names a verification method which could not be resolved.', + ); + } + } + + + /** + * The DIIP profile's rule about how a holder is identified. + * + * It rests entirely on the `kid` header: an absolute DID URL of one of the two methods the profile + * names, resolved under `authentication`. Nothing here reads the `iss` claim, which OpenID4VCI has + * name the client the access token was issued to and has omitted altogether when no client is + * identified. + * + * The profile's own text puts the holder's DID in `iss`, and that cannot be met at the same time as + * OpenID4VCI's rule for an anonymous pre-authorized code, which requires the claim to be absent. + * FIDEScommunity/DIIP#83 proposes resolving it exactly this way - drop the requirement on `iss`, + * require an absolute DID URL in `kid` - and that proposal, still open at the time of writing, is + * what this implements. It also reads the profile's "MUST support ... as the `iss` value" as the + * capability requirement it is worded as: a did:jwk or did:web `iss` is accepted here, it is simply + * not demanded, and nothing about holder binding rests on it. What proves the holder is possession + * of a key their DID document lists, which is checked either way. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException + */ + protected function assertDiipHolderDidUrl(DidUrl $didUrl): void + { + if (!in_array($didUrl->getMethod(), self::DIIP_HOLDER_DID_METHODS, true)) { + throw new CredentialRequestException( + 'invalid_proof', + sprintf( + 'This credential configuration issues to holders identified by %s only.', + implode(' or ', array_map( + static fn(string $method): string => DidUrl::PREFIX . $method, + self::DIIP_HOLDER_DID_METHODS, + )), + ), + ); + } } diff --git a/src/VerifiableCredentials/Values/DidResolutionBudget.php b/src/VerifiableCredentials/Values/DidResolutionBudget.php new file mode 100644 index 00000000..ee0d3840 --- /dev/null +++ b/src/VerifiableCredentials/Values/DidResolutionBudget.php @@ -0,0 +1,128 @@ + + */ + protected array $documentsByDid = []; + + /** + * The DIDs this request has already sent this deployment out to fetch, whether or not the fetch + * then succeeded. + * + * @var array + */ + protected array $attemptedNetworkDids = []; + + + /** + * @param float $deadlineTimestamp When everything this request is doing has to be finished, as a + * unix timestamp with fractions. Passed down into each resolution, so that the fetches share one + * bound rather than each getting the per-fetch timeout to itself. + * @param int $maxNetworkResolvedDids How many distinct DIDs this request may send this deployment + * out to fetch. + */ + public function __construct( + protected readonly float $deadlineTimestamp, + protected readonly int $maxNetworkResolvedDids, + ) { + } + + + public function getDeadlineTimestamp(): float + { + return $this->deadlineTimestamp; + } + + + /** + * Whether resolving this DID URL is still within what the request may spend. + * + * A DID this request has already gone out for costs nothing more, and neither does one which + * resolves locally. + */ + public function canResolve(DidUrl $didUrl): bool + { + if (in_array($didUrl->getMethod(), self::LOCALLY_RESOLVED_DID_METHODS, true)) { + return true; + } + + if (array_key_exists($didUrl->getDid(), $this->attemptedNetworkDids)) { + return true; + } + + return count($this->attemptedNetworkDids) < $this->maxNetworkResolvedDids; + } + + + /** + * Record that this request is about to go out for this DID. + * + * Recorded before the attempt rather than after it succeeds. A failing fetch is the expensive one - + * it can be a full timeout - so counting only successes would let a request name a fresh + * unreachable host in every proof and pay for all of them. + */ + public function noteResolutionAttempt(DidUrl $didUrl): void + { + if (in_array($didUrl->getMethod(), self::LOCALLY_RESOLVED_DID_METHODS, true)) { + return; + } + + $this->attemptedNetworkDids[$didUrl->getDid()] = true; + } + + + /** + * The document an earlier proof in this same request already resolved this DID to, if any. + */ + public function recallDocument(DidUrl $didUrl): ?DidDocument + { + return $this->documentsByDid[$didUrl->getDid()] ?? null; + } + + + public function rememberDocument(DidUrl $didUrl, DidDocument $didDocument): void + { + $this->documentsByDid[$didUrl->getDid()] = $didDocument; + } +} diff --git a/src/VerifiableCredentials/Values/ValidatedOpenId4VciProof.php b/src/VerifiableCredentials/Values/ValidatedOpenId4VciProof.php index 889541d9..8f2d92d6 100644 --- a/src/VerifiableCredentials/Values/ValidatedOpenId4VciProof.php +++ b/src/VerifiableCredentials/Values/ValidatedOpenId4VciProof.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\VerifiableCredentials\Values; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\VerifiableCredentials\OpenId4VciProof; /** @@ -19,11 +20,14 @@ class ValidatedOpenId4VciProof * @param string $subject The holder identifier the credential is issued to. * @param ?string $keyId The verification method the proof named, or null when the proof carried its * key inline in a `jwk` header and so named no verification method at all. + * @param ?array $holderJwk The key the proof carried inline, or null when it named + * a verification method instead. */ public function __construct( protected readonly OpenId4VciProof $proof, protected readonly string $subject, protected readonly ?string $keyId, + protected readonly ?array $holderJwk = null, ) { } @@ -44,4 +48,42 @@ public function getKeyId(): ?string { return $this->keyId; } + + + /** + * @return ?array + */ + public function getHolderJwk(): ?array + { + return $this->holderJwk; + } + + + /** + * The `cnf` claim stating which key this credential is held by, in whichever way the proof stated + * it. + * + * Built here rather than in each of the three format branches which emit it. All three have to say + * the same thing about the same proof, and the way that goes wrong is one branch being written + * before a second way of naming a key exists and never learning about it - which is exactly what + * happened to the inline-key case, whose credentials carried no `cnf` at all and so looked unbound + * to any verifier which checks holder binding there rather than at `sub`. + * + * @return ?array + */ + public function getConfirmation(): ?array + { + if (is_string($this->keyId)) { + return [ClaimsEnum::Kid->value => $this->keyId]; + } + + if (is_array($this->holderJwk)) { + // RFC 7800 names the key itself, which is all an inline proof gave us to name. There is no + // verification method to point at, and manufacturing one would be inventing an identifier + // the wallet never published. + return [ClaimsEnum::Jwk->value => $this->holderJwk]; + } + + return null; + } } diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php index 4969fa01..92657c53 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php @@ -198,8 +198,10 @@ public function testDescribesWhatEachConfigurationCanBeProvedAndSignedWith(): vo [SignatureAlgorithmEnum::ES256->value], $configuration[ClaimsEnum::CredentialSigningAlgValuesSupported->value], ); + // `jwk` alongside the DID methods, because a key proof may carry its key inline and this + // configuration accepts one. A wallet has no other way to find that out. $this->assertSame( - ['did:key', 'did:jwk'], + ['did:key', 'did:jwk', 'did:web', 'jwk'], $configuration[ClaimsEnum::CryptographicBindingMethodsSupported->value], ); $this->assertArrayHasKey(ClaimsEnum::ProofTypesSupported->value, $configuration); @@ -208,6 +210,36 @@ public function testDescribesWhatEachConfigurationCanBeProvedAndSignedWith(): vo } + /** + * A DIIP configuration accepts holders identified by the two methods that profile names, and the + * metadata has to say which those are rather than repeating what every other configuration accepts. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testADiipConfigurationAdvertisesOnlyTheMethodsItAccepts(): void + { + $this->bindingPolicy = VciCredentialBindingPolicyEnum::DiipProofBound; + + $metadata = $this->publishedMetadata(); + + /** @var array> $configurations */ + $configurations = $metadata[ClaimsEnum::CredentialConfigurationsSupported->value]; + $configuration = $configurations[self::CONFIGURATION_ID]; + + // No `jwk` here, unlike the default policy: this one refuses a key proof carrying its key + // inline, so advertising the value would invite a proof it would then turn away. + $this->assertSame( + ['did:jwk', 'did:web'], + $configuration[ClaimsEnum::CryptographicBindingMethodsSupported->value], + ); + // It is a key-proof configuration like any other, so both binding fields are present and the + // batch size is advertised. + $this->assertArrayHasKey(ClaimsEnum::ProofTypesSupported->value, $configuration); + $this->assertArrayHasKey(ClaimsEnum::BatchCredentialIssuance->value, $metadata); + } + + /** * The credential endpoint refuses a `proofs` array longer than this, so a wallet has to be able to * find out what the limit is before it builds one. diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php index 867eccc3..5c311eca 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php @@ -263,6 +263,7 @@ function (mixed $key, mixed $algorithm, array $payload) use ($vcSdJwtMock): VcSd protected function issue( string $format = CredentialFormatIdentifiersEnum::JwtVcJson->value, array $proofJwts = ['jwt1'], + ?array $inlineKey = null, ): void { $this->moduleConfigMock->method('getVciCredentialConfiguration') ->willReturn([ClaimsEnum::Format->value => $format]); @@ -279,7 +280,8 @@ protected function issue( $validatedProofs[] = new ValidatedOpenId4VciProof( $this->createMock(OpenId4VciProof::class), self::HOLDER_DID, - self::HOLDER_DID . '#0', + $inlineKey === null ? self::HOLDER_DID . '#0' : null, + $inlineKey, ); } $this->openId4VciProofValidatorMock->method('validateRequest')->willReturn($validatedProofs); @@ -370,6 +372,50 @@ public function testBindsTheCredentialToWhatTheProofResolvedTo(): void } + /** + * `cnf` is where a verifier reads what a credential is held by, and `credentialSubject.id` is not + * equivalent to it. The claim was assembled inside two of the three format branches, so the third + * issued credentials which looked unbound however carefully the proof behind them was checked. + */ + public function testStatesTheConfirmedKeyInEveryFormat(): void + { + $issuableFormats = [ + CredentialFormatIdentifiersEnum::JwtVcJson, + CredentialFormatIdentifiersEnum::DcSdJwt, + CredentialFormatIdentifiersEnum::VcSdJwt, + ]; + + foreach ($issuableFormats as $format) { + $this->setUp(); + $this->issue($format->value); + + $this->assertSame( + [ClaimsEnum::Kid->value => self::HOLDER_DID . '#0'], + $this->signedPayloads[0][ClaimsEnum::Cnf->value] ?? null, + sprintf('The %s format did not state the key its credential is held by.', $format->value), + ); + } + } + + + /** + * A proof carrying its key inline names no verification method, so there is no `kid` to confirm. + * Saying nothing at all was the same defect the other way round: a signature had been verified and + * nothing in the credential said which key it was verified against. + */ + public function testConfirmsAnInlineKeyByTheKeyItself(): void + { + $holderJwk = ['kty' => 'EC', 'crv' => 'P-256', 'x' => 'x-value', 'y' => 'y-value']; + + $this->issue(inlineKey: $holderJwk); + + $this->assertSame( + [ClaimsEnum::Jwk->value => $holderJwk], + $this->signedPayloads[0][ClaimsEnum::Cnf->value] ?? null, + ); + } + + /** * A configuration which advertises no proof type issues one credential, to a subject identifier of * this issuer's own making, and asks the validator for nothing more than that. diff --git a/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php b/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php index 7ed70bc9..38ef18cb 100644 --- a/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php +++ b/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php @@ -20,9 +20,12 @@ use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\VerificationRelationshipEnum; use SimpleSAML\OpenID\Did; +use SimpleSAML\OpenID\Did\DidDocument; use SimpleSAML\OpenID\Did\DidJwkResolver; -use SimpleSAML\OpenID\Did\DidKeyJwkResolver; +use SimpleSAML\OpenID\Did\DidUrl; +use SimpleSAML\OpenID\Did\ResolvedVerificationMethod; use SimpleSAML\OpenID\Exceptions\DidException; use SimpleSAML\OpenID\Exceptions\JwsException; use SimpleSAML\OpenID\SupportedAlgorithms; @@ -50,6 +53,10 @@ class OpenId4VciProofValidatorTest extends TestCase protected const string HOLDER_DID_URL = self::HOLDER_DID . '#0'; + protected const string HOLDER_DID_WEB = 'did:web:wallet.example.org'; + + protected const string HOLDER_DID_WEB_URL = self::HOLDER_DID_WEB . '#key-1'; + /** @var array A public EC key, as a wallet would send it in a `jwk` header. */ protected const array PUBLIC_EC_JWK = [ 'kty' => 'EC', @@ -73,10 +80,11 @@ class OpenId4VciProofValidatorTest extends TestCase protected MockObject $didJwkResolverMock; - protected MockObject $didKeyResolverMock; - protected MockObject $accessTokenMock; + /** How many times the request under test sent this issuer out to resolve a DID document. */ + protected int $documentResolutions = 0; + protected function setUp(): void { @@ -97,19 +105,36 @@ protected function setUp(): void $this->verifiableCredentialsMock->method('openId4VciProofFactory')->willReturn($this->proofFactoryMock); $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); - $this->didKeyResolverMock = $this->createMock(DidKeyJwkResolver::class); $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); - $this->didMock->method('didKeyResolver')->willReturn($this->didKeyResolverMock); - $this->didJwkResolverMock->method('extractJwkFromDidJwk')->willReturn(self::PUBLIC_EC_JWK); $this->didJwkResolverMock->method('generateDidJwkFromJwk')->willReturn(self::HOLDER_DID); - $this->didKeyResolverMock->method('extractJwkFromDidKey')->willReturn(self::PUBLIC_EC_JWK); + + // One call handles every DID method, so the resolvers behind it are no longer stubbed one by + // one. Whichever verification method a proof names resolves to a key under the DID it sits + // under, which is what lets a test spoil that relationship on purpose. + $this->documentResolutions = 0; + $this->didMock->method('resolveDocument')->willReturnCallback( + function (string $did): DidDocument { + $this->documentResolutions++; + + return $this->didDocument($did); + }, + ); $this->nonceServiceMock->method('validateNonce')->willReturn(true); // An authenticated wallet by default, so the anonymous pre-authorized rules are opted into by // the tests which are about them rather than applying everywhere. + $this->walletIdentifiedAs(self::CLIENT_ID); + } + + + /** + * The access token a proof accompanies, issued to a wallet known by this identifier. + */ + protected function walletIdentifiedAs(string $clientId): void + { $clientMock = $this->createMock(ClientEntityInterface::class); - $clientMock->method('getIdentifier')->willReturn(self::CLIENT_ID); + $clientMock->method('getIdentifier')->willReturn($clientId); $this->accessTokenMock = $this->createMock(AccessTokenEntity::class); $this->accessTokenMock->method('getFlowTypeEnum')->willReturn(FlowTypeEnum::VciAuthorizationCode); $this->accessTokenMock->method('getBoundClientId')->willReturn(null); @@ -117,6 +142,53 @@ protected function setUp(): void } + /** + * A document which offers every key asked of it, under the authentication relationship. + * + * Documents rather than single methods, because that is what the validator resolves: one fetch + * answers for every key of one holder, and a test can count the fetches a request caused. + */ + protected function didDocument(string $did): DidDocument + { + $didDocumentMock = $this->createMock(DidDocument::class); + $didDocumentMock->method('getId')->willReturn($did); + // A named method rather than a closure: rector rewrites a single-return closure into an arrow + // function, and the arrow function this one becomes is too long for the line limit phpcs + // enforces, so the two tools disagree about every spelling but this one. + $didDocumentMock->method('resolveVerificationMethod')->willReturnCallback( + $this->verificationMethodIn(...), + ); + + return $didDocumentMock; + } + + + protected function verificationMethodIn( + DidUrl $didUrl, + ?VerificationRelationshipEnum $relationship, + ): ResolvedVerificationMethod { + return new ResolvedVerificationMethod( + $didUrl->getDid(), + $didUrl, + self::PUBLIC_EC_JWK, + $relationship, + ); + } + + + protected function resolvedVerificationMethod(string $didUrl): ResolvedVerificationMethod + { + $parsedDidUrl = new DidUrl($didUrl); + + return new ResolvedVerificationMethod( + $parsedDidUrl->getDid(), + $parsedDidUrl, + self::PUBLIC_EC_JWK, + VerificationRelationshipEnum::Authentication, + ); + } + + protected function sut(): OpenId4VciProofValidator { return new OpenId4VciProofValidator( @@ -183,6 +255,29 @@ protected function requestWith(array $overrides = [], int $proofCount = 1): arra } + /** + * A request whose proofs differ from one another, for the rules which are about the request rather + * than about any one proof in it. + * + * @param list> $overridesPerProof + * @return array + */ + protected function requestWithProofs(array $overridesPerProof): array + { + $index = 0; + $this->proofFactoryMock->method('fromToken')->willReturnCallback( + function () use ($overridesPerProof, &$index): OpenId4VciProof { + /** @var array $overrides */ + $overrides = $overridesPerProof[$index++] ?? []; + + return $this->proofMock($overrides); + }, + ); + + return ['proofs' => ['jwt' => array_fill(0, count($overridesPerProof), 'proof-jwt')]]; + } + + /** * @param array $requestData */ @@ -439,27 +534,193 @@ public function testRefusesAnUnadvertisedSigningAlgorithm(): void /** - * `did:web` arrives in a later step; until then an unresolvable method has to be refused rather than - * fallen through, which is what left proofs unverified. + * A `kid` which is not a DID URL at all is refused rather than falling through, which is what left + * proofs unverified before every method went through one resolution call. */ public function testRefusesAVerificationMethodItCanNotResolve(): void { - $this->assertRefusedWith('invalid_proof', $this->requestWith(['getKeyId' => 'did:web:example.org#0'])); $this->assertRefusedWith('invalid_proof', $this->requestWith(['getKeyId' => 'not-a-did'])); } - public function testRefusesAVerificationMethodWhichFailsToResolve(): void + /** + * A JOSE `kid` names a key. Resolving the bare DID and then picking a verification method out of + * its document would be this issuer choosing which of the holder's keys the credential is bound to, + * and writing that choice into a `cnf` claim the wallet never asserted. + */ + public function testRefusesAKeyIdWhichNamesOnlyTheDid(): void + { + $this->assertRefusedWith('invalid_proof', $this->requestWith(['getKeyId' => self::HOLDER_DID])); + $this->assertRefusedWith('invalid_proof', $this->requestWith(['getKeyId' => self::HOLDER_DID_WEB])); + } + + + public function testRefusesADidWhichFailsToResolve(): void { - $didJwkResolverMock = $this->createMock(DidJwkResolver::class); - $didJwkResolverMock->method('extractJwkFromDidJwk')->willThrowException(new DidException('malformed')); $this->didMock = $this->createMock(Did::class); - $this->didMock->method('didJwkResolver')->willReturn($didJwkResolverMock); + $this->didMock->method('resolveDocument') + ->willThrowException(new DidException('could not be retrieved')); $this->assertRefusedWith('invalid_proof', $this->requestWith()); } + /** + * A document which resolves but does not offer the named key for authentication is refused just the + * same, and with the same message: which of the two failed is a wallet's own business to work out. + * + * Note the policy here is the default `ProofBound`, not `DiipProofBound`. A verification + * relationship is how a DID controller says what a key may be used for, so a key listed under none + * of them has not been authorized to authenticate with, whichever profile the configuration runs. + */ + public function testRefusesAVerificationMethodTheDocumentDoesNotOffer(): void + { + $didDocumentMock = $this->createMock(DidDocument::class); + $didDocumentMock->method('resolveVerificationMethod') + ->willThrowException(new DidException('not under that relationship')); + + $this->didMock = $this->createMock(Did::class); + $this->didMock->method('resolveDocument')->willReturn($didDocumentMock); + + $this->assertRefusedWith('invalid_proof', $this->requestWith()); + } + + + /** + * The holder authenticates with this key, so the document has to list it under that relationship. + * Passing none would search the document's own verificationMethod entries instead, which is not the + * same question. + */ + public function testResolvesUnderTheAuthenticationRelationship(): void + { + $didDocumentMock = $this->createMock(DidDocument::class); + $didDocumentMock->expects($this->once())->method('resolveVerificationMethod') + ->with( + $this->callback( + static fn(DidUrl $didUrl): bool => $didUrl->getValue() === self::HOLDER_DID_URL, + ), + VerificationRelationshipEnum::Authentication, + ) + ->willReturn($this->resolvedVerificationMethod(self::HOLDER_DID_URL)); + + $this->didMock = $this->createMock(Did::class); + $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + // The whole request shares one deadline, so the fetch is bounded by when the request has to be + // done rather than by the per-fetch timeout each proof would otherwise get to itself. + $this->didMock->expects($this->once())->method('resolveDocument') + ->with(self::HOLDER_DID, $this->isFloat()) + ->willReturn($didDocumentMock); + + $this->sut()->validateRequest( + $this->requestWith(), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + } + + + /** + * `did:web` is the point of this step: it resolves through the same call as every other method, + * rather than needing a branch of its own added to a chain. + */ + public function testAcceptsADidWebHolder(): void + { + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(['getKeyId' => self::HOLDER_DID_WEB_URL]), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID_WEB, $validatedProofs[0]->getSubject()); + $this->assertSame(self::HOLDER_DID_WEB_URL, $validatedProofs[0]->getKeyId()); + } + + + /***************************************************************************************************** + * What one request may spend on resolving the DIDs it names. + ****************************************************************************************************/ + + /** + * A DID which has to be fetched is fetched once, however many proofs in the request name it. + */ + public function testResolvesADidOnlyOncePerRequest(): void + { + $this->sut()->validateRequest( + $this->requestWithProofs(array_fill(0, 4, ['getKeyId' => self::HOLDER_DID_WEB_URL])), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertSame(1, $this->documentResolutions); + } + + + /** + * And once for the whole document, not once per key named in it. + * + * Memoising the verification method instead would have kept the cap counting DIDs while the + * requests went out per proof: a batch naming eight keys of one holder is one DID to the budget and + * was eight fetches on the wire, so the bound this states would have been true about DIDs and false + * about outbound requests. The library's cache closes the same gap when one is configured; nothing + * requires one to be. + */ + public function testResolvesOneDocumentForEveryKeyABatchNamesInIt(): void + { + $overridesPerProof = []; + + for ($index = 0; $index < ModuleConfig::VCI_BATCH_SIZE; $index++) { + $overridesPerProof[] = ['getKeyId' => sprintf('%s#key-%d', self::HOLDER_DID_WEB, $index)]; + } + + $this->sut()->validateRequest( + $this->requestWithProofs($overridesPerProof), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertSame(1, $this->documentResolutions); + } + + + /** + * Eight proofs naming eight hosts is not a wallet collecting credentials, it is a request using this + * issuer to reach eight places. + */ + public function testRefusesMoreDistinctFetchedDidsThanOneRequestMay(): void + { + $overridesPerProof = []; + + for ($index = 0; $index <= OpenId4VciProofValidator::MAX_NETWORK_RESOLVED_DIDS; $index++) { + $overridesPerProof[] = ['getKeyId' => sprintf('did:web:wallet%d.example.org#key-1', $index)]; + } + + $this->assertRefusedWith('invalid_proof', $this->requestWithProofs($overridesPerProof)); + } + + + /** + * The cap counts only the DIDs which have to be fetched. Under `did:jwk` every key is its own DID, + * so counting those as well would refuse a legitimate batch of the advertised size. + */ + public function testLocallyResolvedDidsDoNotCountAgainstTheFetchBudget(): void + { + $overridesPerProof = []; + + for ($index = 0; $index < ModuleConfig::VCI_BATCH_SIZE; $index++) { + $overridesPerProof[] = ['getKeyId' => sprintf('%s%d', self::HOLDER_DID_URL, $index)]; + } + + $validatedProofs = $this->sut()->validateRequest( + $this->requestWithProofs($overridesPerProof), + VciCredentialBindingPolicyEnum::ProofBound, + $this->accessTokenMock, + ); + + $this->assertCount(ModuleConfig::VCI_BATCH_SIZE, $validatedProofs); + } + + /***************************************************************************************************** * The claims. ****************************************************************************************************/ @@ -641,4 +902,215 @@ public function testRefusesTheWholeRequestWhenALaterProofIsBad(): void $this->assertRefusedWith('invalid_proof', ['proofs' => ['jwt' => ['good-jwt', 'bad-jwt']]]); } + + + /***************************************************************************************************** + * The DIIP profile rules, which apply on top of the OpenID4VCI ones for configurations set to them. + ****************************************************************************************************/ + + /** + * A wallet identified by a `did:web`, naming a key under that same DID. Everything the profile asks + * for, and the shape a conformant wallet sends. + * + * @throws \Throwable + */ + public function testAcceptsAProofWhichMeetsTheDiipRules(): void + { + $this->walletIdentifiedAs(self::HOLDER_DID_WEB); + + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith([ + 'getIssuer' => self::HOLDER_DID_WEB, + 'getKeyId' => self::HOLDER_DID_WEB_URL, + ]), + VciCredentialBindingPolicyEnum::DiipProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID_WEB, $validatedProofs[0]->getSubject()); + $this->assertSame(self::HOLDER_DID_WEB_URL, $validatedProofs[0]->getKeyId()); + } + + + /** + * A `did:jwk` holder satisfies the profile just as well, so the rules are about the method being one + * of the two the profile names rather than about the identifier being fetched. + * + * @throws \Throwable + */ + public function testAcceptsADidJwkHolderUnderTheDiipRules(): void + { + $this->walletIdentifiedAs(self::HOLDER_DID); + + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(['getIssuer' => self::HOLDER_DID]), + VciCredentialBindingPolicyEnum::DiipProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID, $validatedProofs[0]->getSubject()); + } + + + /** + * The profile's holder binding is carried by the `kid` header, so a proof with no `iss` at all is + * accepted here exactly as it is everywhere else. + * + * The profile's own text asks for the holder's DID in `iss`, which cannot hold at the same time as + * OpenID4VCI's rule for an anonymous pre-authorized code. FIDEScommunity/DIIP#83 proposes dropping + * that requirement in favour of the `kid` rule, and this follows the proposal. + * + * @throws \Throwable + */ + public function testTheDiipRulesDoNotRequireAnIssuerClaim(): void + { + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(['getIssuer' => null, 'getKeyId' => self::HOLDER_DID_WEB_URL]), + VciCredentialBindingPolicyEnum::DiipProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID_WEB, $validatedProofs[0]->getSubject()); + } + + + /** + * And a wallet identified by a URL is not turned away. Requiring a DID here would have constrained + * how a wallet registers while adding nothing to holder binding, which rests on possession of a key + * the holder's DID document lists. + * + * @throws \Throwable + */ + public function testTheDiipRulesDoNotRequireTheWalletToBeIdentifiedByADid(): void + { + // The default wallet, identified by a URL, sending a proof which names it correctly. + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(['getIssuer' => self::CLIENT_ID, 'getKeyId' => self::HOLDER_DID_WEB_URL]), + VciCredentialBindingPolicyEnum::DiipProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID_WEB, $validatedProofs[0]->getSubject()); + } + + + /** + * A DID `iss` is still accepted, since the profile's text asks for one and nothing here refuses it. + * + * @throws \Throwable + */ + public function testTheDiipRulesStillAcceptADidIssuerClaim(): void + { + $this->walletIdentifiedAs(self::HOLDER_DID_WEB); + + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith([ + 'getIssuer' => self::HOLDER_DID_WEB, + 'getKeyId' => self::HOLDER_DID_WEB_URL, + ]), + VciCredentialBindingPolicyEnum::DiipProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID_WEB, $validatedProofs[0]->getSubject()); + } + + + /** + * `did:key` keeps working for every other configuration. The profile names two methods, and it is + * the `kid` header they constrain. + */ + public function testTheDiipRulesRefuseAHolderMethodTheProfileDoesNotName(): void + { + $didKey = 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'; + + $this->assertRefusedWith( + 'invalid_proof', + $this->requestWith(['getKeyId' => $didKey . '#z6Mkh']), + VciCredentialBindingPolicyEnum::DiipProofBound, + ); + + // Refused before the fetch, not after. Nothing is fetched for a did:key either way, but the + // same ordering holds for a method which would be. + $this->assertSame(0, $this->documentResolutions); + } + + + /** + * The profile's requirements are written in DID URLs, and a key sent inline names no verification + * method for one to point at. Every other configuration keeps accepting it. + */ + public function testTheDiipRulesRefuseAnInlineKey(): void + { + $this->walletIdentifiedAs(self::HOLDER_DID_WEB); + + $this->assertRefusedWith( + 'invalid_proof', + $this->requestWith([ + 'getIssuer' => self::HOLDER_DID_WEB, + 'getKeyId' => null, + 'getJsonWebKey' => self::PUBLIC_EC_JWK, + ]), + VciCredentialBindingPolicyEnum::DiipProofBound, + ); + } + + + /** + * The case the whole `iss` question turns on. + * + * OpenID4VCI requires the claim to be absent when the access token identifies no client, and the + * profile's own text requires the holder's DID to be in it. Read literally, the two mean a DIIP + * configuration could never be issued through an anonymous pre-authorized code. Binding on the + * `kid` header instead leaves both rules satisfied and the flow usable. + * + * @throws \Throwable + */ + public function testTheDiipRulesAreMetThroughAnAnonymousPreAuthorizedCode(): void + { + $this->accessTokenMock = $this->createMock(AccessTokenEntity::class); + $this->accessTokenMock->method('getFlowTypeEnum')->willReturn(FlowTypeEnum::VciPreAuthorizedCode); + $this->accessTokenMock->method('getBoundClientId')->willReturn(null); + + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith(['getIssuer' => null, 'getKeyId' => self::HOLDER_DID_WEB_URL]), + VciCredentialBindingPolicyEnum::DiipProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID_WEB, $validatedProofs[0]->getSubject()); + $this->assertSame(self::HOLDER_DID_WEB_URL, $validatedProofs[0]->getKeyId()); + } + + + /** + * A pre-authorized code which does identify its wallet is a different case, and the profile applies + * to it like any other. + * + * @throws \Throwable + */ + public function testAPreAuthorizedCodeIdentifyingItsWalletSatisfiesTheDiipRules(): void + { + $this->accessTokenMock = $this->createMock(AccessTokenEntity::class); + $this->accessTokenMock->method('getFlowTypeEnum')->willReturn(FlowTypeEnum::VciPreAuthorizedCode); + $this->accessTokenMock->method('getBoundClientId')->willReturn(self::HOLDER_DID_WEB); + + $validatedProofs = $this->sut()->validateRequest( + $this->requestWith([ + 'getIssuer' => self::HOLDER_DID_WEB, + 'getKeyId' => self::HOLDER_DID_WEB_URL, + ]), + VciCredentialBindingPolicyEnum::DiipProofBound, + $this->accessTokenMock, + ); + + $this->assertNotNull($validatedProofs[0]); + $this->assertSame(self::HOLDER_DID_WEB, $validatedProofs[0]->getSubject()); + } } diff --git a/tests/unit/src/VerifiableCredentials/Values/DidResolutionBudgetTest.php b/tests/unit/src/VerifiableCredentials/Values/DidResolutionBudgetTest.php new file mode 100644 index 00000000..fbf84784 --- /dev/null +++ b/tests/unit/src/VerifiableCredentials/Values/DidResolutionBudgetTest.php @@ -0,0 +1,147 @@ +createMock(DidDocument::class); + } + + + public function testCountsDistinctFetchedDidsUpToTheCap(): void + { + $didResolutionBudget = $this->sut(2); + + $first = new DidUrl('did:web:one.example.org#key-1'); + $second = new DidUrl('did:web:two.example.org#key-1'); + $third = new DidUrl('did:web:three.example.org#key-1'); + + $this->assertTrue($didResolutionBudget->canResolve($first)); + $didResolutionBudget->noteResolutionAttempt($first); + + $this->assertTrue($didResolutionBudget->canResolve($second)); + $didResolutionBudget->noteResolutionAttempt($second); + + $this->assertFalse($didResolutionBudget->canResolve($third)); + } + + + /** + * Another key in a document already fetched costs nothing more, so it is not what the cap is about. + */ + public function testAnotherKeyInADidAlreadyFetchedStaysWithinTheCap(): void + { + $didResolutionBudget = $this->sut(1); + + $didResolutionBudget->noteResolutionAttempt(new DidUrl('did:web:one.example.org#key-1')); + + $this->assertTrue($didResolutionBudget->canResolve(new DidUrl('did:web:one.example.org#key-2'))); + } + + + /** + * A failing fetch is the expensive one - it can be a full timeout - so counting only the ones which + * succeed would let a request name a fresh unreachable host in every proof and pay for all of them. + * The budget is therefore told about the attempt, and never about the outcome. + */ + public function testAnAttemptCountsWhetherOrNotAnythingCameBack(): void + { + $didResolutionBudget = $this->sut(1); + + $didResolutionBudget->noteResolutionAttempt(new DidUrl('did:web:unreachable.example.org#key-1')); + + $this->assertFalse($didResolutionBudget->canResolve(new DidUrl('did:web:two.example.org#key-1'))); + } + + + /** + * `did:jwk` and `did:key` carry their key inside the identifier, so resolving one is decoding rather + * than fetching. Counting them would refuse a legitimate batch: under `did:jwk` every key is its own + * DID, so eight proofs are eight distinct DIDs. + */ + public function testLocallyResolvedMethodsAreNotCounted(): void + { + $didResolutionBudget = $this->sut(1); + + foreach (['did:jwk:eyJrdHkiOiJFQyJ9#0', 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLG#z6Mkh'] as $value) { + $didUrl = new DidUrl($value); + $this->assertTrue($didResolutionBudget->canResolve($didUrl)); + $didResolutionBudget->noteResolutionAttempt($didUrl); + } + + // The fetch budget is untouched by any of that. + $this->assertTrue($didResolutionBudget->canResolve(new DidUrl('did:web:one.example.org#key-1'))); + } + + + /** + * A method the library gains later is bounded by default rather than exempt by nobody having listed + * it, which is why the local methods are the ones named. + */ + public function testAMethodNobodyNamedCountsAgainstTheCap(): void + { + $didResolutionBudget = $this->sut(1); + + $didResolutionBudget->noteResolutionAttempt(new DidUrl('did:example:abc#key-1')); + + $this->assertFalse($didResolutionBudget->canResolve(new DidUrl('did:web:one.example.org#key-1'))); + } + + + /** + * Keyed by the DID rather than by the DID URL which asked for it, because one fetch answers for + * every key in the document. Keyed the other way, a batch naming two keys of one holder would fetch + * the document twice while the cap counted one. + */ + public function testRecallsADocumentForEveryKeyNamedInIt(): void + { + $didResolutionBudget = $this->sut(); + $didUrl = new DidUrl('did:web:one.example.org#key-1'); + + $this->assertNull($didResolutionBudget->recallDocument($didUrl)); + + $didDocument = $this->didDocument(); + $didResolutionBudget->rememberDocument($didUrl, $didDocument); + + $this->assertSame($didDocument, $didResolutionBudget->recallDocument($didUrl)); + $this->assertSame( + $didDocument, + $didResolutionBudget->recallDocument(new DidUrl('did:web:one.example.org#key-2')), + ); + // Another DID is another document. + $this->assertNull($didResolutionBudget->recallDocument(new DidUrl('did:web:two.example.org#key-1'))); + } + + + public function testCarriesTheDeadlineItWasGiven(): void + { + $deadlineTimestamp = microtime(true) + 15; + + $this->assertSame( + $deadlineTimestamp, + (new DidResolutionBudget($deadlineTimestamp, 4))->getDeadlineTimestamp(), + ); + } +} diff --git a/tests/unit/src/VerifiableCredentials/Values/ValidatedOpenId4VciProofTest.php b/tests/unit/src/VerifiableCredentials/Values/ValidatedOpenId4VciProofTest.php new file mode 100644 index 00000000..9d82edb0 --- /dev/null +++ b/tests/unit/src/VerifiableCredentials/Values/ValidatedOpenId4VciProofTest.php @@ -0,0 +1,73 @@ + */ + protected const array HOLDER_JWK = ['kty' => 'EC', 'crv' => 'P-256', 'x' => 'x', 'y' => 'y']; + + + public function testConfirmsTheVerificationMethodAProofNamed(): void + { + $validatedProof = new ValidatedOpenId4VciProof( + $this->createMock(OpenId4VciProof::class), + self::HOLDER_DID, + self::HOLDER_DID . '#key-1', + ); + + $this->assertSame(self::HOLDER_DID, $validatedProof->getSubject()); + $this->assertSame( + [ClaimsEnum::Kid->value => self::HOLDER_DID . '#key-1'], + $validatedProof->getConfirmation(), + ); + } + + + /** + * An inline key names no verification method, so the key itself is what there is to confirm. + * Manufacturing a verification method id for it would be inventing an identifier the wallet never + * published. + */ + public function testConfirmsAnInlineKeyByTheKeyItself(): void + { + $validatedProof = new ValidatedOpenId4VciProof( + $this->createMock(OpenId4VciProof::class), + self::HOLDER_DID, + null, + self::HOLDER_JWK, + ); + + $this->assertNull($validatedProof->getKeyId()); + $this->assertSame(self::HOLDER_JWK, $validatedProof->getHolderJwk()); + $this->assertSame([ClaimsEnum::Jwk->value => self::HOLDER_JWK], $validatedProof->getConfirmation()); + } + + + public function testHasNothingToConfirmWhenTheProofNamedNoKey(): void + { + $validatedProof = new ValidatedOpenId4VciProof( + $this->createMock(OpenId4VciProof::class), + self::HOLDER_DID, + null, + ); + + $this->assertNull($validatedProof->getConfirmation()); + } +} From 174c61e96cd74b58ef0f175a5cf29aeab8b4b328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 2 Sep 2026 10:57:11 +0200 Subject: [PATCH 07/15] Advertise the binding methods this deployment can actually resolve --- .../VciCredentialBindingPolicyEnum.php | 109 +++++++++++++- ...redentialIssuerConfigurationController.php | 64 ++++++-- src/Factories/DidFactory.php | 11 +- .../OpenId4VciProofValidator.php | 72 ++++----- .../VciCredentialBindingPolicyEnumTest.php | 140 ++++++++++++++++++ ...ntialIssuerConfigurationControllerTest.php | 135 ++++++++++++++++- tests/unit/src/Factories/DidFactoryTest.php | 34 ++++- .../OpenId4VciProofValidatorTest.php | 42 +++++- 8 files changed, 541 insertions(+), 66 deletions(-) create mode 100644 tests/unit/src/Codebooks/VciCredentialBindingPolicyEnumTest.php diff --git a/src/Codebooks/VciCredentialBindingPolicyEnum.php b/src/Codebooks/VciCredentialBindingPolicyEnum.php index 58537a44..6ea416a1 100644 --- a/src/Codebooks/VciCredentialBindingPolicyEnum.php +++ b/src/Codebooks/VciCredentialBindingPolicyEnum.php @@ -18,6 +18,24 @@ */ enum VciCredentialBindingPolicyEnum: string { + /** + * What OpenID4VCI calls a credential bound to a key given in JWK format, which is what a key proof + * carrying its key inline in a `jwk` header produces. + * + * Not a DID method, so it never comes from the resolver registry and has to be stated here. + */ + public const string BINDING_METHOD_JWK = 'jwk'; + + /** + * The DID methods the DIIP profile names for a holder, spelled the way the DID Specification + * Registries spell a method - which is also the spelling `cryptographic_binding_methods_supported` + * uses, and what {@see \SimpleSAML\OpenID\Did::supportedMethods()} returns. + * + * @var list + */ + protected const array DIIP_BINDING_METHODS = ['did:jwk', 'did:web']; + + /** * The default. A Key Proof is required, verified, and the credential is issued to the holder * identifier that proof resolves to. Both binding metadata fields are advertised. @@ -77,10 +95,95 @@ public function requiresKeyProof(): bool /** - * Whether the DIIP profile's identifier rules apply on top of the OpenID4VCI ones. + * Whether a holder identified by this DID method is acceptable under this policy. + * + * Asked about one method rather than about which profile is in force, deliberately. A caller told + * only that the DIIP rules apply still has to know what they say, so the profile's method list ends + * up written out at every such caller - which is how the metadata and the proof validator came to + * hold one list each, free to disagree. + * + * This is the single question behind both halves of the identifier rules: the metadata advertises + * the methods this deployment can resolve which pass it, and a key proof is refused when the method + * its `kid` names does not. Answering it in one place is what keeps the two from drifting apart - + * a method added to the resolver registry becomes acceptable and advertised together, or neither. + * + * Not public, deliberately. On its own it answers only what the profile narrows, which is never the + * whole answer: a method this deployment can not resolve is not one it accepts either. Callers go + * through {@see acceptableDidMethodsFrom()}, which asks both questions at once and so cannot be + * used to accept something the metadata does not advertise. + * + * @param string $didMethod The method spelled with its `did:` prefix, as + * {@see \SimpleSAML\OpenID\Did::supportedMethods()} spells it. */ - public function requiresDiipIdentifiers(): bool + protected function acceptsDidMethod(string $didMethod): bool { - return $this === self::DiipProofBound; + return match ($this) { + // Whatever this deployment can resolve. Nothing in OpenID4VCI narrows the holder to a + // particular method, and narrowing it here would refuse a holder for using a method whose + // support this issuer went on to advertise. + self::ProofBound => true, + self::DiipProofBound => in_array($didMethod, self::DIIP_BINDING_METHODS, true), + // Never asked: a proofless configuration validates no proof, so no holder identifier of any + // kind reaches this. Answered all the same, so that the metadata side gets a list rather + // than an exception if it ever does. + self::Proofless => false, + }; + } + + + /** + * Which of the DID methods this deployment can resolve are acceptable under this policy. + * + * @param list $didMethods Prefixed method names, ordinarily the whole resolver registry. + * @return list + */ + public function acceptableDidMethodsFrom(array $didMethods): array + { + return array_values(array_filter($didMethods, $this->acceptsDidMethod(...))); + } + + + /** + * Whether a key proof may carry its key inline in a `jwk` header rather than naming a verification + * method in a `kid` header. + * + * Kept next to {@see acceptsDidMethod()} because the metadata says so in the same field: a policy + * accepting inline keys advertises `jwk` alongside its DID methods. + */ + public function acceptsInlineKey(): bool + { + return match ($this) { + self::ProofBound => true, + // The profile's requirements are written in DID URLs, and an inline key names no + // verification method for one to point at. + self::DiipProofBound => false, + self::Proofless => false, + }; + } + + + /** + * The value of `cryptographic_binding_methods_supported` for this policy, given what this + * deployment can actually resolve. + * + * @param list $resolvableDidMethods What {@see \SimpleSAML\OpenID\Did::supportedMethods()} + * reports, so that a method the library gains is advertised without this being touched, and + * one it loses stops being advertised the same way. + * @return ?list Null where the policy binds nothing at all, which the caller must tell apart + * from an empty list: the field is omitted entirely rather than published empty. + */ + public function bindingMethodsFrom(array $resolvableDidMethods): ?array + { + if (!$this->requiresKeyProof()) { + return null; + } + + $bindingMethods = $this->acceptableDidMethodsFrom($resolvableDidMethods); + + if ($this->acceptsInlineKey()) { + $bindingMethods[] = self::BINDING_METHOD_JWK; + } + + return $bindingMethods; } } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index ea2fc567..cc58a33d 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -13,7 +13,7 @@ namespace SimpleSAML\Module\oidc\Controllers\VerifiableCredentials; -use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; +use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -26,6 +26,14 @@ class CredentialIssuerConfigurationController { + /** + * Memoised across the credential configurations of one published document. + * + * @var ?list + */ + protected ?array $resolvableDidMethods = null; + + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -34,6 +42,12 @@ public function __construct( protected readonly Routes $routes, protected readonly LoggerService $loggerService, protected readonly VciContextResolver $vciContextResolver, + // The factory rather than the built facade. Constructing one reads no configuration, whereas + // building the facade validates the DID destination settings and the VCI cache adapter - and + // the container resolves every constructor argument before the guard below runs, so taking a + // built one would answer a request this endpoint refuses outright, or one for a deployment + // binding nothing at all, by failing on DID settings neither has any use for. + protected readonly DidFactory $didFactory, ) { if (!$this->moduleConfig->getVciEnabled()) { $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); @@ -66,21 +80,19 @@ public function configuration(): Response $bindingPolicy = $this->moduleConfig->getVciCredentialBindingPolicyFor($credentialConfigurationId); - // A match rather than a comparison, so that a binding policy added later has to state - // what it advertises here instead of falling into whichever branch was written as the - // alternative - which for this pair would have silently unadvertised binding for it. - $bindingMethods = match ($bindingPolicy) { - // `jwk` is not a DID method: OpenID4VCI defines it as the value for a credential - // bound to a key in JWK format, which is what a key proof carrying its key inline - // in a `jwk` header produces. This configuration accepts those and states the key - // in `cnf.jwk`, so leaving the value out would hide a supported path from every - // wallet which reads this metadata to decide what to send. - VciCredentialBindingPolicyEnum::ProofBound => ['did:key', 'did:jwk', 'did:web', 'jwk'], - // The profile names these two, and its rules confine a holder to them: the proof's - // key has to sit under the DID its `iss` claim states. - VciCredentialBindingPolicyEnum::DiipProofBound => ['did:jwk', 'did:web'], - VciCredentialBindingPolicyEnum::Proofless => null, - }; + // Asked of the policy against the resolver registry rather than written out here, so + // that what this advertises and what the Credential Endpoint accepts are one answer + // instead of two lists which have to be kept in step. A DID method the library gains is + // advertised by every configuration whose policy accepts it without this line changing, + // and a policy added later has to say what it binds rather than falling into whichever + // branch an `if` here happened to leave open. + // + // Only a configuration which binds needs the registry, and only then is it worth + // building the DID facade to ask for it: a deployment issuing nothing but proofless + // credentials publishes this document without its DID settings ever being read. + $bindingMethods = $bindingPolicy->requiresKeyProof() ? + $bindingPolicy->bindingMethodsFrom($this->resolvableDidMethods()) : + null; if ($bindingMethods !== null) { $isAnyConfigurationProofBound = true; @@ -183,4 +195,24 @@ public function configuration(): Response return $this->routes->newJsonResponse($configuration); } + + + /** + * The DID methods this deployment can resolve, which is what every binding advertisement is + * filtered from. + * + * Built once for the whole document rather than per credential configuration, since the registry + * is the same for all of them and building the facade is what reads the DID settings. + * + * @return list + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + * @throws \SimpleSAML\OpenID\Exceptions\DidException + * @throws \SimpleSAML\OpenID\Exceptions\DestinationPolicyException + * @throws \Exception + */ + protected function resolvableDidMethods(): array + { + return $this->resolvableDidMethods ??= $this->didFactory->build()->supportedMethods(); + } } diff --git a/src/Factories/DidFactory.php b/src/Factories/DidFactory.php index 9acfefd2..e663ac7f 100644 --- a/src/Factories/DidFactory.php +++ b/src/Factories/DidFactory.php @@ -6,7 +6,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\VciCache; use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Did\DidWebResolver; @@ -24,17 +23,23 @@ class DidFactory * malformed, and the container reaches this factory while wiring up the admin Configuration screens - * the screens whose whole purpose is to report exactly such an option. Deferring it to build() keeps * them reachable. Same reasoning as FederationFactory, which regressed on it once. + * + * The cache is taken as the factory which builds it rather than as a built one, for that same + * reason: `vci_cache_adapter` names a class to instantiate with arguments, so building it reads + * configuration and can throw. Constructing this factory therefore reads nothing at all, which is + * what lets a caller hold one without inheriting a failure from DID settings it may never use. */ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly LoggerService $loggerService, - protected readonly ?VciCache $vciCache = null, + protected readonly ?CacheFactory $cacheFactory = null, ) { } /** * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException On a cache adapter which can not be built. * @throws \SimpleSAML\OpenID\Exceptions\DidException * @throws \SimpleSAML\OpenID\Exceptions\DestinationPolicyException On configuration the library * refuses when the policy is built, rather than letting an exemption that can never match @@ -45,7 +50,7 @@ public function build(): Did { return new Did( maxCacheDuration: $this->moduleConfig->getVciDidCacheMaxDuration(), - cache: $this->vciCache?->cache, + cache: $this->cacheFactory?->forVci()?->cache, logger: $this->loggerService, // Through the library's own helper rather than assembled here: it is what refuses a pinning // mode DID resolution can not run under, and building a DestinationPolicy by hand would walk diff --git a/src/VerifiableCredentials/OpenId4VciProofValidator.php b/src/VerifiableCredentials/OpenId4VciProofValidator.php index 98227e1b..ab01ebfa 100644 --- a/src/VerifiableCredentials/OpenId4VciProofValidator.php +++ b/src/VerifiableCredentials/OpenId4VciProofValidator.php @@ -72,16 +72,6 @@ class OpenId4VciProofValidator */ final public const int REQUEST_DEADLINE_SECONDS = 15; - /** - * The holder DID methods the DIIP profile names. - * - * Applied to the `kid` header, which is where the profile's holder binding actually lives: the - * wallet proves control of a key listed under `authentication` in the document that DID resolves - * to. `did:key` is deliberately absent - this module supports it and every non-DIIP configuration - * keeps accepting it, but the profile names these two. - */ - protected const array DIIP_HOLDER_DID_METHODS = ['jwk', 'web']; - /** * JWK members which describe a key without being part of it, so they are acceptable whatever the * key type is. @@ -489,7 +479,7 @@ protected function resolveKeySource( // verification method for one to point at. Refused rather than resolved into a did:jwk of // this issuer's own making, which would be this issuer deciding how the holder is // identified in a credential whose whole claim is that the holder decided. - if ($bindingPolicy->requiresDiipIdentifiers()) { + if (!$bindingPolicy->acceptsInlineKey()) { throw new CredentialRequestException( 'invalid_proof', 'This credential configuration requires the key proof to name a verification ' . @@ -516,11 +506,9 @@ protected function resolveKeySource( /** @var non-empty-string $keyId */ $didUrl = $this->parseKeyId($keyId); - // Before resolution, not after: a method the profile does not name is refused without this + // Before resolution, not after: a method the policy does not accept is refused without this // deployment first having gone out to fetch the DID naming it. - if ($bindingPolicy->requiresDiipIdentifiers()) { - $this->assertDiipHolderDidUrl($didUrl); - } + $this->assertHolderDidMethodIsAccepted($didUrl, $bindingPolicy); $resolved = $this->resolveVerificationMethod($didUrl, $didResolutionBudget); @@ -677,12 +665,19 @@ protected function resolveDocument( /** - * The DIIP profile's rule about how a holder is identified. + * Refuse a holder whose DID method this credential configuration's binding policy does not accept. * - * It rests entirely on the `kid` header: an absolute DID URL of one of the two methods the profile - * names, resolved under `authentication`. Nothing here reads the `iss` claim, which OpenID4VCI has - * name the client the access token was issued to and has omitted altogether when no client is - * identified. + * Which methods those are is the policy's answer rather than this class's, because the Credential + * Issuer metadata publishes the very same answer - see + * {@see \SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum::acceptsDidMethod()}. Two + * lists kept in step by hand is how an issuer ends up accepting a holder identifier it never + * advertised, or advertising one it refuses. Only the DIIP policy narrows anything today: every + * other proof-bound configuration accepts whatever the resolver registry can resolve. + * + * The DIIP rule rests entirely on the `kid` header: an absolute DID URL of one of the two methods + * the profile names, resolved under `authentication`. Nothing here reads the `iss` claim, which + * OpenID4VCI has name the client the access token was issued to and has omitted altogether when no + * client is identified. * * The profile's own text puts the holder's DID in `iss`, and that cannot be met at the same time as * OpenID4VCI's rule for an anonymous pre-authorized code, which requires the claim to be absent. @@ -695,20 +690,31 @@ protected function resolveDocument( * * @throws \SimpleSAML\Module\oidc\Exceptions\CredentialRequestException */ - protected function assertDiipHolderDidUrl(DidUrl $didUrl): void - { - if (!in_array($didUrl->getMethod(), self::DIIP_HOLDER_DID_METHODS, true)) { - throw new CredentialRequestException( - 'invalid_proof', - sprintf( - 'This credential configuration issues to holders identified by %s only.', - implode(' or ', array_map( - static fn(string $method): string => DidUrl::PREFIX . $method, - self::DIIP_HOLDER_DID_METHODS, - )), - ), - ); + protected function assertHolderDidMethodIsAccepted( + DidUrl $didUrl, + VciCredentialBindingPolicyEnum $bindingPolicy, + ): void { + // The very list this configuration advertises, matched against rather than merely quoted in the + // refusal. Asking the policy alone would accept a method it does not narrow but this deployment + // can not resolve - refused a moment later by the resolver, but refused as an unresolvable DID + // rather than as one this issuer never offered to accept. + $acceptedDidMethods = $bindingPolicy->acceptableDidMethodsFrom($this->did->supportedMethods()); + + if (in_array(DidUrl::PREFIX . $didUrl->getMethod(), $acceptedDidMethods, true)) { + return; } + + throw new CredentialRequestException( + 'invalid_proof', + $acceptedDidMethods === [] ? + // Reachable only where the profile a configuration follows names no method this deployment + // can resolve, which leaves it nothing to name in place of the one it turned away. + 'This credential configuration can not accept the holder identifier this key proof names.' : + sprintf( + 'This credential configuration issues to holders identified by %s only.', + implode(' or ', $acceptedDidMethods), + ), + ); } diff --git a/tests/unit/src/Codebooks/VciCredentialBindingPolicyEnumTest.php b/tests/unit/src/Codebooks/VciCredentialBindingPolicyEnumTest.php new file mode 100644 index 00000000..b9c3d069 --- /dev/null +++ b/tests/unit/src/Codebooks/VciCredentialBindingPolicyEnumTest.php @@ -0,0 +1,140 @@ + + */ + protected const array RESOLVABLE_DID_METHODS = ['did:jwk', 'did:key', 'did:web']; + + + public function testOnlyAProoflessConfigurationIssuesWithoutAKeyProof(): void + { + $this->assertTrue(VciCredentialBindingPolicyEnum::ProofBound->requiresKeyProof()); + $this->assertTrue(VciCredentialBindingPolicyEnum::DiipProofBound->requiresKeyProof()); + $this->assertFalse(VciCredentialBindingPolicyEnum::Proofless->requiresKeyProof()); + } + + + /** + * Nothing in OpenID4VCI narrows the holder to particular DID methods, so the default policy takes + * whatever this deployment can resolve - including a method the library adds after this is written, + * which is the case this cannot be written as a fixed list to cover. + */ + public function testTheDefaultPolicyAcceptsAnyResolvableDidMethod(): void + { + $policy = VciCredentialBindingPolicyEnum::ProofBound; + + $this->assertSame( + self::RESOLVABLE_DID_METHODS, + $policy->acceptableDidMethodsFrom(self::RESOLVABLE_DID_METHODS), + ); + // Including one the library did not have when this was written, which is the case a fixed list + // could not cover. + $this->assertSame(['did:example'], $policy->acceptableDidMethodsFrom(['did:example'])); + } + + + public function testADiipConfigurationAcceptsOnlyTheTwoMethodsTheProfileNames(): void + { + $policy = VciCredentialBindingPolicyEnum::DiipProofBound; + + $this->assertSame( + ['did:jwk', 'did:web'], + // `did:key` is supported by this module and accepted by every other configuration, but not + // named by the profile. + $policy->acceptableDidMethodsFrom(['did:jwk', 'did:key', 'did:web', 'did:example']), + ); + } + + + /** + * Asked, and answered, although no holder identifier reaches a proofless configuration: the + * metadata side calls this for every policy, and an unanswered case there would be an exception on + * the published document rather than an omitted field. + */ + public function testAProoflessConfigurationAcceptsNoHolderIdentifierAtAll(): void + { + $this->assertFalse(VciCredentialBindingPolicyEnum::Proofless->acceptsInlineKey()); + $this->assertSame([], VciCredentialBindingPolicyEnum::Proofless->acceptableDidMethodsFrom( + self::RESOLVABLE_DID_METHODS, + )); + } + + + public function testOnlyTheDefaultPolicyAcceptsAKeyCarriedInline(): void + { + $this->assertTrue(VciCredentialBindingPolicyEnum::ProofBound->acceptsInlineKey()); + // The profile's requirements are written in DID URLs, and an inline key names no verification + // method for one to point at. + $this->assertFalse(VciCredentialBindingPolicyEnum::DiipProofBound->acceptsInlineKey()); + } + + + /** + * The registry's own order is kept, so that the advertised list reads the way the library reports + * it rather than the way a filter happened to rebuild it. + */ + public function testFilteringTheRegistryKeepsItsOrderAndDropsNothingElse(): void + { + $this->assertSame( + self::RESOLVABLE_DID_METHODS, + VciCredentialBindingPolicyEnum::ProofBound->acceptableDidMethodsFrom(self::RESOLVABLE_DID_METHODS), + ); + $this->assertSame( + ['did:jwk', 'did:web'], + VciCredentialBindingPolicyEnum::DiipProofBound->acceptableDidMethodsFrom( + self::RESOLVABLE_DID_METHODS, + ), + ); + } + + + /** + * A profile method this deployment can not resolve is not advertised. Publishing it would send a + * wallet to build a proof the Credential Endpoint would then refuse, since resolution is what + * accepting a holder identifier actually comes down to. + */ + public function testAProfileMethodTheDeploymentCanNotResolveIsNotAdvertised(): void + { + $this->assertSame( + ['did:jwk'], + VciCredentialBindingPolicyEnum::DiipProofBound->bindingMethodsFrom(['did:jwk', 'did:key']), + ); + } + + + public function testTheDefaultPolicyAdvertisesTheRegistryPlusTheInlineKeyValue(): void + { + $this->assertSame( + ['did:jwk', 'did:key', 'did:web', VciCredentialBindingPolicyEnum::BINDING_METHOD_JWK], + VciCredentialBindingPolicyEnum::ProofBound->bindingMethodsFrom(self::RESOLVABLE_DID_METHODS), + ); + } + + + /** + * Null rather than an empty list, and the difference matters at the one call site: OpenID4VCI + * chains `proof_types_supported` to `cryptographic_binding_methods_supported`, so a proofless + * configuration omits both fields instead of publishing one of them empty. + */ + public function testAProoflessConfigurationAdvertisesNoBindingAtAll(): void + { + $this->assertNull( + VciCredentialBindingPolicyEnum::Proofless->bindingMethodsFrom(self::RESOLVABLE_DID_METHODS), + ); + } +} diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php index 92657c53..a7b384d3 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationControllerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; +use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -18,6 +19,7 @@ use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; +use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\ValueAbstracts\KeyPair; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; @@ -66,18 +68,36 @@ class CredentialIssuerConfigurationControllerTest extends TestCase protected MockObject $vciContextResolverMock; + protected MockObject $didMock; + + protected MockObject $didFactoryMock; + protected SignatureKeyPairBag $vciSignatureKeyPairBag; protected VciCredentialBindingPolicyEnum $bindingPolicy; + /** + * What the resolver registry reports it can resolve, which is what the metadata is filtered from. + * + * @var list + */ + protected array $resolvableDidMethods; + protected function setUp(): void { $this->bindingPolicy = VciCredentialBindingPolicyEnum::ProofBound; + $this->resolvableDidMethods = ['did:jwk', 'did:key', 'did:web']; $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->routesMock = $this->createMock(Routes::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); $this->vciContextResolverMock = $this->createMock(VciContextResolver::class); + $this->didMock = $this->createMock(Did::class); + $this->didFactoryMock = $this->createMock(DidFactory::class); + + $this->didMock->method('supportedMethods') + ->willReturnCallback(fn(): array => $this->resolvableDidMethods); + $this->didFactoryMock->method('build')->willReturn($this->didMock); $this->moduleConfigMock->method('getVciEnabled')->willReturn(true); $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); @@ -146,6 +166,7 @@ protected function sut(): CredentialIssuerConfigurationController $this->routesMock, $this->loggerServiceMock, $this->vciContextResolverMock, + $this->didFactoryMock, ); } @@ -198,10 +219,11 @@ public function testDescribesWhatEachConfigurationCanBeProvedAndSignedWith(): vo [SignatureAlgorithmEnum::ES256->value], $configuration[ClaimsEnum::CredentialSigningAlgValuesSupported->value], ); - // `jwk` alongside the DID methods, because a key proof may carry its key inline and this - // configuration accepts one. A wallet has no other way to find that out. + // Every method the resolver registry reports, in its order, plus `jwk` - because a key proof + // may carry its key inline and this configuration accepts one. A wallet has no other way to + // find that out. $this->assertSame( - ['did:key', 'did:jwk', 'did:web', 'jwk'], + ['did:jwk', 'did:key', 'did:web', 'jwk'], $configuration[ClaimsEnum::CryptographicBindingMethodsSupported->value], ); $this->assertArrayHasKey(ClaimsEnum::ProofTypesSupported->value, $configuration); @@ -240,6 +262,111 @@ public function testADiipConfigurationAdvertisesOnlyTheMethodsItAccepts(): void } + /** + * The point of taking the list from the resolver registry rather than writing it out here. + * + * A DID method the library gains is one this deployment can resolve the moment it is upgraded, so + * the default policy accepts a holder using it and has to say so. The DIIP policy does not, and its + * advertisement must not widen along with the registry. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testAMethodTheRegistryGainsIsAdvertisedOnlyWhereItIsAccepted(): void + { + $this->resolvableDidMethods = ['did:jwk', 'did:key', 'did:web', 'did:example']; + + /** @var array> $configurations */ + $configurations = $this->publishedMetadata()[ClaimsEnum::CredentialConfigurationsSupported->value]; + + $this->assertSame( + ['did:jwk', 'did:key', 'did:web', 'did:example', 'jwk'], + $configurations[self::CONFIGURATION_ID][ClaimsEnum::CryptographicBindingMethodsSupported->value], + ); + + $this->bindingPolicy = VciCredentialBindingPolicyEnum::DiipProofBound; + + /** @var array> $configurations */ + $configurations = $this->publishedMetadata()[ClaimsEnum::CredentialConfigurationsSupported->value]; + + $this->assertSame( + ['did:jwk', 'did:web'], + $configurations[self::CONFIGURATION_ID][ClaimsEnum::CryptographicBindingMethodsSupported->value], + ); + } + + + /** + * A deployment binding nothing has no use for the resolver registry, and building it is what reads + * this deployment's DID settings and instantiates its cache adapter. Publishing this document must + * not depend on either, so the facade is never built for such a deployment. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testTheDidFacadeIsNotBuiltForAConfigurationWhichBindsNothing(): void + { + $this->bindingPolicy = VciCredentialBindingPolicyEnum::Proofless; + + $this->didFactoryMock->expects($this->never())->method('build'); + + $this->publishedMetadata(); + } + + + /** + * And it is built once for the whole document rather than once per credential configuration, since + * every one of them is filtered from the same registry. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testTheDidFacadeIsBuiltOnceHoweverManyConfigurationsBind(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciEnabled')->willReturn(true); + $this->moduleConfigMock->method('getVciCredentialBindingPolicyFor') + ->willReturn(VciCredentialBindingPolicyEnum::ProofBound); + $this->moduleConfigMock->method('getActiveVciSignatureKeyPair') + ->willReturnCallback(fn(): SignatureKeyPair => $this->vciSignatureKeyPairBag->getFirstOrFail()); + $this->moduleConfigMock->method('getVciCredentialConfigurationsSupported')->willReturn([ + self::CONFIGURATION_ID => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + ], + 'SecondCredential' => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + ], + ]); + + $this->didFactoryMock->expects($this->once())->method('build')->willReturn($this->didMock); + + $this->publishedMetadata(); + } + + + /** + * The other direction: a method the profile names but this deployment can not resolve is not + * advertised, because a wallet acting on it would have its proof refused at the Credential + * Endpoint. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function testAMethodTheRegistryCanNotResolveIsNotAdvertised(): void + { + $this->bindingPolicy = VciCredentialBindingPolicyEnum::DiipProofBound; + $this->resolvableDidMethods = ['did:jwk', 'did:key']; + + /** @var array> $configurations */ + $configurations = $this->publishedMetadata()[ClaimsEnum::CredentialConfigurationsSupported->value]; + + $this->assertSame( + ['did:jwk'], + $configurations[self::CONFIGURATION_ID][ClaimsEnum::CryptographicBindingMethodsSupported->value], + ); + } + + /** * The credential endpoint refuses a `proofs` array longer than this, so a wallet has to be able to * find out what the limit is before it builds one. @@ -320,6 +447,7 @@ public function testAProoflessConfigurationDropsBindingFieldsTheOperatorWroteIn( $this->routesMock, $this->loggerServiceMock, $this->vciContextResolverMock, + $this->didFactoryMock, ); $content = $controller->configuration()->getContent(); @@ -430,6 +558,7 @@ public function testRefusesToPublishAnythingWhenCredentialsAreDisabled(): void $this->routesMock, $this->loggerServiceMock, $this->vciContextResolverMock, + $this->didFactoryMock, ); } } diff --git a/tests/unit/src/Factories/DidFactoryTest.php b/tests/unit/src/Factories/DidFactoryTest.php index 667b01f7..898732b0 100644 --- a/tests/unit/src/Factories/DidFactoryTest.php +++ b/tests/unit/src/Factories/DidFactoryTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\oidc\Factories\CacheFactory; use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -136,13 +137,44 @@ public function testAcceptsAConfiguredCache(): void { $vciCache = new VciCache(new Psr16Cache(new ArrayAdapter())); + $cacheFactoryMock = $this->createMock(CacheFactory::class); + $cacheFactoryMock->method('forVci')->willReturn($vciCache); + $this->assertInstanceOf( Did::class, - (new DidFactory($this->moduleConfigMock, $this->loggerServiceMock, $vciCache))->build(), + (new DidFactory($this->moduleConfigMock, $this->loggerServiceMock, $cacheFactoryMock))->build(), ); } + /** + * The cache is asked for when the facade is built, not when this factory is constructed. Building + * the adapter reads `vci_cache_adapter` and instantiates the class it names, so a caller holding + * this factory for something else - the metadata document's DID method list, say - must not inherit + * that failure. + */ + public function testTheCacheIsNotBuiltUntilTheFacadeIs(): void + { + $forVciCalls = 0; + + $cacheFactoryMock = $this->createMock(CacheFactory::class); + $cacheFactoryMock->method('forVci')->willReturnCallback( + function () use (&$forVciCalls): ?VciCache { + $forVciCalls++; + + return null; + }, + ); + + $didFactory = new DidFactory($this->moduleConfigMock, $this->loggerServiceMock, $cacheFactoryMock); + + $this->assertSame(0, $forVciCalls); + + $this->assertInstanceOf(Did::class, $didFactory->build()); + $this->assertSame(1, $forVciCalls); + } + + /** * An exemption is a destination that whoever supplies a DID may send this deployment to, so the * library notices it. The factory has to hand over a logger for that to be recorded anywhere. diff --git a/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php b/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php index 38ef18cb..575aedeb 100644 --- a/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php +++ b/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php @@ -90,7 +90,8 @@ protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->verifiableCredentialsMock = $this->createMock(VerifiableCredentialsService::class); - $this->didMock = $this->createMock(Did::class); + $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); + $this->didMock = $this->freshDidMock(); $this->nonceServiceMock = $this->createMock(NonceService::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); @@ -104,8 +105,6 @@ protected function setUp(): void $this->proofFactoryMock = $this->createMock(OpenId4VciProofFactory::class); $this->verifiableCredentialsMock->method('openId4VciProofFactory')->willReturn($this->proofFactoryMock); - $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); - $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); $this->didJwkResolverMock->method('generateDidJwkFromJwk')->willReturn(self::HOLDER_DID); // One call handles every DID method, so the resolvers behind it are no longer stubbed one by @@ -241,6 +240,24 @@ protected function proofMock(array $overrides = []): MockObject } + /** + * A `Did` mock carrying the stubs every test needs, whatever else it goes on to control. + * + * The registry among them: the binding policy is filtered against it before any resolution is + * attempted, so a test replacing this mock to steer one call and rebuilding it by hand would have + * its proof refused for naming an unaccepted method - and a test which expected a refusal anyway + * would keep passing while no longer exercising what it was written for. + */ + protected function freshDidMock(): MockObject + { + $didMock = $this->createMock(Did::class); + $didMock->method('supportedMethods')->willReturn(['did:jwk', 'did:key', 'did:web']); + $didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + + return $didMock; + } + + /** * @param array $overrides * @return array @@ -285,12 +302,20 @@ protected function assertRefusedWith( string $expectedErrorCode, array $requestData, VciCredentialBindingPolicyEnum $bindingPolicy = VciCredentialBindingPolicyEnum::ProofBound, + ?string $expectedMessageFragment = null, ): void { try { $this->sut()->validateRequest($requestData, $bindingPolicy, $this->accessTokenMock); } catch (CredentialRequestException $credentialRequestException) { $this->assertSame($expectedErrorCode, $credentialRequestException->getErrorCode()); + if (is_string($expectedMessageFragment)) { + $this->assertStringContainsString( + $expectedMessageFragment, + $credentialRequestException->getMessage(), + ); + } + return; } @@ -557,7 +582,7 @@ public function testRefusesAKeyIdWhichNamesOnlyTheDid(): void public function testRefusesADidWhichFailsToResolve(): void { - $this->didMock = $this->createMock(Did::class); + $this->didMock = $this->freshDidMock(); $this->didMock->method('resolveDocument') ->willThrowException(new DidException('could not be retrieved')); @@ -579,7 +604,7 @@ public function testRefusesAVerificationMethodTheDocumentDoesNotOffer(): void $didDocumentMock->method('resolveVerificationMethod') ->willThrowException(new DidException('not under that relationship')); - $this->didMock = $this->createMock(Did::class); + $this->didMock = $this->freshDidMock(); $this->didMock->method('resolveDocument')->willReturn($didDocumentMock); $this->assertRefusedWith('invalid_proof', $this->requestWith()); @@ -603,8 +628,7 @@ public function testResolvesUnderTheAuthenticationRelationship(): void ) ->willReturn($this->resolvedVerificationMethod(self::HOLDER_DID_URL)); - $this->didMock = $this->createMock(Did::class); - $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + $this->didMock = $this->freshDidMock(); // The whole request shares one deadline, so the fetch is bounded by when the request has to be // done rather than by the per-fetch timeout each proof would otherwise get to itself. $this->didMock->expects($this->once())->method('resolveDocument') @@ -1033,6 +1057,10 @@ public function testTheDiipRulesRefuseAHolderMethodTheProfileDoesNotName(): void 'invalid_proof', $this->requestWith(['getKeyId' => $didKey . '#z6Mkh']), VciCredentialBindingPolicyEnum::DiipProofBound, + // Naming what this configuration does accept, and naming it from the same source the + // metadata advertises, so a refusal cannot describe a different issuer than the published + // document does. + 'identified by did:jwk or did:web only', ); // Refused before the fetch, not after. Nothing is fetched for a did:key either way, but the From 972ed3a980c8ff9e7f58807f87bfca7e43d14904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 2 Sep 2026 11:13:32 +0200 Subject: [PATCH 08/15] Build DID resolution only where a request actually needs it --- .../CredentialIssuerCredentialController.php | 13 ++++-- src/Factories/DidFactory.php | 32 +++++++++++++++ .../OpenId4VciProofValidator.php | 40 +++++++++++++++++-- ...edentialIssuerCredentialControllerTest.php | 32 ++++++++++++++- tests/unit/src/Factories/DidFactoryTest.php | 28 +++++++++++++ .../OpenId4VciProofValidatorTest.php | 39 +++++++++++++++++- 6 files changed, 175 insertions(+), 9 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 54089078..0a9ad4d7 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Exceptions\CredentialRequestException; +use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; @@ -29,7 +30,6 @@ use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; -use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Exceptions\OpenIdException; use SimpleSAML\OpenID\TokenStatusList\StatusClaim; use SimpleSAML\OpenID\VerifiableCredentials; @@ -68,7 +68,13 @@ public function __construct( protected readonly LoggerService $loggerService, protected readonly RequestParamsResolver $requestParamsResolver, protected readonly UserRepository $userRepository, - protected readonly Did $did, + // The factory rather than the facade, so that the guard below is what answers a deployment with + // Verifiable Credentials switched off. The container resolves every argument here before this + // constructor body runs, and building the facade reads the DID destination settings and + // instantiates the VCI cache adapter - which turned that 403 into a 500 on a request this + // endpoint refuses outright. The only thing wanted of it is the issuer's own did:jwk, derived + // from a key already in hand, so nothing is built until there is a credential to sign. + protected readonly DidFactory $didFactory, protected readonly IssuerStateRepository $issuerStateRepository, protected readonly OpenId4VciProofValidator $openId4VciProofValidator, protected readonly VciContextResolver $vciContextResolver, @@ -605,7 +611,8 @@ public function credential(Request $request): Response $publicKey = $vciSignatureKeyPair->getKeyPair()->getPublicKey(); - $issuerDid = $this->did->didJwkResolver()->generateDidJwkFromJwk($publicKey->jwk()->all()); + $issuerDid = $this->didFactory->build()->didJwkResolver() + ->generateDidJwkFromJwk($publicKey->jwk()->all()); $issuedAt = new DateTimeImmutable(); diff --git a/src/Factories/DidFactory.php b/src/Factories/DidFactory.php index e663ac7f..234c594c 100644 --- a/src/Factories/DidFactory.php +++ b/src/Factories/DidFactory.php @@ -18,6 +18,18 @@ */ class DidFactory { + /** + * The facade this factory has already built, if it has. + * + * Building one instantiates the configured VCI cache adapter, which for a shared backend means + * opening a connection, so the callers within a request share a facade instead of each getting one + * of their own. The container held exactly one before any caller reached this factory directly; + * this is what keeps that true now that they do, and it is why a caller may build lazily without + * turning a single connection into one per collaborator. + */ + protected ?Did $did = null; + + /** * Note the configuration rather than a built policy. Building one throws when the configuration is * malformed, and the container reaches this factory while wiring up the admin Configuration screens - @@ -38,6 +50,13 @@ public function __construct( /** + * The DID facade for this request, built on the first call and returned as it stands afterwards. + * + * Reading configuration is deferred to here rather than to the constructor, so this is the point at + * which a malformed DID or cache setting becomes an error - which is the whole reason a caller with + * no use for DID resolution can hold this factory safely. See {@see $did} for why the result is kept + * rather than rebuilt. + * * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException On a cache adapter which can not be built. * @throws \SimpleSAML\OpenID\Exceptions\DidException @@ -47,6 +66,19 @@ public function __construct( * @throws \Exception */ public function build(): Did + { + return $this->did ??= $this->buildDid(); + } + + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + * @throws \SimpleSAML\OpenID\Exceptions\DidException + * @throws \SimpleSAML\OpenID\Exceptions\DestinationPolicyException + * @throws \Exception + */ + protected function buildDid(): Did { return new Did( maxCacheDuration: $this->moduleConfig->getVciDidCacheMaxDuration(), diff --git a/src/VerifiableCredentials/OpenId4VciProofValidator.php b/src/VerifiableCredentials/OpenId4VciProofValidator.php index ab01ebfa..cccf71f6 100644 --- a/src/VerifiableCredentials/OpenId4VciProofValidator.php +++ b/src/VerifiableCredentials/OpenId4VciProofValidator.php @@ -9,6 +9,7 @@ use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Exceptions\CredentialRequestException; +use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\NonceService; @@ -107,16 +108,47 @@ class OpenId4VciProofValidator ]; + /** The DID facade for this request, obtained on first use. {@see did()} */ + protected ?Did $did = null; + + + /** + * @param \SimpleSAML\Module\oidc\Factories\DidFactory $didFactory The factory rather than the facade + * it builds. This validator is a constructor argument of the Credential Endpoint controller, + * and the container resolves every such argument before that controller's constructor body + * runs - so taking a built facade here made a deployment with Verifiable Credentials switched + * off answer that endpoint by failing on DID settings it has no use for, instead of by the + * 403 the guard is there to give. Nothing about a proof is validated at construction, so + * nothing about DID needs building then either. + */ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly VerifiableCredentials $verifiableCredentials, - protected readonly Did $did, + protected readonly DidFactory $didFactory, protected readonly NonceService $nonceService, protected readonly LoggerService $loggerService, ) { } + /** + * The DID facade, built the first time a proof actually needs one. + * + * The factory keeps the built facade, so this is the same instance every other collaborator in the + * request gets rather than one more of its own. + * + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + * @throws \SimpleSAML\OpenID\Exceptions\DidException + * @throws \SimpleSAML\OpenID\Exceptions\DestinationPolicyException + * @throws \Exception + */ + protected function did(): Did + { + return $this->did ??= $this->didFactory->build(); + } + + /** * Validate everything a Credential Request says about holder binding. * @@ -491,7 +523,7 @@ protected function resolveKeySource( $this->assertPublicJwk($headerJwk); try { - $subject = $this->did->didJwkResolver()->generateDidJwkFromJwk($headerJwk); + $subject = $this->did()->didJwkResolver()->generateDidJwkFromJwk($headerJwk); } catch (JsonException) { throw new CredentialRequestException( 'invalid_proof', @@ -642,7 +674,7 @@ protected function resolveDocument( $didResolutionBudget->noteResolutionAttempt($didUrl); try { - return $this->did->resolveDocument( + return $this->did()->resolveDocument( $didUrl->getDid(), $didResolutionBudget->getDeadlineTimestamp(), ); @@ -698,7 +730,7 @@ protected function assertHolderDidMethodIsAccepted( // refusal. Asking the policy alone would accept a method it does not narrow but this deployment // can not resolve - refused a moment later by the resolver, but refused as an unresolvable DID // rather than as one this issuer never offered to accept. - $acceptedDidMethods = $bindingPolicy->acceptableDidMethodsFrom($this->did->supportedMethods()); + $acceptedDidMethods = $bindingPolicy->acceptableDidMethodsFrom($this->did()->supportedMethods()); if (in_array(DidUrl::PREFIX . $didUrl->getMethod(), $acceptedDidMethods, true)) { return; diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php index 5c311eca..c757f0bc 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php @@ -19,11 +19,13 @@ use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Exceptions\CredentialRequestException; use SimpleSAML\Module\oidc\Exceptions\StatusListException; +use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; +use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\StatusList\CredentialStatusIssuer; @@ -89,6 +91,8 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected MockObject $didMock; + protected MockObject $didFactoryMock; + protected MockObject $issuerStateRepositoryMock; protected MockObject $openId4VciProofValidatorMock; @@ -124,6 +128,8 @@ public function setUp(): void $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); $this->userRepositoryMock = $this->createMock(UserRepository::class); $this->didMock = $this->createMock(Did::class); + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->method('build')->willReturn($this->didMock); $this->issuerStateRepositoryMock = $this->createMock(IssuerStateRepository::class); $this->openId4VciProofValidatorMock = $this->createMock(OpenId4VciProofValidator::class); $this->vciContextResolverMock = $this->createMock(VciContextResolver::class); @@ -302,6 +308,30 @@ protected function dispatch(array $requestData): void } + /** + * A deployment with Verifiable Credentials switched off is refused here, and refused without the DID + * facade being built. + * + * Building it reads the DID destination settings and instantiates the class `vci_cache_adapter` + * names - neither of which such a deployment has any reason to have configured correctly, or at all. + * The container resolves every constructor argument before the constructor body runs, so taking a + * built facade meant a malformed one of those settings answered this endpoint in place of the guard, + * with a 500 where a 403 was intended. + */ + public function testRefusesWhenVciIsDisabledWithoutBuildingTheDidFacade(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->expects($this->never())->method('build'); + + $this->expectException(OidcServerException::class); + + $this->sut(); + } + + protected function sut(): CredentialIssuerCredentialController { return new CredentialIssuerCredentialController( @@ -314,7 +344,7 @@ protected function sut(): CredentialIssuerCredentialController $this->loggerServiceMock, $this->requestParamsResolverMock, $this->userRepositoryMock, - $this->didMock, + $this->didFactoryMock, $this->issuerStateRepositoryMock, $this->openId4VciProofValidatorMock, $this->vciContextResolverMock, diff --git a/tests/unit/src/Factories/DidFactoryTest.php b/tests/unit/src/Factories/DidFactoryTest.php index 898732b0..47dc4d42 100644 --- a/tests/unit/src/Factories/DidFactoryTest.php +++ b/tests/unit/src/Factories/DidFactoryTest.php @@ -175,6 +175,34 @@ function () use (&$forVciCalls): ?VciCache { } + /** + * One facade per request, however many collaborators ask for it. + * + * Callers build lazily rather than being handed a built facade, so several of them reach this + * factory within one request. Building each their own would instantiate the configured cache adapter + * once per caller, which for a shared backend means a connection each - where the container held + * exactly one facade before any of them called this directly. + */ + public function testTheFacadeIsBuiltOnceAndSharedByEveryCaller(): void + { + $forVciCalls = 0; + + $cacheFactoryMock = $this->createMock(CacheFactory::class); + $cacheFactoryMock->method('forVci')->willReturnCallback( + function () use (&$forVciCalls): ?VciCache { + $forVciCalls++; + + return null; + }, + ); + + $didFactory = new DidFactory($this->moduleConfigMock, $this->loggerServiceMock, $cacheFactoryMock); + + $this->assertSame($didFactory->build(), $didFactory->build()); + $this->assertSame(1, $forVciCalls); + } + + /** * An exemption is a destination that whoever supplies a DID may send this deployment to, so the * library notices it. The factory has to hand over a logger for that to be recorded anywhere. diff --git a/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php b/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php index 575aedeb..f4abac72 100644 --- a/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php +++ b/tests/unit/src/VerifiableCredentials/OpenId4VciProofValidatorTest.php @@ -13,6 +13,7 @@ use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Exceptions\CredentialRequestException; +use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\NonceService; @@ -188,12 +189,48 @@ protected function resolvedVerificationMethod(string $didUrl): ResolvedVerificat } + /** + * Constructing this validator builds no DID facade. + * + * It is a constructor argument of the Credential Endpoint controller, so the container builds it + * before that controller's own constructor body can refuse a deployment with Verifiable Credentials + * switched off. Holding a built facade here therefore made the DID and cache settings decide the + * answer to a request that endpoint refuses outright. + */ + public function testBuildsNoDidFacadeUntilAProofNeedsOne(): void + { + $didFactoryMock = $this->createMock(DidFactory::class); + $didFactoryMock->expects($this->never())->method('build'); + + $validator = new OpenId4VciProofValidator( + $this->moduleConfigMock, + $this->verifiableCredentialsMock, + $didFactoryMock, + $this->nonceServiceMock, + $this->loggerServiceMock, + ); + + // Nor does a configuration which validates no proof at all, though it goes the whole way + // through a request. + $validator->validateRequest( + ['credential_configuration_id' => 'test'], + VciCredentialBindingPolicyEnum::Proofless, + $this->accessTokenMock, + ); + } + + protected function sut(): OpenId4VciProofValidator { + $didFactoryMock = $this->createMock(DidFactory::class); + // Resolved when the validator asks rather than when this is wired up, since a test which + // replaces the facade mock to steer one call does so before reaching for the subject. + $didFactoryMock->method('build')->willReturnCallback(fn(): Did => $this->didMock); + return new OpenId4VciProofValidator( $this->moduleConfigMock, $this->verifiableCredentialsMock, - $this->didMock, + $didFactoryMock, $this->nonceServiceMock, $this->loggerServiceMock, ); From 74453ca1b96a4c443e9f4283cb2c73aadeb261f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 2 Sep 2026 15:11:40 +0200 Subject: [PATCH 09/15] Require the openid release that can build a DID document --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c18e7f45..557b57cd 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "psr/log": "^3", "psr/simple-cache": "^3", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~0.7.0", + "simplesamlphp/openid": "~0.8.0", "simplesamlphp/simplesamlphp": "^2.5.3.1", "symfony/cache": "^7.4", "symfony/expression-language": "^7.4", From 20be7d6b60ec416da943e10739b40b9f8a58dc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 2 Sep 2026 17:18:32 +0200 Subject: [PATCH 10/15] Issue credentials under a configurable identity and publish its DID document --- config/module_oidc.php.dist | 71 ++++++ docs/3-oidc-configuration.md | 100 ++++++++ locales/en/LC_MESSAGES/oidc.po | 78 +++++++ locales/es/LC_MESSAGES/oidc.po | 78 +++++++ locales/fr/LC_MESSAGES/oidc.po | 78 +++++++ locales/hr/LC_MESSAGES/oidc.po | 78 +++++++ locales/it/LC_MESSAGES/oidc.po | 78 +++++++ locales/nl/LC_MESSAGES/oidc.po | 78 +++++++ routing/routes/routes.php | 12 + .../ConfigOverview/VciOverviewBuilder.php | 207 ++++++++++++++++ src/Codebooks/RoutesEnum.php | 12 + src/Codebooks/VciIssuerIdentifierModeEnum.php | 45 ++++ src/Controllers/JwksController.php | 38 ++- .../CredentialIssuerCredentialController.php | 72 ++++-- .../JwtVcIssuerConfigurationController.php | 25 +- .../VciDidDocumentController.php | 136 +++++++++++ src/ModuleConfig.php | 122 ++++++++++ .../VciIssuerIdentityRepository.php | 145 ++++++++++++ src/Services/DatabaseMigration.php | 25 ++ src/Utils/Routes.php | 9 + .../Values/VciIssuerIdentifier.php | 81 +++++++ .../Values/VciIssuerIdentity.php | 49 ++++ .../VciIssuerIdentityResolver.php | 130 +++++++++++ .../ConfigOverview/VciOverviewBuilderTest.php | 191 +++++++++++++++ .../ConfigOverview/VciOverviewTestTrait.php | 20 +- .../src/Controllers/JwksControllerTest.php | 59 +++++ ...edentialIssuerCredentialControllerTest.php | 55 +++-- ...JwtVcIssuerConfigurationControllerTest.php | 116 +++++++++ .../VciDidDocumentControllerTest.php | 221 ++++++++++++++++++ tests/unit/src/ModuleConfigTest.php | 152 ++++++++++++ .../VciIssuerIdentityRepositoryTest.php | 148 ++++++++++++ .../Values/VciIssuerIdentifierTest.php | 62 +++++ .../Values/VciIssuerIdentityTest.php | 27 +++ .../VciIssuerIdentityResolverTest.php | 211 +++++++++++++++++ 34 files changed, 2960 insertions(+), 49 deletions(-) create mode 100644 src/Codebooks/VciIssuerIdentifierModeEnum.php create mode 100644 src/Controllers/VerifiableCredentials/VciDidDocumentController.php create mode 100644 src/Repositories/VciIssuerIdentityRepository.php create mode 100644 src/VerifiableCredentials/Values/VciIssuerIdentifier.php create mode 100644 src/VerifiableCredentials/Values/VciIssuerIdentity.php create mode 100644 src/VerifiableCredentials/VciIssuerIdentityResolver.php create mode 100644 tests/unit/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationControllerTest.php create mode 100644 tests/unit/src/Controllers/VerifiableCredentials/VciDidDocumentControllerTest.php create mode 100644 tests/unit/src/Repositories/VciIssuerIdentityRepositoryTest.php create mode 100644 tests/unit/src/VerifiableCredentials/Values/VciIssuerIdentifierTest.php create mode 100644 tests/unit/src/VerifiableCredentials/Values/VciIssuerIdentityTest.php create mode 100644 tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 883087fd..b26f0832 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1120,6 +1120,77 @@ $config = [ ], ], + /** + * How issued credentials name the issuer which signed them, in the `iss` + * claim and the `kid` header. Default is `did_jwk`. + * + * - VciIssuerIdentifierModeEnum::DidJwk ('did_jwk'): a `did:jwk` derived from + * the active signing key. The credential carries the key with it and so + * verifies with no lookup at all, but nothing ties the DID to this + * deployment; whoever verifies it has to establish that by other means. + * - VciIssuerIdentifierModeEnum::DidWeb ('did_web'): the identifier set in + * OPTION_VCI_ISSUER_DID_IDENTIFIER, which must then be set. Resolvable by + * anyone and bound to a domain name, which is what the DIIP profile asks + * of an issuer. + * - VciIssuerIdentifierModeEnum::Https ('https'): this module's issuer URL, + * with the signing key named by its key set identifier and resolved + * through the published JWKS. This is what makes the + * `.well-known/jwt-vc-issuer` document meaningful, and a way out for + * verifiers which will not accept a DID. It is NOT DIIP conformant: the + * profile requires the issuer to be identified by a DID. + * + * Read when a credential is signed, so a change here reaches newly issued + * credentials only. The ones already in wallets keep naming the identity + * they were issued under, which is why moving away from `did_web` does not + * on its own stop the DID document being published - see the option below. + * + * Under `https` the same obligation falls on the key set rather than on a + * document: a credential names its signing key by its JWKS key ID, so it is + * verifiable only while that key is still published. So while this says + * `https`, the VCI keys stay in JWKS and `.well-known/jwt-vc-issuer` keeps + * being served even when OPTION_VCI_ENABLED is false. **Moving off `https` + * withdraws both**, and credentials issued under it stop verifying at that + * point - so make that change only once they have expired, or serve their + * key set by other means. The Verifiable Credential configuration screen + * lists which identities credentials were actually issued under, which is + * what configuration alone cannot tell you. + */ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => + \SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum::DidJwk, + + /** + * (optional) The `did:web` identifier this deployment publishes a DID + * document for. Required when OPTION_VCI_ISSUER_IDENTIFIER_MODE is + * `did_web`. Under the other modes it is optional, and **keep it set** for + * as long as credentials issued under it still need to be verified - see + * the lifecycle note below. + * + * The document is served at the module's `did.json` route, and is served + * whenever this option is set - including when the mode above has moved on + * to something else. That is deliberate: a credential issued under a + * `did:web` identity can only be verified by resolving that DID, so + * withdrawing the document the moment the mode changes would make every + * credential issued under it permanently unverifiable. Keep this set to go + * on answering for what was issued earlier; remove it to retire the + * identity, which is a decision rather than a side effect. + * + * Configured rather than derived, because the `did:web` method decides the + * document URL from the identifier alone. `did:web:example.org` resolves to + * `https://example.org/.well-known/did.json`, at the web root, which + * SimpleSAMLphp does not serve; deriving one from the module URL would + * instead give something like + * `did:web:example.org:simplesaml:module.php:oidc`, which no deployment + * would choose to put into its credentials. Either way, whatever URL the + * identifier resolves to has to be made to reach the module's `did.json` + * route, typically with a rewrite or a reverse proxy. The Verifiable + * Credential configuration overview screen shows both URLs and says when + * they differ. + * + * Example: + * 'did:web:example.org' + */ + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => null, + /** * Allow or disallow non-registered clients to request verifiable * credentials. Default is disallowed (false). diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index d46e3128..2a00a19c 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -78,6 +78,8 @@ There you can see discovery URLs. Typical discovery endpoints are: [https://yourserver/simplesaml/module.php/oidc/.well-known/oauth-authorization-server](https://yourserver/simplesaml/module.php/oidc/.well-known/oauth-authorization-server) - JWT VC Issuer configuration: [https://yourserver/simplesaml/module.php/oidc/.well-known/jwt-vc-issuer](https://yourserver/simplesaml/module.php/oidc/.well-known/jwt-vc-issuer) +- DID document, when a `did:web` issuer identifier is configured: +[https://yourserver/simplesaml/module.php/oidc/did.json](https://yourserver/simplesaml/module.php/oidc/did.json) You may publish these as ".well-known" URLs at the web root using your web server. For example, for `openid-configuration`: @@ -229,6 +231,104 @@ runs out. These settings are shown in the admin area under `OIDC` > `Configuration`, on the VCI screen. +## Issuer identity and the DID document + +A credential says who issued it in its `iss` claim and which key signed it in its +`kid` header, and whoever verifies it has to be able to resolve both. +`OPTION_VCI_ISSUER_IDENTIFIER_MODE` decides how: + +- `did_jwk` (default): `iss` is a `did:jwk` derived from the active signing key + and `kid` is that DID with the `#0` fragment. The credential carries the key + with it, so it verifies with no lookup at all. Nothing ties the DID to this + deployment, though; a verifier has to establish that by other means. +- `did_web`: `iss` is the identifier set in `OPTION_VCI_ISSUER_DID_IDENTIFIER` + and `kid` names one verification method inside the DID document this module + publishes. Resolvable by anyone and bound to a domain name, which is what the + DIIP profile asks of an issuer. +- `https`: `iss` is this module's issuer URL and `kid` is the key's JWKS key ID, + so the key is resolved through the published key set. This is what makes the + `.well-known/jwt-vc-issuer` document meaningful, and a way out for verifiers + which will not accept a DID. **It is not DIIP conformant**, since the profile + requires the issuer to be identified by a DID. + +The mode is read when a credential is signed, so changing it reaches newly issued +credentials only. Credentials already in wallets go on naming the identity they +were issued under — which is what the retention rules below are about. + +### Keeping the `https` identity resolvable + +Under `https` a credential names its signing key by its JWKS key ID, so it is +verifiable only while that key is still published. Two endpoints therefore stop +following `OPTION_VCI_ENABLED` while this mode is selected: the VCI keys stay in +the published JWKS, and `.well-known/jwt-vc-issuer` — which is how an SD-JWT VC +verifier finds that key set — keeps being served. Turning issuance off stops new +credentials being issued without making the existing ones unverifiable, the same +guarantee the Status List and DID document endpoints give. + +**Moving off `https` withdraws both.** Unlike `did:web`, there is no separate +option to keep serving under: the identity is the issuer URL, which every mode +has. Credentials issued under `https` therefore stop verifying when the mode +changes, so make that change only once they have expired, or arrange to serve +their key set by other means. + +### Serving the DID document + +The document is served at `.../module.php/oidc/did.json`, and lists **every** key +configured under `OPTION_VCI_SIGNATURE_KEY_PAIRS` — not only the pair currently +signing — under both `verificationMethod` and `assertionMethod`. That is the same +reasoning as the JWKS document: a key which signed a credential still in +circulation has to stay resolvable, so a pair displaced by a rollover must remain +configured. Each verification method is named by its key ID, so the `kid` in a +credential stays stable across restarts and reordering. + +The `did:web` method decides the document's URL from the identifier alone, which +is why the identifier is configured rather than derived. `did:web:example.org` +resolves to `https://example.org/.well-known/did.json`, at the web root, which +SimpleSAMLphp does not serve; deriving one from the module URL would instead give +`did:web:example.org:simplesaml:module.php:oidc`, which no deployment would +choose to put into its credentials. Either way, whatever URL your identifier +resolves to has to be made to reach `did.json`, the same way the well-known URLs +above are: + +nginx: + +```nginx +location = /.well-known/did.json { + rewrite ^(.*)$ /simplesaml/module.php/oidc/did.json break; + proxy_pass https://localhost; +} +``` + +The VCI configuration screen shows both URLs — the one the identifier resolves to +and the one this module serves — and says so when they differ. + +Like the Status List endpoint, this one is **not** gated on +`OPTION_VCI_ENABLED`. Turning issuance off has to stop new credentials being +issued, not make the existing ones unverifiable. + +### Changing or retiring a `did:web` identity + +A credential naming a `did:web` identity can only be verified by resolving that +DID. If the document stops being served, the signature can never be checked +again — the credential is unverifiable, not merely unbound — and credentials do +not expire unless `OPTION_VCI_CREDENTIAL_TTLS` gives them a lifetime. + +So the document is published **whenever `OPTION_VCI_ISSUER_DID_IDENTIFIER` is +set**, including when the mode has moved on to `did_jwk` or `https`. In that +state nothing new is issued under the DID, but what was issued under it earlier +keeps verifying. Removing the option is what retires the identity, and that is +then a decision rather than a side effect of changing the mode. + +To move to a different `did:web` identity while keeping the old one resolvable, +the old document has to be served by other means: it is a static JSON file, so +fetch it from `did.json` before changing the option and park it at the URL the +old identifier resolves to. + +The VCI configuration screen lists every identity this deployment has actually +issued credentials under, and warns when one of them is a `did:web` it no longer +publishes. Configuration alone cannot answer that question, which is why it is +recorded as credentials are issued. + ## Holder binding and the DIIP profile A credential configuration decides for itself whether the credentials it issues diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index c056296f..2f8d1992 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -2353,3 +2353,81 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" + +msgid "Issuer Identity Mode" +msgstr "" + +msgid "" +"Credentials name a did:jwk derived from the active signing key, so they " +"carry that key with them and verify without any lookup. Whoever verifies " +"one must still bind the DID to this issuer by their own means." +msgstr "" + +msgid "" +"Credentials name the configured did:web below, and can be verified only " +"while the DID document for it is published and reachable." +msgstr "" + +msgid "" +"Credentials name this issuer by its URL and their signing key by its key " +"set identifier, which is what makes the JWT VC Issuer configuration " +"meaningful." +msgstr "" + +msgid "" +"This mode is not DIIP conformant on its own: the profile requires the " +"issuer to be identified by a Decentralized Identifier." +msgstr "" + +msgid "Issuer did:web Identifier" +msgstr "" + +msgid "No DID document is published." +msgstr "" + +msgid "" +"Newly issued credentials are issued under this identity, and the DID " +"document for it is published." +msgstr "" + +msgid "" +"Nothing is issued under this identity any more, but its DID document is " +"still published so that credentials issued under it earlier stay " +"verifiable. Removing this option retires the identity and withdraws the " +"document." +msgstr "" + +msgid "" +"The URL this identifier resolves to is not the one this module serves the " +"document at, so something in front of it has to map the first to the " +"second. Until it does, nothing can resolve this identity and credentials " +"naming it can not be verified." +msgstr "" + +msgid "Identities Credentials Were Issued Under" +msgstr "" + +msgid "" +"This could not be read, so a change of issuer identity can not be reported " +"here. The reason was written to the SimpleSAMLphp log." +msgstr "" + +msgid "None recorded" +msgstr "" + +msgid "" +"No credential has been issued yet, or every one of them was issued before " +"this was recorded." +msgstr "" + +msgid "" +"Credentials naming any of these have been issued, and credentials do not " +"expire unless a lifetime is configured for them." +msgstr "" + +msgid "" +"A did:web identity listed here is no longer the configured one, so its DID " +"document is no longer published and every credential issued under it can no " +"longer be verified. Set it as the did:web identifier again to resume " +"publishing it, or serve the document it needs by other means." +msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index 11b48bef..315c4de2 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -2353,3 +2353,81 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" + +msgid "Issuer Identity Mode" +msgstr "" + +msgid "" +"Credentials name a did:jwk derived from the active signing key, so they " +"carry that key with them and verify without any lookup. Whoever verifies " +"one must still bind the DID to this issuer by their own means." +msgstr "" + +msgid "" +"Credentials name the configured did:web below, and can be verified only " +"while the DID document for it is published and reachable." +msgstr "" + +msgid "" +"Credentials name this issuer by its URL and their signing key by its key " +"set identifier, which is what makes the JWT VC Issuer configuration " +"meaningful." +msgstr "" + +msgid "" +"This mode is not DIIP conformant on its own: the profile requires the " +"issuer to be identified by a Decentralized Identifier." +msgstr "" + +msgid "Issuer did:web Identifier" +msgstr "" + +msgid "No DID document is published." +msgstr "" + +msgid "" +"Newly issued credentials are issued under this identity, and the DID " +"document for it is published." +msgstr "" + +msgid "" +"Nothing is issued under this identity any more, but its DID document is " +"still published so that credentials issued under it earlier stay " +"verifiable. Removing this option retires the identity and withdraws the " +"document." +msgstr "" + +msgid "" +"The URL this identifier resolves to is not the one this module serves the " +"document at, so something in front of it has to map the first to the " +"second. Until it does, nothing can resolve this identity and credentials " +"naming it can not be verified." +msgstr "" + +msgid "Identities Credentials Were Issued Under" +msgstr "" + +msgid "" +"This could not be read, so a change of issuer identity can not be reported " +"here. The reason was written to the SimpleSAMLphp log." +msgstr "" + +msgid "None recorded" +msgstr "" + +msgid "" +"No credential has been issued yet, or every one of them was issued before " +"this was recorded." +msgstr "" + +msgid "" +"Credentials naming any of these have been issued, and credentials do not " +"expire unless a lifetime is configured for them." +msgstr "" + +msgid "" +"A did:web identity listed here is no longer the configured one, so its DID " +"document is no longer published and every credential issued under it can no " +"longer be verified. Set it as the did:web identifier again to resume " +"publishing it, or serve the document it needs by other means." +msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index 25eb4432..d833ca14 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -2353,3 +2353,81 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" + +msgid "Issuer Identity Mode" +msgstr "" + +msgid "" +"Credentials name a did:jwk derived from the active signing key, so they " +"carry that key with them and verify without any lookup. Whoever verifies " +"one must still bind the DID to this issuer by their own means." +msgstr "" + +msgid "" +"Credentials name the configured did:web below, and can be verified only " +"while the DID document for it is published and reachable." +msgstr "" + +msgid "" +"Credentials name this issuer by its URL and their signing key by its key " +"set identifier, which is what makes the JWT VC Issuer configuration " +"meaningful." +msgstr "" + +msgid "" +"This mode is not DIIP conformant on its own: the profile requires the " +"issuer to be identified by a Decentralized Identifier." +msgstr "" + +msgid "Issuer did:web Identifier" +msgstr "" + +msgid "No DID document is published." +msgstr "" + +msgid "" +"Newly issued credentials are issued under this identity, and the DID " +"document for it is published." +msgstr "" + +msgid "" +"Nothing is issued under this identity any more, but its DID document is " +"still published so that credentials issued under it earlier stay " +"verifiable. Removing this option retires the identity and withdraws the " +"document." +msgstr "" + +msgid "" +"The URL this identifier resolves to is not the one this module serves the " +"document at, so something in front of it has to map the first to the " +"second. Until it does, nothing can resolve this identity and credentials " +"naming it can not be verified." +msgstr "" + +msgid "Identities Credentials Were Issued Under" +msgstr "" + +msgid "" +"This could not be read, so a change of issuer identity can not be reported " +"here. The reason was written to the SimpleSAMLphp log." +msgstr "" + +msgid "None recorded" +msgstr "" + +msgid "" +"No credential has been issued yet, or every one of them was issued before " +"this was recorded." +msgstr "" + +msgid "" +"Credentials naming any of these have been issued, and credentials do not " +"expire unless a lifetime is configured for them." +msgstr "" + +msgid "" +"A did:web identity listed here is no longer the configured one, so its DID " +"document is no longer published and every credential issued under it can no " +"longer be verified. Set it as the did:web identifier again to resume " +"publishing it, or serve the document it needs by other means." +msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index 2e489c0c..b98fbf78 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -2400,3 +2400,81 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" + +msgid "Issuer Identity Mode" +msgstr "" + +msgid "" +"Credentials name a did:jwk derived from the active signing key, so they " +"carry that key with them and verify without any lookup. Whoever verifies " +"one must still bind the DID to this issuer by their own means." +msgstr "" + +msgid "" +"Credentials name the configured did:web below, and can be verified only " +"while the DID document for it is published and reachable." +msgstr "" + +msgid "" +"Credentials name this issuer by its URL and their signing key by its key " +"set identifier, which is what makes the JWT VC Issuer configuration " +"meaningful." +msgstr "" + +msgid "" +"This mode is not DIIP conformant on its own: the profile requires the " +"issuer to be identified by a Decentralized Identifier." +msgstr "" + +msgid "Issuer did:web Identifier" +msgstr "" + +msgid "No DID document is published." +msgstr "" + +msgid "" +"Newly issued credentials are issued under this identity, and the DID " +"document for it is published." +msgstr "" + +msgid "" +"Nothing is issued under this identity any more, but its DID document is " +"still published so that credentials issued under it earlier stay " +"verifiable. Removing this option retires the identity and withdraws the " +"document." +msgstr "" + +msgid "" +"The URL this identifier resolves to is not the one this module serves the " +"document at, so something in front of it has to map the first to the " +"second. Until it does, nothing can resolve this identity and credentials " +"naming it can not be verified." +msgstr "" + +msgid "Identities Credentials Were Issued Under" +msgstr "" + +msgid "" +"This could not be read, so a change of issuer identity can not be reported " +"here. The reason was written to the SimpleSAMLphp log." +msgstr "" + +msgid "None recorded" +msgstr "" + +msgid "" +"No credential has been issued yet, or every one of them was issued before " +"this was recorded." +msgstr "" + +msgid "" +"Credentials naming any of these have been issued, and credentials do not " +"expire unless a lifetime is configured for them." +msgstr "" + +msgid "" +"A did:web identity listed here is no longer the configured one, so its DID " +"document is no longer published and every credential issued under it can no " +"longer be verified. Set it as the did:web identifier again to resume " +"publishing it, or serve the document it needs by other means." +msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index 4daaff10..bd160c54 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -2353,3 +2353,81 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" + +msgid "Issuer Identity Mode" +msgstr "" + +msgid "" +"Credentials name a did:jwk derived from the active signing key, so they " +"carry that key with them and verify without any lookup. Whoever verifies " +"one must still bind the DID to this issuer by their own means." +msgstr "" + +msgid "" +"Credentials name the configured did:web below, and can be verified only " +"while the DID document for it is published and reachable." +msgstr "" + +msgid "" +"Credentials name this issuer by its URL and their signing key by its key " +"set identifier, which is what makes the JWT VC Issuer configuration " +"meaningful." +msgstr "" + +msgid "" +"This mode is not DIIP conformant on its own: the profile requires the " +"issuer to be identified by a Decentralized Identifier." +msgstr "" + +msgid "Issuer did:web Identifier" +msgstr "" + +msgid "No DID document is published." +msgstr "" + +msgid "" +"Newly issued credentials are issued under this identity, and the DID " +"document for it is published." +msgstr "" + +msgid "" +"Nothing is issued under this identity any more, but its DID document is " +"still published so that credentials issued under it earlier stay " +"verifiable. Removing this option retires the identity and withdraws the " +"document." +msgstr "" + +msgid "" +"The URL this identifier resolves to is not the one this module serves the " +"document at, so something in front of it has to map the first to the " +"second. Until it does, nothing can resolve this identity and credentials " +"naming it can not be verified." +msgstr "" + +msgid "Identities Credentials Were Issued Under" +msgstr "" + +msgid "" +"This could not be read, so a change of issuer identity can not be reported " +"here. The reason was written to the SimpleSAMLphp log." +msgstr "" + +msgid "None recorded" +msgstr "" + +msgid "" +"No credential has been issued yet, or every one of them was issued before " +"this was recorded." +msgstr "" + +msgid "" +"Credentials naming any of these have been issued, and credentials do not " +"expire unless a lifetime is configured for them." +msgstr "" + +msgid "" +"A did:web identity listed here is no longer the configured one, so its DID " +"document is no longer published and every credential issued under it can no " +"longer be verified. Set it as the did:web identifier again to resume " +"publishing it, or serve the document it needs by other means." +msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 78208e0a..f9265919 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -2307,3 +2307,81 @@ msgid "" "methods and proof types it accepts, and a Credential Request carrying no " "valid key proof is refused." msgstr "" + +msgid "Issuer Identity Mode" +msgstr "" + +msgid "" +"Credentials name a did:jwk derived from the active signing key, so they " +"carry that key with them and verify without any lookup. Whoever verifies " +"one must still bind the DID to this issuer by their own means." +msgstr "" + +msgid "" +"Credentials name the configured did:web below, and can be verified only " +"while the DID document for it is published and reachable." +msgstr "" + +msgid "" +"Credentials name this issuer by its URL and their signing key by its key " +"set identifier, which is what makes the JWT VC Issuer configuration " +"meaningful." +msgstr "" + +msgid "" +"This mode is not DIIP conformant on its own: the profile requires the " +"issuer to be identified by a Decentralized Identifier." +msgstr "" + +msgid "Issuer did:web Identifier" +msgstr "" + +msgid "No DID document is published." +msgstr "" + +msgid "" +"Newly issued credentials are issued under this identity, and the DID " +"document for it is published." +msgstr "" + +msgid "" +"Nothing is issued under this identity any more, but its DID document is " +"still published so that credentials issued under it earlier stay " +"verifiable. Removing this option retires the identity and withdraws the " +"document." +msgstr "" + +msgid "" +"The URL this identifier resolves to is not the one this module serves the " +"document at, so something in front of it has to map the first to the " +"second. Until it does, nothing can resolve this identity and credentials " +"naming it can not be verified." +msgstr "" + +msgid "Identities Credentials Were Issued Under" +msgstr "" + +msgid "" +"This could not be read, so a change of issuer identity can not be reported " +"here. The reason was written to the SimpleSAMLphp log." +msgstr "" + +msgid "None recorded" +msgstr "" + +msgid "" +"No credential has been issued yet, or every one of them was issued before " +"this was recorded." +msgstr "" + +msgid "" +"Credentials naming any of these have been issued, and credentials do not " +"expire unless a lifetime is configured for them." +msgstr "" + +msgid "" +"A did:web identity listed here is no longer the configured one, so its DID " +"document is no longer published and every credential issued under it can no " +"longer be verified. Set it as the did:web identifier again to resume " +"publishing it, or serve the document it needs by other means." +msgstr "" diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 4bfd3863..95c10d14 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -31,6 +31,7 @@ use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialJsonLdContextController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\JwtVcIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\NonceController; +use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\VciDidDocumentController; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -186,6 +187,17 @@ ->controller([JwtVcIssuerConfigurationController::class, 'configuration']) ->methods([HttpMethodsEnum::GET->value]); + /***************************************************************************************************************** + * Decentralized Identifiers + ****************************************************************************************************************/ + + // Not registered under the Verifiable Credential Issuance switch, on purpose: a credential issued + // under a did:web identity names its signing key inside this document, so it stays verifiable only + // while this keeps answering. + $routes->add(RoutesEnum::VciDidDocument->name, RoutesEnum::VciDidDocument->value) + ->controller([VciDidDocumentController::class, 'didDocument']) + ->methods([HttpMethodsEnum::GET->value]); + /***************************************************************************************************************** * API ****************************************************************************************************************/ diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index ee011423..3f4b07b9 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -8,13 +8,19 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Codebooks\ConfigOverviewValueTypeEnum; use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; +use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\VciIssuerIdentityRepository; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; +use SimpleSAML\Module\oidc\Utils\DateIntervalFormatter; +use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\AddressPinningModeEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; +use SimpleSAML\OpenID\Did\DidWebResolver; use SimpleSAML\OpenID\Network\DestinationPolicy; use Stringable; use Throwable; @@ -47,6 +53,23 @@ class VciOverviewBuilder extends AbstractOverviewBuilder ]; + /** + * The repository is read only here, and the Configuration screens already resolve a database + * connection through DatabaseMigration, so this adds no failure mode they did not have. The rows + * which use it still catch for themselves, since these screens exist to be readable while + * something is broken. + */ + public function __construct( + ModuleConfig $moduleConfig, + Routes $routes, + DateIntervalFormatter $dateIntervalFormatter, + LoggerService $logger, + protected readonly VciIssuerIdentityRepository $vciIssuerIdentityRepository, + ) { + parent::__construct($moduleConfig, $routes, $dateIntervalFormatter, $logger); + } + + /** * @return \SimpleSAML\Module\oidc\Admin\ConfigOverview\Section[] * @throws \Exception @@ -345,6 +368,9 @@ function (): Row { 'the OP is reached, which breaks already issued credential offers.', ), ), + $this->buildIssuerIdentityModeRow(), + $this->buildIssuerDidIdentifierRow(), + $this->buildIssuedIdentitiesRow(), new Row( Translate::noop('Credential Issuer Configuration URL'), $this->routes->urlCredentialIssuerConfiguration(), @@ -359,6 +385,187 @@ function (): Row { } + /** + * Which identity newly issued credentials are signed under. + */ + protected function buildIssuerIdentityModeRow(): Row + { + $label = Translate::noop('Issuer Identity Mode'); + + return $this->guardRow( + $label, + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + function () use ($label): Row { + $mode = $this->moduleConfig->getVciIssuerIdentifierMode(); + + return new Row( + $label, + $mode->value, + ConfigOverviewValueTypeEnum::RawText, + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + match ($mode) { + VciIssuerIdentifierModeEnum::DidJwk => Translate::noop( + 'Credentials name a did:jwk derived from the active signing key, so they ' . + 'carry that key with them and verify without any lookup. Whoever verifies ' . + 'one must still bind the DID to this issuer by their own means.', + ), + VciIssuerIdentifierModeEnum::DidWeb => Translate::noop( + 'Credentials name the configured did:web below, and can be verified only ' . + 'while the DID document for it is published and reachable.', + ), + VciIssuerIdentifierModeEnum::Https => Translate::noop( + 'Credentials name this issuer by its URL and their signing key by its key ' . + 'set identifier, which is what makes the JWT VC Issuer configuration ' . + 'meaningful.', + ), + }, + $mode === VciIssuerIdentifierModeEnum::Https ? Translate::noop( + 'This mode is not DIIP conformant on its own: the profile requires the issuer ' . + 'to be identified by a Decentralized Identifier.', + ) : null, + ); + }, + ); + } + + + /** + * The did:web this deployment publishes a document for, and where that document has to be served. + * + * Both URLs are shown rather than only the module's own, because the did:web method decides the + * first one from the identifier alone: a deployment whose DID carries no path is asking for a URL + * at the web root, which SimpleSAMLphp never serves, so something in front has to map one to the + * other. Comparing hosts would not catch it, since the path segments decide the URL too. + */ + protected function buildIssuerDidIdentifierRow(): Row + { + $label = Translate::noop('Issuer did:web Identifier'); + + return $this->guardRow( + $label, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + function () use ($label): Row { + $identifier = $this->moduleConfig->getVciIssuerIdentifier(); + $didWeb = $identifier->getDidWeb(); + + if (is_null($didWeb)) { + return new Row( + $label, + Translate::noop('None configured'), + ConfigOverviewValueTypeEnum::Text, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + Translate::noop('No DID document is published.'), + ); + } + + $documentUrl = DidWebResolver::documentUrlFor($didWeb); + $servedAt = $this->routes->urlVciDidDocument(); + + return new Row( + $label, + [ + 'did' => $didWeb, + 'resolvesTo' => $documentUrl, + 'servedAt' => $servedAt, + ], + ConfigOverviewValueTypeEnum::Json, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + $identifier->isIssuingUnderDidWeb() ? + Translate::noop( + 'Newly issued credentials are issued under this identity, and the DID document ' . + 'for it is published.', + ) : + Translate::noop( + 'Nothing is issued under this identity any more, but its DID document is still ' . + 'published so that credentials issued under it earlier stay verifiable. ' . + 'Removing this option retires the identity and withdraws the document.', + ), + $documentUrl === $servedAt ? null : Translate::noop( + 'The URL this identifier resolves to is not the one this module serves the ' . + 'document at, so something in front of it has to map the first to the second. ' . + 'Until it does, nothing can resolve this identity and credentials naming it ' . + 'can not be verified.', + ), + ); + }, + ); + } + + + /** + * Which identities this deployment has actually issued credentials under. + * + * Configuration cannot answer this, and it is the question which matters: a did:web identity that + * was issued under and is no longer published leaves those credentials unverifiable, and nothing + * else would report it. Only did:web is flagged, since a did:jwk credential carries its own key + * and an issuer URL keeps resolving through the published key set. + */ + protected function buildIssuedIdentitiesRow(): Row + { + $label = Translate::noop('Identities Credentials Were Issued Under'); + + try { + $used = $this->vciIssuerIdentityRepository->getAllUsed(); + $didWeb = $this->moduleConfig->getVciIssuerIdentifier()->getDidWeb(); + } catch (Throwable $exception) { + $this->logger->error( + 'Configuration overview could not read which issuer identities were issued under: ' . + $exception->getMessage(), + ['exceptionClass' => $exception::class], + ); + + return new Row( + $label, + Translate::noop('N/A'), + ConfigOverviewValueTypeEnum::Text, + null, + null, + Translate::noop( + 'This could not be read, so a change of issuer identity can not be reported here. ' . + 'The reason was written to the SimpleSAMLphp log.', + ), + ); + } + + if ($used === []) { + return new Row( + $label, + Translate::noop('None recorded'), + ConfigOverviewValueTypeEnum::Text, + null, + Translate::noop( + 'No credential has been issued yet, or every one of them was issued before this ' . + 'was recorded.', + ), + ); + } + + $noLongerPublished = array_keys(array_filter( + $used, + static fn(string $mode, string $identifier): bool => + $mode === VciIssuerIdentifierModeEnum::DidWeb->value && $identifier !== $didWeb, + ARRAY_FILTER_USE_BOTH, + )); + + return new Row( + $label, + array_keys($used), + ConfigOverviewValueTypeEnum::StringList, + null, + Translate::noop( + 'Credentials naming any of these have been issued, and credentials do not expire ' . + 'unless a lifetime is configured for them.', + ), + $noLongerPublished === [] ? null : Translate::noop( + 'A did:web identity listed here is no longer the configured one, so its DID document ' . + 'is no longer published and every credential issued under it can no longer be ' . + 'verified. Set it as the did:web identifier again to resume publishing it, or serve ' . + 'the document it needs by other means.', + ), + ); + } + + /** * @throws \Exception */ diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 43922929..65d96d97 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -91,6 +91,18 @@ enum RoutesEnum: string case JwtVcIssuerConfiguration = '.well-known/jwt-vc-issuer'; + /***************************************************************************************************************** + * Decentralized Identifiers + ****************************************************************************************************************/ + + // Publishes the DID document for the configured did:web identifier. The did:web method appends + // "did.json" to the path its identifier transforms to, so the name of this route is fixed by the + // method rather than chosen here. Like the Status List route, it is deliberately not gated on the + // Verifiable Credential Issuance switch: a credential issued under a did:web identity can only be + // verified by resolving that DID, so withholding this document makes such credentials unverifiable + // rather than merely unissuable. + case VciDidDocument = 'did.json'; + /***************************************************************************************************************** * API ****************************************************************************************************************/ diff --git a/src/Codebooks/VciIssuerIdentifierModeEnum.php b/src/Codebooks/VciIssuerIdentifierModeEnum.php new file mode 100644 index 00000000..8a4b71de --- /dev/null +++ b/src/Codebooks/VciIssuerIdentifierModeEnum.php @@ -0,0 +1,45 @@ +moduleConfig->getVciEnabled() || $this->isAnyStatusListKeyPublished()) + // + // The issuer identity mode is asked for the same reason. Under it, a credential names its + // signing key by the identifier it carries here and nowhere else, so a credential already in a + // wallet is verifiable only while this key set still lists that key. + $isVciKeySetNeeded = $this->moduleConfig->getVciEnabled() || + $this->isAnyStatusListKeyPublished() || + $this->isCredentialKeyResolvedThroughThisKeySet(); + + $vciPublicKeys = $isVciKeySetNeeded ? $this->moduleConfig->getVciSignatureKeyPairBag()->getAllPublicKeys() : []; @@ -85,6 +94,33 @@ protected function isAnyStatusListKeyPublished(): bool } + /** + * Whether credentials this deployment issues name their signing key by its identifier in this key + * set, rather than carrying the key with them. + * + * Answered from configuration alone, for the same reason the Status List question above is: a + * repository would open a database connection before this controller is entered, and a key set + * which has never needed a database would then fail whenever the database did, taking down + * verification of every token this issuer has ever signed. + * + * The gap that leaves is an operator moving the issuer identity off this mode while credentials + * issued under it are still being verified. That is reported where it can be acted on: the + * Verifiable Credential configuration screen lists the identities credentials were actually issued + * under, which is a question configuration cannot answer. + */ + protected function isCredentialKeyResolvedThroughThisKeySet(): bool + { + try { + return $this->moduleConfig->getVciIssuerIdentifierMode() === VciIssuerIdentifierModeEnum::Https; + } catch (Throwable) { + // A mode which cannot be resolved is reported on the configuration overview screen, which + // owns that error. Here the conservative reading is the one which leaves this key set as it + // was before the issuer identity was configurable at all. + return false; + } + } + + public function jwks(): Response { $response = $this->__invoke(); diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 0a9ad4d7..909a3697 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -11,12 +11,12 @@ use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Exceptions\CredentialRequestException; -use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; +use SimpleSAML\Module\oidc\Repositories\VciIssuerIdentityRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -25,6 +25,8 @@ use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\Module\oidc\Utils\VciContextResolver; use SimpleSAML\Module\oidc\VerifiableCredentials\OpenId4VciProofValidator; +use SimpleSAML\Module\oidc\VerifiableCredentials\Values\VciIssuerIdentity; +use SimpleSAML\Module\oidc\VerifiableCredentials\VciIssuerIdentityResolver; use SimpleSAML\OpenID\Codebooks\AtContextsEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; @@ -68,14 +70,15 @@ public function __construct( protected readonly LoggerService $loggerService, protected readonly RequestParamsResolver $requestParamsResolver, protected readonly UserRepository $userRepository, - // The factory rather than the facade, so that the guard below is what answers a deployment with - // Verifiable Credentials switched off. The container resolves every argument here before this - // constructor body runs, and building the facade reads the DID destination settings and - // instantiates the VCI cache adapter - which turned that 403 into a 500 on a request this - // endpoint refuses outright. The only thing wanted of it is the issuer's own did:jwk, derived - // from a key already in hand, so nothing is built until there is a credential to sign. - protected readonly DidFactory $didFactory, + // Holds the DID facade factory rather than a built facade, so that the guard below is what + // answers a deployment with Verifiable Credentials switched off. The container resolves every + // argument here before this constructor body runs, and building the facade reads the DID + // destination settings and instantiates the VCI cache adapter - which turned that 403 into a + // 500 on a request this endpoint refuses outright. Nothing is built until there is a credential + // to sign. + protected readonly VciIssuerIdentityResolver $vciIssuerIdentityResolver, protected readonly IssuerStateRepository $issuerStateRepository, + protected readonly VciIssuerIdentityRepository $vciIssuerIdentityRepository, protected readonly OpenId4VciProofValidator $openId4VciProofValidator, protected readonly VciContextResolver $vciContextResolver, protected readonly CredentialStatusIssuer $credentialStatusIssuer, @@ -445,6 +448,10 @@ public function credential(Request $request): Response $issuedCredentialsData = []; + // Noted from inside the loop so that what is recorded afterwards is the identity credentials + // were actually signed under, rather than what configuration said before any of them was. + $issuerIdentity = null; + foreach ($validatedProofs as $validatedProof) { // A configuration which issues credentials that are not bound to a holder key has no wallet // key to name here, so the subject is one this issuer derives from the authenticated user. @@ -609,10 +616,13 @@ public function credential(Request $request): Response $signingKey = $vciSignatureKeyPair->getKeyPair()->getPrivateKey(); - $publicKey = $vciSignatureKeyPair->getKeyPair()->getPublicKey(); - - $issuerDid = $this->didFactory->build()->didJwkResolver() - ->generateDidJwkFromJwk($publicKey->jwk()->all()); + // How the credential says who issued it and which key signed it. Resolved from the + // configured identity and the key pair in hand rather than derived here, so that the `iss` + // claim and the `kid` header can not be built under different rules. + $issuerIdentity = $this->vciIssuerIdentityResolver->resolve( + $this->moduleConfig->getVciIssuerIdentifier(), + $vciSignatureKeyPair, + ); $issuedAt = new DateTimeImmutable(); @@ -656,7 +666,8 @@ public function credential(Request $request): Response $this->loggerService->info('Signing and issuing verifiable credential.', [ 'vcId' => $vcId, 'format' => $credentialFormatId, - 'issuerDid' => $issuerDid, + 'issuer' => $issuerIdentity->getIssuer(), + 'issuerKeyId' => $issuerIdentity->getKeyId(), 'sub' => $sub, 'algorithm' => $signatureAlgorithm->value, 'expiresAt' => $expiresAt?->getTimestamp(), @@ -699,7 +710,7 @@ public function credential(Request $request): Response $resolvedCredentialIdentifier, ], //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), - ClaimsEnum::Issuer->value => $issuerDid, + ClaimsEnum::Issuer->value => $issuerIdentity->getIssuer(), ClaimsEnum::Issuance_Date->value => $issuedAt->format(DateTimeInterface::RFC3339), ClaimsEnum::Id->value => $vcId, ClaimsEnum::Credential_Subject->value => @@ -720,7 +731,7 @@ public function credential(Request $request): Response [ ClaimsEnum::Vc->value => $verifiableCredentialBody, //ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), - ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iss->value => $issuerIdentity->getIssuer(), ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $sub, @@ -729,7 +740,7 @@ public function credential(Request $request): Response $commonClaims, ), [ - ClaimsEnum::Kid->value => $issuerDid . '#0', + ClaimsEnum::Kid->value => $issuerIdentity->getKeyId(), ], ); } @@ -737,7 +748,7 @@ public function credential(Request $request): Response if ($credentialFormatId === CredentialFormatIdentifiersEnum::DcSdJwt->value) { $sdJwtPayload = array_merge( [ - ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iss->value => $issuerIdentity->getIssuer(), ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $sub, @@ -752,7 +763,7 @@ public function credential(Request $request): Response $signatureAlgorithm, $sdJwtPayload, [ - ClaimsEnum::Kid->value => $issuerDid . '#0', + ClaimsEnum::Kid->value => $issuerIdentity->getKeyId(), ], disclosureBag: $disclosureBag, ); @@ -775,11 +786,11 @@ public function credential(Request $request): Response CredentialTypesEnum::VerifiableCredential->value, $resolvedCredentialIdentifier, ], - ClaimsEnum::Issuer->value => $issuerDid, + ClaimsEnum::Issuer->value => $issuerIdentity->getIssuer(), ClaimsEnum::ValidFrom->value => $issuedAt->format(DateTimeInterface::RFC3339), ClaimsEnum::Credential_Subject->value => $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], - ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iss->value => $issuerIdentity->getIssuer(), ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $sub, @@ -799,7 +810,7 @@ public function credential(Request $request): Response $signatureAlgorithm, $sdJwtPayload, [ - ClaimsEnum::Kid->value => $issuerDid . '#0', + ClaimsEnum::Kid->value => $issuerIdentity->getKeyId(), ], disclosureBag: $disclosureBag, ); @@ -817,6 +828,25 @@ public function credential(Request $request): Response ); } + // Note which identity this deployment has now issued under, so that a later change to it can be + // reported rather than only discovered by whoever fails to verify an older credential. Recorded + // after the fact and only when something was actually issued, since an identity nothing was + // signed under obliges this deployment to nothing. + if ($issuedCredentialsData !== [] && $issuerIdentity instanceof VciIssuerIdentity) { + try { + $this->vciIssuerIdentityRepository->recordUsage($issuerIdentity); + } catch (Throwable $throwable) { + // Deliberately not fatal, unlike the Status List allocation above. The credential is + // signed and valid whether or not this was written; all that is lost is the ability to + // warn about a later identity change, so failing the request would trade a real + // credential for a diagnostic. + $this->loggerService->error( + 'Could not record the issuer identity credentials were issued under.', + ['issuer' => $issuerIdentity->getIssuer(), 'error' => $throwable->getMessage()], + ); + } + } + if (is_string($issuerState)) { $this->loggerService->debug('Revoking issuer state.', ['issuerState' => $issuerState]); $this->issuerStateRepository->revoke($issuerState); diff --git a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php index 6e1ba8e0..d7f342a0 100644 --- a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php @@ -14,12 +14,14 @@ namespace SimpleSAML\Module\oidc\Controllers\VerifiableCredentials; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; +use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use Symfony\Component\HttpFoundation\Response; +use Throwable; class JwtVcIssuerConfigurationController { @@ -31,13 +33,34 @@ public function __construct( protected readonly Routes $routes, protected readonly LoggerService $loggerService, ) { - if (!$this->moduleConfig->getVciEnabled()) { + if (!$this->moduleConfig->getVciEnabled() && !$this->isNeededByIssuedCredentials()) { $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); } } + /** + * Whether credentials already issued are verified by way of this document. + * + * Under the `https` issuer identity, an SD-JWT VC states its issuer as a URL and a verifier finds + * the key set to check it against by fetching exactly this document. Withdrawing it when issuance + * is switched off would therefore not stop credentials being issued - it would stop the ones + * already issued from being verified, which is the same reason the Status List and DID document + * endpoints are not gated on that switch either. + */ + protected function isNeededByIssuedCredentials(): bool + { + try { + return $this->moduleConfig->getVciIssuerIdentifierMode() === VciIssuerIdentifierModeEnum::Https; + } catch (Throwable) { + // Reported on the configuration overview screen, which owns that error. Here the + // conservative reading is the one this endpoint had before the identity was configurable. + return false; + } + } + + public function configuration(): Response { $configuration = [ diff --git a/src/Controllers/VerifiableCredentials/VciDidDocumentController.php b/src/Controllers/VerifiableCredentials/VciDidDocumentController.php new file mode 100644 index 00000000..7e50336a --- /dev/null +++ b/src/Controllers/VerifiableCredentials/VciDidDocumentController.php @@ -0,0 +1,136 @@ +moduleConfig->getVciIssuerDidIdentifier(); + } catch (Throwable $throwable) { + $this->loggerService->error( + 'Unable to resolve the configured did:web identifier, so no DID document was served.', + ['error' => $throwable->getMessage()], + ); + + return $this->routes->newResponse(null, Response::HTTP_INTERNAL_SERVER_ERROR, $this->baseHeaders()); + } + + if (is_null($didWeb)) { + return $this->routes->newResponse(null, Response::HTTP_NOT_FOUND, $this->baseHeaders()); + } + + try { + $didDocument = $this->didFactory->build()->didDocumentFactory()->forDidWeb( + new DidUrl($didWeb), + // The whole key set rather than the pair which is currently signing. Every key which + // has signed a credential still in circulation has to be here, or the credentials it + // signed stop verifying the moment a newer pair is put in front of it; the same set is + // published in JWKS, and for the same reason. + $this->moduleConfig->getVciSignatureKeyPairBag(), + // Only assertionMethod. These keys sign credentials and Status List Tokens and are used + // for nothing else, and a relationship this deployment does not act in would be a claim + // about the keys which is not true. + [VerificationRelationshipEnum::AssertionMethod], + // Stated rather than left to the library's default, so that a change to that default + // can not silently alter the documents this module has already published. + VerificationMethodTypeEnum::JsonWebKey2020, + ); + } catch (Throwable $throwable) { + // Fail closed. A document which does not describe the keys actually signing is worse than + // no document: it would have a verifier reject valid credentials while reporting a key + // mismatch rather than an outage. + $this->loggerService->error( + 'Unable to build the DID document, so nothing was served.', + ['didWeb' => $didWeb, 'error' => $throwable->getMessage()], + ); + + return $this->routes->newResponse(null, Response::HTTP_INTERNAL_SERVER_ERROR, $this->baseHeaders()); + } + + return $this->routes->newJsonResponse( + $didDocument->jsonSerialize(), + Response::HTTP_OK, + $this->baseHeaders([ + 'Content-Type' => self::MEDIA_TYPE, + 'Cache-Control' => 'public, max-age=' . self::CACHE_MAX_AGE_SECONDS, + ]), + ); + } + + + /** + * Headers every response from here carries. + * + * Cross origin reads are allowed on every outcome and not only on success, so that a browser based + * wallet or verifier can tell a document which is not published from a 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 77dd458e..89d2ea5a 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -16,9 +16,11 @@ use SimpleSAML\Module\oidc\Codebooks\StatusListExpiryLaneEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; +use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; +use SimpleSAML\Module\oidc\VerifiableCredentials\Values\VciIssuerIdentifier; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\AddressPinningModeEnum; @@ -30,6 +32,7 @@ use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; use SimpleSAML\OpenID\Decorators\HttpClientDecorator; +use SimpleSAML\OpenID\Did\DidWebResolver; use SimpleSAML\OpenID\Network\DestinationPolicy; use SimpleSAML\OpenID\Serializers\JwsSerializerBag; use SimpleSAML\OpenID\Serializers\JwsSerializerEnum; @@ -262,6 +265,10 @@ class ModuleConfig final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; + final public const string OPTION_VCI_ISSUER_IDENTIFIER_MODE = 'vci_issuer_identifier_mode'; + + final public const string OPTION_VCI_ISSUER_DID_IDENTIFIER = 'vci_issuer_did_identifier'; + 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'; @@ -2326,6 +2333,121 @@ public function getActiveVciSignatureKeyPair(): SignatureKeyPair } + /** + * Which kind of identity newly issued credentials are signed under. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciIssuerIdentifierMode(): VciIssuerIdentifierModeEnum + { + /** @var mixed $configured */ + $configured = $this->config()->getOptionalValue(self::OPTION_VCI_ISSUER_IDENTIFIER_MODE, null); + + if (is_null($configured)) { + return VciIssuerIdentifierModeEnum::DidJwk; + } + + if ($configured instanceof VciIssuerIdentifierModeEnum) { + return $configured; + } + + if (is_string($configured) && (($mode = VciIssuerIdentifierModeEnum::tryFrom($configured)) !== null)) { + return $mode; + } + + throw new ConfigurationError( + sprintf( + 'Option "%s" must be one of: %s.', + self::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + implode( + ', ', + array_map( + static fn(VciIssuerIdentifierModeEnum $case): string => $case->value, + VciIssuerIdentifierModeEnum::cases(), + ), + ), + ), + self::DEFAULT_FILE_NAME, + ); + } + + + /** + * The did:web this deployment publishes a DID document for, or null when it publishes none. + * + * Configured rather than derived, deliberately. Deriving it from the module URL would produce + * something like did:web:example.org:simplesaml:module.php:oidc, which is a name no deployment + * would choose to put into credentials, and the clean did:web:example.org resolves to a URL at the + * web root which SimpleSAMLphp does not serve at all - so either way the deployment has to say + * which name it is publishing under and arrange for that URL to reach this module. + * + * Validated through the library's own did:web rules rather than by pattern here, so that an + * identifier which is syntactically a DID but could never be resolved - an IP literal host, a + * single label one, a percent encoded segment - is refused where it is configured rather than + * after credentials have been issued under it. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciIssuerDidIdentifier(): ?string + { + $configured = $this->config()->getOptionalString(self::OPTION_VCI_ISSUER_DID_IDENTIFIER, null); + + if (is_null($configured) || trim($configured) === '') { + return null; + } + + $configured = trim($configured); + + try { + DidWebResolver::assertIdentifierIsResolvable($configured); + } catch (Throwable $throwable) { + throw new ConfigurationError( + sprintf( + 'Option "%s" is not a did:web identifier which could be resolved: %s', + self::OPTION_VCI_ISSUER_DID_IDENTIFIER, + $throwable->getMessage(), + ), + self::DEFAULT_FILE_NAME, + ); + } + + return $configured; + } + + + /** + * The two issuer identity options, resolved together. + * + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciIssuerIdentifier(): VciIssuerIdentifier + { + $mode = $this->getVciIssuerIdentifierMode(); + $didWeb = $this->getVciIssuerDidIdentifier(); + + // The one combination which can not be honoured. Every other pairing means something: the DID + // set under another mode keeps its document published for credentials issued earlier, and no + // DID at all under another mode is simply a deployment which never used one. + if ($mode === VciIssuerIdentifierModeEnum::DidWeb && is_null($didWeb)) { + throw new ConfigurationError( + sprintf( + 'Option "%s" is set to "%s", so option "%s" must name the did:web to issue under.', + self::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + VciIssuerIdentifierModeEnum::DidWeb->value, + self::OPTION_VCI_ISSUER_DID_IDENTIFIER, + ), + self::DEFAULT_FILE_NAME, + ); + } + + try { + return new VciIssuerIdentifier($mode, $didWeb); + } catch (Throwable $throwable) { + throw new ConfigurationError($throwable->getMessage(), self::DEFAULT_FILE_NAME); + } + } + + public function getVciCredentialConfigurationsSupported(): array { return $this->config()->getOptionalArray(self::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []); diff --git a/src/Repositories/VciIssuerIdentityRepository.php b/src/Repositories/VciIssuerIdentityRepository.php new file mode 100644 index 00000000..c46a9d4b --- /dev/null +++ b/src/Repositories/VciIssuerIdentityRepository.php @@ -0,0 +1,145 @@ +database->applyPrefix(self::TABLE_NAME); + } + + + /** + * Note that an identity is issued under, if this is the first time it has been seen. + * + * The identifier is hashed for the primary key rather than being the key itself, because a + * `did:jwk` carries a whole public key and an RSA one runs well past what MySQL will index. + * + * @throws \Exception + */ + public function recordUsage(VciIssuerIdentity $identity): void + { + $identifierHash = hash('sha256', $identity->getIssuer()); + + if ($this->isRecorded($identifierHash)) { + return; + } + + try { + $this->database->write( + sprintf( + 'INSERT INTO %s (identifier_hash, identifier, mode, first_used_at) ' . + 'VALUES (:identifier_hash, :identifier, :mode, :first_used_at)', + $this->getTableName(), + ), + [ + 'identifier_hash' => $identifierHash, + 'identifier' => $identity->getIssuer(), + 'mode' => $identity->getMode()->value, + 'first_used_at' => $this->helpers->dateTime()->getUtc() + ->format(DateFormatsEnum::DB_DATETIME->value), + ], + ); + } catch (Throwable $throwable) { + // Two requests recording the same identity at once, and a secondary which had not caught up + // when the check above ran, both arrive here on the primary key. Asking again is what tells + // those apart from a write which genuinely failed: if the row is there now then there was + // nothing to do, and reporting it would raise an error against a perfectly good issuance. + if (!$this->isRecorded($identifierHash)) { + throw $throwable; + } + } + } + + + /** + * Every identity credentials have been issued under, as identifier to the mode which produced it. + * + * @return array + */ + public function getAllUsed(): array + { + $rows = $this->database + ->read(sprintf('SELECT identifier, mode FROM %s', $this->getTableName())) + ->fetchAll(); + + $used = []; + + /** @var mixed $row */ + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + /** @var mixed $identifier */ + $identifier = $row['identifier'] ?? null; + /** @var mixed $mode */ + $mode = $row['mode'] ?? null; + + if (!is_string($identifier) || !is_string($mode)) { + continue; + } + + $used[$identifier] = $mode; + } + + return $used; + } + + + /** + * Read from the primary where the installed SimpleSAMLphp can, since a secondary which has not + * caught up answers no to an identity recorded moments ago and turns this into an insert which + * collides on the primary key. Deployments on an older SimpleSAMLphp fall back to a secondary + * read, where the worst case is that same collision - which the caller treats as nothing to do. + */ + protected function isRecorded(string $identifierHash): bool + { + $statement = sprintf('SELECT 1 FROM %s WHERE identifier_hash = :identifier_hash', $this->getTableName()); + $params = ['identifier_hash' => $identifierHash]; + + $rows = ModuleConfig::hasPrimaryDatabaseReadCapability() ? + $this->database->readPrimary($statement, $params)->fetchAll() : + $this->database->read($statement, $params)->fetchAll(); + + return $rows !== []; + } +} diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 6e18c3a2..a2c42624 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -19,6 +19,7 @@ use SimpleSAML\Module\oidc\Repositories\StatusListEntryRepository; use SimpleSAML\Module\oidc\Repositories\StatusListRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; +use SimpleSAML\Module\oidc\Repositories\VciIssuerIdentityRepository; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; class DatabaseMigration @@ -266,6 +267,11 @@ public function migrate(): void $this->version20260801000005(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260801000005')"); } + + if (!in_array('20260902000001', $versions, true)) { + $this->version20260902000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260902000001')"); + } } @@ -1132,6 +1138,25 @@ private function version20260801000005(): void } + private function version20260902000001(): void + { + $issuerIdentityTableName = $this->database->applyPrefix(VciIssuerIdentityRepository::TABLE_NAME); + $dateTimeColumnType = $this->dateTimeColumnType(); + + // Keyed on a hash of the identifier rather than on the identifier itself: a did:jwk carries a + // whole public key, and an RSA one is longer than MySQL will accept in an index. + $this->database->write(<<< EOT + CREATE TABLE IF NOT EXISTS $issuerIdentityTableName ( + identifier_hash CHAR(64) PRIMARY KEY NOT NULL, + identifier TEXT NOT NULL, + mode VARCHAR(32) NOT NULL, + first_used_at $dateTimeColumnType NOT NULL + ) +EOT + ,); + } + + /** * Whether a table already has a column. * diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 8d295d58..74ab2804 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -329,6 +329,15 @@ public function urlJwtVcIssuerConfiguration(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::JwtVcIssuerConfiguration->value, $parameters); } + /***************************************************************************************************************** + * Decentralized Identifiers + ****************************************************************************************************************/ + + public function urlVciDidDocument(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::VciDidDocument->value, $parameters); + } + /***************************************************************************************************************** * API ****************************************************************************************************************/ diff --git a/src/VerifiableCredentials/Values/VciIssuerIdentifier.php b/src/VerifiableCredentials/Values/VciIssuerIdentifier.php new file mode 100644 index 00000000..ffbc4cab --- /dev/null +++ b/src/VerifiableCredentials/Values/VciIssuerIdentifier.php @@ -0,0 +1,81 @@ +mode === VciIssuerIdentifierModeEnum::DidWeb && is_null($this->didWeb)) { + throw new OidcException( + sprintf( + 'Issuer identifier mode "%s" needs the did:web identifier to issue under, and none ' . + 'is set.', + VciIssuerIdentifierModeEnum::DidWeb->value, + ), + ); + } + } + + + public function getMode(): VciIssuerIdentifierModeEnum + { + return $this->mode; + } + + + /** + * The configured did:web, which is published whenever it is set - see the class note for why that + * is not conditioned on the mode. + * + * @see \SimpleSAML\Module\oidc\ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER + */ + public function getDidWeb(): ?string + { + return $this->didWeb; + } + + + /** + * Whether newly issued credentials are issued under the did:web identity. + * + * Distinct from having one configured: a deployment which has moved on to another mode still + * publishes its document, but no longer signs anything under it. + */ + public function isIssuingUnderDidWeb(): bool + { + return $this->mode === VciIssuerIdentifierModeEnum::DidWeb; + } +} diff --git a/src/VerifiableCredentials/Values/VciIssuerIdentity.php b/src/VerifiableCredentials/Values/VciIssuerIdentity.php new file mode 100644 index 00000000..0a3da81c --- /dev/null +++ b/src/VerifiableCredentials/Values/VciIssuerIdentity.php @@ -0,0 +1,49 @@ +mode; + } + + + public function getIssuer(): string + { + return $this->issuer; + } + + + public function getKeyId(): string + { + return $this->keyId; + } +} diff --git a/src/VerifiableCredentials/VciIssuerIdentityResolver.php b/src/VerifiableCredentials/VciIssuerIdentityResolver.php new file mode 100644 index 00000000..381ec4d4 --- /dev/null +++ b/src/VerifiableCredentials/VciIssuerIdentityResolver.php @@ -0,0 +1,130 @@ +getMode()) { + VciIssuerIdentifierModeEnum::DidJwk => $this->forDidJwk($signatureKeyPair), + VciIssuerIdentifierModeEnum::DidWeb => $this->forDidWeb( + // Not null under this mode: VciIssuerIdentifier refuses that pairing when it is built. + (string)$identifier->getDidWeb(), + $signatureKeyPair, + ), + VciIssuerIdentifierModeEnum::Https => $this->forHttps($signatureKeyPair), + }; + } + + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + protected function forDidJwk(SignatureKeyPair $signatureKeyPair): VciIssuerIdentity + { + try { + $didJwk = $this->didFactory->build()->didJwkResolver()->generateDidJwkFromJwk( + $signatureKeyPair->getKeyPair()->getPublicKey()->jwk()->all(), + ); + } catch (Throwable $throwable) { + throw new OidcException( + 'Unable to derive the did:jwk identifier for the signing key: ' . $throwable->getMessage(), + (int)$throwable->getCode(), + $throwable, + ); + } + + return new VciIssuerIdentity(VciIssuerIdentifierModeEnum::DidJwk, $didJwk, $didJwk . self::DID_JWK_FRAGMENT); + } + + + /** + * The key identifier is minted by the same code which builds the published DID document, rather + * than assembled here, so a `kid` can not name a verification method the document does not carry. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + protected function forDidWeb(string $didWeb, SignatureKeyPair $signatureKeyPair): VciIssuerIdentity + { + try { + $keyId = $this->didFactory->build()->didDocumentFactory()->verificationMethodIdFor( + new DidUrl($didWeb), + $signatureKeyPair->getKeyPair()->getKeyId(), + )->getValue(); + } catch (Throwable $throwable) { + throw new OidcException( + 'Unable to build the did:web verification method identifier for the signing key: ' . + $throwable->getMessage(), + (int)$throwable->getCode(), + $throwable, + ); + } + + return new VciIssuerIdentity(VciIssuerIdentifierModeEnum::DidWeb, $didWeb, $keyId); + } + + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + protected function forHttps(SignatureKeyPair $signatureKeyPair): VciIssuerIdentity + { + try { + $issuer = $this->moduleConfig->getIssuer(); + } catch (Throwable $throwable) { + throw new OidcException( + 'Unable to resolve the issuer URL to identify credentials by: ' . $throwable->getMessage(), + (int)$throwable->getCode(), + $throwable, + ); + } + + return new VciIssuerIdentity( + VciIssuerIdentifierModeEnum::Https, + $issuer, + $signatureKeyPair->getKeyPair()->getKeyId(), + ); + } +} diff --git a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php index 74f17b24..3133b6a0 100644 --- a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php @@ -12,6 +12,7 @@ use SimpleSAML\Module\oidc\Admin\ConfigOverview\Section; use SimpleSAML\Module\oidc\Admin\ConfigOverview\VciOverviewBuilder; use SimpleSAML\Module\oidc\Codebooks\ConfigOverviewValueTypeEnum; +use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\OpenID\Codebooks\AddressPinningModeEnum; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -25,6 +26,14 @@ class VciOverviewBuilderTest extends TestCase use VciOverviewTestTrait; + /** + * A did:web whose document URL a stock module installation can actually serve, and that URL. + */ + protected const string DID_WEB_WITH_PATH = 'did:web:example.org:simplesaml:module.php:oidc'; + + protected const string DID_WEB_WITH_PATH_URL = 'https://example.org/simplesaml/module.php/oidc/did.json'; + + /** * A minimal but realistic credential configuration, shaped like the one in the config template. */ @@ -1243,4 +1252,186 @@ public function testNoDisplayedOptionCanTakeTheScreenDown(): void fn(array $overrides): VciOverviewBuilder => $this->buildVciOverviewBuilder($overrides), ); } + + + public function testShowsTheIssuerIdentityModeAndSaysWhatItMeans(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder()->build(), + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + ); + + $this->assertNotNull($row); + $this->assertSame(VciIssuerIdentifierModeEnum::DidJwk->value, $row->getValue()); + $this->assertNotEmpty($row->getNote()); + $this->assertNull($row->getWarning()); + } + + + /** + * The `https` mode is a deliberate way out for verifiers which will not take a DID, but the + * profile this module claims to follow requires one, so the screen has to say so. + */ + public function testWarnsThatTheHttpsIssuerIdentityIsNotProfileConformant(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder([ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::Https, + ])->build(), + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + ); + + $this->assertNotNull($row); + $this->assertStringContainsString('DIIP', (string)$row->getWarning()); + } + + + public function testSaysWhenNoDidDocumentIsPublished(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder()->build(), + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + ); + + $this->assertNotNull($row); + $this->assertSame('None configured', $row->getValue()); + $this->assertNull($row->getWarning()); + } + + + /** + * Both URLs are shown, and no warning while they agree. + */ + public function testShowsWhereTheDidWebIdentifierResolvesTo(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidWeb, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => self::DID_WEB_WITH_PATH, + ], + didDocumentUrl: self::DID_WEB_WITH_PATH_URL, + )->build(), + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + ); + + $this->assertNotNull($row); + $this->assertSame( + [ + 'did' => self::DID_WEB_WITH_PATH, + 'resolvesTo' => self::DID_WEB_WITH_PATH_URL, + 'servedAt' => self::DID_WEB_WITH_PATH_URL, + ], + $row->getValue(), + ); + $this->assertNull($row->getWarning()); + } + + + /** + * Comparing hosts alone would not catch this: the path segments decide the URL too, and the bare + * form asks for one at the web root which SimpleSAMLphp never serves. + */ + public function testWarnsWhenTheDidWebIdentifierResolvesElsewhere(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidWeb, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => 'did:web:example.org', + ], + didDocumentUrl: self::DID_WEB_WITH_PATH_URL, + )->build(), + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + ); + + $this->assertNotNull($row); + $this->assertStringContainsString('map', (string)$row->getWarning()); + } + + + /** + * The document stays published after the mode moves on, and the row says that is what is + * happening rather than leaving it looking like a stale setting. + */ + public function testSaysADidWebDocumentIsStillPublishedAfterTheModeMovedOn(): void + { + $row = $this->findRowForOption( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidJwk, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => self::DID_WEB_WITH_PATH, + ], + didDocumentUrl: self::DID_WEB_WITH_PATH_URL, + )->build(), + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + ); + + $this->assertNotNull($row); + $this->assertStringContainsString('still', (string)$row->getNote()); + $this->assertNull($row->getWarning()); + } + + + public function testSaysNothingHasBeenIssuedYet(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder()->build(), + 'Identities Credentials Were Issued Under', + ); + + $this->assertNotNull($row); + $this->assertSame('None recorded', $row->getValue()); + $this->assertNull($row->getWarning()); + } + + + /** + * A did:jwk credential carries its own key and an issuer URL keeps resolving through the published + * key set, so neither is a reason to warn. Only a did:web identity which is no longer published is. + */ + public function testDoesNotWarnAboutIdentitiesWhichStayResolvable(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidWeb, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => self::DID_WEB_WITH_PATH, + ], + [ + 'did:jwk:retired' => VciIssuerIdentifierModeEnum::DidJwk->value, + 'https://issuer.example.org' => VciIssuerIdentifierModeEnum::Https->value, + self::DID_WEB_WITH_PATH => VciIssuerIdentifierModeEnum::DidWeb->value, + ], + self::DID_WEB_WITH_PATH_URL, + )->build(), + 'Identities Credentials Were Issued Under', + ); + + $this->assertNotNull($row); + $this->assertSame( + ['did:jwk:retired', 'https://issuer.example.org', self::DID_WEB_WITH_PATH], + $row->getValue(), + ); + $this->assertNull($row->getWarning()); + } + + + /** + * The whole point of recording what was issued under: configuration alone cannot tell anyone that + * credentials exist which nothing can resolve any more. + */ + public function testWarnsAboutADidWebIdentityWhichIsNoLongerPublished(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [], + ['did:web:retired.example.org' => VciIssuerIdentifierModeEnum::DidWeb->value], + )->build(), + 'Identities Credentials Were Issued Under', + ); + + $this->assertNotNull($row); + $this->assertStringContainsString('no longer published', (string)$row->getWarning()); + } } diff --git a/tests/unit/src/Admin/ConfigOverview/VciOverviewTestTrait.php b/tests/unit/src/Admin/ConfigOverview/VciOverviewTestTrait.php index de28f5ea..8e7b3b86 100644 --- a/tests/unit/src/Admin/ConfigOverview/VciOverviewTestTrait.php +++ b/tests/unit/src/Admin/ConfigOverview/VciOverviewTestTrait.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; use SimpleSAML\Module\oidc\Admin\ConfigOverview\VciOverviewBuilder; +use SimpleSAML\Module\oidc\Repositories\VciIssuerIdentityRepository; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\DateIntervalFormatter; use SimpleSAML\Module\oidc\Utils\Routes; @@ -16,15 +17,28 @@ trait VciOverviewTestTrait { /** * @param array $overrides Module config option overrides. + * @param array $usedIssuerIdentities Identifier to the mode it was issued under. + * @param ?string $didDocumentUrl Where this module serves its DID document, which the configured + * did:web identifier has to resolve to. * @throws \Exception */ - protected function buildVciOverviewBuilder(array $overrides = []): VciOverviewBuilder - { + protected function buildVciOverviewBuilder( + array $overrides = [], + array $usedIssuerIdentities = [], + ?string $didDocumentUrl = null, + ): VciOverviewBuilder { + $vciIssuerIdentityRepository = $this->createMock(VciIssuerIdentityRepository::class); + $vciIssuerIdentityRepository->method('getAllUsed')->willReturn($usedIssuerIdentities); + + $routes = $this->createMock(Routes::class); + $routes->method('urlVciDidDocument')->willReturn($didDocumentUrl ?? ''); + return new VciOverviewBuilder( $this->buildOverviewModuleConfig($overrides), - $this->createMock(Routes::class), + $routes, new DateIntervalFormatter(), $this->createMock(LoggerService::class), + $vciIssuerIdentityRepository, ); } } diff --git a/tests/unit/src/Controllers/JwksControllerTest.php b/tests/unit/src/Controllers/JwksControllerTest.php index a8b490e9..f8059fb0 100644 --- a/tests/unit/src/Controllers/JwksControllerTest.php +++ b/tests/unit/src/Controllers/JwksControllerTest.php @@ -7,12 +7,16 @@ use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Error\ConfigurationError; +use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\Controllers\JwksController; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\StatusList\Values\StatusListPoolBag; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Jwks; use SimpleSAML\OpenID\Jwks\Factories\JwksDecoratorFactory; use SimpleSAML\OpenID\Jwks\JwksDecorator; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; use Symfony\Component\HttpFoundation\JsonResponse; /** @@ -111,4 +115,59 @@ public function testItAlwaysReturnsAccessControlAllowOrigin(): void $this->assertTrue($response->headers->has('Access-Control-Allow-Origin')); $this->assertSame('*', $response->headers->get('Access-Control-Allow-Origin')); } + + + /** + * Under the `https` issuer identity a credential names its signing key by its identifier in this + * key set and carries the key nowhere else, so withdrawing it when issuance is switched off would + * make every credential already issued unverifiable. + */ + public function testItKeepsPublishingVciKeysForCredentialsWhichNameThemHere(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + $this->moduleConfigMock->method('getVciStatusListPoolBag') + ->willReturn(new StatusListPoolBag()); + $this->moduleConfigMock->method('getVciIssuerIdentifierMode') + ->willReturn(VciIssuerIdentifierModeEnum::Https); + $this->moduleConfigMock->expects($this->once())->method('getVciSignatureKeyPairBag') + ->willReturn(new SignatureKeyPairBag()); + + $this->mock()->__invoke(); + } + + + /** + * The identity modes which carry their key with the credential do not need it here, so the key set + * stays as it was before the issuer identity became configurable. + */ + public function testItWithdrawsVciKeysForIdentitiesWhichDoNotNeedThem(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + $this->moduleConfigMock->method('getVciStatusListPoolBag') + ->willReturn(new StatusListPoolBag()); + $this->moduleConfigMock->method('getVciIssuerIdentifierMode') + ->willReturn(VciIssuerIdentifierModeEnum::DidJwk); + $this->moduleConfigMock->expects($this->never())->method('getVciSignatureKeyPairBag'); + + $this->mock()->__invoke(); + } + + + /** + * A key set which cannot be served takes down verification of every token this issuer has ever + * signed, so a malformed issuer identity must not reach it. + */ + public function testAMalformedIssuerIdentityModeDoesNotTakeTheKeySetDown(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + $this->moduleConfigMock->method('getVciStatusListPoolBag') + ->willReturn(new StatusListPoolBag()); + $this->moduleConfigMock->method('getVciIssuerIdentifierMode') + ->willThrowException(new ConfigurationError('nope')); + $this->moduleConfigMock->expects($this->never())->method('getVciSignatureKeyPairBag'); + + $response = $this->mock()->jwks(); + + $this->assertSame(200, $response->getStatusCode()); + } } diff --git a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php index c757f0bc..2349393c 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialControllerTest.php @@ -14,17 +14,18 @@ use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; +use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Exceptions\CredentialRequestException; use SimpleSAML\Module\oidc\Exceptions\StatusListException; -use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; +use SimpleSAML\Module\oidc\Repositories\VciIssuerIdentityRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -34,11 +35,12 @@ use SimpleSAML\Module\oidc\Utils\VciContextResolver; use SimpleSAML\Module\oidc\VerifiableCredentials\OpenId4VciProofValidator; use SimpleSAML\Module\oidc\VerifiableCredentials\Values\ValidatedOpenId4VciProof; +use SimpleSAML\Module\oidc\VerifiableCredentials\Values\VciIssuerIdentifier; +use SimpleSAML\Module\oidc\VerifiableCredentials\Values\VciIssuerIdentity; +use SimpleSAML\Module\oidc\VerifiableCredentials\VciIssuerIdentityResolver; 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\Factories\JwkDecoratorFactory; @@ -66,6 +68,8 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected const string ISSUER = 'https://issuer.com'; + protected const string ISSUER_DID = 'did:jwk:test'; + protected const string STATUS_LIST_URI = 'https://issuer.com/module.php/oidc/statuslist/list-1'; protected const string HOLDER_DID = 'did:jwk:holder'; @@ -89,9 +93,9 @@ class CredentialIssuerCredentialControllerTest extends TestCase protected MockObject $userRepositoryMock; - protected MockObject $didMock; + protected MockObject $vciIssuerIdentityResolverMock; - protected MockObject $didFactoryMock; + protected MockObject $vciIssuerIdentityRepositoryMock; protected MockObject $issuerStateRepositoryMock; @@ -127,9 +131,15 @@ public function setUp(): void $this->loggerServiceMock = $this->createMock(LoggerService::class); $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); $this->userRepositoryMock = $this->createMock(UserRepository::class); - $this->didMock = $this->createMock(Did::class); - $this->didFactoryMock = $this->createMock(DidFactory::class); - $this->didFactoryMock->method('build')->willReturn($this->didMock); + $this->vciIssuerIdentityResolverMock = $this->createMock(VciIssuerIdentityResolver::class); + $this->vciIssuerIdentityResolverMock->method('resolve')->willReturn( + new VciIssuerIdentity( + VciIssuerIdentifierModeEnum::DidJwk, + self::ISSUER_DID, + self::ISSUER_DID . '#0', + ), + ); + $this->vciIssuerIdentityRepositoryMock = $this->createMock(VciIssuerIdentityRepository::class); $this->issuerStateRepositoryMock = $this->createMock(IssuerStateRepository::class); $this->openId4VciProofValidatorMock = $this->createMock(OpenId4VciProofValidator::class); $this->vciContextResolverMock = $this->createMock(VciContextResolver::class); @@ -141,6 +151,8 @@ public function setUp(): void // VCI must be enabled in constructor $this->moduleConfigMock->method('getVciEnabled')->willReturn(true); $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + $this->moduleConfigMock->method('getVciIssuerIdentifier') + ->willReturn(new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidJwk)); $this->moduleConfigMock->method('getVciValidCredentialClaimPathsFor')->willReturn([]); $this->moduleConfigMock->method('getVciUserAttributeToCredentialClaimPathMapFor')->willReturn([]); $this->bindingPolicy = VciCredentialBindingPolicyEnum::ProofBound; @@ -200,10 +212,6 @@ protected function prepareSigningKey(): void $this->moduleConfigMock->method('getActiveVciSignatureKeyPair') ->willReturn($this->vciSignatureKeyPairMock); - $didJwkResolverMock = $this->createMock(DidJwkResolver::class); - $this->didMock->method('didJwkResolver')->willReturn($didJwkResolverMock); - $didJwkResolverMock->method('generateDidJwkFromJwk')->willReturn('did:jwk:test'); - $vcHelpersMock = $this->createMock(VcHelpers::class); $this->verifiableCredentialsMock->method('helpers')->willReturn($vcHelpersMock); $vcHelpersMock->method('arr')->willReturn($this->createMock(VcArr::class)); @@ -309,22 +317,22 @@ protected function dispatch(array $requestData): void /** - * A deployment with Verifiable Credentials switched off is refused here, and refused without the DID - * facade being built. + * A deployment with Verifiable Credentials switched off is refused here, and refused without an + * issuer identity being resolved. * - * Building it reads the DID destination settings and instantiates the class `vci_cache_adapter` - * names - neither of which such a deployment has any reason to have configured correctly, or at all. - * The container resolves every constructor argument before the constructor body runs, so taking a - * built facade meant a malformed one of those settings answered this endpoint in place of the guard, - * with a 500 where a 403 was intended. + * Resolving one builds the DID facade, which reads the DID destination settings and instantiates + * the class `vci_cache_adapter` names - neither of which such a deployment has any reason to have + * configured correctly, or at all. The container resolves every constructor argument before the + * constructor body runs, so anything which builds that facade eagerly answers this endpoint in + * place of the guard, with a 500 where a 403 was intended. */ - public function testRefusesWhenVciIsDisabledWithoutBuildingTheDidFacade(): void + public function testRefusesWhenVciIsDisabledWithoutResolvingAnIssuerIdentity(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); - $this->didFactoryMock = $this->createMock(DidFactory::class); - $this->didFactoryMock->expects($this->never())->method('build'); + $this->vciIssuerIdentityResolverMock = $this->createMock(VciIssuerIdentityResolver::class); + $this->vciIssuerIdentityResolverMock->expects($this->never())->method('resolve'); $this->expectException(OidcServerException::class); @@ -344,8 +352,9 @@ protected function sut(): CredentialIssuerCredentialController $this->loggerServiceMock, $this->requestParamsResolverMock, $this->userRepositoryMock, - $this->didFactoryMock, + $this->vciIssuerIdentityResolverMock, $this->issuerStateRepositoryMock, + $this->vciIssuerIdentityRepositoryMock, $this->openId4VciProofValidatorMock, $this->vciContextResolverMock, $this->credentialStatusIssuerMock, diff --git a/tests/unit/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationControllerTest.php new file mode 100644 index 00000000..ae974692 --- /dev/null +++ b/tests/unit/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationControllerTest.php @@ -0,0 +1,116 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + + $this->routesMock = $this->createMock(Routes::class); + $this->routesMock->method('getModuleUrl')->willReturn(self::JWKS_URI); + $this->routesMock->method('newJsonResponse')->willReturnCallback( + static fn(?array $data, int $status = 200, array $headers = []): JsonResponse => + new JsonResponse($data, $status, $headers), + ); + + $this->loggerServiceMock = $this->createMock(LoggerService::class); + } + + + protected function sut(): JwtVcIssuerConfigurationController + { + return new JwtVcIssuerConfigurationController( + $this->moduleConfigMock, + $this->routesMock, + $this->loggerServiceMock, + ); + } + + + public function testPublishesTheIssuerAndItsKeySet(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(true); + + $configuration = json_decode((string)$this->sut()->configuration()->getContent(), true); + + $this->assertSame(self::ISSUER, $configuration[ClaimsEnum::Issuer->value] ?? null); + $this->assertSame(self::JWKS_URI, $configuration[ClaimsEnum::JwksUri->value] ?? null); + } + + + public function testRefusesWhileIssuanceIsDisabled(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + $this->moduleConfigMock->method('getVciIssuerIdentifierMode') + ->willReturn(VciIssuerIdentifierModeEnum::DidJwk); + + $this->expectException(OidcServerException::class); + + $this->sut(); + } + + + /** + * Under the `https` issuer identity this document is how an SD-JWT VC verifier finds the key set + * to check an already issued credential against, so switching issuance off must not withdraw it. + */ + public function testKeepsServingWhileCredentialsAreVerifiedThroughIt(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + $this->moduleConfigMock->method('getVciIssuerIdentifierMode') + ->willReturn(VciIssuerIdentifierModeEnum::Https); + + $configuration = json_decode((string)$this->sut()->configuration()->getContent(), true); + + $this->assertSame(self::ISSUER, $configuration[ClaimsEnum::Issuer->value] ?? null); + } + + + /** + * A malformed identity mode is reported on the configuration screen which owns it, and must not + * decide this endpoint one way or the other by throwing out of the constructor. + */ + public function testAMalformedIssuerIdentityModeLeavesTheEndpointAsItWas(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + $this->moduleConfigMock->method('getVciIssuerIdentifierMode') + ->willThrowException(new ConfigurationError('nope')); + + $this->expectException(OidcServerException::class); + + $this->sut(); + } +} diff --git a/tests/unit/src/Controllers/VerifiableCredentials/VciDidDocumentControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/VciDidDocumentControllerTest.php new file mode 100644 index 00000000..4ad08be9 --- /dev/null +++ b/tests/unit/src/Controllers/VerifiableCredentials/VciDidDocumentControllerTest.php @@ -0,0 +1,221 @@ + self::DID_WEB]; + + + protected MockObject $moduleConfigMock; + + protected MockObject $didFactoryMock; + + protected MockObject $didDocumentFactoryMock; + + protected MockObject $routesMock; + + protected MockObject $loggerServiceMock; + + protected SignatureKeyPairBag $vciSignatureKeyPairBag; + + + protected function setUp(): void + { + $this->vciSignatureKeyPairBag = new SignatureKeyPairBag(); + + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciIssuerDidIdentifier')->willReturn(self::DID_WEB); + $this->moduleConfigMock->method('getVciSignatureKeyPairBag') + ->willReturn($this->vciSignatureKeyPairBag); + + $didDocumentMock = $this->createMock(DidDocument::class); + $didDocumentMock->method('jsonSerialize')->willReturn(self::DOCUMENT); + + $this->didDocumentFactoryMock = $this->createMock(DidDocumentFactory::class); + $this->didDocumentFactoryMock->method('forDidWeb')->willReturn($didDocumentMock); + + $didMock = $this->createMock(Did::class); + $didMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); + + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->method('build')->willReturn($didMock); + + $this->loggerServiceMock = $this->createMock(LoggerService::class); + + $this->routesMock = $this->createMock(Routes::class); + // A real JsonResponse rather than a stand-in, so that the content type this controller sets is + // checked against what JsonResponse itself would do with it. + $this->routesMock->method('newJsonResponse')->willReturnCallback( + static fn(?array $data, int $status = 200, array $headers = []): JsonResponse => + new JsonResponse($data, $status, $headers), + ); + $this->routesMock->method('newResponse')->willReturnCallback( + static fn(?string $content, int $status = 200, array $headers = []): Response => + new Response($content, $status, $headers), + ); + } + + + protected function sut(): VciDidDocumentController + { + return new VciDidDocumentController( + $this->moduleConfigMock, + $this->didFactoryMock, + $this->routesMock, + $this->loggerServiceMock, + ); + } + + + public function testPublishesTheDocumentForTheConfiguredDidWeb(): void + { + $response = $this->sut()->didDocument(); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $this->assertSame(json_encode(self::DOCUMENT), $response->getContent()); + $this->assertSame( + VciDidDocumentController::MEDIA_TYPE, + $response->headers->get('Content-Type'), + ); + $this->assertSame('*', $response->headers->get('Access-Control-Allow-Origin')); + $this->assertStringContainsString('max-age=', (string)$response->headers->get('Cache-Control')); + } + + + /** + * Every configured key, not only the pair which is currently signing: a key which has signed a + * credential still in circulation has to stay resolvable, and only assertionMethod, since these + * keys are used for nothing else. + */ + public function testPublishesEveryKeyUnderAssertionMethodOnly(): void + { + $this->didDocumentFactoryMock->expects($this->once()) + ->method('forDidWeb') + ->with( + $this->callback( + fn(DidUrl $did): bool => $did->getDid() === self::DID_WEB, + ), + $this->identicalTo($this->vciSignatureKeyPairBag), + [VerificationRelationshipEnum::AssertionMethod], + VerificationMethodTypeEnum::JsonWebKey2020, + ); + + $this->sut()->didDocument(); + } + + + public function testAnswersNotFoundWhenNoDidWebIsConfigured(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciIssuerDidIdentifier')->willReturn(null); + + $response = $this->sut()->didDocument(); + + $this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + $this->assertSame( + '*', + $response->headers->get('Access-Control-Allow-Origin'), + 'A browser based verifier has to be able to tell this from a network failure.', + ); + } + + + /** + * The identity is retired by removing the identifier, not by switching mode, so publication is + * decided by that one option and does not consult the mode at all. + * + * Stated as never reading the mode rather than as looping over its values, because the mode is + * also a thing which can be misconfigured: were it read here, a typo in a setting which has no + * bearing on this document would stop it being served and take every credential issued under the + * DID down with it. + */ + public function testPublicationIsDecidedByTheIdentifierAloneAndNeverByTheMode(): void + { + $this->moduleConfigMock->expects($this->never())->method('getVciIssuerIdentifierMode'); + $this->moduleConfigMock->expects($this->never())->method('getVciIssuerIdentifier'); + + $response = $this->sut()->didDocument(); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $this->assertSame(json_encode(self::DOCUMENT), $response->getContent()); + } + + + /** + * Not gated on the issuance switch, on the same reasoning as the Status List endpoint: turning + * issuance off must stop new credentials being issued, not make the existing ones unverifiable. + */ + public function testKeepsPublishingWhileIssuanceIsDisabled(): void + { + $this->moduleConfigMock->expects($this->never())->method('getVciEnabled'); + + $response = $this->sut()->didDocument(); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + + public function testFailsClosedWhenTheConfiguredIdentifierCanNotBeResolved(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getVciIssuerDidIdentifier') + ->willThrowException(new RuntimeException('nope')); + + $this->loggerServiceMock->expects($this->once())->method('error'); + + $response = $this->sut()->didDocument(); + + $this->assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + } + + + /** + * A document which does not describe the keys actually signing would have a verifier reject valid + * credentials, which is worse than an outage it can retry. + */ + public function testFailsClosedWhenTheDocumentCanNotBeBuilt(): void + { + $this->didDocumentFactoryMock = $this->createMock(DidDocumentFactory::class); + $this->didDocumentFactoryMock->method('forDidWeb') + ->willThrowException(new RuntimeException('nope')); + + $didMock = $this->createMock(Did::class); + $didMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->method('build')->willReturn($didMock); + + $this->loggerServiceMock->expects($this->once())->method('error'); + + $response = $this->sut()->didDocument(); + + $this->assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + $this->assertSame('', $response->getContent()); + } +} diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index bcd1faf6..21d2184e 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -8,6 +8,7 @@ use Defuse\Crypto\Key; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; @@ -19,6 +20,7 @@ use SimpleSAML\Module\oidc\Codebooks\StatusListExpiryLaneEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; +use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; @@ -1902,4 +1904,154 @@ public function testRejectsAnUnknownDidAddressPinningMode(): void 'whenever-convenient', ))->getVciDidAddressPinningMode(); } + + + /** + * The default is what the module did before the option existed. + * + * @throws \Exception + */ + public function testIssuerIdentifierModeDefaultsToDidJwk(): void + { + $sut = $this->sut(); + + $this->assertSame(VciIssuerIdentifierModeEnum::DidJwk, $sut->getVciIssuerIdentifierMode()); + $this->assertNull($sut->getVciIssuerDidIdentifier()); + $this->assertSame(VciIssuerIdentifierModeEnum::DidJwk, $sut->getVciIssuerIdentifier()->getMode()); + } + + + /** + * @throws \Exception + */ + public function testIssuerIdentifierModeAcceptsAnEnumCaseOrItsValue(): void + { + $this->assertSame( + VciIssuerIdentifierModeEnum::Https, + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + VciIssuerIdentifierModeEnum::Https, + ))->getVciIssuerIdentifierMode(), + ); + + $this->assertSame( + VciIssuerIdentifierModeEnum::Https, + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + 'https', + ))->getVciIssuerIdentifierMode(), + ); + } + + + /** + * @throws \Exception + */ + public function testRejectsAnUnknownIssuerIdentifierMode(): void + { + $this->expectException(ConfigurationError::class); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + 'did:sov', + ))->getVciIssuerIdentifierMode(); + } + + + /** + * @throws \Exception + */ + public function testResolvesTheConfiguredDidWebIdentifier(): void + { + $identifier = $this->sut(overrides: array_merge($this->overrides, [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidWeb, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => 'did:web:example.org', + ]))->getVciIssuerIdentifier(); + + $this->assertTrue($identifier->isIssuingUnderDidWeb()); + $this->assertSame('did:web:example.org', $identifier->getDidWeb()); + } + + + /** + * The state the publish-after-mode-change rule rests on: the identifier is still configured, so + * its document is still published, but nothing is issued under it any more. + * + * @throws \Exception + */ + public function testKeepsADidWebIdentifierConfiguredUnderAnotherMode(): void + { + $identifier = $this->sut(overrides: array_merge($this->overrides, [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidJwk, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => 'did:web:example.org', + ]))->getVciIssuerIdentifier(); + + $this->assertFalse($identifier->isIssuingUnderDidWeb()); + $this->assertSame('did:web:example.org', $identifier->getDidWeb()); + } + + + /** + * @throws \Exception + */ + public function testRejectsDidWebModeWithoutAnIdentifierToIssueUnder(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage(ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE, + VciIssuerIdentifierModeEnum::DidWeb, + ))->getVciIssuerIdentifier(); + } + + + /** + * Refused where it is configured rather than after credentials have been issued under it. These + * are all syntactically DIDs; none of them is one this library could ever resolve. + * + * @throws \Exception + */ + #[DataProvider('unresolvableDidWebIdentifierDataProvider')] + public function testRejectsADidWebIdentifierWhichCouldNotBeResolved(string $identifier): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage(ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER); + + $this->sut(overrides: $this->withOption( + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + $identifier, + ))->getVciIssuerDidIdentifier(); + } + + + /** + * @return array + */ + public static function unresolvableDidWebIdentifierDataProvider(): array + { + return [ + 'single label host' => ['did:web:localhost'], + 'IPv4 literal' => ['did:web:127.0.0.1'], + 'percent encoded segment' => ['did:web:example.org:%2Fetc'], + 'another method' => ['did:key:z6Mk'], + 'not a DID at all' => ['https://example.org'], + 'carries a fragment' => ['did:web:example.org#0'], + ]; + } + + + /** + * An option left as an empty string is the same as not setting it, since a deployment which + * cleared the value meant to stop publishing rather than to publish under nothing. + * + * @throws \Exception + */ + public function testTreatsABlankDidWebIdentifierAsNoneAtAll(): void + { + $this->assertNull( + $this->sut(overrides: $this->withOption(ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, ' ')) + ->getVciIssuerDidIdentifier(), + ); + } } diff --git a/tests/unit/src/Repositories/VciIssuerIdentityRepositoryTest.php b/tests/unit/src/Repositories/VciIssuerIdentityRepositoryTest.php new file mode 100644 index 00000000..6d99b515 --- /dev/null +++ b/tests/unit/src/Repositories/VciIssuerIdentityRepositoryTest.php @@ -0,0 +1,148 @@ + 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ]; + + Configuration::loadFromArray($config, '', 'simplesaml'); + (new DatabaseMigration())->migrate(); + } + + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + + $this->repository = new VciIssuerIdentityRepository( + $this->moduleConfigMock, + Database::getInstance(), + null, + new Helpers(), + ); + + Database::getInstance()->write('DELETE FROM ' . $this->repository->getTableName()); + } + + + public function testGetTableName(): void + { + $this->assertSame('phpunit_oidc_vci_issuer_identity', $this->repository->getTableName()); + } + + + public function testRecordsNothingUntilAnIdentityIsUsed(): void + { + $this->assertSame([], $this->repository->getAllUsed()); + } + + + public function testRecordsTheIdentityAndTheModeItCameFrom(): void + { + $this->repository->recordUsage($this->identity(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB)); + + $this->assertSame( + [self::DID_WEB => VciIssuerIdentifierModeEnum::DidWeb->value], + $this->repository->getAllUsed(), + ); + } + + + /** + * Recorded once, however many credentials are issued under it. + */ + public function testRecordingTheSameIdentityAgainChangesNothing(): void + { + $identity = $this->identity(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB); + + $this->repository->recordUsage($identity); + $this->repository->recordUsage($identity); + $this->repository->recordUsage($identity); + + $this->assertCount(1, $this->repository->getAllUsed()); + } + + + /** + * The whole point of keeping a set rather than the first identity: a deployment which moved away + * and back would otherwise compare equal to what is stored and report nothing, while credentials + * issued in between stay unverifiable. + */ + public function testKeepsEveryIdentityEverUsed(): void + { + $this->repository->recordUsage($this->identity(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB)); + $this->repository->recordUsage($this->identity(VciIssuerIdentifierModeEnum::DidJwk, 'did:jwk:first')); + $this->repository->recordUsage($this->identity(VciIssuerIdentifierModeEnum::DidWeb, 'did:web:other.org')); + $this->repository->recordUsage($this->identity(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB)); + + $this->assertSame( + [ + self::DID_WEB => VciIssuerIdentifierModeEnum::DidWeb->value, + 'did:jwk:first' => VciIssuerIdentifierModeEnum::DidJwk->value, + 'did:web:other.org' => VciIssuerIdentifierModeEnum::DidWeb->value, + ], + $this->repository->getAllUsed(), + ); + } + + + /** + * A did:jwk carrying an RSA key runs past what MySQL will index, which is why the key is a hash of + * the identifier rather than the identifier itself. + */ + public function testRecordsAnIdentifierTooLongToBeIndexed(): void + { + $identifier = 'did:jwk:' . str_repeat('a', 4000); + + $this->repository->recordUsage($this->identity(VciIssuerIdentifierModeEnum::DidJwk, $identifier)); + + $this->assertSame( + [$identifier => VciIssuerIdentifierModeEnum::DidJwk->value], + $this->repository->getAllUsed(), + ); + } + + + protected function identity(VciIssuerIdentifierModeEnum $mode, string $issuer): VciIssuerIdentity + { + return new VciIssuerIdentity($mode, $issuer, $issuer . '#0'); + } +} diff --git a/tests/unit/src/VerifiableCredentials/Values/VciIssuerIdentifierTest.php b/tests/unit/src/VerifiableCredentials/Values/VciIssuerIdentifierTest.php new file mode 100644 index 00000000..708276f2 --- /dev/null +++ b/tests/unit/src/VerifiableCredentials/Values/VciIssuerIdentifierTest.php @@ -0,0 +1,62 @@ +assertSame(VciIssuerIdentifierModeEnum::DidJwk, $identifier->getMode()); + $this->assertNull($identifier->getDidWeb()); + $this->assertFalse($identifier->isIssuingUnderDidWeb()); + } + + + public function testCarriesTheDidWebItIssuesUnder(): void + { + $identifier = new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB); + + $this->assertSame(self::DID_WEB, $identifier->getDidWeb()); + $this->assertTrue($identifier->isIssuingUnderDidWeb()); + } + + + /** + * The state which keeps a published document alive after the deployment stopped issuing under it. + */ + public function testKeepsADidWebWhichIsNoLongerIssuedUnder(): void + { + foreach ([VciIssuerIdentifierModeEnum::DidJwk, VciIssuerIdentifierModeEnum::Https] as $mode) { + $identifier = new VciIssuerIdentifier($mode, self::DID_WEB); + + $this->assertSame(self::DID_WEB, $identifier->getDidWeb()); + $this->assertFalse( + $identifier->isIssuingUnderDidWeb(), + 'The DID is published, but nothing is issued under it.', + ); + } + } + + + public function testRefusesDidWebModeWithoutADidWeb(): void + { + $this->expectException(OidcException::class); + $this->expectExceptionMessage(VciIssuerIdentifierModeEnum::DidWeb->value); + + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidWeb); + } +} diff --git a/tests/unit/src/VerifiableCredentials/Values/VciIssuerIdentityTest.php b/tests/unit/src/VerifiableCredentials/Values/VciIssuerIdentityTest.php new file mode 100644 index 00000000..fe12bbe1 --- /dev/null +++ b/tests/unit/src/VerifiableCredentials/Values/VciIssuerIdentityTest.php @@ -0,0 +1,27 @@ +assertSame(VciIssuerIdentifierModeEnum::DidWeb, $identity->getMode()); + $this->assertSame('did:web:example.org', $identity->getIssuer()); + $this->assertSame('did:web:example.org#key-01', $identity->getKeyId()); + } +} diff --git a/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php b/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php new file mode 100644 index 00000000..231d5cba --- /dev/null +++ b/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php @@ -0,0 +1,211 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + + $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); + $this->didJwkResolverMock->method('generateDidJwkFromJwk')->willReturn(self::DID_JWK); + + $this->didDocumentFactoryMock = $this->createMock(DidDocumentFactory::class); + $this->didDocumentFactoryMock->method('verificationMethodIdFor')->willReturnCallback( + static fn(DidUrl $did, string $keyId): DidUrl => new DidUrl($did->getDid() . '#' . $keyId), + ); + + $this->didMock = $this->createMock(Did::class); + $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + $this->didMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); + + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->method('build')->willReturn($this->didMock); + + $keyPairMock = $this->createMock(KeyPair::class); + $keyPairMock->method('getKeyId')->willReturn(self::KEY_ID); + $keyPairMock->method('getPublicKey')->willReturn( + (new JwkDecoratorFactory())->fromData(['kty' => 'EC']), + ); + + $signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $signatureKeyPairMock->method('getKeyPair')->willReturn($keyPairMock); + + $this->signatureKeyPair = $signatureKeyPairMock; + } + + + protected function sut(): VciIssuerIdentityResolver + { + return new VciIssuerIdentityResolver($this->moduleConfigMock, $this->didFactoryMock); + } + + + public function testDidJwkNamesTheKeyByTheFragmentTheMethodDefines(): void + { + $identity = $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidJwk), + $this->signatureKeyPair, + ); + + $this->assertSame(VciIssuerIdentifierModeEnum::DidJwk, $identity->getMode()); + $this->assertSame(self::DID_JWK, $identity->getIssuer()); + $this->assertSame(self::DID_JWK . '#0', $identity->getKeyId()); + } + + + /** + * The key identifier has to be minted by the same code which builds the published document, or a + * credential could name a verification method the document does not carry. + */ + public function testDidWebNamesTheKeyThroughTheDocumentFactory(): void + { + $this->didDocumentFactoryMock->expects($this->once())->method('verificationMethodIdFor'); + + $identity = $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB), + $this->signatureKeyPair, + ); + + $this->assertSame(VciIssuerIdentifierModeEnum::DidWeb, $identity->getMode()); + $this->assertSame(self::DID_WEB, $identity->getIssuer()); + $this->assertSame(self::DID_WEB . '#' . self::KEY_ID, $identity->getKeyId()); + } + + + public function testHttpsNamesTheIssuerUrlAndTheKeySetIdentifier(): void + { + $identity = $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::Https), + $this->signatureKeyPair, + ); + + $this->assertSame(VciIssuerIdentifierModeEnum::Https, $identity->getMode()); + $this->assertSame(self::ISSUER, $identity->getIssuer()); + $this->assertSame(self::KEY_ID, $identity->getKeyId()); + } + + + /** + * The did:web identifier is taken from the identifier handed in, never from configuration, so that + * a caller identifying something the way it was created is not overruled by today's settings. + */ + public function testTheConfiguredIdentityIsNeverReadForTheDidModes(): void + { + $this->moduleConfigMock->expects($this->never())->method('getVciIssuerIdentifier'); + $this->moduleConfigMock->expects($this->never())->method('getVciIssuerIdentifierMode'); + $this->moduleConfigMock->expects($this->never())->method('getVciIssuerDidIdentifier'); + + $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB), + $this->signatureKeyPair, + ); + + $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidJwk), + $this->signatureKeyPair, + ); + } + + + public function testReportsAFailureToDeriveTheDidJwk(): void + { + $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); + $this->didJwkResolverMock->method('generateDidJwkFromJwk') + ->willThrowException(new RuntimeException('nope')); + $this->didMock = $this->createMock(Did::class); + $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->method('build')->willReturn($this->didMock); + + $this->expectException(OidcException::class); + $this->expectExceptionMessage('did:jwk'); + + $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidJwk), + $this->signatureKeyPair, + ); + } + + + public function testReportsAFailureToMintTheDidWebVerificationMethodId(): void + { + $this->didDocumentFactoryMock = $this->createMock(DidDocumentFactory::class); + $this->didDocumentFactoryMock->method('verificationMethodIdFor') + ->willThrowException(new RuntimeException('nope')); + $this->didMock = $this->createMock(Did::class); + $this->didMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->method('build')->willReturn($this->didMock); + + $this->expectException(OidcException::class); + $this->expectExceptionMessage('did:web'); + + $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB), + $this->signatureKeyPair, + ); + } + + + public function testReportsAnIssuerUrlWhichCanNotBeResolved(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getIssuer')->willThrowException(new RuntimeException('nope')); + + $this->expectException(OidcException::class); + $this->expectExceptionMessage('issuer URL'); + + $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::Https), + $this->signatureKeyPair, + ); + } +} From e1a3e62eff4ad2c6c1db054f0a950f8008369e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 3 Sep 2026 06:45:32 +0200 Subject: [PATCH 11/15] Publish the DID document without the resolver's configuration --- locales/en/LC_MESSAGES/oidc.po | 8 +++ locales/es/LC_MESSAGES/oidc.po | 8 +++ locales/fr/LC_MESSAGES/oidc.po | 8 +++ locales/hr/LC_MESSAGES/oidc.po | 8 +++ locales/it/LC_MESSAGES/oidc.po | 8 +++ locales/nl/LC_MESSAGES/oidc.po | 8 +++ .../ConfigOverview/VciOverviewBuilder.php | 28 ++++++---- .../CredentialIssuerCredentialController.php | 4 +- .../VciDidDocumentController.php | 8 +-- src/Factories/DidFactory.php | 56 +++++++++++++++++-- src/Server/Exceptions/OidcServerException.php | 5 +- .../VciIssuerIdentityResolver.php | 4 +- .../ConfigOverview/VciOverviewBuilderTest.php | 35 ++++++++++-- .../VciDidDocumentControllerTest.php | 30 +++++++--- .../VciIssuerIdentityResolverTest.php | 38 ++++++++----- 15 files changed, 201 insertions(+), 55 deletions(-) diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index 2f8d1992..6401b832 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -2431,3 +2431,11 @@ msgid "" "longer be verified. Set it as the did:web identifier again to resume " "publishing it, or serve the document it needs by other means." msgstr "" + +msgid "" +"An identity listed here is no longer one this deployment publishes - a " +"did:web whose document is no longer served, or an issuer URL which is no " +"longer this issuer - so every credential issued under it can no longer be " +"verified. Restore that identity to resume publishing it, or serve what it " +"needs by other means." +msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index 315c4de2..ca0ebac5 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -2431,3 +2431,11 @@ msgid "" "longer be verified. Set it as the did:web identifier again to resume " "publishing it, or serve the document it needs by other means." msgstr "" + +msgid "" +"An identity listed here is no longer one this deployment publishes - a " +"did:web whose document is no longer served, or an issuer URL which is no " +"longer this issuer - so every credential issued under it can no longer be " +"verified. Restore that identity to resume publishing it, or serve what it " +"needs by other means." +msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index d833ca14..709be758 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -2431,3 +2431,11 @@ msgid "" "longer be verified. Set it as the did:web identifier again to resume " "publishing it, or serve the document it needs by other means." msgstr "" + +msgid "" +"An identity listed here is no longer one this deployment publishes - a " +"did:web whose document is no longer served, or an issuer URL which is no " +"longer this issuer - so every credential issued under it can no longer be " +"verified. Restore that identity to resume publishing it, or serve what it " +"needs by other means." +msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index b98fbf78..2668b805 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -2478,3 +2478,11 @@ msgid "" "longer be verified. Set it as the did:web identifier again to resume " "publishing it, or serve the document it needs by other means." msgstr "" + +msgid "" +"An identity listed here is no longer one this deployment publishes - a " +"did:web whose document is no longer served, or an issuer URL which is no " +"longer this issuer - so every credential issued under it can no longer be " +"verified. Restore that identity to resume publishing it, or serve what it " +"needs by other means." +msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index bd160c54..ea3957d9 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -2431,3 +2431,11 @@ msgid "" "longer be verified. Set it as the did:web identifier again to resume " "publishing it, or serve the document it needs by other means." msgstr "" + +msgid "" +"An identity listed here is no longer one this deployment publishes - a " +"did:web whose document is no longer served, or an issuer URL which is no " +"longer this issuer - so every credential issued under it can no longer be " +"verified. Restore that identity to resume publishing it, or serve what it " +"needs by other means." +msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index f9265919..6b1a07e4 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -2385,3 +2385,11 @@ msgid "" "longer be verified. Set it as the did:web identifier again to resume " "publishing it, or serve the document it needs by other means." msgstr "" + +msgid "" +"An identity listed here is no longer one this deployment publishes - a " +"did:web whose document is no longer served, or an issuer URL which is no " +"longer this issuer - so every credential issued under it can no longer be " +"verified. Restore that identity to resume publishing it, or serve what it " +"needs by other means." +msgstr "" diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index 3f4b07b9..fd125847 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -495,10 +495,14 @@ function () use ($label): Row { /** * Which identities this deployment has actually issued credentials under. * - * Configuration cannot answer this, and it is the question which matters: a did:web identity that - * was issued under and is no longer published leaves those credentials unverifiable, and nothing - * else would report it. Only did:web is flagged, since a did:jwk credential carries its own key - * and an issuer URL keeps resolving through the published key set. + * Configuration cannot answer this, and it is the question which matters: an identity credentials + * were issued under which this deployment no longer publishes leaves them unverifiable, and + * nothing else would report it. + * + * A did:jwk is never flagged, since such a credential carries its own key and stays verifiable + * whatever is configured afterwards. A did:web is flagged once it is no longer the published + * identifier. An issuer URL is flagged once it is no longer this issuer, because the well-known + * metadata and the key set those credentials resolve through moved with it. */ protected function buildIssuedIdentitiesRow(): Row { @@ -507,6 +511,7 @@ protected function buildIssuedIdentitiesRow(): Row try { $used = $this->vciIssuerIdentityRepository->getAllUsed(); $didWeb = $this->moduleConfig->getVciIssuerIdentifier()->getDidWeb(); + $issuer = $this->moduleConfig->getIssuer(); } catch (Throwable $exception) { $this->logger->error( 'Configuration overview could not read which issuer identities were issued under: ' . @@ -542,8 +547,11 @@ protected function buildIssuedIdentitiesRow(): Row $noLongerPublished = array_keys(array_filter( $used, - static fn(string $mode, string $identifier): bool => - $mode === VciIssuerIdentifierModeEnum::DidWeb->value && $identifier !== $didWeb, + static fn(string $mode, string $identifier): bool => match ($mode) { + VciIssuerIdentifierModeEnum::DidWeb->value => $identifier !== $didWeb, + VciIssuerIdentifierModeEnum::Https->value => $identifier !== $issuer, + default => false, + }, ARRAY_FILTER_USE_BOTH, )); @@ -557,10 +565,10 @@ protected function buildIssuedIdentitiesRow(): Row 'unless a lifetime is configured for them.', ), $noLongerPublished === [] ? null : Translate::noop( - 'A did:web identity listed here is no longer the configured one, so its DID document ' . - 'is no longer published and every credential issued under it can no longer be ' . - 'verified. Set it as the did:web identifier again to resume publishing it, or serve ' . - 'the document it needs by other means.', + 'An identity listed here is no longer one this deployment publishes - a did:web ' . + 'whose document is no longer served, or an issuer URL which is no longer this ' . + 'issuer - so every credential issued under it can no longer be verified. Restore ' . + 'that identity to resume publishing it, or serve what it needs by other means.', ), ); } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 909a3697..cb06d50b 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -878,9 +878,7 @@ protected function setCredentialClaimValue(array &$claims, array $path, mixed $v $temp = []; } - if (!isset($temp[$key])) { - $temp[$key] = []; - } + $temp[$key] ??= []; $temp = &$temp[$key]; } diff --git a/src/Controllers/VerifiableCredentials/VciDidDocumentController.php b/src/Controllers/VerifiableCredentials/VciDidDocumentController.php index 7e50336a..a029f845 100644 --- a/src/Controllers/VerifiableCredentials/VciDidDocumentController.php +++ b/src/Controllers/VerifiableCredentials/VciDidDocumentController.php @@ -50,9 +50,9 @@ class VciDidDocumentController public function __construct( protected readonly ModuleConfig $moduleConfig, - // The factory rather than the facade: building one reads the DID resolution settings and - // instantiates the configured cache adapter, and nothing here resolves anything over the - // network. See CredentialIssuerCredentialController for the same reasoning. + // Asked only for its local document factory, never for a built facade: this document is + // assembled from key material already on disk, so it must not be able to fail on a cache + // adapter or an outbound setting which exists to govern resolving other people's DIDs. protected readonly DidFactory $didFactory, protected readonly Routes $routes, protected readonly LoggerService $loggerService, @@ -82,7 +82,7 @@ public function didDocument(): Response } try { - $didDocument = $this->didFactory->build()->didDocumentFactory()->forDidWeb( + $didDocument = $this->didFactory->didDocumentFactory()->forDidWeb( new DidUrl($didWeb), // The whole key set rather than the pair which is currently signing. Every key which // has signed a credential still in circulation has to be here, or the credentials it diff --git a/src/Factories/DidFactory.php b/src/Factories/DidFactory.php index 234c594c..8e5edcd2 100644 --- a/src/Factories/DidFactory.php +++ b/src/Factories/DidFactory.php @@ -7,7 +7,9 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Did; +use SimpleSAML\OpenID\Did\DidJwkResolver; use SimpleSAML\OpenID\Did\DidWebResolver; +use SimpleSAML\OpenID\Did\Factories\DidDocumentFactory; /** * Builds the library entry point for Decentralized Identifier resolution. @@ -29,6 +31,13 @@ class DidFactory */ protected ?Did $did = null; + /** + * A facade for the work which never leaves this host, kept apart from the one above. + * + * @see localDid() + */ + protected ?Did $localDid = null; + /** * Note the configuration rather than a built policy. Building one throws when the configuration is @@ -72,12 +81,51 @@ public function build(): Did /** - * @throws \SimpleSAML\Error\ConfigurationError - * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + * Builds the DID document this deployment publishes for its own `did:web` identifier. + * + * Deliberately not reached through {@see build()}. Building that facade instantiates the + * configured cache adapter and resolves the DID outbound destination settings, neither of which + * this needs: the document is assembled from key material already on disk and nothing is fetched. + * Going through it would mean a malformed `vci_cache_adapter`, or an outbound option which only + * governs resolving *other people's* DIDs, stopping this document being served - and a credential + * issued under a `did:web` identity can only be verified by resolving that document, so an + * unrelated setting would make credentials already in wallets permanently unverifiable. + * * @throws \SimpleSAML\OpenID\Exceptions\DidException - * @throws \SimpleSAML\OpenID\Exceptions\DestinationPolicyException - * @throws \Exception */ + public function didDocumentFactory(): DidDocumentFactory + { + return $this->localDid()->didDocumentFactory(); + } + + + /** + * Derives a `did:jwk` from a key already in hand, which is likewise a local operation. + * + * @throws \SimpleSAML\OpenID\Exceptions\DidException + */ + public function didJwkResolver(): DidJwkResolver + { + return $this->localDid()->didJwkResolver(); + } + + + /** + * A facade constructed from nothing but a logger, for the operations above. + * + * Every constructor argument the library takes is optional, so this reads no configuration at all + * and therefore cannot fail on any of it. The resolvers hanging off it are deliberately not + * exposed: its destination policy is the library's own default rather than this deployment's, so + * resolving through it would ignore the configured allowlist. That is why the two accessors above + * hand out the local factories rather than the facade itself, and why anything which resolves a + * DID supplied from outside must go through {@see build()}. + */ + protected function localDid(): Did + { + return $this->localDid ??= new Did(logger: $this->loggerService); + } + + protected function buildDid(): Did { return new Did( diff --git a/src/Server/Exceptions/OidcServerException.php b/src/Server/Exceptions/OidcServerException.php index b2805e67..35614dd8 100644 --- a/src/Server/Exceptions/OidcServerException.php +++ b/src/Server/Exceptions/OidcServerException.php @@ -524,10 +524,7 @@ public function generateHttpResponse( $payload = $this->getPayload(); - if ($this->responseMode === null) { - // Fallback to useFragment if responseMode is not set - $this->responseMode = $useFragment ? new FragmentResponseMode() : new QueryResponseMode(); - } + $this->responseMode ??= $useFragment ? new FragmentResponseMode() : new QueryResponseMode(); if ($this->redirectUri !== null) { return $this->responseMode->buildResponse($this->redirectUri, $payload)->generateHttpResponse($response); diff --git a/src/VerifiableCredentials/VciIssuerIdentityResolver.php b/src/VerifiableCredentials/VciIssuerIdentityResolver.php index 381ec4d4..5cf57969 100644 --- a/src/VerifiableCredentials/VciIssuerIdentityResolver.php +++ b/src/VerifiableCredentials/VciIssuerIdentityResolver.php @@ -65,7 +65,7 @@ public function resolve(VciIssuerIdentifier $identifier, SignatureKeyPair $signa protected function forDidJwk(SignatureKeyPair $signatureKeyPair): VciIssuerIdentity { try { - $didJwk = $this->didFactory->build()->didJwkResolver()->generateDidJwkFromJwk( + $didJwk = $this->didFactory->didJwkResolver()->generateDidJwkFromJwk( $signatureKeyPair->getKeyPair()->getPublicKey()->jwk()->all(), ); } catch (Throwable $throwable) { @@ -89,7 +89,7 @@ protected function forDidJwk(SignatureKeyPair $signatureKeyPair): VciIssuerIdent protected function forDidWeb(string $didWeb, SignatureKeyPair $signatureKeyPair): VciIssuerIdentity { try { - $keyId = $this->didFactory->build()->didDocumentFactory()->verificationMethodIdFor( + $keyId = $this->didFactory->didDocumentFactory()->verificationMethodIdFor( new DidUrl($didWeb), $signatureKeyPair->getKeyPair()->getKeyId(), )->getValue(); diff --git a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php index 3133b6a0..60f222b6 100644 --- a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php @@ -33,6 +33,8 @@ class VciOverviewBuilderTest extends TestCase protected const string DID_WEB_WITH_PATH_URL = 'https://example.org/simplesaml/module.php/oidc/did.json'; + protected const string ISSUER = 'https://issuer.example.org'; + /** * A minimal but realistic credential configuration, shaped like the one in the config template. @@ -1387,20 +1389,21 @@ public function testSaysNothingHasBeenIssuedYet(): void /** - * A did:jwk credential carries its own key and an issuer URL keeps resolving through the published - * key set, so neither is a reason to warn. Only a did:web identity which is no longer published is. + * A did:jwk credential carries its own key, so it stays verifiable whatever is configured later. + * The other two are only resolvable while the deployment still publishes them, and here it does. */ - public function testDoesNotWarnAboutIdentitiesWhichStayResolvable(): void + public function testDoesNotWarnAboutIdentitiesWhichAreStillPublished(): void { $row = $this->findRowByLabel( $this->buildVciOverviewBuilder( [ + ModuleConfig::OPTION_ISSUER => self::ISSUER, ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidWeb, ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => self::DID_WEB_WITH_PATH, ], [ 'did:jwk:retired' => VciIssuerIdentifierModeEnum::DidJwk->value, - 'https://issuer.example.org' => VciIssuerIdentifierModeEnum::Https->value, + self::ISSUER => VciIssuerIdentifierModeEnum::Https->value, self::DID_WEB_WITH_PATH => VciIssuerIdentifierModeEnum::DidWeb->value, ], self::DID_WEB_WITH_PATH_URL, @@ -1410,7 +1413,7 @@ public function testDoesNotWarnAboutIdentitiesWhichStayResolvable(): void $this->assertNotNull($row); $this->assertSame( - ['did:jwk:retired', 'https://issuer.example.org', self::DID_WEB_WITH_PATH], + ['did:jwk:retired', self::ISSUER, self::DID_WEB_WITH_PATH], $row->getValue(), ); $this->assertNull($row->getWarning()); @@ -1432,6 +1435,26 @@ public function testWarnsAboutADidWebIdentityWhichIsNoLongerPublished(): void ); $this->assertNotNull($row); - $this->assertStringContainsString('no longer published', (string)$row->getWarning()); + $this->assertStringContainsString('can no longer be verified', (string)$row->getWarning()); + } + + + /** + * An issuer URL identity is no less perishable than a did:web one: credentials issued under it + * resolve their metadata and their signing key through that URL, so changing the issuer leaves + * them naming an identity this deployment no longer answers for. + */ + public function testWarnsAboutAnIssuerUrlIdentityWhichIsNoLongerThisIssuer(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ModuleConfig::OPTION_ISSUER => self::ISSUER], + ['https://old-issuer.example.org' => VciIssuerIdentifierModeEnum::Https->value], + )->build(), + 'Identities Credentials Were Issued Under', + ); + + $this->assertNotNull($row); + $this->assertStringContainsString('can no longer be verified', (string)$row->getWarning()); } } diff --git a/tests/unit/src/Controllers/VerifiableCredentials/VciDidDocumentControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/VciDidDocumentControllerTest.php index 4ad08be9..1496576e 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/VciDidDocumentControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/VciDidDocumentControllerTest.php @@ -16,7 +16,6 @@ use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\VerificationMethodTypeEnum; use SimpleSAML\OpenID\Codebooks\VerificationRelationshipEnum; -use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Did\DidDocument; use SimpleSAML\OpenID\Did\DidUrl; use SimpleSAML\OpenID\Did\Factories\DidDocumentFactory; @@ -61,11 +60,8 @@ protected function setUp(): void $this->didDocumentFactoryMock = $this->createMock(DidDocumentFactory::class); $this->didDocumentFactoryMock->method('forDidWeb')->willReturn($didDocumentMock); - $didMock = $this->createMock(Did::class); - $didMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); - $this->didFactoryMock = $this->createMock(DidFactory::class); - $this->didFactoryMock->method('build')->willReturn($didMock); + $this->didFactoryMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); $this->loggerServiceMock = $this->createMock(LoggerService::class); @@ -131,6 +127,26 @@ public function testPublishesEveryKeyUnderAssertionMethodOnly(): void } + /** + * The document is assembled from key material already on disk, so it must not be able to fail on + * settings which exist to govern resolving other people's DIDs. + * + * Building the DID facade instantiates the configured cache adapter and resolves the outbound + * destination policy. Reaching the document factory through it would let a malformed + * `vci_cache_adapter`, or an outbound option this endpoint never uses, answer with a 500 - and a + * credential issued under a did:web identity can only be verified by resolving this document, so + * such a failure would make credentials already in wallets unverifiable. + */ + public function testPublishingNeverBuildsTheResolvingFacade(): void + { + $this->didFactoryMock->expects($this->never())->method('build'); + + $response = $this->sut()->didDocument(); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testAnswersNotFoundWhenNoDidWebIsConfigured(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); @@ -206,10 +222,8 @@ public function testFailsClosedWhenTheDocumentCanNotBeBuilt(): void $this->didDocumentFactoryMock->method('forDidWeb') ->willThrowException(new RuntimeException('nope')); - $didMock = $this->createMock(Did::class); - $didMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); $this->didFactoryMock = $this->createMock(DidFactory::class); - $this->didFactoryMock->method('build')->willReturn($didMock); + $this->didFactoryMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); $this->loggerServiceMock->expects($this->once())->method('error'); diff --git a/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php b/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php index 231d5cba..c455af99 100644 --- a/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php +++ b/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php @@ -15,7 +15,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\VerifiableCredentials\Values\VciIssuerIdentifier; use SimpleSAML\Module\oidc\VerifiableCredentials\VciIssuerIdentityResolver; -use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Did\DidJwkResolver; use SimpleSAML\OpenID\Did\DidUrl; use SimpleSAML\OpenID\Did\Factories\DidDocumentFactory; @@ -40,8 +39,6 @@ class VciIssuerIdentityResolverTest extends TestCase protected MockObject $didFactoryMock; - protected MockObject $didMock; - protected MockObject $didJwkResolverMock; protected MockObject $didDocumentFactoryMock; @@ -62,12 +59,9 @@ protected function setUp(): void static fn(DidUrl $did, string $keyId): DidUrl => new DidUrl($did->getDid() . '#' . $keyId), ); - $this->didMock = $this->createMock(Did::class); - $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); - $this->didMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); - $this->didFactoryMock = $this->createMock(DidFactory::class); - $this->didFactoryMock->method('build')->willReturn($this->didMock); + $this->didFactoryMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + $this->didFactoryMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); $keyPairMock = $this->createMock(KeyPair::class); $keyPairMock->method('getKeyId')->willReturn(self::KEY_ID); @@ -155,15 +149,33 @@ public function testTheConfiguredIdentityIsNeverReadForTheDidModes(): void } + /** + * Both identifiers are minted from a key already in hand, so neither may depend on the facade + * whose construction reads the cache adapter and the outbound destination settings. + */ + public function testMintingAnIdentityNeverBuildsTheResolvingFacade(): void + { + $this->didFactoryMock->expects($this->never())->method('build'); + + $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidJwk), + $this->signatureKeyPair, + ); + + $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::DidWeb, self::DID_WEB), + $this->signatureKeyPair, + ); + } + + public function testReportsAFailureToDeriveTheDidJwk(): void { $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); $this->didJwkResolverMock->method('generateDidJwkFromJwk') ->willThrowException(new RuntimeException('nope')); - $this->didMock = $this->createMock(Did::class); - $this->didMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); $this->didFactoryMock = $this->createMock(DidFactory::class); - $this->didFactoryMock->method('build')->willReturn($this->didMock); + $this->didFactoryMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); $this->expectException(OidcException::class); $this->expectExceptionMessage('did:jwk'); @@ -180,10 +192,8 @@ public function testReportsAFailureToMintTheDidWebVerificationMethodId(): void $this->didDocumentFactoryMock = $this->createMock(DidDocumentFactory::class); $this->didDocumentFactoryMock->method('verificationMethodIdFor') ->willThrowException(new RuntimeException('nope')); - $this->didMock = $this->createMock(Did::class); - $this->didMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); $this->didFactoryMock = $this->createMock(DidFactory::class); - $this->didFactoryMock->method('build')->willReturn($this->didMock); + $this->didFactoryMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); $this->expectException(OidcException::class); $this->expectExceptionMessage('did:web'); From fe06619aaf33d6de0deb134f2308ade7afb2ff6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 3 Sep 2026 10:00:41 +0200 Subject: [PATCH 12/15] Refuse an issuer URL nothing could discover keys through --- config/module_oidc.php.dist | 6 +- docs/3-oidc-configuration.md | 8 +++ .../JwtVcIssuerConfigurationController.php | 9 ++- .../VciIssuerIdentityResolver.php | 50 ++++++++++++++++ ...JwtVcIssuerConfigurationControllerTest.php | 16 +++++ .../VciIssuerIdentityResolverTest.php | 60 +++++++++++++++++++ 6 files changed, 147 insertions(+), 2 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index b26f0832..28b8f9a4 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1137,7 +1137,11 @@ $config = [ * through the published JWKS. This is what makes the * `.well-known/jwt-vc-issuer` document meaningful, and a way out for * verifiers which will not accept a DID. It is NOT DIIP conformant: the - * profile requires the issuer to be identified by a DID. + * profile requires the issuer to be identified by a DID. Note that the + * issuer must resolve to an absolute `https` URL carrying no query and + * no fragment, since a verifier discovers the key set by inserting + * `.well-known/jwt-vc-issuer` into it; issuance is refused otherwise + * rather than emitting a credential nothing could verify. * * Read when a credential is signed, so a change here reaches newly issued * credentials only. The ones already in wallets keep naming the identity diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 2a00a19c..8cb1c716 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -257,6 +257,14 @@ were issued under — which is what the retention rules below are about. ### Keeping the `https` identity resolvable +A verifier discovers the key set by inserting `.well-known/jwt-vc-issuer` into +the `iss` value, so under this mode the issuer must resolve to an absolute +`https` URL with no query and no fragment. `OPTION_ISSUER` guarantees none of +that — left unset it is derived from the host of the current request, which is +`http://` on a development deployment or behind a proxy forwarding the wrong +scheme — so issuance is refused when it does not, rather than emitting a +credential no verifier could check. + Under `https` a credential names its signing key by its JWKS key ID, so it is verifiable only while that key is still published. Two endpoints therefore stop following `OPTION_VCI_ENABLED` while this mode is selected: the VCI keys stay in diff --git a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php index d7f342a0..86589370 100644 --- a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php @@ -68,6 +68,13 @@ public function configuration(): Response ClaimsEnum::JwksUri->value => $this->routes->getModuleUrl(RoutesEnum::Jwks->value), ]; - return $this->routes->newJsonResponse($configuration); + // Cross origin reads are allowed, as they are on the key set and the DID document. A browser + // based wallet or verifier holding a credential from another origin has to read this document + // to find the key set to check it against, and without the header the browser refuses the read + // -- so the credential fails verification at a deployment which serves everything correctly. + return $this->routes->newJsonResponse( + $configuration, + headers: ['Access-Control-Allow-Origin' => '*'], + ); } } diff --git a/src/VerifiableCredentials/VciIssuerIdentityResolver.php b/src/VerifiableCredentials/VciIssuerIdentityResolver.php index 5cf57969..49a5da95 100644 --- a/src/VerifiableCredentials/VciIssuerIdentityResolver.php +++ b/src/VerifiableCredentials/VciIssuerIdentityResolver.php @@ -121,10 +121,60 @@ protected function forHttps(SignatureKeyPair $signatureKeyPair): VciIssuerIdenti ); } + $this->assertIssuerIsDiscoverable($issuer); + return new VciIssuerIdentity( VciIssuerIdentifierModeEnum::Https, $issuer, $signatureKeyPair->getKeyPair()->getKeyId(), ); } + + + /** + * Refuse an issuer URL which nothing could perform SD-JWT VC discovery against. + * + * Under this mode the `iss` claim is the whole of the key resolution story: a verifier inserts + * `.well-known/jwt-vc-issuer` into it, fetches, and reads `jwks_uri` from what comes back. That + * only works for an absolute https URL, and only where the value carries no query or fragment for + * the insertion to land after. + * + * `getIssuer()` guarantees none of this. It falls back to the host of the current request when the + * option is not set, so a deployment reached over plain HTTP - which is every deployment behind a + * TLS terminating proxy that forwards the wrong scheme, and every development one - would otherwise + * issue credentials naming an `http://` issuer. Refusing is the point: a credential which cannot be + * verified securely is worse than one that was never issued, and this is the last moment anything + * can tell. + * + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + protected function assertIssuerIsDiscoverable(string $issuer): void + { + $parts = parse_url($issuer); + + if ( + // parse_url() decomposes rather than validates: it hands back a scheme and a host for + // "https:// issuer.example.org" too, with the space kept inside the host. So the value is + // checked for being a URL at all before its parts are read. + !filter_var($issuer, FILTER_VALIDATE_URL) || + !is_array($parts) || + // Compared lower cased because URI schemes are case insensitive, while parse_url() keeps + // whatever case it was given. Only the comparison is normalised: the value itself is + // emitted exactly as configured, since the issuer metadata publishes that same string and + // a verifier matching the two byte for byte must not see them disagree. + strtolower($parts['scheme'] ?? '') !== 'https' || + (($parts['host'] ?? '') === '') || + array_key_exists('query', $parts) || + array_key_exists('fragment', $parts) + ) { + throw new OidcException( + sprintf( + 'Credentials are configured to name this issuer by its URL, but "%s" is not one a ' . + 'verifier could discover keys through: it has to be an absolute https URL carrying ' . + 'no query and no fragment.', + $issuer, + ), + ); + } + } } diff --git a/tests/unit/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationControllerTest.php index ae974692..d5193631 100644 --- a/tests/unit/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationControllerTest.php +++ b/tests/unit/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationControllerTest.php @@ -71,6 +71,22 @@ public function testPublishesTheIssuerAndItsKeySet(): void } + /** + * A browser based wallet holding a credential from another origin has to read this document to + * find the key set to check it against, so without the header the browser blocks the read and the + * credential fails verification at a deployment which serves everything else correctly. + */ + public function testAllowsCrossOriginReads(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(true); + + $this->assertSame( + '*', + $this->sut()->configuration()->headers->get('Access-Control-Allow-Origin'), + ); + } + + public function testRefusesWhileIssuanceIsDisabled(): void { $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); diff --git a/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php b/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php index c455af99..b250aec4 100644 --- a/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php +++ b/tests/unit/src/VerifiableCredentials/VciIssuerIdentityResolverTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -205,6 +206,65 @@ public function testReportsAFailureToMintTheDidWebVerificationMethodId(): void } + /** + * `getIssuer()` falls back to the host of the current request, so under this mode a deployment + * reached over plain HTTP would otherwise issue credentials naming an `http://` issuer that no + * verifier could discover keys through. Refused rather than issued. + */ + #[DataProvider('undiscoverableIssuerDataProvider')] + public function testRefusesAnIssuerUrlNothingCouldDiscoverKeysThrough(string $issuer): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getIssuer')->willReturn($issuer); + + $this->expectException(OidcException::class); + $this->expectExceptionMessage('absolute https URL'); + + $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::Https), + $this->signatureKeyPair, + ); + } + + + /** + * @return array + */ + public static function undiscoverableIssuerDataProvider(): array + { + return [ + 'plain HTTP' => ['http://issuer.example.org'], + 'no scheme' => ['issuer.example.org'], + 'no host' => ['https://'], + 'carries a query' => ['https://issuer.example.org?tenant=a'], + 'carries a fragment' => ['https://issuer.example.org#a'], + 'not a URL at all' => ['not a url'], + // parse_url() hands back a scheme and a host for this one, with the space kept inside the + // host, so decomposing it is not the same as validating it. + 'a space inside it' => ['https:// issuer.example.org'], + ]; + } + + + /** + * URI schemes are case insensitive, so this is a valid issuer. The value is emitted exactly as + * configured rather than lower cased: the issuer metadata publishes that same string, and a + * verifier matching `iss` against it byte for byte must not see the two disagree. + */ + public function testAcceptsAnIssuerUrlWhoseSchemeIsUpperCasedAndEmitsItUnchanged(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getIssuer')->willReturn('HTTPS://issuer.example.org'); + + $identity = $this->sut()->resolve( + new VciIssuerIdentifier(VciIssuerIdentifierModeEnum::Https), + $this->signatureKeyPair, + ); + + $this->assertSame('HTTPS://issuer.example.org', $identity->getIssuer()); + } + + public function testReportsAnIssuerUrlWhichCanNotBeResolved(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); From 7eaa30c819c40347f525894b21307e20c41ea4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 3 Sep 2026 13:07:39 +0200 Subject: [PATCH 13/15] Sign Status List Tokens under a recorded did:web identity --- config/module_oidc.php.dist | 18 ++ docs/3-oidc-configuration.md | 30 ++- src/Codebooks/StatusListKeyProfileEnum.php | 13 ++ src/Controllers/JwksController.php | 21 +- src/ModuleConfig.php | 83 +++++++- src/Repositories/StatusListRepository.php | 10 +- src/Services/DatabaseMigration.php | 35 ++++ src/StatusList/DbStatusIndexAllocator.php | 1 + src/StatusList/DbStatusListTokenProvider.php | 89 +++++++- src/StatusList/Values/StatusListPool.php | 80 ++++++-- src/StatusList/Values/StatusListPoolBag.php | 14 +- src/StatusList/Values/StatusListRecord.php | 21 ++ .../src/Controllers/JwksControllerTest.php | 44 ++++ tests/unit/src/ModuleConfigTest.php | 193 ++++++++++++++++++ .../Repositories/StatusListRepositoryTest.php | 32 ++- .../StatusList/DbStatusIndexAllocatorTest.php | 3 + .../DbStatusListTokenProviderTest.php | 137 ++++++++++++- .../StatusList/Values/StatusListPoolTest.php | 96 +++++++++ 18 files changed, 867 insertions(+), 53 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 28b8f9a4..5ee87634 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1913,6 +1913,11 @@ $config = [ * - 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. + * - DidWeb: `iss` is the did:web set under + * OPTION_VCI_ISSUER_DID_IDENTIFIER and `kid` names the key in the DID + * document this module publishes for it, matching how credentials are + * identified under the same issuer identity. Requires that option to be + * set, and requires the published document to stay reachable. * - 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. @@ -1922,6 +1927,19 @@ $config = [ * lists keep being served under the profile their holders already resolved * them by. Changing it therefore never invalidates credentials which are * already in wallets. + * + * A list created under DidWeb also records the identifier itself, for the + * same reason it records its signing key: changing or clearing + * OPTION_VCI_ISSUER_DID_IDENTIFIER routes new credentials to new lists + * rather than changing the issuer of tokens already being verified. Keep the + * DID document published for every identifier any unretired list still + * names. The administration screen lists the identities credentials were + * issued under, which covers these too unless credentials are issued under + * some other identity than the Status Lists are. + * + * Note that a deployment which creates a DidWeb list can not be rolled back + * to a release without this profile: an older process reading such a row + * fails rather than guessing an identity for it. */ ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => \SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum::DidJwk, diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 8cb1c716..2318c80c 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -860,6 +860,11 @@ 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. +- `did_web`: `iss` is the `did:web` set under `OPTION_VCI_ISSUER_DID_IDENTIFIER` and `kid` names the + signing key in the DID document this module publishes for it. This is the profile which makes a + credential and the Status List Token it points at name the same issuer under the same resolvable + identity. It requires that option to be set — a pool on this profile without one is a configuration + error rather than something discovered when a token is signed. - `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. @@ -867,6 +872,27 @@ 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 `did_web` list also records the identifier, not just the profile.** It has to: unlike the other two, +that identifier is a setting of its own which can be changed or cleared while lists created under it are +still being served. Reading it afresh at signing time would silently rewrite the `iss` of every token +those lists emit, leaving a wallet holding a credential naming one issuer and a status token naming +another. Because the identifier is recorded, changing it behaves like every other policy change: new +credentials go to new lists, and the old lists go on naming the issuer they were created under. + +The obligation that follows is the same one credentials carry. **Keep the DID document published for +every identifier an unretired list still names**, not only for the one currently configured — a Relying +Party checking a credential's status has to resolve the token's issuer, and a `404` there is +indistinguishable from a revoked deployment. The Verifiable Credential configuration screen lists the +identities **credentials** were issued under, which answers this too whenever credentials are issued +under the same `did:web`. It does not cover the case where they are not — a deployment naming its +credentials some other way while its Status Lists use `did:web` has to track the identifiers of its +unretired lists itself. + +**Selecting `did_web` is a one-way upgrade.** Once a list has been created under it, the deployment can +no longer be rolled back to a release that predates the profile: an older process reading that row +refuses it rather than guessing an identity, which takes that list's endpoint down. Roll the code +forward everywhere before switching a pool onto it. + **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 @@ -883,7 +909,9 @@ 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 +once Relying Parties refetch it. `did_web` behaves the same way as `jwks` here: the key is named in the +published DID document, which is built from the configured keys, so removing one withdraws it there +too. 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 diff --git a/src/Codebooks/StatusListKeyProfileEnum.php b/src/Codebooks/StatusListKeyProfileEnum.php index 2a35c1da..40c98f00 100644 --- a/src/Codebooks/StatusListKeyProfileEnum.php +++ b/src/Codebooks/StatusListKeyProfileEnum.php @@ -27,6 +27,19 @@ enum StatusListKeyProfileEnum: string */ case DidJwk = 'did_jwk'; + /** + * `iss` is the deployment's configured `did:web`, and `kid` the verification method that DID's + * published document names for the signing key, matching how Verifiable Credentials are identified + * under the same issuer identity. Unlike `did_jwk`, the token does not carry its key: a Relying + * Party resolves the document over HTTPS, which is what lets the same issuer name outlive any one + * key. Requires a configured issuer `did:web` identifier, whose document this module publishes. + * + * The identifier is recorded on each list at creation, so a list keeps naming the issuer it was + * created under even after the configured one changes -- and that document therefore has to stay + * resolvable for as long as any credential pointing at the list is still being verified. + */ + case DidWeb = 'did_web'; + /** * `iss` is the module's issuer URL and `kid` is the JWKS key ID, so a Relying Party resolves the * key through the issuer's published JWKS. Use this for Relying Parties which will not accept a diff --git a/src/Controllers/JwksController.php b/src/Controllers/JwksController.php index 65ac3bd6..0eeeccb6 100644 --- a/src/Controllers/JwksController.php +++ b/src/Controllers/JwksController.php @@ -70,6 +70,12 @@ public function __invoke(): JsonResponse * 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. * + * Asked of the raw configured values rather than of the built pools, which matters for the same + * reason. Building them is all or nothing: one pool misconfigured in any way -- a bad bit count, or + * an issuer `did:web` which only another pool's profile even reads -- would make the whole bag + * unreadable, this question answer "no", and the key that a perfectly good `jwks` pool's already + * published tokens are verified through be withdrawn over a setting those tokens never used. + * * 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 @@ -78,19 +84,14 @@ public function __invoke(): JsonResponse protected function isAnyStatusListKeyPublished(): bool { try { - foreach ($this->moduleConfig->getVciStatusListPoolBag()->getAll() as $pool) { - if ($pool->getKeyProfile() === StatusListKeyProfileEnum::Jwks) { - return true; - } - } + return $this->moduleConfig->isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum::Jwks); } 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. + // Only a pool list or a default key profile which can not be read at all reaches here, and + // both are reported on the configuration overview screen, which owns that error. 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; } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 89d2ea5a..b269fad6 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -1829,6 +1829,67 @@ public function getVciStatusListKeyProfile(): StatusListKeyProfileEnum } + /** + * Whether any configured Status List pool signs under the given key profile. + * + * Answered from the raw configured values rather than by building the pools, and that is the point + * of it. The JWKS endpoint asks this to decide whether the credential signing key still has to be + * published, and a pool misconfigured in any way at all would otherwise make the whole bag + * unreadable and the answer "no" - withdrawing the key that a different, perfectly good pool's + * already published tokens are verified through. A question about one pool must not be answerable + * only while every pool is valid. + * + * The pool bag asks it too, before resolving the issuer `did:web`, so that an option only the + * `did_web` profile reads is not consulted for a deployment which has no pool on it. + * + * A value which is not a valid profile at all counts as "no": the pool is about to be refused by + * name, which is a better error than one about whichever profile was being asked about. + * + * @param array $pools + */ + protected function isAnyStatusListPoolOnKeyProfileIn( + array $pools, + StatusListKeyProfileEnum $keyProfile, + StatusListKeyProfileEnum $defaultKeyProfile, + ): bool { + /** @var mixed $poolConfig */ + foreach ($pools as $poolConfig) { + if (!is_array($poolConfig) || !array_key_exists(StatusListPool::KEY_KEY_PROFILE, $poolConfig)) { + if ($defaultKeyProfile === $keyProfile) { + return true; + } + + continue; + } + + /** @var mixed $configured */ + $configured = $poolConfig[StatusListPool::KEY_KEY_PROFILE]; + + if ($configured === $keyProfile || $configured === $keyProfile->value) { + return true; + } + } + + return false; + } + + + /** + * Whether any configured Status List pool signs under the given key profile. + * + * @throws \SimpleSAML\Error\ConfigurationError On a malformed default key profile, which is the one + * value this cannot be answered without. + */ + public function isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum $keyProfile): bool + { + return $this->isAnyStatusListPoolOnKeyProfileIn( + $this->config()->getOptionalArray(self::OPTION_VCI_STATUS_LIST_POOLS, []), + $keyProfile, + $this->getVciStatusListKeyProfile(), + ); + } + + /** * The configured Status List pools. * @@ -1844,9 +1905,27 @@ public function getVciStatusListPoolBag(): StatusListPoolBag return $this->vciStatusListPoolBag; } + $pools = $this->config()->getOptionalArray(self::OPTION_VCI_STATUS_LIST_POOLS, []); + $defaultKeyProfile = $this->getVciStatusListKeyProfile(); + $poolBag = StatusListPoolBag::fromConfig( - $this->config()->getOptionalArray(self::OPTION_VCI_STATUS_LIST_POOLS, []), - $this->getVciStatusListKeyProfile(), + $pools, + $defaultKeyProfile, + // The module wide issuer identity, which the `did_web` key profile stamps onto every list + // it creates. Taken from here rather than configured per pool, so that a deployment cannot + // end up with two issuer identities of which only one has a published DID document. + // + // Resolved only when some pool is actually on that profile. Reading it unconditionally + // would mean a malformed did:web identifier -- an option no other profile looks at -- also + // stopping the pools from being read, and the JWKS endpoint answers "does any pool need its + // key published?" by reading them. A pool on the `jwks` profile would then have the key its + // already published tokens are verified through quietly withdrawn, over a setting it does + // not use. + $this->isAnyStatusListPoolOnKeyProfileIn( + $pools, + StatusListKeyProfileEnum::DidWeb, + $defaultKeyProfile, + ) ? $this->getVciIssuerDidIdentifier() : null, ); $supportedIds = $this->getVciCredentialConfigurationIdsSupported(); diff --git a/src/Repositories/StatusListRepository.php b/src/Repositories/StatusListRepository.php index 48c00cde..f56d1607 100644 --- a/src/Repositories/StatusListRepository.php +++ b/src/Repositories/StatusListRepository.php @@ -323,19 +323,20 @@ public function create( int $refreshIntervalSeconds, string $signingKeyId, StatusListKeyProfileEnum $keyProfile, + ?string $issuerIdentifier = null, ): void { $this->database->write( sprintf( 'INSERT INTO %s ( id, uri, pool_id, policy_fingerprint, expiry_lane, generation, bits, capacity, allowed_statuses, ttl_seconds, token_validity_seconds, refresh_interval_seconds, - signing_key_id, key_profile, allocated_count, is_active, + signing_key_id, key_profile, issuer_identifier, allocated_count, is_active, signed_token_content_hash, created_at ) VALUES ( :id, :uri, :pool_id, :policy_fingerprint, :expiry_lane, :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 + :refresh_interval_seconds, :signing_key_id, :key_profile, :issuer_identifier, + :allocated_count, :is_active, :signed_token_content_hash, :created_at )', $this->getTableName(), ), @@ -354,6 +355,9 @@ public function create( 'refresh_interval_seconds' => [$refreshIntervalSeconds, PDO::PARAM_INT], 'signing_key_id' => $signingKeyId, 'key_profile' => $keyProfile->value, + // Null under every profile but `did_web`, which is the only one naming the issuer by + // something a list has to remember rather than derive. + 'issuer_identifier' => $issuerIdentifier, '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, diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index a2c42624..dfab695c 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -272,6 +272,11 @@ public function migrate(): void $this->version20260902000001(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260902000001')"); } + + if (!in_array('20260903000001', $versions, true)) { + $this->version20260903000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260903000001')"); + } } @@ -1157,6 +1162,36 @@ private function version20260902000001(): void } + /** + * The issuer identifier a Status List was created under. + * + * Only the `did_web` key profile fills it in, and only that profile needs it: a `did:jwk` is + * derived from the signing key the row already records, and the `jwks` profile names the issuer by + * a URL which cannot move without the key set moving with it. A `did:web` identifier is neither -- + * it is a setting of its own, which an operator can change or clear while lists created under it + * are still being served. Recording it is what keeps such a change from rewriting the `iss` of + * every token those lists emit, which would leave a wallet holding a credential naming one issuer + * and a Status List Token naming another. + * + * Nullable, so it can be added to a table which already has rows: every existing list is on a + * profile which does not use it. + */ + private function version20260903000001(): void + { + $statusListTableName = $this->database->applyPrefix(StatusListRepository::TABLE_NAME); + + if ($this->hasColumn($statusListTableName, 'issuer_identifier')) { + return; + } + + $this->database->write(<<< EOT + ALTER TABLE {$statusListTableName} + ADD issuer_identifier TEXT NULL +EOT + ,); + } + + /** * Whether a table already has a column. * diff --git a/src/StatusList/DbStatusIndexAllocator.php b/src/StatusList/DbStatusIndexAllocator.php index c0499864..803eb7e5 100644 --- a/src/StatusList/DbStatusIndexAllocator.php +++ b/src/StatusList/DbStatusIndexAllocator.php @@ -477,6 +477,7 @@ protected function createList( $pool->getRefreshIntervalInSeconds(), $signingKeyId, $pool->getKeyProfile(), + $pool->getIssuerIdentifier(), ); } catch (Throwable $throwable) { $this->loggerService->info( diff --git a/src/StatusList/DbStatusListTokenProvider.php b/src/StatusList/DbStatusListTokenProvider.php index 546c5d94..c6fadc6d 100644 --- a/src/StatusList/DbStatusListTokenProvider.php +++ b/src/StatusList/DbStatusListTokenProvider.php @@ -8,6 +8,7 @@ use DateTimeImmutable; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\Exceptions\StatusListException; +use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\StatusListEntryRepository; @@ -18,7 +19,7 @@ use SimpleSAML\Module\oidc\StatusList\Values\StatusListRecord; use SimpleSAML\Module\oidc\StatusList\Values\StatusListTokenResult; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; -use SimpleSAML\OpenID\Did; +use SimpleSAML\OpenID\Did\DidUrl; use SimpleSAML\OpenID\TokenStatusList; use SimpleSAML\OpenID\ValueAbstracts\KeyPair; use Throwable; @@ -59,7 +60,12 @@ public function __construct( protected readonly StatusListKeyResolver $statusListKeyResolver, protected readonly TokenStatusList $tokenStatusList, protected readonly ModuleConfig $moduleConfig, - protected readonly Did $did, + // The factory rather than the facade it builds. Building that one reads the DID cache adapter + // and the outbound destination settings, none of which signing a token needs: both identifiers + // below are minted from key material already in hand and nothing is fetched. Taking the facade + // meant an unrelated outbound option could stop every Status List Token being signed, including + // for lists whose profile resolves nothing at all. + protected readonly DidFactory $didFactory, protected readonly Helpers $helpers, protected readonly LoggerService $loggerService, ) { @@ -262,7 +268,7 @@ protected function sign( // 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); + $identity = $this->identityFor($statusList, $keyPair); try { $list = $this->tokenStatusList->statusListFactory()->fromEntries( @@ -304,23 +310,35 @@ protected function sign( * 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. + * than anything derivable from the spec. The whole record is taken rather than the profile alone + * because the answer is assembled from the row and not from configuration -- both the profile and, + * under `did_web`, the identifier itself -- so that changing a setting 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 + protected function identityFor(StatusListRecord $statusList, KeyPair $keyPair): array { - if ($keyProfile === StatusListKeyProfileEnum::Jwks) { - return [ + return match ($statusList->getKeyProfile()) { + StatusListKeyProfileEnum::Jwks => [ 'issuer' => $this->moduleConfig->getIssuer(), 'keyId' => $keyPair->getKeyId(), - ]; - } + ], + StatusListKeyProfileEnum::DidWeb => $this->didWebIdentityFor($statusList, $keyPair), + StatusListKeyProfileEnum::DidJwk => $this->didJwkIdentityFor($keyPair), + }; + } + + /** + * @return array{issuer: string, keyId: string} + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function didJwkIdentityFor(KeyPair $keyPair): array + { try { - $didJwk = $this->did->didJwkResolver()->generateDidJwkFromJwk( + $didJwk = $this->didFactory->didJwkResolver()->generateDidJwkFromJwk( $keyPair->getPublicKey()->jwk()->all(), ); } catch (Throwable $throwable) { @@ -341,6 +359,55 @@ protected function identityFor(StatusListKeyProfileEnum $keyProfile, KeyPair $ke } + /** + * The identifier comes from the list's own row and is never re-derived from configuration, so a + * list keeps naming the issuer it was created under even after that setting changes or is cleared. + * The key is named through the same factory which builds the published DID document, so a token can + * not point at a verification method that document does not carry. + * + * @return array{issuer: string, keyId: string} + * @throws \SimpleSAML\Module\oidc\Exceptions\StatusListException + */ + protected function didWebIdentityFor(StatusListRecord $statusList, KeyPair $keyPair): array + { + $didWeb = $statusList->getIssuerIdentifier(); + + if (is_null($didWeb)) { + // Only reachable for a row written by something other than the allocator, which refuses + // this profile without an identifier long before a list exists. Signing under a guessed + // identity would be worse than not signing: the token would name an issuer this list was + // never created under, and a wallet cannot tell the difference. + throw new StatusListException( + sprintf( + 'Status List "%s" is recorded under the "%s" key profile but carries no issuer ' . + 'identifier, so there is no issuer to sign it as.', + $statusList->getId(), + StatusListKeyProfileEnum::DidWeb->value, + ), + ); + } + + try { + $keyId = $this->didFactory->didDocumentFactory()->verificationMethodIdFor( + new DidUrl($didWeb), + $keyPair->getKeyId(), + )->getValue(); + } catch (Throwable $throwable) { + throw new StatusListException( + 'Unable to build the did:web verification method identifier for the Status List ' . + 'signing key: ' . $throwable->getMessage(), + (int)$throwable->getCode(), + $throwable, + ); + } + + return [ + 'issuer' => $didWeb, + 'keyId' => $keyId, + ]; + } + + /** * How close to expiry a published token is replaced rather than served. * diff --git a/src/StatusList/Values/StatusListPool.php b/src/StatusList/Values/StatusListPool.php index d8da7d6d..eabe0c2e 100644 --- a/src/StatusList/Values/StatusListPool.php +++ b/src/StatusList/Values/StatusListPool.php @@ -8,6 +8,7 @@ use DateTimeImmutable; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; use SimpleSAML\OpenID\TokenStatusList\StatusList; use Throwable; @@ -88,6 +89,10 @@ class StatusListPool * @param string[] $credentialConfigurationIds Credential configurations which allocate from this pool. * @param \SimpleSAML\OpenID\Codebooks\StatusTypeEnum[] $allowedStatuses Statuses this pool may * emit. Always includes Valid, since an entry has to be able to return to it. + * @param ?string $issuerIdentifier The deployment's issuer `did:web`, required by that key profile + * and unused by every other. Taken from the module wide issuer identity rather than configured per + * pool: a deployment has one such identity, and letting a pool name a second would leave one of + * them without the published DID document a Relying Party has to resolve. * @throws \SimpleSAML\Error\ConfigurationError */ public function __construct( @@ -100,6 +105,7 @@ public function __construct( protected readonly DateInterval $tokenValidity, protected readonly DateInterval $refreshInterval, protected readonly StatusListKeyProfileEnum $keyProfile, + protected readonly ?string $issuerIdentifier = null, ) { $this->validate(); } @@ -126,6 +132,23 @@ protected function validate(): void ); } + // Refused while the pool is being built, rather than left to fail when a token is signed: by + // then the list exists and credentials already point at it. The option is named through its + // constant so that renaming it can not leave this message pointing at something gone. + if ($this->keyProfile === StatusListKeyProfileEnum::DidWeb && $this->issuerIdentifier === null) { + throw new ConfigurationError( + sprintf( + 'Status List pool "%s" signs under the "%s" key profile, which names the issuer by ' . + 'a `did:web` identifier, but "%s" is not set. Set it, or move the pool to another ' . + '"%s".', + $this->id, + StatusListKeyProfileEnum::DidWeb->value, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER, + self::KEY_KEY_PROFILE, + ), + ); + } + if (!StatusList::isAllowedBits($this->bits)) { throw new ConfigurationError( sprintf( @@ -342,6 +365,16 @@ public function getKeyProfile(): StatusListKeyProfileEnum } + /** + * The issuer `did:web` a list created from this pool is stamped with, or null under the profiles + * which identify the issuer some other way. + */ + public function getIssuerIdentifier(): ?string + { + return $this->issuerIdentifier; + } + + /** * Hash of the immutable part of this pool's policy, which allocation filters candidate lists on. * @@ -359,21 +392,27 @@ public function getKeyProfile(): StatusListKeyProfileEnum */ 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, - ), - ); + $policy = [ + '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, + ]; + + // Included only under the profile which uses it, so that a pool on any other profile hashes + // the same bytes it hashed before this one existed. Adding the key unconditionally -- even as + // null -- would move every existing pool onto fresh lists at once, splitting herds which had + // no reason to split. Under `did_web` it has to be here: a list records the identifier it was + // created with, so changing that identifier must route new credentials to a new list rather + // than onto one whose tokens still name the issuer it was created under. + if ($this->keyProfile === StatusListKeyProfileEnum::DidWeb) { + $policy['issuer_identifier'] = $this->issuerIdentifier; + } + + return hash('sha256', json_encode($policy, JSON_THROW_ON_ERROR)); } @@ -387,7 +426,10 @@ public static function fromConfig( string $id, array $config, StatusListKeyProfileEnum $defaultKeyProfile, + ?string $issuerIdentifier = null, ): self { + $keyProfile = self::resolveKeyProfile($id, $config, $defaultKeyProfile); + return new self( $id, self::resolveCredentialConfigurationIds($id, $config), @@ -397,7 +439,13 @@ public static function fromConfig( 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), + $keyProfile, + // Carried only by the profile which uses it. The caller resolves the identifier as soon as + // any one pool needs it, and a deployment may well mix profiles, so handing it to the rest + // would have them stamp every list they create with an identity nothing will ever resolve + // from it -- and a later reader asking which DID documents still have to be published would + // read those rows and name one that was never needed. + $keyProfile === StatusListKeyProfileEnum::DidWeb ? $issuerIdentifier : null, ); } diff --git a/src/StatusList/Values/StatusListPoolBag.php b/src/StatusList/Values/StatusListPoolBag.php index b81b8dd5..de9b15c3 100644 --- a/src/StatusList/Values/StatusListPoolBag.php +++ b/src/StatusList/Values/StatusListPoolBag.php @@ -116,8 +116,11 @@ public function getAllCredentialConfigurationIds(): array * @param array $config Pool identifier to that pool's settings. * @throws \SimpleSAML\Error\ConfigurationError */ - public static function fromConfig(array $config, StatusListKeyProfileEnum $defaultKeyProfile): self - { + public static function fromConfig( + array $config, + StatusListKeyProfileEnum $defaultKeyProfile, + ?string $issuerIdentifier = null, + ): self { $pools = []; /** @var mixed $poolConfig */ @@ -138,7 +141,12 @@ public static function fromConfig(array $config, StatusListKeyProfileEnum $defau ); } - $pools[] = StatusListPool::fromConfig($poolId, $poolConfig, $defaultKeyProfile); + $pools[] = StatusListPool::fromConfig( + $poolId, + $poolConfig, + $defaultKeyProfile, + $issuerIdentifier, + ); } return new self(...$pools); diff --git a/src/StatusList/Values/StatusListRecord.php b/src/StatusList/Values/StatusListRecord.php index 8fe5dd5c..1e640d72 100644 --- a/src/StatusList/Values/StatusListRecord.php +++ b/src/StatusList/Values/StatusListRecord.php @@ -33,6 +33,11 @@ class StatusListRecord * filters candidate lists on, so that a list holding credentials which never expire -- and which * therefore can never be retired -- never also holds credentials which do. * @param string $allowedStatuses Comma separated status values this list may carry. + * @param ?string $issuerIdentifier The issuer `did:web` this list was created under, or null + * under the key profiles which name the issuer some other way. Recorded rather than read from + * configuration when a token is signed, for the same reason the signing key is: a list has to keep + * naming the issuer its holders resolved it under, so changing the configured identity routes new + * credentials to new lists instead of altering what an existing list emits. * @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. @@ -56,6 +61,7 @@ public function __construct( protected readonly int $refreshIntervalSeconds, protected readonly string $signingKeyId, protected readonly StatusListKeyProfileEnum $keyProfile, + protected readonly ?string $issuerIdentifier, protected readonly int $allocatedCount, protected readonly bool $isActive, protected readonly ?DateTimeImmutable $deactivatedAt, @@ -184,6 +190,16 @@ public function getKeyProfile(): StatusListKeyProfileEnum } + /** + * The issuer `did:web` this list names, or null under a key profile which names the issuer some + * other way. Under `did_web` this is what the token's `iss` is, so it is never re-derived. + */ + public function getIssuerIdentifier(): ?string + { + return $this->issuerIdentifier; + } + + /** * 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. @@ -289,6 +305,11 @@ public static function fromRow(array $row): self self::asInt($row, 'refresh_interval_seconds'), self::asString($row, 'signing_key_id'), self::asKeyProfile($row, 'key_profile'), + // Added by a later migration than the table, so a row read before that migration has run + // has no such column -- which reads the same as a list created under a key profile that + // does not use one. Only the `did_web` profile does, and no such list can exist before the + // migration which introduced both. + self::asNullableString($row, 'issuer_identifier'), self::asInt($row, 'allocated_count'), self::asBool($row, 'is_active'), self::asNullableDateTime($row, 'deactivated_at'), diff --git a/tests/unit/src/Controllers/JwksControllerTest.php b/tests/unit/src/Controllers/JwksControllerTest.php index f8059fb0..06b6f41e 100644 --- a/tests/unit/src/Controllers/JwksControllerTest.php +++ b/tests/unit/src/Controllers/JwksControllerTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Error\ConfigurationError; +use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\Controllers\JwksController; use SimpleSAML\Module\oidc\ModuleConfig; @@ -153,6 +154,49 @@ public function testItWithdrawsVciKeysForIdentitiesWhichDoNotNeedThem(): void } + /** + * A pool on the `jwks` profile has its tokens verified through this key set, so the key stays + * published even while issuance is off. + */ + public function testItKeepsPublishingVciKeysForAPoolWhoseTokensNameThemHere(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + $this->moduleConfigMock->method('getVciIssuerIdentifierMode') + ->willReturn(VciIssuerIdentifierModeEnum::DidJwk); + $this->moduleConfigMock->method('isAnyStatusListPoolOnKeyProfile') + ->with(StatusListKeyProfileEnum::Jwks) + ->willReturn(true); + $this->moduleConfigMock->expects($this->once())->method('getVciSignatureKeyPairBag') + ->willReturn(new SignatureKeyPairBag()); + + $this->mock()->__invoke(); + } + + + /** + * Whether one pool needs its key published must not depend on every other pool being valid. + * Building the bag is all or nothing, so a deployment mixing a `jwks` pool with a `did_web` one + * whose issuer identifier is missing or malformed would otherwise have this answer "no" -- and the + * key that the `jwks` pool's already published tokens are verified through withdrawn, over an + * option those tokens never used. + */ + public function testAPoolBrokenOnAnotherProfileDoesNotWithdrawTheKeyAValidPoolNeeds(): void + { + $this->moduleConfigMock->method('getVciEnabled')->willReturn(false); + $this->moduleConfigMock->method('getVciIssuerIdentifierMode') + ->willReturn(VciIssuerIdentifierModeEnum::DidJwk); + $this->moduleConfigMock->method('isAnyStatusListPoolOnKeyProfile')->willReturn(true); + + // The question is answered without ever building them, so one which cannot be built is not + // able to change the answer. + $this->moduleConfigMock->expects($this->never())->method('getVciStatusListPoolBag'); + $this->moduleConfigMock->expects($this->once())->method('getVciSignatureKeyPairBag') + ->willReturn(new SignatureKeyPairBag()); + + $this->mock()->__invoke(); + } + + /** * A key set which cannot be served takes down verification of every token this issuer has ever * signed, so a malformed issuer identity must not reach it. diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 21d2184e..004a0189 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -1042,6 +1042,199 @@ protected function withStatusListPool(bool $isEnabled): array } + /** + * The did:web key profile names the issuer by an identifier the pool does not carry itself, so the + * module wide one is handed to it. A deployment has one such identity, and a pool naming a second + * would leave one of them without the DID document a Relying Party has to resolve. + * + * @throws \Exception + */ + public function testHandsTheIssuerIdentifierToAPoolOnTheDidWebKeyProfile(): void + { + $sut = $this->sut(overrides: array_merge( + $this->withStatusListPool(true), + [ + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => StatusListKeyProfileEnum::DidWeb, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => 'did:web:issuer.example.org', + ], + )); + + $pool = $sut->getVciStatusListPoolBag()->getById('default'); + + $this->assertSame(StatusListKeyProfileEnum::DidWeb, $pool?->getKeyProfile()); + $this->assertSame('did:web:issuer.example.org', $pool->getIssuerIdentifier()); + } + + + /** + * Answered without building the pools, so that a pool which cannot be built -- here a `did_web` one + * whose issuer identifier is missing -- cannot change the answer for one which can. The JWKS + * endpoint decides whether to keep publishing the credential signing key by asking this, and a + * `jwks` pool's already published tokens are verified through that key. + * + * @throws \Exception + */ + public function testAnswersWhichKeyProfilesAreInUseEvenWhileAnotherPoolCanNotBeBuilt(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED => [ + 'TestCredential' => [], + 'OtherCredential' => [], + ], + ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED => true, + ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS => [ + 'published' => [ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['TestCredential'], + StatusListPool::KEY_KEY_PROFILE => StatusListKeyProfileEnum::Jwks, + ], + // No issuer did:web is configured, so building this one throws. + 'decentralised' => [ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['OtherCredential'], + StatusListPool::KEY_KEY_PROFILE => StatusListKeyProfileEnum::DidWeb, + ], + ], + ], + )); + + $this->assertTrue($sut->isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum::Jwks)); + $this->assertTrue($sut->isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum::DidWeb)); + $this->assertFalse($sut->isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum::DidJwk)); + + // The pool bag itself is still refused, which is where that error belongs. + $this->expectException(ConfigurationError::class); + + $sut->getVciStatusListPoolBag(); + } + + + /** + * A pool which names no profile of its own takes the deployment default, so the answer has to + * follow that rather than only what each pool spells out. + * + * @throws \Exception + */ + public function testAPoolWhichNamesNoKeyProfileCountsUnderTheDeploymentDefault(): void + { + $sut = $this->sut(overrides: array_merge( + $this->withStatusListPool(true), + [ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => StatusListKeyProfileEnum::Jwks], + )); + + $this->assertTrue($sut->isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum::Jwks)); + $this->assertFalse($sut->isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum::DidJwk)); + } + + + /** + * @throws \Exception + */ + public function testNoPoolIsOnAnyKeyProfileWhenNonePoolIsConfigured(): void + { + $this->assertFalse( + $this->sut()->isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum::Jwks), + ); + } + + + /** + * A pool may take the profile on its own while the deployment default is another, so whether the + * issuer identifier is needed cannot be answered from the default alone. Spelled as the string a + * hand written config would use, which is the other shape that has to be recognised. + * + * @throws \Exception + */ + public function testHandsTheIssuerIdentifierToAPoolWhichTakesTheDidWebProfileOnItsOwn(): void + { + $sut = $this->sut(overrides: array_merge( + $this->overrides, + [ + ModuleConfig::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED => ['TestCredential' => []], + ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED => true, + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => StatusListKeyProfileEnum::DidJwk, + ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS => [ + 'default' => [ + StatusListPool::KEY_CREDENTIAL_CONFIGURATIONS => ['TestCredential'], + StatusListPool::KEY_KEY_PROFILE => StatusListKeyProfileEnum::DidWeb->value, + ], + ], + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => 'did:web:issuer.example.org', + ], + )); + + $pool = $sut->getVciStatusListPoolBag()->getById('default'); + + $this->assertSame(StatusListKeyProfileEnum::DidWeb, $pool?->getKeyProfile()); + $this->assertSame('did:web:issuer.example.org', $pool->getIssuerIdentifier()); + } + + + /** + * @throws \Exception + */ + public function testRefusesAPoolOnTheDidWebKeyProfileWithNoIssuerIdentifier(): void + { + $sut = $this->sut(overrides: array_merge( + $this->withStatusListPool(true), + [ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => StatusListKeyProfileEnum::DidWeb], + )); + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage(ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER); + + $sut->getVciStatusListPoolBag(); + } + + + /** + * A pool on a profile which never looks at the issuer did:web must stay readable when that option + * is malformed. The JWKS endpoint decides whether to publish the credential signing key by reading + * the pools, so a failure here would withdraw the key that a `jwks` profile pool's already + * published tokens are verified through -- over a setting those tokens do not use. + * + * @throws \Exception + */ + public function testAMalformedIssuerIdentifierDoesNotStopPoolsWhichDoNotUseOneFromResolving(): void + { + $sut = $this->sut(overrides: array_merge( + $this->withStatusListPool(true), + [ + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => StatusListKeyProfileEnum::Jwks, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => 'did:web:localhost', + ], + )); + + $this->assertSame( + StatusListKeyProfileEnum::Jwks, + $sut->getVciStatusListPoolBag()->getById('default')?->getKeyProfile(), + ); + } + + + /** + * The same option is still refused where it is actually used, so a malformed identifier cannot + * reach a list which would then be signed under it. + * + * @throws \Exception + */ + public function testAMalformedIssuerIdentifierIsStillRefusedForAPoolWhichUsesOne(): void + { + $sut = $this->sut(overrides: array_merge( + $this->withStatusListPool(true), + [ + ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => StatusListKeyProfileEnum::DidWeb, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => 'did:web:localhost', + ], + )); + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage(ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER); + + $sut->getVciStatusListPoolBag(); + } + + /** * A pool whose credential configurations all lack a lifetime allocates only into the non-expiring * lane, so any list it has in the other one is no longer an allocation target and has to be diff --git a/tests/unit/src/Repositories/StatusListRepositoryTest.php b/tests/unit/src/Repositories/StatusListRepositoryTest.php index a5219cda..9fd591c0 100644 --- a/tests/unit/src/Repositories/StatusListRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusListRepositoryTest.php @@ -104,6 +104,8 @@ protected function createList( string $poolId = self::POOL_ID, string $policyFingerprint = self::POLICY, StatusListExpiryLaneEnum $expiryLane = StatusListExpiryLaneEnum::Expiring, + StatusListKeyProfileEnum $keyProfile = StatusListKeyProfileEnum::DidJwk, + ?string $issuerIdentifier = null, ): void { $this->repository->create( $id, @@ -119,12 +121,40 @@ protected function createList( 604800, 3600, 'a-signing-key-id', - StatusListKeyProfileEnum::DidJwk, + $keyProfile, + $issuerIdentifier, ); $this->repository->activate($id); } + /** + * Round trips the column the did:web key profile rests on. The identifier is written when the list + * is created and read back with the row, so signing never has to ask configuration who the issuer + * was -- which is what keeps a changed setting from rewriting the issuer of tokens already being + * verified. The other profiles derive their identity and leave it null. + * + * @throws \Exception + */ + public function testRecordsTheIssuerIdentifierAListWasCreatedUnder(): void + { + $this->createList(); + $this->createList( + id: 'a-did-web-list', + generation: 2, + keyProfile: StatusListKeyProfileEnum::DidWeb, + issuerIdentifier: 'did:web:issuer.example.org', + ); + + $this->assertNull($this->repository->findById(self::LIST_ID)?->getIssuerIdentifier()); + + $record = $this->repository->findById('a-did-web-list'); + + $this->assertSame(StatusListKeyProfileEnum::DidWeb, $record?->getKeyProfile()); + $this->assertSame('did:web:issuer.example.org', $record->getIssuerIdentifier()); + } + + /** * 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 diff --git a/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php b/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php index da461826..6355cfe0 100644 --- a/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php +++ b/tests/unit/src/StatusList/DbStatusIndexAllocatorTest.php @@ -720,6 +720,7 @@ public function create( int $refreshIntervalSeconds, string $signingKeyId, StatusListKeyProfileEnum $keyProfile, + ?string $issuerIdentifier = null, ): void { throw new Exception('Database error: duplicate generation.'); } @@ -1230,6 +1231,7 @@ public function create( int $refreshIntervalSeconds, string $signingKeyId, StatusListKeyProfileEnum $keyProfile, + ?string $issuerIdentifier = null, ): void { throw new Exception('Database error: duplicate generation.'); } @@ -1284,6 +1286,7 @@ public function create( int $refreshIntervalSeconds, string $signingKeyId, StatusListKeyProfileEnum $keyProfile, + ?string $issuerIdentifier = null, ): void { throw new Exception('Database error: disk full.'); } diff --git a/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php b/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php index f7c64268..896cc7e2 100644 --- a/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php +++ b/tests/unit/src/StatusList/DbStatusListTokenProviderTest.php @@ -11,9 +11,11 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use RuntimeException; use SimpleSAML\Module\oidc\Codebooks\StatusListExpiryLaneEnum; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; use SimpleSAML\Module\oidc\Exceptions\StatusListException; +use SimpleSAML\Module\oidc\Factories\DidFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\StatusListEntryRepository; @@ -25,8 +27,9 @@ use SimpleSAML\Module\oidc\StatusList\Values\StatusListRecord; use SimpleSAML\Module\oidc\StatusList\Values\StatusListTokenResult; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; -use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Did\DidJwkResolver; +use SimpleSAML\OpenID\Did\DidUrl; +use SimpleSAML\OpenID\Did\Factories\DidDocumentFactory; use SimpleSAML\OpenID\Helpers as OpenIdHelpers; use SimpleSAML\OpenID\Jwk\Factories\JwkDecoratorFactory; use SimpleSAML\OpenID\Jwk\JwkDecorator; @@ -49,6 +52,8 @@ class DbStatusListTokenProviderTest extends TestCase protected const string DID_JWK = 'did:jwk:eyJrdHkiOiJFQyJ9'; + protected const string DID_WEB = 'did:web:issuer.example.org'; + protected const string SIGNED_TOKEN = 'freshly.signed.token'; protected const string PUBLISHED_TOKEN = 'already.published.token'; @@ -66,6 +71,10 @@ class DbStatusListTokenProviderTest extends TestCase protected MockObject $didJwkResolverMock; + protected MockObject $didDocumentFactoryMock; + + protected MockObject $didFactoryMock; + protected MockObject $loggerServiceMock; protected StatusListContentHasher $statusListContentHasher; @@ -95,6 +104,15 @@ protected function setUp(): void $this->didJwkResolverMock = $this->createMock(DidJwkResolver::class); $this->didJwkResolverMock->method('generateDidJwkFromJwk')->willReturn(self::DID_JWK); + + $this->didDocumentFactoryMock = $this->createMock(DidDocumentFactory::class); + $this->didDocumentFactoryMock->method('verificationMethodIdFor')->willReturnCallback( + static fn(DidUrl $did, string $keyId): DidUrl => new DidUrl($did->getDid() . '#' . $keyId), + ); + + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->method('didJwkResolver')->willReturn($this->didJwkResolverMock); + $this->didFactoryMock->method('didDocumentFactory')->willReturn($this->didDocumentFactoryMock); } @@ -107,9 +125,6 @@ protected function setUp(): void */ 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())); @@ -123,7 +138,7 @@ protected function sut(): DbStatusListTokenProvider $this->statusListKeyResolverMock, $tokenStatusListMock, $this->moduleConfigMock, - $didMock, + $this->didFactoryMock, $this->helpers, $this->loggerServiceMock, ); @@ -173,6 +188,7 @@ protected function record( ?string $retiredAt = null, StatusListKeyProfileEnum $keyProfile = StatusListKeyProfileEnum::DidJwk, int $invalidationCounter = 4, + ?string $issuerIdentifier = null, ): StatusListRecord { return new StatusListRecord( self::LIST_ID, @@ -189,6 +205,7 @@ protected function record( 3600, self::SIGNING_KEY_ID, $keyProfile, + $issuerIdentifier, 0, true, null, @@ -523,13 +540,121 @@ public function testSignsWithTheIssuerAndKeyIdUnderTheJwksProfile(): void } + /** + * Under the did:web profile the token names the identifier the list was created under, and the key + * by the verification method the published DID document gives it. + * + * @throws \Exception + */ + public function testSignsWithTheRecordedDidWebIdentity(): void + { + $this->givenAListWhichNeedsPublishing(StatusListKeyProfileEnum::DidWeb, self::DID_WEB); + $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(), + self::DID_WEB, + [], + ['kid' => self::DID_WEB . '#' . self::SIGNING_KEY_ID], + ) + ->willReturn($this->signedTokenStub()); + + $this->sut()->getToken(self::LIST_ID); + } + + + /** + * The configured identity is never consulted, so changing or clearing it leaves every list already + * being served naming the issuer its holders resolved it under. + * + * @throws \Exception + */ + public function testTheConfiguredIssuerIdentityIsNeverReadWhenSigning(): void + { + $this->moduleConfigMock->expects($this->never())->method('getVciIssuerDidIdentifier'); + $this->moduleConfigMock->expects($this->never())->method('getVciIssuerIdentifier'); + $this->moduleConfigMock->expects($this->never())->method('getVciIssuerIdentifierMode'); + + $this->givenAListWhichNeedsPublishing(StatusListKeyProfileEnum::DidWeb, self::DID_WEB); + + $this->sut()->getToken(self::LIST_ID); + } + + + /** + * Both identities are minted from key material already in hand, so neither may depend on the facade + * whose construction reads the cache adapter and the outbound destination settings -- neither of + * which signing a list has any use for. + * + * @throws \Exception + */ + public function testSigningNeverBuildsTheResolvingFacade(): void + { + $this->didFactoryMock->expects($this->never())->method('build'); + + $this->givenAListWhichNeedsPublishing(StatusListKeyProfileEnum::DidWeb, self::DID_WEB); + + $this->sut()->getToken(self::LIST_ID); + } + + + /** + * A key identifier which cannot be built is reported rather than worked around, since the only ways + * around it would be to sign with a `kid` the published document does not carry or to sign with + * none at all. + * + * @throws \Exception + */ + public function testReportsAFailureToMintTheDidWebVerificationMethodId(): void + { + $didDocumentFactoryMock = $this->createMock(DidDocumentFactory::class); + $didDocumentFactoryMock->method('verificationMethodIdFor') + ->willThrowException(new RuntimeException('nope')); + + $this->didFactoryMock = $this->createMock(DidFactory::class); + $this->didFactoryMock->method('didDocumentFactory')->willReturn($didDocumentFactoryMock); + + $this->givenAListWhichNeedsPublishing(StatusListKeyProfileEnum::DidWeb, self::DID_WEB); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('did:web'); + + $this->sut()->getToken(self::LIST_ID); + } + + + /** + * Signing under a guessed identity would be worse than not signing at all: the token would name an + * issuer the list was never created under, and a wallet cannot tell the difference. + * + * @throws \Exception + */ + public function testRefusesToSignADidWebListWhichRecordedNoIdentifier(): void + { + $this->givenAListWhichNeedsPublishing(StatusListKeyProfileEnum::DidWeb); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('no issuer identifier'); + + $this->sut()->getToken(self::LIST_ID); + } + + /** * @throws \Exception */ protected function givenAListWhichNeedsPublishing( StatusListKeyProfileEnum $keyProfile = StatusListKeyProfileEnum::DidJwk, + ?string $issuerIdentifier = null, ): void { - $record = $this->record(keyProfile: $keyProfile); + $record = $this->record(keyProfile: $keyProfile, issuerIdentifier: $issuerIdentifier); $this->statusListRepositoryMock->method('findById')->willReturn($record); $this->statusListRepositoryMock->method('findByIdOnPrimary')->willReturn($record); diff --git a/tests/unit/src/StatusList/Values/StatusListPoolTest.php b/tests/unit/src/StatusList/Values/StatusListPoolTest.php index 3678e2a1..a66ca742 100644 --- a/tests/unit/src/StatusList/Values/StatusListPoolTest.php +++ b/tests/unit/src/StatusList/Values/StatusListPoolTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; @@ -21,6 +22,8 @@ class StatusListPoolTest extends TestCase protected const string KEY_ID = 'signing-key-1'; + protected const string DID_WEB = 'did:web:issuer.example.org'; + /** * @param array $overrides @@ -29,6 +32,7 @@ class StatusListPoolTest extends TestCase protected function sut( array $overrides = [], StatusListKeyProfileEnum $defaultKeyProfile = StatusListKeyProfileEnum::DidJwk, + ?string $issuerIdentifier = null, ): StatusListPool { return StatusListPool::fromConfig( self::POOL_ID, @@ -37,6 +41,7 @@ protected function sut( $overrides, ), $defaultKeyProfile, + $issuerIdentifier, ); } @@ -89,6 +94,51 @@ public function testTakesTheGlobalKeyProfileAndAllowsAPoolToOverrideIt(): void } + /** + * Refused while the pool is built rather than when a token is signed, because by then the list + * exists and credentials already point at it. + */ + public function testRejectsTheDidWebProfileWithNoIssuerIdentifier(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage(ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER); + + $this->sut([StatusListPool::KEY_KEY_PROFILE => StatusListKeyProfileEnum::DidWeb]); + } + + + public function testCarriesTheIssuerIdentifierUnderTheDidWebProfile(): void + { + $pool = $this->sut( + [StatusListPool::KEY_KEY_PROFILE => StatusListKeyProfileEnum::DidWeb], + issuerIdentifier: self::DID_WEB, + ); + + $this->assertSame(StatusListKeyProfileEnum::DidWeb, $pool->getKeyProfile()); + $this->assertSame(self::DID_WEB, $pool->getIssuerIdentifier()); + } + + + /** + * The identifier is resolved once for the whole bag, as soon as any one pool needs it, so a mixed + * configuration hands it to pools which do not. They must not keep it: a list they create would + * otherwise record an identity nothing resolves from it, and whoever later asks which DID documents + * still have to be served would be told to keep one that was never needed. + */ + #[DataProvider('profilesWhichDoNotUseTheIssuerIdentifierDataProvider')] + public function testDoesNotCarryTheIssuerIdentifierUnderTheOtherProfiles( + StatusListKeyProfileEnum $keyProfile, + ): void { + $pool = $this->sut( + [StatusListPool::KEY_KEY_PROFILE => $keyProfile], + issuerIdentifier: self::DID_WEB, + ); + + $this->assertSame($keyProfile, $pool->getKeyProfile()); + $this->assertNull($pool->getIssuerIdentifier()); + } + + public function testRejectsAnUnknownKeyProfile(): void { $this->expectException(ConfigurationError::class); @@ -383,6 +433,52 @@ public function testPolicyFingerprintChangesWithTheSigningKey(): void } + /** + * A list records the identifier it was created under, so a changed identifier has to route new + * credentials to a new list rather than onto one whose tokens still name the old issuer. + */ + public function testPolicyFingerprintChangesWithTheIssuerIdentifier(): void + { + $didWeb = [StatusListPool::KEY_KEY_PROFILE => StatusListKeyProfileEnum::DidWeb]; + + $this->assertNotSame( + $this->sut($didWeb, issuerIdentifier: self::DID_WEB)->getPolicyFingerprint(self::KEY_ID), + $this->sut($didWeb, issuerIdentifier: 'did:web:other.example.org') + ->getPolicyFingerprint(self::KEY_ID), + ); + } + + + /** + * The identifier is part of the fingerprint only under the profile which uses it. Were it always + * included, every deployment's pools would fingerprint differently the moment this option existed + * and move onto fresh lists, splitting herds which had no reason to split. + */ + #[DataProvider('profilesWhichDoNotUseTheIssuerIdentifierDataProvider')] + public function testPolicyFingerprintIgnoresTheIssuerIdentifierUnderTheOtherProfiles( + StatusListKeyProfileEnum $keyProfile, + ): void { + $config = [StatusListPool::KEY_KEY_PROFILE => $keyProfile]; + + $this->assertSame( + $this->sut($config)->getPolicyFingerprint(self::KEY_ID), + $this->sut($config, issuerIdentifier: self::DID_WEB)->getPolicyFingerprint(self::KEY_ID), + ); + } + + + /** + * @return array + */ + public static function profilesWhichDoNotUseTheIssuerIdentifierDataProvider(): array + { + return [ + 'did:jwk' => [StatusListKeyProfileEnum::DidJwk], + 'jwks' => [StatusListKeyProfileEnum::Jwks], + ]; + } + + /** * 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. From 7e3120074094fd4a42d179ecb5b2878e82b0abab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 3 Sep 2026 14:12:42 +0200 Subject: [PATCH 14/15] Report which DID documents unretired Status Lists still need --- config/module_oidc.php.dist | 8 +- docs/3-oidc-configuration.md | 15 +- locales/en/LC_MESSAGES/oidc.po | 24 ++ locales/es/LC_MESSAGES/oidc.po | 24 ++ locales/fr/LC_MESSAGES/oidc.po | 24 ++ locales/hr/LC_MESSAGES/oidc.po | 24 ++ locales/it/LC_MESSAGES/oidc.po | 24 ++ locales/nl/LC_MESSAGES/oidc.po | 24 ++ .../ConfigOverview/VciOverviewBuilder.php | 135 ++++++++++- src/Repositories/StatusListRepository.php | 59 ++++- .../ConfigOverview/VciOverviewBuilderTest.php | 220 ++++++++++++++++++ .../ConfigOverview/VciOverviewTestTrait.php | 16 ++ .../Repositories/StatusListRepositoryTest.php | 88 +++++++ 13 files changed, 671 insertions(+), 14 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 5ee87634..9b0e2df5 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -1933,9 +1933,11 @@ $config = [ * OPTION_VCI_ISSUER_DID_IDENTIFIER routes new credentials to new lists * rather than changing the issuer of tokens already being verified. Keep the * DID document published for every identifier any unretired list still - * names. The administration screen lists the identities credentials were - * issued under, which covers these too unless credentials are issued under - * some other identity than the Status Lists are. + * names. The administration screen reports them, separately from the + * identities credentials were issued under, since a deployment may well + * identify its credentials some other way. An identifier leaves that row + * once every list under it has been retired, which is when its document may + * be withdrawn. * * Note that a deployment which creates a DidWeb list can not be rolled back * to a release without this profile: an older process reading such a row diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 2318c80c..e94d2916 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -335,7 +335,8 @@ old identifier resolves to. The VCI configuration screen lists every identity this deployment has actually issued credentials under, and warns when one of them is a `did:web` it no longer publishes. Configuration alone cannot answer that question, which is why it is -recorded as credentials are issued. +recorded as credentials are issued. Status Lists carry the same obligation and +are reported on their own row, described under [Key profile](#key-profile). ## Holder binding and the DIIP profile @@ -882,11 +883,13 @@ credentials go to new lists, and the old lists go on naming the issuer they were The obligation that follows is the same one credentials carry. **Keep the DID document published for every identifier an unretired list still names**, not only for the one currently configured — a Relying Party checking a credential's status has to resolve the token's issuer, and a `404` there is -indistinguishable from a revoked deployment. The Verifiable Credential configuration screen lists the -identities **credentials** were issued under, which answers this too whenever credentials are issued -under the same `did:web`. It does not cover the case where they are not — a deployment naming its -credentials some other way while its Status Lists use `did:web` has to track the identifiers of its -unretired lists itself. +indistinguishable from a revoked deployment. The Verifiable Credential configuration screen answers this +directly: separately from the identities credentials were issued under, it lists the ones the Status +Lists still being served are signed under, and warns when any of them is no longer the configured +`did:web`. The two are separate rows because they are separate questions — a deployment may identify its +credentials by a `did:jwk` while its Status Lists use `did:web`, and then the credential row has never +seen that identifier. An identifier leaves the Status List row once every list under it has been +retired, and that is the point at which its document may finally be withdrawn. **Selecting `did_web` is a one-way upgrade.** Once a list has been created under it, the deployment can no longer be rolled back to a release that predates the profile: an older process reading that row diff --git a/locales/en/LC_MESSAGES/oidc.po b/locales/en/LC_MESSAGES/oidc.po index 6401b832..7ad478ae 100644 --- a/locales/en/LC_MESSAGES/oidc.po +++ b/locales/en/LC_MESSAGES/oidc.po @@ -2439,3 +2439,27 @@ msgid "" "verified. Restore that identity to resume publishing it, or serve what it " "needs by other means." msgstr "" + +msgid "Identities Status Lists Are Signed Under" +msgstr "" + +msgid "" +"No Status List which is still served names a did:web, either because none " +"was created under that key profile or because every one which was has been " +"retired." +msgstr "" + +msgid "" +"Status List Tokens are signed under these, so the DID document of each has " +"to stay resolvable for as long as the lists naming it are served. An " +"identifier drops off this row once every list under it has been retired, " +"and only then may its document be withdrawn." +msgstr "" + +msgid "" +"An identifier listed here is not the configured did:web, so this module no " +"longer publishes its DID document and nothing can verify the Status List " +"Tokens signed under it, leaving every credential in those lists without a " +"resolvable status. Set it as the issuer did:web identifier again, or serve " +"its document by other means, until those lists retire." +msgstr "" diff --git a/locales/es/LC_MESSAGES/oidc.po b/locales/es/LC_MESSAGES/oidc.po index ca0ebac5..91fdbf26 100644 --- a/locales/es/LC_MESSAGES/oidc.po +++ b/locales/es/LC_MESSAGES/oidc.po @@ -2439,3 +2439,27 @@ msgid "" "verified. Restore that identity to resume publishing it, or serve what it " "needs by other means." msgstr "" + +msgid "Identities Status Lists Are Signed Under" +msgstr "" + +msgid "" +"No Status List which is still served names a did:web, either because none " +"was created under that key profile or because every one which was has been " +"retired." +msgstr "" + +msgid "" +"Status List Tokens are signed under these, so the DID document of each has " +"to stay resolvable for as long as the lists naming it are served. An " +"identifier drops off this row once every list under it has been retired, " +"and only then may its document be withdrawn." +msgstr "" + +msgid "" +"An identifier listed here is not the configured did:web, so this module no " +"longer publishes its DID document and nothing can verify the Status List " +"Tokens signed under it, leaving every credential in those lists without a " +"resolvable status. Set it as the issuer did:web identifier again, or serve " +"its document by other means, until those lists retire." +msgstr "" diff --git a/locales/fr/LC_MESSAGES/oidc.po b/locales/fr/LC_MESSAGES/oidc.po index 709be758..95893bb1 100644 --- a/locales/fr/LC_MESSAGES/oidc.po +++ b/locales/fr/LC_MESSAGES/oidc.po @@ -2439,3 +2439,27 @@ msgid "" "verified. Restore that identity to resume publishing it, or serve what it " "needs by other means." msgstr "" + +msgid "Identities Status Lists Are Signed Under" +msgstr "" + +msgid "" +"No Status List which is still served names a did:web, either because none " +"was created under that key profile or because every one which was has been " +"retired." +msgstr "" + +msgid "" +"Status List Tokens are signed under these, so the DID document of each has " +"to stay resolvable for as long as the lists naming it are served. An " +"identifier drops off this row once every list under it has been retired, " +"and only then may its document be withdrawn." +msgstr "" + +msgid "" +"An identifier listed here is not the configured did:web, so this module no " +"longer publishes its DID document and nothing can verify the Status List " +"Tokens signed under it, leaving every credential in those lists without a " +"resolvable status. Set it as the issuer did:web identifier again, or serve " +"its document by other means, until those lists retire." +msgstr "" diff --git a/locales/hr/LC_MESSAGES/oidc.po b/locales/hr/LC_MESSAGES/oidc.po index 2668b805..2c09040d 100644 --- a/locales/hr/LC_MESSAGES/oidc.po +++ b/locales/hr/LC_MESSAGES/oidc.po @@ -2486,3 +2486,27 @@ msgid "" "verified. Restore that identity to resume publishing it, or serve what it " "needs by other means." msgstr "" + +msgid "Identities Status Lists Are Signed Under" +msgstr "" + +msgid "" +"No Status List which is still served names a did:web, either because none " +"was created under that key profile or because every one which was has been " +"retired." +msgstr "" + +msgid "" +"Status List Tokens are signed under these, so the DID document of each has " +"to stay resolvable for as long as the lists naming it are served. An " +"identifier drops off this row once every list under it has been retired, " +"and only then may its document be withdrawn." +msgstr "" + +msgid "" +"An identifier listed here is not the configured did:web, so this module no " +"longer publishes its DID document and nothing can verify the Status List " +"Tokens signed under it, leaving every credential in those lists without a " +"resolvable status. Set it as the issuer did:web identifier again, or serve " +"its document by other means, until those lists retire." +msgstr "" diff --git a/locales/it/LC_MESSAGES/oidc.po b/locales/it/LC_MESSAGES/oidc.po index ea3957d9..f1711427 100644 --- a/locales/it/LC_MESSAGES/oidc.po +++ b/locales/it/LC_MESSAGES/oidc.po @@ -2439,3 +2439,27 @@ msgid "" "verified. Restore that identity to resume publishing it, or serve what it " "needs by other means." msgstr "" + +msgid "Identities Status Lists Are Signed Under" +msgstr "" + +msgid "" +"No Status List which is still served names a did:web, either because none " +"was created under that key profile or because every one which was has been " +"retired." +msgstr "" + +msgid "" +"Status List Tokens are signed under these, so the DID document of each has " +"to stay resolvable for as long as the lists naming it are served. An " +"identifier drops off this row once every list under it has been retired, " +"and only then may its document be withdrawn." +msgstr "" + +msgid "" +"An identifier listed here is not the configured did:web, so this module no " +"longer publishes its DID document and nothing can verify the Status List " +"Tokens signed under it, leaving every credential in those lists without a " +"resolvable status. Set it as the issuer did:web identifier again, or serve " +"its document by other means, until those lists retire." +msgstr "" diff --git a/locales/nl/LC_MESSAGES/oidc.po b/locales/nl/LC_MESSAGES/oidc.po index 6b1a07e4..627b714d 100644 --- a/locales/nl/LC_MESSAGES/oidc.po +++ b/locales/nl/LC_MESSAGES/oidc.po @@ -2393,3 +2393,27 @@ msgid "" "verified. Restore that identity to resume publishing it, or serve what it " "needs by other means." msgstr "" + +msgid "Identities Status Lists Are Signed Under" +msgstr "" + +msgid "" +"No Status List which is still served names a did:web, either because none " +"was created under that key profile or because every one which was has been " +"retired." +msgstr "" + +msgid "" +"Status List Tokens are signed under these, so the DID document of each has " +"to stay resolvable for as long as the lists naming it are served. An " +"identifier drops off this row once every list under it has been retired, " +"and only then may its document be withdrawn." +msgstr "" + +msgid "" +"An identifier listed here is not the configured did:web, so this module no " +"longer publishes its DID document and nothing can verify the Status List " +"Tokens signed under it, leaving every credential in those lists without a " +"resolvable status. Set it as the issuer did:web identifier again, or serve " +"its document by other means, until those lists retire." +msgstr "" diff --git a/src/Admin/ConfigOverview/VciOverviewBuilder.php b/src/Admin/ConfigOverview/VciOverviewBuilder.php index fd125847..5c7da63a 100644 --- a/src/Admin/ConfigOverview/VciOverviewBuilder.php +++ b/src/Admin/ConfigOverview/VciOverviewBuilder.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\oidc\Codebooks\VciCredentialBindingPolicyEnum; use SimpleSAML\Module\oidc\Codebooks\VciIssuerIdentifierModeEnum; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\StatusListRepository; use SimpleSAML\Module\oidc\Repositories\VciIssuerIdentityRepository; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\StatusList\Values\StatusListPool; @@ -54,9 +55,9 @@ class VciOverviewBuilder extends AbstractOverviewBuilder /** - * The repository is read only here, and the Configuration screens already resolve a database + * The repositories are read only here, and the Configuration screens already resolve a database * connection through DatabaseMigration, so this adds no failure mode they did not have. The rows - * which use it still catch for themselves, since these screens exist to be readable while + * which use them still catch for themselves, since these screens exist to be readable while * something is broken. */ public function __construct( @@ -65,6 +66,7 @@ public function __construct( DateIntervalFormatter $dateIntervalFormatter, LoggerService $logger, protected readonly VciIssuerIdentityRepository $vciIssuerIdentityRepository, + protected readonly StatusListRepository $statusListRepository, ) { parent::__construct($moduleConfig, $routes, $dateIntervalFormatter, $logger); } @@ -182,6 +184,7 @@ function () use ($isEnabled): Row { ); }, ), + $this->buildStatusListIdentitiesRow(), $this->guardRow( Translate::noop('Status List Requests Per Minute'), ModuleConfig::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE, @@ -293,6 +296,128 @@ function (): Row { } + /** + * Which issuer identities the Status Lists this deployment still serves are signed under. + * + * The configured did:web is one question and this is another. A list records the identifier it was + * created under and goes on signing its tokens with it, so an identifier which has since been + * changed is still being emitted, and its DID document has to stay resolvable for as long as that + * is true -- otherwise the tokens can not be verified and every credential pointing at those lists + * loses its status. Nothing else reports it: the deployment may well issue its credentials under a + * did:jwk or an issuer URL, in which case its did:web appears on no credential at all and the row + * above, which answers for credentials, has never seen it. + * + * Only lists which have not been retired are counted, and that is the point of the row. A retired + * list answers 404, so nothing resolves its issuer any more; once the last list under an old + * identifier retires, the identifier drops off here, and that is the signal that its document may + * finally be withdrawn. The credentials row can never say that, because a credential once issued + * can not be taken back. + * + * Shown whether or not Status Lists are enabled, since lists which already exist keep being served + * either way. + */ + protected function buildStatusListIdentitiesRow(): Row + { + $label = Translate::noop('Identities Status Lists Are Signed Under'); + + try { + $used = $this->statusListRepository->getUnretiredIssuerIdentifiers(); + } catch (Throwable $exception) { + $this->logger->error( + 'Configuration overview could not read which identities Status Lists are signed ' . + 'under: ' . $exception->getMessage(), + ['exceptionClass' => $exception::class], + ); + + return new Row( + $label, + Translate::noop('N/A'), + ConfigOverviewValueTypeEnum::Text, + null, + null, + Translate::noop( + 'This could not be read, so a change of issuer identity can not be reported here. ' . + 'The reason was written to the SimpleSAMLphp log.', + ), + ); + } + + if ($used === []) { + return new Row( + $label, + Translate::noop('None recorded'), + ConfigOverviewValueTypeEnum::Text, + null, + Translate::noop( + 'No Status List which is still served names a did:web, either because none was ' . + 'created under that key profile or because every one which was has been retired.', + ), + ); + } + + $note = Translate::noop( + 'Status List Tokens are signed under these, so the DID document of each has to stay ' . + 'resolvable for as long as the lists naming it are served. An identifier drops off ' . + 'this row once every list under it has been retired, and only then may its document ' . + 'be withdrawn.', + ); + + // The configured identifier decides only whether to warn, and reading it is a separate + // failure from reading the lists. Answering this question inside the same try would make it + // answerable only while an unrelated option is valid -- and a malformed did:web is reported + // on its own row already, so all that would achieve is hiding the identifiers from the one + // screen which can name them, in exactly the state where an administrator is looking for + // them. + // + // The did:web option is read on its own rather than through getVciIssuerIdentifier(), because + // what is being asked is which document VciDidDocumentController publishes, and that + // controller reads this option alone -- publication does not depend on the mode. Resolving the + // two together would throw on a malformed mode, and again on the one pairing which can not be + // honoured: a mode still naming did:web after the identifier was cleared. That second case is + // this row's whole reason to exist, since clearing the option is what withdraws the document + // the lists still name, and it would have been reported as unreadable rather than as the + // warning it is. + try { + $didWeb = $this->moduleConfig->getVciIssuerDidIdentifier(); + } catch (Throwable $exception) { + $this->logger->error( + 'Configuration overview could not read the configured issuer did:web while ' . + 'reporting which identities Status Lists are signed under: ' . $exception->getMessage(), + ['exceptionClass' => $exception::class], + ); + + return new Row( + $label, + $used, + ConfigOverviewValueTypeEnum::StringList, + null, + $note, + Translate::noop( + 'This could not be read, so a change of issuer identity can not be reported here. ' . + 'The reason was written to the SimpleSAMLphp log.', + ), + ); + } + + $noLongerPublished = array_filter($used, static fn(string $identifier): bool => $identifier !== $didWeb); + + return new Row( + $label, + $used, + ConfigOverviewValueTypeEnum::StringList, + null, + $note, + $noLongerPublished === [] ? null : Translate::noop( + 'An identifier listed here is not the configured did:web, so this module no longer ' . + 'publishes its DID document and nothing can verify the Status List Tokens signed ' . + 'under it, leaving every credential in those lists without a resolvable status. Set ' . + 'it as the issuer did:web identifier again, or serve its document by other means, ' . + 'until those lists retire.', + ), + ); + } + + /** * @return array> */ @@ -510,7 +635,11 @@ protected function buildIssuedIdentitiesRow(): Row try { $used = $this->vciIssuerIdentityRepository->getAllUsed(); - $didWeb = $this->moduleConfig->getVciIssuerIdentifier()->getDidWeb(); + // The did:web option alone, not the two resolved together: what is being asked is which + // document is published, and VciDidDocumentController reads this option by itself. See + // buildStatusListIdentitiesRow(), which explains at length why the pairing would throw in + // the very state this row has to report. + $didWeb = $this->moduleConfig->getVciIssuerDidIdentifier(); $issuer = $this->moduleConfig->getIssuer(); } catch (Throwable $exception) { $this->logger->error( diff --git a/src/Repositories/StatusListRepository.php b/src/Repositories/StatusListRepository.php index f56d1607..71ac18b2 100644 --- a/src/Repositories/StatusListRepository.php +++ b/src/Repositories/StatusListRepository.php @@ -884,18 +884,73 @@ public function findRetiredWithEntries(int $limit, DateTimeImmutable $retiredBef } + /** + * Every issuer identity the lists this deployment still serves are signed under. + * + * A list records the `did:web` it was created under and keeps signing its tokens with it, so this + * is the set of DID documents which still have to resolve -- and configuration can not answer it, + * since the configured identifier is only the one new lists are created under. + * + * Retired lists are left out, and that is the point of the query. Such a list answers 404, so + * nothing resolves its issuer any more; an identifier which appears on none of the remaining lists + * is one whose document may finally be withdrawn. Contrast VciIssuerIdentityRepository, which + * remembers every identity credentials were issued under for good, because a credential once + * issued can not be taken back. + * + * Read from the primary, in keeping with the rule above: this drives the decision to stop + * publishing a document, and a lagging secondary would omit a list created moments ago. + * + * Deliberately not filtered by key profile. Only the `did_web` profile records an identifier + * today, so the filter would be redundant, and the only thing it could ever do is drop a row -- + * which on this query means an identity whose document is still needed going unreported. Naming + * one document too many costs an operator a file nobody fetches; naming one too few costs every + * credential in those lists its status. Nor is there an older state to worry about: the profile, + * this column and its migration all arrived together, and a `did_web` pool without an identifier + * is refused when the configuration is read, so no list can carry the one without the other. + * + * Deduplicated in PHP rather than by `SELECT DISTINCT`, because MySQL's usual collation is case + * insensitive and would fold two byte-distinct identifiers into one arbitrarily chosen row -- + * `did:web` path segments are case sensitive, and the caller compares byte for byte. Sorted here + * for the same reason, so the display order does not depend on the database's collation either. + * + * @return string[] Ordered by identifier, so the display does not reshuffle between page loads. + */ + public function getUnretiredIssuerIdentifiers(): array + { + $identifiers = array_values( + array_unique( + $this->readIdentifiers( + sprintf( + 'SELECT issuer_identifier FROM %s WHERE issuer_identifier IS NOT NULL ' . + 'AND retired_at IS NULL', + $this->getTableName(), + ), + [], + 'issuer_identifier', + ), + SORT_STRING, + ), + ); + + sort($identifiers, SORT_STRING); + + return $identifiers; + } + + /** * @param array $params + * @param string $column Which column of the result set carries the identifier. * @return string[] */ - protected function readIdentifiers(string $statement, array $params = []): array + protected function readIdentifiers(string $statement, array $params = [], string $column = 'id'): array { $identifiers = []; /** @var mixed $row */ foreach ($this->readPrimary($statement, $params) as $row) { /** @var mixed $id */ - $id = is_array($row) ? ($row['id'] ?? null) : null; + $id = is_array($row) ? ($row[$column] ?? null) : null; if (is_scalar($id)) { $identifiers[] = (string)$id; diff --git a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php index 60f222b6..ad694254 100644 --- a/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php +++ b/tests/unit/src/Admin/ConfigOverview/VciOverviewBuilderTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use RuntimeException; use SimpleSAML\Module\oidc\Admin\ConfigOverview\AbstractOverviewBuilder; use SimpleSAML\Module\oidc\Admin\ConfigOverview\Section; use SimpleSAML\Module\oidc\Admin\ConfigOverview\VciOverviewBuilder; @@ -1457,4 +1458,223 @@ public function testWarnsAboutAnIssuerUrlIdentityWhichIsNoLongerThisIssuer(): vo $this->assertNotNull($row); $this->assertStringContainsString('can no longer be verified', (string)$row->getWarning()); } + + + public function testSaysNoStatusListNamesADidWeb(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder()->build(), + 'Identities Status Lists Are Signed Under', + ); + + $this->assertNotNull($row); + $this->assertSame('None recorded', $row->getValue()); + $this->assertNull($row->getWarning()); + } + + + /** + * The lists name the identifier the deployment still publishes, so there is nothing to act on -- + * only the standing obligation to keep publishing it, which the note states. + */ + public function testDoesNotWarnAboutStatusListIdentitiesWhichAreStillPublished(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => self::DID_WEB_WITH_PATH], + didDocumentUrl: self::DID_WEB_WITH_PATH_URL, + statusListIssuerIdentifiers: [self::DID_WEB_WITH_PATH], + )->build(), + 'Identities Status Lists Are Signed Under', + ); + + $this->assertNotNull($row); + $this->assertSame([self::DID_WEB_WITH_PATH], $row->getValue()); + $this->assertStringContainsString('has to stay', (string)$row->getNote()); + $this->assertNull($row->getWarning()); + } + + + /** + * The case M5c left unreported: credentials are issued under a did:jwk, so the deployment's + * did:web appears on no credential and the row which answers for credentials has never seen it. + * Only the lists know it, and only they can say its document is still needed. + */ + public function testWarnsAboutAStatusListIdentityWhichIsNoLongerPublished(): void + { + $sections = $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidJwk, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => self::DID_WEB_WITH_PATH, + ], + didDocumentUrl: self::DID_WEB_WITH_PATH_URL, + statusListIssuerIdentifiers: ['did:web:retired.example.org'], + )->build(); + + $row = $this->findRowByLabel($sections, 'Identities Status Lists Are Signed Under'); + + $this->assertNotNull($row); + $this->assertStringContainsString('not the configured did:web', (string)$row->getWarning()); + + // The credentials row is the one which cannot see this, which is why the new row exists. + $credentialsRow = $this->findRowByLabel($sections, 'Identities Credentials Were Issued Under'); + + $this->assertNotNull($credentialsRow); + $this->assertNull($credentialsRow->getWarning()); + } + + + /** + * Clearing the identifier retires the identity and withdraws its document, which is exactly the + * change a list created under it can not follow: it keeps signing under what it recorded. + */ + public function testWarnsAboutStatusListIdentitiesOnceTheIdentifierIsCleared(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidJwk, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => null, + ], + statusListIssuerIdentifiers: [self::DID_WEB_WITH_PATH], + )->build(), + 'Identities Status Lists Are Signed Under', + ); + + $this->assertNotNull($row); + $this->assertStringContainsString('not the configured did:web', (string)$row->getWarning()); + } + + + /** + * Lists which already exist keep being served whatever the switch says, so the obligation their + * identifiers carry does not go away with it. + */ + public function testReportsStatusListIdentitiesWhileStatusListsAreDisabled(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED => false, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => null, + ], + statusListIssuerIdentifiers: ['did:web:retired.example.org'], + )->build(), + 'Identities Status Lists Are Signed Under', + ); + + $this->assertNotNull($row); + $this->assertSame(['did:web:retired.example.org'], $row->getValue()); + $this->assertNotNull($row->getWarning()); + } + + + /** + * These screens are what an administrator opens when the database is already unhappy, so a read + * which fails has to leave the rest of the page standing. + */ + public function testSurvivesAFailureToReadTheStatusListIdentities(): void + { + $sections = $this->buildVciOverviewBuilder( + statusListIssuerIdentifiers: new RuntimeException('No such column: issuer_identifier'), + )->build(); + + $row = $this->findRowByLabel($sections, 'Identities Status Lists Are Signed Under'); + + $this->assertNotNull($row); + $this->assertSame('N/A', $row->getValue()); + $this->assertStringContainsString('could not be read', (string)$row->getWarning()); + $this->assertNotEmpty($sections); + } + + + /** + * The identifiers come from storage and the configured did:web only decides whether to warn about + * them, so a malformed option must not cost the reader the list itself -- that option is reported + * on its own row, and this is the screen an administrator is on while fixing it. + */ + public function testStillListsStatusListIdentitiesWhenTheConfiguredDidWebIsMalformed(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => 'not-a-did'], + statusListIssuerIdentifiers: [self::DID_WEB_WITH_PATH], + )->build(), + 'Identities Status Lists Are Signed Under', + ); + + $this->assertNotNull($row); + $this->assertSame([self::DID_WEB_WITH_PATH], $row->getValue()); + $this->assertStringContainsString('could not be read', (string)$row->getWarning()); + } + + + /** + * Clearing the identifier while the mode still names did:web is the one pairing the two options + * can not be resolved into together -- and it is also precisely the state this row exists to + * report, since clearing the option is what withdraws the document the lists still name. Asking + * for the identifier alone, as the endpoint which publishes the document does, is what keeps the + * warning from being reported as an unreadable configuration. + */ + public function testWarnsAboutStatusListIdentitiesWhenTheModeStillNamesAClearedDidWeb(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidWeb, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => null, + ], + statusListIssuerIdentifiers: [self::DID_WEB_WITH_PATH], + )->build(), + 'Identities Status Lists Are Signed Under', + ); + + $this->assertNotNull($row); + $this->assertStringContainsString('not the configured did:web', (string)$row->getWarning()); + } + + + /** + * A malformed mode says nothing about which document is published, so it must not cost this row + * its comparison either. + */ + public function testStillComparesStatusListIdentitiesWhenTheIssuerModeIsMalformed(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => 'not-a-mode', + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => self::DID_WEB_WITH_PATH, + ], + statusListIssuerIdentifiers: [self::DID_WEB_WITH_PATH], + )->build(), + 'Identities Status Lists Are Signed Under', + ); + + $this->assertNotNull($row); + $this->assertNull($row->getWarning()); + } + + + /** + * The same coupling, in the row which answers for credentials. It predates this change and is the + * identical defect: the state it has to report is the state the paired accessor refuses. + */ + public function testWarnsAboutIssuedIdentitiesWhenTheModeStillNamesAClearedDidWeb(): void + { + $row = $this->findRowByLabel( + $this->buildVciOverviewBuilder( + [ + ModuleConfig::OPTION_VCI_ISSUER_IDENTIFIER_MODE => VciIssuerIdentifierModeEnum::DidWeb, + ModuleConfig::OPTION_VCI_ISSUER_DID_IDENTIFIER => null, + ], + ['did:web:retired.example.org' => VciIssuerIdentifierModeEnum::DidWeb->value], + )->build(), + 'Identities Credentials Were Issued Under', + ); + + $this->assertNotNull($row); + $this->assertSame(['did:web:retired.example.org'], $row->getValue()); + $this->assertStringContainsString('can no longer be verified', (string)$row->getWarning()); + } } diff --git a/tests/unit/src/Admin/ConfigOverview/VciOverviewTestTrait.php b/tests/unit/src/Admin/ConfigOverview/VciOverviewTestTrait.php index 8e7b3b86..5523a512 100644 --- a/tests/unit/src/Admin/ConfigOverview/VciOverviewTestTrait.php +++ b/tests/unit/src/Admin/ConfigOverview/VciOverviewTestTrait.php @@ -5,10 +5,12 @@ namespace SimpleSAML\Test\Module\oidc\unit\Admin\ConfigOverview; use SimpleSAML\Module\oidc\Admin\ConfigOverview\VciOverviewBuilder; +use SimpleSAML\Module\oidc\Repositories\StatusListRepository; use SimpleSAML\Module\oidc\Repositories\VciIssuerIdentityRepository; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\DateIntervalFormatter; use SimpleSAML\Module\oidc\Utils\Routes; +use Throwable; /** * Builds a VciOverviewBuilder for tests. Requires OverviewTestTrait. @@ -20,16 +22,29 @@ trait VciOverviewTestTrait * @param array $usedIssuerIdentities Identifier to the mode it was issued under. * @param ?string $didDocumentUrl Where this module serves its DID document, which the configured * did:web identifier has to resolve to. + * @param string[]|\Throwable $statusListIssuerIdentifiers The did:web identifiers the Status Lists + * which are still served were created + * under, or what reading them throws. * @throws \Exception */ protected function buildVciOverviewBuilder( array $overrides = [], array $usedIssuerIdentities = [], ?string $didDocumentUrl = null, + array|Throwable $statusListIssuerIdentifiers = [], ): VciOverviewBuilder { $vciIssuerIdentityRepository = $this->createMock(VciIssuerIdentityRepository::class); $vciIssuerIdentityRepository->method('getAllUsed')->willReturn($usedIssuerIdentities); + $statusListRepository = $this->createMock(StatusListRepository::class); + $getUnretiredIssuerIdentifiers = $statusListRepository->method('getUnretiredIssuerIdentifiers'); + + if ($statusListIssuerIdentifiers instanceof Throwable) { + $getUnretiredIssuerIdentifiers->willThrowException($statusListIssuerIdentifiers); + } else { + $getUnretiredIssuerIdentifiers->willReturn($statusListIssuerIdentifiers); + } + $routes = $this->createMock(Routes::class); $routes->method('urlVciDidDocument')->willReturn($didDocumentUrl ?? ''); @@ -39,6 +54,7 @@ protected function buildVciOverviewBuilder( new DateIntervalFormatter(), $this->createMock(LoggerService::class), $vciIssuerIdentityRepository, + $statusListRepository, ); } } diff --git a/tests/unit/src/Repositories/StatusListRepositoryTest.php b/tests/unit/src/Repositories/StatusListRepositoryTest.php index 9fd591c0..f3e2bb40 100644 --- a/tests/unit/src/Repositories/StatusListRepositoryTest.php +++ b/tests/unit/src/Repositories/StatusListRepositoryTest.php @@ -155,6 +155,94 @@ public function testRecordsTheIssuerIdentifierAListWasCreatedUnder(): void } + /** + * Which DID documents still have to resolve. Lists sharing an identifier ask for one document + * between them, and a list on another key profile asks for none, so both collapse away. The + * retired one is the case which matters: it answers 404, so nothing resolves its issuer any more + * and an operator is free to stop publishing it -- which is only true while nothing else names it. + * + * @throws \Exception + */ + public function testReportsTheIssuerIdentifiersOfListsWhichAreStillServed(): void + { + $this->createList(); + $this->createList( + id: 'first-under-one', + generation: 2, + keyProfile: StatusListKeyProfileEnum::DidWeb, + issuerIdentifier: 'did:web:one.example.org', + ); + $this->createList( + id: 'second-under-one', + generation: 3, + keyProfile: StatusListKeyProfileEnum::DidWeb, + issuerIdentifier: 'did:web:one.example.org', + ); + $this->createList( + id: 'only-under-two', + generation: 4, + keyProfile: StatusListKeyProfileEnum::DidWeb, + issuerIdentifier: 'did:web:two.example.org', + ); + + $this->assertSame( + ['did:web:one.example.org', 'did:web:two.example.org'], + $this->repository->getUnretiredIssuerIdentifiers(), + ); + + // Deactivation is not retirement. A deactivated list has stopped taking new credentials but + // is still served to the ones it already holds, so its document is still required. + $this->repository->deactivate('only-under-two'); + + $this->assertSame( + ['did:web:one.example.org', 'did:web:two.example.org'], + $this->repository->getUnretiredIssuerIdentifiers(), + ); + + $this->assertTrue($this->repository->retire('only-under-two', $this->spentBefore())); + + $this->assertSame( + ['did:web:one.example.org'], + $this->repository->getUnretiredIssuerIdentifiers(), + ); + } + + + /** + * Two identifiers differing only in case are two identifiers: a `did:web` carries path segments, + * and those are case sensitive. MySQL's usual collation is not, so leaving the deduplication to + * `SELECT DISTINCT` would fold these into one arbitrarily chosen row -- and the one dropped is an + * identity whose document is still required, reported to nobody. + * + * This runs on SQLite, which compares TEXT byte for byte and so would pass either way. It pins the + * contract rather than reproducing the collation, which is also why the deduplication is in PHP: + * there is no test here which could have caught it in SQL. + * + * @throws \Exception + */ + public function testKeepsIssuerIdentifiersWhichDifferOnlyInCaseApart(): void + { + $this->createList( + id: 'lower', + keyProfile: StatusListKeyProfileEnum::DidWeb, + issuerIdentifier: 'did:web:example.org:issuers:alice', + ); + $this->createList( + id: 'upper', + generation: 2, + keyProfile: StatusListKeyProfileEnum::DidWeb, + issuerIdentifier: 'did:web:example.org:issuers:Alice', + ); + + // Byte order, not the database's, which is the other half of the same promise: the display + // must not reshuffle because a deployment moved to another collation. + $this->assertSame( + ['did:web:example.org:issuers:Alice', 'did:web:example.org:issuers:alice'], + $this->repository->getUnretiredIssuerIdentifiers(), + ); + } + + /** * 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 From 77af8e7175aecfbf58e71930ec1b3b2d9a678a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 4 Sep 2026 08:45:41 +0200 Subject: [PATCH 15/15] State what is claimed about DIIP, and what is not --- docs/1-oidc.md | 114 +++++++++++++++++++++++++++++++++++++ docs/5-oidc-conformance.md | 11 ++++ 2 files changed, 125 insertions(+) diff --git a/docs/1-oidc.md b/docs/1-oidc.md index 7f4eb8ba..6ed7a1f2 100644 --- a/docs/1-oidc.md +++ b/docs/1-oidc.md @@ -63,6 +63,12 @@ Drafts / experimental (see the notes below for scope and caveats): - OpenID Federation — automatic client registration and related features - OpenID for Verifiable Credential Issuance, OpenID4VCI (experimental, not for production) +- [Token Status List](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/) + — what makes an issued credential revocable and suspendable; off by default. + See [Configuration](3-oidc-configuration.md#token-status-lists-credential-revocation) +- [Decentralized Identity Interop Profile, DIIP v5](https://FIDEScommunity.github.io/DIIP) + — the **Issuer Agent** role only. See the [DIIP note](#note-on-the-diip-profile) + below ## Note on Dynamic Client Registration (DCR) @@ -132,6 +138,15 @@ Currently implemented OpenID4VCI features: or reinstates an issued credential through its Token Status List entry. - JSON-LD Context | `credential-issuer/context/{credentialConfigurationId}` - Serves custom JSON-LD contexts for `vc+sd-jwt` credentials. + - Status List | `statuslist/{statusListId}` - Serves the signed Token Status + List that an issued credential's `status` claim points at. + - DID Document | `did.json` - Serves this issuer's `did:web` document, so a + verifier can resolve the key a credential was signed with. Served whenever a + `did:web` identifier is configured — but at the module's own URL, so the URL + that identifier resolves to still has to be routed here by the web server. + See [Configuration](3-oidc-configuration.md#serving-the-did-document). + - JWT VC Issuer Metadata | `.well-known/jwt-vc-issuer` - Points an SD-JWT VC + verifier at this issuer's key set. - Supported Flows & Grant Types - Authorization Code Flow: Fully supported - Pre-Authorized Code Flow: Fully supported @@ -149,7 +164,23 @@ Currently implemented OpenID4VCI features: - Cryptographic Binding Methods: - `did:key`: Supported for proof validation and subject binding. - `did:jwk`: Supported for proof validation and subject binding. + - `did:web`: Supported for proof validation and subject binding. The + document is fetched over the network, so DID resolution has a destination + policy of its own, separate from the federation one. + - A key proof may also carry its key inline in a `jwk` header. That is a + documented extension rather than a profile feature. - Nonce Validation for mandatory `c_nonce` validation in proofs. + - Holder binding is stated in a `cnf` claim, in every credential format. + - Each credential configuration decides for itself whether a key proof is + required and which identifier rules apply to it. See + [Configuration](3-oidc-configuration.md#holder-binding-and-the-diip-profile). +- Issuer Identity: a `did:jwk` derived from the signing key (default), a +configured `did:web` whose document this module publishes, or the issuer URL +with the key resolved through the published key set. See +[Configuration](3-oidc-configuration.md#issuer-identity-and-the-did-document). +- Credential Status: a Token Status List entry allocated at issuance, and +withdraw / suspend / reinstate through the admin UI or the API. See +[Configuration](3-oidc-configuration.md#token-status-lists-credential-revocation). - JSON-LD Support: Ability to host and reference custom JSON-LD contexts for enhanced semantic interoperability - API for credential offer fetching @@ -157,6 +188,89 @@ enhanced semantic interoperability OpenID4VCI is also implemented using the [SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid). +## Note on the DIIP profile + +The [Decentralized Identity Interop Profile (DIIP)](https://FIDEScommunity.github.io/DIIP), +release v5, sits on top of OpenID4VCI and names three roles: Issuer, Holder and +Verifier. This module implements the **Issuer Agent** role, and what is claimed +here is scoped to that role rather than to "DIIP conformance" unqualified: + +- **Issuer — in scope.** The module can be identified by a `did:jwk` or a + `did:web`, and publishes a DID document for the latter. A Status List Token is + signed under an identity of its own, chosen by the pool's key profile rather + than by the credential issuer mode — so having a credential and the status + token it points at name the same `did:web` means setting both. See [Key + profile](3-oidc-configuration.md#key-profile). +- **Holder — in scope, as a consumer of holder identifiers.** The module holds + no credentials of its own, but it accepts a holder's `did:jwk` or `did:web` in + an OpenID4VCI key proof, verifies the proof against the key that DID resolves + to, and binds the issued credential to it. +- **Verifier — out of scope.** There is no OpenID4VP surface here at all: no + `vp_token`, no `presentation_definition`, no request object endpoint for + presentation. The profile's `did` Client Identifier Scheme requirement belongs + to that surface, so it does not apply to this module. If OpenID4VP + verification is ever added, verifier identifiers come back into scope and + nothing below covers them. + +**Nothing certifies this, and it is not a claim about the whole profile.** There +is no DIIP conformance suite of the kind the OpenID Foundation runs for OpenID +Connect (see [Conformance testing](#conformance-testing) below for what is +actually tested), so this is a self-assessment. What has been worked through +against the profile text is its identifier half: that Issuers and Holders can be +identified by `did:jwk` and `did:web`, and the two identifier-dependent issuance +requirements, the `jwt` proof type and the `cnf` holder binding claim. Two +readings this module makes along the way — what the profile's `iss` requirement +can mean alongside OpenID4VCI, and which party's DID document its +`assertionMethod` sentence is about — are written out under [Three +interpretations this module makes](3-oidc-configuration.md#three-interpretations-this-module-makes), +so a deployment which reads them differently knows where it differs. + +Only one of those rules is a **per credential configuration** choice, and it is +the one about the *holder's* identifier: the `DiipProofBound` binding policy +requires the key proof to name its key in a `kid` header which is an absolute +`did:jwk` or `did:web` URL, so inline keys and `did:key` holders are refused. It +applies to the configurations that ask for it and to no others, because DIIP's +requirements are additive. The rest are not per configuration at all — the +*issuer's* identity is deployment wide, and a `cnf` claim is emitted by every +proof-bound configuration rather than only by the DIIP ones. + +**Choosing that policy is therefore not by itself a conformant deployment**, and +neither is any single setting. The profile also places requirements on the +deployment as a whole — credential formats, signature algorithm, the issuance +flows, revocation — and those were not traced through one at a time here. The +one most easily missed is a setting rather than a feature: DIIP requires the +Issuer's authorization server to require pushed authorization requests and to +advertise `require_pushed_authorization_requests` as `true`, which here means +setting `OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS` — off by default. See +[Configuration](3-oidc-configuration.md#pushed-authorization-requests-par-and-request-objects). + +Most of the profile's requirements are worded as *"MUST support"* — capabilities +an implementation has to have, rather than a list of things it may not otherwise +do, which is the same reading applied to the `iss` claim above. So the question +worth asking of a deployment is not whether some setting disqualifies it, but +whether a given credential comes out carrying the properties a DIIP verifier +expects. Several independently configured things decide that, and the binding +policy is only one of them: + +- the **issuer identity mode** — under `https` a credential names its issuer by + a URL rather than by a DID; +- the **credential format** — the profile's are the SD-JWT ones, so + `jwt_vc_json` is not among them; +- the **algorithm of the active signing key** — the profile names ES256, and + this module permits RSA and the larger EC curves too; +- and the **binding policy** — `DiipProofBound` is what *guarantees* the + `cnf.kid` names a `did:jwk` or `did:web` verification method, because it + refuses everything else. `ProofBound` produces the same binding when a wallet + happens to send such a proof, but it will bind to an inline key or a `did:key` + holder just as readily, and `Proofless` does not bind at all. + +A credential carries the profile's properties where all four line up for it — +the format and the binding policy from its own credential configuration, the +issuer identity and the signing key from the deployment. Only the first three +are settled by configuration alone: under `ProofBound` the binding a credential +ends up with is whichever one the wallet's proof asked for, which is the reason +`DiipProofBound` exists. + ## Conformance testing On every build, CI runs the following OpenID Foundation certification test diff --git a/docs/5-oidc-conformance.md b/docs/5-oidc-conformance.md index 522d57b3..62cc0648 100644 --- a/docs/5-oidc-conformance.md +++ b/docs/5-oidc-conformance.md @@ -186,3 +186,14 @@ RFC 9126 (PAR) and related `request` / `request_uri` MUST-level requirements are tracked, and mapped to the unit tests that cover them, in `conformance-tests/rfc9126-par-compliance.md`. Keep that checklist in sync when changing PAR or request-object behaviour. + +## Verifiable Credentials are not covered by these plans + +The certification profiles above are OpenID Connect ones, and nothing in them +exercises OpenID4VCI, Token Status Lists or the DIIP profile. No equivalent +certification programme exists to run against those, so what the module claims +there is a self-assessment against the specification text rather than a test +result, and it is backed by unit tests instead. The claim and the roles it +covers are in [OIDC Module](1-oidc.md#note-on-the-diip-profile); the readings +this module makes of individual profile requirements are in +[Configuration](3-oidc-configuration.md#three-interpretations-this-module-makes).