primo commit

This commit is contained in:
2024-12-17 17:34:10 +01:00
commit e650f8df99
16435 changed files with 2451012 additions and 0 deletions

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;
}
}