1 <?php 2 3 namespace lbuchs\WebAuthn; 4 use lbuchs\WebAuthn\Binary\ByteBuffer; 5 require_once 'WebAuthnException.php'; 6 require_once 'Binary/ByteBuffer.php'; 7 require_once 'Attestation/AttestationObject.php'; 8 require_once 'Attestation/AuthenticatorData.php'; 9 require_once 'Attestation/Format/FormatBase.php'; 10 require_once 'Attestation/Format/None.php'; 11 require_once 'Attestation/Format/AndroidKey.php'; 12 require_once 'Attestation/Format/AndroidSafetyNet.php'; 13 require_once 'Attestation/Format/Apple.php'; 14 require_once 'Attestation/Format/Packed.php'; 15 require_once 'Attestation/Format/Tpm.php'; 16 require_once 'Attestation/Format/U2f.php'; 17 require_once 'CBOR/CborDecoder.php'; 18 19 /** 20 * WebAuthn 21 * @author Lukas Buchs 22 * @license https://github.com/lbuchs/WebAuthn/blob/master/LICENSE MIT 23 */ 24 class WebAuthn { 25 // relying party 26 private $_rpName; 27 private $_rpId; 28 private $_rpIdHash; 29 private $_challenge; 30 private $_signatureCounter; 31 private $_caFiles; 32 private $_formats; 33 34 /** 35 * Initialize a new WebAuthn server 36 * @param string $rpName the relying party name 37 * @param string $rpId the relying party ID = the domain name 38 * @param bool $useBase64UrlEncoding true to use base64 url encoding for binary data in json objects. Default is a RFC 1342-Like serialized string. 39 * @throws WebAuthnException 40 */ 41 public function __construct($rpName, $rpId, $allowedFormats=null, $useBase64UrlEncoding=false) { 42 $this->_rpName = $rpName; 43 $this->_rpId = $rpId; 44 $this->_rpIdHash = \hash('sha256', $rpId, true); 45 ByteBuffer::$useBase64UrlEncoding = !!$useBase64UrlEncoding; 46 $supportedFormats = array('android-key', 'android-safetynet', 'apple', 'fido-u2f', 'none', 'packed', 'tpm'); 47 48 if (!\function_exists('\openssl_open')) { 49 throw new WebAuthnException('OpenSSL-Module not installed');; 50 } 51 52 if (!\in_array('SHA256', \array_map('\strtoupper', \openssl_get_md_methods()))) { 53 throw new WebAuthnException('SHA256 not supported by this openssl installation.'); 54 } 55 56 // default: all format 57 if (!is_array($allowedFormats)) { 58 $allowedFormats = $supportedFormats; 59 } 60 $this->_formats = $allowedFormats; 61 62 // validate formats 63 $invalidFormats = \array_diff($this->_formats, $supportedFormats); 64 if (!$this->_formats || $invalidFormats) { 65 throw new WebAuthnException('invalid formats on construct: ' . implode(', ', $invalidFormats)); 66 } 67 } 68 69 /** 70 * add a root certificate to verify new registrations 71 * @param string $path file path of / directory with root certificates 72 * @param array|null $certFileExtensions if adding a direction, all files with provided extension are added. default: pem, crt, cer, der 73 */ 74 public function addRootCertificates($path, $certFileExtensions=null) { 75 if (!\is_array($this->_caFiles)) { 76 $this->_caFiles = array(); 77 } 78 if ($certFileExtensions === null) { 79 $certFileExtensions = array('pem', 'crt', 'cer', 'der'); 80 } 81 $path = \rtrim(\trim($path), '\\/'); 82 if (\is_dir($path)) { 83 foreach (\scandir($path) as $ca) { 84 if (\is_file($path . DIRECTORY_SEPARATOR . $ca) && \in_array(\strtolower(\pathinfo($ca, PATHINFO_EXTENSION)), $certFileExtensions)) { 85 $this->addRootCertificates($path . DIRECTORY_SEPARATOR . $ca); 86 } 87 } 88 } else if (\is_file($path) && !\in_array(\realpath($path), $this->_caFiles)) { 89 $this->_caFiles[] = \realpath($path); 90 } 91 } 92 93 /** 94 * Returns the generated challenge to save for later validation 95 * @return ByteBuffer 96 */ 97 public function getChallenge() { 98 return $this->_challenge; 99 } 100 101 /** 102 * generates the object for a key registration 103 * provide this data to navigator.credentials.create 104 * @param string $userId 105 * @param string $userName 106 * @param string $userDisplayName 107 * @param int $timeout timeout in seconds 108 * @param bool|string $requireResidentKey 'required', if the key should be stored by the authentication device 109 * Valid values: 110 * true = required 111 * false = preferred 112 * string 'required' 'preferred' 'discouraged' 113 * @param bool|string $requireUserVerification indicates that you require user verification and will fail the operation 114 * if the response does not have the UV flag set. 115 * Valid values: 116 * true = required 117 * false = preferred 118 * string 'required' 'preferred' 'discouraged' 119 * @param bool|null $crossPlatformAttachment true for cross-platform devices (eg. fido usb), 120 * false for platform devices (eg. windows hello, android safetynet), 121 * null for both 122 * @param array $excludeCredentialIds a array of ids, which are already registered, to prevent re-registration 123 * @return \stdClass 124 */ 125 public function getCreateArgs($userId, $userName, $userDisplayName, $timeout=20, $requireResidentKey=false, $requireUserVerification=false, $crossPlatformAttachment=null, $excludeCredentialIds=array()) { 126 127 $args = new \stdClass(); 128 $args->publicKey = new \stdClass(); 129 130 // relying party 131 $args->publicKey->rp = new \stdClass(); 132 $args->publicKey->rp->name = $this->_rpName; 133 $args->publicKey->rp->id = $this->_rpId; 134 135 $args->publicKey->authenticatorSelection = new \stdClass(); 136 $args->publicKey->authenticatorSelection->userVerification = 'preferred'; 137 138 // validate User Verification Requirement 139 if (\is_bool($requireUserVerification)) { 140 $args->publicKey->authenticatorSelection->userVerification = $requireUserVerification ? 'required' : 'preferred'; 141 142 } else if (\is_string($requireUserVerification) && \in_array(\strtolower($requireUserVerification), ['required', 'preferred', 'discouraged'])) { 143 $args->publicKey->authenticatorSelection->userVerification = \strtolower($requireUserVerification); 144 } 145 146 // validate Resident Key Requirement 147 if (\is_bool($requireResidentKey) && $requireResidentKey) { 148 $args->publicKey->authenticatorSelection->requireResidentKey = true; 149 $args->publicKey->authenticatorSelection->residentKey = 'required'; 150 151 } else if (\is_string($requireResidentKey) && \in_array(\strtolower($requireResidentKey), ['required', 'preferred', 'discouraged'])) { 152 $requireResidentKey = \strtolower($requireResidentKey); 153 $args->publicKey->authenticatorSelection->residentKey = $requireResidentKey; 154 $args->publicKey->authenticatorSelection->requireResidentKey = $requireResidentKey === 'required'; 155 } 156 157 // filte authenticators attached with the specified authenticator attachment modality 158 if (\is_bool($crossPlatformAttachment)) { 159 $args->publicKey->authenticatorSelection->authenticatorAttachment = $crossPlatformAttachment ? 'cross-platform' : 'platform'; 160 } 161 162 // user 163 $args->publicKey->user = new \stdClass(); 164 $args->publicKey->user->id = new ByteBuffer($userId); // binary 165 $args->publicKey->user->name = $userName; 166 $args->publicKey->user->displayName = $userDisplayName; 167 168 // supported algorithms 169 $args->publicKey->pubKeyCredParams = array(); 170 $tmp = new \stdClass(); 171 $tmp->type = 'public-key'; 172 $tmp->alg = -7; // ES256 173 $args->publicKey->pubKeyCredParams[] = $tmp; 174 unset ($tmp); 175 176 $tmp = new \stdClass(); 177 $tmp->type = 'public-key'; 178 $tmp->alg = -257; // RS256 179 $args->publicKey->pubKeyCredParams[] = $tmp; 180 unset ($tmp); 181 182 // if there are root certificates added, we need direct attestation to validate 183 // against the root certificate. If there are no root-certificates added, 184 // anonymization ca are also accepted, because we can't validate the root anyway. 185 $attestation = 'indirect'; 186 if (\is_array($this->_caFiles)) { 187 $attestation = 'direct'; 188 } 189 190 $args->publicKey->attestation = \count($this->_formats) === 1 && \in_array('none', $this->_formats) ? 'none' : $attestation; 191 $args->publicKey->extensions = new \stdClass(); 192 $args->publicKey->extensions->exts = true; 193 $args->publicKey->timeout = $timeout * 1000; // microseconds 194 $args->publicKey->challenge = $this->_createChallenge(); // binary 195 196 //prevent re-registration by specifying existing credentials 197 $args->publicKey->excludeCredentials = array(); 198 199 if (is_array($excludeCredentialIds)) { 200 foreach ($excludeCredentialIds as $id) { 201 $tmp = new \stdClass(); 202 $tmp->id = $id instanceof ByteBuffer ? $id : new ByteBuffer($id); // binary 203 $tmp->type = 'public-key'; 204 $tmp->transports = array('usb', 'nfc', 'ble', 'hybrid', 'internal'); 205 $args->publicKey->excludeCredentials[] = $tmp; 206 unset ($tmp); 207 } 208 } 209 210 return $args; 211 } 212 213 /** 214 * generates the object for key validation 215 * Provide this data to navigator.credentials.get 216 * @param array $credentialIds binary 217 * @param int $timeout timeout in seconds 218 * @param bool $allowUsb allow removable USB 219 * @param bool $allowNfc allow Near Field Communication (NFC) 220 * @param bool $allowBle allow Bluetooth 221 * @param bool $allowHybrid allow a combination of (often separate) data-transport and proximity mechanisms. 222 * @param bool $allowInternal allow client device-specific transport. These authenticators are not removable from the client device. 223 * @param bool|string $requireUserVerification indicates that you require user verification and will fail the operation 224 * if the response does not have the UV flag set. 225 * Valid values: 226 * true = required 227 * false = preferred 228 * string 'required' 'preferred' 'discouraged' 229 * @return \stdClass 230 */ 231 public function getGetArgs($credentialIds=array(), $timeout=20, $allowUsb=true, $allowNfc=true, $allowBle=true, $allowHybrid=true, $allowInternal=true, $requireUserVerification=false) { 232 233 // validate User Verification Requirement 234 if (\is_bool($requireUserVerification)) { 235 $requireUserVerification = $requireUserVerification ? 'required' : 'preferred'; 236 } else if (\is_string($requireUserVerification) && \in_array(\strtolower($requireUserVerification), ['required', 'preferred', 'discouraged'])) { 237 $requireUserVerification = \strtolower($requireUserVerification); 238 } else { 239 $requireUserVerification = 'preferred'; 240 } 241 242 $args = new \stdClass(); 243 $args->publicKey = new \stdClass(); 244 $args->publicKey->timeout = $timeout * 1000; // microseconds 245 $args->publicKey->challenge = $this->_createChallenge(); // binary 246 $args->publicKey->userVerification = $requireUserVerification; 247 $args->publicKey->rpId = $this->_rpId; 248 249 if (\is_array($credentialIds) && \count($credentialIds) > 0) { 250 $args->publicKey->allowCredentials = array(); 251 252 foreach ($credentialIds as $id) { 253 $tmp = new \stdClass(); 254 $tmp->id = $id instanceof ByteBuffer ? $id : new ByteBuffer($id); // binary 255 $tmp->transports = array(); 256 257 if ($allowUsb) { 258 $tmp->transports[] = 'usb'; 259 } 260 if ($allowNfc) { 261 $tmp->transports[] = 'nfc'; 262 } 263 if ($allowBle) { 264 $tmp->transports[] = 'ble'; 265 } 266 if ($allowHybrid) { 267 $tmp->transports[] = 'hybrid'; 268 } 269 if ($allowInternal) { 270 $tmp->transports[] = 'internal'; 271 } 272 273 $tmp->type = 'public-key'; 274 $args->publicKey->allowCredentials[] = $tmp; 275 unset ($tmp); 276 } 277 } 278 279 return $args; 280 } 281 282 /** 283 * returns the new signature counter value. 284 * returns null if there is no counter 285 * @return ?int 286 */ 287 public function getSignatureCounter() { 288 return \is_int($this->_signatureCounter) ? $this->_signatureCounter : null; 289 } 290 291 /** 292 * process a create request and returns data to save for future logins 293 * @param string $clientDataJSON binary from browser 294 * @param string $attestationObject binary from browser 295 * @param string|ByteBuffer $challenge binary used challange 296 * @param bool $requireUserVerification true, if the device must verify user (e.g. by biometric data or pin) 297 * @param bool $requireUserPresent false, if the device must NOT check user presence (e.g. by pressing a button) 298 * @param bool $failIfRootMismatch false, if there should be no error thrown if root certificate doesn't match 299 * @param bool $requireCtsProfileMatch false, if you don't want to check if the device is approved as a Google-certified Android device. 300 * @return \stdClass 301 * @throws WebAuthnException 302 */ 303 public function processCreate($clientDataJSON, $attestationObject, $challenge, $requireUserVerification=false, $requireUserPresent=true, $failIfRootMismatch=true, $requireCtsProfileMatch=true) { 304 $clientDataHash = \hash('sha256', $clientDataJSON, true); 305 $clientData = \json_decode($clientDataJSON); 306 $challenge = $challenge instanceof ByteBuffer ? $challenge : new ByteBuffer($challenge); 307 308 // security: https://www.w3.org/TR/webauthn/#registering-a-new-credential 309 310 // 2. Let C, the client data claimed as collected during the credential creation, 311 // be the result of running an implementation-specific JSON parser on JSONtext. 312 if (!\is_object($clientData)) { 313 throw new WebAuthnException('invalid client data', WebAuthnException::INVALID_DATA); 314 } 315 316 // 3. Verify that the value of C.type is webauthn.create. 317 if (!\property_exists($clientData, 'type') || $clientData->type !== 'webauthn.create') { 318 throw new WebAuthnException('invalid type', WebAuthnException::INVALID_TYPE); 319 } 320 321 // 4. Verify that the value of C.challenge matches the challenge that was sent to the authenticator in the create() call. 322 if (!\property_exists($clientData, 'challenge') || ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString() !== $challenge->getBinaryString()) { 323 throw new WebAuthnException('invalid challenge', WebAuthnException::INVALID_CHALLENGE); 324 } 325 326 // 5. Verify that the value of C.origin matches the Relying Party's origin. 327 if (!\property_exists($clientData, 'origin') || !$this->_checkOrigin($clientData->origin)) { 328 throw new WebAuthnException('invalid origin', WebAuthnException::INVALID_ORIGIN); 329 } 330 331 // Attestation 332 $attestationObject = new Attestation\AttestationObject($attestationObject, $this->_formats); 333 334 // 9. Verify that the RP ID hash in authData is indeed the SHA-256 hash of the RP ID expected by the RP. 335 if (!$attestationObject->validateRpIdHash($this->_rpIdHash)) { 336 throw new WebAuthnException('invalid rpId hash', WebAuthnException::INVALID_RELYING_PARTY); 337 } 338 339 // 14. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature 340 if (!$attestationObject->validateAttestation($clientDataHash)) { 341 throw new WebAuthnException('invalid certificate signature', WebAuthnException::INVALID_SIGNATURE); 342 } 343 344 // Android-SafetyNet: if required, check for Compatibility Testing Suite (CTS). 345 if ($requireCtsProfileMatch && $attestationObject->getAttestationFormat() instanceof Attestation\Format\AndroidSafetyNet) { 346 if (!$attestationObject->getAttestationFormat()->ctsProfileMatch()) { 347 throw new WebAuthnException('invalid ctsProfileMatch: device is not approved as a Google-certified Android device.', WebAuthnException::ANDROID_NOT_TRUSTED); 348 } 349 } 350 351 // 15. If validation is successful, obtain a list of acceptable trust anchors 352 $rootValid = is_array($this->_caFiles) ? $attestationObject->validateRootCertificate($this->_caFiles) : null; 353 if ($failIfRootMismatch && is_array($this->_caFiles) && !$rootValid) { 354 throw new WebAuthnException('invalid root certificate', WebAuthnException::CERTIFICATE_NOT_TRUSTED); 355 } 356 357 // 10. Verify that the User Present bit of the flags in authData is set. 358 $userPresent = $attestationObject->getAuthenticatorData()->getUserPresent(); 359 if ($requireUserPresent && !$userPresent) { 360 throw new WebAuthnException('user not present during authentication', WebAuthnException::USER_PRESENT); 361 } 362 363 // 11. If user verification is required for this registration, verify that the User Verified bit of the flags in authData is set. 364 $userVerified = $attestationObject->getAuthenticatorData()->getUserVerified(); 365 if ($requireUserVerification && !$userVerified) { 366 throw new WebAuthnException('user not verified during authentication', WebAuthnException::USER_VERIFICATED); 367 } 368 369 $signCount = $attestationObject->getAuthenticatorData()->getSignCount(); 370 if ($signCount > 0) { 371 $this->_signatureCounter = $signCount; 372 } 373 374 // prepare data to store for future logins 375 $data = new \stdClass(); 376 $data->rpId = $this->_rpId; 377 $data->attestationFormat = $attestationObject->getAttestationFormatName(); 378 $data->credentialId = $attestationObject->getAuthenticatorData()->getCredentialId(); 379 $data->credentialPublicKey = $attestationObject->getAuthenticatorData()->getPublicKeyPem(); 380 $data->certificateChain = $attestationObject->getCertificateChain(); 381 $data->certificate = $attestationObject->getCertificatePem(); 382 $data->certificateIssuer = $attestationObject->getCertificateIssuer(); 383 $data->certificateSubject = $attestationObject->getCertificateSubject(); 384 $data->signatureCounter = $this->_signatureCounter; 385 $data->AAGUID = $attestationObject->getAuthenticatorData()->getAAGUID(); 386 $data->rootValid = $rootValid; 387 $data->userPresent = $userPresent; 388 $data->userVerified = $userVerified; 389 return $data; 390 } 391 392 393 /** 394 * process a get request 395 * @param string $clientDataJSON binary from browser 396 * @param string $authenticatorData binary from browser 397 * @param string $signature binary from browser 398 * @param string $credentialPublicKey string PEM-formated public key from used credentialId 399 * @param string|ByteBuffer $challenge binary from used challange 400 * @param int $prevSignatureCnt signature count value of the last login 401 * @param bool $requireUserVerification true, if the device must verify user (e.g. by biometric data or pin) 402 * @param bool $requireUserPresent true, if the device must check user presence (e.g. by pressing a button) 403 * @return boolean true if get is successful 404 * @throws WebAuthnException 405 */ 406 public function processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, $prevSignatureCnt=null, $requireUserVerification=false, $requireUserPresent=true) { 407 $authenticatorObj = new Attestation\AuthenticatorData($authenticatorData); 408 $clientDataHash = \hash('sha256', $clientDataJSON, true); 409 $clientData = \json_decode($clientDataJSON); 410 $challenge = $challenge instanceof ByteBuffer ? $challenge : new ByteBuffer($challenge); 411 412 // https://www.w3.org/TR/webauthn/#verifying-assertion 413 414 // 1. If the allowCredentials option was given when this authentication ceremony was initiated, 415 // verify that credential.id identifies one of the public key credentials that were listed in allowCredentials. 416 // -> TO BE VERIFIED BY IMPLEMENTATION 417 418 // 2. If credential.response.userHandle is present, verify that the user identified 419 // by this value is the owner of the public key credential identified by credential.id. 420 // -> TO BE VERIFIED BY IMPLEMENTATION 421 422 // 3. Using credential’s id attribute (or the corresponding rawId, if base64url encoding is 423 // inappropriate for your use case), look up the corresponding credential public key. 424 // -> TO BE LOOKED UP BY IMPLEMENTATION 425 426 // 5. Let JSONtext be the result of running UTF-8 decode on the value of cData. 427 if (!\is_object($clientData)) { 428 throw new WebAuthnException('invalid client data', WebAuthnException::INVALID_DATA); 429 } 430 431 // 7. Verify that the value of C.type is the string webauthn.get. 432 if (!\property_exists($clientData, 'type') || $clientData->type !== 'webauthn.get') { 433 throw new WebAuthnException('invalid type', WebAuthnException::INVALID_TYPE); 434 } 435 436 // 8. Verify that the value of C.challenge matches the challenge that was sent to the 437 // authenticator in the PublicKeyCredentialRequestOptions passed to the get() call. 438 if (!\property_exists($clientData, 'challenge') || ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString() !== $challenge->getBinaryString()) { 439 throw new WebAuthnException('invalid challenge', WebAuthnException::INVALID_CHALLENGE); 440 } 441 442 // 9. Verify that the value of C.origin matches the Relying Party's origin. 443 if (!\property_exists($clientData, 'origin') || !$this->_checkOrigin($clientData->origin)) { 444 throw new WebAuthnException('invalid origin', WebAuthnException::INVALID_ORIGIN); 445 } 446 447 // 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party. 448 if ($authenticatorObj->getRpIdHash() !== $this->_rpIdHash) { 449 throw new WebAuthnException('invalid rpId hash', WebAuthnException::INVALID_RELYING_PARTY); 450 } 451 452 // 12. Verify that the User Present bit of the flags in authData is set 453 if ($requireUserPresent && !$authenticatorObj->getUserPresent()) { 454 throw new WebAuthnException('user not present during authentication', WebAuthnException::USER_PRESENT); 455 } 456 457 // 13. If user verification is required for this assertion, verify that the User Verified bit of the flags in authData is set. 458 if ($requireUserVerification && !$authenticatorObj->getUserVerified()) { 459 throw new WebAuthnException('user not verificated during authentication', WebAuthnException::USER_VERIFICATED); 460 } 461 462 // 14. Verify the values of the client extension outputs 463 // (extensions not implemented) 464 465 // 16. Using the credential public key looked up in step 3, verify that sig is a valid signature 466 // over the binary concatenation of authData and hash. 467 $dataToVerify = ''; 468 $dataToVerify .= $authenticatorData; 469 $dataToVerify .= $clientDataHash; 470 471 $publicKey = \openssl_pkey_get_public($credentialPublicKey); 472 if ($publicKey === false) { 473 throw new WebAuthnException('public key invalid', WebAuthnException::INVALID_PUBLIC_KEY); 474 } 475 476 if (\openssl_verify($dataToVerify, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { 477 throw new WebAuthnException('invalid signature', WebAuthnException::INVALID_SIGNATURE); 478 } 479 480 $signatureCounter = $authenticatorObj->getSignCount(); 481 if ($signatureCounter !== 0) { 482 $this->_signatureCounter = $signatureCounter; 483 } 484 485 // 17. If either of the signature counter value authData.signCount or 486 // previous signature count is nonzero, and if authData.signCount 487 // less than or equal to previous signature count, it's a signal 488 // that the authenticator may be cloned 489 if ($prevSignatureCnt !== null) { 490 if ($signatureCounter !== 0 || $prevSignatureCnt !== 0) { 491 if ($prevSignatureCnt >= $signatureCounter) { 492 throw new WebAuthnException('signature counter not valid', WebAuthnException::SIGNATURE_COUNTER); 493 } 494 } 495 } 496 497 return true; 498 } 499 500 /** 501 * Downloads root certificates from FIDO Alliance Metadata Service (MDS) to a specific folder 502 * https://fidoalliance.org/metadata/ 503 * @param string $certFolder Folder path to save the certificates in PEM format. 504 * @param bool $deleteCerts delete certificates in the target folder before adding the new ones. 505 * @return int number of cetificates 506 * @throws WebAuthnException 507 */ 508 public function queryFidoMetaDataService($certFolder, $deleteCerts=true) { 509 $url = 'https://mds.fidoalliance.org/'; 510 $raw = null; 511 if (\function_exists('curl_init')) { 512 $ch = \curl_init($url); 513 \curl_setopt($ch, CURLOPT_HEADER, false); 514 \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 515 \curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 516 \curl_setopt($ch, CURLOPT_USERAGENT, 'github.com/lbuchs/WebAuthn - A simple PHP WebAuthn server library'); 517 $raw = \curl_exec($ch); 518 \curl_close($ch); 519 } else { 520 $raw = \file_get_contents($url); 521 } 522 523 $certFolder = \rtrim(\realpath($certFolder), '\\/'); 524 if (!is_dir($certFolder)) { 525 throw new WebAuthnException('Invalid folder path for query FIDO Alliance Metadata Service'); 526 } 527 528 if (!\is_string($raw)) { 529 throw new WebAuthnException('Unable to query FIDO Alliance Metadata Service'); 530 } 531 532 $jwt = \explode('.', $raw); 533 if (\count($jwt) !== 3) { 534 throw new WebAuthnException('Invalid JWT from FIDO Alliance Metadata Service'); 535 } 536 537 if ($deleteCerts) { 538 foreach (\scandir($certFolder) as $ca) { 539 if (\substr($ca, -4) === '.pem') { 540 if (\unlink($certFolder . DIRECTORY_SEPARATOR . $ca) === false) { 541 throw new WebAuthnException('Cannot delete certs in folder for FIDO Alliance Metadata Service'); 542 } 543 } 544 } 545 } 546 547 list($header, $payload, $hash) = $jwt; 548 $payload = Binary\ByteBuffer::fromBase64Url($payload)->getJson(); 549 550 $count = 0; 551 if (\is_object($payload) && \property_exists($payload, 'entries') && \is_array($payload->entries)) { 552 foreach ($payload->entries as $entry) { 553 if (\is_object($entry) && \property_exists($entry, 'metadataStatement') && \is_object($entry->metadataStatement)) { 554 $description = $entry->metadataStatement->description ?? null; 555 $attestationRootCertificates = $entry->metadataStatement->attestationRootCertificates ?? null; 556 557 if ($description && $attestationRootCertificates) { 558 559 // create filename 560 $certFilename = \preg_replace('/[^a-z0-9]/i', '_', $description); 561 $certFilename = \trim(\preg_replace('/\_{2,}/i', '_', $certFilename),'_') . '.pem'; 562 $certFilename = \strtolower($certFilename); 563 564 // add certificate 565 $certContent = $description . "\n"; 566 $certContent .= \str_repeat('-', \mb_strlen($description)) . "\n"; 567 568 foreach ($attestationRootCertificates as $attestationRootCertificate) { 569 $attestationRootCertificate = \str_replace(["\n", "\r", ' '], '', \trim($attestationRootCertificate)); 570 $count++; 571 $certContent .= "\n-----BEGIN CERTIFICATE-----\n"; 572 $certContent .= \chunk_split($attestationRootCertificate, 64, "\n"); 573 $certContent .= "-----END CERTIFICATE-----\n"; 574 } 575 576 if (\file_put_contents($certFolder . DIRECTORY_SEPARATOR . $certFilename, $certContent) === false) { 577 throw new WebAuthnException('unable to save certificate from FIDO Alliance Metadata Service'); 578 } 579 } 580 } 581 } 582 } 583 584 return $count; 585 } 586 587 // ----------------------------------------------- 588 // PRIVATE 589 // ----------------------------------------------- 590 591 /** 592 * checks if the origin matchs the RP ID 593 * @param string $origin 594 * @return boolean 595 * @throws WebAuthnException 596 */ 597 private function _checkOrigin($origin) { 598 // https://www.w3.org/TR/webauthn/#rp-id 599 600 // The origin's scheme must be https 601 if ($this->_rpId !== 'localhost' && \parse_url($origin, PHP_URL_SCHEME) !== 'https') { 602 return false; 603 } 604 605 // extract host from origin 606 $host = \parse_url($origin, PHP_URL_HOST); 607 $host = \trim($host, '.'); 608 609 // The RP ID must be equal to the origin's effective domain, or a registrable 610 // domain suffix of the origin's effective domain. 611 return \preg_match('/' . \preg_quote($this->_rpId) . '$/i', $host) === 1; 612 } 613 614 /** 615 * generates a new challange 616 * @param int $length 617 * @return string 618 * @throws WebAuthnException 619 */ 620 private function _createChallenge($length = 32) { 621 if (!$this->_challenge) { 622 $this->_challenge = ByteBuffer::randomBuffer($length); 623 } 624 return $this->_challenge; 625 } 626 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body