diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ac589c12..bc26eb02 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -692,6 +692,11 @@ parameters: count: 1 path: src/symfony/src/Controller/AttestationControllerFactory.php + - + message: "#^Method Webauthn\\\\Bundle\\\\CredentialOptionsBuilder\\\\PublicKeyCredentialCreationOptionsBuilder\\:\\:getFromRequest\\(\\) invoked with 3 parameters, 2 required\\.$#" + count: 1 + path: src/symfony/src/Controller/AttestationRequestController.php + - message: "#^Call to an undefined method Symfony\\\\Component\\\\HttpFoundation\\\\Request\\:\\:getContentType\\(\\)\\.$#" count: 1 @@ -807,11 +812,6 @@ parameters: count: 1 path: src/symfony/src/CredentialOptionsBuilder/ProfileBasedRequestOptionsBuilder.php - - - message: "#^Should not use function \"dump\", please change the code\\.$#" - count: 1 - path: src/symfony/src/CredentialOptionsBuilder/ProfileBasedRequestOptionsBuilder.php - - message: """ #^Fetching class constant class of deprecated class Webauthn\\\\Bundle\\\\Event\\\\AuthenticatorAssertionResponseValidationFailedEvent\\: @@ -1061,6 +1061,11 @@ parameters: count: 4 path: src/symfony/src/DependencyInjection/WebauthnExtension.php + - + message: "#^Cannot access offset 'hide_existing…' on mixed\\.$#" + count: 1 + path: src/symfony/src/DependencyInjection/WebauthnExtension.php + - message: "#^Cannot access offset 'host' on mixed\\.$#" count: 4 diff --git a/src/symfony/src/Controller/AttestationControllerFactory.php b/src/symfony/src/Controller/AttestationControllerFactory.php index 286a067e..d2fef6a0 100644 --- a/src/symfony/src/Controller/AttestationControllerFactory.php +++ b/src/symfony/src/Controller/AttestationControllerFactory.php @@ -72,13 +72,15 @@ public function createRequestController( OptionsStorage $optionStorage, CreationOptionsHandler $creationOptionsHandler, FailureHandler|AuthenticationFailureHandlerInterface $failureHandler, + bool $hideExistingExcludedCredentials = false ): AttestationRequestController { return new AttestationRequestController( $optionsBuilder, $userEntityGuesser, $optionStorage, $creationOptionsHandler, - $failureHandler + $failureHandler, + $hideExistingExcludedCredentials ); } diff --git a/src/symfony/src/Controller/AttestationRequestController.php b/src/symfony/src/Controller/AttestationRequestController.php index 314fe14d..70f86b51 100644 --- a/src/symfony/src/Controller/AttestationRequestController.php +++ b/src/symfony/src/Controller/AttestationRequestController.php @@ -24,6 +24,7 @@ public function __construct( private readonly OptionsStorage $optionsStorage, private readonly CreationOptionsHandler $creationOptionsHandler, private readonly FailureHandler|AuthenticationFailureHandlerInterface $failureHandler, + private readonly bool $hideExistingExcludedCredentials = false, ) { } @@ -31,7 +32,11 @@ public function __invoke(Request $request): Response { try { $userEntity = $this->userEntityGuesser->findUserEntity($request); - $publicKeyCredentialCreationOptions = $this->extractor->getFromRequest($request, $userEntity); + $publicKeyCredentialCreationOptions = $this->extractor->getFromRequest( + $request, + $userEntity, + $this->hideExistingExcludedCredentials + ); $response = $this->creationOptionsHandler->onCreationOptions( $publicKeyCredentialCreationOptions, diff --git a/src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php b/src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php index 9d1dd498..7b9a10f8 100644 --- a/src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php +++ b/src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php @@ -48,7 +48,8 @@ public function __construct( public function getFromRequest( Request $request, - PublicKeyCredentialUserEntity $userEntity + PublicKeyCredentialUserEntity $userEntity, + bool $hideExistingExcludedCredentials = false ): PublicKeyCredentialCreationOptions { $format = method_exists( $request, @@ -57,7 +58,7 @@ public function getFromRequest( $format === 'json' || throw new BadRequestHttpException('Only JSON content type allowed'); $content = $request->getContent(); - $excludedCredentials = $this->getCredentials($userEntity); + $excludedCredentials = $hideExistingExcludedCredentials === true ? [] : $this->getCredentials($userEntity); $optionsRequest = $this->getServerPublicKeyCredentialCreationOptionsRequest($content); $authenticatorSelectionData = $optionsRequest->authenticatorSelection; $authenticatorSelection = null; diff --git a/src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php b/src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php index 634d3ce8..12e3c92c 100644 --- a/src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php +++ b/src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php @@ -12,6 +12,7 @@ interface PublicKeyCredentialCreationOptionsBuilder { public function getFromRequest( Request $request, - PublicKeyCredentialUserEntity $userEntity + PublicKeyCredentialUserEntity $userEntity, + /*bool $hideExistingExcludedCredentials = false*/ ): PublicKeyCredentialCreationOptions; } diff --git a/src/symfony/src/DependencyInjection/Configuration.php b/src/symfony/src/DependencyInjection/Configuration.php index e060a5ba..b1031004 100644 --- a/src/symfony/src/DependencyInjection/Configuration.php +++ b/src/symfony/src/DependencyInjection/Configuration.php @@ -354,6 +354,12 @@ private function addControllersConfig(ArrayNodeDefinition $rootNode): void ->scalarNode('user_entity_guesser') ->isRequired() ->end() + ->scalarNode('hide_existing_credentials') + ->info( + 'In order to prevent username enumeration, the existing credentials can be hidden. This is highly recommended when the attestation ceremony is performed by anonymous users.' + ) + ->defaultFalse() + ->end() ->scalarNode('options_storage') ->defaultValue(SessionStorage::class) ->info('Service responsible of the options/user entity storage during the ceremony') diff --git a/src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php b/src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php index 68eb78be..7daae1b1 100644 --- a/src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php +++ b/src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php @@ -415,6 +415,7 @@ private function createAttestationRequestControllerAndRoute( new Reference($optionsStorageId), new Reference($optionsHandlerId), new Reference($failureHandlerId), + true, ]); $this->createControllerAndRoute( $container, diff --git a/src/symfony/src/DependencyInjection/WebauthnExtension.php b/src/symfony/src/DependencyInjection/WebauthnExtension.php index 7240f980..8c92df26 100644 --- a/src/symfony/src/DependencyInjection/WebauthnExtension.php +++ b/src/symfony/src/DependencyInjection/WebauthnExtension.php @@ -215,6 +215,7 @@ private function loadCreationControllersSupport(ContainerBuilder $container, arr new Reference($creationConfig['options_storage']), new Reference($creationConfig['options_handler']), new Reference($creationConfig['failure_handler']), + $creationConfig['hide_existing_credentials'] ?? false, ]) ->addTag(DynamicRouteCompilerPass::TAG, [ 'method' => $creationConfig['options_method'], diff --git a/tests/symfony/config/config.yml b/tests/symfony/config/config.yml index e473f7d2..d04ba5a3 100644 --- a/tests/symfony/config/config.yml +++ b/tests/symfony/config/config.yml @@ -131,6 +131,7 @@ webauthn: enabled: true creation: test: + hide_existing_credentials: true options_path: '/devices/add/options' result_path: '/devices/add' #host: null diff --git a/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php b/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php index d2ed3912..ef91ea58 100644 --- a/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php +++ b/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php @@ -64,6 +64,7 @@ public function anExistingUserCanAskForOptionsUsingTheDedicatedController(): voi static::assertArrayHasKey($expectedKey, $data); } static::assertSame('ok', $data['status']); + static::assertArrayNotHasKey('excludeCredentials', $data); // username enumeration prevention is enabled } #[Test] diff --git a/tests/symfony/functional/PublicKeyCredentialSourceRepository.php b/tests/symfony/functional/PublicKeyCredentialSourceRepository.php index ecb53769..570d007a 100644 --- a/tests/symfony/functional/PublicKeyCredentialSourceRepository.php +++ b/tests/symfony/functional/PublicKeyCredentialSourceRepository.php @@ -38,6 +38,24 @@ public function __construct( 100 ); $this->saveCredentialSource($publicKeyCredentialSource1); + $publicKeyCredentialSource2 = PublicKeyCredentialSource::create( + base64_decode( + 'Ac8zKrpVWv9UCwxY1FyMqkESz2lV4CNwTk2+Hp19LgKbvh5uQ2/i6AMbTbTz1zcNapCEeiLJPlAAVM4L7AIow6I=', + true + ), + PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, + [], + AttestationStatement::TYPE_NONE, + EmptyTrustPath::create(), + Uuid::fromBinary(base64_decode('AAAAAAAAAAAAAAAAAAAAAA==', true)), + base64_decode( + 'pQECAyYgASFYIJV56vRrFusoDf9hm3iDmllcxxXzzKyO9WruKw4kWx7zIlgg/nq63l8IMJcIdKDJcXRh9hoz0L+nVwP1Oxil3/oNQYs=', + true + ), + '929fba2f-2361-4bc6-a917-bb76aa14c7f9', + 100 + ); + $this->saveCredentialSource($publicKeyCredentialSource2); } public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource