├── .editorconfig ├── .gitignore ├── .gitattributes ├── phpstan.neon.dist ├── src ├── Exception │ ├── CborException.php │ ├── WebAuthnException.php │ ├── ParseException.php │ ├── RemoteException.php │ ├── ExtensionException.php │ ├── ByteBufferException.php │ ├── NotAvailableException.php │ ├── UnsupportedException.php │ ├── VerificationException.php │ ├── ConfigurationException.php │ ├── CredentialIdExistsException.php │ ├── DataValidationException.php │ ├── NoCredentialsException.php │ ├── UnexpectedValueException.php │ ├── FormatNotSupportedException.php │ └── UntrustedException.php ├── Metadata │ ├── Source │ │ ├── MetadataSourceInterface.php │ │ ├── StatementDirectorySource.php │ │ ├── MetadataServiceSource.php │ │ └── BundledSource.php │ ├── MetadataResolverInterface.php │ ├── NullMetadataResolver.php │ ├── Provider │ │ ├── MetadataProviderInterface.php │ │ ├── Apple │ │ │ ├── AppleDeviceMetadata.php │ │ │ └── AppleDevicesProvider.php │ │ └── FileProvider.php │ ├── Preset │ │ └── FidoMetadataService.php │ ├── Statement │ │ ├── TocItem.php │ │ ├── AttestationConstant.php │ │ └── AuthenticatorStatus.php │ └── MetadataResolver.php ├── Attestation │ ├── TrustPath │ │ ├── TrustPathInterface.php │ │ ├── EmptyTrustPath.php │ │ ├── EcdaaKeyTrustPath.php │ │ └── CertificateTrustPath.php │ ├── TrustAnchor │ │ ├── TrustAnchorInterface.php │ │ ├── TrustPathValidatorInterface.php │ │ ├── MetadataInterface.php │ │ ├── CertificateTrustAnchor.php │ │ └── TrustPathValidator.php │ ├── Statement │ │ ├── AttestationStatementInterface.php │ │ ├── UnsupportedAttestationStatement.php │ │ ├── NoneAttestationStatement.php │ │ ├── AbstractAttestationStatement.php │ │ ├── AppleAttestationStatement.php │ │ ├── FidoU2fAttestationStatement.php │ │ ├── AndroidSafetyNetAttestationStatement.php │ │ ├── AndroidKeyAttestationStatement.php │ │ └── PackedAttestationStatement.php │ ├── Tpm │ │ ├── KeyPublicIdInterface.php │ │ ├── KeyParametersInterface.php │ │ ├── TpmStructureTrait.php │ │ ├── TpmRsaPublicId.php │ │ ├── TpmEccPublicId.php │ │ └── TpmAttest.php │ ├── Identifier │ │ ├── IdentifierInterface.php │ │ ├── Aaid.php │ │ ├── AttestationKeyIdentifier.php │ │ └── Aaguid.php │ ├── Verifier │ │ ├── AttestationVerifierInterface.php │ │ ├── UnsupportedAttestationVerifier.php │ │ ├── VerificationResult.php │ │ └── NoneAttestationVerifier.php │ ├── Android │ │ ├── SafetyNetResponseInterface.php │ │ ├── AndroidAttestationExtension.php │ │ ├── SafetyNetResponse.php │ │ ├── AuthorizationList.php │ │ └── SafetyNetResponseParser.php │ ├── Registry │ │ ├── AttestationFormatRegistryInterface.php │ │ ├── AttestationFormatInterface.php │ │ ├── BuiltInAttestationFormat.php │ │ └── AttestationFormatRegistry.php │ ├── AuthenticatorIdentifier.php │ ├── Fido │ │ └── FidoAaguidExtension.php │ ├── AttestationType.php │ └── AttestationObject.php ├── Dom │ ├── DictionaryInterface.php │ ├── AuthenticatorAttestationResponseInterface.php │ ├── AuthenticatorAssertionResponseInterface.php │ ├── CredentialInterface.php │ ├── PublicKeyCredentialType.php │ ├── AbstractDictionary.php │ ├── AuthenticatorResponseInterface.php │ ├── ResidentKeyRequirement.php │ ├── CredentialRequestOptions.php │ ├── TokenBindingStatus.php │ ├── AuthenticatorAttestationResponse.php │ ├── CredentialCreationOptions.php │ ├── PublicKeyCredentialInterface.php │ ├── AuthenticatorTransport.php │ ├── AttestationConveyancePreference.php │ ├── PublicKeyCredentialParameters.php │ ├── AuthenticatorAttachment.php │ ├── UserVerificationRequirement.php │ ├── AuthenticationExtensionsClientInputs.php │ ├── PublicKeyCredentialEntity.php │ ├── AuthenticatorAssertionResponse.php │ ├── PublicKeyCredentialRpEntity.php │ ├── PublicKeyCredential.php │ ├── TokenBinding.php │ ├── AbstractAuthenticatorResponse.php │ ├── PublicKeyCredentialDescriptor.php │ ├── PublicKeyCredentialUserEntity.php │ └── AuthenticatorSelectionCriteria.php ├── Extension │ ├── ExtensionOutputInterface.php │ ├── RegistrationExtensionInputInterface.php │ ├── AuthenticationExtensionInputInterface.php │ ├── ExtensionRegistryInterface.php │ ├── ExtensionInputInterface.php │ ├── ExtensionInterface.php │ ├── AppId │ │ ├── AppIdExtensionOutput.php │ │ ├── AppIdExtensionInput.php │ │ └── AppIdExtension.php │ ├── Generic │ │ ├── GenericExtensionOutput.php │ │ ├── GenericExtension.php │ │ └── GenericExtensionInput.php │ ├── AbstractExtension.php │ ├── AbstractExtensionOutput.php │ ├── ExtensionHelper.php │ ├── ExtensionRegistry.php │ ├── ExtensionProcessingContext.php │ ├── ExtensionResponseInterface.php │ ├── AbstractExtensionInput.php │ └── ExtensionResponse.php ├── Pki │ ├── Jwt │ │ ├── JwtValidatorInterface.php │ │ ├── JwtInterface.php │ │ ├── X5cParameter.php │ │ ├── ValidationContext.php │ │ ├── Jwt.php │ │ └── JwtValidator.php │ ├── ChainValidatorInterface.php │ ├── NullCertificateStatusResolver.php │ ├── CertificateStatusResolverInterface.php │ ├── CertificateExtension.php │ ├── CertificateDetailsInterface.php │ ├── Crl.php │ ├── X509Certificate.php │ └── ChainValidator.php ├── Cache │ ├── CacheProviderInterface.php │ └── FileCacheProvider.php ├── Builder │ ├── PolicyCallbackInterface.php │ └── ServiceContainer.php ├── Remote │ ├── DownloaderInterface.php │ ├── CachingClientFactory.php │ ├── FileContents.php │ └── Downloader.php ├── Server │ ├── UserIdentityInterface.php │ ├── Authentication │ │ ├── AuthenticationResultInterface.php │ │ ├── AuthenticationRequest.php │ │ ├── AuthenticationResult.php │ │ └── AuthenticationContext.php │ ├── UserIdentity.php │ ├── Registration │ │ ├── RegistrationRequest.php │ │ ├── RegistrationContext.php │ │ └── RegistrationResultInterface.php │ └── ServerInterface.php ├── Credential │ ├── UserCredentialInterface.php │ ├── CredentialId.php │ ├── UserCredential.php │ ├── CredentialStoreInterface.php │ └── UserHandle.php ├── Policy │ ├── PolicyInterface.php │ └── Trust │ │ ├── Voter │ │ ├── TrustVoterInterface.php │ │ ├── AllowEmptyMetadataVoter.php │ │ ├── UndesiredStatusReportVoter.php │ │ ├── SupportedAttestationTypeVoter.php │ │ ├── TrustChainVoter.php │ │ └── TrustAttestationTypeVoter.php │ │ ├── TrustDecisionManagerInterface.php │ │ ├── TrustVote.php │ │ └── TrustDecisionManager.php ├── Config │ └── RelyingPartyInterface.php ├── Format │ ├── Cbor.php │ ├── Base64UrlEncoding.php │ ├── SerializableTrait.php │ └── BinaryHandle.php ├── Util │ └── BundledData.php └── Crypto │ ├── CoseKeyInterface.php │ ├── CoseHash.php │ ├── CoseAlgorithm.php │ ├── OpenSslVerifier.php │ └── Der.php ├── credits.md ├── .scrutinizer.yml ├── data └── apple │ └── apple-webauthn-root.crt ├── .github └── workflows │ ├── check.yml │ └── test.yml ├── LICENSE ├── composer.json └── README.md /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset=utf-8 3 | indent_style=space 4 | indent_size=4 5 | trim_trailing_whitespace=true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea 3 | .php_cs.cache 4 | /composer.lock 5 | /report 6 | /var 7 | /_* 8 | /*.cache 9 | /tools/*/vendor -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /conformance export-ignore 2 | /phpunit.xml.dist export-ignore 3 | /tests export-ignore 4 | /tools export-ignore 5 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - src 5 | - conformance 6 | checkMissingIterableValueType: false -------------------------------------------------------------------------------- /src/Exception/CborException.php: -------------------------------------------------------------------------------- 1 | getFormat()); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Attestation/Verifier/AttestationVerifierInterface.php: -------------------------------------------------------------------------------- 1 | metadataDir = $metadataDir; 15 | } 16 | 17 | public function getMetadataDir(): string 18 | { 19 | return $this->metadataDir; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Pki/CertificateStatusResolverInterface.php: -------------------------------------------------------------------------------- 1 | ecdaaKeyId = $ecdaaKeyId; 17 | } 18 | 19 | public function getEcdaaKeyId(): ByteBuffer 20 | { 21 | return $this->ecdaaKeyId; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Policy/Trust/Voter/TrustVoterInterface.php: -------------------------------------------------------------------------------- 1 | reason; 15 | } 16 | 17 | public static function createWithReason(?string $reason): self 18 | { 19 | $e = new self($reason === null ? 'Not trusted' : sprintf('Not trusted: %s', $reason)); 20 | $e->reason = $reason; 21 | return $e; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Extension/AppId/AppIdExtensionOutput.php: -------------------------------------------------------------------------------- 1 | appIdUsed = $appIdUsed; 18 | } 19 | 20 | public function getAppIdUsed(): bool 21 | { 22 | return $this->appIdUsed; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Format/Cbor.php: -------------------------------------------------------------------------------- 1 | input = $appId; 14 | } 15 | 16 | public function getAppId(): string 17 | { 18 | return $this->input; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Dom/AuthenticatorResponseInterface.php: -------------------------------------------------------------------------------- 1 | cacheDir = $cacheDir; 18 | } 19 | 20 | public function getCachePool(string $scope): CacheItemPoolInterface 21 | { 22 | return new FilesystemAdapter($scope, 0, $this->cacheDir); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Util/BundledData.php: -------------------------------------------------------------------------------- 1 | cert = $cert; 19 | } 20 | 21 | public function getCertificate(): X509Certificate 22 | { 23 | return $this->cert; 24 | } 25 | 26 | public function getType(): string 27 | { 28 | return self::TYPE; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Policy/Trust/TrustDecisionManagerInterface.php: -------------------------------------------------------------------------------- 1 | publicKey = $options; 19 | } 20 | 21 | public function getAsArray(): array 22 | { 23 | $map = []; 24 | if ($this->publicKey !== null) { 25 | $map['publicKey'] = $this->publicKey; 26 | } 27 | return $map; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Attestation/Registry/AttestationFormatRegistryInterface.php: -------------------------------------------------------------------------------- 1 | getIdentifier()); 18 | $this->response = $response; 19 | } 20 | 21 | public function getResponse(): ExtensionResponseInterface 22 | { 23 | return $this->response; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Dom/TokenBindingStatus.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 17 | 18 | if (!ExtensionHelper::validExtensionIdentifier($identifier)) { 19 | throw new WebAuthnException(sprintf("Invalid extension identifier '%s'.", $identifier)); 20 | } 21 | } 22 | 23 | public function getIdentifier(): string 24 | { 25 | return $this->identifier; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Attestation/Statement/NoneAttestationStatement.php: -------------------------------------------------------------------------------- 1 | getStatement(); 17 | if ($statement->count() !== 0) { 18 | throw new ParseException("Expecting empty map for 'none' attestation statement."); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Attestation/Tpm/TpmStructureTrait.php: -------------------------------------------------------------------------------- 1 | getUint16Val($offset); 12 | $data = $buffer->getBytes($offset + 2, $len); 13 | $offset += (2 + $len); 14 | return new ByteBuffer($data); 15 | } 16 | 17 | private static function readFixed(ByteBuffer $buffer, int &$offset, int $length): ByteBuffer 18 | { 19 | $data = $buffer->getBytes($offset, $length); 20 | $offset += $length; 21 | return new ByteBuffer($data); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Extension/AbstractExtensionOutput.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 18 | 19 | if (!ExtensionHelper::validExtensionIdentifier($identifier)) { 20 | throw new WebAuthnException(sprintf("Invalid extension identifier '%s'.", $identifier)); 21 | } 22 | } 23 | 24 | public function getIdentifier(): string 25 | { 26 | return $this->identifier; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Attestation/AuthenticatorIdentifier.php: -------------------------------------------------------------------------------- 1 | id = $id; 26 | $this->type = $type; 27 | } 28 | 29 | public function getId(): string 30 | { 31 | return $this->id; 32 | } 33 | 34 | public function getType(): string 35 | { 36 | return $this->type; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Policy/Trust/Voter/AllowEmptyMetadataVoter.php: -------------------------------------------------------------------------------- 1 | modulus = $modulus; 19 | } 20 | 21 | public function getModulus(): ByteBuffer 22 | { 23 | return $this->modulus; 24 | } 25 | 26 | public static function parse(ByteBuffer $buffer, int $offset, ?int &$endOffset): KeyPublicIdInterface 27 | { 28 | $modulus = self::readLengthPrefixed($buffer, $offset); 29 | $endOffset = $offset; 30 | return new self($modulus); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | check: 7 | runs-on: ubuntu-latest 8 | name: PHPstan 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Install PHP 14 | uses: shivammathur/setup-php@v2 15 | with: 16 | php-version: 7.4 17 | coverage: none 18 | extensions: mbstring, gmp 19 | tools: composer:v2 20 | 21 | - name: Install composer dependencies 22 | run: composer install --ansi --no-progress --no-scripts --no-suggest --prefer-dist 23 | 24 | - name: Run PHPstan 25 | run: | 26 | php -v 27 | vendor/bin/phpstan analyse 28 | -------------------------------------------------------------------------------- /src/Extension/ExtensionHelper.php: -------------------------------------------------------------------------------- 1 | self::MAX_IDENTIFIER_LENGHT) { 16 | return false; 17 | } 18 | return (bool) preg_match('~^[\x21\x23-\x5B\x5D-\x7E]+$~', $identifier); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Dom/AuthenticatorAttestationResponse.php: -------------------------------------------------------------------------------- 1 | attestationObject = $attestationObject; 18 | } 19 | 20 | public function getAttestationObject(): ByteBuffer 21 | { 22 | return $this->attestationObject; 23 | } 24 | 25 | public function asAttestationResponse(): AuthenticatorAttestationResponseInterface 26 | { 27 | return $this; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Dom/CredentialCreationOptions.php: -------------------------------------------------------------------------------- 1 | publicKey = $options; 19 | } 20 | 21 | public function getPublicKeyOptions(): ?PublicKeyCredentialCreationOptions 22 | { 23 | return $this->publicKey; 24 | } 25 | 26 | public function getAsArray(): array 27 | { 28 | $map = []; 29 | if ($this->publicKey !== null) { 30 | $map['publicKey'] = $this->publicKey; 31 | } 32 | return $map; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Extension/Generic/GenericExtension.php: -------------------------------------------------------------------------------- 1 | __serialize()); 21 | } 22 | 23 | /** 24 | * @final 25 | * 26 | * @param string $serialized 27 | */ 28 | public function unserialize($serialized): void 29 | { 30 | $this->__unserialize(\unserialize($serialized)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Dom/PublicKeyCredentialInterface.php: -------------------------------------------------------------------------------- 1 | oid = $oid; 27 | $this->critical = $critical; 28 | $this->value = $value; 29 | } 30 | 31 | public function getOid(): string 32 | { 33 | return $this->oid; 34 | } 35 | 36 | public function isCritical(): bool 37 | { 38 | return $this->critical; 39 | } 40 | 41 | public function getValue(): ByteBuffer 42 | { 43 | return $this->value; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Metadata/Source/MetadataServiceSource.php: -------------------------------------------------------------------------------- 1 | url = $url; 25 | $this->rootCert = $rootCert; 26 | $this->accessToken = $accessToken; 27 | } 28 | 29 | public function getUrl(): string 30 | { 31 | return $this->url; 32 | } 33 | 34 | public function getRootCert(): string 35 | { 36 | return $this->rootCert; 37 | } 38 | 39 | public function getAccessToken(): ?string 40 | { 41 | return $this->accessToken; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Pki/CertificateDetailsInterface.php: -------------------------------------------------------------------------------- 1 | input = $input; 22 | } 23 | 24 | /** 25 | * @param mixed $input 26 | */ 27 | public function setInput($input): void 28 | { 29 | $this->input = $input; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Attestation/Identifier/Aaid.php: -------------------------------------------------------------------------------- 1 | aaid = $aaid; 23 | } 24 | 25 | public function getType(): string 26 | { 27 | return self::TYPE; 28 | } 29 | 30 | public function toString(): string 31 | { 32 | return $this->aaid; 33 | } 34 | 35 | public function equals(IdentifierInterface $identifier): bool 36 | { 37 | return $identifier instanceof self && $this->aaid === $identifier->aaid; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Policy/Trust/Voter/UndesiredStatusReportVoter.php: -------------------------------------------------------------------------------- 1 | getStatusReports() as $sr) { 22 | if ($sr->hasUndesiredStatus()) { 23 | return TrustVote::untrusted(); 24 | } 25 | } 26 | return TrustVote::abstain(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Credential/CredentialId.php: -------------------------------------------------------------------------------- 1 | getBinaryString()); 30 | } 31 | 32 | public function equals(self $other): bool 33 | { 34 | return hash_equals($this->raw, $other->raw); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Attestation/Identifier/AttestationKeyIdentifier.php: -------------------------------------------------------------------------------- 1 | id = strtolower($identifier); 22 | } 23 | 24 | public function getType(): string 25 | { 26 | return self::TYPE; 27 | } 28 | 29 | public function toString(): string 30 | { 31 | return $this->id; 32 | } 33 | 34 | public function equals(IdentifierInterface $identifier): bool 35 | { 36 | return $identifier instanceof self && $this->id === $identifier->id; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Server/UserIdentity.php: -------------------------------------------------------------------------------- 1 | userHandle = $userHandle; 27 | $this->username = $username; 28 | $this->displayName = $displayName; 29 | } 30 | 31 | public function getUserHandle(): UserHandle 32 | { 33 | return $this->userHandle; 34 | } 35 | 36 | public function getUsername(): string 37 | { 38 | return $this->username; 39 | } 40 | 41 | public function getDisplayName(): string 42 | { 43 | return $this->displayName; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Attestation/Verifier/UnsupportedAttestationVerifier.php: -------------------------------------------------------------------------------- 1 | x = $x; 24 | $this->y = $y; 25 | } 26 | 27 | public function getX(): ByteBuffer 28 | { 29 | return $this->x; 30 | } 31 | 32 | public function getY(): ByteBuffer 33 | { 34 | return $this->y; 35 | } 36 | 37 | public static function parse(ByteBuffer $buffer, int $offset, ?int &$endOffset): KeyPublicIdInterface 38 | { 39 | $x = self::readLengthPrefixed($buffer, $offset); 40 | $y = self::readLengthPrefixed($buffer, $offset); 41 | $endOffset = $offset; 42 | return new self($x, $y); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Dom/AttestationConveyancePreference.php: -------------------------------------------------------------------------------- 1 | certificates = $certificates; 28 | $this->key = $key; 29 | } 30 | 31 | /** 32 | * Certificates in X5C in the order from the JWT (leaf first). 33 | * 34 | * @return array|X509Certificate[] 35 | */ 36 | public function getCertificates(): array 37 | { 38 | return $this->certificates; 39 | } 40 | 41 | public function getCoseKey(): CoseKeyInterface 42 | { 43 | return $this->key; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Crypto/CoseKeyInterface.php: -------------------------------------------------------------------------------- 1 | credentialId = $credentialId; 27 | $this->publicKey = $publicKey; 28 | $this->userHandle = $userHandle; 29 | } 30 | 31 | public function getCredentialId(): CredentialId 32 | { 33 | return $this->credentialId; 34 | } 35 | 36 | public function getPublicKey(): CoseKeyInterface 37 | { 38 | return $this->publicKey; 39 | } 40 | 41 | public function getUserHandle(): UserHandle 42 | { 43 | return $this->userHandle; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Metadata/Provider/Apple/AppleDevicesProvider.php: -------------------------------------------------------------------------------- 1 | getAttestationObject()->getFormat() === AppleAttestationStatement::FORMAT_ID) { 17 | return new AppleDeviceMetadata(); 18 | } 19 | return null; 20 | } 21 | 22 | public function getDescription(): string 23 | { 24 | return 'Apple devices metadata provider'; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Dom/PublicKeyCredentialParameters.php: -------------------------------------------------------------------------------- 1 | alg = $alg; 33 | $this->type = $type; 34 | } 35 | 36 | public function getAsArray(): array 37 | { 38 | return [ 39 | 'type' => $this->type, 40 | 'alg' => $this->alg, 41 | ]; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Thomas Bleeker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Server/Registration/RegistrationRequest.php: -------------------------------------------------------------------------------- 1 | creationOptions = $creationOptions; 23 | $this->context = $context; 24 | } 25 | 26 | public function getClientOptions(): PublicKeyCredentialCreationOptions 27 | { 28 | return $this->creationOptions; 29 | } 30 | 31 | public function getClientOptionsJson(): array 32 | { 33 | return JsonConverter::encodeDictionary($this->creationOptions); 34 | } 35 | 36 | public function getContext(): RegistrationContext 37 | { 38 | return $this->context; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Server/Authentication/AuthenticationRequest.php: -------------------------------------------------------------------------------- 1 | requestOptions = $requestOptions; 23 | $this->context = $context; 24 | } 25 | 26 | public function getClientOptions(): PublicKeyCredentialRequestOptions 27 | { 28 | return $this->requestOptions; 29 | } 30 | 31 | public function getClientOptionsJson(): array 32 | { 33 | return JsonConverter::encodeDictionary($this->requestOptions); 34 | } 35 | 36 | public function getContext(): AuthenticationContext 37 | { 38 | return $this->context; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Remote/CachingClientFactory.php: -------------------------------------------------------------------------------- 1 | cacheProvider = $cacheProvider; 22 | } 23 | 24 | public function createClient(): Client 25 | { 26 | $stack = HandlerStack::create(); 27 | 28 | $stack->push( 29 | new CacheMiddleware( 30 | new PrivateCacheStrategy( 31 | new Psr6CacheStorage( 32 | $this->cacheProvider->getCachePool('http') 33 | ) 34 | ) 35 | ) 36 | ); 37 | 38 | return new Client(['handler' => $stack]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Extension/ExtensionRegistry.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | private $extensions = []; 14 | 15 | public function __construct() 16 | { 17 | } 18 | 19 | public function addExtension(ExtensionInterface $extension): void 20 | { 21 | $id = $extension->getIdentifier(); 22 | if (isset($this->extensions[$id])) { 23 | throw new ConfigurationException(sprintf('Extension with identifier %s is already registered.', $id)); 24 | } 25 | $this->extensions[$id] = $extension; 26 | } 27 | 28 | public function getExtension(string $extensionId): ExtensionInterface 29 | { 30 | $ext = $this->extensions[$extensionId] ?? null; 31 | if ($ext === null) { 32 | throw new UnsupportedException(sprintf('Extension with id %s not supported.', $extensionId)); 33 | } 34 | return $ext; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Credential/CredentialStoreInterface.php: -------------------------------------------------------------------------------- 1 | data = $data; 25 | $this->contentType = $contentType; 26 | } 27 | 28 | public function getData(): string 29 | { 30 | return $this->data; 31 | } 32 | 33 | public function getContentType(): string 34 | { 35 | return $this->contentType; 36 | } 37 | 38 | public function __serialize(): array 39 | { 40 | return [ 41 | 'contentType' => $this->contentType, 42 | 'data' => $this->data, 43 | ]; 44 | } 45 | 46 | public function __unserialize(array $serialized): void 47 | { 48 | $this->contentType = $serialized['contentType']; 49 | $this->data = $serialized['data']; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Attestation/Verifier/VerificationResult.php: -------------------------------------------------------------------------------- 1 | type = $type; 32 | $this->trustPath = $trustPath; 33 | } 34 | 35 | public function getAttestationType(): string 36 | { 37 | return $this->type; 38 | } 39 | 40 | public function getTrustPath(): TrustPathInterface 41 | { 42 | return $this->trustPath; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Server/Registration/RegistrationContext.php: -------------------------------------------------------------------------------- 1 | userHandle = $userHandle; 22 | } 23 | 24 | public function getUserHandle(): UserHandle 25 | { 26 | return $this->userHandle; 27 | } 28 | 29 | public function __serialize(): array 30 | { 31 | return [ 32 | 'parent' => parent::__serialize(), 33 | 'userHandle' => $this->userHandle, 34 | ]; 35 | } 36 | 37 | public function __unserialize(array $data): void 38 | { 39 | parent::__unserialize($data['parent']); 40 | $this->userHandle = $data['userHandle']; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Extension/ExtensionProcessingContext.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | private $outputs = []; 21 | 22 | public function __construct(string $operation) 23 | { 24 | $this->operation = $operation; 25 | } 26 | 27 | public function getOperation(): string 28 | { 29 | return $this->operation; 30 | } 31 | 32 | public function getOverruledRpId(): ?string 33 | { 34 | return $this->overruledRpId; 35 | } 36 | 37 | public function setOverruledRpId(?string $overruledRpId): void 38 | { 39 | $this->overruledRpId = $overruledRpId; 40 | } 41 | 42 | public function addOutput(ExtensionOutputInterface $output): void 43 | { 44 | $this->outputs[$output->getIdentifier()] = $output; 45 | } 46 | 47 | // public function getOutput(string $identifier) 48 | // { 49 | // $output = $this->outputs[$identifier] ?? null; 50 | // 51 | // } 52 | } 53 | -------------------------------------------------------------------------------- /src/Policy/Trust/Voter/SupportedAttestationTypeVoter.php: -------------------------------------------------------------------------------- 1 | getVerificationResult()->getAttestationType(); 23 | // 'None' attestation is not specified in metadata so ignore that case, otherwise authenticator should support 24 | // this type. 25 | if ($type !== AttestationType::NONE && !$metadata->supportsAttestationType($type)) { 26 | return TrustVote::untrusted(); 27 | } 28 | return TrustVote::abstain(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Crypto/CoseHash.php: -------------------------------------------------------------------------------- 1 | 'sha1', 16 | CoseAlgorithm::ES256 => 'sha256', 17 | CoseAlgorithm::ES384 => 'sha384', 18 | CoseAlgorithm::ES512 => 'sha512', 19 | CoseAlgorithm::RS256 => 'sha256', 20 | CoseAlgorithm::RS384 => 'sha384', 21 | CoseAlgorithm::RS512 => 'sha512', 22 | ]; 23 | 24 | /** 25 | * CoseHash constructor. 26 | * 27 | * @param int $algorithm CoseAlgorithm identifier 28 | * 29 | * @see CoseAlgorithm 30 | * 31 | * @throws UnsupportedException 32 | */ 33 | public function __construct(int $algorithm) 34 | { 35 | $phpAlg = self::MAP[$algorithm] ?? null; 36 | if ($phpAlg === null) { 37 | throw new UnsupportedException(sprintf('COSE algorithm %d not supported for hashing.', $algorithm)); 38 | } 39 | $this->phpAlg = $phpAlg; 40 | } 41 | 42 | public function hash(string $data): string 43 | { 44 | return hash($this->phpAlg, $data, true); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Extension/ExtensionResponseInterface.php: -------------------------------------------------------------------------------- 1 | inputs[] = $input; 23 | } 24 | 25 | public function getAsArray(): array 26 | { 27 | $map = []; 28 | foreach ($this->inputs as $input) { 29 | $map[$input->getIdentifier()] = $input->getInput(); 30 | } 31 | return $map; 32 | } 33 | 34 | /** 35 | * @param ExtensionInputInterface[] $inputs 36 | * 37 | * @return AuthenticationExtensionsClientInputs 38 | */ 39 | public static function fromArray(array $inputs): self 40 | { 41 | $obj = new AuthenticationExtensionsClientInputs(); 42 | foreach ($inputs as $input) { 43 | $obj->addInput($input); 44 | } 45 | return $obj; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Server/Authentication/AuthenticationResult.php: -------------------------------------------------------------------------------- 1 | userCredential = $userCredential; 24 | $this->authenticatorData = $authenticatorData; 25 | } 26 | 27 | public function getUserCredential(): UserCredentialInterface 28 | { 29 | return $this->userCredential; 30 | } 31 | 32 | public function getUserHandle(): UserHandle 33 | { 34 | return $this->userCredential->getUserHandle(); 35 | } 36 | 37 | public function getAuthenticatorData(): AuthenticatorData 38 | { 39 | return $this->authenticatorData; 40 | } 41 | 42 | public function isUserVerified(): bool 43 | { 44 | return $this->authenticatorData->isUserVerified(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Dom/PublicKeyCredentialEntity.php: -------------------------------------------------------------------------------- 1 | name = $name; 22 | } 23 | 24 | public function getName(): string 25 | { 26 | return $this->name; 27 | } 28 | 29 | public function setName(string $name): void 30 | { 31 | $this->name = $name; 32 | } 33 | 34 | public function getIcon(): ?string 35 | { 36 | return $this->icon; 37 | } 38 | 39 | public function setIcon(?string $icon): void 40 | { 41 | // TODO: FILTER_VALIDATE_URL does not allow data urls 42 | // if ($icon !== null && filter_var($icon, FILTER_VALIDATE_URL) === false) { 43 | // throw new ConfigurationException("Invalid relying party icon url."); 44 | // } 45 | $this->icon = $icon; 46 | } 47 | 48 | public function getAsArray(): array 49 | { 50 | return self::removeNullValues([ 51 | 'name' => $this->name, 52 | 'icon' => $this->icon, 53 | ]); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Policy/Trust/Voter/TrustChainVoter.php: -------------------------------------------------------------------------------- 1 | pathValidator = $pathVal; 21 | } 22 | 23 | public function voteOnTrust( 24 | RegistrationResultInterface $registrationResult, 25 | TrustPathInterface $trustPath, 26 | ?MetadataInterface $metadata 27 | ): TrustVote { 28 | if ($metadata === null) { 29 | return TrustVote::abstain(); 30 | } 31 | 32 | $trustAnchors = $metadata->getTrustAnchors(); 33 | foreach ($trustAnchors as $anchor) { 34 | if ($this->pathValidator->validate($trustPath, $anchor)) { 35 | return TrustVote::trusted(); 36 | } 37 | } 38 | return TrustVote::abstain(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Attestation/Android/AndroidAttestationExtension.php: -------------------------------------------------------------------------------- 1 | seAuthList = $seAuthList; 35 | $this->teeAuthList = $teeAuthList; 36 | $this->challenge = $challenge; 37 | } 38 | 39 | public function getSoftwareEnforcedAuthList(): AuthorizationList 40 | { 41 | return $this->seAuthList; 42 | } 43 | 44 | public function getTeeEnforcedAuthList(): AuthorizationList 45 | { 46 | return $this->teeAuthList; 47 | } 48 | 49 | public function getChallenge(): ByteBuffer 50 | { 51 | return $this->challenge; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Policy/Trust/Voter/TrustAttestationTypeVoter.php: -------------------------------------------------------------------------------- 1 | trustedType = $attestationType; 26 | } 27 | 28 | public function voteOnTrust( 29 | RegistrationResultInterface $registrationResult, 30 | TrustPathInterface $trustPath, 31 | ?MetadataInterface $metadata 32 | ): TrustVote { 33 | if ($registrationResult->getVerificationResult()->getAttestationType() === $this->trustedType) { 34 | return TrustVote::trusted(); 35 | } 36 | return TrustVote::abstain(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Dom/AuthenticatorAssertionResponse.php: -------------------------------------------------------------------------------- 1 | authenticatorData = $authenticatorData; 28 | $this->signature = $signature; 29 | $this->userHandle = $userHandle; 30 | } 31 | 32 | public function getAuthenticatorData(): ByteBuffer 33 | { 34 | return $this->authenticatorData; 35 | } 36 | 37 | public function getSignature(): ByteBuffer 38 | { 39 | return $this->signature; 40 | } 41 | 42 | public function getUserHandle(): ?ByteBuffer 43 | { 44 | return $this->userHandle; 45 | } 46 | 47 | public function asAssertionResponse(): AuthenticatorAssertionResponseInterface 48 | { 49 | return $this; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Extension/AbstractExtensionInput.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 26 | 27 | if (!ExtensionHelper::validExtensionIdentifier($identifier)) { 28 | throw new WebAuthnException(sprintf("Invalid extension identifier '%s'.", $identifier)); 29 | } 30 | } 31 | 32 | public function getIdentifier(): string 33 | { 34 | return $this->identifier; 35 | } 36 | 37 | /** 38 | * @return mixed 39 | */ 40 | public function getInput() 41 | { 42 | return $this->input; 43 | } 44 | 45 | public function __serialize(): array 46 | { 47 | return ['id' => $this->identifier, 'input' => $this->input]; 48 | } 49 | 50 | public function __unserialize(array $data): void 51 | { 52 | $this->identifier = $data['id']; 53 | $this->input = $data['input']; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Metadata/Preset/FidoMetadataService.php: -------------------------------------------------------------------------------- 1 | nonce = $nonce; 35 | $this->x5c = $x5c; 36 | $this->ctsProfileMatch = $ctsProfileMatch; 37 | $this->timestampMs = $timestampMs; 38 | } 39 | 40 | public function getNonce(): string 41 | { 42 | return $this->nonce; 43 | } 44 | 45 | /** 46 | * @return X509Certificate[] 47 | */ 48 | public function getCertificateChain(): array 49 | { 50 | return $this->x5c; 51 | } 52 | 53 | public function isCtsProfileMatch(): bool 54 | { 55 | return $this->ctsProfileMatch; 56 | } 57 | 58 | public function getTimestampMs() 59 | { 60 | return $this->timestampMs; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Attestation/Verifier/NoneAttestationVerifier.php: -------------------------------------------------------------------------------- 1 | purposeList, true); 31 | } 32 | 33 | /** 34 | * @return int[] 35 | */ 36 | public function getPurposeList(): array 37 | { 38 | return $this->purposeList; 39 | } 40 | 41 | public function addPurpose(int $purpose): void 42 | { 43 | $this->purposeList[] = $purpose; 44 | } 45 | 46 | public function hasAllApplications(): bool 47 | { 48 | return $this->allApplications; 49 | } 50 | 51 | public function setAllApplications(bool $allApplications): void 52 | { 53 | $this->allApplications = $allApplications; 54 | } 55 | 56 | public function getOrigin(): ?int 57 | { 58 | return $this->origin; 59 | } 60 | 61 | public function setOrigin(?int $origin): void 62 | { 63 | $this->origin = $origin; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Format/BinaryHandle.php: -------------------------------------------------------------------------------- 1 | raw = $rawBytes; 22 | } 23 | 24 | public function toString(): string 25 | { 26 | return Base64UrlEncoding::encode($this->raw); 27 | } 28 | 29 | public function toBinary(): string 30 | { 31 | return $this->raw; 32 | } 33 | 34 | public function toHex(): string 35 | { 36 | return bin2hex($this->raw); 37 | } 38 | 39 | public function toBuffer(): ByteBuffer 40 | { 41 | return new ByteBuffer($this->raw); 42 | } 43 | 44 | protected static function convertHex(string $hex): string 45 | { 46 | $bin = @hex2bin($hex); 47 | if ($bin === false) { 48 | throw new InvalidArgumentException('Invalid hex string'); 49 | } 50 | return $bin; 51 | } 52 | 53 | public function __serialize(): array 54 | { 55 | return [ 56 | 'raw' => $this->raw, 57 | ]; 58 | } 59 | 60 | public function __unserialize(array $data): void 61 | { 62 | $this->raw = $data['raw']; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Metadata/Statement/TocItem.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 36 | $this->hash = $hash; 37 | $this->url = $url; 38 | $this->statusReports = $statusReports; 39 | } 40 | 41 | public function getIdentifier(): IdentifierInterface 42 | { 43 | return $this->identifier; 44 | } 45 | 46 | public function getHash(): ?ByteBuffer 47 | { 48 | return $this->hash; 49 | } 50 | 51 | public function getUrl(): ?string 52 | { 53 | return $this->url; 54 | } 55 | 56 | /** 57 | * @return StatusReport[] 58 | */ 59 | public function getStatusReports(): array 60 | { 61 | return $this->statusReports; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Policy/Trust/TrustVote.php: -------------------------------------------------------------------------------- 1 | type = $type; 26 | $this->reason = $reason; 27 | } 28 | 29 | public function isAbstain(): bool 30 | { 31 | return $this->type === self::VOTE_ABSTAIN; 32 | } 33 | 34 | public function isTrusted(): bool 35 | { 36 | return $this->type === self::VOTE_TRUSTED; 37 | } 38 | 39 | public function isUntrusted(): bool 40 | { 41 | return $this->type === self::VOTE_UNTRUSTED; 42 | } 43 | 44 | public function getReason(): ?string 45 | { 46 | return $this->reason; 47 | } 48 | 49 | public static function trusted(): self 50 | { 51 | return new TrustVote(self::VOTE_TRUSTED); 52 | } 53 | 54 | public static function abstain(): self 55 | { 56 | return new TrustVote(self::VOTE_ABSTAIN); 57 | } 58 | 59 | public static function untrusted(?string $reason = null): self 60 | { 61 | return new TrustVote(self::VOTE_UNTRUSTED, $reason); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Metadata/Statement/AttestationConstant.php: -------------------------------------------------------------------------------- 1 | self::TAG_ATTESTATION_BASIC_FULL, 28 | AttestationType::SELF => self::TAG_ATTESTATION_BASIC_SURROGATE, 29 | AttestationType::ATT_CA => self::TAG_ATTESTATION_ATT_CA, 30 | AttestationType::ECDAA => self::TAG_ATTESTATION_ECDAA, 31 | ]; 32 | 33 | /** 34 | * @codeCoverageIgnore 35 | */ 36 | private function __construct() 37 | { 38 | } 39 | 40 | /** 41 | * Converts AttestationType style constant to numerical constant. 42 | * Returns null if there is no equivalent. 43 | */ 44 | public static function convertType(string $type): ?int 45 | { 46 | return self::MAP[$type] ?? null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Attestation/Registry/BuiltInAttestationFormat.php: -------------------------------------------------------------------------------- 1 | $statementClass 28 | */ 29 | public function __construct(string $formatId, string $statementClass, AttestationVerifierInterface $verifier) 30 | { 31 | $this->formatId = $formatId; 32 | $this->statementClass = $statementClass; 33 | $this->verifier = $verifier; 34 | } 35 | 36 | public function getFormatId(): string 37 | { 38 | return $this->formatId; 39 | } 40 | 41 | public function createStatement(AttestationObject $attestationObject): AttestationStatementInterface 42 | { 43 | $class = $this->statementClass; 44 | return new $class($attestationObject); 45 | } 46 | 47 | public function getVerifier(): AttestationVerifierInterface 48 | { 49 | return $this->verifier; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Metadata/Statement/AuthenticatorStatus.php: -------------------------------------------------------------------------------- 1 | key = $key; 32 | // TODO: check types 33 | $this->allowedAlgorithms = $allowedAlgorithms; 34 | } 35 | 36 | /** 37 | * @return string[] 38 | */ 39 | public function getAllowedAlgorithms(): array 40 | { 41 | return $this->allowedAlgorithms; 42 | } 43 | 44 | public function getKey(): CoseKeyInterface 45 | { 46 | return $this->key; 47 | } 48 | 49 | public function getReferenceUnixTime(): int 50 | { 51 | return $this->referenceUnixTime ?? time(); 52 | } 53 | 54 | public function getClockLeeway(): int 55 | { 56 | return self::DEFAULT_CLOCK_LEEWAY; 57 | } 58 | 59 | public function withReferenceUnixTime(int $timestamp): self 60 | { 61 | $copy = clone $this; 62 | $copy->referenceUnixTime = $timestamp; 63 | return $copy; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Dom/PublicKeyCredentialRpEntity.php: -------------------------------------------------------------------------------- 1 | id = $id; 32 | } 33 | } 34 | 35 | public function getId(): ?string 36 | { 37 | return $this->id; 38 | } 39 | 40 | public function getAsArray(): array 41 | { 42 | $map = parent::getAsArray(); 43 | if ($this->id !== null) { 44 | $map['id'] = $this->id; 45 | } 46 | return $map; 47 | } 48 | 49 | public static function fromRelyingParty(RelyingPartyInterface $rp): self 50 | { 51 | $rpEntity = new self($rp->getName(), $rp->getId()); 52 | $rpEntity->setIcon($rp->getIconUrl()); 53 | return $rpEntity; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Attestation/Statement/AbstractAttestationStatement.php: -------------------------------------------------------------------------------- 1 | getFormat(); 20 | if ($actualFormat !== $formatId) { 21 | throw new ParseException(sprintf("Not expecting format '%s' but '%s'.", $actualFormat, $formatId)); 22 | } 23 | $this->formatId = $formatId; 24 | } 25 | 26 | /** 27 | * @param ByteBuffer[] $x5c 28 | * 29 | * @return X509Certificate[] 30 | * 31 | * @throws ParseException 32 | */ 33 | protected function buildPEMCertificateArray(array $x5c): array 34 | { 35 | $certificates = []; 36 | foreach ($x5c as $item) { 37 | if (!($item instanceof ByteBuffer)) { 38 | throw new ParseException('x5c should be array of binary data elements.'); 39 | } 40 | $certificates[] = X509Certificate::fromDer($item->getBinaryString()); 41 | } 42 | return $certificates; 43 | } 44 | 45 | public function getFormatId(): string 46 | { 47 | return $this->formatId; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Server/ServerInterface.php: -------------------------------------------------------------------------------- 1 | getStatement(); 27 | 28 | // Note: early iOS versions included an 'alg' parameter which was removed later 29 | try { 30 | DataValidator::checkMap( 31 | $statement, 32 | [ 33 | 'x5c' => 'array', 34 | ], 35 | false 36 | ); 37 | } catch (DataValidationException $e) { 38 | throw new ParseException('Invalid apple attestation statement.', 0, $e); 39 | } 40 | 41 | $x5c = $statement->get('x5c'); 42 | $this->certificates = $this->buildPEMCertificateArray($x5c); 43 | } 44 | 45 | /** 46 | * @return X509Certificate[] 47 | */ 48 | public function getCertificates(): array 49 | { 50 | return $this->certificates; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Metadata/MetadataResolver.php: -------------------------------------------------------------------------------- 1 | providers = $providers; 25 | $this->logger = new NullLogger(); 26 | } 27 | 28 | public function getMetadata(RegistrationResultInterface $registrationResult): ?MetadataInterface 29 | { 30 | foreach ($this->providers as $provider) { 31 | try { 32 | $metadata = $provider->getMetadata($registrationResult); 33 | if ($metadata !== null) { 34 | $this->logger->info('Found metadata for authenticator in provider {provider}.', ['provider' => $provider->getDescription()]); 35 | return $metadata; 36 | } 37 | } catch (WebAuthnException $e) { 38 | $this->logger->warning('Error retrieving metadata ({error}) - ignoring provider {provider}.', ['error' => $e->getMessage(), 'provider' => $provider->getDescription(), 'exception' => $e]); 39 | continue; 40 | } 41 | } 42 | return null; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "madwizard/webauthn", 3 | "description": "Web Authentication API server for PHP", 4 | "type": "library", 5 | "autoload": { 6 | "psr-4": { 7 | "MadWizard\\WebAuthn\\": "src/" 8 | } 9 | }, 10 | "autoload-dev": { 11 | "psr-4": { 12 | "MadWizard\\WebAuthn\\Tests\\": "tests/", 13 | "MadWizard\\WebAuthn\\Conformance\\": "conformance/src/" 14 | } 15 | }, 16 | "require": { 17 | "php": "^7.2.0|^8.0", 18 | "sop/x509": "^0.7.0", 19 | "ext-openssl": "*", 20 | "ext-json": "*", 21 | "guzzlehttp/guzzle": "^6.5|^7.0", 22 | "psr/cache": "^1.0|^2.0|^3.0", 23 | "kevinrob/guzzle-cache-middleware": "^3.3", 24 | "psr/log": "^1.1|^2.0|^3.0", 25 | "sop/asn1": "^4.1", 26 | "sop/x501": "^0.6.1", 27 | "sop/crypto-types": "^0.3.0", 28 | "sop/crypto-bridge": "^0.3.1", 29 | "sop/crypto-encoding": "^0.3.0", 30 | "ext-sodium": "*", 31 | "symfony/cache": "^4.4|^5.2|^6.0|^7.0" 32 | }, 33 | "require-dev": { 34 | "phpunit/phpunit": "^8.5.29", 35 | "phpstan/phpstan": "^0.12.64", 36 | "symfony/var-dumper": "^5.4", 37 | "symfony/console": "^5.4", 38 | "symfony/dotenv": "^5.2", 39 | "phpseclib/phpseclib": "^3.0.1", 40 | "sebastian/comparator": "^3.0.5" 41 | }, 42 | "archive": { 43 | "exclude": [ 44 | "/tests", 45 | "/phpunit.xml.dist", 46 | "/conformance" 47 | ] 48 | }, 49 | "license": "MIT", 50 | "authors": [ 51 | { 52 | "name": "Thomas Bleeker", 53 | "email": "support@madwizard.org" 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/Dom/PublicKeyCredential.php: -------------------------------------------------------------------------------- 1 | rawId = $rawCredentialId; 27 | $this->response = $response; 28 | } 29 | 30 | public function getType(): string 31 | { 32 | return PublicKeyCredentialType::PUBLIC_KEY; 33 | } 34 | 35 | public function getRawId(): ByteBuffer 36 | { 37 | return $this->rawId; 38 | } 39 | 40 | /** 41 | * The credential's identifier. For public key credentials this is a base64url encoded version of the raw credential ID. 42 | */ 43 | public function getId(): string 44 | { 45 | return $this->rawId->getBase64Url(); 46 | } 47 | 48 | public function getResponse(): AuthenticatorResponseInterface 49 | { 50 | return $this->response; 51 | } 52 | 53 | public function setClientExtensionResults(array $extensionResults): void 54 | { 55 | $this->clientExtensionResults = $extensionResults; 56 | } 57 | 58 | /** 59 | * @return array Array of client extensions as provided by the client (no parsing done yet) 60 | */ 61 | public function getClientExtensionResults(): array 62 | { 63 | return $this->clientExtensionResults; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Dom/TokenBinding.php: -------------------------------------------------------------------------------- 1 | status = $status; 28 | if ($this->status === TokenBindingStatus::PRESENT && $id === null) { 29 | throw new UnexpectedValueException("Token binding id should be set if status is 'present'."); 30 | } 31 | if ($this->status === TokenBindingStatus::SUPPORTED && $id !== null) { 32 | throw new UnexpectedValueException("Token binding id cannot be set if status is 'supported'."); 33 | } 34 | $this->id = $id; 35 | } 36 | 37 | public function getStatus(): string 38 | { 39 | return $this->status; 40 | } 41 | 42 | /** 43 | * @return ByteBuffer Returns the token binding ID 44 | * 45 | * @throws NotAvailableException 46 | */ 47 | public function getId(): ByteBuffer 48 | { 49 | if ($this->id === null) { 50 | throw new NotAvailableException('No token binding ID available.'); 51 | } 52 | return $this->id; 53 | } 54 | 55 | public function hasId(): bool 56 | { 57 | return $this->id !== null; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Attestation/Fido/FidoAaguidExtension.php: -------------------------------------------------------------------------------- 1 | equals($aaguid)) { 26 | throw new VerificationException('AAGUID in certificate extension does not match the AAGUID in the authenticator data.'); 27 | } 28 | } 29 | 30 | private static function getFidoAaguidExtensionValue(CertificateDetailsInterface $cert): ?Aaguid 31 | { 32 | $extension = $cert->getExtensionData(self::OID_FIDO_GEN_CE_AAGUID); 33 | if ($extension === null) { 34 | return null; 35 | } 36 | 37 | if ($extension->isCritical()) { 38 | throw new VerificationException('FIDO AAGUID extension must not be critical.'); 39 | } 40 | 41 | try { 42 | $rawAaguid = UnspecifiedType::fromDER($extension->getValue()->getBinaryString())->asOctetString()->string(); 43 | return new Aaguid(new ByteBuffer($rawAaguid)); 44 | } catch (Exception $e) { 45 | throw new ParseException('Failed to parse AAGUID extension', 0, $e); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Attestation/TrustPath/CertificateTrustPath.php: -------------------------------------------------------------------------------- 1 | certificates = $certificates; 23 | } 24 | 25 | public static function fromPemList(array $x5c): self 26 | { 27 | return new CertificateTrustPath(...array_map(static function (string $s): X509Certificate { 28 | return X509Certificate::fromPem($s); 29 | }, $x5c)); 30 | } 31 | 32 | public static function fromBase64List(array $x5c): self 33 | { 34 | return new CertificateTrustPath(...array_map(static function (string $s): X509Certificate { 35 | return X509Certificate::fromBase64($s); 36 | }, $x5c)); 37 | } 38 | 39 | /** 40 | * @return X509Certificate[] 41 | */ 42 | public function getCertificates(): array 43 | { 44 | return $this->certificates; 45 | } 46 | 47 | /** 48 | * Returns certificates as a list of PEM encoded strings (including armor). 49 | * 50 | * @return string[] 51 | */ 52 | public function asPemList(): array 53 | { 54 | return array_map(static function (X509Certificate $cert): string { 55 | return $cert->asPem(); 56 | }, $this->certificates); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Remote/Downloader.php: -------------------------------------------------------------------------------- 1 | client = $client; 25 | $this->logger = new NullLogger(); 26 | } 27 | 28 | public function downloadFile(string $uri): FileContents 29 | { 30 | // TODO: remove token in logging? 31 | try { 32 | $response = $this->client->get($uri); 33 | } catch (RequestException $e) { 34 | $errorResponse = $e->getResponse(); 35 | if ($errorResponse) { 36 | $message = sprintf('Error response while downloading URL: %d %s', $errorResponse->getStatusCode(), $errorResponse->getReasonPhrase()); 37 | } else { 38 | $message = sprintf('Failed to download URL: %s', $e->getMessage()); 39 | } 40 | throw new RemoteException($message, 0, $e); 41 | } catch (ClientExceptionInterface $e) { 42 | throw new RemoteException(sprintf('Failed to download URL: %s', $e->getMessage()), 0, $e); 43 | } 44 | 45 | $content = $response->getBody()->getContents(); 46 | $types = $response->getHeader('Content-Type'); 47 | $contentType = $types[0] ?? 'application/octet-stream'; 48 | return new FileContents($content, $contentType); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Attestation/Statement/FidoU2fAttestationStatement.php: -------------------------------------------------------------------------------- 1 | getStatement(); 31 | 32 | try { 33 | DataValidator::checkMap( 34 | $statement, 35 | [ 36 | 'x5c' => 'array', 37 | 'sig' => ByteBuffer::class, 38 | ] 39 | ); 40 | } catch (DataValidationException $e) { 41 | throw new ParseException('Invalid FIDO U2F attestation statement.', 0, $e); 42 | } 43 | 44 | $sig = $statement->get('sig'); 45 | $x5c = $statement->get('x5c'); 46 | 47 | $this->signature = $sig; 48 | $this->certificates = $this->buildPEMCertificateArray($x5c); 49 | } 50 | 51 | public function getSignature(): ByteBuffer 52 | { 53 | return $this->signature; 54 | } 55 | 56 | /** 57 | * @return X509Certificate[] 58 | */ 59 | public function getCertificates(): array 60 | { 61 | return $this->certificates; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Server/Authentication/AuthenticationContext.php: -------------------------------------------------------------------------------- 1 | userHandle = $userHandle; 28 | } 29 | 30 | public function addAllowCredentialId(CredentialId $credentialId): void 31 | { 32 | $this->allowCredentialIds[] = $credentialId; 33 | } 34 | 35 | public function getUserHandle(): ?UserHandle 36 | { 37 | return $this->userHandle; 38 | } 39 | 40 | /** 41 | * @return CredentialId[] 42 | */ 43 | public function getAllowCredentialIds(): array 44 | { 45 | return $this->allowCredentialIds; 46 | } 47 | 48 | public function __serialize(): array 49 | { 50 | return [ 51 | 'parent' => parent::__serialize(), 52 | 'userHandle' => $this->userHandle, 53 | 'allowCredentialIds' => $this->allowCredentialIds, 54 | ]; 55 | } 56 | 57 | public function __unserialize(array $data): void 58 | { 59 | parent::__unserialize($data['parent']); 60 | $this->userHandle = $data['userHandle']; 61 | $this->allowCredentialIds = $data['allowCredentialIds']; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Attestation/Statement/AndroidSafetyNetAttestationStatement.php: -------------------------------------------------------------------------------- 1 | getStatement(); 30 | 31 | try { 32 | DataValidator::checkMap( 33 | $statement, 34 | [ 35 | 'ver' => 'string', 36 | 'response' => ByteBuffer::class, 37 | ] 38 | ); 39 | } catch (DataValidationException $e) { 40 | throw new ParseException('Invalid Android SafetyNet attestation statement.', 0, $e); 41 | } 42 | 43 | $this->version = $statement->get('ver'); 44 | if ($this->version === '') { 45 | throw new ParseException('Android SafetyNet version is empty.'); 46 | } 47 | 48 | $res = $statement->get('response'); 49 | assert($res instanceof ByteBuffer); 50 | $this->response = $res->getBinaryString(); 51 | } 52 | 53 | public function getVersion(): string 54 | { 55 | return $this->version; 56 | } 57 | 58 | public function getResponse(): string 59 | { 60 | return $this->response; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Extension/AppId/AppIdExtension.php: -------------------------------------------------------------------------------- 1 | getClientExtensionOutput(); 23 | if (!is_bool($extensionOutput)) { 24 | throw new ParseException('Expecting boolean value in appid extension output.'); 25 | } 26 | 27 | return new AppIdExtensionOutput($extensionOutput); 28 | } 29 | 30 | public function processExtension(ExtensionInputInterface $input, ExtensionOutputInterface $output, ExtensionProcessingContext $context): void 31 | { 32 | if (!$input instanceof AppIdExtensionInput) { 33 | throw new ExtensionException('Expecting appid extension input to be AppIdExtensionInput.'); 34 | } 35 | if (!$output instanceof AppIdExtensionOutput) { 36 | throw new ExtensionException('Expecting appid extension output to be AppIdExtensionOutput.'); 37 | } 38 | // SPEC: Client extension output: If true, the AppID was used and thus, when verifying an assertion, 39 | // the Relying Party MUST expect the rpIdHash to be the hash of the AppID, not the RP ID. 40 | if ($output->getAppIdUsed()) { 41 | $context->setOverruledRpId($input->getAppId()); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Pki/Crl.php: -------------------------------------------------------------------------------- 1 | loadCA($ca->asDer()) === false) { 37 | throw new ParseException('Failed to load CA certificate for CRL.'); 38 | } 39 | } 40 | 41 | $crlInfo = $crl->loadCRL($crlData); 42 | if ($crlInfo === false) { 43 | throw new ParseException('Failed to load CRL data.'); 44 | } 45 | 46 | $nextUpdate = $crlInfo['tbsCertList']['nextUpdate']['utcTime'] ?? null; 47 | if ($nextUpdate !== null) { 48 | $this->nextUpdate = new DateTimeImmutable($nextUpdate, new DateTimeZone('UTC')); 49 | } 50 | 51 | if (true !== $crl->validateSignature()) { 52 | throw new VerificationException('Failed to verify CRL signature.'); 53 | } 54 | $this->crl = $crl; 55 | } 56 | 57 | public function isRevoked(string $serial): bool 58 | { 59 | return $this->crl->getRevoked($serial) !== false; 60 | } 61 | 62 | public function getNextUpdate(): ?DateTimeImmutable 63 | { 64 | return $this->nextUpdate; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Dom/AbstractAuthenticatorResponse.php: -------------------------------------------------------------------------------- 1 | clientDataJson = $clientDataJson; 26 | 27 | // Specification says to remove the UTF-8 byte order mark, if any 28 | if (\substr($clientDataJson, 0, 3) === self::UTF8_BOM) { 29 | $clientDataJson = substr($clientDataJson, 3); 30 | } 31 | $data = \json_decode($clientDataJson, true, 10); 32 | if ($data === null && json_last_error() !== JSON_ERROR_NONE) { 33 | throw new ParseException('Unparseable client data JSON'); 34 | } 35 | if (!\is_array($data)) { 36 | throw new ParseException('Expected object for client data'); 37 | } 38 | $this->clientData = CollectedClientData::fromJson($data); 39 | } 40 | 41 | public function getClientDataJson(): string 42 | { 43 | return $this->clientDataJson; 44 | } 45 | 46 | public function getParsedClientData(): CollectedClientData 47 | { 48 | return $this->clientData; 49 | } 50 | 51 | public function asAttestationResponse(): AuthenticatorAttestationResponseInterface 52 | { 53 | throw new WebAuthnException('Response is not an attestation response.'); 54 | } 55 | 56 | public function asAssertionResponse(): AuthenticatorAssertionResponseInterface 57 | { 58 | throw new WebAuthnException('Response is not an assertion response.'); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Metadata/Provider/FileProvider.php: -------------------------------------------------------------------------------- 1 | source = $source; 23 | } 24 | 25 | public function getMetadata(RegistrationResultInterface $registrationResult): ?MetadataInterface 26 | { 27 | $identifier = $registrationResult->getIdentifier(); 28 | if ($identifier === null) { 29 | return null; 30 | } 31 | 32 | $iterator = new GlobIterator($this->source->getMetadataDir() . DIRECTORY_SEPARATOR . '*.json'); 33 | 34 | /** 35 | * @var SplFileInfo $fileInfo 36 | */ 37 | foreach ($iterator as $fileInfo) { 38 | if (!$fileInfo->isFile()) { 39 | continue; 40 | } 41 | 42 | $data = file_get_contents($fileInfo->getPathname()); 43 | if ($data === false) { 44 | throw new WebAuthnException(sprintf('Cannot read file %s.', $fileInfo->getPathname())); 45 | } 46 | $statement = MetadataStatement::decodeString($data); 47 | 48 | if ($statement->matchesIdentifier($identifier)) { 49 | return $statement; 50 | } 51 | } 52 | return null; 53 | } 54 | 55 | public function getDescription(): string 56 | { 57 | return sprintf('Metadata files directory=%s', $this->source->getMetadataDir()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Dom/PublicKeyCredentialDescriptor.php: -------------------------------------------------------------------------------- 1 | type = $type; 31 | $this->id = $credentialId; 32 | } 33 | 34 | public function addTransport(string $transport): void 35 | { 36 | if (!AuthenticatorTransport::isValidValue($transport)) { // TODO:REMOVE see https://github.com/w3c/webauthn/issues/1268 37 | throw new WebAuthnException(sprintf("Transport '%s' is not a valid transport value.", $transport)); 38 | } 39 | if ($this->transports === null) { 40 | $this->transports = []; 41 | } 42 | $this->transports[] = $transport; 43 | } 44 | 45 | public function getAsArray(): array 46 | { 47 | return self::removeNullValues([ 48 | 'type' => $this->type, 49 | 'id' => $this->id, 50 | 'transports' => $this->transports, 51 | ]); 52 | } 53 | 54 | public function getType(): string 55 | { 56 | return $this->type; 57 | } 58 | 59 | public function getId(): ByteBuffer 60 | { 61 | return $this->id; 62 | } 63 | 64 | /** 65 | * @return string[]|null 66 | */ 67 | public function getTransports(): ?array 68 | { 69 | return $this->transports; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Crypto/OpenSslVerifier.php: -------------------------------------------------------------------------------- 1 | OPENSSL_ALGO_SHA256, 12 | CoseAlgorithm::ES384 => OPENSSL_ALGO_SHA384, 13 | CoseAlgorithm::ES512 => OPENSSL_ALGO_SHA512, 14 | 15 | CoseAlgorithm::RS256 => OPENSSL_ALGO_SHA256, 16 | CoseAlgorithm::RS384 => OPENSSL_ALGO_SHA384, 17 | CoseAlgorithm::RS512 => OPENSSL_ALGO_SHA512, 18 | CoseAlgorithm::RS1 => OPENSSL_ALGO_SHA1, 19 | ]; 20 | 21 | /** 22 | * @var int 23 | */ 24 | private $openSslAlgorithm; 25 | 26 | public function __construct(int $coseAlgorithm) 27 | { 28 | $this->openSslAlgorithm = $this->getOpenSslAlgorithm($coseAlgorithm); 29 | } 30 | 31 | private function getOpenSslAlgorithm(int $algorithm): int 32 | { 33 | $openSslAlgorithm = self::OPENSSL_ALGO_MAP[$algorithm] ?? null; 34 | 35 | if ($openSslAlgorithm === null) { 36 | throw new UnsupportedException('Unsupported algorithm'); 37 | } 38 | 39 | return $openSslAlgorithm; 40 | } 41 | 42 | public function verify(string $data, string $signature, string $keyPem): bool 43 | { 44 | $publicKey = openssl_pkey_get_public($keyPem); 45 | if ($publicKey === false) { 46 | throw new WebAuthnException('Public key invalid'); 47 | } 48 | try { 49 | $verify = openssl_verify($data, $signature, $publicKey, $this->openSslAlgorithm); 50 | if ($verify === 1) { 51 | return true; 52 | } 53 | if ($verify === 0) { 54 | return false; 55 | } 56 | 57 | throw new WebAuthnException('Failed to check signature'); 58 | } finally { 59 | if (PHP_VERSION_ID < 80000) { 60 | openssl_free_key($publicKey); 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Attestation/AttestationType.php: -------------------------------------------------------------------------------- 1 | format = $format; 33 | $this->statement = $statement; 34 | $this->authData = $authData; 35 | } 36 | 37 | public static function parse(ByteBuffer $buffer): self 38 | { 39 | try { 40 | $data = CborDecoder::decode($buffer); 41 | if (!$data instanceof CborMap) { 42 | throw new WebAuthnException('Expecting attestation object to be a CBOR map.'); 43 | } 44 | 45 | DataValidator::checkMap( 46 | $data, 47 | [ 48 | 'fmt' => 'string', 49 | 'attStmt' => CborMap::class, 50 | 'authData' => ByteBuffer::class, 51 | ] 52 | ); 53 | return new self($data->get('fmt'), $data->get('attStmt'), $data->get('authData')); 54 | } catch (CborException $e) { 55 | throw new ParseException('Failed to parse CBOR attestation object.', 0, $e); 56 | } 57 | } 58 | 59 | public function getFormat(): string 60 | { 61 | return $this->format; 62 | } 63 | 64 | public function getStatement(): CborMap 65 | { 66 | return $this->statement; 67 | } 68 | 69 | public function getAuthenticatorData(): ByteBuffer 70 | { 71 | return $this->authData; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Attestation/Tpm/TpmAttest.php: -------------------------------------------------------------------------------- 1 | getBytes(0, 4); 30 | if ($magic !== self::TPM_GENERATED) { 31 | throw new ParseException('Magic bytes of TPM attestation are not TPM_GENERATED sequence.'); 32 | } 33 | 34 | // Read type 35 | $type = $data->getUint16Val(4); 36 | if ($type !== self::TPM_ST_ATTEST_CERTIFY) { 37 | throw new ParseException(sprintf('Wrong type for TPMS_ATTEST structure, expecting TPM_ST_ATTEST_CERTIFY, not 0x%04Xd.', $type)); 38 | } 39 | //$this->objectAttributes = $data->getUint32Val(6); 40 | 41 | $offset = 6; 42 | 43 | // qualifiedSigner 44 | self::readLengthPrefixed($data, $offset); 45 | 46 | // Extra data 47 | $this->extraData = self::readLengthPrefixed($data, $offset); 48 | 49 | // Clock info 50 | self::readFixed($data, $offset, 17); 51 | 52 | // Firmware version 53 | self::readFixed($data, $offset, 8); 54 | 55 | // Attested name 56 | $this->attName = self::readLengthPrefixed($data, $offset); 57 | 58 | // Attested qualified name 59 | self::readLengthPrefixed($data, $offset); 60 | 61 | if ($offset !== $data->getLength()) { 62 | throw new ParseException('Unexpected bytes after TPMS_ATTEST structure.'); 63 | } 64 | } 65 | 66 | public function getAttName(): ByteBuffer 67 | { 68 | return $this->attName; 69 | } 70 | 71 | public function getExtraData(): ByteBuffer 72 | { 73 | return $this->extraData; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Dom/PublicKeyCredentialUserEntity.php: -------------------------------------------------------------------------------- 1 | setId($id); 33 | $this->displayName = $displayName; 34 | } 35 | 36 | public function getId(): ByteBuffer 37 | { 38 | return $this->id; 39 | } 40 | 41 | private function setId(ByteBuffer $id): void 42 | { 43 | if ($id->isEmpty()) { 44 | throw new WebAuthnException('User handle cannot be empty.'); 45 | } 46 | 47 | if ($id->getLength() > UserHandle::MAX_USER_HANDLE_BYTES) { 48 | throw new WebAuthnException(sprintf('User handle cannot be larger than %d bytes.', UserHandle::MAX_USER_HANDLE_BYTES)); 49 | } 50 | $this->id = $id; 51 | } 52 | 53 | public function getDisplayName(): string 54 | { 55 | return $this->displayName; 56 | } 57 | 58 | public function getAsArray(): array 59 | { 60 | return array_merge( 61 | parent::getAsArray(), 62 | [ 63 | 'id' => $this->id, 64 | 'displayName' => $this->displayName, 65 | ] 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Credential/UserHandle.php: -------------------------------------------------------------------------------- 1 | self::MAX_USER_HANDLE_BYTES) { 24 | throw new WebAuthnException(sprintf('User handle cannot be larger than %d bytes.', self::MAX_USER_HANDLE_BYTES)); 25 | } 26 | if ($rawBytes === '') { 27 | // https://www.w3.org/TR/webauthn-2/#dom-publickeycredentialuserentity-id 28 | throw new WebAuthnException('User handle must not be empty'); 29 | } 30 | parent::__construct($rawBytes); 31 | } 32 | 33 | public static function fromString(string $base64urlString): self 34 | { 35 | return new self(Base64UrlEncoding::decode($base64urlString)); 36 | } 37 | 38 | public static function fromBinary(string $binary): self 39 | { 40 | return new self($binary); 41 | } 42 | 43 | public static function fromHex(string $hex): self 44 | { 45 | return new self(parent::convertHex($hex)); 46 | } 47 | 48 | public static function fromBuffer(ByteBuffer $buffer): self 49 | { 50 | return new self($buffer->getBinaryString()); 51 | } 52 | 53 | public static function random(int $length = self::MAX_USER_HANDLE_BYTES): self 54 | { 55 | try { 56 | return new UserHandle(random_bytes($length)); 57 | } catch (Exception $e) { 58 | throw new NotAvailableException('Cannot generate random bytes for user handle.', 0, $e); 59 | } 60 | } 61 | 62 | public function equals(self $other): bool 63 | { 64 | return hash_equals($this->raw, $other->raw); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Attestation/Statement/AndroidKeyAttestationStatement.php: -------------------------------------------------------------------------------- 1 | getStatement(); 39 | 40 | try { 41 | DataValidator::checkMap( 42 | $statement, 43 | [ 44 | 'alg' => 'integer', 45 | 'x5c' => 'array', 46 | 'sig' => ByteBuffer::class, 47 | ] 48 | ); 49 | } catch (DataValidationException $e) { 50 | throw new ParseException('Invalid Android key attestation statement.', 0, $e); 51 | } 52 | 53 | $this->signature = $statement->get('sig'); 54 | $this->algorithm = $statement->get('alg'); 55 | $this->certificates = $this->buildPEMCertificateArray($statement->get('x5c')); 56 | } 57 | 58 | public function getAlgorithm(): int 59 | { 60 | return $this->algorithm; 61 | } 62 | 63 | public function getSignature(): ByteBuffer 64 | { 65 | return $this->signature; 66 | } 67 | 68 | /** 69 | * @return X509Certificate[] 70 | */ 71 | public function getCertificates(): array 72 | { 73 | return $this->certificates; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Attestation/Android/SafetyNetResponseParser.php: -------------------------------------------------------------------------------- 1 | getCoseKey()); 32 | 33 | $claims = $validator->validate($jwt, $context); 34 | 35 | $nonce = $claims['nonce'] ?? null; 36 | if (!\is_string($nonce)) { 37 | throw new ParseException('Expecting nonce to be a string.'); 38 | } 39 | 40 | $ctsProfileMatch = $claims['ctsProfileMatch'] ?? null; 41 | if (!\is_bool($ctsProfileMatch)) { 42 | throw new ParseException('Expecting ctsProfileMatch to be a boolean.'); 43 | } 44 | 45 | $timetampMs = $claims['timestampMs'] ?? null; 46 | if (!is_int($timetampMs) && !is_float($timetampMs)) { 47 | throw new ParseException('Expecting timeStampMs to be a number.'); 48 | } 49 | 50 | return new SafetyNetResponse($nonce, $x5cParam->getCertificates(), $ctsProfileMatch, $timetampMs); 51 | } catch (\Exception $e) { 52 | throw new ParseException('Failed to parse SafetyNet response.', 0, $e); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Attestation/Identifier/Aaguid.php: -------------------------------------------------------------------------------- 1 | getLength() !== self::AAGUID_LENGTH) { 22 | throw new ParseException(sprintf('AAGUID should be %d bytes, not %d', self::AAGUID_LENGTH, $raw->getLength())); 23 | } 24 | $this->raw = $raw; 25 | } 26 | 27 | public function getType(): string 28 | { 29 | return self::TYPE; 30 | } 31 | 32 | public static function parseString(string $aaguid): self 33 | { 34 | if (!preg_match('~^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$~', $aaguid)) { 35 | throw new ParseException('Invalid AAGUID'); 36 | } 37 | $hex = substr($aaguid, 0, 8) . 38 | substr($aaguid, 9, 4) . 39 | substr($aaguid, 14, 4) . 40 | substr($aaguid, 19, 4) . 41 | substr($aaguid, 24, 12); 42 | 43 | return new Aaguid(ByteBuffer::fromHex($hex)); 44 | } 45 | 46 | public function toString(): string 47 | { 48 | $hex = $this->raw->getHex(); 49 | return sprintf( 50 | '%s-%s-%s-%s-%s', 51 | substr($hex, 0, 8), 52 | substr($hex, 8, 4), 53 | substr($hex, 12, 4), 54 | substr($hex, 16, 4), 55 | substr($hex, 20, 12) 56 | ); 57 | } 58 | 59 | public function getHex(): string 60 | { 61 | return $this->raw->getHex(); 62 | } 63 | 64 | public function isZeroAaguid(): bool 65 | { 66 | // Check if all zero bytes - U2F authenticators use this to indicate they have no AAGUID 67 | return strspn($this->raw->getBinaryString(), "\0") === self::AAGUID_LENGTH; 68 | } 69 | 70 | public function equals(IdentifierInterface $identifier): bool 71 | { 72 | return $identifier instanceof self && $this->raw->equals($identifier->raw); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Attestation/Registry/AttestationFormatRegistry.php: -------------------------------------------------------------------------------- 1 | formats[$format->getFormatId()] = $format; 31 | } 32 | 33 | public function createStatement(AttestationObject $attestationObject): AttestationStatementInterface 34 | { 35 | $formatId = $attestationObject->getFormat(); 36 | $format = $this->formats[$formatId] ?? null; 37 | if ($format === null) { 38 | if ($this->strictSupportedFormats) { 39 | throw new FormatNotSupportedException(sprintf('Format "%s" is not supported.', $formatId)); 40 | } 41 | return new UnsupportedAttestationStatement($attestationObject); 42 | } 43 | return $format->createStatement($attestationObject); 44 | } 45 | 46 | public function getVerifier(string $formatId): AttestationVerifierInterface 47 | { 48 | $format = $this->formats[$formatId] ?? null; 49 | if ($format === null) { 50 | if ($this->strictSupportedFormats) { 51 | throw new FormatNotSupportedException(sprintf('Format "%s" is not supported.', $formatId)); 52 | } 53 | return new UnsupportedAttestationVerifier(); 54 | } 55 | return $format->getVerifier(); 56 | } 57 | 58 | public function strictSupportedFormats(bool $strict): void 59 | { 60 | $this->strictSupportedFormats = $strict; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Policy/Trust/TrustDecisionManager.php: -------------------------------------------------------------------------------- 1 | logger = new NullLogger(); 26 | } 27 | 28 | public function addVoter(TrustVoterInterface $trustVoter): self 29 | { 30 | $this->voters[] = $trustVoter; 31 | return $this; 32 | } 33 | 34 | public function verifyTrust(RegistrationResultInterface $registrationResult, ?MetadataInterface $metadata): void 35 | { 36 | $trusted = false; 37 | $trustPath = $registrationResult->getVerificationResult()->getTrustPath(); 38 | foreach ($this->voters as $voter) { 39 | $vote = $voter->voteOnTrust($registrationResult, $trustPath, $metadata); 40 | if ($vote->isTrusted()) { 41 | $this->logger->debug("Voter {class} voted 'trusted'.", ['class' => get_class($voter)]); 42 | $trusted = true; 43 | } elseif ($vote->isUntrusted()) { 44 | $this->logger->debug("Voter {class} voted 'untrusted'.", ['class' => get_class($voter), 'reason' => $vote->getReason()]); 45 | throw UntrustedException::createWithReason($vote->getReason()); 46 | } elseif ($vote->isAbstain()) { 47 | $this->logger->debug('Voter {class} abstained from voting.', ['class' => get_class($voter)]); 48 | } else { 49 | throw new WebAuthnException('Unsupported vote type.'); 50 | } 51 | } 52 | 53 | if (!$trusted) { 54 | $this->logger->debug('No voter trusted the registration.'); 55 | throw UntrustedException::createWithReason('No voter trusted the registration.'); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Pki/X509Certificate.php: -------------------------------------------------------------------------------- 1 | base64 = $base64; 25 | } 26 | 27 | public function equals(X509Certificate $cert): bool 28 | { 29 | return $this->base64 === $cert->base64; 30 | } 31 | 32 | public static function fromDer(string $der): self 33 | { 34 | return new self(base64_encode($der)); 35 | } 36 | 37 | public static function fromPem(string $pem): self 38 | { 39 | $start = strpos($pem, self::BEGIN_CERTIFICATE); 40 | $end = strpos($pem, self::END_CERTIFICATE); 41 | if ($start === false || $end === false) { 42 | throw new ParseException('Missing certificate PEM armor.'); 43 | } 44 | $start += strlen(self::BEGIN_CERTIFICATE); 45 | $base64 = substr($pem, $start, $end - $start); 46 | $base64 = preg_replace('~\s+~', '', $base64); 47 | return self::fromBase64($base64); 48 | } 49 | 50 | public static function fromBase64(string $base64): self 51 | { 52 | $decoded = base64_decode($base64, true); 53 | if ($decoded === false) { 54 | throw new ParseException('Invalid base64 encoding in PEM certificate.'); 55 | } 56 | return new X509Certificate(base64_encode($decoded)); 57 | } 58 | 59 | public function asDer(): string 60 | { 61 | $binary = base64_decode($this->base64, true); 62 | assert(is_string($binary)); 63 | return $binary; 64 | } 65 | 66 | public function asPem(): string 67 | { 68 | return "-----BEGIN CERTIFICATE-----\n" . 69 | chunk_split($this->base64, 64, "\n") . 70 | "-----END CERTIFICATE-----\n"; 71 | } 72 | 73 | public function __serialize(): array 74 | { 75 | return ['b' => $this->base64]; 76 | } 77 | 78 | public function __unserialize(array $data): void 79 | { 80 | $this->base64 = $data['b']; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Dom/AuthenticatorSelectionCriteria.php: -------------------------------------------------------------------------------- 1 | authenticatorAttachment; 33 | } 34 | 35 | public function setAuthenticatorAttachment(?string $value): void 36 | { 37 | if ($value !== null && !AuthenticatorAttachment::isValidValue($value)) { 38 | throw new InvalidArgumentException(sprintf('Value %s is not a valid AuthenticatorAttachment', $value)); 39 | } 40 | $this->authenticatorAttachment = $value; 41 | } 42 | 43 | public function getRequireResidentKey(): ?bool 44 | { 45 | return $this->requireResidentKey; 46 | } 47 | 48 | public function setRequireResidentKey(?bool $value): void 49 | { 50 | $this->requireResidentKey = $value; 51 | } 52 | 53 | public function getUserVerification(): ?string 54 | { 55 | return $this->userVerification; 56 | } 57 | 58 | public function setUserVerification(?string $value): void 59 | { 60 | if ($value !== null && !UserVerificationRequirement::isValidValue($value)) { 61 | throw new InvalidArgumentException(sprintf('Value %s is not a valid UserVerificationRequirement', $value)); 62 | } 63 | $this->userVerification = $value; 64 | } 65 | 66 | public function getAsArray(): array 67 | { 68 | $map = []; 69 | if ($this->authenticatorAttachment !== null) { 70 | $map['authenticatorAttachment'] = $this->authenticatorAttachment; 71 | } 72 | 73 | if ($this->requireResidentKey !== null) { 74 | $map['requireResidentKey'] = $this->requireResidentKey; 75 | } 76 | if ($this->userVerification !== null) { 77 | $map['userVerification'] = $this->userVerification; 78 | } 79 | return $map; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebAuthn Relying Party server library for PHP 2 | 3 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/madwizard-org/webauthn-server/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/madwizard-org/webauthn-server/?branch=master) 4 | [![Code Coverage](https://scrutinizer-ci.com/g/madwizard-org/webauthn-server/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/madwizard-org/webauthn-server/?branch=master) 5 | [![Build Status](https://scrutinizer-ci.com/g/madwizard-org/webauthn-server/badges/build.png?b=master)](https://scrutinizer-ci.com/g/madwizard-org/webauthn-server/build-status/master) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | 8 | ## Current state 9 | 10 | Pretty stable but the API may still change slightly until the 1.0 release. 11 | 12 | ## Goal 13 | 14 | This library aims to implement the relying party server of the WebAuthn specification in PHP. Important goals are: 15 | 16 | - Implement the level 1 WebAuthn specification 17 | - Good quality, secure and maintainable code 18 | - Easy to use for the end-user 19 | 20 | 21 | ## Installation 22 | 23 | Installation via composer: 24 | ```bash 25 | composer require madwizard/webauthn 26 | ``` 27 | 28 | ## Supported features 29 | 30 | - > PHP 7.2 31 | - FIDO conformant library 32 | - Attestation types: 33 | - FIDO U2F 34 | - Packed 35 | - TPM 36 | - Android SafetyNet 37 | - Android Key 38 | - Apple 39 | - None 40 | - Optional 'unsupported' type to handle future types 41 | - Metadata service support 42 | - Validating metadata 43 | - Extensions: 44 | - appid 45 | 46 | 47 | ## Usage 48 | 49 | The library is still in development so documentation is limited. The general pattern to follow is: 50 | 51 | 1. Implement `CredentialStoreInterface` (you will need `UserCredential` or your own implementation of `UserCredentialInterface`) 52 | 2. Create an instance of `RelyingParty` and use the `ServerBuilder` class to build a server object: 53 | ```php 54 | $server = (new ServerBuilder()) 55 | ->setRelyingParty($rp) 56 | ->setCredentialStore($store) 57 | ->build(); 58 | ``` 59 | 3. Use `startRegistration`/`finishRegistration` to register credentials. Be sure to store the temporary `AttestationContext` server side! 60 | 4. and `startAuthentication`/`finishAuthentication` to authenticate. Be sure to store the temporary `AssertionContext` server side! 61 | 62 | ## Resources 63 | 64 | [WebAuthn specification](https://www.w3.org/TR/webauthn/) 65 | -------------------------------------------------------------------------------- /src/Metadata/Source/BundledSource.php: -------------------------------------------------------------------------------- 1 | false, 14 | // 'yubico-u2f' => true, 15 | ]; 16 | 17 | /** 18 | * @var string[] 19 | * @phpstan-var array 20 | */ 21 | private const PROVIDERS = [ 22 | 'apple' => AppleDevicesProvider::class, 23 | ]; 24 | 25 | /** 26 | * @var array 27 | */ 28 | private $enabledSets; 29 | 30 | public function __construct(array $sets = ['@all']) 31 | { 32 | $this->enabledSets = self::SETS; 33 | foreach ($sets as $set) { 34 | if ($set === '') { 35 | throw new UnexpectedValueException('Empty set name'); 36 | } 37 | 38 | if ($set === '@all') { 39 | $this->enabledSets = array_fill_keys(array_keys(self::SETS), true); 40 | } else { 41 | $add = true; 42 | if ($set[0] === '-') { 43 | $set = substr($set, 1); 44 | $add = false; 45 | } 46 | 47 | if (!isset(self::SETS[$set])) { 48 | throw new UnexpectedValueException(sprintf("Invalid set name '%s'.", $set)); 49 | } 50 | if ($add) { 51 | $this->enabledSets[$set] = true; 52 | } elseif (isset($this->enabledSets[$set])) { 53 | $this->enabledSets[$set] = false; 54 | } 55 | } 56 | } 57 | } 58 | 59 | public function getEnableSets(): array 60 | { 61 | return array_keys(array_filter($this->enabledSets, static function ($value) { return $value; })); 62 | } 63 | 64 | /** 65 | * @return MetadataProviderInterface[] 66 | */ 67 | public function createProviders(): array 68 | { 69 | $providers = []; 70 | foreach ($this->enabledSets as $type => $enabled) { 71 | if ($enabled) { 72 | $className = self::PROVIDERS[$type]; 73 | $providers[] = new $className(); 74 | } 75 | } 76 | return $providers; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Extension/ExtensionResponse.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 37 | } 38 | 39 | public function getIdentifier(): string 40 | { 41 | return $this->identifier; 42 | } 43 | 44 | public function hasClientExtensionOutput(): bool 45 | { 46 | return $this->hasClientExtensionOutput; 47 | } 48 | 49 | public function getClientExtensionOutput() 50 | { 51 | return $this->clientExtensionOutput; 52 | } 53 | 54 | public function hasAuthenticatorExtensionOutput(): bool 55 | { 56 | if (!$this->hasClientExtensionOutput) { 57 | throw new NotAvailableException(sprintf('No client extension output is available for extension "%s".', $this->identifier)); 58 | } 59 | return $this->hasAuthenticatorExtensionOutput; 60 | } 61 | 62 | public function getAuthenticatorExtensionOutput() 63 | { 64 | if (!$this->hasAuthenticatorExtensionOutput) { 65 | throw new NotAvailableException(sprintf('No authenticator extension output is available for extension "%s".', $this->identifier)); 66 | } 67 | return $this->authenticatorExtensionOutput; 68 | } 69 | 70 | /** 71 | * @param mixed $clientExtensionOutput 72 | */ 73 | public function setClientExtensionOutput($clientExtensionOutput): void 74 | { 75 | $this->hasClientExtensionOutput = true; 76 | $this->clientExtensionOutput = $clientExtensionOutput; 77 | } 78 | 79 | /** 80 | * @param mixed $authenticatorExtensionOutput 81 | */ 82 | public function setAuthenticatorExtensionOutput($authenticatorExtensionOutput): void 83 | { 84 | $this->hasAuthenticatorExtensionOutput = true; 85 | $this->authenticatorExtensionOutput = $authenticatorExtensionOutput; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Pki/Jwt/Jwt.php: -------------------------------------------------------------------------------- 1 | header = $header; 48 | $this->body = $body; 49 | $this->signedData = new ByteBuffer($parts[0] . '.' . $parts[1]); 50 | $this->signature = new ByteBuffer($signature); 51 | } 52 | 53 | private static function decodeToken(array $parts): array 54 | { 55 | if (count($parts) !== 3) { 56 | throw new ParseException('Invalid JWT'); 57 | } 58 | 59 | return [ 60 | Base64UrlEncoding::decode($parts[0]), 61 | Base64UrlEncoding::decode($parts[1]), 62 | Base64UrlEncoding::decode($parts[2]), 63 | ]; 64 | } 65 | 66 | private static function jsonDecode(string $json): array 67 | { 68 | $decoded = json_decode($json, true); 69 | if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { 70 | throw new ParseException(sprintf('JSON parse error in JWT: %s.', json_last_error_msg())); 71 | } 72 | return $decoded; 73 | } 74 | 75 | public function getHeader(): array 76 | { 77 | return $this->header; 78 | } 79 | 80 | public function getBody(): array 81 | { 82 | return $this->body; 83 | } 84 | 85 | public function getSignedData(): ByteBuffer 86 | { 87 | return $this->signedData; 88 | } 89 | 90 | public function getSignature(): ByteBuffer 91 | { 92 | return $this->signature; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Crypto/Der.php: -------------------------------------------------------------------------------- 1 | 0) { 15 | $lenBytes = \chr($len % 256) . $lenBytes; 16 | $len = \intdiv($len, 256); 17 | } 18 | return \chr(0x80 | \strlen($lenBytes)) . $lenBytes; 19 | } 20 | 21 | public static function sequence(string $contents): string 22 | { 23 | return "\x30" . self::length(\strlen($contents)) . $contents; 24 | } 25 | 26 | public static function oid(string $encoded): string 27 | { 28 | return "\x06" . self::length(\strlen($encoded)) . $encoded; 29 | } 30 | 31 | public static function unsignedInteger(string $bytes): string 32 | { 33 | $len = \strlen($bytes); 34 | 35 | // Remove leading zero bytes 36 | for ($i = 0; $i < ($len - 1); $i++) { 37 | if (\ord($bytes[$i]) !== 0) { 38 | break; 39 | } 40 | } 41 | if ($i !== 0) { 42 | $bytes = \substr($bytes, $i); 43 | } 44 | 45 | // If most significant bit is set, prefix with another zero to prevent it being seen as negative number 46 | if ((\ord($bytes[0]) & 0x80) !== 0) { 47 | $bytes = "\x00" . $bytes; 48 | } 49 | 50 | return "\x02" . self::length(\strlen($bytes)) . $bytes; 51 | } 52 | 53 | public static function bitString(string $bytes): string 54 | { 55 | $len = \strlen($bytes) + 1; 56 | 57 | return "\x03" . self::length($len) . "\x00" . $bytes; 58 | } 59 | 60 | public static function octetString(string $bytes): string 61 | { 62 | $len = \strlen($bytes); 63 | 64 | return "\x04" . self::length($len) . $bytes; 65 | } 66 | 67 | public static function contextTag(int $tag, bool $constructed, string $content): string 68 | { 69 | return \chr(($tag & 0x1F) | // Context specific tag number 70 | (1 << 7) | // Context-specific flag 71 | ($constructed ? (1 << 5) : 0)) . 72 | self::length(\strlen($content)) . 73 | $content; 74 | } 75 | 76 | public static function nullValue(): string 77 | { 78 | return "\x05\x00"; 79 | } 80 | 81 | public static function pem(string $type, string $der): string 82 | { 83 | return sprintf("-----BEGIN %s-----\n", strtoupper($type)) . 84 | chunk_split(base64_encode($der), 64, "\n") . 85 | sprintf("-----END %s-----\n", strtoupper($type)); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Attestation/Statement/PackedAttestationStatement.php: -------------------------------------------------------------------------------- 1 | getStatement(); 41 | 42 | try { 43 | DataValidator::checkMap( 44 | $statement, 45 | [ 46 | 'alg' => 'integer', 47 | 'sig' => ByteBuffer::class, 48 | 'x5c' => '?array', 49 | 'ecdaaKeyId' => '?' . ByteBuffer::class, 50 | ] 51 | ); 52 | } catch (DataValidationException $e) { 53 | throw new ParseException('Invalid packed attestation statement.', 0, $e); 54 | } 55 | 56 | $this->algorithm = $statement->get('alg'); 57 | $this->signature = $statement->get('sig'); 58 | 59 | $this->ecdaaKeyId = $statement->getDefault('ecdaaKeyId', null); 60 | $x5c = $statement->getDefault('x5c', null); 61 | 62 | if ($this->ecdaaKeyId !== null && $x5c !== null) { 63 | throw new ParseException('ecdaaKeyId and x5c cannot both be set.'); 64 | } 65 | $this->certificates = $x5c === null ? null : $this->buildPEMCertificateArray($x5c); 66 | } 67 | 68 | public function getSignature(): ByteBuffer 69 | { 70 | return $this->signature; 71 | } 72 | 73 | public function getAlgorithm(): int 74 | { 75 | return $this->algorithm; 76 | } 77 | 78 | /** 79 | * @return X509Certificate[]|null 80 | */ 81 | public function getCertificates(): ?array 82 | { 83 | return $this->certificates; 84 | } 85 | 86 | public function getEcdaaKeyId(): ?ByteBuffer 87 | { 88 | return $this->ecdaaKeyId; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Attestation/TrustAnchor/TrustPathValidator.php: -------------------------------------------------------------------------------- 1 | chainValidator = $chainValidator; 21 | } 22 | 23 | public function validate(TrustPathInterface $trustPath, TrustAnchorInterface $trustAnchor): bool 24 | { 25 | if ($trustAnchor instanceof CertificateTrustAnchor && $trustPath instanceof CertificateTrustPath) { 26 | // WebAauthn SPEC (v2): 27 | // Use the X.509 certificates returned as the attestation trust path from the verification procedure 28 | // to verify that the attestation public key either correctly chains up to an acceptable root certificate, 29 | // or is itself an acceptable certificate 30 | // (i.e., it and the root certificate obtained in Step 20 may be the same). 31 | 32 | $trustAnchorCert = $trustAnchor->getCertificate(); 33 | $trustPathCerts = $trustPath->getCertificates(); 34 | 35 | // Check if trust path is trust anchor itself 36 | if (count($trustPathCerts) === 1 && $trustPathCerts[0]->equals($trustAnchorCert)) { 37 | return true; 38 | } 39 | 40 | $chain = array_merge([$trustAnchorCert], array_reverse($trustPath->getCertificates())); 41 | 42 | // RFC5280 6.1: "A certificate MUST NOT appear more than once in a prospective certification path." 43 | // https://github.com/fido-alliance/conformance-test-tools-resources/issues/605 44 | if ($this->containsDuplicates(...$chain)) { 45 | return false; 46 | } 47 | 48 | if ($this->chainValidator->validateChain(...$chain)) { 49 | return true; 50 | } 51 | } 52 | return false; 53 | } 54 | 55 | private function containsDuplicates(X509Certificate ...$chain): bool 56 | { 57 | $map = []; 58 | foreach ($chain as $cert) { 59 | $pem = $cert->asPem(); 60 | if (isset($map[$pem])) { 61 | return true; 62 | } 63 | $map[$pem] = true; 64 | } 65 | return false; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Pki/ChainValidator.php: -------------------------------------------------------------------------------- 1 | statusResolver = $statusResolver; 30 | $this->logger = new NullLogger(); 31 | } 32 | 33 | private function getReferenceDate(): DateTimeImmutable 34 | { 35 | return new DateTimeImmutable(); 36 | } 37 | 38 | private function validateCertificates(X509Certificate ...$certificates): bool 39 | { 40 | try { 41 | $pathCerts = array_map(function (X509Certificate $c) { 42 | return Certificate::fromDER($c->asDer()); 43 | }, $certificates); 44 | $path = new CertificationPath(...$pathCerts); 45 | $config = new PathValidationConfig($this->getReferenceDate(), self::MAX_VALIDATION_LENGTH); 46 | } catch (Exception $e) { 47 | throw new VerificationException(sprintf('Failed to validate certificate: %s', $e->getMessage()), 0, $e); 48 | } 49 | try { 50 | $path->validate($config); 51 | return true; 52 | } catch (PathValidationException $e) { 53 | $this->logger->debug(sprintf('Path validation of certificate failed: %s', $e->getMessage())); 54 | return false; 55 | } catch (Exception $e) { 56 | throw new VerificationException(sprintf('Failed to validate certificate: %s', $e->getMessage()), 0, $e); 57 | } 58 | } 59 | 60 | public function validateChain(X509Certificate ...$certificates): bool 61 | { 62 | if ($this->validateCertificates(...$certificates)) { 63 | $numCerts = count($certificates); 64 | for ($i = 1; $i < $numCerts; $i++) { 65 | if ($this->statusResolver->isRevoked($certificates[$i], ...array_slice($certificates, 0, $i))) { 66 | return false; 67 | } 68 | } 69 | return true; 70 | } 71 | return false; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Pki/Jwt/JwtValidator.php: -------------------------------------------------------------------------------- 1 | ['convert' => true, 'sigComponentLen' => 32], 15 | 'ES384' => ['convert' => true, 'sigComponentLen' => 48], 16 | 'ES512' => ['convert' => true, 'sigComponentLen' => 66], 17 | 'RS256' => ['convert' => false], 18 | 'RS384' => ['convert' => false], 19 | 'RS512' => ['convert' => false], 20 | ]; 21 | 22 | public function __construct() 23 | { 24 | } 25 | 26 | public function validate(JwtInterface $token, ValidationContext $context): array 27 | { 28 | // TODO: validate other header items 29 | $header = $token->getHeader(); 30 | $alg = $this->validateAlgorithm($header, $context); 31 | 32 | $asn1Sig = $this->convertSignature($token->getSignature(), $alg); 33 | if (!$context->getKey()->verifySignature($token->getSignedData(), $asn1Sig)) { 34 | throw new VerificationException('Invalid signature.'); 35 | } 36 | /* TODO 37 | $now = $context->getReferenceUnixTime(); 38 | 39 | $exp = $header['exp'] ?? null; 40 | if ($exp !== null) { 41 | if (!is_int($exp)) { 42 | throw new VerificationException('Invalid "exp" header value.'); 43 | } 44 | } 45 | */ 46 | return $token->getBody(); 47 | } 48 | 49 | private function convertSignature(ByteBuffer $signature, string $algorithm): ByteBuffer 50 | { 51 | $algInfo = self::ALG_INFO[$algorithm]; 52 | if (!$algInfo['convert']) { 53 | return $signature; 54 | } 55 | $componentLen = $algInfo['sigComponentLen']; 56 | if ($signature->getLength() !== ($componentLen * 2)) { 57 | throw new ParseException(sprintf('Invalid signature length %d.', $signature->getLength())); 58 | } 59 | $r = $signature->getBytes(0, $componentLen); 60 | $s = $signature->getBytes($componentLen, $componentLen); 61 | return new ByteBuffer(Der::sequence(Der::unsignedInteger($r) . Der::unsignedInteger($s))); 62 | } 63 | 64 | private function validateAlgorithm(array $header, ValidationContext $ctx): string 65 | { 66 | $alg = $header['alg'] ?? null; 67 | if (in_array($alg, $ctx->getAllowedAlgorithms(), true)) { 68 | return $alg; 69 | } 70 | throw new VerificationException('Algorithm not allowed.'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Builder/ServiceContainer.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class ServiceContainer implements ArrayAccess 15 | { 16 | /** 17 | * @phpstan-var array 18 | * 19 | * @var object[] 20 | */ 21 | private $serviceMap = []; 22 | 23 | /** 24 | * @phpstan-var array 25 | * 26 | * @var callable[] 27 | */ 28 | private $instantiators = []; 29 | 30 | /** 31 | * @param string $offset 32 | * @param string $offset 33 | * @phpstan-param class-string $offset 34 | * 35 | * @return bool 36 | */ 37 | public function offsetExists($offset): bool 38 | { 39 | return isset($this->serviceMap[$offset]) || isset($this->instantiators[$offset]); 40 | } 41 | 42 | /** 43 | * @template T of object 44 | * 45 | * @param string $service 46 | * @phpstan-param class-string $service 47 | * @phpstan-return T 48 | */ 49 | public function offsetGet($service): object 50 | { 51 | /** 52 | * @phpstan-var T 53 | */ 54 | $service = ($this->serviceMap[$service] ?? $this->instantiate($service)); 55 | return $service; 56 | } 57 | 58 | /** 59 | * @param string $offset 60 | */ 61 | #[\ReturnTypeWillChange] 62 | public function offsetUnset($offset) 63 | { 64 | throw new RuntimeException('Unset operation is not supported.'); 65 | } 66 | 67 | /** 68 | * @param string $offset 69 | * @param callable $value 70 | * @phpstan-param class-string $offset 71 | * @phpstan-param callable(self): object $value 72 | */ 73 | public function offsetSet($offset, $value): void 74 | { 75 | $this->instantiators[$offset] = $value; 76 | } 77 | 78 | /** 79 | * @template T of object 80 | * @phpstan-param class-string $offset 81 | * @phpstan-return T of object 82 | */ 83 | private function instantiate(string $offset): object 84 | { 85 | $instantiator = $this->instantiators[$offset] ?? null; 86 | if ($instantiator === null) { 87 | throw new WebAuthnException(sprintf('Missing service %s.', $offset)); 88 | } 89 | /** 90 | * @phpstan-var T 91 | */ 92 | $service = $instantiator($this); 93 | $this->serviceMap[$offset] = $service; 94 | 95 | if ($service instanceof LoggerAwareInterface) { 96 | $service->setLogger($this[LoggerInterface::class]); 97 | } 98 | // Free instantatior resources: 99 | unset($this->instantiators[$offset]); 100 | return $service; 101 | } 102 | } 103 | --------------------------------------------------------------------------------