| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
/* |
| 6 |
* The MIT License (MIT) |
| 7 |
* |
| 8 |
* Copyright (c) 2014-2021 Spomky-Labs |
| 9 |
* |
| 10 |
* This software may be modified and distributed under the terms |
| 11 |
* of the MIT license. See the LICENSE file for details. |
| 12 |
*/ |
| 13 |
|
| 14 |
namespace WP_DEFENDER_VENDOR\Webauthn; |
| 15 |
|
| 16 |
use WP_DEFENDER_VENDOR\Assert\Assertion; |
| 17 |
use WP_DEFENDER_VENDOR\CBOR\Decoder; |
| 18 |
use WP_DEFENDER_VENDOR\CBOR\OtherObject\OtherObjectManager; |
| 19 |
use WP_DEFENDER_VENDOR\CBOR\Tag\TagObjectManager; |
| 20 |
use WP_DEFENDER_VENDOR\Cose\Algorithm\Manager; |
| 21 |
use WP_DEFENDER_VENDOR\Cose\Algorithm\Signature\Signature; |
| 22 |
use WP_DEFENDER_VENDOR\Cose\Key\Key; |
| 23 |
use function count; |
| 24 |
use function in_array; |
| 25 |
use function is_string; |
| 26 |
use WP_DEFENDER_VENDOR\Psr\Http\Message\ServerRequestInterface; |
| 27 |
use WP_DEFENDER_VENDOR\Psr\Log\LoggerInterface; |
| 28 |
use WP_DEFENDER_VENDOR\Psr\Log\NullLogger; |
| 29 |
use function WP_DEFENDER_VENDOR\Safe\parse_url; |
| 30 |
use Throwable; |
| 31 |
use WP_DEFENDER_VENDOR\Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs; |
| 32 |
use WP_DEFENDER_VENDOR\Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputs; |
| 33 |
use WP_DEFENDER_VENDOR\Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler; |
| 34 |
use WP_DEFENDER_VENDOR\Webauthn\Counter\CounterChecker; |
| 35 |
use WP_DEFENDER_VENDOR\Webauthn\Counter\ThrowExceptionIfInvalid; |
| 36 |
use WP_DEFENDER_VENDOR\Webauthn\TokenBinding\TokenBindingHandler; |
| 37 |
use WP_DEFENDER_VENDOR\Webauthn\Util\CoseSignatureFixer; |
| 38 |
|
| 39 |
class AuthenticatorAssertionResponseValidator |
| 40 |
{ |
| 41 |
/** |
| 42 |
* @var PublicKeyCredentialSourceRepository |
| 43 |
*/ |
| 44 |
private $publicKeyCredentialSourceRepository; |
| 45 |
|
| 46 |
/** |
| 47 |
* @var Decoder |
| 48 |
*/ |
| 49 |
private $decoder; |
| 50 |
|
| 51 |
/** |
| 52 |
* @var TokenBindingHandler |
| 53 |
*/ |
| 54 |
private $tokenBindingHandler; |
| 55 |
|
| 56 |
/** |
| 57 |
* @var ExtensionOutputCheckerHandler |
| 58 |
*/ |
| 59 |
private $extensionOutputCheckerHandler; |
| 60 |
|
| 61 |
/** |
| 62 |
* @var Manager|null |
| 63 |
*/ |
| 64 |
private $algorithmManager; |
| 65 |
|
| 66 |
/** |
| 67 |
* @var CounterChecker |
| 68 |
*/ |
| 69 |
private $counterChecker; |
| 70 |
|
| 71 |
/** |
| 72 |
* @var LoggerInterface|null |
| 73 |
*/ |
| 74 |
private $logger; |
| 75 |
|
| 76 |
public function __construct(PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository, TokenBindingHandler $tokenBindingHandler, ExtensionOutputCheckerHandler $extensionOutputCheckerHandler, Manager $algorithmManager, ?CounterChecker $counterChecker = null, ?LoggerInterface $logger = null) |
| 77 |
{ |
| 78 |
if (null !== $logger) { |
| 79 |
@trigger_error('The argument "logger" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setLogger".', E_USER_DEPRECATED); |
| 80 |
} |
| 81 |
if (null !== $counterChecker) { |
| 82 |
@trigger_error('The argument "counterChecker" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setCounterChecker".', E_USER_DEPRECATED); |
| 83 |
} |
| 84 |
$this->publicKeyCredentialSourceRepository = $publicKeyCredentialSourceRepository; |
| 85 |
$this->decoder = new Decoder(new TagObjectManager(), new OtherObjectManager()); |
| 86 |
$this->tokenBindingHandler = $tokenBindingHandler; |
| 87 |
$this->extensionOutputCheckerHandler = $extensionOutputCheckerHandler; |
| 88 |
$this->algorithmManager = $algorithmManager; |
| 89 |
$this->counterChecker = $counterChecker ?? new ThrowExceptionIfInvalid(); |
| 90 |
$this->logger = $logger ?? new NullLogger(); |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* @see https://www.w3.org/TR/webauthn/#verifying-assertion |
| 95 |
*/ |
| 96 |
public function check(string $credentialId, AuthenticatorAssertionResponse $authenticatorAssertionResponse, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, ServerRequestInterface $request, ?string $userHandle, array $securedRelyingPartyId = []): PublicKeyCredentialSource |
| 97 |
{ |
| 98 |
try { |
| 99 |
$this->logger->info('Checking the authenticator assertion response', [ |
| 100 |
'credentialId' => $credentialId, |
| 101 |
'authenticatorAssertionResponse' => $authenticatorAssertionResponse, |
| 102 |
'publicKeyCredentialRequestOptions' => $publicKeyCredentialRequestOptions, |
| 103 |
'host' => $request->getUri()->getHost(), |
| 104 |
'userHandle' => $userHandle, |
| 105 |
]); |
| 106 |
/** @see 7.2.1 */ |
| 107 |
if (0 !== count($publicKeyCredentialRequestOptions->getAllowCredentials())) { |
| 108 |
Assertion::true($this->isCredentialIdAllowed($credentialId, $publicKeyCredentialRequestOptions->getAllowCredentials()), 'The credential ID is not allowed.'); |
| 109 |
} |
| 110 |
|
| 111 |
/** @see 7.2.2 */ |
| 112 |
$publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($credentialId); |
| 113 |
Assertion::notNull($publicKeyCredentialSource, 'The credential ID is invalid.'); |
| 114 |
|
| 115 |
/** @see 7.2.3 */ |
| 116 |
$attestedCredentialData = $publicKeyCredentialSource->getAttestedCredentialData(); |
| 117 |
$credentialUserHandle = $publicKeyCredentialSource->getUserHandle(); |
| 118 |
$responseUserHandle = $authenticatorAssertionResponse->getUserHandle(); |
| 119 |
|
| 120 |
/** @see 7.2.2 User Handle*/ |
| 121 |
if (null !== $userHandle) { //If the user was identified before the authentication ceremony was initiated, |
| 122 |
Assertion::eq($credentialUserHandle, $userHandle, 'Invalid user handle'); |
| 123 |
if (null !== $responseUserHandle && '' !== $responseUserHandle) { |
| 124 |
Assertion::eq($credentialUserHandle, $responseUserHandle, 'Invalid user handle'); |
| 125 |
} |
| 126 |
} else { |
| 127 |
Assertion::notEmpty($responseUserHandle, 'User handle is mandatory'); |
| 128 |
Assertion::eq($credentialUserHandle, $responseUserHandle, 'Invalid user handle'); |
| 129 |
} |
| 130 |
|
| 131 |
$credentialPublicKey = $attestedCredentialData->getCredentialPublicKey(); |
| 132 |
$isU2F = U2FPublicKey::isU2FKey($credentialPublicKey); |
| 133 |
if ($isU2F) { |
| 134 |
$credentialPublicKey = U2FPublicKey::createCOSEKey($credentialPublicKey); |
| 135 |
} |
| 136 |
Assertion::notNull($credentialPublicKey, 'No public key available.'); |
| 137 |
$stream = new StringStream($credentialPublicKey); |
| 138 |
$credentialPublicKeyStream = $this->decoder->decode($stream); |
| 139 |
Assertion::true($stream->isEOF(), 'Invalid key. Presence of extra bytes.'); |
| 140 |
$stream->close(); |
| 141 |
|
| 142 |
/** @see 7.2.4 */ |
| 143 |
/** @see 7.2.5 */ |
| 144 |
//Nothing to do. Use of objects directly |
| 145 |
|
| 146 |
/** @see 7.2.6 */ |
| 147 |
$C = $authenticatorAssertionResponse->getClientDataJSON(); |
| 148 |
|
| 149 |
/** @see 7.2.7 */ |
| 150 |
Assertion::eq('webauthn.get', $C->getType(), 'The client data type is not "webauthn.get".'); |
| 151 |
|
| 152 |
/** @see 7.2.8 */ |
| 153 |
Assertion::true(hash_equals($publicKeyCredentialRequestOptions->getChallenge(), $C->getChallenge()), 'Invalid challenge.'); |
| 154 |
|
| 155 |
/** @see 7.2.9 */ |
| 156 |
$rpId = $publicKeyCredentialRequestOptions->getRpId() ?? $request->getUri()->getHost(); |
| 157 |
$facetId = $this->getFacetId($rpId, $publicKeyCredentialRequestOptions->getExtensions(), $authenticatorAssertionResponse->getAuthenticatorData()->getExtensions()); |
| 158 |
$parsedRelyingPartyId = parse_url($C->getOrigin()); |
| 159 |
Assertion::isArray($parsedRelyingPartyId, 'Invalid origin'); |
| 160 |
if (!in_array($facetId, $securedRelyingPartyId, true)) { |
| 161 |
$scheme = $parsedRelyingPartyId['scheme'] ?? ''; |
| 162 |
Assertion::eq('https', $scheme, 'Invalid scheme. HTTPS required.'); |
| 163 |
} |
| 164 |
$clientDataRpId = $parsedRelyingPartyId['host'] ?? ''; |
| 165 |
Assertion::notEmpty($clientDataRpId, 'Invalid origin rpId.'); |
| 166 |
$rpIdLength = mb_strlen($facetId); |
| 167 |
Assertion::eq(mb_substr('.'.$clientDataRpId, -($rpIdLength + 1)), '.'.$facetId, 'rpId mismatch.'); |
| 168 |
|
| 169 |
/** @see 7.2.10 */ |
| 170 |
if (null !== $C->getTokenBinding()) { |
| 171 |
$this->tokenBindingHandler->check($C->getTokenBinding(), $request); |
| 172 |
} |
| 173 |
|
| 174 |
$expectedRpIdHash = $isU2F ? $C->getOrigin() : $facetId; |
| 175 |
// u2f response has full origin in rpIdHash |
| 176 |
/** @see 7.2.11 */ |
| 177 |
$rpIdHash = hash('sha256', $expectedRpIdHash, true); |
| 178 |
Assertion::true(hash_equals($rpIdHash, $authenticatorAssertionResponse->getAuthenticatorData()->getRpIdHash()), 'rpId hash mismatch.'); |
| 179 |
|
| 180 |
/** @see 7.2.12 */ |
| 181 |
Assertion::true($authenticatorAssertionResponse->getAuthenticatorData()->isUserPresent(), 'User was not present'); |
| 182 |
/** @see 7.2.13 */ |
| 183 |
if (AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED === $publicKeyCredentialRequestOptions->getUserVerification()) { |
| 184 |
Assertion::true($authenticatorAssertionResponse->getAuthenticatorData()->isUserVerified(), 'User authentication required.'); |
| 185 |
} |
| 186 |
|
| 187 |
/** @see 7.2.14 */ |
| 188 |
$extensionsClientOutputs = $authenticatorAssertionResponse->getAuthenticatorData()->getExtensions(); |
| 189 |
if (null !== $extensionsClientOutputs) { |
| 190 |
$this->extensionOutputCheckerHandler->check( |
| 191 |
$publicKeyCredentialRequestOptions->getExtensions(), |
| 192 |
$extensionsClientOutputs |
| 193 |
); |
| 194 |
} |
| 195 |
|
| 196 |
/** @see 7.2.15 */ |
| 197 |
$getClientDataJSONHash = hash('sha256', $authenticatorAssertionResponse->getClientDataJSON()->getRawData(), true); |
| 198 |
|
| 199 |
/** @see 7.2.16 */ |
| 200 |
$dataToVerify = $authenticatorAssertionResponse->getAuthenticatorData()->getAuthData().$getClientDataJSONHash; |
| 201 |
$signature = $authenticatorAssertionResponse->getSignature(); |
| 202 |
$coseKey = new Key($credentialPublicKeyStream->getNormalizedData()); |
| 203 |
$algorithm = $this->algorithmManager->get($coseKey->alg()); |
| 204 |
Assertion::isInstanceOf($algorithm, Signature::class, 'Invalid algorithm identifier. Should refer to a signature algorithm'); |
| 205 |
$signature = CoseSignatureFixer::fix($signature, $algorithm); |
| 206 |
Assertion::true($algorithm->verify($dataToVerify, $coseKey, $signature), 'Invalid signature.'); |
| 207 |
|
| 208 |
/** @see 7.2.17 */ |
| 209 |
$storedCounter = $publicKeyCredentialSource->getCounter(); |
| 210 |
$responseCounter = $authenticatorAssertionResponse->getAuthenticatorData()->getSignCount(); |
| 211 |
if (0 !== $responseCounter || 0 !== $storedCounter) { |
| 212 |
$this->counterChecker->check($publicKeyCredentialSource, $responseCounter); |
| 213 |
} |
| 214 |
$publicKeyCredentialSource->setCounter($responseCounter); |
| 215 |
$this->publicKeyCredentialSourceRepository->saveCredentialSource($publicKeyCredentialSource); |
| 216 |
|
| 217 |
/** @see 7.2.18 */ |
| 218 |
//All good. We can continue. |
| 219 |
$this->logger->info('The assertion is valid'); |
| 220 |
$this->logger->debug('Public Key Credential Source', ['publicKeyCredentialSource' => $publicKeyCredentialSource]); |
| 221 |
|
| 222 |
return $publicKeyCredentialSource; |
| 223 |
} catch (Throwable $throwable) { |
| 224 |
$this->logger->error('An error occurred', [ |
| 225 |
'exception' => $throwable, |
| 226 |
]); |
| 227 |
throw $throwable; |
| 228 |
} |
| 229 |
} |
| 230 |
|
| 231 |
public function setLogger(LoggerInterface $logger): self |
| 232 |
{ |
| 233 |
$this->logger = $logger; |
| 234 |
|
| 235 |
return $this; |
| 236 |
} |
| 237 |
|
| 238 |
public function setCounterChecker(CounterChecker $counterChecker): self |
| 239 |
{ |
| 240 |
$this->counterChecker = $counterChecker; |
| 241 |
|
| 242 |
return $this; |
| 243 |
} |
| 244 |
|
| 245 |
/** |
| 246 |
* @param array<PublicKeyCredentialDescriptor> $allowedCredentials |
| 247 |
*/ |
| 248 |
private function isCredentialIdAllowed(string $credentialId, array $allowedCredentials): bool |
| 249 |
{ |
| 250 |
foreach ($allowedCredentials as $allowedCredential) { |
| 251 |
if (hash_equals($allowedCredential->getId(), $credentialId)) { |
| 252 |
return true; |
| 253 |
} |
| 254 |
} |
| 255 |
|
| 256 |
return false; |
| 257 |
} |
| 258 |
|
| 259 |
private function getFacetId(string $rpId, AuthenticationExtensionsClientInputs $authenticationExtensionsClientInputs, ?AuthenticationExtensionsClientOutputs $authenticationExtensionsClientOutputs): string |
| 260 |
{ |
| 261 |
if (null === $authenticationExtensionsClientOutputs || !$authenticationExtensionsClientInputs->has('appid') || !$authenticationExtensionsClientOutputs->has('appid')) { |
| 262 |
return $rpId; |
| 263 |
} |
| 264 |
$appId = $authenticationExtensionsClientInputs->get('appid')->value(); |
| 265 |
$wasUsed = $authenticationExtensionsClientOutputs->get('appid')->value(); |
| 266 |
if (!is_string($appId) || true !== $wasUsed) { |
| 267 |
return $rpId; |
| 268 |
} |
| 269 |
|
| 270 |
return $appId; |
| 271 |
} |
| 272 |
} |
| 273 |
|