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,12 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService;
use Psr\Log\LoggerInterface;
interface CanLogData
{
public function setLogger(LoggerInterface $logger): void;
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\CertificateChain;
interface CertificateChainValidator
{
/**
* @param string[] $untrustedCertificates
* @param string[] $trustedCertificates
*/
public function check(array $untrustedCertificates, array $trustedCertificates): void;
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\CertificateChain;
use function in_array;
use ParagonIE\ConstantTime\Base64;
use const PHP_EOL;
use function preg_replace;
class CertificateToolbox
{
/**
* @param string[] $data
*
* @return string[]
*/
public static function fixPEMStructures(array $data, string $type = 'CERTIFICATE'): array
{
return array_map(static fn ($d): string => self::fixPEMStructure($d, $type), $data);
}
public static function fixPEMStructure(string $data, string $type = 'CERTIFICATE'): string
{
if (str_contains($data, '-----BEGIN')) {
return trim($data);
}
$pem = '-----BEGIN ' . $type . '-----' . PHP_EOL;
$pem .= chunk_split($data, 64, PHP_EOL);
return $pem . ('-----END ' . $type . '-----' . PHP_EOL);
}
public static function convertPEMToDER(string $data): string
{
if (! str_contains($data, '-----BEGIN')) {
return $data;
}
$data = preg_replace('/[\-]{5}.*[\-]{5}[\r\n]*/', '', $data);
$data = preg_replace("/[\r\n]*/", '', $data);
return Base64::decode(trim($data), true);
}
public static function convertDERToPEM(string $data, string $type = 'CERTIFICATE'): string
{
if (str_contains($data, '-----BEGIN')) {
return $data;
}
$der = self::unusedBytesFix($data);
return self::fixPEMStructure(base64_encode($der), $type);
}
/**
* @param string[] $data
*
* @return string[]
*/
public static function convertAllDERToPEM(iterable $data, string $type = 'CERTIFICATE'): array
{
$certificates = [];
foreach ($data as $d) {
$certificates[] = self::convertDERToPEM($d, $type);
}
return $certificates;
}
private static function unusedBytesFix(string $data): string
{
$hash = hash('sha256', $data);
if (in_array($hash, self::getCertificateHashes(), true)) {
$data[mb_strlen($data, '8bit') - 257] = "\0";
}
return $data;
}
/**
* @return string[]
*/
private static function getCertificateHashes(): array
{
return [
'349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
'1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
'6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511',
];
}
}

View File

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\CertificateChain;
use function count;
use DateTimeZone;
use function in_array;
use Lcobucci\Clock\Clock;
use Lcobucci\Clock\SystemClock;
use function parse_url;
use const PHP_EOL;
use const PHP_URL_SCHEME;
use Psr\Clock\ClockInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use SpomkyLabs\Pki\ASN1\Type\UnspecifiedType;
use SpomkyLabs\Pki\CryptoEncoding\PEM;
use SpomkyLabs\Pki\X509\Certificate\Certificate;
use SpomkyLabs\Pki\X509\CertificationPath\CertificationPath;
use SpomkyLabs\Pki\X509\CertificationPath\PathValidation\PathValidationConfig;
use Throwable;
use Webauthn\MetadataService\Event\BeforeCertificateChainValidation;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\CertificateChainValidationFailed;
use Webauthn\MetadataService\Event\CertificateChainValidationSucceeded;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\MetadataService\Exception\CertificateChainException;
use Webauthn\MetadataService\Exception\CertificateRevocationListException;
use Webauthn\MetadataService\Exception\InvalidCertificateException;
/**
* @final
*/
class PhpCertificateChainValidator implements CertificateChainValidator, CanDispatchEvents
{
private const MAX_VALIDATION_LENGTH = 5;
private readonly Clock|ClockInterface $clock;
private EventDispatcherInterface $dispatcher;
public function __construct(
private readonly ClientInterface $client,
private readonly RequestFactoryInterface $requestFactory,
null|Clock|ClockInterface $clock = null,
private readonly bool $allowFailures = true
) {
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;
}
/**
* @param string[] $untrustedCertificates
* @param string[] $trustedCertificates
*/
public function check(array $untrustedCertificates, array $trustedCertificates): void
{
foreach ($trustedCertificates as $trustedCertificate) {
$this->dispatcher->dispatch(
BeforeCertificateChainValidation::create($untrustedCertificates, $trustedCertificate)
);
try {
if ($this->validateChain($untrustedCertificates, $trustedCertificate)) {
$this->dispatcher->dispatch(
CertificateChainValidationSucceeded::create($untrustedCertificates, $trustedCertificate)
);
return;
}
} catch (Throwable $exception) {
$this->dispatcher->dispatch(
CertificateChainValidationFailed::create($untrustedCertificates, $trustedCertificate)
);
throw $exception;
}
}
throw CertificateChainException::create($untrustedCertificates, $trustedCertificates);
}
/**
* @param string[] $untrustedCertificates
*/
private function validateChain(array $untrustedCertificates, string $trustedCertificate): bool
{
$untrustedCertificates = array_map(
static fn (string $cert): Certificate => Certificate::fromPEM(PEM::fromString($cert)),
array_reverse($untrustedCertificates)
);
$trustedCertificate = Certificate::fromPEM(PEM::fromString($trustedCertificate));
// The trust path and the authenticator certificate are the same
if (count(
$untrustedCertificates
) === 1 && $untrustedCertificates[0]->toPEM()->string() === $trustedCertificate->toPEM()->string()) {
return true;
}
$uniqueCertificates = array_map(
static fn (Certificate $cert): string => $cert->toPEM()
->string(),
array_merge($untrustedCertificates, [$trustedCertificate])
);
count(array_unique($uniqueCertificates)) === count(
$uniqueCertificates
) || throw CertificateChainException::create(
$untrustedCertificates,
[$trustedCertificate],
'Invalid certificate chain with duplicated certificates.'
);
if (! $this->validateCertificates($trustedCertificate, ...$untrustedCertificates)) {
return false;
}
$certificates = array_merge([$trustedCertificate], $untrustedCertificates);
$numCerts = count($certificates);
for ($i = 1; $i < $numCerts; $i++) {
if ($this->isRevoked($certificates[$i])) {
throw CertificateChainException::create(
$untrustedCertificates,
[$trustedCertificate],
'Unable to validate the certificate chain. Revoked certificate found.'
);
}
}
return true;
}
private function isRevoked(Certificate $subject): bool
{
try {
$csn = $subject->tbsCertificate()
->serialNumber();
} catch (Throwable $e) {
throw InvalidCertificateException::create(
$subject->toPEM()
->string(),
sprintf('Failed to parse certificate: %s', $e->getMessage()),
$e
);
}
try {
$urls = $this->getCrlUrlList($subject);
} catch (Throwable $e) {
if ($this->allowFailures) {
return false;
}
throw InvalidCertificateException::create(
$subject->toPEM()
->string(),
'Failed to get CRL distribution points: ' . $e->getMessage(),
$e
);
}
foreach ($urls as $url) {
try {
$revokedCertificates = $this->retrieveRevokedSerialNumbers($url);
if (in_array($csn, $revokedCertificates, true)) {
return true;
}
} catch (Throwable $e) {
if ($this->allowFailures) {
return false;
}
throw CertificateRevocationListException::create($url, sprintf(
'Failed to retrieve the CRL:' . PHP_EOL . '%s',
$e->getMessage()
), $e);
}
}
return false;
}
private function validateCertificates(Certificate ...$certificates): bool
{
try {
$config = PathValidationConfig::create($this->clock->now(), self::MAX_VALIDATION_LENGTH);
CertificationPath::create(...$certificates)->validate($config);
return true;
} catch (Throwable) {
return false;
}
}
/**
* @return string[]
*/
private function retrieveRevokedSerialNumbers(string $url): array
{
try {
$request = $this->requestFactory->createRequest('GET', $url);
$response = $this->client->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw CertificateRevocationListException::create($url, 'Failed to download the CRL');
}
$crlData = $response->getBody()
->getContents();
$crl = UnspecifiedType::fromDER($crlData)->asSequence();
count($crl) === 3 || throw CertificateRevocationListException::create($url, 'Invalid CRL.');
$tbsCertList = $crl->at(0)
->asSequence();
count($tbsCertList) >= 6 || throw CertificateRevocationListException::create($url, 'Invalid CRL.');
$list = $tbsCertList->at(5)
->asSequence();
return array_map(static function (UnspecifiedType $r) use ($url): string {
$sequence = $r->asSequence();
count($sequence) >= 1 || throw CertificateRevocationListException::create($url, 'Invalid CRL.');
return $sequence->at(0)
->asInteger()
->number();
}, $list->elements());
} catch (Throwable $e) {
throw CertificateRevocationListException::create($url, 'Failed to download the CRL', $e);
}
}
/**
* @return string[]
*/
private function getCrlUrlList(Certificate $subject): array
{
try {
$urls = [];
$extensions = $subject->tbsCertificate()
->extensions();
if ($extensions->hasCRLDistributionPoints()) {
$crlDists = $extensions->crlDistributionPoints();
foreach ($crlDists->distributionPoints() as $dist) {
$url = $dist->fullName()
->names()
->firstURI();
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! in_array($scheme, ['http', 'https'], true)) {
continue;
}
$urls[] = $url;
}
}
return $urls;
} catch (Throwable $e) {
throw InvalidCertificateException::create(
$subject->toPEM()
->string(),
'Failed to get CRL distribution points from certificate: ' . $e->getMessage(),
$e
);
}
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Event;
final class BeforeCertificateChainValidation implements WebauthnEvent
{
/**
* @param string[] $untrustedCertificates
*/
public function __construct(
public readonly array $untrustedCertificates,
public readonly string $trustedCertificate
) {
}
/**
* @param string[] $untrustedCertificates
*/
public static function create(array $untrustedCertificates, string $trustedCertificate): self
{
return new self($untrustedCertificates, $trustedCertificate);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Event;
use Psr\EventDispatcher\EventDispatcherInterface;
interface CanDispatchEvents
{
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void;
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Event;
final class CertificateChainValidationFailed implements WebauthnEvent
{
/**
* @param string[] $untrustedCertificates
*/
public function __construct(
public readonly array $untrustedCertificates,
public readonly string $trustedCertificate
) {
}
/**
* @param string[] $untrustedCertificates
*/
public static function create(array $untrustedCertificates, string $trustedCertificate): self
{
return new self($untrustedCertificates, $trustedCertificate);
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Event;
final class CertificateChainValidationSucceeded implements WebauthnEvent
{
/**
* @param string[] $untrustedCertificates
*/
public function __construct(
public readonly array $untrustedCertificates,
public readonly string $trustedCertificate
) {
}
/**
* @param string[] $untrustedCertificates
*/
public static function create(array $untrustedCertificates, string $trustedCertificate): self
{
return new self($untrustedCertificates, $trustedCertificate);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Event;
use Webauthn\MetadataService\Statement\MetadataStatement;
final class MetadataStatementFound implements WebauthnEvent
{
public function __construct(
public readonly MetadataStatement $metadataStatement
) {
}
public static function create(MetadataStatement $metadataStatement): self
{
return new self($metadataStatement);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Event;
use Psr\EventDispatcher\EventDispatcherInterface;
final class NullEventDispatcher implements EventDispatcherInterface
{
public function dispatch(object $event): object
{
return $event;
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Event;
interface WebauthnEvent
{
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Exception;
use Throwable;
class CertificateChainException extends MetadataServiceException
{
/**
* @param array<string> $untrustedCertificates
* @param array<string> $trustedCertificates
*/
public function __construct(
public readonly array $untrustedCertificates,
public readonly array $trustedCertificates,
string $message,
?Throwable $previous = null
) {
parent::__construct($message, $previous);
}
/**
* @param array<string> $untrustedCertificates
* @param array<string> $trustedCertificates
*/
public static function create(
array $untrustedCertificates,
array $trustedCertificates,
string $message = 'Unable to validate the certificate chain.',
?Throwable $previous = null
): self {
return new self($untrustedCertificates, $trustedCertificates, $message, $previous);
}
}

View File

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

View File

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

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Exception;
use Throwable;
final class ExpiredCertificateException extends CertificateException
{
public static function create(
string $certificate,
string $message = 'Expired certificate',
?Throwable $previous = null
): self {
return new self($certificate, $message, $previous);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Exception;
use Throwable;
final class MissingMetadataStatementException extends MetadataStatementException
{
public function __construct(
public readonly string $aaguid,
string $message,
?Throwable $previous = null
) {
parent::__construct($message, $previous);
}
public static function create(
string $aaguid,
string $message = 'The Metadata Statement is missing',
?Throwable $previous = null
): self {
return new self($aaguid, $message, $previous);
}
}

View File

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

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService;
use Webauthn\MetadataService\Statement\MetadataStatement;
interface MetadataStatementRepository
{
public function findOneByAAGUID(string $aaguid): ?MetadataStatement;
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use Webauthn\MetadataService\Exception\MissingMetadataStatementException;
use Webauthn\MetadataService\Statement\MetadataStatement;
final class ChainedMetadataServices implements MetadataService
{
/**
* @var MetadataService[]
*/
private array $services = [];
public function __construct(MetadataService ...$services)
{
foreach ($services as $service) {
$this->addServices($service);
}
}
public static function create(MetadataService ...$services): self
{
return new self(...$services);
}
public function addServices(MetadataService ...$services): self
{
foreach ($services as $service) {
$this->services[] = $service;
}
return $this;
}
public function list(): iterable
{
foreach ($this->services as $service) {
yield from $service->list();
}
}
public function has(string $aaguid): bool
{
foreach ($this->services as $service) {
if ($service->has($aaguid)) {
return true;
}
}
return false;
}
public function get(string $aaguid): MetadataStatement
{
foreach ($this->services as $service) {
if ($service->has($aaguid)) {
return $service->get($aaguid);
}
}
throw MissingMetadataStatementException::create($aaguid);
}
}

View File

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use ParagonIE\ConstantTime\Base64;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use function sprintf;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\MetadataStatementFound;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Exception\MissingMetadataStatementException;
use Webauthn\MetadataService\Statement\MetadataStatement;
final class DistantResourceMetadataService implements MetadataService, CanDispatchEvents
{
private ?MetadataStatement $statement = null;
private EventDispatcherInterface $dispatcher;
/**
* @param array<string, string> $additionalHeaderParameters
*/
public function __construct(
private readonly RequestFactoryInterface $requestFactory,
private readonly ClientInterface $httpClient,
private readonly string $uri,
private readonly bool $isBase64Encoded = false,
private readonly array $additionalHeaderParameters = [],
) {
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
/**
* @param array<string, mixed> $additionalHeaderParameters
*/
public static function create(
RequestFactoryInterface $requestFactory,
ClientInterface $httpClient,
string $uri,
bool $isBase64Encoded = false,
array $additionalHeaderParameters = []
): self {
return new self($requestFactory, $httpClient, $uri, $isBase64Encoded, $additionalHeaderParameters);
}
public function list(): iterable
{
$this->loadData();
$this->statement !== null || throw MetadataStatementLoadingException::create(
'Unable to load the metadata statement'
);
$aaguid = $this->statement->getAaguid();
if ($aaguid === null) {
yield from [];
} else {
yield from [$aaguid];
}
}
public function has(string $aaguid): bool
{
$this->loadData();
$this->statement !== null || throw MetadataStatementLoadingException::create(
'Unable to load the metadata statement'
);
return $aaguid === $this->statement->getAaguid();
}
public function get(string $aaguid): MetadataStatement
{
$this->loadData();
$this->statement !== null || throw MetadataStatementLoadingException::create(
'Unable to load the metadata statement'
);
if ($aaguid === $this->statement->getAaguid()) {
$this->dispatcher->dispatch(MetadataStatementFound::create($this->statement));
return $this->statement;
}
throw MissingMetadataStatementException::create($aaguid);
}
private function loadData(): void
{
if ($this->statement !== null) {
return;
}
$content = $this->fetch();
if ($this->isBase64Encoded) {
$content = Base64::decode($content, true);
}
$this->statement = MetadataStatement::createFromString($content);
}
private function fetch(): string
{
$request = $this->requestFactory->createRequest('GET', $this->uri);
foreach ($this->additionalHeaderParameters as $k => $v) {
$request = $request->withHeader($k, $v);
}
$response = $this->httpClient->sendRequest($request);
$response->getStatusCode() === 200 || throw MetadataStatementLoadingException::create(sprintf(
'Unable to contact the server. Response code is %d',
$response->getStatusCode()
));
$response->getBody()
->rewind();
$content = $response->getBody()
->getContents();
$content !== '' || throw MetadataStatementLoadingException::create(
'Unable to contact the server. The response has no content'
);
return $content;
}
}

View File

@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use function array_key_exists;
use function is_array;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\ES256;
use Jose\Component\Signature\Algorithm\RS256;
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 function sprintf;
use Throwable;
use Webauthn\MetadataService\CertificateChain\CertificateChainValidator;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\MetadataStatementFound;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Exception\MissingMetadataStatementException;
use Webauthn\MetadataService\Statement\MetadataStatement;
use Webauthn\MetadataService\Statement\StatusReport;
final class FidoAllianceCompliantMetadataService implements MetadataService, CanDispatchEvents
{
private bool $loaded = false;
/**
* @var MetadataStatement[]
*/
private array $statements = [];
/**
* @var array<string, array<int, StatusReport>>
*/
private array $statusReports = [];
private EventDispatcherInterface $dispatcher;
/**
* @param array<string, mixed> $additionalHeaderParameters
*/
public function __construct(
private readonly RequestFactoryInterface $requestFactory,
private readonly ClientInterface $httpClient,
private readonly string $uri,
private readonly array $additionalHeaderParameters = [],
private readonly ?CertificateChainValidator $certificateChainValidator = null,
private readonly ?string $rootCertificateUri = null,
) {
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
/**
* @param array<string, mixed> $additionalHeaderParameters
*/
public static function create(
RequestFactoryInterface $requestFactory,
ClientInterface $httpClient,
string $uri,
array $additionalHeaderParameters = [],
?CertificateChainValidator $certificateChainValidator = null,
?string $rootCertificateUri = null,
): self {
return new self(
$requestFactory,
$httpClient,
$uri,
$additionalHeaderParameters,
$certificateChainValidator,
$rootCertificateUri
);
}
/**
* @return string[]
*/
public function list(): iterable
{
$this->loadData();
yield from array_keys($this->statements);
}
public function has(string $aaguid): bool
{
$this->loadData();
return array_key_exists($aaguid, $this->statements);
}
public function get(string $aaguid): MetadataStatement
{
$this->loadData();
array_key_exists($aaguid, $this->statements) || throw MissingMetadataStatementException::create($aaguid);
$mds = $this->statements[$aaguid];
$this->dispatcher->dispatch(MetadataStatementFound::create($mds));
return $mds;
}
/**
* @return StatusReport[]
*/
public function getStatusReports(string $aaguid): iterable
{
$this->loadData();
return $this->statusReports[$aaguid] ?? [];
}
private function loadData(): void
{
if ($this->loaded) {
return;
}
$content = $this->fetch($this->uri, $this->additionalHeaderParameters);
$jwtCertificates = [];
try {
$payload = $this->getJwsPayload($content, $jwtCertificates);
$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
$this->validateCertificates(...$jwtCertificates);
foreach ($data['entries'] as $datum) {
$entry = MetadataBLOBPayloadEntry::createFromArray($datum);
$mds = $entry->getMetadataStatement();
if ($mds !== null && $entry->getAaguid() !== null) {
$this->statements[$entry->getAaguid()] = $mds;
$this->statusReports[$entry->getAaguid()] = $entry->getStatusReports();
}
}
} catch (Throwable) {
}
$this->loaded = true;
}
/**
* @param array<string, mixed> $headerParameters
*/
private function fetch(string $uri, array $headerParameters): string
{
$request = $this->requestFactory->createRequest('GET', $uri);
foreach ($headerParameters as $k => $v) {
$request = $request->withHeader($k, $v);
}
$response = $this->httpClient->sendRequest($request);
$response->getStatusCode() === 200 || throw MetadataStatementLoadingException::create(sprintf(
'Unable to contact the server. Response code is %d',
$response->getStatusCode()
));
$response->getBody()
->rewind();
$content = $response->getBody()
->getContents();
$content !== '' || throw MetadataStatementLoadingException::create(
'Unable to contact the server. The response has no content'
);
return $content;
}
/**
* @param string[] $rootCertificates
*/
private function getJwsPayload(string $token, array &$rootCertificates): string
{
$jws = (new CompactSerializer())->unserialize($token);
$jws->countSignatures() === 1 || throw MetadataStatementLoadingException::create(
'Invalid response from the metadata service. Only one signature shall be present.'
);
$signature = $jws->getSignature(0);
$payload = $jws->getPayload();
$payload !== '' || throw MetadataStatementLoadingException::create(
'Invalid response from the metadata service. The token payload is empty.'
);
$header = $signature->getProtectedHeader();
array_key_exists('alg', $header) || throw MetadataStatementLoadingException::create(
'The "alg" parameter is missing.'
);
array_key_exists('x5c', $header) || throw MetadataStatementLoadingException::create(
'The "x5c" parameter is missing.'
);
is_array($header['x5c']) || throw MetadataStatementLoadingException::create(
'The "x5c" parameter should be an array.'
);
$key = JWKFactory::createFromX5C($header['x5c']);
$rootCertificates = $header['x5c'];
$verifier = new JWSVerifier(new AlgorithmManager([new ES256(), new RS256()]));
$isValid = $verifier->verifyWithKey($jws, $key, 0);
$isValid || throw MetadataStatementLoadingException::create(
'Invalid response from the metadata service. The token signature is invalid.'
);
$payload = $jws->getPayload();
$payload !== null || throw MetadataStatementLoadingException::create(
'Invalid response from the metadata service. The payload is missing.'
);
return $payload;
}
private function validateCertificates(string ...$untrustedCertificates): void
{
if ($this->certificateChainValidator === null || $this->rootCertificateUri === null) {
return;
}
$untrustedCertificates = CertificateToolbox::fixPEMStructures($untrustedCertificates);
$rootCertificate = CertificateToolbox::convertDERToPEM($this->fetch($this->rootCertificateUri, []));
$this->certificateChainValidator->check($untrustedCertificates, [$rootCertificate]);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use const DIRECTORY_SEPARATOR;
use function file_get_contents;
use InvalidArgumentException;
use function is_array;
use RuntimeException;
use function sprintf;
use Webauthn\MetadataService\Statement\MetadataStatement;
final class FolderResourceMetadataService implements MetadataService
{
private readonly string $rootPath;
public function __construct(string $rootPath)
{
$this->rootPath = rtrim($rootPath, DIRECTORY_SEPARATOR);
is_dir($this->rootPath) || throw new InvalidArgumentException('The given parameter is not a valid folder.');
is_readable($this->rootPath) || throw new InvalidArgumentException(
'The given parameter is not a valid folder.'
);
}
public function list(): iterable
{
$files = glob($this->rootPath . DIRECTORY_SEPARATOR . '*');
is_array($files) || throw new RuntimeException('Unable to read files.');
foreach ($files as $file) {
if (is_dir($file) || ! is_readable($file)) {
continue;
}
yield basename($file);
}
}
public function has(string $aaguid): bool
{
$filename = $this->rootPath . DIRECTORY_SEPARATOR . $aaguid;
return is_file($filename) && is_readable($filename);
}
public function get(string $aaguid): MetadataStatement
{
$this->has($aaguid) || throw new InvalidArgumentException(sprintf(
'The MDS with the AAGUID "%s" does not exist.',
$aaguid
));
$filename = $this->rootPath . DIRECTORY_SEPARATOR . $aaguid;
$data = trim(file_get_contents($filename));
$mds = MetadataStatement::createFromString($data);
$mds->getAaguid() !== null || throw new RuntimeException('Invalid Metadata Statement.');
return $mds;
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use function array_key_exists;
use Psr\EventDispatcher\EventDispatcherInterface;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\MetadataStatementFound;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\MetadataService\Exception\MissingMetadataStatementException;
use Webauthn\MetadataService\Statement\MetadataStatement;
final class InMemoryMetadataService implements MetadataService, CanDispatchEvents
{
/**
* @var MetadataStatement[]
*/
private array $statements = [];
private EventDispatcherInterface $dispatcher;
public function __construct(MetadataStatement ...$statements)
{
foreach ($statements as $statement) {
$this->addStatements($statement);
}
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(MetadataStatement ...$statements): self
{
return new self(...$statements);
}
public function addStatements(MetadataStatement ...$statements): self
{
foreach ($statements as $statement) {
$aaguid = $statement->getAaguid();
if ($aaguid === null) {
continue;
}
$this->statements[$aaguid] = $statement;
}
return $this;
}
public function list(): iterable
{
yield from array_keys($this->statements);
}
public function has(string $aaguid): bool
{
return array_key_exists($aaguid, $this->statements);
}
public function get(string $aaguid): MetadataStatement
{
array_key_exists($aaguid, $this->statements) || throw MissingMetadataStatementException::create($aaguid);
$mds = $this->statements[$aaguid];
$this->dispatcher->dispatch(MetadataStatementFound::create($mds));
return $mds;
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use function file_get_contents;
use ParagonIE\ConstantTime\Base64;
use Psr\EventDispatcher\EventDispatcherInterface;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\MetadataStatementFound;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Exception\MissingMetadataStatementException;
use Webauthn\MetadataService\Statement\MetadataStatement;
final class LocalResourceMetadataService implements MetadataService, CanDispatchEvents
{
private ?MetadataStatement $statement = null;
private EventDispatcherInterface $dispatcher;
public function __construct(
private readonly string $filename,
private readonly bool $isBase64Encoded = false,
) {
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(string $filename, bool $isBase64Encoded = false): self
{
return new self($filename, $isBase64Encoded);
}
public function list(): iterable
{
$this->loadData();
$this->statement !== null || throw MetadataStatementLoadingException::create(
'Unable to load the metadata statement'
);
$aaguid = $this->statement->getAaguid();
if ($aaguid === null) {
yield from [];
} else {
yield from [$aaguid];
}
}
public function has(string $aaguid): bool
{
$this->loadData();
$this->statement !== null || throw MetadataStatementLoadingException::create(
'Unable to load the metadata statement'
);
return $aaguid === $this->statement->getAaguid();
}
public function get(string $aaguid): MetadataStatement
{
$this->loadData();
$this->statement !== null || throw MetadataStatementLoadingException::create(
'Unable to load the metadata statement'
);
if ($aaguid === $this->statement->getAaguid()) {
$this->dispatcher->dispatch(MetadataStatementFound::create($this->statement));
return $this->statement;
}
throw MissingMetadataStatementException::create($aaguid);
}
private function loadData(): void
{
if ($this->statement !== null) {
return;
}
$content = file_get_contents($this->filename);
if ($this->isBase64Encoded) {
$content = Base64::decode($content, true);
}
$this->statement = MetadataStatement::createFromString($content);
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use function array_key_exists;
use function is_array;
use function is_int;
use function is_string;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class MetadataBLOBPayload implements JsonSerializable
{
/**
* @var MetadataBLOBPayloadEntry[]
*/
private array $entries = [];
/**
* @var string[]
*/
private array $rootCertificates = [];
public function __construct(
private readonly int $no,
private readonly string $nextUpdate,
private readonly ?string $legalHeader = null
) {
}
public function addEntry(MetadataBLOBPayloadEntry $entry): self
{
$this->entries[] = $entry;
return $this;
}
public function getLegalHeader(): ?string
{
return $this->legalHeader;
}
public function getNo(): int
{
return $this->no;
}
public function getNextUpdate(): string
{
return $this->nextUpdate;
}
/**
* @return MetadataBLOBPayloadEntry[]
*/
public function getEntries(): array
{
return $this->entries;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
foreach (['no', 'nextUpdate', 'entries'] as $key) {
array_key_exists($key, $data) || throw MetadataStatementLoadingException::create(sprintf(
'Invalid data. The parameter "%s" is missing',
$key
));
}
is_int($data['no']) || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "no" shall be an integer'
);
is_string($data['nextUpdate']) || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "nextUpdate" shall be a string'
);
is_array($data['entries']) || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "entries" shall be a n array of entries'
);
$object = new self($data['no'], $data['nextUpdate'], $data['legalHeader'] ?? null);
foreach ($data['entries'] as $entry) {
$object->addEntry(MetadataBLOBPayloadEntry::createFromArray($entry));
}
return $object;
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'legalHeader' => $this->legalHeader,
'nextUpdate' => $this->nextUpdate,
'no' => $this->no,
'entries' => array_map(
static fn (MetadataBLOBPayloadEntry $object): array => $object->jsonSerialize(),
$this->entries
),
];
return Utils::filterNullValues($data);
}
/**
* @return string[]
*/
public function getRootCertificates(): array
{
return $this->rootCertificates;
}
/**
* @param string[] $rootCertificates
*/
public function setRootCertificates(array $rootCertificates): self
{
$this->rootCertificates = $rootCertificates;
return $this;
}
}

View File

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use function array_key_exists;
use function count;
use function is_array;
use function is_string;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Statement\BiometricStatusReport;
use Webauthn\MetadataService\Statement\MetadataStatement;
use Webauthn\MetadataService\Statement\StatusReport;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class MetadataBLOBPayloadEntry implements JsonSerializable
{
/**
* @var string[]
*/
private array $attestationCertificateKeyIdentifiers = [];
/**
* @var BiometricStatusReport[]
*/
private array $biometricStatusReports = [];
/**
* @var StatusReport[]
*/
private array $statusReports = [];
/**
* @param string[] $attestationCertificateKeyIdentifiers
*/
public function __construct(
private readonly ?string $aaid,
private readonly ?string $aaguid,
array $attestationCertificateKeyIdentifiers,
private readonly ?MetadataStatement $metadataStatement,
private readonly string $timeOfLastStatusChange,
private readonly ?string $rogueListURL,
private readonly ?string $rogueListHash
) {
if ($aaid !== null && $aaguid !== null) {
throw MetadataStatementLoadingException::create('Authenticators cannot support both AAID and AAGUID');
}
if ($aaid === null && $aaguid === null && count($attestationCertificateKeyIdentifiers) === 0) {
throw MetadataStatementLoadingException::create(
'If neither AAID nor AAGUID are set, the attestation certificate identifier list shall not be empty'
);
}
foreach ($attestationCertificateKeyIdentifiers as $attestationCertificateKeyIdentifier) {
is_string($attestationCertificateKeyIdentifier) || throw MetadataStatementLoadingException::create(
'Invalid attestation certificate identifier. Shall be a list of strings'
);
preg_match(
'/^[0-9a-f]+$/',
$attestationCertificateKeyIdentifier
) === 1 || throw MetadataStatementLoadingException::create(
'Invalid attestation certificate identifier. Shall be a list of strings'
);
}
$this->attestationCertificateKeyIdentifiers = $attestationCertificateKeyIdentifiers;
}
public function getAaid(): ?string
{
return $this->aaid;
}
public function getAaguid(): ?string
{
return $this->aaguid;
}
/**
* @return string[]
*/
public function getAttestationCertificateKeyIdentifiers(): array
{
return $this->attestationCertificateKeyIdentifiers;
}
public function getMetadataStatement(): ?MetadataStatement
{
return $this->metadataStatement;
}
public function addBiometricStatusReports(BiometricStatusReport ...$biometricStatusReports): self
{
foreach ($biometricStatusReports as $biometricStatusReport) {
$this->biometricStatusReports[] = $biometricStatusReport;
}
return $this;
}
/**
* @return BiometricStatusReport[]
*/
public function getBiometricStatusReports(): array
{
return $this->biometricStatusReports;
}
public function addStatusReports(StatusReport ...$statusReports): self
{
foreach ($statusReports as $statusReport) {
$this->statusReports[] = $statusReport;
}
return $this;
}
/**
* @return StatusReport[]
*/
public function getStatusReports(): array
{
return $this->statusReports;
}
public function getTimeOfLastStatusChange(): string
{
return $this->timeOfLastStatusChange;
}
public function getRogueListURL(): string|null
{
return $this->rogueListURL;
}
public function getRogueListHash(): string|null
{
return $this->rogueListHash;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
array_key_exists('timeOfLastStatusChange', $data) || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "timeOfLastStatusChange" is missing'
);
array_key_exists('statusReports', $data) || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "statusReports" is missing'
);
is_array($data['statusReports']) || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "statusReports" shall be an array of StatusReport objects'
);
$object = new self(
$data['aaid'] ?? null,
$data['aaguid'] ?? null,
$data['attestationCertificateKeyIdentifiers'] ?? [],
isset($data['metadataStatement']) ? MetadataStatement::createFromArray($data['metadataStatement']) : null,
$data['timeOfLastStatusChange'],
$data['rogueListURL'] ?? null,
$data['rogueListHash'] ?? null
);
foreach ($data['statusReports'] as $statusReport) {
$object->addStatusReports(StatusReport::createFromArray($statusReport));
}
if (array_key_exists('biometricStatusReport', $data)) {
foreach ($data['biometricStatusReport'] as $biometricStatusReport) {
$object->addBiometricStatusReports(BiometricStatusReport::createFromArray($biometricStatusReport));
}
}
return $object;
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'aaid' => $this->aaid,
'aaguid' => $this->aaguid,
'attestationCertificateKeyIdentifiers' => $this->attestationCertificateKeyIdentifiers,
'statusReports' => array_map(
static fn (StatusReport $object): array => $object->jsonSerialize(),
$this->statusReports
),
'timeOfLastStatusChange' => $this->timeOfLastStatusChange,
'rogueListURL' => $this->rogueListURL,
'rogueListHash' => $this->rogueListHash,
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use Webauthn\MetadataService\Statement\MetadataStatement;
interface MetadataService
{
/**
* @return string[] The list of AAGUID supported by the service
*/
public function list(): iterable;
public function has(string $aaguid): bool;
public function get(string $aaguid): MetadataStatement;
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Service;
use function array_key_exists;
use Psr\EventDispatcher\EventDispatcherInterface;
use Webauthn\MetadataService\Event\CanDispatchEvents;
use Webauthn\MetadataService\Event\MetadataStatementFound;
use Webauthn\MetadataService\Event\NullEventDispatcher;
use Webauthn\MetadataService\Exception\MissingMetadataStatementException;
use Webauthn\MetadataService\Statement\MetadataStatement;
final class StringMetadataService implements MetadataService, CanDispatchEvents
{
/**
* @var MetadataStatement[]
*/
private array $statements = [];
private EventDispatcherInterface $dispatcher;
public function __construct(string ...$statements)
{
foreach ($statements as $statement) {
$this->addStatements(MetadataStatement::createFromString($statement));
}
$this->dispatcher = new NullEventDispatcher();
}
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->dispatcher = $eventDispatcher;
}
public static function create(string ...$statements): self
{
return new self(...$statements);
}
public function addStatements(MetadataStatement ...$statements): self
{
foreach ($statements as $statement) {
$aaguid = $statement->getAaguid();
if ($aaguid === null) {
continue;
}
$this->statements[$aaguid] = $statement;
}
return $this;
}
public function list(): iterable
{
yield from array_keys($this->statements);
}
public function has(string $aaguid): bool
{
return array_key_exists($aaguid, $this->statements);
}
public function get(string $aaguid): MetadataStatement
{
array_key_exists($aaguid, $this->statements) || throw MissingMetadataStatementException::create($aaguid);
$mds = $this->statements[$aaguid];
$this->dispatcher->dispatch(MetadataStatementFound::create($mds));
return $mds;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
abstract class AbstractDescriptor implements JsonSerializable
{
private readonly ?int $maxRetries;
private readonly ?int $blockSlowdown;
public function __construct(?int $maxRetries = null, ?int $blockSlowdown = null)
{
$maxRetries >= 0 || throw MetadataStatementLoadingException::create(
'Invalid data. The value of "maxRetries" must be a positive integer'
);
$blockSlowdown >= 0 || throw MetadataStatementLoadingException::create(
'Invalid data. The value of "blockSlowdown" must be a positive integer'
);
$this->maxRetries = $maxRetries;
$this->blockSlowdown = $blockSlowdown;
}
public function getMaxRetries(): ?int
{
return $this->maxRetries;
}
public function getBlockSlowdown(): ?int
{
return $this->blockSlowdown;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use JsonSerializable;
/**
* @final
*/
class AlternativeDescriptions implements JsonSerializable
{
/**
* @var array<string, string>
*/
private array $descriptions = [];
/**
* @param array<string, string> $descriptions
*/
public static function create(array $descriptions = []): self
{
$object = new self();
foreach ($descriptions as $k => $v) {
$object->add($k, $v);
}
return $object;
}
/**
* @return array<string, string>
*/
public function all(): array
{
return $this->descriptions;
}
public function add(string $locale, string $description): self
{
$this->descriptions[$locale] = $description;
return $this;
}
/**
* @return array<string, string>
*/
public function jsonSerialize(): array
{
return $this->descriptions;
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use JsonSerializable;
/**
* @final
*/
class AuthenticatorGetInfo implements JsonSerializable
{
/**
* @var string[]
*/
private array $info = [];
/**
* @param array<string|int, mixed> $data
*/
public static function create(array $data = []): self
{
$object = new self();
foreach ($data as $k => $v) {
$object->add($k, $v);
}
return $object;
}
public function add(string|int $key, mixed $value): self
{
$this->info[$key] = $value;
return $this;
}
/**
* @return string[]
*/
public function jsonSerialize(): array
{
return $this->info;
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
abstract class AuthenticatorStatus
{
final public const NOT_FIDO_CERTIFIED = 'NOT_FIDO_CERTIFIED';
final public const FIDO_CERTIFIED = 'FIDO_CERTIFIED';
final public const USER_VERIFICATION_BYPASS = 'USER_VERIFICATION_BYPASS';
final public const ATTESTATION_KEY_COMPROMISE = 'ATTESTATION_KEY_COMPROMISE';
final public const USER_KEY_REMOTE_COMPROMISE = 'USER_KEY_REMOTE_COMPROMISE';
final public const USER_KEY_PHYSICAL_COMPROMISE = 'USER_KEY_PHYSICAL_COMPROMISE';
final public const UPDATE_AVAILABLE = 'UPDATE_AVAILABLE';
final public const REVOKED = 'REVOKED';
final public const SELF_ASSERTION_SUBMITTED = 'SELF_ASSERTION_SUBMITTED';
final public const FIDO_CERTIFIED_L1 = 'FIDO_CERTIFIED_L1';
final public const FIDO_CERTIFIED_L1plus = 'FIDO_CERTIFIED_L1plus';
final public const FIDO_CERTIFIED_L2 = 'FIDO_CERTIFIED_L2';
final public const FIDO_CERTIFIED_L2plus = 'FIDO_CERTIFIED_L2plus';
final public const FIDO_CERTIFIED_L3 = 'FIDO_CERTIFIED_L3';
final public const FIDO_CERTIFIED_L3plus = 'FIDO_CERTIFIED_L3plus';
final public const FIDO_CERTIFIED_L4 = 'FIDO_CERTIFIED_L4';
final public const FIDO_CERTIFIED_L5 = 'FIDO_CERTIFIED_L5';
/**
* @return string[]
*/
public static function list(): array
{
return [
self::NOT_FIDO_CERTIFIED,
self::FIDO_CERTIFIED,
self::USER_VERIFICATION_BYPASS,
self::ATTESTATION_KEY_COMPROMISE,
self::USER_KEY_REMOTE_COMPROMISE,
self::USER_KEY_PHYSICAL_COMPROMISE,
self::UPDATE_AVAILABLE,
self::REVOKED,
self::SELF_ASSERTION_SUBMITTED,
self::FIDO_CERTIFIED_L1,
self::FIDO_CERTIFIED_L1plus,
self::FIDO_CERTIFIED_L2,
self::FIDO_CERTIFIED_L2plus,
self::FIDO_CERTIFIED_L3,
self::FIDO_CERTIFIED_L3plus,
self::FIDO_CERTIFIED_L4,
self::FIDO_CERTIFIED_L5,
];
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class BiometricAccuracyDescriptor extends AbstractDescriptor
{
public function __construct(
private readonly ?float $selfAttestedFRR,
private readonly ?float $selfAttestedFAR,
private readonly ?float $maxTemplates,
?int $maxRetries = null,
?int $blockSlowdown = null
) {
parent::__construct($maxRetries, $blockSlowdown);
}
public function getSelfAttestedFRR(): ?float
{
return $this->selfAttestedFRR;
}
public function getSelfAttestedFAR(): ?float
{
return $this->selfAttestedFAR;
}
public function getMaxTemplates(): ?float
{
return $this->maxTemplates;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
return new self(
$data['selfAttestedFRR'] ?? null,
$data['selfAttestedFAR'] ?? null,
$data['maxTemplates'] ?? null,
$data['maxRetries'] ?? null,
$data['blockSlowdown'] ?? null
);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'selfAttestedFRR' => $this->selfAttestedFRR,
'selfAttestedFAR' => $this->selfAttestedFAR,
'maxTemplates' => $this->maxTemplates,
'maxRetries' => $this->getMaxRetries(),
'blockSlowdown' => $this->getBlockSlowdown(),
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use JsonSerializable;
/**
* @final
*/
class BiometricStatusReport implements JsonSerializable
{
private ?int $certLevel = null;
private ?int $modality = null;
private ?string $effectiveDate = null;
private ?string $certificationDescriptor = null;
private ?string $certificateNumber = null;
private ?string $certificationPolicyVersion = null;
private ?string $certificationRequirementsVersion = null;
public function getCertLevel(): int|null
{
return $this->certLevel;
}
public function getModality(): int|null
{
return $this->modality;
}
public function getEffectiveDate(): ?string
{
return $this->effectiveDate;
}
public function getCertificationDescriptor(): ?string
{
return $this->certificationDescriptor;
}
public function getCertificateNumber(): ?string
{
return $this->certificateNumber;
}
public function getCertificationPolicyVersion(): ?string
{
return $this->certificationPolicyVersion;
}
public function getCertificationRequirementsVersion(): ?string
{
return $this->certificationRequirementsVersion;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$object = new self();
$object->certLevel = $data['certLevel'] ?? null;
$object->modality = $data['modality'] ?? null;
$object->effectiveDate = $data['effectiveDate'] ?? null;
$object->certificationDescriptor = $data['certificationDescriptor'] ?? null;
$object->certificateNumber = $data['certificateNumber'] ?? null;
$object->certificationPolicyVersion = $data['certificationPolicyVersion'] ?? null;
$object->certificationRequirementsVersion = $data['certificationRequirementsVersion'] ?? null;
return $object;
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'certLevel' => $this->certLevel,
'modality' => $this->modality,
'effectiveDate' => $this->effectiveDate,
'certificationDescriptor' => $this->certificationDescriptor,
'certificateNumber' => $this->certificateNumber,
'certificationPolicyVersion' => $this->certificationPolicyVersion,
'certificationRequirementsVersion' => $this->certificationRequirementsVersion,
];
return array_filter($data, static fn ($var): bool => $var !== null);
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class CodeAccuracyDescriptor extends AbstractDescriptor
{
private readonly int $base;
private readonly int $minLength;
public function __construct(int $base, int $minLength, ?int $maxRetries = null, ?int $blockSlowdown = null)
{
$base >= 0 || throw MetadataStatementLoadingException::create(
'Invalid data. The value of "base" must be a positive integer'
);
$minLength >= 0 || throw MetadataStatementLoadingException::create(
'Invalid data. The value of "minLength" must be a positive integer'
);
$this->base = $base;
$this->minLength = $minLength;
parent::__construct($maxRetries, $blockSlowdown);
}
public function getBase(): int
{
return $this->base;
}
public function getMinLength(): int
{
return $this->minLength;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
array_key_exists('base', $data) || throw MetadataStatementLoadingException::create(
'The parameter "base" is missing'
);
array_key_exists('minLength', $data) || throw MetadataStatementLoadingException::create(
'The parameter "minLength" is missing'
);
return new self(
$data['base'],
$data['minLength'],
$data['maxRetries'] ?? null,
$data['blockSlowdown'] ?? null
);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'base' => $this->base,
'minLength' => $this->minLength,
'maxRetries' => $this->getMaxRetries(),
'blockSlowdown' => $this->getBlockSlowdown(),
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use function is_array;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class DisplayPNGCharacteristicsDescriptor implements JsonSerializable
{
private readonly int $width;
private readonly int $height;
private readonly int $bitDepth;
private readonly int $colorType;
private readonly int $compression;
private readonly int $filter;
private readonly int $interlace;
/**
* @var RgbPaletteEntry[]
*/
private array $plte = [];
public function __construct(
int $width,
int $height,
int $bitDepth,
int $colorType,
int $compression,
int $filter,
int $interlace
) {
$width >= 0 || throw MetadataStatementLoadingException::create('Invalid width');
$height >= 0 || throw MetadataStatementLoadingException::create('Invalid height');
($bitDepth >= 0 && $bitDepth <= 254) || throw MetadataStatementLoadingException::create('Invalid bit depth');
($colorType >= 0 && $colorType <= 254) || throw MetadataStatementLoadingException::create(
'Invalid color type'
);
($compression >= 0 && $compression <= 254) || throw MetadataStatementLoadingException::create(
'Invalid compression'
);
($filter >= 0 && $filter <= 254) || throw MetadataStatementLoadingException::create('Invalid filter');
($interlace >= 0 && $interlace <= 254) || throw MetadataStatementLoadingException::create(
'Invalid interlace'
);
$this->width = $width;
$this->height = $height;
$this->bitDepth = $bitDepth;
$this->colorType = $colorType;
$this->compression = $compression;
$this->filter = $filter;
$this->interlace = $interlace;
}
public function addPalettes(RgbPaletteEntry ...$rgbPaletteEntries): self
{
foreach ($rgbPaletteEntries as $rgbPaletteEntry) {
$this->plte[] = $rgbPaletteEntry;
}
return $this;
}
public function getWidth(): int
{
return $this->width;
}
public function getHeight(): int
{
return $this->height;
}
public function getBitDepth(): int
{
return $this->bitDepth;
}
public function getColorType(): int
{
return $this->colorType;
}
public function getCompression(): int
{
return $this->compression;
}
public function getFilter(): int
{
return $this->filter;
}
public function getInterlace(): int
{
return $this->interlace;
}
/**
* @return RgbPaletteEntry[]
*/
public function getPaletteEntries(): array
{
return $this->plte;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
foreach ([
'width',
'compression',
'height',
'bitDepth',
'colorType',
'compression',
'filter',
'interlace',
] as $key) {
array_key_exists($key, $data) || throw MetadataStatementLoadingException::create(sprintf(
'Invalid data. The key "%s" is missing',
$key
));
}
$object = new self(
$data['width'],
$data['height'],
$data['bitDepth'],
$data['colorType'],
$data['compression'],
$data['filter'],
$data['interlace']
);
if (isset($data['plte'])) {
$plte = $data['plte'];
is_array($plte) || throw MetadataStatementLoadingException::create('Invalid "plte" parameter');
foreach ($plte as $item) {
$object->addPalettes(RgbPaletteEntry::createFromArray($item));
}
}
return $object;
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'width' => $this->width,
'height' => $this->height,
'bitDepth' => $this->bitDepth,
'colorType' => $this->colorType,
'compression' => $this->compression,
'filter' => $this->filter,
'interlace' => $this->interlace,
'plte' => $this->plte,
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use JsonSerializable;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @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 EcdaaTrustAnchor implements JsonSerializable
{
public function __construct(
private readonly string $X,
private readonly string $Y,
private readonly string $c,
private readonly string $sx,
private readonly string $sy,
private readonly string $G1Curve
) {
}
public function getX(): string
{
return $this->X;
}
public function getY(): string
{
return $this->Y;
}
public function getC(): string
{
return $this->c;
}
public function getSx(): string
{
return $this->sx;
}
public function getSy(): string
{
return $this->sy;
}
public function getG1Curve(): string
{
return $this->G1Curve;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
foreach (['X', 'Y', 'c', 'sx', 'sy', 'G1Curve'] as $key) {
array_key_exists($key, $data) || throw MetadataStatementLoadingException::create(sprintf(
'Invalid data. The key "%s" is missing',
$key
));
}
return new self(
Base64UrlSafe::decode($data['X']),
Base64UrlSafe::decode($data['Y']),
Base64UrlSafe::decode($data['c']),
Base64UrlSafe::decode($data['sx']),
Base64UrlSafe::decode($data['sy']),
$data['G1Curve']
);
}
/**
* @return array<string, string>
*/
public function jsonSerialize(): array
{
$data = [
'X' => Base64UrlSafe::encodeUnpadded($this->X),
'Y' => Base64UrlSafe::encodeUnpadded($this->Y),
'c' => Base64UrlSafe::encodeUnpadded($this->c),
'sx' => Base64UrlSafe::encodeUnpadded($this->sx),
'sy' => Base64UrlSafe::encodeUnpadded($this->sy),
'G1Curve' => $this->G1Curve,
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class ExtensionDescriptor implements JsonSerializable
{
private readonly ?int $tag;
public function __construct(
private readonly string $id,
?int $tag,
private readonly ?string $data,
private readonly bool $failIfUnknown
) {
if ($tag !== null) {
$tag >= 0 || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "tag" shall be a positive integer'
);
}
$this->tag = $tag;
}
public function getId(): string
{
return $this->id;
}
public function getTag(): ?int
{
return $this->tag;
}
public function getData(): ?string
{
return $this->data;
}
public function isFailIfUnknown(): bool
{
return $this->failIfUnknown;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
array_key_exists('id', $data) || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "id" is missing'
);
array_key_exists('fail_if_unknown', $data) || throw MetadataStatementLoadingException::create(
'Invalid data. The parameter "fail_if_unknown" is missing'
);
return new self($data['id'], $data['tag'] ?? null, $data['data'] ?? null, $data['fail_if_unknown']);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$result = [
'id' => $this->id,
'tag' => $this->tag,
'data' => $this->data,
'fail_if_unknown' => $this->failIfUnknown,
];
return Utils::filterNullValues($result);
}
}

View File

@ -0,0 +1,556 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use function is_array;
use function is_string;
use const JSON_THROW_ON_ERROR;
use JsonSerializable;
use Webauthn\MetadataService\CertificateChain\CertificateToolbox;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class MetadataStatement implements JsonSerializable
{
final public const KEY_PROTECTION_SOFTWARE = 'software';
final public const KEY_PROTECTION_HARDWARE = 'hardware';
final public const KEY_PROTECTION_TEE = 'tee';
final public const KEY_PROTECTION_SECURE_ELEMENT = 'secure_element';
final public const KEY_PROTECTION_REMOTE_HANDLE = 'remote_handle';
final public const MATCHER_PROTECTION_SOFTWARE = 'software';
final public const MATCHER_PROTECTION_TEE = 'tee';
final public const MATCHER_PROTECTION_ON_CHIP = 'on_chip';
final public const ATTACHMENT_HINT_INTERNAL = 'internal';
final public const ATTACHMENT_HINT_EXTERNAL = 'external';
final public const ATTACHMENT_HINT_WIRED = 'wired';
final public const ATTACHMENT_HINT_WIRELESS = 'wireless';
final public const ATTACHMENT_HINT_NFC = 'nfc';
final public const ATTACHMENT_HINT_BLUETOOTH = 'bluetooth';
final public const ATTACHMENT_HINT_NETWORK = 'network';
final public const ATTACHMENT_HINT_READY = 'ready';
final public const ATTACHMENT_HINT_WIFI_DIRECT = 'wifi_direct';
final public const TRANSACTION_CONFIRMATION_DISPLAY_ANY = 'any';
final public const TRANSACTION_CONFIRMATION_DISPLAY_PRIVILEGED_SOFTWARE = 'privileged_software';
final public const TRANSACTION_CONFIRMATION_DISPLAY_TEE = 'tee';
final public const TRANSACTION_CONFIRMATION_DISPLAY_HARDWARE = 'hardware';
final public const TRANSACTION_CONFIRMATION_DISPLAY_REMOTE = 'remote';
final public const ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW = 'secp256r1_ecdsa_sha256_raw';
final public const ALG_SIGN_SECP256R1_ECDSA_SHA256_DER = 'secp256r1_ecdsa_sha256_der';
final public const ALG_SIGN_RSASSA_PSS_SHA256_RAW = 'rsassa_pss_sha256_raw';
final public const ALG_SIGN_RSASSA_PSS_SHA256_DER = 'rsassa_pss_sha256_der';
final public const ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW = 'secp256k1_ecdsa_sha256_raw';
final public const ALG_SIGN_SECP256K1_ECDSA_SHA256_DER = 'secp256k1_ecdsa_sha256_der';
final public const ALG_SIGN_SM2_SM3_RAW = 'sm2_sm3_raw';
final public const ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW = 'rsa_emsa_pkcs1_sha256_raw';
final public const ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER = 'rsa_emsa_pkcs1_sha256_der';
final public const ALG_SIGN_RSASSA_PSS_SHA384_RAW = 'rsassa_pss_sha384_raw';
final public const ALG_SIGN_RSASSA_PSS_SHA512_RAW = 'rsassa_pss_sha256_raw';
final public const ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW = 'rsassa_pkcsv15_sha256_raw';
final public const ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW = 'rsassa_pkcsv15_sha384_raw';
final public const ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW = 'rsassa_pkcsv15_sha512_raw';
final public const ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW = 'rsassa_pkcsv15_sha1_raw';
final public const ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW = 'secp384r1_ecdsa_sha384_raw';
final public const ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW = 'secp512r1_ecdsa_sha256_raw';
final public const ALG_SIGN_ED25519_EDDSA_SHA256_RAW = 'ed25519_eddsa_sha512_raw';
final public const ALG_KEY_ECC_X962_RAW = 'ecc_x962_raw';
final public const ALG_KEY_ECC_X962_DER = 'ecc_x962_der';
final public const ALG_KEY_RSA_2048_RAW = 'rsa_2048_raw';
final public const ALG_KEY_RSA_2048_DER = 'rsa_2048_der';
final public const ALG_KEY_COSE = 'cose';
final public const ATTESTATION_BASIC_FULL = 'basic_full';
final public const ATTESTATION_BASIC_SURROGATE = 'basic_surrogate';
/**
* @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 ATTESTATION_ECDAA = 'ecdaa';
final public const ATTESTATION_ATTCA = 'attca';
final public const ATTESTATION_ANONCA = 'anonca';
private ?string $legalHeader = null;
private ?string $aaid = null;
private ?string $aaguid = null;
/**
* @var string[]
*/
private array $attestationCertificateKeyIdentifiers = [];
private AlternativeDescriptions $alternativeDescriptions;
/**
* @var string[]
*/
private array $keyProtection = [];
private ?bool $isKeyRestricted = null;
private ?bool $isFreshUserVerificationRequired = null;
private ?int $cryptoStrength = null;
/**
* @var string[]
*/
private array $attachmentHint = [];
private ?string $tcDisplayContentType = null;
/**
* @var DisplayPNGCharacteristicsDescriptor[]
*/
private array $tcDisplayPNGCharacteristics = [];
/**
* @var EcdaaTrustAnchor[]
*/
private array $ecdaaTrustAnchors = [];
private ?string $icon = null;
/**
* @var ExtensionDescriptor[]
*/
private array $supportedExtensions = [];
private null|AuthenticatorGetInfo $authenticatorGetInfo = null;
/**
* @param Version[] $upv
* @param string[] $authenticationAlgorithms
* @param string[] $publicKeyAlgAndEncodings
* @param string[] $attestationTypes
* @param VerificationMethodANDCombinations[] $userVerificationDetails
* @param string[] $matcherProtection
* @param string[] $tcDisplay
* @param string[] $attestationRootCertificates
*/
public function __construct(
private readonly string $description,
private readonly int $authenticatorVersion,
private readonly string $protocolFamily,
private readonly int $schema,
private readonly array $upv,
private readonly array $authenticationAlgorithms,
private readonly array $publicKeyAlgAndEncodings,
private readonly array $attestationTypes,
private readonly array $userVerificationDetails,
private readonly array $matcherProtection,
private readonly array $tcDisplay,
private readonly array $attestationRootCertificates,
) {
$this->alternativeDescriptions = new AlternativeDescriptions();
$this->authenticatorGetInfo = new AuthenticatorGetInfo();
}
public static function createFromString(string $statement): self
{
$data = json_decode($statement, true, 512, JSON_THROW_ON_ERROR);
return self::createFromArray($data);
}
public function getLegalHeader(): ?string
{
return $this->legalHeader;
}
public function getAaid(): ?string
{
return $this->aaid;
}
public function getAaguid(): ?string
{
return $this->aaguid;
}
public function isKeyRestricted(): ?bool
{
return $this->isKeyRestricted;
}
public function isFreshUserVerificationRequired(): ?bool
{
return $this->isFreshUserVerificationRequired;
}
public function getAuthenticatorGetInfo(): AuthenticatorGetInfo|null
{
return $this->authenticatorGetInfo;
}
/**
* @return string[]
*/
public function getAttestationCertificateKeyIdentifiers(): array
{
return $this->attestationCertificateKeyIdentifiers;
}
public function getDescription(): string
{
return $this->description;
}
public function getAlternativeDescriptions(): AlternativeDescriptions
{
return $this->alternativeDescriptions;
}
public function getAuthenticatorVersion(): int
{
return $this->authenticatorVersion;
}
public function getProtocolFamily(): string
{
return $this->protocolFamily;
}
/**
* @return Version[]
*/
public function getUpv(): array
{
return $this->upv;
}
public function getSchema(): ?int
{
return $this->schema;
}
/**
* @return string[]
*/
public function getAuthenticationAlgorithms(): array
{
return $this->authenticationAlgorithms;
}
/**
* @return string[]
*/
public function getPublicKeyAlgAndEncodings(): array
{
return $this->publicKeyAlgAndEncodings;
}
/**
* @return string[]
*/
public function getAttestationTypes(): array
{
return $this->attestationTypes;
}
/**
* @return VerificationMethodANDCombinations[]
*/
public function getUserVerificationDetails(): array
{
return $this->userVerificationDetails;
}
/**
* @return string[]
*/
public function getKeyProtection(): array
{
return $this->keyProtection;
}
/**
* @return string[]
*/
public function getMatcherProtection(): array
{
return $this->matcherProtection;
}
public function getCryptoStrength(): ?int
{
return $this->cryptoStrength;
}
/**
* @return string[]
*/
public function getAttachmentHint(): array
{
return $this->attachmentHint;
}
/**
* @return string[]
*/
public function getTcDisplay(): array
{
return $this->tcDisplay;
}
public function getTcDisplayContentType(): ?string
{
return $this->tcDisplayContentType;
}
/**
* @return DisplayPNGCharacteristicsDescriptor[]
*/
public function getTcDisplayPNGCharacteristics(): array
{
return $this->tcDisplayPNGCharacteristics;
}
/**
* @return string[]
*/
public function getAttestationRootCertificates(): array
{
return $this->attestationRootCertificates;
}
/**
* @return EcdaaTrustAnchor[]
*
* @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 function getEcdaaTrustAnchors(): array
{
return $this->ecdaaTrustAnchors;
}
public function getIcon(): ?string
{
return $this->icon;
}
/**
* @return ExtensionDescriptor[]
*/
public function getSupportedExtensions(): array
{
return $this->supportedExtensions;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$requiredKeys = [
'description',
'authenticatorVersion',
'protocolFamily',
'schema',
'upv',
'authenticationAlgorithms',
'publicKeyAlgAndEncodings',
'attestationTypes',
'userVerificationDetails',
'matcherProtection',
'tcDisplay',
'attestationRootCertificates',
];
foreach ($requiredKeys as $key) {
array_key_exists($key, $data) || throw MetadataStatementLoadingException::create(sprintf(
'Invalid data. The key "%s" is missing',
$key
));
}
$subObjects = [
'authenticationAlgorithms',
'publicKeyAlgAndEncodings',
'attestationTypes',
'matcherProtection',
'tcDisplay',
'attestationRootCertificates',
];
foreach ($subObjects as $subObject) {
is_array($data[$subObject]) || throw MetadataStatementLoadingException::create(sprintf(
'Invalid Metadata Statement. The parameter "%s" shall be a list of strings.',
$subObject
));
foreach ($data[$subObject] as $datum) {
is_string($datum) || throw MetadataStatementLoadingException::create(sprintf(
'Invalid Metadata Statement. The parameter "%s" shall be a list of strings.',
$subObject
));
}
}
$object = new self(
$data['description'],
$data['authenticatorVersion'],
$data['protocolFamily'],
$data['schema'],
array_map(static function ($upv): Version {
is_array($upv) || throw MetadataStatementLoadingException::create('Invalid Metadata Statement');
return Version::createFromArray($upv);
}, $data['upv']),
$data['authenticationAlgorithms'],
$data['publicKeyAlgAndEncodings'],
$data['attestationTypes'],
array_map(static function ($userVerificationDetails): VerificationMethodANDCombinations {
is_array($userVerificationDetails) || throw MetadataStatementLoadingException::create(
'Invalid Metadata Statement'
);
return VerificationMethodANDCombinations::createFromArray($userVerificationDetails);
}, $data['userVerificationDetails']),
$data['matcherProtection'],
$data['tcDisplay'],
CertificateToolbox::fixPEMStructures($data['attestationRootCertificates'])
);
$object->legalHeader = $data['legalHeader'] ?? null;
$object->aaid = $data['aaid'] ?? null;
$object->aaguid = $data['aaguid'] ?? null;
$object->attestationCertificateKeyIdentifiers = $data['attestationCertificateKeyIdentifiers'] ?? [];
$object->alternativeDescriptions = AlternativeDescriptions::create($data['alternativeDescriptions'] ?? []);
$object->authenticatorGetInfo = isset($data['attestationTypes']) ? AuthenticatorGetInfo::create(
$data['attestationTypes']
) : null;
$object->keyProtection = $data['keyProtection'] ?? [];
$object->isKeyRestricted = $data['isKeyRestricted'] ?? null;
$object->isFreshUserVerificationRequired = $data['isFreshUserVerificationRequired'] ?? null;
$object->cryptoStrength = $data['cryptoStrength'] ?? null;
$object->attachmentHint = $data['attachmentHint'] ?? [];
$object->tcDisplayContentType = $data['tcDisplayContentType'] ?? null;
if (isset($data['tcDisplayPNGCharacteristics'])) {
$tcDisplayPNGCharacteristics = $data['tcDisplayPNGCharacteristics'];
is_array($tcDisplayPNGCharacteristics) || throw MetadataStatementLoadingException::create(
'Invalid Metadata Statement'
);
foreach ($tcDisplayPNGCharacteristics as $tcDisplayPNGCharacteristic) {
is_array($tcDisplayPNGCharacteristic) || throw MetadataStatementLoadingException::create(
'Invalid Metadata Statement'
);
$object->tcDisplayPNGCharacteristics[] = DisplayPNGCharacteristicsDescriptor::createFromArray(
$tcDisplayPNGCharacteristic
);
}
}
$object->ecdaaTrustAnchors = $data['ecdaaTrustAnchors'] ?? [];
$object->icon = $data['icon'] ?? null;
if (isset($data['supportedExtensions'])) {
$supportedExtensions = $data['supportedExtensions'];
is_array($supportedExtensions) || throw MetadataStatementLoadingException::create(
'Invalid Metadata Statement'
);
foreach ($supportedExtensions as $supportedExtension) {
is_array($supportedExtension) || throw MetadataStatementLoadingException::create(
'Invalid Metadata Statement'
);
$object->supportedExtensions[] = ExtensionDescriptor::createFromArray($supportedExtension);
}
}
return $object;
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'legalHeader' => $this->legalHeader,
'aaid' => $this->aaid,
'aaguid' => $this->aaguid,
'attestationCertificateKeyIdentifiers' => $this->attestationCertificateKeyIdentifiers,
'description' => $this->description,
'alternativeDescriptions' => $this->alternativeDescriptions,
'authenticatorVersion' => $this->authenticatorVersion,
'protocolFamily' => $this->protocolFamily,
'upv' => $this->upv,
'authenticationAlgorithms' => $this->authenticationAlgorithms,
'publicKeyAlgAndEncodings' => $this->publicKeyAlgAndEncodings,
'attestationTypes' => $this->attestationTypes,
'userVerificationDetails' => $this->userVerificationDetails,
'keyProtection' => $this->keyProtection,
'isKeyRestricted' => $this->isKeyRestricted,
'isFreshUserVerificationRequired' => $this->isFreshUserVerificationRequired,
'matcherProtection' => $this->matcherProtection,
'cryptoStrength' => $this->cryptoStrength,
'attachmentHint' => $this->attachmentHint,
'tcDisplay' => $this->tcDisplay,
'tcDisplayContentType' => $this->tcDisplayContentType,
'tcDisplayPNGCharacteristics' => array_map(
static fn (DisplayPNGCharacteristicsDescriptor $object): array => $object->jsonSerialize(),
$this->tcDisplayPNGCharacteristics
),
'attestationRootCertificates' => CertificateToolbox::fixPEMStructures($this->attestationRootCertificates),
'ecdaaTrustAnchors' => array_map(
static fn (EcdaaTrustAnchor $object): array => $object->jsonSerialize(),
$this->ecdaaTrustAnchors
),
'icon' => $this->icon,
'authenticatorGetInfo' => $this->authenticatorGetInfo,
'supportedExtensions' => array_map(
static fn (ExtensionDescriptor $object): array => $object->jsonSerialize(),
$this->supportedExtensions
),
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use function is_int;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class PatternAccuracyDescriptor extends AbstractDescriptor
{
private readonly int $minComplexity;
public function __construct(int $minComplexity, ?int $maxRetries = null, ?int $blockSlowdown = null)
{
$minComplexity >= 0 || throw MetadataStatementLoadingException::create(
'Invalid data. The value of "minComplexity" must be a positive integer'
);
$this->minComplexity = $minComplexity;
parent::__construct($maxRetries, $blockSlowdown);
}
public function getMinComplexity(): int
{
return $this->minComplexity;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
array_key_exists('minComplexity', $data) || throw MetadataStatementLoadingException::create(
'The key "minComplexity" is missing'
);
foreach (['minComplexity', 'maxRetries', 'blockSlowdown'] as $key) {
if (array_key_exists($key, $data)) {
is_int($data[$key]) || throw MetadataStatementLoadingException::create(
sprintf('Invalid data. The value of "%s" must be a positive integer', $key)
);
}
}
return new self($data['minComplexity'], $data['maxRetries'] ?? null, $data['blockSlowdown'] ?? null);
}
/**
* @return array<string, int|null>
*/
public function jsonSerialize(): array
{
$data = [
'minComplexity' => $this->minComplexity,
'maxRetries' => $this->getMaxRetries(),
'blockSlowdown' => $this->getBlockSlowdown(),
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use function is_int;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
/**
* @final
*/
class RgbPaletteEntry implements JsonSerializable
{
private readonly int $r;
private readonly int $g;
private readonly int $b;
public function __construct(int $r, int $g, int $b)
{
($r >= 0 && $r <= 255) || throw MetadataStatementLoadingException::create('The key "r" is invalid');
($g >= 0 && $g <= 255) || throw MetadataStatementLoadingException::create('The key "g" is invalid');
($b >= 0 && $b <= 255) || throw MetadataStatementLoadingException::create('The key "b" is invalid');
$this->r = $r;
$this->g = $g;
$this->b = $b;
}
public function getR(): int
{
return $this->r;
}
public function getG(): int
{
return $this->g;
}
public function getB(): int
{
return $this->b;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
foreach (['r', 'g', 'b'] as $key) {
array_key_exists($key, $data) || throw MetadataStatementLoadingException::create(sprintf(
'The key "%s" is missing',
$key
));
is_int($data[$key]) || throw MetadataStatementLoadingException::create(
sprintf('The key "%s" is invalid', $key)
);
}
return new self($data['r'], $data['g'], $data['b']);
}
/**
* @return array<string, int>
*/
public function jsonSerialize(): array
{
return [
'r' => $this->r,
'g' => $this->g,
'b' => $this->b,
];
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use function is_string;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
/**
* @final
*/
class RogueListEntry implements JsonSerializable
{
public function __construct(
private readonly string $sk,
private readonly string $date
) {
}
public function getSk(): string
{
return $this->sk;
}
public function getDate(): ?string
{
return $this->date;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
array_key_exists('sk', $data) || throw MetadataStatementLoadingException::create('The key "sk" is missing');
is_string($data['sk']) || throw MetadataStatementLoadingException::create('The key "date" is invalid');
array_key_exists('date', $data) || throw MetadataStatementLoadingException::create(
'The key "date" is missing'
);
is_string($data['date']) || throw MetadataStatementLoadingException::create('The key "date" is invalid');
return new self($data['sk'], $data['date']);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
return [
'sk' => $this->sk,
'date' => $this->date,
];
}
}

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use function in_array;
use function is_string;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class StatusReport implements JsonSerializable
{
/**
* @see AuthenticatorStatus
*/
private readonly string $status;
public function __construct(
string $status,
private readonly ?string $effectiveDate,
private readonly ?string $certificate,
private readonly ?string $url,
private readonly ?string $certificationDescriptor,
private readonly ?string $certificateNumber,
private readonly ?string $certificationPolicyVersion,
private readonly ?string $certificationRequirementsVersion
) {
in_array($status, AuthenticatorStatus::list(), true) || throw MetadataStatementLoadingException::create(
'The value of the key "status" is not acceptable'
);
$this->status = $status;
}
public function isCompromised(): bool
{
return in_array($this->status, [
AuthenticatorStatus::ATTESTATION_KEY_COMPROMISE,
AuthenticatorStatus::USER_KEY_PHYSICAL_COMPROMISE,
AuthenticatorStatus::USER_KEY_REMOTE_COMPROMISE,
AuthenticatorStatus::USER_VERIFICATION_BYPASS,
], true);
}
public function getStatus(): string
{
return $this->status;
}
public function getEffectiveDate(): ?string
{
return $this->effectiveDate;
}
public function getCertificate(): ?string
{
return $this->certificate;
}
public function getUrl(): ?string
{
return $this->url;
}
public function getCertificationDescriptor(): ?string
{
return $this->certificationDescriptor;
}
public function getCertificateNumber(): ?string
{
return $this->certificateNumber;
}
public function getCertificationPolicyVersion(): ?string
{
return $this->certificationPolicyVersion;
}
public function getCertificationRequirementsVersion(): ?string
{
return $this->certificationRequirementsVersion;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
array_key_exists('status', $data) || throw MetadataStatementLoadingException::create(
'The key "status" is missing'
);
foreach ([
'effectiveDate',
'certificate',
'url',
'certificationDescriptor',
'certificateNumber',
'certificationPolicyVersion',
'certificationRequirementsVersion',
] as $key) {
if (isset($data[$key])) {
$value = $data[$key];
$value === null || is_string($value) || throw MetadataStatementLoadingException::create(sprintf(
'The value of the key "%s" is invalid',
$key
));
}
}
return new self(
$data['status'],
$data['effectiveDate'] ?? null,
$data['certificate'] ?? null,
$data['url'] ?? null,
$data['certificationDescriptor'] ?? null,
$data['certificateNumber'] ?? null,
$data['certificationPolicyVersion'] ?? null,
$data['certificationRequirementsVersion'] ?? null
);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'status' => $this->status,
'effectiveDate' => $this->effectiveDate,
'certificate' => $this->certificate,
'url' => $this->url,
'certificationDescriptor' => $this->certificationDescriptor,
'certificateNumber' => $this->certificateNumber,
'certificationPolicyVersion' => $this->certificationPolicyVersion,
'certificationRequirementsVersion' => $this->certificationRequirementsVersion,
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function is_array;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
/**
* @final
*/
class VerificationMethodANDCombinations implements JsonSerializable
{
/**
* @var VerificationMethodDescriptor[]
*/
private array $verificationMethods = [];
public function addVerificationMethodDescriptor(VerificationMethodDescriptor $verificationMethodDescriptor): self
{
$this->verificationMethods[] = $verificationMethodDescriptor;
return $this;
}
/**
* @return VerificationMethodDescriptor[]
*/
public function getVerificationMethods(): array
{
return $this->verificationMethods;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$object = new self();
foreach ($data as $datum) {
is_array($datum) || throw MetadataStatementLoadingException::create('Invalid data');
$object->addVerificationMethodDescriptor(VerificationMethodDescriptor::createFromArray($datum));
}
return $object;
}
/**
* @return array<array<mixed>>
*/
public function jsonSerialize(): array
{
return array_map(
static fn (VerificationMethodDescriptor $object): array => $object->jsonSerialize(),
$this->verificationMethods
);
}
}

View File

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use function is_array;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class VerificationMethodDescriptor implements JsonSerializable
{
final public const USER_VERIFY_PRESENCE_INTERNAL = 'presence_internal';
final public const USER_VERIFY_FINGERPRINT_INTERNAL = 'fingerprint_internal';
final public const USER_VERIFY_PASSCODE_INTERNAL = 'passcode_internal';
final public const USER_VERIFY_VOICEPRINT_INTERNAL = 'voiceprint_internal';
final public const USER_VERIFY_FACEPRINT_INTERNAL = 'faceprint_internal';
final public const USER_VERIFY_LOCATION_INTERNAL = 'location_internal';
final public const USER_VERIFY_EYEPRINT_INTERNAL = 'eyeprint_internal';
final public const USER_VERIFY_PATTERN_INTERNAL = 'pattern_internal';
final public const USER_VERIFY_HANDPRINT_INTERNAL = 'handprint_internal';
final public const USER_VERIFY_PASSCODE_EXTERNAL = 'passcode_external';
final public const USER_VERIFY_PATTERN_EXTERNAL = 'pattern_external';
final public const USER_VERIFY_NONE = 'none';
final public const USER_VERIFY_ALL = 'all';
private readonly string $userVerificationMethod;
public function __construct(
string $userVerificationMethod,
private readonly ?CodeAccuracyDescriptor $caDesc = null,
private readonly ?BiometricAccuracyDescriptor $baDesc = null,
private readonly ?PatternAccuracyDescriptor $paDesc = null
) {
$userVerificationMethod >= 0 || throw MetadataStatementLoadingException::create(
'The parameter "userVerificationMethod" is invalid'
);
$this->userVerificationMethod = $userVerificationMethod;
}
public function getUserVerificationMethod(): string
{
return $this->userVerificationMethod;
}
public function userPresence(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_PRESENCE_INTERNAL;
}
public function fingerprint(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_FINGERPRINT_INTERNAL;
}
public function passcodeInternal(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_PASSCODE_INTERNAL;
}
public function voicePrint(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_VOICEPRINT_INTERNAL;
}
public function facePrint(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_FACEPRINT_INTERNAL;
}
public function location(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_LOCATION_INTERNAL;
}
public function eyePrint(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_EYEPRINT_INTERNAL;
}
public function patternInternal(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_PATTERN_INTERNAL;
}
public function handprint(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_HANDPRINT_INTERNAL;
}
public function passcodeExternal(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_PASSCODE_EXTERNAL;
}
public function patternExternal(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_PATTERN_EXTERNAL;
}
public function none(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_NONE;
}
public function all(): bool
{
return $this->userVerificationMethod === self::USER_VERIFY_ALL;
}
public function getCaDesc(): ?CodeAccuracyDescriptor
{
return $this->caDesc;
}
public function getBaDesc(): ?BiometricAccuracyDescriptor
{
return $this->baDesc;
}
public function getPaDesc(): ?PatternAccuracyDescriptor
{
return $this->paDesc;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
if (isset($data['userVerification']) && ! isset($data['userVerificationMethod'])) {
$data['userVerificationMethod'] = $data['userVerification'];
unset($data['userVerification']);
}
array_key_exists('userVerificationMethod', $data) || throw MetadataStatementLoadingException::create(
'The parameters "userVerificationMethod" is missing'
);
foreach (['caDesc', 'baDesc', 'paDesc'] as $key) {
if (isset($data[$key])) {
is_array($data[$key]) || throw MetadataStatementLoadingException::create(
sprintf('Invalid parameter "%s"', $key)
);
}
}
$caDesc = isset($data['caDesc']) ? CodeAccuracyDescriptor::createFromArray($data['caDesc']) : null;
$baDesc = isset($data['baDesc']) ? BiometricAccuracyDescriptor::createFromArray($data['baDesc']) : null;
$paDesc = isset($data['paDesc']) ? PatternAccuracyDescriptor::createFromArray($data['paDesc']) : null;
return new self($data['userVerificationMethod'], $caDesc, $baDesc, $paDesc);
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
$data = [
'userVerificationMethod' => $this->userVerificationMethod,
'caDesc' => $this->caDesc?->jsonSerialize(),
'baDesc' => $this->baDesc?->jsonSerialize(),
'paDesc' => $this->paDesc?->jsonSerialize(),
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use function is_int;
use JsonSerializable;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @final
*/
class Version implements JsonSerializable
{
private readonly ?int $major;
private readonly ?int $minor;
public function __construct(?int $major, ?int $minor)
{
if ($major === null && $minor === null) {
throw MetadataStatementLoadingException::create('Invalid data. Must contain at least one item');
}
$major >= 0 || throw MetadataStatementLoadingException::create('Invalid argument "major"');
$minor >= 0 || throw MetadataStatementLoadingException::create('Invalid argument "minor"');
$this->major = $major;
$this->minor = $minor;
}
public function getMajor(): ?int
{
return $this->major;
}
public function getMinor(): ?int
{
return $this->minor;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
foreach (['major', 'minor'] as $key) {
if (array_key_exists($key, $data)) {
is_int($data[$key]) || throw MetadataStatementLoadingException::create(
sprintf('Invalid value for key "%s"', $key)
);
}
}
return new self($data['major'] ?? null, $data['minor'] ?? null);
}
/**
* @return array<string, int|null>
*/
public function jsonSerialize(): array
{
$data = [
'major' => $this->major,
'minor' => $this->minor,
];
return Utils::filterNullValues($data);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService;
use Webauthn\MetadataService\Statement\StatusReport;
interface StatusReportRepository
{
/**
* @return StatusReport[]
*/
public function findStatusReportsByAAGUID(string $aaguid): array;
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService;
/**
* @internal
*/
abstract class Utils
{
/**
* @param array<mixed> $data
*
* @return array<mixed>
*/
public static function filterNullValues(array $data): array
{
return array_filter($data, static fn ($var): bool => $var !== null);
}
}