first commit

This commit is contained in:
2025-06-17 11:53:18 +02:00
commit 9f0f7ba12b
8804 changed files with 1369176 additions and 0 deletions

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-2022 Spomky-Labs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use CBOR\Decoder;
use CBOR\Normalizable;
use Cose\Algorithms;
use Cose\Key\Ec2Key;
use Cose\Key\Key;
use Cose\Key\RsaKey;
use function count;
use function is_array;
use function openssl_pkey_get_public;
use function openssl_verify;
use Psr\EventDispatcher\EventDispatcherInterface;
use SpomkyLabs\Pki\ASN1\Type\Constructed\Sequence;
use SpomkyLabs\Pki\ASN1\Type\Primitive\OctetString;
use SpomkyLabs\Pki\ASN1\Type\Tagged\ExplicitTagging;
use Webauthn\AuthenticatorData;
use Webauthn\Event\AttestationStatementLoaded;
use Webauthn\Exception\AttestationStatementLoadingException;
use Webauthn\Exception\AttestationStatementVerificationException;
use Webauthn\Exception\InvalidAttestationStatementException;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
final class AndroidKeyAttestationStatementSupport implements AttestationStatementSupport, CanDispatchEvents
{
private readonly Decoder $decoder;
private EventDispatcherInterface $dispatcher;
public function __construct()
{
$this->decoder = Decoder::create();
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(): self
{
return new self();
}
public function name(): string
{
return 'android-key';
}
/**
* {@inheritDoc}
* @param array<string, mixed> $attestation
*/
public function load(array $attestation): AttestationStatement
{
array_key_exists('attStmt', $attestation) || throw AttestationStatementLoadingException::create($attestation);
foreach (['sig', 'x5c', 'alg'] as $key) {
array_key_exists($key, $attestation['attStmt']) || throw AttestationStatementLoadingException::create(
$attestation,
sprintf('The attestation statement value "%s" is missing.', $key)
);
}
$certificates = $attestation['attStmt']['x5c'];
(is_countable($certificates) ? count(
$certificates
) : 0) > 0 || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "x5c" must be a list with at least one certificate.'
);
$certificates = CertificateToolbox::convertAllDERToPEM($certificates);
$attestationStatement = AttestationStatement::createBasic(
$attestation['fmt'],
$attestation['attStmt'],
new CertificateTrustPath($certificates)
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
/**
* {@inheritDoc}
*/
public function isValid(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
$trustPath = $attestationStatement->getTrustPath();
$trustPath instanceof CertificateTrustPath || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid trust path. Shall contain certificates.'
);
$certificates = $trustPath->getCertificates();
//Decode leaf attestation certificate
$leaf = $certificates[0];
$this->checkCertificate($leaf, $clientDataJSONHash, $authenticatorData);
$signedData = $authenticatorData->getAuthData() . $clientDataJSONHash;
$alg = $attestationStatement->get('alg');
return openssl_verify(
$signedData,
$attestationStatement->get('sig'),
$leaf,
Algorithms::getOpensslAlgorithmFor((int) $alg)
) === 1;
}
private function checkCertificate(
string $certificate,
string $clientDataHash,
AuthenticatorData $authenticatorData
): void {
$resource = openssl_pkey_get_public($certificate);
$details = openssl_pkey_get_details($resource);
is_array($details) || throw AttestationStatementVerificationException::create(
'Unable to read the certificate'
);
//Check that authData publicKey matches the public key in the attestation certificate
$attestedCredentialData = $authenticatorData->getAttestedCredentialData();
$attestedCredentialData !== null || throw AttestationStatementVerificationException::create(
'No attested credential data found'
);
$publicKeyData = $attestedCredentialData->getCredentialPublicKey();
$publicKeyData !== null || throw AttestationStatementVerificationException::create(
'No attested public key found'
);
$publicDataStream = new StringStream($publicKeyData);
$coseKey = $this->decoder->decode($publicDataStream);
$coseKey instanceof Normalizable || throw AttestationStatementVerificationException::create(
'Invalid attested public key found'
);
$publicDataStream->isEOF() || throw AttestationStatementVerificationException::create(
'Invalid public key data. Presence of extra bytes.'
);
$publicDataStream->close();
$publicKey = Key::createFromData($coseKey->normalize());
($publicKey instanceof Ec2Key) || ($publicKey instanceof RsaKey) || throw AttestationStatementVerificationException::create(
'Unsupported key type'
);
$publicKey->asPEM() === $details['key'] || throw AttestationStatementVerificationException::create(
'Invalid key'
);
/*---------------------------*/
$certDetails = openssl_x509_parse($certificate);
//Find Android KeyStore Extension with OID "1.3.6.1.4.1.11129.2.1.17" in certificate extensions
is_array(
$certDetails
) || throw AttestationStatementVerificationException::create('The certificate is not valid');
array_key_exists('extensions', $certDetails) || throw AttestationStatementVerificationException::create(
'The certificate has no extension'
);
is_array($certDetails['extensions']) || throw AttestationStatementVerificationException::create(
'The certificate has no extension'
);
array_key_exists(
'1.3.6.1.4.1.11129.2.1.17',
$certDetails['extensions']
) || throw AttestationStatementVerificationException::create(
'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is missing'
);
$extension = $certDetails['extensions']['1.3.6.1.4.1.11129.2.1.17'];
$extensionAsAsn1 = Sequence::fromDER($extension);
$extensionAsAsn1->has(4);
//Check that attestationChallenge is set to the clientDataHash.
$extensionAsAsn1->has(4) || throw AttestationStatementVerificationException::create(
'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid'
);
$ext = $extensionAsAsn1->at(4)
->asElement();
$ext instanceof OctetString || throw AttestationStatementVerificationException::create(
'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid'
);
$clientDataHash === $ext->string() || throw AttestationStatementVerificationException::create(
'The client data hash is not valid'
);
//Check that both teeEnforced and softwareEnforced structures don't contain allApplications(600) tag.
$extensionAsAsn1->has(6) || throw AttestationStatementVerificationException::create(
'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid'
);
$softwareEnforcedFlags = $extensionAsAsn1->at(6)
->asElement();
$softwareEnforcedFlags instanceof Sequence || throw AttestationStatementVerificationException::create(
'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid'
);
$this->checkAbsenceOfAllApplicationsTag($softwareEnforcedFlags);
$extensionAsAsn1->has(7) || throw AttestationStatementVerificationException::create(
'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid'
);
$teeEnforcedFlags = $extensionAsAsn1->at(7)
->asElement();
$teeEnforcedFlags instanceof Sequence || throw AttestationStatementVerificationException::create(
'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid'
);
$this->checkAbsenceOfAllApplicationsTag($teeEnforcedFlags);
}
private function checkAbsenceOfAllApplicationsTag(Sequence $sequence): void
{
foreach ($sequence->elements() as $tag) {
$tag->asElement() instanceof ExplicitTagging || throw AttestationStatementVerificationException::create(
'Invalid tag'
);
$tag->asElement()
->tag() !== 600 || throw AttestationStatementVerificationException::create('Forbidden tag 600 found');
}
}
}

View File

@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use function count;
use function is_array;
use function is_int;
use function is_string;
use Jose\Component\Core\Algorithm as AlgorithmInterface;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\EdDSA;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\ES384;
use Jose\Component\Signature\Algorithm\ES512;
use Jose\Component\Signature\Algorithm\PS256;
use Jose\Component\Signature\Algorithm\PS384;
use Jose\Component\Signature\Algorithm\PS512;
use Jose\Component\Signature\Algorithm\RS256;
use Jose\Component\Signature\Algorithm\RS384;
use Jose\Component\Signature\Algorithm\RS512;
use Jose\Component\Signature\JWS;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use const JSON_THROW_ON_ERROR;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Webauthn\AuthenticatorData;
use Webauthn\Event\AttestationStatementLoaded;
use Webauthn\Exception\AttestationStatementLoadingException;
use Webauthn\Exception\AttestationStatementVerificationException;
use Webauthn\Exception\InvalidAttestationStatementException;
use Webauthn\Exception\UnsupportedFeatureException;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\TrustPath\CertificateTrustPath;
final class AndroidSafetyNetAttestationStatementSupport implements AttestationStatementSupport, CanDispatchEvents
{
private ?string $apiKey = null;
private ?ClientInterface $client = null;
private readonly CompactSerializer $jwsSerializer;
private ?JWSVerifier $jwsVerifier = null;
private ?RequestFactoryInterface $requestFactory = null;
private int $leeway = 0;
private int $maxAge = 60000;
private EventDispatcherInterface $dispatcher;
public function __construct()
{
if (! class_exists(RS256::class)) {
throw UnsupportedFeatureException::create(
'The algorithm RS256 is missing. Did you forget to install the package web-token/jwt-signature-algorithm-rsa?'
);
}
if (! class_exists(JWKFactory::class)) {
throw UnsupportedFeatureException::create(
'The class Jose\Component\KeyManagement\JWKFactory is missing. Did you forget to install the package web-token/jwt-key-mgmt?'
);
}
$this->jwsSerializer = new CompactSerializer();
$this->initJwsVerifier();
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(): self
{
return new self();
}
public function enableApiVerification(
ClientInterface $client,
string $apiKey,
RequestFactoryInterface $requestFactory
): self {
$this->apiKey = $apiKey;
$this->client = $client;
$this->requestFactory = $requestFactory;
return $this;
}
public function setMaxAge(int $maxAge): self
{
$this->maxAge = $maxAge;
return $this;
}
public function setLeeway(int $leeway): self
{
$this->leeway = $leeway;
return $this;
}
public function name(): string
{
return 'android-safetynet';
}
/**
* @param array<string, mixed> $attestation
*/
public function load(array $attestation): AttestationStatement
{
array_key_exists('attStmt', $attestation) || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attestation object'
);
foreach (['ver', 'response'] as $key) {
array_key_exists($key, $attestation['attStmt']) || throw AttestationStatementLoadingException::create(
$attestation,
sprintf('The attestation statement value "%s" is missing.', $key)
);
$attestation['attStmt'][$key] !== '' || throw AttestationStatementLoadingException::create(
$attestation,
sprintf('The attestation statement value "%s" is empty.', $key)
);
}
$jws = $this->jwsSerializer->unserialize($attestation['attStmt']['response']);
$jwsHeader = $jws->getSignature(0)
->getProtectedHeader();
array_key_exists('x5c', $jwsHeader) || throw AttestationStatementLoadingException::create(
$attestation,
'The response in the attestation statement must contain a "x5c" header.'
);
(is_countable($jwsHeader['x5c']) ? count(
$jwsHeader['x5c']
) : 0) > 0 || throw AttestationStatementLoadingException::create(
$attestation,
'The "x5c" parameter in the attestation statement response must contain at least one certificate.'
);
$certificates = $this->convertCertificatesToPem($jwsHeader['x5c']);
$attestation['attStmt']['jws'] = $jws;
$attestationStatement = AttestationStatement::createBasic(
$this->name(),
$attestation['attStmt'],
new CertificateTrustPath($certificates)
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
public function isValid(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
$trustPath = $attestationStatement->getTrustPath();
$trustPath instanceof CertificateTrustPath || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid trust path'
);
$certificates = $trustPath->getCertificates();
$firstCertificate = current($certificates);
is_string($firstCertificate) || throw InvalidAttestationStatementException::create(
$attestationStatement,
'No certificate'
);
$parsedCertificate = openssl_x509_parse($firstCertificate);
is_array($parsedCertificate) || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid attestation object'
);
array_key_exists('subject', $parsedCertificate) || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid attestation object'
);
array_key_exists('CN', $parsedCertificate['subject']) || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid attestation object'
);
$parsedCertificate['subject']['CN'] === 'attest.android.com' || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid attestation object'
);
/** @var JWS $jws */
$jws = $attestationStatement->get('jws');
$payload = $jws->getPayload();
$this->validatePayload($payload, $clientDataJSONHash, $authenticatorData);
//Check the signature
$this->validateSignature($jws, $trustPath);
//Check against Google service
$this->validateUsingGoogleApi($attestationStatement);
return true;
}
private function validatePayload(
?string $payload,
string $clientDataJSONHash,
AuthenticatorData $authenticatorData
): void {
$payload !== null || throw AttestationStatementVerificationException::create('Invalid attestation object');
$payload = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
array_key_exists('nonce', $payload) || throw AttestationStatementVerificationException::create(
'Invalid attestation object. "nonce" is missing.'
);
$payload['nonce'] === base64_encode(
hash('sha256', $authenticatorData->getAuthData() . $clientDataJSONHash, true)
) || throw AttestationStatementVerificationException::create('Invalid attestation object. Invalid nonce');
array_key_exists('ctsProfileMatch', $payload) || throw AttestationStatementVerificationException::create(
'Invalid attestation object. "ctsProfileMatch" is missing.'
);
$payload['ctsProfileMatch'] || throw AttestationStatementVerificationException::create(
'Invalid attestation object. "ctsProfileMatch" value is false.'
);
array_key_exists('timestampMs', $payload) || throw AttestationStatementVerificationException::create(
'Invalid attestation object. Timestamp is missing.'
);
is_int($payload['timestampMs']) || throw AttestationStatementVerificationException::create(
'Invalid attestation object. Timestamp shall be an integer.'
);
$currentTime = time() * 1000;
$payload['timestampMs'] <= $currentTime + $this->leeway || throw AttestationStatementVerificationException::create(
sprintf(
'Invalid attestation object. Issued in the future. Current time: %d. Response time: %d',
$currentTime,
$payload['timestampMs']
)
);
$currentTime - $payload['timestampMs'] <= $this->maxAge || throw AttestationStatementVerificationException::create(
sprintf(
'Invalid attestation object. Too old. Current time: %d. Response time: %d',
$currentTime,
$payload['timestampMs']
)
);
}
private function validateSignature(JWS $jws, CertificateTrustPath $trustPath): void
{
$jwk = JWKFactory::createFromCertificate($trustPath->getCertificates()[0]);
$isValid = $this->jwsVerifier?->verifyWithKey($jws, $jwk, 0);
$isValid === true || throw AttestationStatementVerificationException::create('Invalid response signature');
}
private function validateUsingGoogleApi(AttestationStatement $attestationStatement): void
{
if ($this->client === null || $this->apiKey === null || $this->requestFactory === null) {
return;
}
$uri = sprintf(
'https://www.googleapis.com/androidcheck/v1/attestations/verify?key=%s',
urlencode($this->apiKey)
);
$requestBody = sprintf('{"signedAttestation":"%s"}', $attestationStatement->get('response'));
$request = $this->requestFactory->createRequest('POST', $uri);
$request = $request->withHeader('content-type', 'application/json');
$request->getBody()
->write($requestBody);
$response = $this->client->sendRequest($request);
$this->checkGoogleApiResponse($response);
$responseBody = $this->getResponseBody($response);
$responseBodyJson = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
array_key_exists(
'isValidSignature',
$responseBodyJson
) || throw AttestationStatementVerificationException::create('Invalid response.');
$responseBodyJson['isValidSignature'] === true || throw AttestationStatementVerificationException::create(
'Invalid response.'
);
}
private function getResponseBody(ResponseInterface $response): string
{
$responseBody = '';
$response->getBody()
->rewind();
do {
$tmp = $response->getBody()
->read(1024);
if ($tmp === '') {
break;
}
$responseBody .= $tmp;
} while (true);
return $responseBody;
}
private function checkGoogleApiResponse(ResponseInterface $response): void
{
$response->getStatusCode() === 200 || throw AttestationStatementVerificationException::create(
'Request did not succeeded'
);
$response->hasHeader('content-type') || throw AttestationStatementVerificationException::create(
'Unrecognized response'
);
foreach ($response->getHeader('content-type') as $header) {
if (mb_strpos($header, 'application/json') === 0) {
return;
}
}
throw AttestationStatementVerificationException::create('Unrecognized response');
}
/**
* @param string[] $certificates
*
* @return string[]
*/
private function convertCertificatesToPem(array $certificates): array
{
foreach ($certificates as $k => $v) {
$certificates[$k] = CertificateToolbox::fixPEMStructure($v);
}
return $certificates;
}
private function initJwsVerifier(): void
{
$algorithmClasses = [
RS256::class, RS384::class, RS512::class,
PS256::class, PS384::class, PS512::class,
ES256::class, ES384::class, ES512::class,
EdDSA::class,
];
/** @var AlgorithmInterface[] $algorithms */
$algorithms = [];
foreach ($algorithmClasses as $algorithm) {
if (class_exists($algorithm)) {
/** @var AlgorithmInterface $algorithm */
$algorithms[] = new $algorithm();
}
}
$algorithmManager = new AlgorithmManager($algorithms);
$this->jwsVerifier = new JWSVerifier($algorithmManager);
}
}

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use CBOR\Decoder;
use CBOR\Normalizable;
use Cose\Key\Ec2Key;
use Cose\Key\Key;
use Cose\Key\RsaKey;
use function count;
use function is_array;
use function openssl_pkey_get_public;
use Psr\EventDispatcher\EventDispatcherInterface;
use Webauthn\AuthenticatorData;
use Webauthn\Event\AttestationStatementLoaded;
use Webauthn\Exception\AttestationStatementLoadingException;
use Webauthn\Exception\AttestationStatementVerificationException;
use Webauthn\Exception\InvalidAttestationStatementException;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
final class AppleAttestationStatementSupport implements AttestationStatementSupport, CanDispatchEvents
{
private readonly Decoder $decoder;
private EventDispatcherInterface $dispatcher;
public function __construct()
{
$this->decoder = Decoder::create();
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(): self
{
return new self();
}
public function name(): string
{
return 'apple';
}
/**
* @param array<string, mixed> $attestation
*/
public function load(array $attestation): AttestationStatement
{
array_key_exists('attStmt', $attestation) || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attestation object'
);
array_key_exists('x5c', $attestation['attStmt']) || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "x5c" is missing.'
);
$certificates = $attestation['attStmt']['x5c'];
(is_countable($certificates) ? count(
$certificates
) : 0) > 0 || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "x5c" must be a list with at least one certificate.'
);
$certificates = CertificateToolbox::convertAllDERToPEM($certificates);
$attestationStatement = AttestationStatement::createAnonymizationCA(
$attestation['fmt'],
$attestation['attStmt'],
new CertificateTrustPath($certificates)
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
public function isValid(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
$trustPath = $attestationStatement->getTrustPath();
$trustPath instanceof CertificateTrustPath || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid trust path'
);
$certificates = $trustPath->getCertificates();
//Decode leaf attestation certificate
$leaf = $certificates[0];
$this->checkCertificateAndGetPublicKey($leaf, $clientDataJSONHash, $authenticatorData);
return true;
}
private function checkCertificateAndGetPublicKey(
string $certificate,
string $clientDataHash,
AuthenticatorData $authenticatorData
): void {
$resource = openssl_pkey_get_public($certificate);
$details = openssl_pkey_get_details($resource);
is_array($details) || throw AttestationStatementVerificationException::create(
'Unable to read the certificate'
);
//Check that authData publicKey matches the public key in the attestation certificate
$attestedCredentialData = $authenticatorData->getAttestedCredentialData();
$attestedCredentialData !== null || throw AttestationStatementVerificationException::create(
'No attested credential data found'
);
$publicKeyData = $attestedCredentialData->getCredentialPublicKey();
$publicKeyData !== null || throw AttestationStatementVerificationException::create(
'No attested public key found'
);
$publicDataStream = new StringStream($publicKeyData);
$coseKey = $this->decoder->decode($publicDataStream);
$coseKey instanceof Normalizable || throw AttestationStatementVerificationException::create(
'Invalid attested public key found'
);
$publicDataStream->isEOF() || throw AttestationStatementVerificationException::create(
'Invalid public key data. Presence of extra bytes.'
);
$publicDataStream->close();
$publicKey = Key::createFromData($coseKey->normalize());
($publicKey instanceof Ec2Key) || ($publicKey instanceof RsaKey) || throw AttestationStatementVerificationException::create(
'Unsupported key type'
);
//We check the attested key corresponds to the key in the certificate
$publicKey->asPEM() === $details['key'] || throw AttestationStatementVerificationException::create(
'Invalid key'
);
/*---------------------------*/
$certDetails = openssl_x509_parse($certificate);
//Find Apple Extension with OID "1.2.840.113635.100.8.2" in certificate extensions
is_array(
$certDetails
) || throw AttestationStatementVerificationException::create('The certificate is not valid');
array_key_exists('extensions', $certDetails) || throw AttestationStatementVerificationException::create(
'The certificate has no extension'
);
is_array($certDetails['extensions']) || throw AttestationStatementVerificationException::create(
'The certificate has no extension'
);
array_key_exists(
'1.2.840.113635.100.8.2',
$certDetails['extensions']
) || throw AttestationStatementVerificationException::create(
'The certificate extension "1.2.840.113635.100.8.2" is missing'
);
$extension = $certDetails['extensions']['1.2.840.113635.100.8.2'];
$nonceToHash = $authenticatorData->getAuthData() . $clientDataHash;
$nonce = hash('sha256', $nonceToHash);
//'3024a1220420' corresponds to the Sequence+Explicitly Tagged Object + Octet Object
'3024a1220420' . $nonce === bin2hex(
(string) $extension
) || throw AttestationStatementVerificationException::create('The client data hash is not valid');
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use Webauthn\AuthenticatorData;
use Webauthn\MetadataService\Statement\MetadataStatement;
class AttestationObject
{
private ?MetadataStatement $metadataStatement = null;
public function __construct(
private readonly string $rawAttestationObject,
private AttestationStatement $attStmt,
private readonly AuthenticatorData $authData
) {
}
public function getRawAttestationObject(): string
{
return $this->rawAttestationObject;
}
public function getAttStmt(): AttestationStatement
{
return $this->attStmt;
}
public function setAttStmt(AttestationStatement $attStmt): void
{
$this->attStmt = $attStmt;
}
public function getAuthData(): AuthenticatorData
{
return $this->authData;
}
public function getMetadataStatement(): ?MetadataStatement
{
return $this->metadataStatement;
}
public function setMetadataStatement(MetadataStatement $metadataStatement): self
{
$this->metadataStatement = $metadataStatement;
return $this;
}
}

View File

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use CBOR\Decoder;
use CBOR\MapObject;
use CBOR\Normalizable;
use function is_array;
use function ord;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid;
use Throwable;
use function unpack;
use Webauthn\AttestedCredentialData;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputsLoader;
use Webauthn\AuthenticatorData;
use Webauthn\Event\AttestationObjectLoaded;
use Webauthn\Exception\InvalidDataException;
use Webauthn\MetadataService\CanLogData;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\StringStream;
use Webauthn\Util\Base64;
class AttestationObjectLoader implements CanDispatchEvents, CanLogData
{
private const FLAG_AT = 0b01000000;
private const FLAG_ED = 0b10000000;
private readonly Decoder $decoder;
private LoggerInterface $logger;
private EventDispatcherInterface $dispatcher;
public function __construct(
private readonly AttestationStatementSupportManager $attestationStatementSupportManager
) {
$this->decoder = Decoder::create();
$this->logger = new NullLogger();
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(AttestationStatementSupportManager $attestationStatementSupportManager): self
{
return new self($attestationStatementSupportManager);
}
public function load(string $data): AttestationObject
{
try {
$this->logger->info('Trying to load the data', [
'data' => $data,
]);
$decodedData = Base64::decode($data);
$stream = new StringStream($decodedData);
$parsed = $this->decoder->decode($stream);
$this->logger->info('Loading the Attestation Statement');
$parsed instanceof Normalizable || throw InvalidDataException::create(
$parsed,
'Invalid attestation object. Unexpected object.'
);
$attestationObject = $parsed->normalize();
$stream->isEOF() || throw InvalidDataException::create(
null,
'Invalid attestation object. Presence of extra bytes.'
);
$stream->close();
is_array($attestationObject) || throw InvalidDataException::create(
$attestationObject,
'Invalid attestation object'
);
array_key_exists('authData', $attestationObject) || throw InvalidDataException::create(
$attestationObject,
'Invalid attestation object'
);
array_key_exists('fmt', $attestationObject) || throw InvalidDataException::create(
$attestationObject,
'Invalid attestation object'
);
array_key_exists('attStmt', $attestationObject) || throw InvalidDataException::create(
$attestationObject,
'Invalid attestation object'
);
$authData = $attestationObject['authData'];
$attestationStatementSupport = $this->attestationStatementSupportManager->get($attestationObject['fmt']);
$attestationStatement = $attestationStatementSupport->load($attestationObject);
$this->logger->info('Attestation Statement loaded');
$this->logger->debug('Attestation Statement loaded', [
'attestationStatement' => $attestationStatement,
]);
$authDataStream = new StringStream($authData);
$rp_id_hash = $authDataStream->read(32);
$flags = $authDataStream->read(1);
$signCount = $authDataStream->read(4);
$signCount = unpack('N', $signCount);
$this->logger->debug(sprintf('Signature counter: %d', $signCount[1]));
$attestedCredentialData = null;
if (0 !== (ord($flags) & self::FLAG_AT)) {
$this->logger->info('Attested Credential Data is present');
$aaguid = Uuid::fromBinary($authDataStream->read(16));
$credentialLength = $authDataStream->read(2);
$credentialLength = unpack('n', $credentialLength);
$credentialId = $authDataStream->read($credentialLength[1]);
$credentialPublicKey = $this->decoder->decode($authDataStream);
$credentialPublicKey instanceof MapObject || throw InvalidDataException::create(
$credentialPublicKey,
'The data does not contain a valid credential public key.'
);
$attestedCredentialData = new AttestedCredentialData(
$aaguid,
$credentialId,
(string) $credentialPublicKey
);
$this->logger->info('Attested Credential Data loaded');
$this->logger->debug('Attested Credential Data loaded', [
'at' => $attestedCredentialData,
]);
}
$extension = null;
if (0 !== (ord($flags) & self::FLAG_ED)) {
$this->logger->info('Extension Data loaded');
$extension = $this->decoder->decode($authDataStream);
$extension = AuthenticationExtensionsClientOutputsLoader::load($extension);
$this->logger->info('Extension Data loaded');
$this->logger->debug('Extension Data loaded', [
'ed' => $extension,
]);
}
$authDataStream->isEOF() || throw InvalidDataException::create(
null,
'Invalid authentication data. Presence of extra bytes.'
);
$authDataStream->close();
$authenticatorData = new AuthenticatorData(
$authData,
$rp_id_hash,
$flags,
$signCount[1],
$attestedCredentialData,
$extension
);
$attestationObject = new AttestationObject($data, $attestationStatement, $authenticatorData);
$this->logger->info('Attestation Object loaded');
$this->logger->debug('Attestation Object', [
'ed' => $attestationObject,
]);
$this->dispatcher->dispatch(AttestationObjectLoaded::create($attestationObject));
return $attestationObject;
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
throw $throwable;
}
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
}

View File

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use JsonSerializable;
use Webauthn\Exception\InvalidDataException;
use Webauthn\TrustPath\TrustPath;
use Webauthn\TrustPath\TrustPathLoader;
class AttestationStatement implements JsonSerializable
{
final public const TYPE_NONE = 'none';
final public const TYPE_BASIC = 'basic';
final public const TYPE_SELF = 'self';
final public const TYPE_ATTCA = 'attca';
/**
* @deprecated since 4.2.0 and will be removed in 5.0.0. The ECDAA Trust Anchor does no longer exist in Webauthn specification.
*/
final public const TYPE_ECDAA = 'ecdaa';
final public const TYPE_ANONCA = 'anonca';
/**
* @param array<string, mixed> $attStmt
*/
public function __construct(
private readonly string $fmt,
private readonly array $attStmt,
private readonly string $type,
private readonly TrustPath $trustPath
) {
}
/**
* @param array<string, mixed> $attStmt
*/
public static function createNone(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_NONE, $trustPath);
}
/**
* @param array<string, mixed> $attStmt
*/
public static function createBasic(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_BASIC, $trustPath);
}
/**
* @param array<string, mixed> $attStmt
*/
public static function createSelf(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_SELF, $trustPath);
}
/**
* @param array<string, mixed> $attStmt
*/
public static function createAttCA(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_ATTCA, $trustPath);
}
/**
* @param array<string, mixed> $attStmt
*
* @deprecated since 4.2.0 and will be removed in 5.0.0. The ECDAA Trust Anchor does no longer exist in Webauthn specification.
*/
public static function createEcdaa(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_ECDAA, $trustPath);
}
/**
* @param array<string, mixed> $attStmt
*/
public static function createAnonymizationCA(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_ANONCA, $trustPath);
}
public function getFmt(): string
{
return $this->fmt;
}
/**
* @return mixed[]
*/
public function getAttStmt(): array
{
return $this->attStmt;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->attStmt);
}
public function get(string $key): mixed
{
$this->has($key) || throw InvalidDataException::create($this->attStmt, sprintf(
'The attestation statement has no key "%s".',
$key
));
return $this->attStmt[$key];
}
public function getTrustPath(): TrustPath
{
return $this->trustPath;
}
public function getType(): string
{
return $this->type;
}
/**
* @param mixed[] $data
*/
public static function createFromArray(array $data): self
{
foreach (['fmt', 'attStmt', 'trustPath', 'type'] as $key) {
array_key_exists($key, $data) || throw InvalidDataException::create($data, sprintf(
'The key "%s" is missing',
$key
));
}
return new self(
$data['fmt'],
$data['attStmt'],
$data['type'],
TrustPathLoader::loadTrustPath($data['trustPath'])
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return [
'fmt' => $this->fmt,
'attStmt' => $this->attStmt,
'trustPath' => $this->trustPath->jsonSerialize(),
'type' => $this->type,
];
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use Webauthn\AuthenticatorData;
interface AttestationStatementSupport
{
public function name(): string;
/**
* @param array<string, mixed> $attestation
*/
public function load(array $attestation): AttestationStatement;
public function isValid(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool;
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use Webauthn\Exception\InvalidDataException;
class AttestationStatementSupportManager
{
/**
* @var AttestationStatementSupport[]
*/
private array $attestationStatementSupports = [];
public static function create(): self
{
return new self();
}
public function add(AttestationStatementSupport $attestationStatementSupport): void
{
$this->attestationStatementSupports[$attestationStatementSupport->name()] = $attestationStatementSupport;
}
public function has(string $name): bool
{
return array_key_exists($name, $this->attestationStatementSupports);
}
public function get(string $name): AttestationStatementSupport
{
$this->has($name) || throw InvalidDataException::create($name, sprintf(
'The attestation statement format "%s" is not supported.',
$name
));
return $this->attestationStatementSupports[$name];
}
}

View File

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use CBOR\Decoder;
use CBOR\MapObject;
use Cose\Key\Ec2Key;
use function count;
use function is_array;
use const OPENSSL_ALGO_SHA256;
use function openssl_pkey_get_public;
use function openssl_verify;
use Psr\EventDispatcher\EventDispatcherInterface;
use Throwable;
use Webauthn\AuthenticatorData;
use Webauthn\Event\AttestationStatementLoaded;
use Webauthn\Exception\AttestationStatementLoadingException;
use Webauthn\Exception\AttestationStatementVerificationException;
use Webauthn\Exception\InvalidAttestationStatementException;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
final class FidoU2FAttestationStatementSupport implements AttestationStatementSupport, CanDispatchEvents
{
private readonly Decoder $decoder;
private EventDispatcherInterface $dispatcher;
public function __construct()
{
$this->decoder = Decoder::create();
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(): self
{
return new self();
}
public function name(): string
{
return 'fido-u2f';
}
/**
* @param array<string, mixed> $attestation
*/
public function load(array $attestation): AttestationStatement
{
array_key_exists('attStmt', $attestation) || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attestation object'
);
foreach (['sig', 'x5c'] as $key) {
array_key_exists($key, $attestation['attStmt']) || throw AttestationStatementLoadingException::create(
$attestation,
sprintf('The attestation statement value "%s" is missing.', $key)
);
}
$certificates = $attestation['attStmt']['x5c'];
is_array($certificates) || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "x5c" must be a list with one certificate.'
);
count($certificates) === 1 || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "x5c" must be a list with one certificate.'
);
reset($certificates);
$certificates = CertificateToolbox::convertAllDERToPEM($certificates);
$this->checkCertificate($certificates[0]);
$attestationStatement = AttestationStatement::createBasic(
$attestation['fmt'],
$attestation['attStmt'],
new CertificateTrustPath($certificates)
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
public function isValid(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
$authenticatorData->getAttestedCredentialData()
?->getAaguid()
->__toString() === '00000000-0000-0000-0000-000000000000' || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid AAGUID for fido-u2f attestation statement. Shall be "00000000-0000-0000-0000-000000000000"'
);
$trustPath = $attestationStatement->getTrustPath();
$trustPath instanceof CertificateTrustPath || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid trust path'
);
$dataToVerify = "\0";
$dataToVerify .= $authenticatorData->getRpIdHash();
$dataToVerify .= $clientDataJSONHash;
$dataToVerify .= $authenticatorData->getAttestedCredentialData()
->getCredentialId();
$dataToVerify .= $this->extractPublicKey(
$authenticatorData->getAttestedCredentialData()
->getCredentialPublicKey()
);
return openssl_verify(
$dataToVerify,
$attestationStatement->get('sig'),
$trustPath->getCertificates()[0],
OPENSSL_ALGO_SHA256
) === 1;
}
private function extractPublicKey(?string $publicKey): string
{
$publicKey !== null || throw AttestationStatementVerificationException::create(
'The attested credential data does not contain a valid public key.'
);
$publicKeyStream = new StringStream($publicKey);
$coseKey = $this->decoder->decode($publicKeyStream);
$publicKeyStream->isEOF() || throw AttestationStatementVerificationException::create(
'Invalid public key. Presence of extra bytes.'
);
$publicKeyStream->close();
$coseKey instanceof MapObject || throw AttestationStatementVerificationException::create(
'The attested credential data does not contain a valid public key.'
);
$coseKey = $coseKey->normalize();
$ec2Key = new Ec2Key($coseKey + [
Ec2Key::TYPE => 2,
Ec2Key::DATA_CURVE => Ec2Key::CURVE_P256,
]);
return "\x04" . $ec2Key->x() . $ec2Key->y();
}
private function checkCertificate(string $publicKey): void
{
try {
$resource = openssl_pkey_get_public($publicKey);
$details = openssl_pkey_get_details($resource);
} catch (Throwable $throwable) {
throw AttestationStatementVerificationException::create(
'Invalid certificate or certificate chain',
$throwable
);
}
is_array($details) || throw AttestationStatementVerificationException::create(
'Invalid certificate or certificate chain'
);
array_key_exists('ec', $details) || throw AttestationStatementVerificationException::create(
'Invalid certificate or certificate chain'
);
array_key_exists('curve_name', $details['ec']) || throw AttestationStatementVerificationException::create(
'Invalid certificate or certificate chain'
);
$details['ec']['curve_name'] === 'prime256v1' || throw AttestationStatementVerificationException::create(
'Invalid certificate or certificate chain'
);
array_key_exists('curve_oid', $details['ec']) || throw AttestationStatementVerificationException::create(
'Invalid certificate or certificate chain'
);
$details['ec']['curve_oid'] === '1.2.840.10045.3.1.7' || throw AttestationStatementVerificationException::create(
'Invalid certificate or certificate chain'
);
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function count;
use function is_array;
use function is_string;
use Psr\EventDispatcher\EventDispatcherInterface;
use Webauthn\AuthenticatorData;
use Webauthn\Event\AttestationStatementLoaded;
use Webauthn\Exception\AttestationStatementLoadingException;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\TrustPath\EmptyTrustPath;
final class NoneAttestationStatementSupport implements AttestationStatementSupport, CanDispatchEvents
{
private EventDispatcherInterface $dispatcher;
public function __construct()
{
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(): self
{
return new self();
}
public function name(): string
{
return 'none';
}
/**
* @param array<string, mixed> $attestation
*/
public function load(array $attestation): AttestationStatement
{
$format = $attestation['fmt'] ?? null;
$attestationStatement = $attestation['attStmt'] ?? [];
(is_string($format) && $format !== '') || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attestation object'
);
(is_array(
$attestationStatement
) && $attestationStatement === []) || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attestation object'
);
$attestationStatement = AttestationStatement::createNone(
$format,
$attestationStatement,
EmptyTrustPath::create()
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
public function isValid(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
return count($attestationStatement->getAttStmt()) === 0;
}
}

View File

@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use CBOR\Decoder;
use CBOR\MapObject;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\Signature;
use Cose\Algorithms;
use Cose\Key\Key;
use function count;
use function in_array;
use function is_array;
use function is_string;
use function openssl_verify;
use Psr\EventDispatcher\EventDispatcherInterface;
use Webauthn\AuthenticatorData;
use Webauthn\Event\AttestationStatementLoaded;
use Webauthn\Exception\AttestationStatementLoadingException;
use Webauthn\Exception\AttestationStatementVerificationException;
use Webauthn\Exception\InvalidAttestationStatementException;
use Webauthn\Exception\InvalidDataException;
use Webauthn\Exception\UnsupportedFeatureException;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
use Webauthn\TrustPath\EcdaaKeyIdTrustPath;
use Webauthn\TrustPath\EmptyTrustPath;
use Webauthn\Util\CoseSignatureFixer;
final class PackedAttestationStatementSupport implements AttestationStatementSupport, CanDispatchEvents
{
private readonly Decoder $decoder;
private EventDispatcherInterface $dispatcher;
public function __construct(
private readonly Manager $algorithmManager
) {
$this->decoder = Decoder::create();
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(Manager $algorithmManager): self
{
return new self($algorithmManager);
}
public function name(): string
{
return 'packed';
}
/**
* @param array<string, mixed> $attestation
*/
public function load(array $attestation): AttestationStatement
{
array_key_exists('sig', $attestation['attStmt']) || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "sig" is missing.'
);
array_key_exists('alg', $attestation['attStmt']) || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "alg" is missing.'
);
is_string($attestation['attStmt']['sig']) || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "sig" is missing.'
);
return match (true) {
array_key_exists('x5c', $attestation['attStmt']) => $this->loadBasicType($attestation),
array_key_exists('ecdaaKeyId', $attestation['attStmt']) => $this->loadEcdaaType($attestation['attStmt']),
default => $this->loadEmptyType($attestation),
};
}
public function isValid(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
$trustPath = $attestationStatement->getTrustPath();
return match (true) {
$trustPath instanceof CertificateTrustPath => $this->processWithCertificate(
$clientDataJSONHash,
$attestationStatement,
$authenticatorData,
$trustPath
),
$trustPath instanceof EcdaaKeyIdTrustPath => $this->processWithECDAA(),
$trustPath instanceof EmptyTrustPath => $this->processWithSelfAttestation(
$clientDataJSONHash,
$attestationStatement,
$authenticatorData
),
default => throw InvalidAttestationStatementException::create(
$attestationStatement,
'Unsupported attestation statement'
),
};
}
/**
* @param mixed[] $attestation
*/
private function loadBasicType(array $attestation): AttestationStatement
{
$certificates = $attestation['attStmt']['x5c'];
is_array($certificates) || throw AttestationStatementVerificationException::create(
'The attestation statement value "x5c" must be a list with at least one certificate.'
);
count($certificates) > 0 || throw AttestationStatementVerificationException::create(
'The attestation statement value "x5c" must be a list with at least one certificate.'
);
$certificates = CertificateToolbox::convertAllDERToPEM($certificates);
$attestationStatement = AttestationStatement::createBasic(
$attestation['fmt'],
$attestation['attStmt'],
new CertificateTrustPath($certificates)
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
/**
* @param array<string, mixed> $attestation
*/
private function loadEcdaaType(array $attestation): AttestationStatement
{
$ecdaaKeyId = $attestation['attStmt']['ecdaaKeyId'];
is_string($ecdaaKeyId) || throw AttestationStatementVerificationException::create(
'The attestation statement value "ecdaaKeyId" is invalid.'
);
$attestationStatement = AttestationStatement::createEcdaa(
$attestation['fmt'],
$attestation['attStmt'],
new EcdaaKeyIdTrustPath($attestation['ecdaaKeyId'])
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
/**
* @param mixed[] $attestation
*/
private function loadEmptyType(array $attestation): AttestationStatement
{
$attestationStatement = AttestationStatement::createSelf(
$attestation['fmt'],
$attestation['attStmt'],
new EmptyTrustPath()
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
private function checkCertificate(string $attestnCert, AuthenticatorData $authenticatorData): void
{
$parsed = openssl_x509_parse($attestnCert);
is_array($parsed) || throw AttestationStatementVerificationException::create('Invalid certificate');
//Check version
isset($parsed['version']) || throw AttestationStatementVerificationException::create(
'Invalid certificate version'
);
$parsed['version'] === 2 || throw AttestationStatementVerificationException::create(
'Invalid certificate version'
);
//Check subject field
isset($parsed['name']) || throw AttestationStatementVerificationException::create(
'Invalid certificate name. The Subject Organization Unit must be "Authenticator Attestation"'
);
str_contains(
(string) $parsed['name'],
'/OU=Authenticator Attestation'
) || throw AttestationStatementVerificationException::create(
'Invalid certificate name. The Subject Organization Unit must be "Authenticator Attestation"'
);
//Check extensions
isset($parsed['extensions']) || throw AttestationStatementVerificationException::create(
'Certificate extensions are missing'
);
is_array($parsed['extensions']) || throw AttestationStatementVerificationException::create(
'Certificate extensions are missing'
);
//Check certificate is not a CA cert
isset($parsed['extensions']['basicConstraints']) || throw AttestationStatementVerificationException::create(
'The Basic Constraints extension must have the CA component set to false'
);
$parsed['extensions']['basicConstraints'] === 'CA:FALSE' || throw AttestationStatementVerificationException::create(
'The Basic Constraints extension must have the CA component set to false'
);
$attestedCredentialData = $authenticatorData->getAttestedCredentialData();
$attestedCredentialData !== null || throw AttestationStatementVerificationException::create(
'No attested credential available'
);
// id-fido-gen-ce-aaguid OID check
if (in_array('1.3.6.1.4.1.45724.1.1.4', $parsed['extensions'], true)) {
hash_equals(
$attestedCredentialData->getAaguid()
->toBinary(),
$parsed['extensions']['1.3.6.1.4.1.45724.1.1.4']
) || throw AttestationStatementVerificationException::create(
'The value of the "aaguid" does not match with the certificate'
);
}
}
private function processWithCertificate(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData,
CertificateTrustPath $trustPath
): bool {
$certificates = $trustPath->getCertificates();
// Check leaf certificate
$this->checkCertificate($certificates[0], $authenticatorData);
// Get the COSE algorithm identifier and the corresponding OpenSSL one
$coseAlgorithmIdentifier = (int) $attestationStatement->get('alg');
$opensslAlgorithmIdentifier = Algorithms::getOpensslAlgorithmFor($coseAlgorithmIdentifier);
// Verification of the signature
$signedData = $authenticatorData->getAuthData() . $clientDataJSONHash;
$result = openssl_verify(
$signedData,
$attestationStatement->get('sig'),
$certificates[0],
$opensslAlgorithmIdentifier
);
return $result === 1;
}
private function processWithECDAA(): never
{
throw UnsupportedFeatureException::create('ECDAA not supported');
}
private function processWithSelfAttestation(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
$attestedCredentialData = $authenticatorData->getAttestedCredentialData();
$attestedCredentialData !== null || throw AttestationStatementVerificationException::create(
'No attested credential available'
);
$credentialPublicKey = $attestedCredentialData->getCredentialPublicKey();
$credentialPublicKey !== null || throw AttestationStatementVerificationException::create(
'No credential public key available'
);
$publicKeyStream = new StringStream($credentialPublicKey);
$publicKey = $this->decoder->decode($publicKeyStream);
$publicKeyStream->isEOF() || throw AttestationStatementVerificationException::create(
'Invalid public key. Presence of extra bytes.'
);
$publicKeyStream->close();
$publicKey instanceof MapObject || throw AttestationStatementVerificationException::create(
'The attested credential data does not contain a valid public key.'
);
$publicKey = $publicKey->normalize();
$publicKey = new Key($publicKey);
$publicKey->alg() === (int) $attestationStatement->get(
'alg'
) || throw AttestationStatementVerificationException::create(
'The algorithm of the attestation statement and the key are not identical.'
);
$dataToVerify = $authenticatorData->getAuthData() . $clientDataJSONHash;
$algorithm = $this->algorithmManager->get((int) $attestationStatement->get('alg'));
if (! $algorithm instanceof Signature) {
throw InvalidDataException::create($algorithm, 'Invalid algorithm');
}
$signature = CoseSignatureFixer::fix($attestationStatement->get('sig'), $algorithm);
return $algorithm->verify($dataToVerify, $publicKey, $signature);
}
}

View File

@ -0,0 +1,445 @@
<?php
declare(strict_types=1);
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use CBOR\Decoder;
use CBOR\MapObject;
use Cose\Algorithms;
use Cose\Key\Ec2Key;
use Cose\Key\Key;
use Cose\Key\OkpKey;
use Cose\Key\RsaKey;
use function count;
use DateTimeImmutable;
use DateTimeZone;
use function in_array;
use function is_array;
use function is_int;
use Lcobucci\Clock\Clock;
use Lcobucci\Clock\SystemClock;
use function openssl_verify;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Psr\Clock\ClockInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use function unpack;
use Webauthn\AuthenticatorData;
use Webauthn\Event\AttestationStatementLoaded;
use Webauthn\Exception\AttestationStatementLoadingException;
use Webauthn\Exception\AttestationStatementVerificationException;
use Webauthn\Exception\InvalidAttestationStatementException;
use Webauthn\Exception\UnsupportedFeatureException;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
use Webauthn\TrustPath\EcdaaKeyIdTrustPath;
final class TPMAttestationStatementSupport implements AttestationStatementSupport, CanDispatchEvents
{
private readonly Clock|ClockInterface $clock;
private EventDispatcherInterface $dispatcher;
public function __construct(null|Clock|ClockInterface $clock = null)
{
if ($clock === null) {
trigger_deprecation(
'web-auth/metadata-service',
'4.5.0',
'The parameter "$clock" will become mandatory in 5.0.0. Please set a valid PSR Clock implementation instead of "null".'
);
$clock = new SystemClock(new DateTimeZone('UTC'));
}
$this->clock = $clock;
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(null|Clock|ClockInterface $clock = null): self
{
return new self($clock);
}
public function name(): string
{
return 'tpm';
}
/**
* @param array<string, mixed> $attestation
*/
public function load(array $attestation): AttestationStatement
{
array_key_exists('attStmt', $attestation) || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attestation object'
);
! array_key_exists(
'ecdaaKeyId',
$attestation['attStmt']
) || throw AttestationStatementLoadingException::create($attestation, 'ECDAA not supported');
foreach (['ver', 'ver', 'sig', 'alg', 'certInfo', 'pubArea'] as $key) {
array_key_exists($key, $attestation['attStmt']) || throw AttestationStatementLoadingException::create(
$attestation,
sprintf('The attestation statement value "%s" is missing.', $key)
);
}
$attestation['attStmt']['ver'] === '2.0' || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attestation object'
);
$certInfo = $this->checkCertInfo($attestation['attStmt']['certInfo']);
bin2hex((string) $certInfo['type']) === '8017' || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attestation object'
);
$pubArea = $this->checkPubArea($attestation['attStmt']['pubArea']);
$pubAreaAttestedNameAlg = mb_substr((string) $certInfo['attestedName'], 0, 2, '8bit');
$pubAreaHash = hash(
$this->getTPMHash($pubAreaAttestedNameAlg),
(string) $attestation['attStmt']['pubArea'],
true
);
$attestedName = $pubAreaAttestedNameAlg . $pubAreaHash;
$attestedName === $certInfo['attestedName'] || throw AttestationStatementLoadingException::create(
$attestation,
'Invalid attested name'
);
$attestation['attStmt']['parsedCertInfo'] = $certInfo;
$attestation['attStmt']['parsedPubArea'] = $pubArea;
$certificates = CertificateToolbox::convertAllDERToPEM($attestation['attStmt']['x5c']);
count($certificates) > 0 || throw AttestationStatementLoadingException::create(
$attestation,
'The attestation statement value "x5c" must be a list with at least one certificate.'
);
$attestationStatement = AttestationStatement::createAttCA(
$this->name(),
$attestation['attStmt'],
new CertificateTrustPath($certificates)
);
$this->dispatcher->dispatch(AttestationStatementLoaded::create($attestationStatement));
return $attestationStatement;
}
public function isValid(
string $clientDataJSONHash,
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
$attToBeSigned = $authenticatorData->getAuthData() . $clientDataJSONHash;
$attToBeSignedHash = hash(
Algorithms::getHashAlgorithmFor((int) $attestationStatement->get('alg')),
$attToBeSigned,
true
);
$attestationStatement->get(
'parsedCertInfo'
)['extraData'] === $attToBeSignedHash || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Invalid attestation hash'
);
$credentialPublicKey = $authenticatorData->getAttestedCredentialData()?->getCredentialPublicKey();
$credentialPublicKey !== null || throw InvalidAttestationStatementException::create(
$attestationStatement,
'Not credential public key available in the attested credential data'
);
$this->checkUniquePublicKey($attestationStatement->get('parsedPubArea')['unique'], $credentialPublicKey);
return match (true) {
$attestationStatement->getTrustPath() instanceof CertificateTrustPath => $this->processWithCertificate(
$attestationStatement,
$authenticatorData
),
$attestationStatement->getTrustPath() instanceof EcdaaKeyIdTrustPath => $this->processWithECDAA(),
default => throw InvalidAttestationStatementException::create(
$attestationStatement,
'Unsupported attestation statement'
),
};
}
private function checkUniquePublicKey(string $unique, string $cborPublicKey): void
{
$cborDecoder = Decoder::create();
$publicKey = $cborDecoder->decode(new StringStream($cborPublicKey));
$publicKey instanceof MapObject || throw AttestationStatementVerificationException::create(
'Invalid public key'
);
$key = Key::create($publicKey->normalize());
switch ($key->type()) {
case Key::TYPE_OKP:
$uniqueFromKey = (new OkpKey($key->getData()))->x();
break;
case Key::TYPE_EC2:
$ec2Key = new Ec2Key($key->getData());
$uniqueFromKey = "\x04" . $ec2Key->x() . $ec2Key->y();
break;
case Key::TYPE_RSA:
$uniqueFromKey = (new RsaKey($key->getData()))->n();
break;
default:
throw AttestationStatementVerificationException::create('Invalid or unsupported key type.');
}
$unique === $uniqueFromKey || throw AttestationStatementVerificationException::create(
'Invalid pubArea.unique value'
);
}
/**
* @return mixed[]
*/
private function checkCertInfo(string $data): array
{
$certInfo = new StringStream($data);
$magic = $certInfo->read(4);
bin2hex($magic) === 'ff544347' || throw AttestationStatementVerificationException::create(
'Invalid attestation object'
);
$type = $certInfo->read(2);
$qualifiedSignerLength = unpack('n', $certInfo->read(2))[1];
$qualifiedSigner = $certInfo->read($qualifiedSignerLength); //Ignored
$extraDataLength = unpack('n', $certInfo->read(2))[1];
$extraData = $certInfo->read($extraDataLength);
$clockInfo = $certInfo->read(17); //Ignore
$firmwareVersion = $certInfo->read(8);
$attestedNameLength = unpack('n', $certInfo->read(2))[1];
$attestedName = $certInfo->read($attestedNameLength);
$attestedQualifiedNameLength = unpack('n', $certInfo->read(2))[1];
$attestedQualifiedName = $certInfo->read($attestedQualifiedNameLength); //Ignore
$certInfo->isEOF() || throw AttestationStatementVerificationException::create(
'Invalid certificate information. Presence of extra bytes.'
);
$certInfo->close();
return [
'magic' => $magic,
'type' => $type,
'qualifiedSigner' => $qualifiedSigner,
'extraData' => $extraData,
'clockInfo' => $clockInfo,
'firmwareVersion' => $firmwareVersion,
'attestedName' => $attestedName,
'attestedQualifiedName' => $attestedQualifiedName,
];
}
/**
* @return mixed[]
*/
private function checkPubArea(string $data): array
{
$pubArea = new StringStream($data);
$type = $pubArea->read(2);
$nameAlg = $pubArea->read(2);
$objectAttributes = $pubArea->read(4);
$authPolicyLength = unpack('n', $pubArea->read(2))[1];
$authPolicy = $pubArea->read($authPolicyLength);
$parameters = $this->getParameters($type, $pubArea);
$unique = $this->getUnique($type, $pubArea);
$pubArea->isEOF() || throw AttestationStatementVerificationException::create(
'Invalid public area. Presence of extra bytes.'
);
$pubArea->close();
return [
'type' => $type,
'nameAlg' => $nameAlg,
'objectAttributes' => $objectAttributes,
'authPolicy' => $authPolicy,
'parameters' => $parameters,
'unique' => $unique,
];
}
/**
* @return mixed[]
*/
private function getParameters(string $type, StringStream $stream): array
{
return match (bin2hex($type)) {
'0001' => [
'symmetric' => $stream->read(2),
'scheme' => $stream->read(2),
'keyBits' => unpack('n', $stream->read(2))[1],
'exponent' => $this->getExponent($stream->read(4)),
],
'0023' => [
'symmetric' => $stream->read(2),
'scheme' => $stream->read(2),
'curveId' => $stream->read(2),
'kdf' => $stream->read(2),
],
default => throw AttestationStatementVerificationException::create('Unsupported type'),
};
}
private function getUnique(string $type, StringStream $stream): string
{
switch (bin2hex($type)) {
case '0001':
$uniqueLength = unpack('n', $stream->read(2))[1];
return $stream->read($uniqueLength);
case '0023':
$xLen = unpack('n', $stream->read(2))[1];
$x = $stream->read($xLen);
$yLen = unpack('n', $stream->read(2))[1];
$y = $stream->read($yLen);
return "\04" . $x . $y;
default:
throw AttestationStatementVerificationException::create('Unsupported type');
}
}
private function getExponent(string $exponent): string
{
return bin2hex($exponent) === '00000000' ? Base64UrlSafe::decodeNoPadding('AQAB') : $exponent;
}
private function getTPMHash(string $nameAlg): string
{
return match (bin2hex($nameAlg)) {
'0004' => 'sha1',
'000b' => 'sha256',
'000c' => 'sha384',
'000d' => 'sha512',
default => throw AttestationStatementVerificationException::create('Unsupported hash algorithm'),
};
}
private function processWithCertificate(
AttestationStatement $attestationStatement,
AuthenticatorData $authenticatorData
): bool {
$trustPath = $attestationStatement->getTrustPath();
$trustPath instanceof CertificateTrustPath || throw AttestationStatementVerificationException::create(
'Invalid trust path'
);
$certificates = $trustPath->getCertificates();
// Check certificate CA chain and returns the Attestation Certificate
$this->checkCertificate($certificates[0], $authenticatorData);
// Get the COSE algorithm identifier and the corresponding OpenSSL one
$coseAlgorithmIdentifier = (int) $attestationStatement->get('alg');
$opensslAlgorithmIdentifier = Algorithms::getOpensslAlgorithmFor($coseAlgorithmIdentifier);
$result = openssl_verify(
$attestationStatement->get('certInfo'),
$attestationStatement->get('sig'),
$certificates[0],
$opensslAlgorithmIdentifier
);
return $result === 1;
}
private function checkCertificate(string $attestnCert, AuthenticatorData $authenticatorData): void
{
$parsed = openssl_x509_parse($attestnCert);
is_array($parsed) || throw AttestationStatementVerificationException::create('Invalid certificate');
//Check version
(isset($parsed['version']) && $parsed['version'] === 2) || throw AttestationStatementVerificationException::create(
'Invalid certificate version'
);
//Check subject field is empty
isset($parsed['subject']) || throw AttestationStatementVerificationException::create(
'Invalid certificate name. The Subject should be empty'
);
is_array($parsed['subject']) || throw AttestationStatementVerificationException::create(
'Invalid certificate name. The Subject should be empty'
);
count($parsed['subject']) === 0 || throw AttestationStatementVerificationException::create(
'Invalid certificate name. The Subject should be empty'
);
// Check period of validity
array_key_exists(
'validFrom_time_t',
$parsed
) || throw AttestationStatementVerificationException::create('Invalid certificate start date.');
is_int($parsed['validFrom_time_t']) || throw AttestationStatementVerificationException::create(
'Invalid certificate start date.'
);
$startDate = (new DateTimeImmutable())->setTimestamp($parsed['validFrom_time_t']);
$startDate < $this->clock->now() || throw AttestationStatementVerificationException::create(
'Invalid certificate start date.'
);
array_key_exists('validTo_time_t', $parsed) || throw AttestationStatementVerificationException::create(
'Invalid certificate end date.'
);
is_int($parsed['validTo_time_t']) || throw AttestationStatementVerificationException::create(
'Invalid certificate end date.'
);
$endDate = (new DateTimeImmutable())->setTimestamp($parsed['validTo_time_t']);
$endDate > $this->clock->now() || throw AttestationStatementVerificationException::create(
'Invalid certificate end date.'
);
//Check extensions
(isset($parsed['extensions']) && is_array(
$parsed['extensions']
)) || throw AttestationStatementVerificationException::create('Certificate extensions are missing');
//Check subjectAltName
isset($parsed['extensions']['subjectAltName']) || throw AttestationStatementVerificationException::create(
'The "subjectAltName" is missing'
);
//Check extendedKeyUsage
isset($parsed['extensions']['extendedKeyUsage']) || throw AttestationStatementVerificationException::create(
'The "subjectAltName" is missing'
);
$parsed['extensions']['extendedKeyUsage'] === '2.23.133.8.3' || throw AttestationStatementVerificationException::create(
'The "extendedKeyUsage" is invalid'
);
// id-fido-gen-ce-aaguid OID check
in_array('1.3.6.1.4.1.45724.1.1.4', $parsed['extensions'], true) && ! hash_equals(
$authenticatorData->getAttestedCredentialData()
?->getAaguid()
->toBinary() ?? '',
$parsed['extensions']['1.3.6.1.4.1.45724.1.1.4']
) && throw AttestationStatementVerificationException::create(
'The value of the "aaguid" does not match with the certificate'
);
}
private function processWithECDAA(): never
{
throw UnsupportedFeatureException::create('ECDAA not supported');
}
}

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use function is_string;
use JsonSerializable;
use ParagonIE\ConstantTime\Base64;
use Symfony\Component\Uid\AbstractUid;
use Symfony\Component\Uid\Uuid;
use Webauthn\Exception\InvalidDataException;
/**
* @see https://www.w3.org/TR/webauthn/#sec-attested-credential-data
*/
class AttestedCredentialData implements JsonSerializable
{
public function __construct(
private AbstractUid $aaguid,
private readonly string $credentialId,
private readonly ?string $credentialPublicKey
) {
}
public function getAaguid(): AbstractUid
{
return $this->aaguid;
}
public function setAaguid(AbstractUid $aaguid): void
{
$this->aaguid = $aaguid;
}
public function getCredentialId(): string
{
return $this->credentialId;
}
public function getCredentialPublicKey(): ?string
{
return $this->credentialPublicKey;
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
array_key_exists('aaguid', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "aaguid" is missing.'
);
$aaguid = $json['aaguid'];
is_string($aaguid) || throw InvalidDataException::create(
$json,
'Invalid input. "aaguid" shall be a string of 36 characters'
);
mb_strlen($aaguid, '8bit') === 36 || throw InvalidDataException::create(
$json,
'Invalid input. "aaguid" shall be a string of 36 characters'
);
$uuid = Uuid::fromString($aaguid);
array_key_exists('credentialId', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "credentialId" is missing.'
);
$credentialId = $json['credentialId'];
is_string($credentialId) || throw InvalidDataException::create(
$json,
'Invalid input. "credentialId" shall be a string'
);
$credentialId = Base64::decode($credentialId, true);
$credentialPublicKey = null;
if (isset($json['credentialPublicKey'])) {
$credentialPublicKey = Base64::decode($json['credentialPublicKey'], true);
}
return new self($uuid, $credentialId, $credentialPublicKey);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$result = [
'aaguid' => $this->aaguid->__toString(),
'credentialId' => base64_encode($this->credentialId),
];
if ($this->credentialPublicKey !== null) {
$result['credentialPublicKey'] = base64_encode($this->credentialPublicKey);
}
return $result;
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Webauthn\AuthenticationExtensions;
use JsonSerializable;
class AuthenticationExtension implements JsonSerializable
{
public function __construct(
private readonly string $name,
private readonly mixed $value
) {
}
public static function create(string $name, mixed $value): self
{
return new self($name, $value);
}
public function name(): string
{
return $this->name;
}
public function value(): mixed
{
return $this->value;
}
public function jsonSerialize(): mixed
{
return $this->value;
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Webauthn\AuthenticationExtensions;
use function array_key_exists;
use ArrayIterator;
use function count;
use const COUNT_NORMAL;
use Countable;
use Iterator;
use IteratorAggregate;
use JsonSerializable;
use Webauthn\Exception\AuthenticationExtensionException;
/**
* @implements IteratorAggregate<AuthenticationExtension>
*/
class AuthenticationExtensionsClientInputs implements JsonSerializable, Countable, IteratorAggregate
{
/**
* @var AuthenticationExtension[]
*/
private array $extensions = [];
public static function create(): self
{
return new self();
}
public function add(AuthenticationExtension ...$extensions): self
{
foreach ($extensions as $extension) {
$this->extensions[$extension->name()] = $extension;
}
return $this;
}
/**
* @param array<string, mixed> $json
*/
public static function createFromArray(array $json): self
{
$object = new self();
foreach ($json as $k => $v) {
$object->add(AuthenticationExtension::create($k, $v));
}
return $object;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->extensions);
}
public function get(string $key): AuthenticationExtension
{
$this->has($key) || throw AuthenticationExtensionException::create(sprintf(
'The extension with key "%s" is not available',
$key
));
return $this->extensions[$key];
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return array_map(
static fn (AuthenticationExtension $object): mixed => $object->jsonSerialize(),
$this->extensions
);
}
/**
* @return Iterator<string, AuthenticationExtension>
*/
public function getIterator(): Iterator
{
return new ArrayIterator($this->extensions);
}
public function count(int $mode = COUNT_NORMAL): int
{
return count($this->extensions, $mode);
}
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Webauthn\AuthenticationExtensions;
use function array_key_exists;
use ArrayIterator;
use function count;
use const COUNT_NORMAL;
use Countable;
use Iterator;
use IteratorAggregate;
use const JSON_THROW_ON_ERROR;
use JsonSerializable;
use Webauthn\Exception\AuthenticationExtensionException;
/**
* @implements IteratorAggregate<AuthenticationExtension>
*/
class AuthenticationExtensionsClientOutputs implements JsonSerializable, Countable, IteratorAggregate
{
/**
* @var AuthenticationExtension[]
*/
private array $extensions = [];
public static function create(): self
{
return new self();
}
public function add(AuthenticationExtension ...$extensions): void
{
foreach ($extensions as $extension) {
$this->extensions[$extension->name()] = $extension;
}
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return self::createFromArray($data);
}
/**
* @param array<string, mixed> $json
*/
public static function createFromArray(array $json): self
{
$object = new self();
foreach ($json as $k => $v) {
$object->add(AuthenticationExtension::create($k, $v));
}
return $object;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->extensions);
}
public function get(string $key): AuthenticationExtension
{
$this->has($key) || throw AuthenticationExtensionException::create(sprintf(
'The extension with key "%s" is not available',
$key
));
return $this->extensions[$key];
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return array_map(
static fn (AuthenticationExtension $object): mixed => $object->jsonSerialize(),
$this->extensions
);
}
/**
* @return Iterator<string, AuthenticationExtension>
*/
public function getIterator(): Iterator
{
return new ArrayIterator($this->extensions);
}
public function count(int $mode = COUNT_NORMAL): int
{
return count($this->extensions, $mode);
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Webauthn\AuthenticationExtensions;
use CBOR\CBORObject;
use CBOR\MapObject;
use function is_string;
use Webauthn\Exception\AuthenticationExtensionException;
abstract class AuthenticationExtensionsClientOutputsLoader
{
public static function load(CBORObject $object): AuthenticationExtensionsClientOutputs
{
$object instanceof MapObject || throw AuthenticationExtensionException::create('Invalid extension object');
$data = $object->normalize();
$extensions = AuthenticationExtensionsClientOutputs::create();
foreach ($data as $key => $value) {
is_string($key) || throw AuthenticationExtensionException::create('Invalid extension key');
$extensions->add(AuthenticationExtension::create($key, $value));
}
return $extensions;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Webauthn\AuthenticationExtensions;
interface ExtensionOutputChecker
{
public function check(
AuthenticationExtensionsClientInputs $inputs,
AuthenticationExtensionsClientOutputs $outputs
): void;
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Webauthn\AuthenticationExtensions;
class ExtensionOutputCheckerHandler
{
/**
* @var ExtensionOutputChecker[]
*/
private array $checkers = [];
public static function create(): self
{
return new self();
}
public function add(ExtensionOutputChecker $checker): void
{
$this->checkers[] = $checker;
}
public function check(
AuthenticationExtensionsClientInputs $inputs,
AuthenticationExtensionsClientOutputs $outputs
): void {
foreach ($this->checkers as $checker) {
$checker->check($inputs, $outputs);
}
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Webauthn\AuthenticationExtensions;
use Exception;
use Throwable;
class ExtensionOutputError extends Exception
{
public function __construct(
private readonly AuthenticationExtension $authenticationExtension,
string $message = '',
int $code = 0,
Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
public function getAuthenticationExtension(): AuthenticationExtension
{
return $this->authenticationExtension;
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use Webauthn\Util\Base64;
/**
* @see https://www.w3.org/TR/webauthn/#authenticatorassertionresponse
*/
class AuthenticatorAssertionResponse extends AuthenticatorResponse
{
public function __construct(
CollectedClientData $clientDataJSON,
private readonly AuthenticatorData $authenticatorData,
private readonly string $signature,
private readonly ?string $userHandle
) {
parent::__construct($clientDataJSON);
}
public function getAuthenticatorData(): AuthenticatorData
{
return $this->authenticatorData;
}
public function getSignature(): string
{
return $this->signature;
}
public function getUserHandle(): ?string
{
if ($this->userHandle === null || $this->userHandle === '') {
return $this->userHandle;
}
return Base64::decode($this->userHandle);
}
}

View File

@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use CBOR\Decoder;
use CBOR\Normalizable;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\Signature;
use Cose\Key\Key;
use function count;
use function in_array;
use function is_array;
use function is_string;
use function parse_url;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Throwable;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputs;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\Counter\CounterChecker;
use Webauthn\Counter\ThrowExceptionIfInvalid;
use Webauthn\Event\AuthenticatorAssertionResponseValidationFailedEvent;
use Webauthn\Event\AuthenticatorAssertionResponseValidationSucceededEvent;
use Webauthn\Exception\AuthenticatorResponseVerificationException;
use Webauthn\MetadataService\CanLogData;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\TokenBinding\TokenBindingHandler;
use Webauthn\Util\CoseSignatureFixer;
class AuthenticatorAssertionResponseValidator implements CanLogData, CanDispatchEvents
{
private readonly Decoder $decoder;
private CounterChecker $counterChecker;
private LoggerInterface $logger;
private EventDispatcherInterface $eventDispatcher;
public function __construct(
private readonly PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository,
private readonly ?TokenBindingHandler $tokenBindingHandler,
private readonly ExtensionOutputCheckerHandler $extensionOutputCheckerHandler,
private readonly ?Manager $algorithmManager,
?EventDispatcherInterface $eventDispatcher = null,
) {
if ($this->tokenBindingHandler !== null) {
trigger_deprecation(
'web-auth/webauthn-symfony-bundle',
'4.3.0',
'The parameter "$tokenBindingHandler" is deprecated since 4.3.0 and will be removed in 5.0.0. Please set "null" instead.'
);
}
if ($eventDispatcher === null) {
$this->eventDispatcher = new NullEventDispatcher();
} else {
$this->eventDispatcher = $eventDispatcher;
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
'The parameter "$eventDispatcher" is deprecated since 4.5.0 will be removed in 5.0.0. Please use `setEventDispatcher` instead.'
);
}
$this->decoder = Decoder::create();
$this->counterChecker = new ThrowExceptionIfInvalid();
$this->logger = new NullLogger();
}
public static function create(
PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository,
?TokenBindingHandler $tokenBindingHandler,
ExtensionOutputCheckerHandler $extensionOutputCheckerHandler,
?Manager $algorithmManager,
?EventDispatcherInterface $eventDispatcher = null
): self {
return new self(
$publicKeyCredentialSourceRepository,
$tokenBindingHandler,
$extensionOutputCheckerHandler,
$algorithmManager,
$eventDispatcher,
);
}
/**
* @param string[] $securedRelyingPartyId
*
* @see https://www.w3.org/TR/webauthn/#verifying-assertion
*/
public function check(
string $credentialId,
AuthenticatorAssertionResponse $authenticatorAssertionResponse,
PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
ServerRequestInterface|string $request,
?string $userHandle,
array $securedRelyingPartyId = []
): PublicKeyCredentialSource {
if ($request instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the method `check` of the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
try {
$this->logger->info('Checking the authenticator assertion response', [
'credentialId' => $credentialId,
'authenticatorAssertionResponse' => $authenticatorAssertionResponse,
'publicKeyCredentialRequestOptions' => $publicKeyCredentialRequestOptions,
'host' => is_string($request) ? $request : $request->getUri()
->getHost(),
'userHandle' => $userHandle,
]);
if (count($publicKeyCredentialRequestOptions->getAllowCredentials()) !== 0) {
$this->isCredentialIdAllowed(
$credentialId,
$publicKeyCredentialRequestOptions->getAllowCredentials()
) || throw AuthenticatorResponseVerificationException::create('The credential ID is not allowed.');
}
$publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId(
$credentialId
);
$publicKeyCredentialSource !== null || throw AuthenticatorResponseVerificationException::create(
'The credential ID is invalid.'
);
$attestedCredentialData = $publicKeyCredentialSource->getAttestedCredentialData();
$credentialUserHandle = $publicKeyCredentialSource->getUserHandle();
$responseUserHandle = $authenticatorAssertionResponse->getUserHandle();
if ($userHandle !== null) { //If the user was identified before the authentication ceremony was initiated,
$credentialUserHandle === $userHandle || throw AuthenticatorResponseVerificationException::create(
'Invalid user handle'
);
if ($responseUserHandle !== null && $responseUserHandle !== '') {
$credentialUserHandle === $responseUserHandle || throw AuthenticatorResponseVerificationException::create(
'Invalid user handle'
);
}
} else {
($responseUserHandle !== '' && $credentialUserHandle === $responseUserHandle) || throw AuthenticatorResponseVerificationException::create(
'Invalid user handle'
);
}
$credentialPublicKey = $attestedCredentialData->getCredentialPublicKey();
$credentialPublicKey !== null || throw AuthenticatorResponseVerificationException::create(
'No public key available.'
);
$isU2F = U2FPublicKey::isU2FKey($credentialPublicKey);
if ($isU2F === true) {
$credentialPublicKey = U2FPublicKey::convertToCoseKey($credentialPublicKey);
}
$stream = new StringStream($credentialPublicKey);
$credentialPublicKeyStream = $this->decoder->decode($stream);
$stream->isEOF() || throw AuthenticatorResponseVerificationException::create(
'Invalid key. Presence of extra bytes.'
);
$stream->close();
$C = $authenticatorAssertionResponse->getClientDataJSON();
$C->getType() === 'webauthn.get' || throw AuthenticatorResponseVerificationException::create(
'The client data type is not "webauthn.get".'
);
hash_equals(
$publicKeyCredentialRequestOptions->getChallenge(),
$C->getChallenge()
) || throw AuthenticatorResponseVerificationException::create('Invalid challenge.');
$rpId = $publicKeyCredentialRequestOptions->getRpId() ?? (is_string(
$request
) ? $request : $request->getUri()
->getHost());
$facetId = $this->getFacetId(
$rpId,
$publicKeyCredentialRequestOptions->getExtensions(),
$authenticatorAssertionResponse->getAuthenticatorData()
->getExtensions()
);
$parsedRelyingPartyId = parse_url($C->getOrigin());
is_array($parsedRelyingPartyId) || throw AuthenticatorResponseVerificationException::create(
'Invalid origin'
);
if (! in_array($facetId, $securedRelyingPartyId, true)) {
$scheme = $parsedRelyingPartyId['scheme'] ?? '';
$scheme === 'https' || throw AuthenticatorResponseVerificationException::create(
'Invalid scheme. HTTPS required.'
);
}
$clientDataRpId = $parsedRelyingPartyId['host'] ?? '';
$clientDataRpId !== '' || throw AuthenticatorResponseVerificationException::create('Invalid origin rpId.');
$rpIdLength = mb_strlen($facetId);
mb_substr(
'.' . $clientDataRpId,
-($rpIdLength + 1)
) === '.' . $facetId || throw AuthenticatorResponseVerificationException::create('rpId mismatch.');
if (! is_string($request) && $C->getTokenBinding() !== null) {
$this->tokenBindingHandler?->check($C->getTokenBinding(), $request);
}
$rpIdHash = hash('sha256', $isU2F ? $C->getOrigin() : $facetId, true);
hash_equals(
$rpIdHash,
$authenticatorAssertionResponse->getAuthenticatorData()
->getRpIdHash()
) || throw AuthenticatorResponseVerificationException::create('rpId hash mismatch.');
if ($publicKeyCredentialRequestOptions->getUserVerification() === AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED) {
$authenticatorAssertionResponse->getAuthenticatorData()
->isUserPresent() || throw AuthenticatorResponseVerificationException::create(
'User was not present'
);
$authenticatorAssertionResponse->getAuthenticatorData()
->isUserVerified() || throw AuthenticatorResponseVerificationException::create(
'User authentication required.'
);
}
$extensionsClientOutputs = $authenticatorAssertionResponse->getAuthenticatorData()
->getExtensions();
if ($extensionsClientOutputs !== null) {
$this->extensionOutputCheckerHandler->check(
$publicKeyCredentialRequestOptions->getExtensions(),
$extensionsClientOutputs
);
}
$getClientDataJSONHash = hash(
'sha256',
$authenticatorAssertionResponse->getClientDataJSON()
->getRawData(),
true
);
$dataToVerify = $authenticatorAssertionResponse->getAuthenticatorData()
->getAuthData() . $getClientDataJSONHash;
$signature = $authenticatorAssertionResponse->getSignature();
$credentialPublicKeyStream instanceof Normalizable || throw AuthenticatorResponseVerificationException::create(
'Invalid attestation object. Unexpected object.'
);
$normalizedData = $credentialPublicKeyStream->normalize();
is_array($normalizedData) || throw AuthenticatorResponseVerificationException::create(
'Invalid attestation object. Unexpected object.'
);
$coseKey = Key::create($normalizedData);
$algorithm = $this->algorithmManager?->get($coseKey->alg());
$algorithm instanceof Signature || throw AuthenticatorResponseVerificationException::create(
'Invalid algorithm identifier. Should refer to a signature algorithm'
);
$signature = CoseSignatureFixer::fix($signature, $algorithm);
$algorithm->verify(
$dataToVerify,
$coseKey,
$signature
) || throw AuthenticatorResponseVerificationException::create('Invalid signature.');
$storedCounter = $publicKeyCredentialSource->getCounter();
$responseCounter = $authenticatorAssertionResponse->getAuthenticatorData()
->getSignCount();
if ($responseCounter !== 0 || $storedCounter !== 0) {
$this->counterChecker->check($publicKeyCredentialSource, $responseCounter);
}
$publicKeyCredentialSource->setCounter($responseCounter);
$this->publicKeyCredentialSourceRepository->saveCredentialSource($publicKeyCredentialSource);
//All good. We can continue.
$this->logger->info('The assertion is valid');
$this->logger->debug('Public Key Credential Source', [
'publicKeyCredentialSource' => $publicKeyCredentialSource,
]);
$this->eventDispatcher->dispatch(
$this->createAuthenticatorAssertionResponseValidationSucceededEvent(
$credentialId,
$authenticatorAssertionResponse,
$publicKeyCredentialRequestOptions,
$request,
$userHandle,
$publicKeyCredentialSource
)
);
return $publicKeyCredentialSource;
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
$this->eventDispatcher->dispatch(
$this->createAuthenticatorAssertionResponseValidationFailedEvent(
$credentialId,
$authenticatorAssertionResponse,
$publicKeyCredentialRequestOptions,
$request,
$userHandle,
$throwable
)
);
throw $throwable;
}
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->eventDispatcher = $eventDispatcher;
}
public function setCounterChecker(CounterChecker $counterChecker): self
{
$this->counterChecker = $counterChecker;
return $this;
}
protected function createAuthenticatorAssertionResponseValidationSucceededEvent(
string $credentialId,
AuthenticatorAssertionResponse $authenticatorAssertionResponse,
PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
ServerRequestInterface|string $request,
?string $userHandle,
PublicKeyCredentialSource $publicKeyCredentialSource
): AuthenticatorAssertionResponseValidationSucceededEvent {
if ($request instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the method `createAuthenticatorAssertionResponseValidationSucceededEvent` of the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
return new AuthenticatorAssertionResponseValidationSucceededEvent(
$credentialId,
$authenticatorAssertionResponse,
$publicKeyCredentialRequestOptions,
$request,
$userHandle,
$publicKeyCredentialSource
);
}
protected function createAuthenticatorAssertionResponseValidationFailedEvent(
string $credentialId,
AuthenticatorAssertionResponse $authenticatorAssertionResponse,
PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
ServerRequestInterface|string $request,
?string $userHandle,
Throwable $throwable
): AuthenticatorAssertionResponseValidationFailedEvent {
if ($request instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the method `createAuthenticatorAssertionResponseValidationFailedEvent` of the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
return new AuthenticatorAssertionResponseValidationFailedEvent(
$credentialId,
$authenticatorAssertionResponse,
$publicKeyCredentialRequestOptions,
$request,
$userHandle,
$throwable
);
}
/**
* @param array<PublicKeyCredentialDescriptor> $allowedCredentials
*/
private function isCredentialIdAllowed(string $credentialId, array $allowedCredentials): bool
{
foreach ($allowedCredentials as $allowedCredential) {
if (hash_equals($allowedCredential->getId(), $credentialId)) {
return true;
}
}
return false;
}
private function getFacetId(
string $rpId,
AuthenticationExtensionsClientInputs $authenticationExtensionsClientInputs,
?AuthenticationExtensionsClientOutputs $authenticationExtensionsClientOutputs
): string {
if ($authenticationExtensionsClientOutputs === null || ! $authenticationExtensionsClientInputs->has(
'appid'
) || ! $authenticationExtensionsClientOutputs->has('appid')) {
return $rpId;
}
$appId = $authenticationExtensionsClientInputs->get('appid')
->value();
$wasUsed = $authenticationExtensionsClientOutputs->get('appid')
->value();
if (! is_string($appId) || $wasUsed !== true) {
return $rpId;
}
return $appId;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use Webauthn\AttestationStatement\AttestationObject;
/**
* @see https://www.w3.org/TR/webauthn/#authenticatorattestationresponse
*/
class AuthenticatorAttestationResponse extends AuthenticatorResponse
{
public function __construct(
CollectedClientData $clientDataJSON,
private readonly AttestationObject $attestationObject
) {
parent::__construct($clientDataJSON);
}
public function getAttestationObject(): AttestationObject
{
return $this->attestationObject;
}
}

View File

@ -0,0 +1,510 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use function count;
use function in_array;
use function is_array;
use function is_string;
use function parse_url;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid;
use Throwable;
use Webauthn\AttestationStatement\AttestationObject;
use Webauthn\AttestationStatement\AttestationStatement;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputs;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\Event\AuthenticatorAttestationResponseValidationFailedEvent;
use Webauthn\Event\AuthenticatorAttestationResponseValidationSucceededEvent;
use Webauthn\Exception\AuthenticatorResponseVerificationException;
use Webauthn\MetadataService\CanLogData;
use Webauthn\MetadataService\CertificateChain\CertificateChainValidator;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\MetadataService\MetadataStatementRepository;
use Webauthn\MetadataService\Statement\MetadataStatement;
use Webauthn\MetadataService\StatusReportRepository;
use Webauthn\TokenBinding\TokenBindingHandler;
use Webauthn\TrustPath\CertificateTrustPath;
use Webauthn\TrustPath\EmptyTrustPath;
class AuthenticatorAttestationResponseValidator implements CanLogData, CanDispatchEvents
{
private LoggerInterface $logger;
private EventDispatcherInterface $eventDispatcher;
private ?MetadataStatementRepository $metadataStatementRepository = null;
private ?StatusReportRepository $statusReportRepository = null;
private ?CertificateChainValidator $certificateChainValidator = null;
public function __construct(
private readonly AttestationStatementSupportManager $attestationStatementSupportManager,
private readonly PublicKeyCredentialSourceRepository $publicKeyCredentialSource,
private readonly ?TokenBindingHandler $tokenBindingHandler,
private readonly ExtensionOutputCheckerHandler $extensionOutputCheckerHandler,
?EventDispatcherInterface $eventDispatcher = null,
) {
if ($this->tokenBindingHandler !== null) {
trigger_deprecation(
'web-auth/webauthn-symfony-bundle',
'4.3.0',
'The parameter "$tokenBindingHandler" is deprecated since 4.3.0 and will be removed in 5.0.0. Please set "null" instead.'
);
}
if ($eventDispatcher === null) {
$this->eventDispatcher = new NullEventDispatcher();
} else {
$this->eventDispatcher = $eventDispatcher;
trigger_deprecation(
'web-auth/webauthn-symfony-bundle',
'4.5.0',
'The parameter "$eventDispatcher" is deprecated since 4.5.0 will be removed in 5.0.0. Please use `setEventDispatcher` instead.'
);
}
$this->logger = new NullLogger();
}
public static function create(
AttestationStatementSupportManager $attestationStatementSupportManager,
PublicKeyCredentialSourceRepository $publicKeyCredentialSource,
?TokenBindingHandler $tokenBindingHandler,
ExtensionOutputCheckerHandler $extensionOutputCheckerHandler,
?EventDispatcherInterface $eventDispatcher = null
): self {
return new self(
$attestationStatementSupportManager,
$publicKeyCredentialSource,
$tokenBindingHandler,
$extensionOutputCheckerHandler,
$eventDispatcher,
);
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->eventDispatcher = $eventDispatcher;
}
public function setCertificateChainValidator(): self
{
return $this;
}
public function enableMetadataStatementSupport(
MetadataStatementRepository $metadataStatementRepository,
StatusReportRepository $statusReportRepository,
CertificateChainValidator $certificateChainValidator
): self {
$this->metadataStatementRepository = $metadataStatementRepository;
$this->certificateChainValidator = $certificateChainValidator;
$this->statusReportRepository = $statusReportRepository;
return $this;
}
/**
* @param string[] $securedRelyingPartyId
*
* @see https://www.w3.org/TR/webauthn/#registering-a-new-credential
*/
public function check(
AuthenticatorAttestationResponse $authenticatorAttestationResponse,
PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
ServerRequestInterface|string $request,
array $securedRelyingPartyId = []
): PublicKeyCredentialSource {
if ($request instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the method `check` of the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
try {
$this->logger->info('Checking the authenticator attestation response', [
'authenticatorAttestationResponse' => $authenticatorAttestationResponse,
'publicKeyCredentialCreationOptions' => $publicKeyCredentialCreationOptions,
'host' => is_string($request) ? $request : $request->getUri()
->getHost(),
]);
//Nothing to do
$C = $authenticatorAttestationResponse->getClientDataJSON();
$C->getType() === 'webauthn.create' || throw AuthenticatorResponseVerificationException::create(
'The client data type is not "webauthn.create".'
);
hash_equals(
$publicKeyCredentialCreationOptions->getChallenge(),
$C->getChallenge()
) || throw AuthenticatorResponseVerificationException::create('Invalid challenge.');
$rpId = $publicKeyCredentialCreationOptions->getRp()
->getId() ?? (is_string($request) ? $request : $request->getUri()->getHost());
$facetId = $this->getFacetId(
$rpId,
$publicKeyCredentialCreationOptions->getExtensions(),
$authenticatorAttestationResponse->getAttestationObject()
->getAuthData()
->getExtensions()
);
$parsedRelyingPartyId = parse_url($C->getOrigin());
is_array($parsedRelyingPartyId) || throw AuthenticatorResponseVerificationException::create(
sprintf('The origin URI "%s" is not valid', $C->getOrigin())
);
array_key_exists(
'scheme',
$parsedRelyingPartyId
) || throw AuthenticatorResponseVerificationException::create('Invalid origin rpId.');
$clientDataRpId = $parsedRelyingPartyId['host'] ?? '';
$clientDataRpId !== '' || throw AuthenticatorResponseVerificationException::create('Invalid origin rpId.');
$rpIdLength = mb_strlen($facetId);
mb_substr(
'.' . $clientDataRpId,
-($rpIdLength + 1)
) === '.' . $facetId || throw AuthenticatorResponseVerificationException::create('rpId mismatch.');
if (! in_array($facetId, $securedRelyingPartyId, true)) {
$scheme = $parsedRelyingPartyId['scheme'];
$scheme === 'https' || throw AuthenticatorResponseVerificationException::create(
'Invalid scheme. HTTPS required.'
);
}
if (! is_string($request) && $C->getTokenBinding() !== null) {
$this->tokenBindingHandler?->check($C->getTokenBinding(), $request);
}
$clientDataJSONHash = hash(
'sha256',
$authenticatorAttestationResponse->getClientDataJSON()
->getRawData(),
true
);
$attestationObject = $authenticatorAttestationResponse->getAttestationObject();
$rpIdHash = hash('sha256', $facetId, true);
hash_equals(
$rpIdHash,
$attestationObject->getAuthData()
->getRpIdHash()
) || throw AuthenticatorResponseVerificationException::create('rpId hash mismatch.');
if ($publicKeyCredentialCreationOptions->getAuthenticatorSelection()?->getUserVerification() === AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED) {
$attestationObject->getAuthData()
->isUserPresent() || throw AuthenticatorResponseVerificationException::create(
'User was not present'
);
$attestationObject->getAuthData()
->isUserVerified() || throw AuthenticatorResponseVerificationException::create(
'User authentication required.'
);
}
$extensionsClientOutputs = $attestationObject->getAuthData()
->getExtensions();
if ($extensionsClientOutputs !== null) {
$this->extensionOutputCheckerHandler->check(
$publicKeyCredentialCreationOptions->getExtensions(),
$extensionsClientOutputs
);
}
$this->checkMetadataStatement($publicKeyCredentialCreationOptions, $attestationObject);
$fmt = $attestationObject->getAttStmt()
->getFmt();
$this->attestationStatementSupportManager->has(
$fmt
) || throw AuthenticatorResponseVerificationException::create(
'Unsupported attestation statement format.'
);
$attestationStatementSupport = $this->attestationStatementSupportManager->get($fmt);
$attestationStatementSupport->isValid(
$clientDataJSONHash,
$attestationObject->getAttStmt(),
$attestationObject->getAuthData()
) || throw AuthenticatorResponseVerificationException::create('Invalid attestation statement.');
$attestationObject->getAuthData()
->hasAttestedCredentialData() || throw AuthenticatorResponseVerificationException::create(
'There is no attested credential data.'
);
$attestedCredentialData = $attestationObject->getAuthData()
->getAttestedCredentialData();
$attestedCredentialData !== null || throw AuthenticatorResponseVerificationException::create(
'There is no attested credential data.'
);
$credentialId = $attestedCredentialData->getCredentialId();
$this->publicKeyCredentialSource->findOneByCredentialId(
$credentialId
) === null || throw AuthenticatorResponseVerificationException::create(
'The credential ID already exists.'
);
$publicKeyCredentialSource = $this->createPublicKeyCredentialSource(
$credentialId,
$attestedCredentialData,
$attestationObject,
$publicKeyCredentialCreationOptions->getUser()
->getId()
);
$this->logger->info('The attestation is valid');
$this->logger->debug('Public Key Credential Source', [
'publicKeyCredentialSource' => $publicKeyCredentialSource,
]);
$this->eventDispatcher->dispatch(
$this->createAuthenticatorAttestationResponseValidationSucceededEvent(
$authenticatorAttestationResponse,
$publicKeyCredentialCreationOptions,
$request,
$publicKeyCredentialSource
)
);
return $publicKeyCredentialSource;
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
$this->eventDispatcher->dispatch(
$this->createAuthenticatorAttestationResponseValidationFailedEvent(
$authenticatorAttestationResponse,
$publicKeyCredentialCreationOptions,
$request,
$throwable
)
);
throw $throwable;
}
}
protected function createAuthenticatorAttestationResponseValidationSucceededEvent(
AuthenticatorAttestationResponse $authenticatorAttestationResponse,
PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
ServerRequestInterface|string $request,
PublicKeyCredentialSource $publicKeyCredentialSource
): AuthenticatorAttestationResponseValidationSucceededEvent {
if ($request instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the method `createAuthenticatorAttestationResponseValidationSucceededEvent` of the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
return new AuthenticatorAttestationResponseValidationSucceededEvent(
$authenticatorAttestationResponse,
$publicKeyCredentialCreationOptions,
$request,
$publicKeyCredentialSource
);
}
protected function createAuthenticatorAttestationResponseValidationFailedEvent(
AuthenticatorAttestationResponse $authenticatorAttestationResponse,
PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
ServerRequestInterface|string $request,
Throwable $throwable
): AuthenticatorAttestationResponseValidationFailedEvent {
if ($request instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the method `createAuthenticatorAttestationResponseValidationFailedEvent` of the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
return new AuthenticatorAttestationResponseValidationFailedEvent(
$authenticatorAttestationResponse,
$publicKeyCredentialCreationOptions,
$request,
$throwable
);
}
private function checkCertificateChain(
AttestationStatement $attestationStatement,
?MetadataStatement $metadataStatement
): void {
$trustPath = $attestationStatement->getTrustPath();
if (! $trustPath instanceof CertificateTrustPath) {
return;
}
$authenticatorCertificates = $trustPath->getCertificates();
if ($metadataStatement === null) {
$this->certificateChainValidator?->check($authenticatorCertificates, []);
return;
}
$trustedCertificates = CertificateToolbox::fixPEMStructures(
$metadataStatement->getAttestationRootCertificates()
);
$this->certificateChainValidator?->check($authenticatorCertificates, $trustedCertificates);
}
private function checkMetadataStatement(
PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
AttestationObject $attestationObject
): void {
$attestationStatement = $attestationObject->getAttStmt();
$attestedCredentialData = $attestationObject->getAuthData()
->getAttestedCredentialData();
$attestedCredentialData !== null || throw AuthenticatorResponseVerificationException::create(
'No attested credential data found'
);
$aaguid = $attestedCredentialData->getAaguid()
->__toString();
if ($publicKeyCredentialCreationOptions->getAttestation() === null || $publicKeyCredentialCreationOptions->getAttestation() === PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE) {
$this->logger->debug('No attestation is asked.');
//No attestation is asked. We shall ensure that the data is anonymous.
if ($aaguid === '00000000-0000-0000-0000-000000000000' && in_array(
$attestationStatement->getType(),
[AttestationStatement::TYPE_NONE, AttestationStatement::TYPE_SELF],
true
)) {
$this->logger->debug('The Attestation Statement is anonymous.');
$this->checkCertificateChain($attestationStatement, null);
return;
}
$this->logger->debug('Anonymization required. AAGUID and Attestation Statement changed.', [
'aaguid' => $aaguid,
'AttestationStatement' => $attestationStatement,
]);
$attestedCredentialData->setAaguid(Uuid::fromString('00000000-0000-0000-0000-000000000000'));
$attestationObject->setAttStmt(AttestationStatement::createNone('none', [], new EmptyTrustPath()));
return;
}
// If no Attestation Statement has been returned or if null AAGUID (=00000000-0000-0000-0000-000000000000)
// => nothing to check
if ($attestationStatement->getType() === AttestationStatement::TYPE_NONE) {
$this->logger->debug('No attestation returned.');
//No attestation is returned. We shall ensure that the AAGUID is a null one.
if ($aaguid !== '00000000-0000-0000-0000-000000000000') {
$this->logger->debug('Anonymization required. AAGUID and Attestation Statement changed.', [
'aaguid' => $aaguid,
'AttestationStatement' => $attestationStatement,
]);
$attestedCredentialData->setAaguid(Uuid::fromString('00000000-0000-0000-0000-000000000000'));
return;
}
return;
}
if ($aaguid === '00000000-0000-0000-0000-000000000000') {
//No need to continue if the AAGUID is null.
// This could be the case e.g. with AnonCA type
return;
}
//The MDS Repository is mandatory here
$this->metadataStatementRepository !== null || throw AuthenticatorResponseVerificationException::create(
'The Metadata Statement Repository is mandatory when requesting attestation objects.'
);
$metadataStatement = $this->metadataStatementRepository->findOneByAAGUID($aaguid);
// At this point, the Metadata Statement is mandatory
$metadataStatement !== null || throw AuthenticatorResponseVerificationException::create(
sprintf('The Metadata Statement for the AAGUID "%s" is missing', $aaguid)
);
// We check the last status report
$this->checkStatusReport($aaguid);
// We check the certificate chain (if any)
$this->checkCertificateChain($attestationStatement, $metadataStatement);
// Check Attestation Type is allowed
if (count($metadataStatement->getAttestationTypes()) !== 0) {
$type = $this->getAttestationType($attestationStatement);
in_array(
$type,
$metadataStatement->getAttestationTypes(),
true
) || throw AuthenticatorResponseVerificationException::create(
sprintf(
'Invalid attestation statement. The attestation type "%s" is not allowed for this authenticator.',
$type
)
);
}
}
private function getAttestationType(AttestationStatement $attestationStatement): string
{
return match ($attestationStatement->getType()) {
AttestationStatement::TYPE_BASIC => MetadataStatement::ATTESTATION_BASIC_FULL,
AttestationStatement::TYPE_SELF => MetadataStatement::ATTESTATION_BASIC_SURROGATE,
AttestationStatement::TYPE_ATTCA => MetadataStatement::ATTESTATION_ATTCA,
AttestationStatement::TYPE_ECDAA => MetadataStatement::ATTESTATION_ECDAA,
AttestationStatement::TYPE_ANONCA => MetadataStatement::ATTESTATION_ANONCA,
default => throw AuthenticatorResponseVerificationException::create('Invalid attestation type'),
};
}
private function checkStatusReport(string $aaguid): void
{
$statusReports = $this->statusReportRepository === null ? [] : $this->statusReportRepository->findStatusReportsByAAGUID(
$aaguid
);
if (count($statusReports) !== 0) {
$lastStatusReport = end($statusReports);
if ($lastStatusReport->isCompromised()) {
throw AuthenticatorResponseVerificationException::create(
'The authenticator is compromised and cannot be used'
);
}
}
}
private function createPublicKeyCredentialSource(
string $credentialId,
AttestedCredentialData $attestedCredentialData,
AttestationObject $attestationObject,
string $userHandle
): PublicKeyCredentialSource {
$credentialPublicKey = $attestedCredentialData->getCredentialPublicKey();
$credentialPublicKey !== null || throw AuthenticatorResponseVerificationException::create(
'Not credential public key available in the attested credential data'
);
return new PublicKeyCredentialSource(
$credentialId,
PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
[],
$attestationObject->getAttStmt()
->getType(),
$attestationObject->getAttStmt()
->getTrustPath(),
$attestedCredentialData->getAaguid(),
$credentialPublicKey,
$userHandle,
$attestationObject->getAuthData()
->getSignCount()
);
}
private function getFacetId(
string $rpId,
AuthenticationExtensionsClientInputs $authenticationExtensionsClientInputs,
?AuthenticationExtensionsClientOutputs $authenticationExtensionsClientOutputs
): string {
if ($authenticationExtensionsClientOutputs === null || ! $authenticationExtensionsClientInputs->has(
'appid'
) || ! $authenticationExtensionsClientOutputs->has('appid')) {
return $rpId;
}
$appId = $authenticationExtensionsClientInputs->get('appid')
->value();
$wasUsed = $authenticationExtensionsClientOutputs->get('appid')
->value();
if (! is_string($appId) || $wasUsed !== true) {
return $rpId;
}
return $appId;
}
}

View File

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function ord;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputs;
/**
* @see https://www.w3.org/TR/webauthn/#sec-authenticator-data
*/
class AuthenticatorData
{
private const FLAG_UP = 0b00000001;
private const FLAG_RFU1 = 0b00000010;
private const FLAG_UV = 0b00000100;
private const FLAG_RFU2 = 0b00111000;
private const FLAG_AT = 0b01000000;
private const FLAG_ED = 0b10000000;
public function __construct(
protected string $authData,
protected string $rpIdHash,
protected string $flags,
protected int $signCount,
protected ?AttestedCredentialData $attestedCredentialData,
protected ?AuthenticationExtensionsClientOutputs $extensions
) {
}
public function getAuthData(): string
{
return $this->authData;
}
public function getRpIdHash(): string
{
return $this->rpIdHash;
}
public function isUserPresent(): bool
{
return 0 !== (ord($this->flags) & self::FLAG_UP);
}
public function isUserVerified(): bool
{
return 0 !== (ord($this->flags) & self::FLAG_UV);
}
public function hasAttestedCredentialData(): bool
{
return 0 !== (ord($this->flags) & self::FLAG_AT);
}
public function hasExtensions(): bool
{
return 0 !== (ord($this->flags) & self::FLAG_ED);
}
public function getReservedForFutureUse1(): int
{
return ord($this->flags) & self::FLAG_RFU1;
}
public function getReservedForFutureUse2(): int
{
return ord($this->flags) & self::FLAG_RFU2;
}
public function getSignCount(): int
{
return $this->signCount;
}
public function getAttestedCredentialData(): ?AttestedCredentialData
{
return $this->attestedCredentialData;
}
public function getExtensions(): ?AuthenticationExtensionsClientOutputs
{
return $this->extensions !== null && $this->hasExtensions() ? $this->extensions : null;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Webauthn;
/**
* @see https://www.w3.org/TR/webauthn/#authenticatorresponse
*/
abstract class AuthenticatorResponse
{
public function __construct(
private readonly CollectedClientData $clientDataJSON
) {
}
public function getClientDataJSON(): CollectedClientData
{
return $this->clientDataJSON;
}
}

View File

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function is_bool;
use function is_string;
use const JSON_THROW_ON_ERROR;
use JsonSerializable;
use Webauthn\Exception\InvalidDataException;
class AuthenticatorSelectionCriteria implements JsonSerializable
{
final public const AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE = null;
final public const AUTHENTICATOR_ATTACHMENT_PLATFORM = 'platform';
final public const AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM = 'cross-platform';
final public const USER_VERIFICATION_REQUIREMENT_REQUIRED = 'required';
final public const USER_VERIFICATION_REQUIREMENT_PREFERRED = 'preferred';
final public const USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 'discouraged';
final public const RESIDENT_KEY_REQUIREMENT_NO_PREFERENCE = null;
/**
* @deprecated Please use AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_NO_PREFERENCE instead
*/
final public const RESIDENT_KEY_REQUIREMENT_NONE = null;
final public const RESIDENT_KEY_REQUIREMENT_REQUIRED = 'required';
final public const RESIDENT_KEY_REQUIREMENT_PREFERRED = 'preferred';
final public const RESIDENT_KEY_REQUIREMENT_DISCOURAGED = 'discouraged';
private ?string $authenticatorAttachment = null;
/**
* @deprecated Will be removed in 5.0. Please use residentKey instead
*/
private bool $requireResidentKey = false;
private string $userVerification = self::USER_VERIFICATION_REQUIREMENT_PREFERRED;
private null|string $residentKey = self::RESIDENT_KEY_REQUIREMENT_PREFERRED;
public static function create(): self
{
return new self();
}
public function setAuthenticatorAttachment(?string $authenticatorAttachment): self
{
$this->authenticatorAttachment = $authenticatorAttachment;
return $this;
}
/**
* @deprecated since v4.1. Please use setResidentKey instead
*/
public function setRequireResidentKey(bool $requireResidentKey): self
{
$this->requireResidentKey = $requireResidentKey;
//$this->residentKey = $requireResidentKey ? self::RESIDENT_KEY_REQUIREMENT_REQUIRED : self::RESIDENT_KEY_REQUIREMENT_DISCOURAGED;
return $this;
}
public function setUserVerification(string $userVerification): self
{
$this->userVerification = $userVerification;
return $this;
}
public function setResidentKey(null|string $residentKey): self
{
$this->residentKey = $residentKey;
//$this->requireResidentKey = $residentKey === self::RESIDENT_KEY_REQUIREMENT_REQUIRED;
return $this;
}
public function getAuthenticatorAttachment(): ?string
{
return $this->authenticatorAttachment;
}
/**
* @deprecated Will be removed in 5.0. Please use getResidentKey() instead
*/
public function isRequireResidentKey(): bool
{
return $this->requireResidentKey;
}
public function getUserVerification(): string
{
return $this->userVerification;
}
public function getResidentKey(): null|string
{
return $this->residentKey;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
$authenticatorAttachment = $json['authenticatorAttachment'] ?? null;
$requireResidentKey = $json['requireResidentKey'] ?? false;
$userVerification = $json['userVerification'] ?? self::USER_VERIFICATION_REQUIREMENT_PREFERRED;
$residentKey = $json['residentKey'] ?? self::RESIDENT_KEY_REQUIREMENT_PREFERRED;
$authenticatorAttachment === null || is_string($authenticatorAttachment) || throw InvalidDataException::create(
$json,
'Invalid "authenticatorAttachment" value'
);
is_bool($requireResidentKey) || throw InvalidDataException::create(
$json,
'Invalid "requireResidentKey" value'
);
is_string($userVerification) || throw InvalidDataException::create($json, 'Invalid "userVerification" value');
is_string($residentKey) || throw InvalidDataException::create($json, 'Invalid "residentKey" value');
return self::create()
->setAuthenticatorAttachment($authenticatorAttachment)
->setRequireResidentKey($requireResidentKey)
->setUserVerification($userVerification)
->setResidentKey($residentKey);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'requireResidentKey' => $this->requireResidentKey,
'userVerification' => $this->userVerification,
// 'residentKey' => $this->residentKey, // TODO: On hold. Waiting for issue clarification. See https://github.com/fido-alliance/conformance-test-tools-resources/issues/676
];
if ($this->authenticatorAttachment !== null) {
$json['authenticatorAttachment'] = $this->authenticatorAttachment;
}
return $json;
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Webauthn\CertificateChainChecker;
use Webauthn\MetadataService\CertificateChain\CertificateChainValidator;
/**
* @deprecated since v4.1. Please use Webauthn\MetadataService\CertificateChainChecker\CertificateChainValidator instead
*/
interface CertificateChainChecker extends CertificateChainValidator
{
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Webauthn\CertificateChainChecker;
use Webauthn\MetadataService\CertificateChain\PhpCertificateChainValidator;
/**
* @deprecated since v4.1. Please use Webauthn\MetadataService\CertificateChainChecker\PhpCertificateChainValidator instead
*/
final class PhpCertificateChainChecker extends PhpCertificateChainValidator
{
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox as BaseCertificateToolbox;
/**
* @deprecated since v4.1. Please use Webauthn\MetadataService\CertificateChainChecker\PhpCertificateChainValidator instead
*/
class CertificateToolbox extends BaseCertificateToolbox
{
}

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use function is_array;
use function is_string;
use const JSON_THROW_ON_ERROR;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\Exception\InvalidDataException;
use Webauthn\TokenBinding\TokenBinding;
class CollectedClientData
{
/**
* @var mixed[]
*/
private readonly array $data;
private readonly string $type;
private readonly string $challenge;
private readonly string $origin;
private readonly bool $crossOrigin;
/**
* @var mixed[]|null
* @deprecated Since 4.3.0 and will be removed in 5.0.0
*/
private readonly ?array $tokenBinding;
/**
* @param mixed[] $data
*/
public function __construct(
private readonly string $rawData,
array $data
) {
$type = $data['type'] ?? '';
(is_string($type) && $type !== '') || throw InvalidDataException::create(
$data,
'Invalid parameter "type". Shall be a non-empty string.'
);
$this->type = $type;
$challenge = $data['challenge'] ?? '';
is_string($challenge) || throw InvalidDataException::create(
$data,
'Invalid parameter "challenge". Shall be a string.'
);
$challenge = Base64UrlSafe::decodeNoPadding($challenge);
$challenge !== '' || throw InvalidDataException::create(
$data,
'Invalid parameter "challenge". Shall not be empty.'
);
$this->challenge = $challenge;
$origin = $data['origin'] ?? '';
(is_string($origin) && $origin !== '') || throw InvalidDataException::create(
$data,
'Invalid parameter "origin". Shall be a non-empty string.'
);
$this->origin = $origin;
$this->crossOrigin = $data['crossOrigin'] ?? false;
$tokenBinding = $data['tokenBinding'] ?? null;
$tokenBinding === null || is_array($tokenBinding) || throw InvalidDataException::create(
$data,
'Invalid parameter "tokenBinding". Shall be an object or .'
);
$this->tokenBinding = $tokenBinding;
$this->data = $data;
}
public static function createFormJson(string $data): self
{
$rawData = Base64UrlSafe::decodeNoPadding($data);
$json = json_decode($rawData, true, 512, JSON_THROW_ON_ERROR);
return new self($rawData, $json);
}
public function getType(): string
{
return $this->type;
}
public function getChallenge(): string
{
return $this->challenge;
}
public function getOrigin(): string
{
return $this->origin;
}
public function getCrossOrigin(): bool
{
return $this->crossOrigin;
}
/**
* @deprecated Since 4.3.0 and will be removed in 5.0.0
*/
public function getTokenBinding(): ?TokenBinding
{
return $this->tokenBinding === null ? null : TokenBinding::createFormArray($this->tokenBinding);
}
public function getRawData(): string
{
return $this->rawData;
}
/**
* @return string[]
*/
public function all(): array
{
return array_keys($this->data);
}
public function has(string $key): bool
{
return array_key_exists($key, $this->data);
}
public function get(string $key): mixed
{
if (! $this->has($key)) {
throw InvalidDataException::create($this->data, sprintf('The key "%s" is missing', $key));
}
return $this->data[$key];
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Webauthn\Counter;
use Webauthn\PublicKeyCredentialSource;
interface CounterChecker
{
public function check(PublicKeyCredentialSource $publicKeyCredentialSource, int $currentCounter): void;
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Webauthn\Counter;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Throwable;
use Webauthn\Exception\CounterException;
use Webauthn\MetadataService\CanLogData;
use Webauthn\PublicKeyCredentialSource;
final class ThrowExceptionIfInvalid implements CounterChecker, CanLogData
{
public function __construct(
private LoggerInterface $logger = new NullLogger()
) {
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
public function check(PublicKeyCredentialSource $publicKeyCredentialSource, int $currentCounter): void
{
try {
$currentCounter > $publicKeyCredentialSource->getCounter() || throw CounterException::create(
$currentCounter,
$publicKeyCredentialSource->getCounter(),
'Invalid counter.'
);
} catch (Throwable $throwable) {
$this->logger->error('The counter is invalid', [
'current' => $currentCounter,
'new' => $publicKeyCredentialSource->getCounter(),
]);
throw $throwable;
}
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Webauthn;
/**
* @see https://w3c.github.io/webappsec-credential-management/#credential
*/
abstract class Credential
{
public function __construct(
protected string $id,
protected string $type
) {
}
public function getId(): string
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Webauthn\Event;
use Webauthn\AttestationStatement\AttestationObject;
use Webauthn\MetadataService\Event\WebauthnEvent;
class AttestationObjectLoaded implements WebauthnEvent
{
public function __construct(
public readonly AttestationObject $attestationObject
) {
}
public static function create(AttestationObject $attestationObject): self
{
return new self($attestationObject);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Webauthn\Event;
use Webauthn\AttestationStatement\AttestationStatement;
use Webauthn\MetadataService\Event\WebauthnEvent;
class AttestationStatementLoaded implements WebauthnEvent
{
public function __construct(
public readonly AttestationStatement $attestationStatement
) {
}
public static function create(AttestationStatement $attestationStatement): self
{
return new self($attestationStatement);
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Webauthn\Event;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\PublicKeyCredentialRequestOptions;
class AuthenticatorAssertionResponseValidationFailedEvent
{
public function __construct(
private readonly string $credentialId,
private readonly AuthenticatorAssertionResponse $authenticatorAssertionResponse,
private readonly PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
public readonly ServerRequestInterface|string $host,
private readonly ?string $userHandle,
private readonly Throwable $throwable
) {
if ($host instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
}
public function getCredentialId(): string
{
return $this->credentialId;
}
public function getAuthenticatorAssertionResponse(): AuthenticatorAssertionResponse
{
return $this->authenticatorAssertionResponse;
}
public function getPublicKeyCredentialRequestOptions(): PublicKeyCredentialRequestOptions
{
return $this->publicKeyCredentialRequestOptions;
}
/**
* @deprecated since 4.5.0 and will be removed in 5.0.0. Please use the `host` property instead
*/
public function getRequest(): ServerRequestInterface|string
{
return $this->host;
}
public function getUserHandle(): ?string
{
return $this->userHandle;
}
public function getThrowable(): Throwable
{
return $this->throwable;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Webauthn\Event;
use Psr\Http\Message\ServerRequestInterface;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialSource;
class AuthenticatorAssertionResponseValidationSucceededEvent
{
public function __construct(
private readonly string $credentialId,
private readonly AuthenticatorAssertionResponse $authenticatorAssertionResponse,
private readonly PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
public readonly ServerRequestInterface|string $host,
private readonly ?string $userHandle,
private readonly PublicKeyCredentialSource $publicKeyCredentialSource
) {
if ($host instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
}
public function getCredentialId(): string
{
return $this->credentialId;
}
public function getAuthenticatorAssertionResponse(): AuthenticatorAssertionResponse
{
return $this->authenticatorAssertionResponse;
}
public function getPublicKeyCredentialRequestOptions(): PublicKeyCredentialRequestOptions
{
return $this->publicKeyCredentialRequestOptions;
}
/**
* @deprecated since 4.5.0 and will be removed in 5.0.0. Please use the `host` property instead
*/
public function getRequest(): ServerRequestInterface|string
{
return $this->host;
}
public function getUserHandle(): ?string
{
return $this->userHandle;
}
public function getPublicKeyCredentialSource(): PublicKeyCredentialSource
{
return $this->publicKeyCredentialSource;
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Webauthn\Event;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\PublicKeyCredentialCreationOptions;
class AuthenticatorAttestationResponseValidationFailedEvent
{
public function __construct(
private readonly AuthenticatorAttestationResponse $authenticatorAttestationResponse,
private readonly PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
public readonly ServerRequestInterface|string $host,
private readonly Throwable $throwable
) {
if ($host instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
}
public function getAuthenticatorAttestationResponse(): AuthenticatorAttestationResponse
{
return $this->authenticatorAttestationResponse;
}
public function getPublicKeyCredentialCreationOptions(): PublicKeyCredentialCreationOptions
{
return $this->publicKeyCredentialCreationOptions;
}
/**
* @deprecated since 4.5.0 and will be removed in 5.0.0. Please use the `host` property instead
*/
public function getRequest(): ServerRequestInterface|string
{
return $this->host;
}
public function getThrowable(): Throwable
{
return $this->throwable;
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Webauthn\Event;
use Psr\Http\Message\ServerRequestInterface;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialSource;
class AuthenticatorAttestationResponseValidationSucceededEvent
{
public function __construct(
private readonly AuthenticatorAttestationResponse $authenticatorAttestationResponse,
private readonly PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
public readonly ServerRequestInterface|string $host,
private readonly PublicKeyCredentialSource $publicKeyCredentialSource
) {
if ($host instanceof ServerRequestInterface) {
trigger_deprecation(
'web-auth/webauthn-lib',
'4.5.0',
sprintf(
'Passing a %s to the class "%s" is deprecated since 4.5.0 and will be removed in 5.0.0. Please inject the host as a string instead.',
ServerRequestInterface::class,
self::class
)
);
}
}
public function getAuthenticatorAttestationResponse(): AuthenticatorAttestationResponse
{
return $this->authenticatorAttestationResponse;
}
public function getPublicKeyCredentialCreationOptions(): PublicKeyCredentialCreationOptions
{
return $this->publicKeyCredentialCreationOptions;
}
/**
* @deprecated since 4.5.0 and will be removed in 5.0.0. Please use the `host` property instead
*/
public function getRequest(): ServerRequestInterface|string
{
return $this->host;
}
public function getPublicKeyCredentialSource(): PublicKeyCredentialSource
{
return $this->publicKeyCredentialSource;
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
class AttestationStatementException extends WebauthnException
{
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
final class AttestationStatementLoadingException extends AttestationStatementException
{
/**
* @param array<string, mixed> $attestation
*/
public function __construct(
public readonly array $attestation,
string $message,
?Throwable $previous = null
) {
parent::__construct($message, $previous);
}
/**
* @param array<string, mixed> $attestation
*/
public static function create(
array $attestation,
string $message = 'Invalid attestation object',
?Throwable $previous = null
): self {
return new self($attestation, $message, $previous);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
final class AttestationStatementVerificationException extends AttestationStatementException
{
public static function create(string $message = 'Invalid attestation object', ?Throwable $previous = null): self
{
return new self($message, $previous);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
final class AuthenticationExtensionException extends WebauthnException
{
public static function create(string $message, ?Throwable $previous = null): self
{
return new self($message, $previous);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
final class AuthenticatorResponseVerificationException extends WebauthnException
{
public static function create(string $message, ?Throwable $previous = null): self
{
return new self($message, $previous);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
final class CounterException extends WebauthnException
{
public function __construct(
public int $currentCounter,
public int $authenticatorCounter,
string $message,
?Throwable $previous = null
) {
parent::__construct($message, $previous);
}
public static function create(
int $currentCounter,
int $authenticatorCounter,
string $message,
?Throwable $previous = null
): self {
return new self($currentCounter, $authenticatorCounter, $message, $previous);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatement;
final class InvalidAttestationStatementException extends AttestationStatementException
{
public function __construct(
public readonly AttestationStatement $attestationStatement,
string $message,
?Throwable $previous = null
) {
parent::__construct($message, $previous);
}
public static function create(
AttestationStatement $attestationStatement,
string $message = 'Invalid attestation statement',
?Throwable $previous = null
): self {
return new self($attestationStatement, $message, $previous);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
final class InvalidDataException extends WebauthnException
{
public function __construct(
public readonly mixed $data,
string $message,
?Throwable $previous = null
) {
parent::__construct($message, $previous);
}
public static function create(mixed $data, string $message, ?Throwable $previous = null): self
{
return new self($data, $message, $previous);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
final class InvalidTrustPathException extends WebauthnException
{
public static function create(string $message, ?Throwable $previous = null): self
{
return new self($message, $previous);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Throwable;
final class UnsupportedFeatureException extends WebauthnException
{
public static function create(string $message, ?Throwable $previous = null): self
{
return new self($message, $previous);
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Webauthn\Exception;
use Exception;
use Throwable;
class WebauthnException extends Exception
{
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use const JSON_THROW_ON_ERROR;
use Stringable;
/**
* @see https://www.w3.org/TR/webauthn/#iface-pkcredential
*/
class PublicKeyCredential extends Credential implements Stringable
{
public function __construct(
string $id,
string $type,
protected string $rawId,
protected AuthenticatorResponse $response
) {
parent::__construct($id, $type);
}
public function __toString(): string
{
return json_encode($this, JSON_THROW_ON_ERROR);
}
public function getRawId(): string
{
return $this->rawId;
}
public function getResponse(): AuthenticatorResponse
{
return $this->response;
}
/**
* @param string[] $transport
*/
public function getPublicKeyCredentialDescriptor(array $transport = []): PublicKeyCredentialDescriptor
{
return new PublicKeyCredentialDescriptor($this->getType(), $this->getRawId(), $transport);
}
}

View File

@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use function count;
use function in_array;
use function is_array;
use const JSON_THROW_ON_ERROR;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\Exception\InvalidDataException;
use Webauthn\Util\Base64;
final class PublicKeyCredentialCreationOptions extends PublicKeyCredentialOptions
{
public const ATTESTATION_CONVEYANCE_PREFERENCE_NONE = 'none';
public const ATTESTATION_CONVEYANCE_PREFERENCE_INDIRECT = 'indirect';
public const ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT = 'direct';
public const ATTESTATION_CONVEYANCE_PREFERENCE_ENTERPRISE = 'enterprise';
/**
* @var PublicKeyCredentialDescriptor[]
*/
private array $excludeCredentials = [];
private ?AuthenticatorSelectionCriteria $authenticatorSelection = null;
private ?string $attestation = null;
/**
* @param PublicKeyCredentialParameters[] $pubKeyCredParams
*/
public function __construct(
private readonly PublicKeyCredentialRpEntity $rp,
private readonly PublicKeyCredentialUserEntity $user,
string $challenge,
private array $pubKeyCredParams
) {
parent::__construct($challenge);
}
/**
* @param PublicKeyCredentialParameters[] $pubKeyCredParams
*/
public static function create(
PublicKeyCredentialRpEntity $rp,
PublicKeyCredentialUserEntity $user,
string $challenge,
array $pubKeyCredParams
): self {
return new self($rp, $user, $challenge, $pubKeyCredParams);
}
public function addPubKeyCredParam(PublicKeyCredentialParameters $pubKeyCredParam): self
{
$this->pubKeyCredParams[] = $pubKeyCredParam;
return $this;
}
public function addPubKeyCredParams(PublicKeyCredentialParameters ...$pubKeyCredParams): self
{
foreach ($pubKeyCredParams as $pubKeyCredParam) {
$this->addPubKeyCredParam($pubKeyCredParam);
}
return $this;
}
public function excludeCredential(PublicKeyCredentialDescriptor $excludeCredential): self
{
$this->excludeCredentials[] = $excludeCredential;
return $this;
}
public function excludeCredentials(PublicKeyCredentialDescriptor ...$excludeCredentials): self
{
foreach ($excludeCredentials as $excludeCredential) {
$this->excludeCredential($excludeCredential);
}
return $this;
}
public function setAuthenticatorSelection(?AuthenticatorSelectionCriteria $authenticatorSelection): self
{
$this->authenticatorSelection = $authenticatorSelection;
return $this;
}
public function setAttestation(string $attestation): self
{
in_array($attestation, [
self::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
self::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT,
self::ATTESTATION_CONVEYANCE_PREFERENCE_INDIRECT,
self::ATTESTATION_CONVEYANCE_PREFERENCE_ENTERPRISE,
], true) || throw InvalidDataException::create($attestation, 'Invalid attestation conveyance mode');
$this->attestation = $attestation;
return $this;
}
public function getRp(): PublicKeyCredentialRpEntity
{
return $this->rp;
}
public function getUser(): PublicKeyCredentialUserEntity
{
return $this->user;
}
/**
* @return PublicKeyCredentialParameters[]
*/
public function getPubKeyCredParams(): array
{
return $this->pubKeyCredParams;
}
/**
* @return PublicKeyCredentialDescriptor[]
*/
public function getExcludeCredentials(): array
{
return $this->excludeCredentials;
}
public function getAuthenticatorSelection(): ?AuthenticatorSelectionCriteria
{
return $this->authenticatorSelection;
}
public function getAttestation(): ?string
{
return $this->attestation;
}
public static function createFromString(string $data): static
{
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return self::createFromArray($data);
}
public static function createFromArray(array $json): static
{
array_key_exists('rp', $json) || throw InvalidDataException::create($json, 'Invalid input. "rp" is missing.');
array_key_exists('pubKeyCredParams', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "pubKeyCredParams" is missing.'
);
is_array($json['pubKeyCredParams']) || throw InvalidDataException::create(
$json,
'Invalid input. "pubKeyCredParams" is not an array.'
);
array_key_exists('challenge', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "challenge" is missing.'
);
array_key_exists('attestation', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "attestation" is missing.'
);
array_key_exists('user', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "user" is missing.'
);
$pubKeyCredParams = [];
foreach ($json['pubKeyCredParams'] as $pubKeyCredParam) {
if (! is_array($pubKeyCredParam)) {
continue;
}
$pubKeyCredParams[] = PublicKeyCredentialParameters::createFromArray($pubKeyCredParam);
}
$excludeCredentials = [];
if (isset($json['excludeCredentials'])) {
foreach ($json['excludeCredentials'] as $excludeCredential) {
$excludeCredentials[] = PublicKeyCredentialDescriptor::createFromArray($excludeCredential);
}
}
$challenge = Base64::decode($json['challenge']);
return self
::create(
PublicKeyCredentialRpEntity::createFromArray($json['rp']),
PublicKeyCredentialUserEntity::createFromArray($json['user']),
$challenge,
$pubKeyCredParams
)
->setTimeout($json['timeout'] ?? null)
->excludeCredentials(...$excludeCredentials)
->setAuthenticatorSelection(
isset($json['authenticatorSelection']) ? AuthenticatorSelectionCriteria::createFromArray(
$json['authenticatorSelection']
) : null
)
->setAttestation($json['attestation'] ?? null)
->setExtensions(
isset($json['extensions']) ? AuthenticationExtensionsClientInputs::createFromArray(
$json['extensions']
) : new AuthenticationExtensionsClientInputs()
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'rp' => $this->rp->jsonSerialize(),
'user' => $this->user->jsonSerialize(),
'challenge' => Base64UrlSafe::encodeUnpadded($this->challenge),
'pubKeyCredParams' => array_map(
static fn (PublicKeyCredentialParameters $object): array => $object->jsonSerialize(),
$this->pubKeyCredParams
),
];
if ($this->timeout !== null) {
$json['timeout'] = $this->timeout;
}
if (count($this->excludeCredentials) !== 0) {
$json['excludeCredentials'] = array_map(
static fn (PublicKeyCredentialDescriptor $object): array => $object->jsonSerialize(),
$this->excludeCredentials
);
}
if ($this->authenticatorSelection !== null) {
$json['authenticatorSelection'] = $this->authenticatorSelection->jsonSerialize();
}
if ($this->attestation !== null) {
$json['attestation'] = $this->attestation;
}
if ($this->extensions->count() !== 0) {
$json['extensions'] = $this->extensions;
}
return $json;
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use function count;
use const JSON_THROW_ON_ERROR;
use JsonSerializable;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\Exception\InvalidDataException;
class PublicKeyCredentialDescriptor implements JsonSerializable
{
final public const CREDENTIAL_TYPE_PUBLIC_KEY = 'public-key';
final public const AUTHENTICATOR_TRANSPORT_USB = 'usb';
final public const AUTHENTICATOR_TRANSPORT_NFC = 'nfc';
final public const AUTHENTICATOR_TRANSPORT_BLE = 'ble';
final public const AUTHENTICATOR_TRANSPORT_CABLE = 'cable';
final public const AUTHENTICATOR_TRANSPORT_INTERNAL = 'internal';
/**
* @param string[] $transports
*/
public function __construct(
protected string $type,
protected string $id,
protected array $transports = []
) {
}
/**
* @param string[] $transports
*/
public static function create(string $type, string $id, array $transports = []): self
{
return new self($type, $id, $transports);
}
public function getType(): string
{
return $this->type;
}
public function getId(): string
{
return $this->id;
}
/**
* @return string[]
*/
public function getTransports(): array
{
return $this->transports;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
array_key_exists('type', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "type" is missing.'
);
array_key_exists('id', $json) || throw InvalidDataException::create($json, 'Invalid input. "id" is missing.');
$id = Base64UrlSafe::decodeNoPadding($json['id']);
return new self($json['type'], $id, $json['transports'] ?? []);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'type' => $this->type,
'id' => Base64UrlSafe::encodeUnpadded($this->id),
];
if (count($this->transports) !== 0) {
$json['transports'] = $this->transports;
}
return $json;
}
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use ArrayIterator;
use function count;
use const COUNT_NORMAL;
use Countable;
use function is_array;
use Iterator;
use IteratorAggregate;
use const JSON_THROW_ON_ERROR;
use JsonSerializable;
/**
* @implements IteratorAggregate<PublicKeyCredentialDescriptor>
*/
class PublicKeyCredentialDescriptorCollection implements JsonSerializable, Countable, IteratorAggregate
{
/**
* @var PublicKeyCredentialDescriptor[]
*/
private array $publicKeyCredentialDescriptors = [];
public function add(PublicKeyCredentialDescriptor ...$publicKeyCredentialDescriptors): void
{
foreach ($publicKeyCredentialDescriptors as $publicKeyCredentialDescriptor) {
$this->publicKeyCredentialDescriptors[$publicKeyCredentialDescriptor->getId()] = $publicKeyCredentialDescriptor;
}
}
public function has(string $id): bool
{
return array_key_exists($id, $this->publicKeyCredentialDescriptors);
}
public function remove(string $id): void
{
if (! $this->has($id)) {
return;
}
unset($this->publicKeyCredentialDescriptors[$id]);
}
/**
* @return Iterator<string, PublicKeyCredentialDescriptor>
*/
public function getIterator(): Iterator
{
return new ArrayIterator($this->publicKeyCredentialDescriptors);
}
public function count(int $mode = COUNT_NORMAL): int
{
return count($this->publicKeyCredentialDescriptors, $mode);
}
/**
* @return array<string, mixed>[]
*/
public function jsonSerialize(): array
{
return array_map(
static fn (PublicKeyCredentialDescriptor $object): array => $object->jsonSerialize(),
$this->publicKeyCredentialDescriptors
);
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
$collection = new self();
foreach ($json as $item) {
if (! is_array($item)) {
continue;
}
$collection->add(PublicKeyCredentialDescriptor::createFromArray($item));
}
return $collection;
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use JsonSerializable;
abstract class PublicKeyCredentialEntity implements JsonSerializable
{
public function __construct(
protected string $name,
protected ?string $icon
) {
}
public function getName(): string
{
return $this->name;
}
public function getIcon(): ?string
{
return $this->icon;
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'name' => $this->name,
];
if ($this->icon !== null) {
$json['icon'] = $this->icon;
}
return $json;
}
}

View File

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use CBOR\Decoder;
use CBOR\MapObject;
use function is_array;
use function is_string;
use const JSON_THROW_ON_ERROR;
use function ord;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Uid\Uuid;
use Throwable;
use function unpack;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputsLoader;
use Webauthn\Exception\InvalidDataException;
use Webauthn\MetadataService\CanLogData;
use Webauthn\Util\Base64;
class PublicKeyCredentialLoader implements CanLogData
{
private const FLAG_AT = 0b01000000;
private const FLAG_ED = 0b10000000;
private readonly Decoder $decoder;
private LoggerInterface $logger;
public function __construct(
private readonly AttestationObjectLoader $attestationObjectLoader
) {
$this->decoder = Decoder::create();
$this->logger = new NullLogger();
}
public static function create(AttestationObjectLoader $attestationObjectLoader): self
{
return new self($attestationObjectLoader);
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
/**
* @param mixed[] $json
*/
public function loadArray(array $json): PublicKeyCredential
{
$this->logger->info('Trying to load data from an array', [
'data' => $json,
]);
try {
foreach (['id', 'rawId', 'type'] as $key) {
array_key_exists($key, $json) || throw InvalidDataException::create($json, sprintf(
'The parameter "%s" is missing',
$key
));
is_string($json[$key]) || throw InvalidDataException::create($json, sprintf(
'The parameter "%s" shall be a string',
$key
));
}
array_key_exists('response', $json) || throw InvalidDataException::create(
$json,
'The parameter "response" is missing'
);
is_array($json['response']) || throw InvalidDataException::create(
$json,
'The parameter "response" shall be an array'
);
$json['type'] === 'public-key' || throw InvalidDataException::create($json, sprintf(
'Unsupported type "%s"',
$json['type']
));
$id = Base64UrlSafe::decodeNoPadding($json['id']);
$rawId = Base64::decode($json['rawId']);
hash_equals($id, $rawId) || throw InvalidDataException::create($json, 'Invalid ID');
$publicKeyCredential = new PublicKeyCredential(
$json['id'],
$json['type'],
$rawId,
$this->createResponse($json['response'])
);
$this->logger->info('The data has been loaded');
$this->logger->debug('Public Key Credential', [
'publicKeyCredential' => $publicKeyCredential,
]);
return $publicKeyCredential;
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
throw $throwable;
}
}
public function load(string $data): PublicKeyCredential
{
$this->logger->info('Trying to load data from a string', [
'data' => $data,
]);
try {
$json = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return $this->loadArray($json);
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
throw InvalidDataException::create($data, 'Unable to load the data', $throwable);
}
}
/**
* @param mixed[] $response
*/
private function createResponse(array $response): AuthenticatorResponse
{
array_key_exists('clientDataJSON', $response) || throw InvalidDataException::create(
$response,
'Invalid data. The parameter "clientDataJSON" is missing'
);
is_string($response['clientDataJSON']) || throw InvalidDataException::create(
$response,
'Invalid data. The parameter "clientDataJSON" is invalid'
);
$userHandle = $response['userHandle'] ?? null;
$userHandle === null || is_string($userHandle) || throw InvalidDataException::create(
$response,
'Invalid data. The parameter "userHandle" is invalid'
);
switch (true) {
case array_key_exists('attestationObject', $response):
is_string($response['attestationObject']) || throw InvalidDataException::create(
$response,
'Invalid data. The parameter "attestationObject " is invalid'
);
$attestationObject = $this->attestationObjectLoader->load($response['attestationObject']);
return new AuthenticatorAttestationResponse(CollectedClientData::createFormJson(
$response['clientDataJSON']
), $attestationObject);
case array_key_exists('authenticatorData', $response) && array_key_exists('signature', $response):
$authData = Base64UrlSafe::decodeNoPadding($response['authenticatorData']);
$authDataStream = new StringStream($authData);
$rp_id_hash = $authDataStream->read(32);
$flags = $authDataStream->read(1);
$signCount = $authDataStream->read(4);
$signCount = unpack('N', $signCount);
$attestedCredentialData = null;
if (0 !== (ord($flags) & self::FLAG_AT)) {
$aaguid = Uuid::fromBinary($authDataStream->read(16));
$credentialLength = $authDataStream->read(2);
$credentialLength = unpack('n', $credentialLength);
$credentialId = $authDataStream->read($credentialLength[1]);
$credentialPublicKey = $this->decoder->decode($authDataStream);
$credentialPublicKey instanceof MapObject || throw InvalidDataException::create(
$authData,
'The data does not contain a valid credential public key.'
);
$attestedCredentialData = new AttestedCredentialData(
$aaguid,
$credentialId,
(string) $credentialPublicKey
);
}
$extension = null;
if (0 !== (ord($flags) & self::FLAG_ED)) {
$extension = $this->decoder->decode($authDataStream);
$extension = AuthenticationExtensionsClientOutputsLoader::load($extension);
}
$authDataStream->isEOF() || throw InvalidDataException::create(
$authData,
'Invalid authentication data. Presence of extra bytes.'
);
$authDataStream->close();
$authenticatorData = new AuthenticatorData(
$authData,
$rp_id_hash,
$flags,
$signCount[1],
$attestedCredentialData,
$extension
);
try {
$signature = Base64::decode($response['signature']);
} catch (Throwable $e) {
throw InvalidDataException::create(
$response['signature'],
'The signature shall be Base64 Url Safe encoded',
$e
);
}
return new AuthenticatorAssertionResponse(
CollectedClientData::createFormJson($response['clientDataJSON']),
$authenticatorData,
$signature,
$response['userHandle'] ?? null
);
default:
throw InvalidDataException::create($response, 'Unable to create the response object');
}
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use JsonSerializable;
use Webauthn\AuthenticationExtensions\AuthenticationExtension;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
abstract class PublicKeyCredentialOptions implements JsonSerializable
{
protected ?int $timeout = null;
protected AuthenticationExtensionsClientInputs $extensions;
public function __construct(
protected string $challenge
) {
$this->extensions = new AuthenticationExtensionsClientInputs();
}
public function setTimeout(?int $timeout): static
{
$this->timeout = $timeout;
return $this;
}
public function addExtension(AuthenticationExtension $extension): static
{
$this->extensions->add($extension);
return $this;
}
/**
* @param AuthenticationExtension[] $extensions
*/
public function addExtensions(array $extensions): static
{
foreach ($extensions as $extension) {
$this->addExtension($extension);
}
return $this;
}
public function setExtensions(AuthenticationExtensionsClientInputs $extensions): static
{
$this->extensions = $extensions;
return $this;
}
public function getChallenge(): string
{
return $this->challenge;
}
public function getTimeout(): ?int
{
return $this->timeout;
}
public function getExtensions(): AuthenticationExtensionsClientInputs
{
return $this->extensions;
}
abstract public static function createFromString(string $data): static;
/**
* @param mixed[] $json
*/
abstract public static function createFromArray(array $json): static;
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use const JSON_THROW_ON_ERROR;
use JsonSerializable;
use Webauthn\Exception\InvalidDataException;
class PublicKeyCredentialParameters implements JsonSerializable
{
public function __construct(
private readonly string $type,
private readonly int $alg
) {
}
public static function create(string $type, int $alg): self
{
return new self($type, $alg);
}
public function getType(): string
{
return $this->type;
}
public function getAlg(): int
{
return $this->alg;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
array_key_exists('type', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "type" is missing.'
);
array_key_exists('alg', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "alg" is missing.'
);
return new self($json['type'], $json['alg']);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return [
'type' => $this->type,
'alg' => $this->alg,
];
}
}

View File

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use function count;
use function in_array;
use const JSON_THROW_ON_ERROR;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\Exception\InvalidDataException;
use Webauthn\Util\Base64;
final class PublicKeyCredentialRequestOptions extends PublicKeyCredentialOptions
{
public const USER_VERIFICATION_REQUIREMENT_REQUIRED = 'required';
public const USER_VERIFICATION_REQUIREMENT_PREFERRED = 'preferred';
public const USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 'discouraged';
private ?string $rpId = null;
/**
* @var PublicKeyCredentialDescriptor[]
*/
private array $allowCredentials = [];
private ?string $userVerification = null;
public static function create(string $challenge): self
{
return new self($challenge);
}
public function setRpId(?string $rpId): self
{
$this->rpId = $rpId;
return $this;
}
public function allowCredential(PublicKeyCredentialDescriptor $allowCredential): self
{
$this->allowCredentials[] = $allowCredential;
return $this;
}
public function allowCredentials(PublicKeyCredentialDescriptor ...$allowCredentials): self
{
foreach ($allowCredentials as $allowCredential) {
$this->allowCredential($allowCredential);
}
return $this;
}
public function setUserVerification(?string $userVerification): self
{
if ($userVerification === null) {
$this->rpId = null;
return $this;
}
in_array($userVerification, [
self::USER_VERIFICATION_REQUIREMENT_REQUIRED,
self::USER_VERIFICATION_REQUIREMENT_PREFERRED,
self::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
], true) || throw InvalidDataException::create($userVerification, 'Invalid user verification requirement');
$this->userVerification = $userVerification;
return $this;
}
public function getRpId(): ?string
{
return $this->rpId;
}
/**
* @return PublicKeyCredentialDescriptor[]
*/
public function getAllowCredentials(): array
{
return $this->allowCredentials;
}
public function getUserVerification(): ?string
{
return $this->userVerification;
}
public static function createFromString(string $data): static
{
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): static
{
array_key_exists('challenge', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "challenge" is missing.'
);
$allowCredentials = [];
$allowCredentialList = $json['allowCredentials'] ?? [];
foreach ($allowCredentialList as $allowCredential) {
$allowCredentials[] = PublicKeyCredentialDescriptor::createFromArray($allowCredential);
}
$challenge = Base64::decode($json['challenge']);
return self::create($challenge)
->setRpId($json['rpId'] ?? null)
->allowCredentials(...$allowCredentials)
->setUserVerification($json['userVerification'] ?? null)
->setTimeout($json['timeout'] ?? null)
->setExtensions(
isset($json['extensions']) ? AuthenticationExtensionsClientInputs::createFromArray(
$json['extensions']
) : new AuthenticationExtensionsClientInputs()
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'challenge' => Base64UrlSafe::encodeUnpadded($this->challenge),
];
if ($this->rpId !== null) {
$json['rpId'] = $this->rpId;
}
if ($this->userVerification !== null) {
$json['userVerification'] = $this->userVerification;
}
if (count($this->allowCredentials) !== 0) {
$json['allowCredentials'] = array_map(
static fn (PublicKeyCredentialDescriptor $object): array => $object->jsonSerialize(),
$this->allowCredentials
);
}
if ($this->extensions->count() !== 0) {
$json['extensions'] = $this->extensions->jsonSerialize();
}
if ($this->timeout !== null) {
$json['timeout'] = $this->timeout;
}
return $json;
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use Webauthn\Exception\InvalidDataException;
class PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity
{
public function __construct(
string $name,
protected ?string $id = null,
?string $icon = null
) {
parent::__construct($name, $icon);
}
public static function create(string $name, ?string $id = null, ?string $icon = null): self
{
return new self($name, $id, $icon);
}
public function getId(): ?string
{
return $this->id;
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
array_key_exists('name', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "name" is missing.'
);
return new self($json['name'], $json['id'] ?? null, $json['icon'] ?? null);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = parent::jsonSerialize();
if ($this->id !== null) {
$json['id'] = $this->id;
}
return $json;
}
}

View File

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use JsonSerializable;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Symfony\Component\Uid\AbstractUid;
use Symfony\Component\Uid\Uuid;
use Throwable;
use Webauthn\Exception\InvalidDataException;
use Webauthn\TrustPath\TrustPath;
use Webauthn\TrustPath\TrustPathLoader;
/**
* @see https://www.w3.org/TR/webauthn/#iface-pkcredential
*/
class PublicKeyCredentialSource implements JsonSerializable
{
/**
* @param string[] $transports
* @param array<string, mixed>|null $otherUI
*/
public function __construct(
protected string $publicKeyCredentialId,
protected string $type,
protected array $transports,
protected string $attestationType,
protected TrustPath $trustPath,
protected AbstractUid $aaguid,
protected string $credentialPublicKey,
protected string $userHandle,
protected int $counter,
protected ?array $otherUI = null
) {
}
/**
* @param string[] $transports
* @param array<string, mixed>|null $otherUI
*/
public static function create(
string $publicKeyCredentialId,
string $type,
array $transports,
string $attestationType,
TrustPath $trustPath,
AbstractUid $aaguid,
string $credentialPublicKey,
string $userHandle,
int $counter,
?array $otherUI = null
): self {
return new self(
$publicKeyCredentialId,
$type,
$transports,
$attestationType,
$trustPath,
$aaguid,
$credentialPublicKey,
$userHandle,
$counter,
$otherUI
);
}
public function getPublicKeyCredentialId(): string
{
return $this->publicKeyCredentialId;
}
public function getPublicKeyCredentialDescriptor(): PublicKeyCredentialDescriptor
{
return new PublicKeyCredentialDescriptor($this->type, $this->publicKeyCredentialId, $this->transports);
}
public function getAttestationType(): string
{
return $this->attestationType;
}
public function getTrustPath(): TrustPath
{
return $this->trustPath;
}
public function getAttestedCredentialData(): AttestedCredentialData
{
return new AttestedCredentialData($this->aaguid, $this->publicKeyCredentialId, $this->credentialPublicKey);
}
public function getType(): string
{
return $this->type;
}
/**
* @return string[]
*/
public function getTransports(): array
{
return $this->transports;
}
public function getAaguid(): AbstractUid
{
return $this->aaguid;
}
public function getCredentialPublicKey(): string
{
return $this->credentialPublicKey;
}
public function getUserHandle(): string
{
return $this->userHandle;
}
public function getCounter(): int
{
return $this->counter;
}
public function setCounter(int $counter): void
{
$this->counter = $counter;
}
/**
* @return array<string, mixed>|null
*/
public function getOtherUI(): ?array
{
return $this->otherUI;
}
/**
* @param array<string, mixed>|null $otherUI
*/
public function setOtherUI(?array $otherUI): self
{
$this->otherUI = $otherUI;
return $this;
}
/**
* @param mixed[] $data
*/
public static function createFromArray(array $data): self
{
$keys = array_keys(get_class_vars(self::class));
foreach ($keys as $key) {
if ($key === 'otherUI') {
continue;
}
array_key_exists($key, $data) || throw InvalidDataException::create($data, sprintf(
'The parameter "%s" is missing',
$key
));
}
mb_strlen((string) $data['aaguid'], '8bit') === 36 || throw InvalidDataException::create(
$data,
'Invalid AAGUID'
);
$uuid = Uuid::fromString($data['aaguid']);
try {
return new self(
Base64UrlSafe::decodeNoPadding($data['publicKeyCredentialId']),
$data['type'],
$data['transports'],
$data['attestationType'],
TrustPathLoader::loadTrustPath($data['trustPath']),
$uuid,
Base64UrlSafe::decodeNoPadding($data['credentialPublicKey']),
Base64UrlSafe::decodeNoPadding($data['userHandle']),
$data['counter'],
$data['otherUI'] ?? null
);
} catch (Throwable $throwable) {
throw InvalidDataException::create($data, 'Unable to load the data', $throwable);
}
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return [
'publicKeyCredentialId' => Base64UrlSafe::encodeUnpadded($this->publicKeyCredentialId),
'type' => $this->type,
'transports' => $this->transports,
'attestationType' => $this->attestationType,
'trustPath' => $this->trustPath->jsonSerialize(),
'aaguid' => $this->aaguid->__toString(),
'credentialPublicKey' => Base64UrlSafe::encodeUnpadded($this->credentialPublicKey),
'userHandle' => Base64UrlSafe::encodeUnpadded($this->userHandle),
'counter' => $this->counter,
'otherUI' => $this->otherUI,
];
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Webauthn;
interface PublicKeyCredentialSourceRepository
{
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource;
/**
* @return PublicKeyCredentialSource[]
*/
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array;
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void;
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use function array_key_exists;
use function is_array;
use const JSON_THROW_ON_ERROR;
use ParagonIE\ConstantTime\Base64;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\Exception\InvalidDataException;
class PublicKeyCredentialUserEntity extends PublicKeyCredentialEntity
{
protected string $id;
public function __construct(
string $name,
string $id,
protected string $displayName,
?string $icon = null
) {
parent::__construct($name, $icon);
mb_strlen($id, '8bit') <= 64 || throw InvalidDataException::create($id, 'User ID max length is 64 bytes');
$this->id = $id;
}
public static function create(string $name, string $id, string $displayName, ?string $icon = null): self
{
return new self($name, $id, $displayName, $icon);
}
public function getId(): string
{
return $this->id;
}
public function getDisplayName(): string
{
return $this->displayName;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
is_array($data) || throw InvalidDataException::create($data, 'Invalid data');
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
array_key_exists('name', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "name" is missing.'
);
array_key_exists('id', $json) || throw InvalidDataException::create($json, 'Invalid input. "id" is missing.');
array_key_exists('displayName', $json) || throw InvalidDataException::create(
$json,
'Invalid input. "displayName" is missing.'
);
$id = Base64::decode($json['id'], true);
return new self($json['name'], $id, $json['displayName'], $json['icon'] ?? null);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = parent::jsonSerialize();
$json['id'] = Base64UrlSafe::encodeUnpadded($this->id);
$json['displayName'] = $this->displayName;
return $json;
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use CBOR\Stream;
use function fclose;
use function fopen;
use function fread;
use function fwrite;
use function rewind;
use Webauthn\Exception\InvalidDataException;
final class StringStream implements Stream
{
/**
* @var resource
*/
private $data;
private readonly int $length;
private int $totalRead = 0;
public function __construct(string $data)
{
$this->length = mb_strlen($data, '8bit');
$resource = fopen('php://memory', 'rb+');
fwrite($resource, $data);
rewind($resource);
$this->data = $resource;
}
public function read(int $length): string
{
if ($length <= 0) {
return '';
}
$read = fread($this->data, $length);
$bytesRead = mb_strlen($read, '8bit');
mb_strlen($read, '8bit') === $length || throw InvalidDataException::create(null, sprintf(
'Out of range. Expected: %d, read: %d.',
$length,
$bytesRead
));
$this->totalRead += $bytesRead;
return $read;
}
public function close(): void
{
fclose($this->data);
}
public function isEOF(): bool
{
return $this->totalRead === $this->length;
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Webauthn\TokenBinding;
use Psr\Http\Message\ServerRequestInterface;
/**
* @deprecated Since 4.3.0 and will be removed in 5.0.0
*/
final class IgnoreTokenBindingHandler implements TokenBindingHandler
{
public static function create(): self
{
return new self();
}
public function check(TokenBinding $tokenBinding, ServerRequestInterface $request): void
{
//Does nothing
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Webauthn\TokenBinding;
use function count;
use Psr\Http\Message\ServerRequestInterface;
use Webauthn\Exception\InvalidDataException;
/**
* @deprecated Since 4.3.0 and will be removed in 5.0.0
*/
final class SecTokenBindingHandler implements TokenBindingHandler
{
public static function create(): self
{
return new self();
}
public function check(TokenBinding $tokenBinding, ServerRequestInterface $request): void
{
if ($tokenBinding->getStatus() !== TokenBinding::TOKEN_BINDING_STATUS_PRESENT) {
return;
}
$request->hasHeader('Sec-Token-Binding') || throw InvalidDataException::create(
$tokenBinding,
'The header parameter "Sec-Token-Binding" is missing.'
);
$tokenBindingIds = $request->getHeader('Sec-Token-Binding');
count($tokenBindingIds) === 1 || throw InvalidDataException::create(
$tokenBinding,
'The header parameter "Sec-Token-Binding" is invalid.'
);
$tokenBindingId = reset($tokenBindingIds);
$tokenBindingId === $tokenBinding->getId() || throw InvalidDataException::create(
$tokenBinding,
'The header parameter "Sec-Token-Binding" is invalid.'
);
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Webauthn\TokenBinding;
use function array_key_exists;
use function in_array;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\Exception\InvalidDataException;
/**
* @deprecated Since 4.3.0 and will be removed in 5.0.0
*/
class TokenBinding
{
final public const TOKEN_BINDING_STATUS_PRESENT = 'present';
final public const TOKEN_BINDING_STATUS_SUPPORTED = 'supported';
final public const TOKEN_BINDING_STATUS_NOT_SUPPORTED = 'not-supported';
private readonly string $status;
private readonly ?string $id;
public function __construct(string $status, ?string $id)
{
$status === self::TOKEN_BINDING_STATUS_PRESENT && $id === null && throw InvalidDataException::create(
[$status, $id],
'The member "id" is required when status is "present"'
);
$this->status = $status;
$this->id = $id;
}
/**
* @param mixed[] $json
*/
public static function createFormArray(array $json): self
{
array_key_exists('status', $json) || throw InvalidDataException::create(
$json,
'The member "status" is required'
);
$status = $json['status'];
in_array($status, self::getSupportedStatus(), true) || throw InvalidDataException::create($json, sprintf(
'The member "status" is invalid. Supported values are: %s',
implode(', ', self::getSupportedStatus())
));
$id = array_key_exists('id', $json) ? Base64UrlSafe::decodeNoPadding($json['id']) : null;
return new self($status, $id);
}
public function getStatus(): string
{
return $this->status;
}
public function getId(): ?string
{
return $this->id;
}
/**
* @return string[]
*/
private static function getSupportedStatus(): array
{
return [
self::TOKEN_BINDING_STATUS_PRESENT,
self::TOKEN_BINDING_STATUS_SUPPORTED,
self::TOKEN_BINDING_STATUS_NOT_SUPPORTED,
];
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\TokenBinding;
use Psr\Http\Message\ServerRequestInterface;
/**
* @deprecated Since 4.3.0 and will be removed in 5.0.0
*/
interface TokenBindingHandler
{
public function check(TokenBinding $tokenBinding, ServerRequestInterface $request): void;
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Webauthn\TokenBinding;
use Psr\Http\Message\ServerRequestInterface;
use Webauthn\Exception\InvalidDataException;
/**
* @deprecated Since 4.3.0 and will be removed in 5.0.0
*/
final class TokenBindingNotSupportedHandler implements TokenBindingHandler
{
public static function create(): self
{
return new self();
}
public function check(TokenBinding $tokenBinding, ServerRequestInterface $request): void
{
$tokenBinding->getStatus() !== TokenBinding::TOKEN_BINDING_STATUS_PRESENT || throw InvalidDataException::create(
$tokenBinding,
'Token binding not supported.'
);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Webauthn\TrustPath;
use function array_key_exists;
use function is_array;
use Webauthn\Exception\InvalidTrustPathException;
final class CertificateTrustPath implements TrustPath
{
/**
* @param string[] $certificates
*/
public function __construct(
private readonly array $certificates
) {
}
/**
* @param string[] $certificates
*/
public static function create(array $certificates): self
{
return new self($certificates);
}
/**
* @return string[]
*/
public function getCertificates(): array
{
return $this->certificates;
}
/**
* {@inheritdoc}
*/
public static function createFromArray(array $data): static
{
array_key_exists('x5c', $data) || throw InvalidTrustPathException::create('The trust path type is invalid');
$x5c = $data['x5c'];
is_array($x5c) || throw InvalidTrustPathException::create(
'The trust path type is invalid. The parameter "x5c" shall contain strings.'
);
return new self($x5c);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return [
'type' => self::class,
'x5c' => $this->certificates,
];
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Webauthn\TrustPath;
use function array_key_exists;
use Webauthn\Exception\InvalidTrustPathException;
/**
* @deprecated since 4.2.0 and will be removed in 5.0.0. The ECDAA Trust Anchor does no longer exist in Webauthn specification.
*/
final class EcdaaKeyIdTrustPath implements TrustPath
{
public function __construct(
private readonly string $ecdaaKeyId
) {
}
public function getEcdaaKeyId(): string
{
return $this->ecdaaKeyId;
}
/**
* @return string[]
*/
public function jsonSerialize(): array
{
return [
'type' => self::class,
'ecdaaKeyId' => $this->ecdaaKeyId,
];
}
/**
* {@inheritdoc}
*/
public static function createFromArray(array $data): static
{
array_key_exists('ecdaaKeyId', $data) || throw InvalidTrustPathException::create(
'The trust path type is invalid'
);
return new self($data['ecdaaKeyId']);
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Webauthn\TrustPath;
final class EmptyTrustPath implements TrustPath
{
public static function create(): self
{
return new self();
}
/**
* @return string[]
*/
public function jsonSerialize(): array
{
return [
'type' => self::class,
];
}
/**
* {@inheritdoc}
*/
public static function createFromArray(array $data): static
{
return new self();
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\TrustPath;
use JsonSerializable;
interface TrustPath extends JsonSerializable
{
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): static;
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Webauthn\TrustPath;
use function array_key_exists;
use function class_implements;
use function in_array;
use Webauthn\Exception\InvalidTrustPathException;
abstract class TrustPathLoader
{
/**
* @param mixed[] $data
*/
public static function loadTrustPath(array $data): TrustPath
{
array_key_exists('type', $data) || throw InvalidTrustPathException::create('The trust path type is missing');
$type = $data['type'];
if (class_exists($type) !== true) {
throw InvalidTrustPathException::create(
sprintf('The trust path type "%s" is not supported', $data['type'])
);
}
$implements = class_implements($type);
if (in_array(TrustPath::class, $implements, true)) {
return $type::createFromArray($data);
}
throw InvalidTrustPathException::create(sprintf('The trust path type "%s" is not supported', $data['type']));
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Webauthn;
use CBOR\ByteStringObject;
use CBOR\MapItem;
use CBOR\MapObject;
use CBOR\NegativeIntegerObject;
use CBOR\UnsignedIntegerObject;
use Cose\Algorithms;
use Cose\Key\Ec2Key;
class U2FPublicKey
{
public static function isU2FKey(string $publicKey): bool
{
return $publicKey[0] === "\x04" && mb_strlen($publicKey, '8bit') === 65;
}
public static function convertToCoseKey(string $publicKey): string
{
return MapObject::create([
MapItem::create(
UnsignedIntegerObject::create(Ec2Key::TYPE),
UnsignedIntegerObject::create(Ec2Key::TYPE_EC2)
),
MapItem::create(
UnsignedIntegerObject::create(Ec2Key::ALG),
NegativeIntegerObject::create(Algorithms::COSE_ALGORITHM_ES256)
),
MapItem::create(
NegativeIntegerObject::create(Ec2Key::DATA_CURVE),
UnsignedIntegerObject::create(Ec2Key::CURVE_P256)
),
MapItem::create(
NegativeIntegerObject::create(Ec2Key::DATA_X),
ByteStringObject::create(mb_substr($publicKey, 1, 32, '8bit'))
),
MapItem::create(
NegativeIntegerObject::create(Ec2Key::DATA_Y),
ByteStringObject::create(mb_substr($publicKey, 33, null, '8bit'))
),
])->__toString();
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Webauthn\Util;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Throwable;
use Webauthn\Exception\InvalidDataException;
abstract class Base64
{
public static function decode(string $data): string
{
try {
return Base64UrlSafe::decode($data);
} catch (Throwable) {
}
try {
return \ParagonIE\ConstantTime\Base64::decode($data, true);
} catch (Throwable $e) {
throw InvalidDataException::create($data, 'Invalid data submitted', $e);
}
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Webauthn\Util;
use Cose\Algorithm\Signature\ECDSA;
use Cose\Algorithm\Signature\ECDSA\ECSignature;
use Cose\Algorithm\Signature\ECDSA\ES256;
use Cose\Algorithm\Signature\ECDSA\ES256K;
use Cose\Algorithm\Signature\ECDSA\ES384;
use Cose\Algorithm\Signature\ECDSA\ES512;
use Cose\Algorithm\Signature\Signature;
/**
* This class fixes the signature of the ECDSA based algorithms.
*
* @internal
*
* @see https://www.w3.org/TR/webauthn/#signature-attestation-types
*/
abstract class CoseSignatureFixer
{
public static function fix(string $signature, Signature $algorithm): string
{
switch ($algorithm::identifier()) {
case ES256K::ID:
case ES256::ID:
if (mb_strlen($signature, '8bit') === 64) {
return $signature;
}
return ECSignature::fromAsn1(
$signature,
64
); //TODO: fix this hardcoded value by adding a dedicated method for the algorithms
case ES384::ID:
if (mb_strlen($signature, '8bit') === 96) {
return $signature;
}
return ECSignature::fromAsn1($signature, 96);
case ES512::ID:
if (mb_strlen($signature, '8bit') === 132) {
return $signature;
}
return ECSignature::fromAsn1($signature, 132);
}
return $signature;
}
}