Update website

This commit is contained in:
Guilhem Lavaux 2024-11-23 20:45:29 +01:00
parent 41ce1aa076
commit ea0eb1c6e0
4222 changed files with 721797 additions and 14 deletions

View file

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

View file

@ -0,0 +1,49 @@
{
"name": "web-auth/webauthn-lib",
"type": "library",
"license": "MIT",
"description": "FIDO2/Webauthn Support For PHP",
"keywords": ["FIDO", "FIDO2", "webauthn"],
"homepage": "https://github.com/web-auth",
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-auth/webauthn-library/contributors"
}
],
"require": {
"php": ">=7.2",
"ext-json": "*",
"ext-openssl": "*",
"ext-mbstring": "*",
"beberlei/assert": "^3.2",
"fgrosse/phpasn1": "^2.1",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0",
"psr/log": "^1.1",
"ramsey/uuid": "^3.8|^4.0",
"spomky-labs/base64url": "^2.0",
"spomky-labs/cbor-php": "^1.0|^2.0",
"symfony/process": "^3.0|^4.0|^5.0",
"thecodingmachine/safe": "^1.1",
"web-auth/cose-lib": "self.version",
"web-auth/metadata-service": "self.version"
},
"autoload": {
"psr-4": {
"Webauthn\\": "src/"
}
},
"suggest": {
"psr/log-implementation": "Recommended to receive logs from the library",
"web-token/jwt-key-mgmt": "Mandatory for the AndroidSafetyNet Attestation Statement support",
"web-token/jwt-signature-algorithm-rsa": "Mandatory for the AndroidSafetyNet Attestation Statement support",
"web-token/jwt-signature-algorithm-ecdsa": "Recommended for the AndroidSafetyNet Attestation Statement support",
"web-token/jwt-signature-algorithm-eddsa": "Recommended for the AndroidSafetyNet Attestation Statement support"
}
}

View file

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Assert\Assertion;
use CBOR\Decoder;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Algorithms;
use Cose\Key\Ec2Key;
use Cose\Key\Key;
use Cose\Key\RsaKey;
use function count;
use FG\ASN1\ASNObject;
use FG\ASN1\ExplicitlyTaggedObject;
use FG\ASN1\Universal\OctetString;
use FG\ASN1\Universal\Sequence;
use function Safe\hex2bin;
use function Safe\openssl_pkey_get_public;
use function Safe\sprintf;
use Webauthn\AuthenticatorData;
use Webauthn\CertificateToolbox;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
final class AndroidKeyAttestationStatementSupport implements AttestationStatementSupport
{
/**
* @var Decoder
*/
private $decoder;
public function __construct()
{
$this->decoder = new Decoder(new TagObjectManager(), new OtherObjectManager());
}
public function name(): string
{
return 'android-key';
}
/**
* @param mixed[] $attestation
*/
public function load(array $attestation): AttestationStatement
{
Assertion::keyExists($attestation, 'attStmt', 'Invalid attestation object');
foreach (['sig', 'x5c', 'alg'] as $key) {
Assertion::keyExists($attestation['attStmt'], $key, sprintf('The attestation statement value "%s" is missing.', $key));
}
$certificates = $attestation['attStmt']['x5c'];
Assertion::isArray($certificates, 'The attestation statement value "x5c" must be a list with at least one certificate.');
Assertion::greaterThan(count($certificates), 0, 'The attestation statement value "x5c" must be a list with at least one certificate.');
Assertion::allString($certificates, 'The attestation statement value "x5c" must be a list with at least one certificate.');
$certificates = CertificateToolbox::convertAllDERToPEM($certificates);
return AttestationStatement::createBasic($attestation['fmt'], $attestation['attStmt'], new CertificateTrustPath($certificates));
}
public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
$trustPath = $attestationStatement->getTrustPath();
Assertion::isInstanceOf($trustPath, CertificateTrustPath::class, 'Invalid trust path');
$certificates = $trustPath->getCertificates();
//Decode leaf attestation certificate
$leaf = $certificates[0];
$this->checkCertificateAndGetPublicKey($leaf, $clientDataJSONHash, $authenticatorData);
$signedData = $authenticatorData->getAuthData().$clientDataJSONHash;
$alg = $attestationStatement->get('alg');
return 1 === openssl_verify($signedData, $attestationStatement->get('sig'), $leaf, Algorithms::getOpensslAlgorithmFor((int) $alg));
}
private function checkCertificateAndGetPublicKey(string $certificate, string $clientDataHash, AuthenticatorData $authenticatorData): void
{
$resource = openssl_pkey_get_public($certificate);
$details = openssl_pkey_get_details($resource);
Assertion::isArray($details, 'Unable to read the certificate');
//Check that authData publicKey matches the public key in the attestation certificate
$attestedCredentialData = $authenticatorData->getAttestedCredentialData();
Assertion::notNull($attestedCredentialData, 'No attested credential data found');
$publicKeyData = $attestedCredentialData->getCredentialPublicKey();
Assertion::notNull($publicKeyData, 'No attested public key found');
$publicDataStream = new StringStream($publicKeyData);
$coseKey = $this->decoder->decode($publicDataStream)->getNormalizedData(false);
Assertion::true($publicDataStream->isEOF(), 'Invalid public key data. Presence of extra bytes.');
$publicDataStream->close();
$publicKey = Key::createFromData($coseKey);
Assertion::true(($publicKey instanceof Ec2Key) || ($publicKey instanceof RsaKey), 'Unsupported key type');
Assertion::eq($publicKey->asPEM(), $details['key'], 'Invalid key');
/*---------------------------*/
$certDetails = openssl_x509_parse($certificate);
//Find Android KeyStore Extension with OID “1.3.6.1.4.1.11129.2.1.17” in certificate extensions
Assertion::isArray($certDetails, 'The certificate is not valid');
Assertion::keyExists($certDetails, 'extensions', 'The certificate has no extension');
Assertion::isArray($certDetails['extensions'], 'The certificate has no extension');
Assertion::keyExists($certDetails['extensions'], '1.3.6.1.4.1.11129.2.1.17', 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is missing');
$extension = $certDetails['extensions']['1.3.6.1.4.1.11129.2.1.17'];
$extensionAsAsn1 = ASNObject::fromBinary($extension);
Assertion::isInstanceOf($extensionAsAsn1, Sequence::class, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
$objects = $extensionAsAsn1->getChildren();
//Check that attestationChallenge is set to the clientDataHash.
Assertion::keyExists($objects, 4, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
Assertion::isInstanceOf($objects[4], OctetString::class, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
Assertion::eq($clientDataHash, hex2bin(($objects[4])->getContent()), 'The client data hash is not valid');
//Check that both teeEnforced and softwareEnforced structures dont contain allApplications(600) tag.
Assertion::keyExists($objects, 6, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
$softwareEnforcedFlags = $objects[6];
Assertion::isInstanceOf($softwareEnforcedFlags, Sequence::class, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
$this->checkAbsenceOfAllApplicationsTag($softwareEnforcedFlags);
Assertion::keyExists($objects, 7, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
$teeEnforcedFlags = $objects[6];
Assertion::isInstanceOf($teeEnforcedFlags, Sequence::class, 'The certificate extension "1.3.6.1.4.1.11129.2.1.17" is invalid');
$this->checkAbsenceOfAllApplicationsTag($teeEnforcedFlags);
}
private function checkAbsenceOfAllApplicationsTag(Sequence $sequence): void
{
foreach ($sequence->getChildren() as $tag) {
Assertion::isInstanceOf($tag, ExplicitlyTaggedObject::class, 'Invalid tag');
/* @var ExplicitlyTaggedObject $tag */
Assertion::notEq(600, (int) $tag->getTag(), 'Forbidden tag 600 found');
}
}
}

View file

@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Assert\Assertion;
use InvalidArgumentException;
use Jose\Component\Core\Algorithm as AlgorithmInterface;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\Util\JsonConverter;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm;
use Jose\Component\Signature\JWS;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use function Safe\json_decode;
use function Safe\sprintf;
use Webauthn\AuthenticatorData;
use Webauthn\CertificateToolbox;
use Webauthn\TrustPath\CertificateTrustPath;
final class AndroidSafetyNetAttestationStatementSupport implements AttestationStatementSupport
{
/**
* @var string|null
*/
private $apiKey;
/**
* @var ClientInterface|null
*/
private $client;
/**
* @var CompactSerializer
*/
private $jwsSerializer;
/**
* @var JWSVerifier|null
*/
private $jwsVerifier;
/**
* @var RequestFactoryInterface|null
*/
private $requestFactory;
/**
* @var int
*/
private $leeway;
/**
* @var int
*/
private $maxAge;
public function __construct(?ClientInterface $client = null, ?string $apiKey = null, ?RequestFactoryInterface $requestFactory = null, ?int $leeway = null, ?int $maxAge = null)
{
if (!class_exists(Algorithm\RS256::class)) {
throw new RuntimeException('The algorithm RS256 is missing. Did you forget to install the package web-token/jwt-signature-algorithm-rsa?');
}
if (!class_exists(JWKFactory::class)) {
throw new RuntimeException('The class Jose\Component\KeyManagement\JWKFactory is missing. Did you forget to install the package web-token/jwt-key-mgmt?');
}
if (null !== $client) {
@trigger_error('The argument "client" is deprecated since version 3.3 and will be removed in 4.0. Please set `null` instead and use the method "enableApiVerification".', E_USER_DEPRECATED);
}
if (null !== $apiKey) {
@trigger_error('The argument "apiKey" is deprecated since version 3.3 and will be removed in 4.0. Please set `null` instead and use the method "enableApiVerification".', E_USER_DEPRECATED);
}
if (null !== $requestFactory) {
@trigger_error('The argument "requestFactory" is deprecated since version 3.3 and will be removed in 4.0. Please set `null` instead and use the method "enableApiVerification".', E_USER_DEPRECATED);
}
if (null !== $maxAge) {
@trigger_error('The argument "maxAge" is deprecated since version 3.3 and will be removed in 4.0. Please set `null` instead and use the method "setMaxAge".', E_USER_DEPRECATED);
}
if (null !== $leeway) {
@trigger_error('The argument "leeway" is deprecated since version 3.3 and will be removed in 4.0. Please set `null` instead and use the method "setLeeway".', E_USER_DEPRECATED);
}
$this->jwsSerializer = new CompactSerializer();
$this->initJwsVerifier();
//To be removed in 4.0
$this->leeway = $leeway ?? 0;
$this->maxAge = $maxAge ?? 60000;
$this->apiKey = $apiKey;
$this->client = $client;
$this->requestFactory = $requestFactory;
}
public function enableApiVerification(ClientInterface $client, string $apiKey, RequestFactoryInterface $requestFactory): self
{
$this->apiKey = $apiKey;
$this->client = $client;
$this->requestFactory = $requestFactory;
return $this;
}
public function setMaxAge(int $maxAge): self
{
$this->maxAge = $maxAge;
return $this;
}
public function setLeeway(int $leeway): self
{
$this->leeway = $leeway;
return $this;
}
public function name(): string
{
return 'android-safetynet';
}
/**
* @param mixed[] $attestation
*/
public function load(array $attestation): AttestationStatement
{
Assertion::keyExists($attestation, 'attStmt', 'Invalid attestation object');
foreach (['ver', 'response'] as $key) {
Assertion::keyExists($attestation['attStmt'], $key, sprintf('The attestation statement value "%s" is missing.', $key));
Assertion::notEmpty($attestation['attStmt'][$key], sprintf('The attestation statement value "%s" is empty.', $key));
}
$jws = $this->jwsSerializer->unserialize($attestation['attStmt']['response']);
$jwsHeader = $jws->getSignature(0)->getProtectedHeader();
Assertion::keyExists($jwsHeader, 'x5c', 'The response in the attestation statement must contain a "x5c" header.');
Assertion::notEmpty($jwsHeader['x5c'], 'The "x5c" parameter in the attestation statement response must contain at least one certificate.');
$certificates = $this->convertCertificatesToPem($jwsHeader['x5c']);
$attestation['attStmt']['jws'] = $jws;
return AttestationStatement::createBasic(
$this->name(),
$attestation['attStmt'],
new CertificateTrustPath($certificates)
);
}
public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
$trustPath = $attestationStatement->getTrustPath();
Assertion::isInstanceOf($trustPath, CertificateTrustPath::class, 'Invalid trust path');
$certificates = $trustPath->getCertificates();
$firstCertificate = current($certificates);
Assertion::string($firstCertificate, 'No certificate');
$parsedCertificate = openssl_x509_parse($firstCertificate);
Assertion::isArray($parsedCertificate, 'Invalid attestation object');
Assertion::keyExists($parsedCertificate, 'subject', 'Invalid attestation object');
Assertion::keyExists($parsedCertificate['subject'], 'CN', 'Invalid attestation object');
Assertion::eq($parsedCertificate['subject']['CN'], 'attest.android.com', 'Invalid attestation object');
/** @var JWS $jws */
$jws = $attestationStatement->get('jws');
$payload = $jws->getPayload();
$this->validatePayload($payload, $clientDataJSONHash, $authenticatorData);
//Check the signature
$this->validateSignature($jws, $trustPath);
//Check against Google service
$this->validateUsingGoogleApi($attestationStatement);
return true;
}
private function validatePayload(?string $payload, string $clientDataJSONHash, AuthenticatorData $authenticatorData): void
{
Assertion::notNull($payload, 'Invalid attestation object');
$payload = JsonConverter::decode($payload);
Assertion::isArray($payload, 'Invalid attestation object');
Assertion::keyExists($payload, 'nonce', 'Invalid attestation object. "nonce" is missing.');
Assertion::eq($payload['nonce'], base64_encode(hash('sha256', $authenticatorData->getAuthData().$clientDataJSONHash, true)), 'Invalid attestation object. Invalid nonce');
Assertion::keyExists($payload, 'ctsProfileMatch', 'Invalid attestation object. "ctsProfileMatch" is missing.');
Assertion::true($payload['ctsProfileMatch'], 'Invalid attestation object. "ctsProfileMatch" value is false.');
Assertion::keyExists($payload, 'timestampMs', 'Invalid attestation object. Timestamp is missing.');
Assertion::integer($payload['timestampMs'], 'Invalid attestation object. Timestamp shall be an integer.');
$currentTime = time() * 1000;
Assertion::lessOrEqualThan($payload['timestampMs'], $currentTime + $this->leeway, sprintf('Invalid attestation object. Issued in the future. Current time: %d. Response time: %d', $currentTime, $payload['timestampMs']));
Assertion::lessOrEqualThan($currentTime - $payload['timestampMs'], $this->maxAge, sprintf('Invalid attestation object. Too old. Current time: %d. Response time: %d', $currentTime, $payload['timestampMs']));
}
private function validateSignature(JWS $jws, CertificateTrustPath $trustPath): void
{
$jwk = JWKFactory::createFromCertificate($trustPath->getCertificates()[0]);
$isValid = $this->jwsVerifier->verifyWithKey($jws, $jwk, 0);
Assertion::true($isValid, 'Invalid response signature');
}
private function validateUsingGoogleApi(AttestationStatement $attestationStatement): void
{
if (null === $this->client || null === $this->apiKey || null === $this->requestFactory) {
return;
}
$uri = sprintf('https://www.googleapis.com/androidcheck/v1/attestations/verify?key=%s', urlencode($this->apiKey));
$requestBody = sprintf('{"signedAttestation":"%s"}', $attestationStatement->get('response'));
$request = $this->requestFactory->createRequest('POST', $uri);
$request = $request->withHeader('content-type', 'application/json');
$request->getBody()->write($requestBody);
$response = $this->client->sendRequest($request);
$this->checkGoogleApiResponse($response);
$responseBody = $this->getResponseBody($response);
$responseBodyJson = json_decode($responseBody, true);
Assertion::keyExists($responseBodyJson, 'isValidSignature', 'Invalid response.');
Assertion::boolean($responseBodyJson['isValidSignature'], 'Invalid response.');
Assertion::true($responseBodyJson['isValidSignature'], 'Invalid response.');
}
private function getResponseBody(ResponseInterface $response): string
{
$responseBody = '';
$response->getBody()->rewind();
while (true) {
$tmp = $response->getBody()->read(1024);
if ('' === $tmp) {
break;
}
$responseBody .= $tmp;
}
return $responseBody;
}
private function checkGoogleApiResponse(ResponseInterface $response): void
{
Assertion::eq(200, $response->getStatusCode(), 'Request did not succeeded');
Assertion::true($response->hasHeader('content-type'), 'Unrecognized response');
foreach ($response->getHeader('content-type') as $header) {
if (0 === mb_strpos($header, 'application/json')) {
return;
}
}
throw new InvalidArgumentException('Unrecognized response');
}
/**
* @param string[] $certificates
*
* @return string[]
*/
private function convertCertificatesToPem(array $certificates): array
{
foreach ($certificates as $k => $v) {
$certificates[$k] = CertificateToolbox::fixPEMStructure($v);
}
return $certificates;
}
private function initJwsVerifier(): void
{
$algorithmClasses = [
Algorithm\RS256::class, Algorithm\RS384::class, Algorithm\RS512::class,
Algorithm\PS256::class, Algorithm\PS384::class, Algorithm\PS512::class,
Algorithm\ES256::class, Algorithm\ES384::class, Algorithm\ES512::class,
Algorithm\EdDSA::class,
];
/* @var AlgorithmInterface[] $algorithms */
$algorithms = [];
foreach ($algorithmClasses as $algorithm) {
if (class_exists($algorithm)) {
/* @var AlgorithmInterface $algorithm */
$algorithms[] = new $algorithm();
}
}
$algorithmManager = new AlgorithmManager($algorithms);
$this->jwsVerifier = new JWSVerifier($algorithmManager);
}
}

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Assert\Assertion;
use CBOR\Decoder;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Key\Ec2Key;
use Cose\Key\Key;
use Cose\Key\RsaKey;
use function count;
use function Safe\openssl_pkey_get_public;
use function Safe\sprintf;
use Webauthn\AuthenticatorData;
use Webauthn\CertificateToolbox;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
final class AppleAttestationStatementSupport implements AttestationStatementSupport
{
/**
* @var Decoder
*/
private $decoder;
public function __construct()
{
$this->decoder = new Decoder(new TagObjectManager(), new OtherObjectManager());
}
public function name(): string
{
return 'apple';
}
/**
* @param mixed[] $attestation
*/
public function load(array $attestation): AttestationStatement
{
Assertion::keyExists($attestation, 'attStmt', 'Invalid attestation object');
foreach (['x5c'] as $key) {
Assertion::keyExists($attestation['attStmt'], $key, sprintf('The attestation statement value "%s" is missing.', $key));
}
$certificates = $attestation['attStmt']['x5c'];
Assertion::isArray($certificates, 'The attestation statement value "x5c" must be a list with at least one certificate.');
Assertion::greaterThan(count($certificates), 0, 'The attestation statement value "x5c" must be a list with at least one certificate.');
Assertion::allString($certificates, 'The attestation statement value "x5c" must be a list with at least one certificate.');
$certificates = CertificateToolbox::convertAllDERToPEM($certificates);
return AttestationStatement::createAnonymizationCA($attestation['fmt'], $attestation['attStmt'], new CertificateTrustPath($certificates));
}
public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
$trustPath = $attestationStatement->getTrustPath();
Assertion::isInstanceOf($trustPath, CertificateTrustPath::class, 'Invalid trust path');
$certificates = $trustPath->getCertificates();
//Decode leaf attestation certificate
$leaf = $certificates[0];
$this->checkCertificateAndGetPublicKey($leaf, $clientDataJSONHash, $authenticatorData);
return true;
}
private function checkCertificateAndGetPublicKey(string $certificate, string $clientDataHash, AuthenticatorData $authenticatorData): void
{
$resource = openssl_pkey_get_public($certificate);
$details = openssl_pkey_get_details($resource);
Assertion::isArray($details, 'Unable to read the certificate');
//Check that authData publicKey matches the public key in the attestation certificate
$attestedCredentialData = $authenticatorData->getAttestedCredentialData();
Assertion::notNull($attestedCredentialData, 'No attested credential data found');
$publicKeyData = $attestedCredentialData->getCredentialPublicKey();
Assertion::notNull($publicKeyData, 'No attested public key found');
$publicDataStream = new StringStream($publicKeyData);
$coseKey = $this->decoder->decode($publicDataStream)->getNormalizedData(false);
Assertion::true($publicDataStream->isEOF(), 'Invalid public key data. Presence of extra bytes.');
$publicDataStream->close();
$publicKey = Key::createFromData($coseKey);
Assertion::true(($publicKey instanceof Ec2Key) || ($publicKey instanceof RsaKey), 'Unsupported key type');
//We check the attested key corresponds to the key in the certificate
Assertion::eq($publicKey->asPEM(), $details['key'], 'Invalid key');
/*---------------------------*/
$certDetails = openssl_x509_parse($certificate);
//Find Apple Extension with OID “1.2.840.113635.100.8.2” in certificate extensions
Assertion::isArray($certDetails, 'The certificate is not valid');
Assertion::keyExists($certDetails, 'extensions', 'The certificate has no extension');
Assertion::isArray($certDetails['extensions'], 'The certificate has no extension');
Assertion::keyExists($certDetails['extensions'], '1.2.840.113635.100.8.2', 'The certificate extension "1.2.840.113635.100.8.2" is missing');
$extension = $certDetails['extensions']['1.2.840.113635.100.8.2'];
$nonceToHash = $authenticatorData->getAuthData().$clientDataHash;
$nonce = hash('sha256', $nonceToHash);
//'3024a1220420' corresponds to the Sequence+Explicitly Tagged Object + Octet Object
Assertion::eq('3024a1220420'.$nonce, bin2hex($extension), 'The client data hash is not valid');
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Webauthn\AuthenticatorData;
use Webauthn\MetadataService\MetadataStatement;
class AttestationObject
{
/**
* @var string
*/
private $rawAttestationObject;
/**
* @var AttestationStatement
*/
private $attStmt;
/**
* @var AuthenticatorData
*/
private $authData;
/**
* @var MetadataStatement|null
*/
private $metadataStatement;
public function __construct(string $rawAttestationObject, AttestationStatement $attStmt, AuthenticatorData $authData, ?MetadataStatement $metadataStatement = null)
{
if (null !== $metadataStatement) {
@trigger_error('The argument "metadataStatement" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setMetadataStatement".', E_USER_DEPRECATED);
}
$this->rawAttestationObject = $rawAttestationObject;
$this->attStmt = $attStmt;
$this->authData = $authData;
$this->metadataStatement = $metadataStatement;
}
public function getRawAttestationObject(): string
{
return $this->rawAttestationObject;
}
public function getAttStmt(): AttestationStatement
{
return $this->attStmt;
}
public function setAttStmt(AttestationStatement $attStmt): void
{
$this->attStmt = $attStmt;
}
public function getAuthData(): AuthenticatorData
{
return $this->authData;
}
public function getMetadataStatement(): ?MetadataStatement
{
return $this->metadataStatement;
}
public function setMetadataStatement(MetadataStatement $metadataStatement): self
{
$this->metadataStatement = $metadataStatement;
return $this;
}
}

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Assert\Assertion;
use Base64Url\Base64Url;
use CBOR\Decoder;
use CBOR\MapObject;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use function ord;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Ramsey\Uuid\Uuid;
use function Safe\sprintf;
use function Safe\unpack;
use Throwable;
use Webauthn\AttestedCredentialData;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputsLoader;
use Webauthn\AuthenticatorData;
use Webauthn\MetadataService\MetadataStatementRepository;
use Webauthn\StringStream;
class AttestationObjectLoader
{
private const FLAG_AT = 0b01000000;
private const FLAG_ED = 0b10000000;
/**
* @var Decoder
*/
private $decoder;
/**
* @var AttestationStatementSupportManager
*/
private $attestationStatementSupportManager;
/**
* @var LoggerInterface|null
*/
private $logger;
public function __construct(AttestationStatementSupportManager $attestationStatementSupportManager, ?MetadataStatementRepository $metadataStatementRepository = null, ?LoggerInterface $logger = null)
{
if (null !== $metadataStatementRepository) {
@trigger_error('The argument "metadataStatementRepository" is deprecated since version 3.2 and will be removed in 4.0. Please set `null` instead.', E_USER_DEPRECATED);
}
if (null !== $logger) {
@trigger_error('The argument "logger" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setLogger" instead.', E_USER_DEPRECATED);
}
$this->decoder = new Decoder(new TagObjectManager(), new OtherObjectManager());
$this->attestationStatementSupportManager = $attestationStatementSupportManager;
$this->logger = $logger ?? new NullLogger();
}
public static function create(AttestationStatementSupportManager $attestationStatementSupportManager): self
{
return new self($attestationStatementSupportManager);
}
public function load(string $data): AttestationObject
{
try {
$this->logger->info('Trying to load the data', ['data' => $data]);
$decodedData = Base64Url::decode($data);
$stream = new StringStream($decodedData);
$parsed = $this->decoder->decode($stream);
$this->logger->info('Loading the Attestation Statement');
$attestationObject = $parsed->getNormalizedData();
Assertion::true($stream->isEOF(), 'Invalid attestation object. Presence of extra bytes.');
$stream->close();
Assertion::isArray($attestationObject, 'Invalid attestation object');
Assertion::keyExists($attestationObject, 'authData', 'Invalid attestation object');
Assertion::keyExists($attestationObject, 'fmt', 'Invalid attestation object');
Assertion::keyExists($attestationObject, 'attStmt', 'Invalid attestation object');
$authData = $attestationObject['authData'];
$attestationStatementSupport = $this->attestationStatementSupportManager->get($attestationObject['fmt']);
$attestationStatement = $attestationStatementSupport->load($attestationObject);
$this->logger->info('Attestation Statement loaded');
$this->logger->debug('Attestation Statement loaded', ['attestationStatement' => $attestationStatement]);
$authDataStream = new StringStream($authData);
$rp_id_hash = $authDataStream->read(32);
$flags = $authDataStream->read(1);
$signCount = $authDataStream->read(4);
$signCount = unpack('N', $signCount)[1];
$this->logger->debug(sprintf('Signature counter: %d', $signCount));
$attestedCredentialData = null;
if (0 !== (ord($flags) & self::FLAG_AT)) {
$this->logger->info('Attested Credential Data is present');
$aaguid = Uuid::fromBytes($authDataStream->read(16));
$credentialLength = $authDataStream->read(2);
$credentialLength = unpack('n', $credentialLength)[1];
$credentialId = $authDataStream->read($credentialLength);
$credentialPublicKey = $this->decoder->decode($authDataStream);
Assertion::isInstanceOf($credentialPublicKey, MapObject::class, 'The data does not contain a valid credential public key.');
$attestedCredentialData = new AttestedCredentialData($aaguid, $credentialId, (string) $credentialPublicKey);
$this->logger->info('Attested Credential Data loaded');
$this->logger->debug('Attested Credential Data loaded', ['at' => $attestedCredentialData]);
}
$extension = null;
if (0 !== (ord($flags) & self::FLAG_ED)) {
$this->logger->info('Extension Data loaded');
$extension = $this->decoder->decode($authDataStream);
$extension = AuthenticationExtensionsClientOutputsLoader::load($extension);
$this->logger->info('Extension Data loaded');
$this->logger->debug('Extension Data loaded', ['ed' => $extension]);
}
Assertion::true($authDataStream->isEOF(), 'Invalid authentication data. Presence of extra bytes.');
$authDataStream->close();
$authenticatorData = new AuthenticatorData($authData, $rp_id_hash, $flags, $signCount, $attestedCredentialData, $extension);
$attestationObject = new AttestationObject($data, $attestationStatement, $authenticatorData);
$this->logger->info('Attestation Object loaded');
$this->logger->debug('Attestation Object', ['ed' => $attestationObject]);
return $attestationObject;
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
throw $throwable;
}
}
public function setLogger(LoggerInterface $logger): self
{
$this->logger = $logger;
return $this;
}
}

View file

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use Assert\Assertion;
use JsonSerializable;
use function Safe\sprintf;
use Webauthn\TrustPath\TrustPath;
use Webauthn\TrustPath\TrustPathLoader;
class AttestationStatement implements JsonSerializable
{
public const TYPE_NONE = 'none';
public const TYPE_BASIC = 'basic';
public const TYPE_SELF = 'self';
public const TYPE_ATTCA = 'attca';
public const TYPE_ECDAA = 'ecdaa';
public const TYPE_ANONCA = 'anonca';
/**
* @var string
*/
private $fmt;
/**
* @var mixed[]
*/
private $attStmt;
/**
* @var TrustPath
*/
private $trustPath;
/**
* @var string
*/
private $type;
/**
* @param mixed[] $attStmt
*/
public function __construct(string $fmt, array $attStmt, string $type, TrustPath $trustPath)
{
$this->fmt = $fmt;
$this->attStmt = $attStmt;
$this->type = $type;
$this->trustPath = $trustPath;
}
/**
* @param mixed[] $attStmt
*/
public static function createNone(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_NONE, $trustPath);
}
/**
* @param mixed[] $attStmt
*/
public static function createBasic(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_BASIC, $trustPath);
}
/**
* @param mixed[] $attStmt
*/
public static function createSelf(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_SELF, $trustPath);
}
/**
* @param mixed[] $attStmt
*/
public static function createAttCA(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_ATTCA, $trustPath);
}
/**
* @param mixed[] $attStmt
*/
public static function createEcdaa(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_ECDAA, $trustPath);
}
public static function createAnonymizationCA(string $fmt, array $attStmt, TrustPath $trustPath): self
{
return new self($fmt, $attStmt, self::TYPE_ANONCA, $trustPath);
}
public function getFmt(): string
{
return $this->fmt;
}
/**
* @return mixed[]
*/
public function getAttStmt(): array
{
return $this->attStmt;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->attStmt);
}
/**
* @return mixed
*/
public function get(string $key)
{
Assertion::true($this->has($key), sprintf('The attestation statement has no key "%s".', $key));
return $this->attStmt[$key];
}
public function getTrustPath(): TrustPath
{
return $this->trustPath;
}
public function getType(): string
{
return $this->type;
}
/**
* @param mixed[] $data
*/
public static function createFromArray(array $data): self
{
foreach (['fmt', 'attStmt', 'trustPath', 'type'] as $key) {
Assertion::keyExists($data, $key, sprintf('The key "%s" is missing', $key));
}
return new self(
$data['fmt'],
$data['attStmt'],
$data['type'],
TrustPathLoader::loadTrustPath($data['trustPath'])
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return [
'fmt' => $this->fmt,
'attStmt' => $this->attStmt,
'trustPath' => $this->trustPath->jsonSerialize(),
'type' => $this->type,
];
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Webauthn\AuthenticatorData;
interface AttestationStatementSupport
{
public function name(): string;
/**
* @param mixed[] $attestation
*/
public function load(array $attestation): AttestationStatement;
public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool;
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use Assert\Assertion;
use function Safe\sprintf;
class AttestationStatementSupportManager
{
/**
* @var AttestationStatementSupport[]
*/
private $attestationStatementSupports = [];
public function add(AttestationStatementSupport $attestationStatementSupport): void
{
$this->attestationStatementSupports[$attestationStatementSupport->name()] = $attestationStatementSupport;
}
public function has(string $name): bool
{
return array_key_exists($name, $this->attestationStatementSupports);
}
public function get(string $name): AttestationStatementSupport
{
Assertion::true($this->has($name), sprintf('The attestation statement format "%s" is not supported.', $name));
return $this->attestationStatementSupports[$name];
}
}

View file

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Assert\Assertion;
use CBOR\Decoder;
use CBOR\MapObject;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Key\Ec2Key;
use InvalidArgumentException;
use function Safe\openssl_pkey_get_public;
use function Safe\sprintf;
use Throwable;
use Webauthn\AuthenticatorData;
use Webauthn\CertificateToolbox;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
final class FidoU2FAttestationStatementSupport implements AttestationStatementSupport
{
/**
* @var Decoder
*/
private $decoder;
public function __construct()
{
$this->decoder = new Decoder(new TagObjectManager(), new OtherObjectManager());
}
public function name(): string
{
return 'fido-u2f';
}
/**
* @param mixed[] $attestation
*/
public function load(array $attestation): AttestationStatement
{
Assertion::keyExists($attestation, 'attStmt', 'Invalid attestation object');
foreach (['sig', 'x5c'] as $key) {
Assertion::keyExists($attestation['attStmt'], $key, sprintf('The attestation statement value "%s" is missing.', $key));
}
$certificates = $attestation['attStmt']['x5c'];
Assertion::isArray($certificates, 'The attestation statement value "x5c" must be a list with one certificate.');
Assertion::count($certificates, 1, 'The attestation statement value "x5c" must be a list with one certificate.');
Assertion::allString($certificates, 'The attestation statement value "x5c" must be a list with one certificate.');
reset($certificates);
$certificates = CertificateToolbox::convertAllDERToPEM($certificates);
$this->checkCertificate($certificates[0]);
return AttestationStatement::createBasic($attestation['fmt'], $attestation['attStmt'], new CertificateTrustPath($certificates));
}
public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
Assertion::eq(
$authenticatorData->getAttestedCredentialData()->getAaguid()->toString(),
'00000000-0000-0000-0000-000000000000',
'Invalid AAGUID for fido-u2f attestation statement. Shall be "00000000-0000-0000-0000-000000000000"'
);
$trustPath = $attestationStatement->getTrustPath();
Assertion::isInstanceOf($trustPath, CertificateTrustPath::class, 'Invalid trust path');
$dataToVerify = "\0";
$dataToVerify .= $authenticatorData->getRpIdHash();
$dataToVerify .= $clientDataJSONHash;
$dataToVerify .= $authenticatorData->getAttestedCredentialData()->getCredentialId();
$dataToVerify .= $this->extractPublicKey($authenticatorData->getAttestedCredentialData()->getCredentialPublicKey());
return 1 === openssl_verify($dataToVerify, $attestationStatement->get('sig'), $trustPath->getCertificates()[0], OPENSSL_ALGO_SHA256);
}
private function extractPublicKey(?string $publicKey): string
{
Assertion::notNull($publicKey, 'The attested credential data does not contain a valid public key.');
$publicKeyStream = new StringStream($publicKey);
$coseKey = $this->decoder->decode($publicKeyStream);
Assertion::true($publicKeyStream->isEOF(), 'Invalid public key. Presence of extra bytes.');
$publicKeyStream->close();
Assertion::isInstanceOf($coseKey, MapObject::class, 'The attested credential data does not contain a valid public key.');
$coseKey = $coseKey->getNormalizedData();
$ec2Key = new Ec2Key($coseKey + [Ec2Key::TYPE => 2, Ec2Key::DATA_CURVE => Ec2Key::CURVE_P256]);
return "\x04".$ec2Key->x().$ec2Key->y();
}
private function checkCertificate(string $publicKey): void
{
try {
$resource = openssl_pkey_get_public($publicKey);
$details = openssl_pkey_get_details($resource);
} catch (Throwable $throwable) {
throw new InvalidArgumentException('Invalid certificate or certificate chain', 0, $throwable);
}
Assertion::isArray($details, 'Invalid certificate or certificate chain');
Assertion::keyExists($details, 'ec', 'Invalid certificate or certificate chain');
Assertion::keyExists($details['ec'], 'curve_name', 'Invalid certificate or certificate chain');
Assertion::eq($details['ec']['curve_name'], 'prime256v1', 'Invalid certificate or certificate chain');
Assertion::keyExists($details['ec'], 'curve_oid', 'Invalid certificate or certificate chain');
Assertion::eq($details['ec']['curve_oid'], '1.2.840.10045.3.1.7', 'Invalid certificate or certificate chain');
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Assert\Assertion;
use function count;
use Webauthn\AuthenticatorData;
use Webauthn\TrustPath\EmptyTrustPath;
final class NoneAttestationStatementSupport implements AttestationStatementSupport
{
public function name(): string
{
return 'none';
}
/**
* @param mixed[] $attestation
*/
public function load(array $attestation): AttestationStatement
{
Assertion::noContent($attestation['attStmt'], 'Invalid attestation object');
return AttestationStatement::createNone($attestation['fmt'], $attestation['attStmt'], new EmptyTrustPath());
}
public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
return 0 === count($attestationStatement->getAttStmt());
}
}

View file

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use function array_key_exists;
use Assert\Assertion;
use CBOR\Decoder;
use CBOR\MapObject;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\Signature;
use Cose\Algorithms;
use Cose\Key\Key;
use function in_array;
use InvalidArgumentException;
use function is_array;
use RuntimeException;
use Webauthn\AuthenticatorData;
use Webauthn\CertificateToolbox;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
use Webauthn\TrustPath\EcdaaKeyIdTrustPath;
use Webauthn\TrustPath\EmptyTrustPath;
use Webauthn\Util\CoseSignatureFixer;
final class PackedAttestationStatementSupport implements AttestationStatementSupport
{
/**
* @var Decoder
*/
private $decoder;
/**
* @var Manager
*/
private $algorithmManager;
public function __construct(Manager $algorithmManager)
{
$this->decoder = new Decoder(new TagObjectManager(), new OtherObjectManager());
$this->algorithmManager = $algorithmManager;
}
public function name(): string
{
return 'packed';
}
/**
* @param mixed[] $attestation
*/
public function load(array $attestation): AttestationStatement
{
Assertion::keyExists($attestation['attStmt'], 'sig', 'The attestation statement value "sig" is missing.');
Assertion::keyExists($attestation['attStmt'], 'alg', 'The attestation statement value "alg" is missing.');
Assertion::string($attestation['attStmt']['sig'], 'The attestation statement value "sig" is missing.');
switch (true) {
case array_key_exists('x5c', $attestation['attStmt']):
return $this->loadBasicType($attestation);
case array_key_exists('ecdaaKeyId', $attestation['attStmt']):
return $this->loadEcdaaType($attestation['attStmt']);
default:
return $this->loadEmptyType($attestation);
}
}
public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
$trustPath = $attestationStatement->getTrustPath();
switch (true) {
case $trustPath instanceof CertificateTrustPath:
return $this->processWithCertificate($clientDataJSONHash, $attestationStatement, $authenticatorData, $trustPath);
case $trustPath instanceof EcdaaKeyIdTrustPath:
return $this->processWithECDAA();
case $trustPath instanceof EmptyTrustPath:
return $this->processWithSelfAttestation($clientDataJSONHash, $attestationStatement, $authenticatorData);
default:
throw new InvalidArgumentException('Unsupported attestation statement');
}
}
/**
* @param mixed[] $attestation
*/
private function loadBasicType(array $attestation): AttestationStatement
{
$certificates = $attestation['attStmt']['x5c'];
Assertion::isArray($certificates, 'The attestation statement value "x5c" must be a list with at least one certificate.');
Assertion::minCount($certificates, 1, 'The attestation statement value "x5c" must be a list with at least one certificate.');
$certificates = CertificateToolbox::convertAllDERToPEM($certificates);
return AttestationStatement::createBasic($attestation['fmt'], $attestation['attStmt'], new CertificateTrustPath($certificates));
}
private function loadEcdaaType(array $attestation): AttestationStatement
{
$ecdaaKeyId = $attestation['attStmt']['ecdaaKeyId'];
Assertion::string($ecdaaKeyId, 'The attestation statement value "ecdaaKeyId" is invalid.');
return AttestationStatement::createEcdaa($attestation['fmt'], $attestation['attStmt'], new EcdaaKeyIdTrustPath($attestation['ecdaaKeyId']));
}
/**
* @param mixed[] $attestation
*/
private function loadEmptyType(array $attestation): AttestationStatement
{
return AttestationStatement::createSelf($attestation['fmt'], $attestation['attStmt'], new EmptyTrustPath());
}
private function checkCertificate(string $attestnCert, AuthenticatorData $authenticatorData): void
{
$parsed = openssl_x509_parse($attestnCert);
Assertion::isArray($parsed, 'Invalid certificate');
//Check version
Assertion::false(!isset($parsed['version']) || 2 !== $parsed['version'], 'Invalid certificate version');
//Check subject field
Assertion::false(!isset($parsed['name']) || false === mb_strpos($parsed['name'], '/OU=Authenticator Attestation'), 'Invalid certificate name. The Subject Organization Unit must be "Authenticator Attestation"');
//Check extensions
Assertion::false(!isset($parsed['extensions']) || !is_array($parsed['extensions']), 'Certificate extensions are missing');
//Check certificate is not a CA cert
Assertion::false(!isset($parsed['extensions']['basicConstraints']) || 'CA:FALSE' !== $parsed['extensions']['basicConstraints'], 'The Basic Constraints extension must have the CA component set to false');
$attestedCredentialData = $authenticatorData->getAttestedCredentialData();
Assertion::notNull($attestedCredentialData, 'No attested credential available');
// id-fido-gen-ce-aaguid OID check
Assertion::false(in_array('1.3.6.1.4.1.45724.1.1.4', $parsed['extensions'], true) && !hash_equals($attestedCredentialData->getAaguid()->getBytes(), $parsed['extensions']['1.3.6.1.4.1.45724.1.1.4']), 'The value of the "aaguid" does not match with the certificate');
}
private function processWithCertificate(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData, CertificateTrustPath $trustPath): bool
{
$certificates = $trustPath->getCertificates();
// Check leaf certificate
$this->checkCertificate($certificates[0], $authenticatorData);
// Get the COSE algorithm identifier and the corresponding OpenSSL one
$coseAlgorithmIdentifier = (int) $attestationStatement->get('alg');
$opensslAlgorithmIdentifier = Algorithms::getOpensslAlgorithmFor($coseAlgorithmIdentifier);
// Verification of the signature
$signedData = $authenticatorData->getAuthData().$clientDataJSONHash;
$result = openssl_verify($signedData, $attestationStatement->get('sig'), $certificates[0], $opensslAlgorithmIdentifier);
return 1 === $result;
}
private function processWithECDAA(): bool
{
throw new RuntimeException('ECDAA not supported');
}
private function processWithSelfAttestation(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
$attestedCredentialData = $authenticatorData->getAttestedCredentialData();
Assertion::notNull($attestedCredentialData, 'No attested credential available');
$credentialPublicKey = $attestedCredentialData->getCredentialPublicKey();
Assertion::notNull($credentialPublicKey, 'No credential public key available');
$publicKeyStream = new StringStream($credentialPublicKey);
$publicKey = $this->decoder->decode($publicKeyStream);
Assertion::true($publicKeyStream->isEOF(), 'Invalid public key. Presence of extra bytes.');
$publicKeyStream->close();
Assertion::isInstanceOf($publicKey, MapObject::class, 'The attested credential data does not contain a valid public key.');
$publicKey = $publicKey->getNormalizedData(false);
$publicKey = new Key($publicKey);
Assertion::eq($publicKey->alg(), (int) $attestationStatement->get('alg'), 'The algorithm of the attestation statement and the key are not identical.');
$dataToVerify = $authenticatorData->getAuthData().$clientDataJSONHash;
$algorithm = $this->algorithmManager->get((int) $attestationStatement->get('alg'));
if (!$algorithm instanceof Signature) {
throw new RuntimeException('Invalid algorithm');
}
$signature = CoseSignatureFixer::fix($attestationStatement->get('sig'), $algorithm);
return $algorithm->verify($dataToVerify, $publicKey, $signature);
}
}

View file

@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AttestationStatement;
use Assert\Assertion;
use Base64Url\Base64Url;
use CBOR\Decoder;
use CBOR\MapObject;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Algorithms;
use Cose\Key\Ec2Key;
use Cose\Key\Key;
use Cose\Key\OkpKey;
use Cose\Key\RsaKey;
use function count;
use function in_array;
use InvalidArgumentException;
use function is_array;
use RuntimeException;
use Safe\DateTimeImmutable;
use function Safe\sprintf;
use function Safe\unpack;
use Webauthn\AuthenticatorData;
use Webauthn\CertificateToolbox;
use Webauthn\StringStream;
use Webauthn\TrustPath\CertificateTrustPath;
use Webauthn\TrustPath\EcdaaKeyIdTrustPath;
final class TPMAttestationStatementSupport implements AttestationStatementSupport
{
public function name(): string
{
return 'tpm';
}
/**
* @param mixed[] $attestation
*/
public function load(array $attestation): AttestationStatement
{
Assertion::keyExists($attestation, 'attStmt', 'Invalid attestation object');
Assertion::keyNotExists($attestation['attStmt'], 'ecdaaKeyId', 'ECDAA not supported');
foreach (['ver', 'ver', 'sig', 'alg', 'certInfo', 'pubArea'] as $key) {
Assertion::keyExists($attestation['attStmt'], $key, sprintf('The attestation statement value "%s" is missing.', $key));
}
Assertion::eq('2.0', $attestation['attStmt']['ver'], 'Invalid attestation object');
$certInfo = $this->checkCertInfo($attestation['attStmt']['certInfo']);
Assertion::eq('8017', bin2hex($certInfo['type']), 'Invalid attestation object');
$pubArea = $this->checkPubArea($attestation['attStmt']['pubArea']);
$pubAreaAttestedNameAlg = mb_substr($certInfo['attestedName'], 0, 2, '8bit');
$pubAreaHash = hash($this->getTPMHash($pubAreaAttestedNameAlg), $attestation['attStmt']['pubArea'], true);
$attestedName = $pubAreaAttestedNameAlg.$pubAreaHash;
Assertion::eq($attestedName, $certInfo['attestedName'], 'Invalid attested name');
$attestation['attStmt']['parsedCertInfo'] = $certInfo;
$attestation['attStmt']['parsedPubArea'] = $pubArea;
$certificates = CertificateToolbox::convertAllDERToPEM($attestation['attStmt']['x5c']);
Assertion::minCount($certificates, 1, 'The attestation statement value "x5c" must be a list with at least one certificate.');
return AttestationStatement::createAttCA(
$this->name(),
$attestation['attStmt'],
new CertificateTrustPath($certificates)
);
}
public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
$attToBeSigned = $authenticatorData->getAuthData().$clientDataJSONHash;
$attToBeSignedHash = hash(Algorithms::getHashAlgorithmFor((int) $attestationStatement->get('alg')), $attToBeSigned, true);
Assertion::eq($attestationStatement->get('parsedCertInfo')['extraData'], $attToBeSignedHash, 'Invalid attestation hash');
$this->checkUniquePublicKey(
$attestationStatement->get('parsedPubArea')['unique'],
$authenticatorData->getAttestedCredentialData()->getCredentialPublicKey()
);
switch (true) {
case $attestationStatement->getTrustPath() instanceof CertificateTrustPath:
return $this->processWithCertificate($clientDataJSONHash, $attestationStatement, $authenticatorData);
case $attestationStatement->getTrustPath() instanceof EcdaaKeyIdTrustPath:
return $this->processWithECDAA();
default:
throw new InvalidArgumentException('Unsupported attestation statement');
}
}
private function checkUniquePublicKey(string $unique, string $cborPublicKey): void
{
$cborDecoder = new Decoder(new TagObjectManager(), new OtherObjectManager());
$publicKey = $cborDecoder->decode(new StringStream($cborPublicKey));
Assertion::isInstanceOf($publicKey, MapObject::class, 'Invalid public key');
$key = new Key($publicKey->getNormalizedData(false));
switch ($key->type()) {
case Key::TYPE_OKP:
$uniqueFromKey = (new OkpKey($key->getData()))->x();
break;
case Key::TYPE_EC2:
$ec2Key = new Ec2Key($key->getData());
$uniqueFromKey = "\x04".$ec2Key->x().$ec2Key->y();
break;
case Key::TYPE_RSA:
$uniqueFromKey = (new RsaKey($key->getData()))->n();
break;
default:
throw new InvalidArgumentException('Invalid or unsupported key type.');
}
Assertion::eq($unique, $uniqueFromKey, 'Invalid pubArea.unique value');
}
/**
* @return mixed[]
*/
private function checkCertInfo(string $data): array
{
$certInfo = new StringStream($data);
$magic = $certInfo->read(4);
Assertion::eq('ff544347', bin2hex($magic), 'Invalid attestation object');
$type = $certInfo->read(2);
$qualifiedSignerLength = unpack('n', $certInfo->read(2))[1];
$qualifiedSigner = $certInfo->read($qualifiedSignerLength); //Ignored
$extraDataLength = unpack('n', $certInfo->read(2))[1];
$extraData = $certInfo->read($extraDataLength);
$clockInfo = $certInfo->read(17); //Ignore
$firmwareVersion = $certInfo->read(8);
$attestedNameLength = unpack('n', $certInfo->read(2))[1];
$attestedName = $certInfo->read($attestedNameLength);
$attestedQualifiedNameLength = unpack('n', $certInfo->read(2))[1];
$attestedQualifiedName = $certInfo->read($attestedQualifiedNameLength); //Ignore
Assertion::true($certInfo->isEOF(), 'Invalid certificate information. Presence of extra bytes.');
$certInfo->close();
return [
'magic' => $magic,
'type' => $type,
'qualifiedSigner' => $qualifiedSigner,
'extraData' => $extraData,
'clockInfo' => $clockInfo,
'firmwareVersion' => $firmwareVersion,
'attestedName' => $attestedName,
'attestedQualifiedName' => $attestedQualifiedName,
];
}
/**
* @return mixed[]
*/
private function checkPubArea(string $data): array
{
$pubArea = new StringStream($data);
$type = $pubArea->read(2);
$nameAlg = $pubArea->read(2);
$objectAttributes = $pubArea->read(4);
$authPolicyLength = unpack('n', $pubArea->read(2))[1];
$authPolicy = $pubArea->read($authPolicyLength);
$parameters = $this->getParameters($type, $pubArea);
$uniqueLength = unpack('n', $pubArea->read(2))[1];
$unique = $pubArea->read($uniqueLength);
Assertion::true($pubArea->isEOF(), 'Invalid public area. Presence of extra bytes.');
$pubArea->close();
return [
'type' => $type,
'nameAlg' => $nameAlg,
'objectAttributes' => $objectAttributes,
'authPolicy' => $authPolicy,
'parameters' => $parameters,
'unique' => $unique,
];
}
/**
* @return mixed[]
*/
private function getParameters(string $type, StringStream $stream): array
{
switch (bin2hex($type)) {
case '0001':
case '0014':
case '0016':
return [
'symmetric' => $stream->read(2),
'scheme' => $stream->read(2),
'keyBits' => unpack('n', $stream->read(2))[1],
'exponent' => $this->getExponent($stream->read(4)),
];
case '0018':
return [
'symmetric' => $stream->read(2),
'scheme' => $stream->read(2),
'curveId' => $stream->read(2),
'kdf' => $stream->read(2),
];
default:
throw new InvalidArgumentException('Unsupported type');
}
}
private function getExponent(string $exponent): string
{
return '00000000' === bin2hex($exponent) ? Base64Url::decode('AQAB') : $exponent;
}
private function getTPMHash(string $nameAlg): string
{
switch (bin2hex($nameAlg)) {
case '0004':
return 'sha1'; //: "TPM_ALG_SHA1",
case '000b':
return 'sha256'; //: "TPM_ALG_SHA256",
case '000c':
return 'sha384'; //: "TPM_ALG_SHA384",
case '000d':
return 'sha512'; //: "TPM_ALG_SHA512",
default:
throw new InvalidArgumentException('Unsupported hash algorithm');
}
}
private function processWithCertificate(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool
{
$trustPath = $attestationStatement->getTrustPath();
Assertion::isInstanceOf($trustPath, CertificateTrustPath::class, 'Invalid trust path');
$certificates = $trustPath->getCertificates();
// Check certificate CA chain and returns the Attestation Certificate
$this->checkCertificate($certificates[0], $authenticatorData);
// Get the COSE algorithm identifier and the corresponding OpenSSL one
$coseAlgorithmIdentifier = (int) $attestationStatement->get('alg');
$opensslAlgorithmIdentifier = Algorithms::getOpensslAlgorithmFor($coseAlgorithmIdentifier);
$result = openssl_verify($attestationStatement->get('certInfo'), $attestationStatement->get('sig'), $certificates[0], $opensslAlgorithmIdentifier);
return 1 === $result;
}
private function checkCertificate(string $attestnCert, AuthenticatorData $authenticatorData): void
{
$parsed = openssl_x509_parse($attestnCert);
Assertion::isArray($parsed, 'Invalid certificate');
//Check version
Assertion::false(!isset($parsed['version']) || 2 !== $parsed['version'], 'Invalid certificate version');
//Check subject field is empty
Assertion::false(!isset($parsed['subject']) || !is_array($parsed['subject']) || 0 !== count($parsed['subject']), 'Invalid certificate name. The Subject should be empty');
// Check period of validity
Assertion::keyExists($parsed, 'validFrom_time_t', 'Invalid certificate start date.');
Assertion::integer($parsed['validFrom_time_t'], 'Invalid certificate start date.');
$startDate = (new DateTimeImmutable())->setTimestamp($parsed['validFrom_time_t']);
Assertion::true($startDate < new DateTimeImmutable(), 'Invalid certificate start date.');
Assertion::keyExists($parsed, 'validTo_time_t', 'Invalid certificate end date.');
Assertion::integer($parsed['validTo_time_t'], 'Invalid certificate end date.');
$endDate = (new DateTimeImmutable())->setTimestamp($parsed['validTo_time_t']);
Assertion::true($endDate > new DateTimeImmutable(), 'Invalid certificate end date.');
//Check extensions
Assertion::false(!isset($parsed['extensions']) || !is_array($parsed['extensions']), 'Certificate extensions are missing');
//Check subjectAltName
Assertion::false(!isset($parsed['extensions']['subjectAltName']), 'The "subjectAltName" is missing');
//Check extendedKeyUsage
Assertion::false(!isset($parsed['extensions']['extendedKeyUsage']), 'The "subjectAltName" is missing');
Assertion::eq($parsed['extensions']['extendedKeyUsage'], '2.23.133.8.3', 'The "extendedKeyUsage" is invalid');
// id-fido-gen-ce-aaguid OID check
Assertion::false(in_array('1.3.6.1.4.1.45724.1.1.4', $parsed['extensions'], true) && !hash_equals($authenticatorData->getAttestedCredentialData()->getAaguid()->getBytes(), $parsed['extensions']['1.3.6.1.4.1.45724.1.1.4']), 'The value of the "aaguid" does not match with the certificate');
}
private function processWithECDAA(): bool
{
throw new RuntimeException('ECDAA not supported');
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use JsonSerializable;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use function Safe\base64_decode;
/**
* @see https://www.w3.org/TR/webauthn/#sec-attested-credential-data
*/
class AttestedCredentialData implements JsonSerializable
{
/**
* @var UuidInterface
*/
private $aaguid;
/**
* @var string
*/
private $credentialId;
/**
* @var string|null
*/
private $credentialPublicKey;
public function __construct(UuidInterface $aaguid, string $credentialId, ?string $credentialPublicKey)
{
$this->aaguid = $aaguid;
$this->credentialId = $credentialId;
$this->credentialPublicKey = $credentialPublicKey;
}
public function getAaguid(): UuidInterface
{
return $this->aaguid;
}
public function setAaguid(UuidInterface $aaguid): void
{
$this->aaguid = $aaguid;
}
public function getCredentialId(): string
{
return $this->credentialId;
}
public function getCredentialPublicKey(): ?string
{
return $this->credentialPublicKey;
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
Assertion::keyExists($json, 'aaguid', 'Invalid input. "aaguid" is missing.');
Assertion::keyExists($json, 'credentialId', 'Invalid input. "credentialId" is missing.');
switch (true) {
case 36 === mb_strlen($json['aaguid'], '8bit'):
$uuid = Uuid::fromString($json['aaguid']);
break;
default: // Kept for compatibility with old format
$decoded = base64_decode($json['aaguid'], true);
$uuid = Uuid::fromBytes($decoded);
}
$credentialId = base64_decode($json['credentialId'], true);
$credentialPublicKey = null;
if (isset($json['credentialPublicKey'])) {
$credentialPublicKey = base64_decode($json['credentialPublicKey'], true);
}
return new self(
$uuid,
$credentialId,
$credentialPublicKey
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$result = [
'aaguid' => $this->aaguid->toString(),
'credentialId' => base64_encode($this->credentialId),
];
if (null !== $this->credentialPublicKey) {
$result['credentialPublicKey'] = base64_encode($this->credentialPublicKey);
}
return $result;
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AuthenticationExtensions;
use JsonSerializable;
class AuthenticationExtension implements JsonSerializable
{
/**
* @var string
*/
private $name;
/**
* @var mixed
*/
private $value;
/**
* @param mixed $value
*/
public function __construct(string $name, $value)
{
$this->name = $name;
$this->value = $value;
}
public function name(): string
{
return $this->name;
}
/**
* @return mixed
*/
public function value()
{
return $this->value;
}
/**
* @return mixed
*/
public function jsonSerialize()
{
return $this->value;
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AuthenticationExtensions;
use function array_key_exists;
use ArrayIterator;
use Assert\Assertion;
use function count;
use Countable;
use Iterator;
use IteratorAggregate;
use JsonSerializable;
use function Safe\sprintf;
class AuthenticationExtensionsClientInputs implements JsonSerializable, Countable, IteratorAggregate
{
/**
* @var AuthenticationExtension[]
*/
private $extensions = [];
public function add(AuthenticationExtension $extension): void
{
$this->extensions[$extension->name()] = $extension;
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
$object = new self();
foreach ($json as $k => $v) {
$object->add(new AuthenticationExtension($k, $v));
}
return $object;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->extensions);
}
/**
* @return mixed
*/
public function get(string $key)
{
Assertion::true($this->has($key), sprintf('The extension with key "%s" is not available', $key));
return $this->extensions[$key];
}
/**
* @return AuthenticationExtension[]
*/
public function jsonSerialize(): array
{
return array_map(static function (AuthenticationExtension $object) {
return $object->jsonSerialize();
}, $this->extensions);
}
/**
* @return Iterator<string, AuthenticationExtension>
*/
public function getIterator(): Iterator
{
return new ArrayIterator($this->extensions);
}
public function count(int $mode = COUNT_NORMAL): int
{
return count($this->extensions, $mode);
}
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AuthenticationExtensions;
use function array_key_exists;
use ArrayIterator;
use Assert\Assertion;
use function count;
use Countable;
use Iterator;
use IteratorAggregate;
use JsonSerializable;
use function Safe\json_decode;
use function Safe\sprintf;
class AuthenticationExtensionsClientOutputs implements JsonSerializable, Countable, IteratorAggregate
{
/**
* @var AuthenticationExtension[]
*/
private $extensions = [];
public function add(AuthenticationExtension $extension): void
{
$this->extensions[$extension->name()] = $extension;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true);
Assertion::isArray($data, 'Invalid data');
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
$object = new self();
foreach ($json as $k => $v) {
$object->add(new AuthenticationExtension($k, $v));
}
return $object;
}
public function has(string $key): bool
{
return array_key_exists($key, $this->extensions);
}
/**
* @return mixed
*/
public function get(string $key)
{
Assertion::true($this->has($key), sprintf('The extension with key "%s" is not available', $key));
return $this->extensions[$key];
}
/**
* @return AuthenticationExtension[]
*/
public function jsonSerialize(): array
{
return array_map(static function (AuthenticationExtension $object) {
return $object->jsonSerialize();
}, $this->extensions);
}
/**
* @return Iterator<string, AuthenticationExtension>
*/
public function getIterator(): Iterator
{
return new ArrayIterator($this->extensions);
}
public function count(int $mode = COUNT_NORMAL): int
{
return count($this->extensions, $mode);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AuthenticationExtensions;
use Assert\Assertion;
use CBOR\CBORObject;
use CBOR\MapObject;
abstract class AuthenticationExtensionsClientOutputsLoader
{
public static function load(CBORObject $object): AuthenticationExtensionsClientOutputs
{
Assertion::isInstanceOf($object, MapObject::class, 'Invalid extension object');
$data = $object->getNormalizedData();
$extensions = new AuthenticationExtensionsClientOutputs();
foreach ($data as $key => $value) {
Assertion::string($key, 'Invalid extension key');
$extensions->add(new AuthenticationExtension($key, $value));
}
return $extensions;
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AuthenticationExtensions;
interface ExtensionOutputChecker
{
/**
* @throws ExtensionOutputError
*/
public function check(AuthenticationExtensionsClientInputs $inputs, AuthenticationExtensionsClientOutputs $outputs): void;
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AuthenticationExtensions;
class ExtensionOutputCheckerHandler
{
/**
* @var ExtensionOutputChecker[]
*/
private $checkers = [];
public function add(ExtensionOutputChecker $checker): void
{
$this->checkers[] = $checker;
}
/**
* @throws ExtensionOutputError
*/
public function check(AuthenticationExtensionsClientInputs $inputs, AuthenticationExtensionsClientOutputs $outputs): void
{
foreach ($this->checkers as $checker) {
$checker->check($inputs, $outputs);
}
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\AuthenticationExtensions;
use Exception;
use Throwable;
class ExtensionOutputError extends Exception
{
/**
* @var AuthenticationExtension
*/
private $authenticationExtension;
public function __construct(AuthenticationExtension $authenticationExtension, string $message = '', int $code = 0, Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->authenticationExtension = $authenticationExtension;
}
public function getAuthenticationExtension(): AuthenticationExtension
{
return $this->authenticationExtension;
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use function Safe\base64_decode;
/**
* @see https://www.w3.org/TR/webauthn/#authenticatorassertionresponse
*/
class AuthenticatorAssertionResponse extends AuthenticatorResponse
{
/**
* @var AuthenticatorData
*/
private $authenticatorData;
/**
* @var string
*/
private $signature;
/**
* @var string|null
*/
private $userHandle;
public function __construct(CollectedClientData $clientDataJSON, AuthenticatorData $authenticatorData, string $signature, ?string $userHandle)
{
parent::__construct($clientDataJSON);
$this->authenticatorData = $authenticatorData;
$this->signature = $signature;
$this->userHandle = $userHandle;
}
public function getAuthenticatorData(): AuthenticatorData
{
return $this->authenticatorData;
}
public function getSignature(): string
{
return $this->signature;
}
public function getUserHandle(): ?string
{
if (null === $this->userHandle || '' === $this->userHandle) {
return $this->userHandle;
}
return base64_decode($this->userHandle, true);
}
}

View file

@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use CBOR\Decoder;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\Signature;
use Cose\Key\Key;
use function count;
use function in_array;
use function is_string;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use function Safe\parse_url;
use Throwable;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputs;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\Counter\CounterChecker;
use Webauthn\Counter\ThrowExceptionIfInvalid;
use Webauthn\TokenBinding\TokenBindingHandler;
use Webauthn\Util\CoseSignatureFixer;
class AuthenticatorAssertionResponseValidator
{
/**
* @var PublicKeyCredentialSourceRepository
*/
private $publicKeyCredentialSourceRepository;
/**
* @var Decoder
*/
private $decoder;
/**
* @var TokenBindingHandler
*/
private $tokenBindingHandler;
/**
* @var ExtensionOutputCheckerHandler
*/
private $extensionOutputCheckerHandler;
/**
* @var Manager|null
*/
private $algorithmManager;
/**
* @var CounterChecker
*/
private $counterChecker;
/**
* @var LoggerInterface|null
*/
private $logger;
public function __construct(PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository, TokenBindingHandler $tokenBindingHandler, ExtensionOutputCheckerHandler $extensionOutputCheckerHandler, Manager $algorithmManager, ?CounterChecker $counterChecker = null, ?LoggerInterface $logger = null)
{
if (null !== $logger) {
@trigger_error('The argument "logger" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setLogger".', E_USER_DEPRECATED);
}
if (null !== $counterChecker) {
@trigger_error('The argument "counterChecker" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setCounterChecker".', E_USER_DEPRECATED);
}
$this->publicKeyCredentialSourceRepository = $publicKeyCredentialSourceRepository;
$this->decoder = new Decoder(new TagObjectManager(), new OtherObjectManager());
$this->tokenBindingHandler = $tokenBindingHandler;
$this->extensionOutputCheckerHandler = $extensionOutputCheckerHandler;
$this->algorithmManager = $algorithmManager;
$this->counterChecker = $counterChecker ?? new ThrowExceptionIfInvalid();
$this->logger = $logger ?? new NullLogger();
}
/**
* @see https://www.w3.org/TR/webauthn/#verifying-assertion
*/
public function check(string $credentialId, AuthenticatorAssertionResponse $authenticatorAssertionResponse, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, ServerRequestInterface $request, ?string $userHandle, array $securedRelyingPartyId = []): PublicKeyCredentialSource
{
try {
$this->logger->info('Checking the authenticator assertion response', [
'credentialId' => $credentialId,
'authenticatorAssertionResponse' => $authenticatorAssertionResponse,
'publicKeyCredentialRequestOptions' => $publicKeyCredentialRequestOptions,
'host' => $request->getUri()->getHost(),
'userHandle' => $userHandle,
]);
/** @see 7.2.1 */
if (0 !== count($publicKeyCredentialRequestOptions->getAllowCredentials())) {
Assertion::true($this->isCredentialIdAllowed($credentialId, $publicKeyCredentialRequestOptions->getAllowCredentials()), 'The credential ID is not allowed.');
}
/** @see 7.2.2 */
$publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($credentialId);
Assertion::notNull($publicKeyCredentialSource, 'The credential ID is invalid.');
/** @see 7.2.3 */
$attestedCredentialData = $publicKeyCredentialSource->getAttestedCredentialData();
$credentialUserHandle = $publicKeyCredentialSource->getUserHandle();
$responseUserHandle = $authenticatorAssertionResponse->getUserHandle();
/** @see 7.2.2 User Handle*/
if (null !== $userHandle) { //If the user was identified before the authentication ceremony was initiated,
Assertion::eq($credentialUserHandle, $userHandle, 'Invalid user handle');
if (null !== $responseUserHandle && '' !== $responseUserHandle) {
Assertion::eq($credentialUserHandle, $responseUserHandle, 'Invalid user handle');
}
} else {
Assertion::notEmpty($responseUserHandle, 'User handle is mandatory');
Assertion::eq($credentialUserHandle, $responseUserHandle, 'Invalid user handle');
}
$credentialPublicKey = $attestedCredentialData->getCredentialPublicKey();
$isU2F = U2FPublicKey::isU2FKey($credentialPublicKey);
if ($isU2F) {
$credentialPublicKey = U2FPublicKey::createCOSEKey($credentialPublicKey);
}
Assertion::notNull($credentialPublicKey, 'No public key available.');
$stream = new StringStream($credentialPublicKey);
$credentialPublicKeyStream = $this->decoder->decode($stream);
Assertion::true($stream->isEOF(), 'Invalid key. Presence of extra bytes.');
$stream->close();
/** @see 7.2.4 */
/** @see 7.2.5 */
//Nothing to do. Use of objects directly
/** @see 7.2.6 */
$C = $authenticatorAssertionResponse->getClientDataJSON();
/** @see 7.2.7 */
Assertion::eq('webauthn.get', $C->getType(), 'The client data type is not "webauthn.get".');
/** @see 7.2.8 */
Assertion::true(hash_equals($publicKeyCredentialRequestOptions->getChallenge(), $C->getChallenge()), 'Invalid challenge.');
/** @see 7.2.9 */
$rpId = $publicKeyCredentialRequestOptions->getRpId() ?? $request->getUri()->getHost();
$facetId = $this->getFacetId($rpId, $publicKeyCredentialRequestOptions->getExtensions(), $authenticatorAssertionResponse->getAuthenticatorData()->getExtensions());
$parsedRelyingPartyId = parse_url($C->getOrigin());
Assertion::isArray($parsedRelyingPartyId, 'Invalid origin');
if (!in_array($facetId, $securedRelyingPartyId, true)) {
$scheme = $parsedRelyingPartyId['scheme'] ?? '';
Assertion::eq('https', $scheme, 'Invalid scheme. HTTPS required.');
}
$clientDataRpId = $parsedRelyingPartyId['host'] ?? '';
Assertion::notEmpty($clientDataRpId, 'Invalid origin rpId.');
$rpIdLength = mb_strlen($facetId);
Assertion::eq(mb_substr('.'.$clientDataRpId, -($rpIdLength + 1)), '.'.$facetId, 'rpId mismatch.');
/** @see 7.2.10 */
if (null !== $C->getTokenBinding()) {
$this->tokenBindingHandler->check($C->getTokenBinding(), $request);
}
$expectedRpIdHash = $isU2F ? $C->getOrigin() : $facetId;
// u2f response has full origin in rpIdHash
/** @see 7.2.11 */
$rpIdHash = hash('sha256', $expectedRpIdHash, true);
Assertion::true(hash_equals($rpIdHash, $authenticatorAssertionResponse->getAuthenticatorData()->getRpIdHash()), 'rpId hash mismatch.');
/** @see 7.2.12 */
Assertion::true($authenticatorAssertionResponse->getAuthenticatorData()->isUserPresent(), 'User was not present');
/** @see 7.2.13 */
if (AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED === $publicKeyCredentialRequestOptions->getUserVerification()) {
Assertion::true($authenticatorAssertionResponse->getAuthenticatorData()->isUserVerified(), 'User authentication required.');
}
/** @see 7.2.14 */
$extensionsClientOutputs = $authenticatorAssertionResponse->getAuthenticatorData()->getExtensions();
if (null !== $extensionsClientOutputs) {
$this->extensionOutputCheckerHandler->check(
$publicKeyCredentialRequestOptions->getExtensions(),
$extensionsClientOutputs
);
}
/** @see 7.2.15 */
$getClientDataJSONHash = hash('sha256', $authenticatorAssertionResponse->getClientDataJSON()->getRawData(), true);
/** @see 7.2.16 */
$dataToVerify = $authenticatorAssertionResponse->getAuthenticatorData()->getAuthData().$getClientDataJSONHash;
$signature = $authenticatorAssertionResponse->getSignature();
$coseKey = new Key($credentialPublicKeyStream->getNormalizedData());
$algorithm = $this->algorithmManager->get($coseKey->alg());
Assertion::isInstanceOf($algorithm, Signature::class, 'Invalid algorithm identifier. Should refer to a signature algorithm');
$signature = CoseSignatureFixer::fix($signature, $algorithm);
Assertion::true($algorithm->verify($dataToVerify, $coseKey, $signature), 'Invalid signature.');
/** @see 7.2.17 */
$storedCounter = $publicKeyCredentialSource->getCounter();
$responseCounter = $authenticatorAssertionResponse->getAuthenticatorData()->getSignCount();
if (0 !== $responseCounter || 0 !== $storedCounter) {
$this->counterChecker->check($publicKeyCredentialSource, $responseCounter);
}
$publicKeyCredentialSource->setCounter($responseCounter);
$this->publicKeyCredentialSourceRepository->saveCredentialSource($publicKeyCredentialSource);
/** @see 7.2.18 */
//All good. We can continue.
$this->logger->info('The assertion is valid');
$this->logger->debug('Public Key Credential Source', ['publicKeyCredentialSource' => $publicKeyCredentialSource]);
return $publicKeyCredentialSource;
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
throw $throwable;
}
}
public function setLogger(LoggerInterface $logger): self
{
$this->logger = $logger;
return $this;
}
public function setCounterChecker(CounterChecker $counterChecker): self
{
$this->counterChecker = $counterChecker;
return $this;
}
/**
* @param array<PublicKeyCredentialDescriptor> $allowedCredentials
*/
private function isCredentialIdAllowed(string $credentialId, array $allowedCredentials): bool
{
foreach ($allowedCredentials as $allowedCredential) {
if (hash_equals($allowedCredential->getId(), $credentialId)) {
return true;
}
}
return false;
}
private function getFacetId(string $rpId, AuthenticationExtensionsClientInputs $authenticationExtensionsClientInputs, ?AuthenticationExtensionsClientOutputs $authenticationExtensionsClientOutputs): string
{
if (null === $authenticationExtensionsClientOutputs || !$authenticationExtensionsClientInputs->has('appid') || !$authenticationExtensionsClientOutputs->has('appid')) {
return $rpId;
}
$appId = $authenticationExtensionsClientInputs->get('appid')->value();
$wasUsed = $authenticationExtensionsClientOutputs->get('appid')->value();
if (!is_string($appId) || true !== $wasUsed) {
return $rpId;
}
return $appId;
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Webauthn\AttestationStatement\AttestationObject;
/**
* @see https://www.w3.org/TR/webauthn/#authenticatorattestationresponse
*/
class AuthenticatorAttestationResponse extends AuthenticatorResponse
{
/**
* @var AttestationObject
*/
private $attestationObject;
public function __construct(CollectedClientData $clientDataJSON, AttestationObject $attestationObject)
{
parent::__construct($clientDataJSON);
$this->attestationObject = $attestationObject;
}
public function getAttestationObject(): AttestationObject
{
return $this->attestationObject;
}
}

View file

@ -0,0 +1,384 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use function count;
use function in_array;
use InvalidArgumentException;
use function is_string;
use LogicException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Ramsey\Uuid\Uuid;
use function Safe\parse_url;
use function Safe\sprintf;
use Throwable;
use Webauthn\AttestationStatement\AttestationObject;
use Webauthn\AttestationStatement\AttestationStatement;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputs;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\CertificateChainChecker\CertificateChainChecker;
use Webauthn\MetadataService\MetadataStatement;
use Webauthn\MetadataService\MetadataStatementRepository;
use Webauthn\MetadataService\StatusReport;
use Webauthn\TokenBinding\TokenBindingHandler;
use Webauthn\TrustPath\CertificateTrustPath;
use Webauthn\TrustPath\EmptyTrustPath;
class AuthenticatorAttestationResponseValidator
{
/**
* @var AttestationStatementSupportManager
*/
private $attestationStatementSupportManager;
/**
* @var PublicKeyCredentialSourceRepository
*/
private $publicKeyCredentialSource;
/**
* @var TokenBindingHandler
*/
private $tokenBindingHandler;
/**
* @var ExtensionOutputCheckerHandler
*/
private $extensionOutputCheckerHandler;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var MetadataStatementRepository|null
*/
private $metadataStatementRepository;
/**
* @var CertificateChainChecker|null
*/
private $certificateChainChecker;
public function __construct(AttestationStatementSupportManager $attestationStatementSupportManager, PublicKeyCredentialSourceRepository $publicKeyCredentialSource, TokenBindingHandler $tokenBindingHandler, ExtensionOutputCheckerHandler $extensionOutputCheckerHandler, ?MetadataStatementRepository $metadataStatementRepository = null, ?LoggerInterface $logger = null)
{
if (null !== $logger) {
@trigger_error('The argument "logger" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setLogger".', E_USER_DEPRECATED);
}
if (null !== $metadataStatementRepository) {
@trigger_error('The argument "metadataStatementRepository" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setMetadataStatementRepository".', E_USER_DEPRECATED);
}
$this->attestationStatementSupportManager = $attestationStatementSupportManager;
$this->publicKeyCredentialSource = $publicKeyCredentialSource;
$this->tokenBindingHandler = $tokenBindingHandler;
$this->extensionOutputCheckerHandler = $extensionOutputCheckerHandler;
$this->metadataStatementRepository = $metadataStatementRepository;
$this->logger = $logger ?? new NullLogger();
}
public function setLogger(LoggerInterface $logger): self
{
$this->logger = $logger;
return $this;
}
public function setCertificateChainChecker(CertificateChainChecker $certificateChainChecker): self
{
$this->certificateChainChecker = $certificateChainChecker;
return $this;
}
public function setMetadataStatementRepository(MetadataStatementRepository $metadataStatementRepository): self
{
$this->metadataStatementRepository = $metadataStatementRepository;
return $this;
}
/**
* @see https://www.w3.org/TR/webauthn/#registering-a-new-credential
*/
public function check(AuthenticatorAttestationResponse $authenticatorAttestationResponse, PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, ServerRequestInterface $request, array $securedRelyingPartyId = []): PublicKeyCredentialSource
{
try {
$this->logger->info('Checking the authenticator attestation response', [
'authenticatorAttestationResponse' => $authenticatorAttestationResponse,
'publicKeyCredentialCreationOptions' => $publicKeyCredentialCreationOptions,
'host' => $request->getUri()->getHost(),
]);
/** @see 7.1.1 */
//Nothing to do
/** @see 7.1.2 */
$C = $authenticatorAttestationResponse->getClientDataJSON();
/** @see 7.1.3 */
Assertion::eq('webauthn.create', $C->getType(), 'The client data type is not "webauthn.create".');
/** @see 7.1.4 */
Assertion::true(hash_equals($publicKeyCredentialCreationOptions->getChallenge(), $C->getChallenge()), 'Invalid challenge.');
/** @see 7.1.5 */
$rpId = $publicKeyCredentialCreationOptions->getRp()->getId() ?? $request->getUri()->getHost();
$facetId = $this->getFacetId($rpId, $publicKeyCredentialCreationOptions->getExtensions(), $authenticatorAttestationResponse->getAttestationObject()->getAuthData()->getExtensions());
$parsedRelyingPartyId = parse_url($C->getOrigin());
Assertion::isArray($parsedRelyingPartyId, sprintf('The origin URI "%s" is not valid', $C->getOrigin()));
Assertion::keyExists($parsedRelyingPartyId, 'scheme', 'Invalid origin rpId.');
$clientDataRpId = $parsedRelyingPartyId['host'] ?? '';
Assertion::notEmpty($clientDataRpId, 'Invalid origin rpId.');
$rpIdLength = mb_strlen($facetId);
Assertion::eq(mb_substr('.'.$clientDataRpId, -($rpIdLength + 1)), '.'.$facetId, 'rpId mismatch.');
if (!in_array($facetId, $securedRelyingPartyId, true)) {
$scheme = $parsedRelyingPartyId['scheme'] ?? '';
Assertion::eq('https', $scheme, 'Invalid scheme. HTTPS required.');
}
/** @see 7.1.6 */
if (null !== $C->getTokenBinding()) {
$this->tokenBindingHandler->check($C->getTokenBinding(), $request);
}
/** @see 7.1.7 */
$clientDataJSONHash = hash('sha256', $authenticatorAttestationResponse->getClientDataJSON()->getRawData(), true);
/** @see 7.1.8 */
$attestationObject = $authenticatorAttestationResponse->getAttestationObject();
/** @see 7.1.9 */
$rpIdHash = hash('sha256', $facetId, true);
Assertion::true(hash_equals($rpIdHash, $attestationObject->getAuthData()->getRpIdHash()), 'rpId hash mismatch.');
/** @see 7.1.10 */
Assertion::true($attestationObject->getAuthData()->isUserPresent(), 'User was not present');
/** @see 7.1.11 */
if (AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED === $publicKeyCredentialCreationOptions->getAuthenticatorSelection()->getUserVerification()) {
Assertion::true($attestationObject->getAuthData()->isUserVerified(), 'User authentication required.');
}
/** @see 7.1.12 */
$extensionsClientOutputs = $attestationObject->getAuthData()->getExtensions();
if (null !== $extensionsClientOutputs) {
$this->extensionOutputCheckerHandler->check(
$publicKeyCredentialCreationOptions->getExtensions(),
$extensionsClientOutputs
);
}
/** @see 7.1.13 */
$this->checkMetadataStatement($publicKeyCredentialCreationOptions, $attestationObject);
$fmt = $attestationObject->getAttStmt()->getFmt();
Assertion::true($this->attestationStatementSupportManager->has($fmt), 'Unsupported attestation statement format.');
/** @see 7.1.14 */
$attestationStatementSupport = $this->attestationStatementSupportManager->get($fmt);
Assertion::true($attestationStatementSupport->isValid($clientDataJSONHash, $attestationObject->getAttStmt(), $attestationObject->getAuthData()), 'Invalid attestation statement.');
/** @see 7.1.15 */
/** @see 7.1.16 */
/** @see 7.1.17 */
Assertion::true($attestationObject->getAuthData()->hasAttestedCredentialData(), 'There is no attested credential data.');
$attestedCredentialData = $attestationObject->getAuthData()->getAttestedCredentialData();
Assertion::notNull($attestedCredentialData, 'There is no attested credential data.');
$credentialId = $attestedCredentialData->getCredentialId();
Assertion::null($this->publicKeyCredentialSource->findOneByCredentialId($credentialId), 'The credential ID already exists.');
/** @see 7.1.18 */
/** @see 7.1.19 */
$publicKeyCredentialSource = $this->createPublicKeyCredentialSource(
$credentialId,
$attestedCredentialData,
$attestationObject,
$publicKeyCredentialCreationOptions->getUser()->getId()
);
$this->logger->info('The attestation is valid');
$this->logger->debug('Public Key Credential Source', ['publicKeyCredentialSource' => $publicKeyCredentialSource]);
return $publicKeyCredentialSource;
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
throw $throwable;
}
}
private function checkCertificateChain(AttestationStatement $attestationStatement, ?MetadataStatement $metadataStatement): void
{
$trustPath = $attestationStatement->getTrustPath();
if (!$trustPath instanceof CertificateTrustPath) {
return;
}
$authenticatorCertificates = $trustPath->getCertificates();
if (null === $metadataStatement) {
// @phpstan-ignore-next-line
null === $this->certificateChainChecker ? CertificateToolbox::checkChain($authenticatorCertificates) : $this->certificateChainChecker->check($authenticatorCertificates, [], null);
return;
}
$metadataStatementCertificates = $metadataStatement->getAttestationRootCertificates();
$rootStatementCertificates = $metadataStatement->getRootCertificates();
foreach ($metadataStatementCertificates as $key => $metadataStatementCertificate) {
$metadataStatementCertificates[$key] = CertificateToolbox::fixPEMStructure($metadataStatementCertificate);
}
$trustedCertificates = array_merge(
$metadataStatementCertificates,
$rootStatementCertificates
);
// @phpstan-ignore-next-line
null === $this->certificateChainChecker ? CertificateToolbox::checkChain($authenticatorCertificates, $trustedCertificates) : $this->certificateChainChecker->check($authenticatorCertificates, $trustedCertificates);
}
private function checkMetadataStatement(PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, AttestationObject $attestationObject): void
{
$attestationStatement = $attestationObject->getAttStmt();
$attestedCredentialData = $attestationObject->getAuthData()->getAttestedCredentialData();
Assertion::notNull($attestedCredentialData, 'No attested credential data found');
$aaguid = $attestedCredentialData->getAaguid()->toString();
if (PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE === $publicKeyCredentialCreationOptions->getAttestation()) {
$this->logger->debug('No attestation is asked.');
//No attestation is asked. We shall ensure that the data is anonymous.
if (
'00000000-0000-0000-0000-000000000000' === $aaguid
&& (AttestationStatement::TYPE_NONE === $attestationStatement->getType() || AttestationStatement::TYPE_SELF === $attestationStatement->getType())) {
$this->logger->debug('The Attestation Statement is anonymous.');
$this->checkCertificateChain($attestationStatement, null);
return;
}
$this->logger->debug('Anonymization required. AAGUID and Attestation Statement changed.', [
'aaguid' => $aaguid,
'AttestationStatement' => $attestationStatement,
]);
$attestedCredentialData->setAaguid(
Uuid::fromString('00000000-0000-0000-0000-000000000000')
);
$attestationObject->setAttStmt(AttestationStatement::createNone('none', [], new EmptyTrustPath()));
return;
}
if (AttestationStatement::TYPE_NONE === $attestationStatement->getType()) {
$this->logger->debug('No attestation returned.');
//No attestation is returned. We shall ensure that the AAGUID is a null one.
if ('00000000-0000-0000-0000-000000000000' !== $aaguid) {
$this->logger->debug('Anonymization required. AAGUID and Attestation Statement changed.', [
'aaguid' => $aaguid,
'AttestationStatement' => $attestationStatement,
]);
$attestedCredentialData->setAaguid(
Uuid::fromString('00000000-0000-0000-0000-000000000000')
);
return;
}
return;
}
//The MDS Repository is mandatory here
Assertion::notNull($this->metadataStatementRepository, 'The Metadata Statement Repository is mandatory when requesting attestation objects.');
$metadataStatement = $this->metadataStatementRepository->findOneByAAGUID($aaguid);
// We check the last status report
$this->checkStatusReport(null === $metadataStatement ? [] : $metadataStatement->getStatusReports());
// We check the certificate chain (if any)
$this->checkCertificateChain($attestationStatement, $metadataStatement);
// If no Attestation Statement has been returned or if null AAGUID (=00000000-0000-0000-0000-000000000000)
// => nothing to check
if ('00000000-0000-0000-0000-000000000000' === $aaguid || AttestationStatement::TYPE_NONE === $attestationStatement->getType()) {
return;
}
// At this point, the Metadata Statement is mandatory
Assertion::notNull($metadataStatement, sprintf('The Metadata Statement for the AAGUID "%s" is missing', $aaguid));
// Check Attestation Type is allowed
if (0 !== count($metadataStatement->getAttestationTypes())) {
$type = $this->getAttestationType($attestationStatement);
Assertion::inArray($type, $metadataStatement->getAttestationTypes(), 'Invalid attestation statement. The attestation type is not allowed for this authenticator');
}
}
/**
* @param StatusReport[] $statusReports
*/
private function checkStatusReport(array $statusReports): void
{
if (0 !== count($statusReports)) {
$lastStatusReport = end($statusReports);
if ($lastStatusReport->isCompromised()) {
throw new LogicException('The authenticator is compromised and cannot be used');
}
}
}
private function createPublicKeyCredentialSource(string $credentialId, AttestedCredentialData $attestedCredentialData, AttestationObject $attestationObject, string $userHandle): PublicKeyCredentialSource
{
return new PublicKeyCredentialSource(
$credentialId,
PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
[],
$attestationObject->getAttStmt()->getType(),
$attestationObject->getAttStmt()->getTrustPath(),
$attestedCredentialData->getAaguid(),
$attestedCredentialData->getCredentialPublicKey(),
$userHandle,
$attestationObject->getAuthData()->getSignCount()
);
}
private function getAttestationType(AttestationStatement $attestationStatement): int
{
switch ($attestationStatement->getType()) {
case AttestationStatement::TYPE_BASIC:
return MetadataStatement::ATTESTATION_BASIC_FULL;
case AttestationStatement::TYPE_SELF:
return MetadataStatement::ATTESTATION_BASIC_SURROGATE;
case AttestationStatement::TYPE_ATTCA:
return MetadataStatement::ATTESTATION_ATTCA;
case AttestationStatement::TYPE_ECDAA:
return MetadataStatement::ATTESTATION_ECDAA;
default:
throw new InvalidArgumentException('Invalid attestation type');
}
}
private function getFacetId(string $rpId, AuthenticationExtensionsClientInputs $authenticationExtensionsClientInputs, ?AuthenticationExtensionsClientOutputs $authenticationExtensionsClientOutputs): string
{
if (null === $authenticationExtensionsClientOutputs || !$authenticationExtensionsClientInputs->has('appid') || !$authenticationExtensionsClientOutputs->has('appid')) {
return $rpId;
}
$appId = $authenticationExtensionsClientInputs->get('appid')->value();
$wasUsed = $authenticationExtensionsClientOutputs->get('appid')->value();
if (!is_string($appId) || true !== $wasUsed) {
return $rpId;
}
return $appId;
}
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use function ord;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputs;
/**
* @see https://www.w3.org/TR/webauthn/#sec-authenticator-data
*/
class AuthenticatorData
{
private const FLAG_UP = 0b00000001;
private const FLAG_RFU1 = 0b00000010;
private const FLAG_UV = 0b00000100;
private const FLAG_RFU2 = 0b00111000;
private const FLAG_AT = 0b01000000;
private const FLAG_ED = 0b10000000;
/**
* @var string
*/
protected $authData;
/**
* @var string
*/
protected $rpIdHash;
/**
* @var string
*/
protected $flags;
/**
* @var int
*/
protected $signCount;
/**
* @var AttestedCredentialData|null
*/
protected $attestedCredentialData;
/**
* @var AuthenticationExtensionsClientOutputs|null
*/
protected $extensions;
public function __construct(string $authData, string $rpIdHash, string $flags, int $signCount, ?AttestedCredentialData $attestedCredentialData, ?AuthenticationExtensionsClientOutputs $extensions)
{
$this->rpIdHash = $rpIdHash;
$this->flags = $flags;
$this->signCount = $signCount;
$this->attestedCredentialData = $attestedCredentialData;
$this->extensions = $extensions;
$this->authData = $authData;
}
public function getAuthData(): string
{
return $this->authData;
}
public function getRpIdHash(): string
{
return $this->rpIdHash;
}
public function isUserPresent(): bool
{
return 0 !== (ord($this->flags) & self::FLAG_UP) ? true : false;
}
public function isUserVerified(): bool
{
return 0 !== (ord($this->flags) & self::FLAG_UV) ? true : false;
}
public function hasAttestedCredentialData(): bool
{
return 0 !== (ord($this->flags) & self::FLAG_AT) ? true : false;
}
public function hasExtensions(): bool
{
return 0 !== (ord($this->flags) & self::FLAG_ED) ? true : false;
}
public function getReservedForFutureUse1(): int
{
return ord($this->flags) & self::FLAG_RFU1;
}
public function getReservedForFutureUse2(): int
{
return ord($this->flags) & self::FLAG_RFU2;
}
public function getSignCount(): int
{
return $this->signCount;
}
public function getAttestedCredentialData(): ?AttestedCredentialData
{
return $this->attestedCredentialData;
}
public function getExtensions(): ?AuthenticationExtensionsClientOutputs
{
return null !== $this->extensions && $this->hasExtensions() ? $this->extensions : null;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
/**
* @see https://www.w3.org/TR/webauthn/#authenticatorresponse
*/
abstract class AuthenticatorResponse
{
/**
* @var CollectedClientData
*/
private $clientDataJSON;
public function __construct(CollectedClientData $clientDataJSON)
{
$this->clientDataJSON = $clientDataJSON;
}
public function getClientDataJSON(): CollectedClientData
{
return $this->clientDataJSON;
}
}

View file

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use JsonSerializable;
use function Safe\json_decode;
class AuthenticatorSelectionCriteria implements JsonSerializable
{
public const AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE = null;
public const AUTHENTICATOR_ATTACHMENT_PLATFORM = 'platform';
public const AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM = 'cross-platform';
public const USER_VERIFICATION_REQUIREMENT_REQUIRED = 'required';
public const USER_VERIFICATION_REQUIREMENT_PREFERRED = 'preferred';
public const USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 'discouraged';
public const RESIDENT_KEY_REQUIREMENT_NONE = null;
public const RESIDENT_KEY_REQUIREMENT_REQUIRED = 'required';
public const RESIDENT_KEY_REQUIREMENT_PREFERRED = 'preferred';
public const RESIDENT_KEY_REQUIREMENT_DISCOURAGED = 'discouraged';
/**
* @var string|null
*/
private $authenticatorAttachment;
/**
* @var bool
*/
private $requireResidentKey;
/**
* @var string
*/
private $userVerification;
/**
* @var string|null
*/
private $residentKey;
public function __construct(?string $authenticatorAttachment = null, ?bool $requireResidentKey = null, ?string $userVerification = null, ?string $residentKey = null)
{
if (null !== $authenticatorAttachment) {
@trigger_error('The argument "authenticatorAttachment" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setAuthenticatorAttachment".', E_USER_DEPRECATED);
}
if (null !== $requireResidentKey) {
@trigger_error('The argument "requireResidentKey" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setRequireResidentKey".', E_USER_DEPRECATED);
}
if (null !== $userVerification) {
@trigger_error('The argument "userVerification" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setUserVerification".', E_USER_DEPRECATED);
}
if (null !== $residentKey) {
@trigger_error('The argument "residentKey" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setResidentKey".', E_USER_DEPRECATED);
}
$this->authenticatorAttachment = $authenticatorAttachment;
$this->requireResidentKey = $requireResidentKey ?? false;
$this->userVerification = $userVerification ?? self::USER_VERIFICATION_REQUIREMENT_PREFERRED;
$this->residentKey = $residentKey ?? self::RESIDENT_KEY_REQUIREMENT_NONE;
}
public static function create(): self
{
return new self();
}
public function setAuthenticatorAttachment(?string $authenticatorAttachment): self
{
$this->authenticatorAttachment = $authenticatorAttachment;
return $this;
}
public function setRequireResidentKey(bool $requireResidentKey): self
{
$this->requireResidentKey = $requireResidentKey;
return $this;
}
public function setUserVerification(string $userVerification): self
{
$this->userVerification = $userVerification;
return $this;
}
public function setResidentKey(?string $residentKey): self
{
$this->residentKey = $residentKey;
return $this;
}
public function getAuthenticatorAttachment(): ?string
{
return $this->authenticatorAttachment;
}
public function isRequireResidentKey(): bool
{
return $this->requireResidentKey;
}
public function getUserVerification(): string
{
return $this->userVerification;
}
public function getResidentKey(): ?string
{
return $this->residentKey;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true);
Assertion::isArray($data, 'Invalid data');
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
return self::create()
->setAuthenticatorAttachment($json['authenticatorAttachment'] ?? null)
->setRequireResidentKey($json['requireResidentKey'] ?? false)
->setUserVerification($json['userVerification'] ?? self::USER_VERIFICATION_REQUIREMENT_PREFERRED)
->setResidentKey($json['residentKey'] ?? self::RESIDENT_KEY_REQUIREMENT_NONE)
;
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'requireResidentKey' => $this->requireResidentKey,
'userVerification' => $this->userVerification,
];
if (null !== $this->authenticatorAttachment) {
$json['authenticatorAttachment'] = $this->authenticatorAttachment;
}
if (null !== $this->residentKey) {
$json['residentKey'] = $this->residentKey;
}
return $json;
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\CertificateChainChecker;
interface CertificateChainChecker
{
/**
* @param string[] $authenticatorCertificates
* @param string[] $trustedCertificates
*/
public function check(array $authenticatorCertificates, array $trustedCertificates): void;
}

View file

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\CertificateChainChecker;
use Assert\Assertion;
use function count;
use InvalidArgumentException;
use function is_int;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use RuntimeException;
use Safe\Exceptions\FilesystemException;
use function Safe\file_put_contents;
use function Safe\ksort;
use function Safe\mkdir;
use function Safe\rename;
use function Safe\sprintf;
use function Safe\tempnam;
use function Safe\unlink;
use Symfony\Component\Process\Process;
final class OpenSSLCertificateChainChecker implements CertificateChainChecker
{
/**
* @var ClientInterface
*/
private $client;
/**
* @var RequestFactoryInterface
*/
private $requestFactory;
/**
* @var string[]
*/
private $rootCertificates = [];
public function __construct(ClientInterface $client, RequestFactoryInterface $requestFactory)
{
$this->client = $client;
$this->requestFactory = $requestFactory;
}
public function addRootCertificate(string $certificate): self
{
$this->rootCertificates[] = $certificate;
return $this;
}
/**
* @param string[] $authenticatorCertificates
* @param string[] $trustedCertificates
*/
public function check(array $authenticatorCertificates, array $trustedCertificates): void
{
if (0 === count($trustedCertificates)) {
$this->checkCertificatesValidity($authenticatorCertificates, true);
return;
}
$this->checkCertificatesValidity($authenticatorCertificates, false);
$hasCrls = false;
$processArguments = ['-no-CAfile', '-no-CApath'];
$caDirname = $this->createTemporaryDirectory();
$processArguments[] = '--CApath';
$processArguments[] = $caDirname;
foreach ($trustedCertificates as $certificate) {
$this->saveToTemporaryFile($caDirname, $certificate, 'webauthn-trusted-', '.pem');
$crl = $this->getCrls($certificate);
if ('' !== $crl) {
$hasCrls = true;
$this->saveToTemporaryFile($caDirname, $crl, 'webauthn-trusted-crl-', '.crl');
}
}
$rehashProcess = new Process(['openssl', 'rehash', $caDirname]);
$rehashProcess->run();
while ($rehashProcess->isRunning()) {
//Just wait
}
if (!$rehashProcess->isSuccessful()) {
throw new InvalidArgumentException('Invalid certificate or certificate chain');
}
$filenames = [];
$leafCertificate = array_shift($authenticatorCertificates);
$leafFilename = $this->saveToTemporaryFile(sys_get_temp_dir(), $leafCertificate, 'webauthn-leaf-', '.pem');
$crl = $this->getCrls($leafCertificate);
if ('' !== $crl) {
$hasCrls = true;
$this->saveToTemporaryFile($caDirname, $crl, 'webauthn-leaf-crl-', '.pem');
}
$filenames[] = $leafFilename;
foreach ($authenticatorCertificates as $certificate) {
$untrustedFilename = $this->saveToTemporaryFile(sys_get_temp_dir(), $certificate, 'webauthn-untrusted-', '.pem');
$crl = $this->getCrls($certificate);
if ('' !== $crl) {
$hasCrls = true;
$this->saveToTemporaryFile($caDirname, $crl, 'webauthn-untrusted-crl-', '.pem');
}
$processArguments[] = '-untrusted';
$processArguments[] = $untrustedFilename;
$filenames[] = $untrustedFilename;
}
$processArguments[] = $leafFilename;
if ($hasCrls) {
array_unshift($processArguments, '-crl_check');
array_unshift($processArguments, '-crl_check_all');
//array_unshift($processArguments, '-crl_download');
array_unshift($processArguments, '-extended_crl');
}
array_unshift($processArguments, 'openssl', 'verify');
$process = new Process($processArguments);
$process->run();
while ($process->isRunning()) {
//Just wait
}
foreach ($filenames as $filename) {
try {
unlink($filename);
} catch (FilesystemException $e) {
continue;
}
}
$this->deleteDirectory($caDirname);
if (!$process->isSuccessful()) {
throw new InvalidArgumentException('Invalid certificate or certificate chain');
}
}
/**
* @param string[] $certificates
*/
private function checkCertificatesValidity(array $certificates, bool $allowRootCertificate): void
{
foreach ($certificates as $certificate) {
$parsed = openssl_x509_parse($certificate);
Assertion::isArray($parsed, 'Unable to read the certificate');
if (false === $allowRootCertificate) {
$this->checkRootCertificate($parsed);
}
Assertion::keyExists($parsed, 'validTo_time_t', 'The certificate has no validity period');
Assertion::keyExists($parsed, 'validFrom_time_t', 'The certificate has no validity period');
Assertion::lessOrEqualThan(time(), $parsed['validTo_time_t'], 'The certificate expired');
Assertion::greaterOrEqualThan(time(), $parsed['validFrom_time_t'], 'The certificate is not usable yet');
}
}
/**
* @param array<string, mixed> $parsed
*/
private function checkRootCertificate(array $parsed): void
{
Assertion::keyExists($parsed, 'subject', 'The certificate has no subject');
Assertion::keyExists($parsed, 'issuer', 'The certificate has no issuer');
$subject = $parsed['subject'];
$issuer = $parsed['issuer'];
ksort($subject);
ksort($issuer);
Assertion::notEq($subject, $issuer, 'Root certificates are not allowed');
}
private function createTemporaryDirectory(): string
{
$caDir = tempnam(sys_get_temp_dir(), 'webauthn-ca-');
if (file_exists($caDir)) {
unlink($caDir);
}
mkdir($caDir);
if (!is_dir($caDir)) {
throw new RuntimeException(sprintf('Directory "%s" was not created', $caDir));
}
return $caDir;
}
private function deleteDirectory(string $dirname): void
{
$rehashProcess = new Process(['rm', '-rf', $dirname]);
$rehashProcess->run();
while ($rehashProcess->isRunning()) {
//Just wait
}
}
private function saveToTemporaryFile(string $folder, string $certificate, string $prefix, string $suffix): string
{
$filename = tempnam($folder, $prefix);
rename($filename, $filename.$suffix);
file_put_contents($filename.$suffix, $certificate, FILE_APPEND);
return $filename.$suffix;
}
private function getCrls(string $certificate): string
{
$parsed = openssl_x509_parse($certificate);
if (false === $parsed || !isset($parsed['extensions']['crlDistributionPoints'])) {
return '';
}
$endpoint = $parsed['extensions']['crlDistributionPoints'];
$pos = mb_strpos($endpoint, 'URI:');
if (!is_int($pos)) {
return '';
}
$endpoint = trim(mb_substr($endpoint, $pos + 4));
$request = $this->requestFactory->createRequest('GET', $endpoint);
$response = $this->client->sendRequest($request);
if (200 !== $response->getStatusCode()) {
return '';
}
return $response->getBody()->getContents();
}
}

View file

@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use function count;
use function in_array;
use InvalidArgumentException;
use RuntimeException;
use Safe\Exceptions\FilesystemException;
use function Safe\file_put_contents;
use function Safe\ksort;
use function Safe\mkdir;
use function Safe\rename;
use function Safe\sprintf;
use function Safe\tempnam;
use function Safe\unlink;
use Symfony\Component\Process\Process;
class CertificateToolbox
{
/**
* @deprecated "This method is deprecated since v3.3 and will be removed en v4.0. Please use Webauthn\CertificateChainChecker\CertificateChainChecker instead"
*
* @param string[] $authenticatorCertificates
* @param string[] $trustedCertificates
*/
public static function checkChain(array $authenticatorCertificates, array $trustedCertificates = []): void
{
if (0 === count($trustedCertificates)) {
self::checkCertificatesValidity($authenticatorCertificates, true);
return;
}
self::checkCertificatesValidity($authenticatorCertificates, false);
$processArguments = ['-no-CAfile', '-no-CApath'];
$caDirname = self::createTemporaryDirectory();
$processArguments[] = '--CApath';
$processArguments[] = $caDirname;
foreach ($trustedCertificates as $certificate) {
self::prepareCertificate($caDirname, $certificate, 'webauthn-trusted-', '.pem');
}
$rehashProcess = new Process(['openssl', 'rehash', $caDirname]);
$rehashProcess->run();
while ($rehashProcess->isRunning()) {
//Just wait
}
if (!$rehashProcess->isSuccessful()) {
throw new InvalidArgumentException('Invalid certificate or certificate chain');
}
$filenames = [];
$leafCertificate = array_shift($authenticatorCertificates);
$leafFilename = self::prepareCertificate(sys_get_temp_dir(), $leafCertificate, 'webauthn-leaf-', '.pem');
$filenames[] = $leafFilename;
foreach ($authenticatorCertificates as $certificate) {
$untrustedFilename = self::prepareCertificate(sys_get_temp_dir(), $certificate, 'webauthn-untrusted-', '.pem');
$processArguments[] = '-untrusted';
$processArguments[] = $untrustedFilename;
$filenames[] = $untrustedFilename;
}
$processArguments[] = $leafFilename;
array_unshift($processArguments, 'openssl', 'verify');
$process = new Process($processArguments);
$process->run();
while ($process->isRunning()) {
//Just wait
}
foreach ($filenames as $filename) {
try {
unlink($filename);
} catch (FilesystemException $e) {
continue;
}
}
self::deleteDirectory($caDirname);
if (!$process->isSuccessful()) {
throw new InvalidArgumentException('Invalid certificate or certificate chain');
}
}
public static function fixPEMStructure(string $certificate, string $type = 'CERTIFICATE'): string
{
$pemCert = '-----BEGIN '.$type.'-----'.PHP_EOL;
$pemCert .= chunk_split($certificate, 64, PHP_EOL);
$pemCert .= '-----END '.$type.'-----'.PHP_EOL;
return $pemCert;
}
public static function convertDERToPEM(string $certificate, string $type = 'CERTIFICATE'): string
{
$derCertificate = self::unusedBytesFix($certificate);
return self::fixPEMStructure(base64_encode($derCertificate), $type);
}
/**
* @param string[] $certificates
*
* @return string[]
*/
public static function convertAllDERToPEM(array $certificates, string $type = 'CERTIFICATE'): array
{
$certs = [];
foreach ($certificates as $publicKey) {
$certs[] = self::convertDERToPEM($publicKey, $type);
}
return $certs;
}
private static function unusedBytesFix(string $certificate): string
{
$certificateHash = hash('sha256', $certificate);
if (in_array($certificateHash, self::getCertificateHashes(), true)) {
$certificate[mb_strlen($certificate, '8bit') - 257] = "\0";
}
return $certificate;
}
/**
* @param string[] $certificates
*/
private static function checkCertificatesValidity(array $certificates, bool $allowRootCertificate): void
{
foreach ($certificates as $certificate) {
$parsed = openssl_x509_parse($certificate);
Assertion::isArray($parsed, 'Unable to read the certificate');
if (false === $allowRootCertificate) {
self::checkRootCertificate($parsed);
}
Assertion::keyExists($parsed, 'validTo_time_t', 'The certificate has no validity period');
Assertion::keyExists($parsed, 'validFrom_time_t', 'The certificate has no validity period');
Assertion::lessOrEqualThan(time(), $parsed['validTo_time_t'], 'The certificate expired');
Assertion::greaterOrEqualThan(time(), $parsed['validFrom_time_t'], 'The certificate is not usable yet');
}
}
/**
* @param array<string, mixed> $parsed
*/
private static function checkRootCertificate(array $parsed): void
{
Assertion::keyExists($parsed, 'subject', 'The certificate has no subject');
Assertion::keyExists($parsed, 'issuer', 'The certificate has no issuer');
$subject = $parsed['subject'];
$issuer = $parsed['issuer'];
ksort($subject);
ksort($issuer);
Assertion::notEq($subject, $issuer, 'Root certificates are not allowed');
}
/**
* @return string[]
*/
private static function getCertificateHashes(): array
{
return [
'349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
'1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
'6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511',
];
}
private static function createTemporaryDirectory(): string
{
$caDir = tempnam(sys_get_temp_dir(), 'webauthn-ca-');
if (file_exists($caDir)) {
unlink($caDir);
}
mkdir($caDir);
if (!is_dir($caDir)) {
throw new RuntimeException(sprintf('Directory "%s" was not created', $caDir));
}
return $caDir;
}
private static function deleteDirectory(string $dirname): void
{
$rehashProcess = new Process(['rm', '-rf', $dirname]);
$rehashProcess->run();
while ($rehashProcess->isRunning()) {
//Just wait
}
}
private static function prepareCertificate(string $folder, string $certificate, string $prefix, string $suffix): string
{
$untrustedFilename = tempnam($folder, $prefix);
rename($untrustedFilename, $untrustedFilename.$suffix);
file_put_contents($untrustedFilename.$suffix, $certificate, FILE_APPEND);
file_put_contents($untrustedFilename.$suffix, PHP_EOL, FILE_APPEND);
return $untrustedFilename.$suffix;
}
}

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use function array_key_exists;
use Assert\Assertion;
use Base64Url\Base64Url;
use InvalidArgumentException;
use function Safe\json_decode;
use function Safe\sprintf;
use Webauthn\TokenBinding\TokenBinding;
class CollectedClientData
{
/**
* @var string
*/
private $rawData;
/**
* @var mixed[]
*/
private $data;
/**
* @var string
*/
private $type;
/**
* @var string
*/
private $challenge;
/**
* @var string
*/
private $origin;
/**
* @var mixed[]|null
*/
private $tokenBinding;
/**
* @param mixed[] $data
*/
public function __construct(string $rawData, array $data)
{
$this->type = $this->findData($data, 'type');
$this->challenge = $this->findData($data, 'challenge', true, true);
$this->origin = $this->findData($data, 'origin');
$this->tokenBinding = $this->findData($data, 'tokenBinding', false);
$this->rawData = $rawData;
$this->data = $data;
}
public static function createFormJson(string $data): self
{
$rawData = Base64Url::decode($data);
$json = json_decode($rawData, true);
Assertion::isArray($json, 'Invalid collected client data');
return new self($rawData, $json);
}
public function getType(): string
{
return $this->type;
}
public function getChallenge(): string
{
return $this->challenge;
}
public function getOrigin(): string
{
return $this->origin;
}
public function getTokenBinding(): ?TokenBinding
{
return null === $this->tokenBinding ? null : TokenBinding::createFormArray($this->tokenBinding);
}
public function getRawData(): string
{
return $this->rawData;
}
/**
* @return string[]
*/
public function all(): array
{
return array_keys($this->data);
}
public function has(string $key): bool
{
return array_key_exists($key, $this->data);
}
/**
* @return mixed
*/
public function get(string $key)
{
if (!$this->has($key)) {
throw new InvalidArgumentException(sprintf('The key "%s" is missing', $key));
}
return $this->data[$key];
}
/**
* @param mixed[] $json
*
* @return mixed|null
*/
private function findData(array $json, string $key, bool $isRequired = true, bool $isB64 = false)
{
if (!array_key_exists($key, $json)) {
if ($isRequired) {
throw new InvalidArgumentException(sprintf('The key "%s" is missing', $key));
}
return;
}
return $isB64 ? Base64Url::decode($json[$key]) : $json[$key];
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\Counter;
use Webauthn\PublicKeyCredentialSource;
interface CounterChecker
{
public function check(PublicKeyCredentialSource $publicKeyCredentialSource, int $currentCounter): void;
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\Counter;
use Assert\Assertion;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Throwable;
use Webauthn\PublicKeyCredentialSource;
final class ThrowExceptionIfInvalid implements CounterChecker
{
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(?LoggerInterface $logger = null)
{
$this->logger = $logger ?? new NullLogger();
}
public function check(PublicKeyCredentialSource $publicKeyCredentialSource, int $currentCounter): void
{
try {
Assertion::greaterThan($currentCounter, $publicKeyCredentialSource->getCounter(), 'Invalid counter.');
} catch (Throwable $throwable) {
$this->logger->error('The counter is invalid', [
'current' => $currentCounter,
'new' => $publicKeyCredentialSource->getCounter(),
]);
throw $throwable;
}
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
/**
* @see https://w3c.github.io/webappsec-credential-management/#credential
*/
abstract class Credential
{
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $type;
public function __construct(string $id, string $type)
{
$this->id = $id;
$this->type = $type;
}
public function getId(): string
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use function Safe\json_encode;
/**
* @see https://www.w3.org/TR/webauthn/#iface-pkcredential
*/
class PublicKeyCredential extends Credential
{
/**
* @var string
*/
protected $rawId;
/**
* @var AuthenticatorResponse
*/
protected $response;
public function __construct(string $id, string $type, string $rawId, AuthenticatorResponse $response)
{
parent::__construct($id, $type);
$this->rawId = $rawId;
$this->response = $response;
}
public function __toString()
{
return json_encode($this);
}
public function getRawId(): string
{
return $this->rawId;
}
public function getResponse(): AuthenticatorResponse
{
return $this->response;
}
/**
* @param string[] $transport
*/
public function getPublicKeyCredentialDescriptor(array $transport = []): PublicKeyCredentialDescriptor
{
return new PublicKeyCredentialDescriptor($this->getType(), $this->getRawId(), $transport);
}
}

View file

@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use Base64Url\Base64Url;
use function count;
use function Safe\json_decode;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
class PublicKeyCredentialCreationOptions extends PublicKeyCredentialOptions
{
public const ATTESTATION_CONVEYANCE_PREFERENCE_NONE = 'none';
public const ATTESTATION_CONVEYANCE_PREFERENCE_INDIRECT = 'indirect';
public const ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT = 'direct';
public const ATTESTATION_CONVEYANCE_PREFERENCE_ENTERPRISE = 'enterprise';
/**
* @var PublicKeyCredentialRpEntity
*/
private $rp;
/**
* @var PublicKeyCredentialUserEntity
*/
private $user;
/**
* @var PublicKeyCredentialParameters[]
*/
private $pubKeyCredParams = [];
/**
* @var PublicKeyCredentialDescriptor[]
*/
private $excludeCredentials = [];
/**
* @var AuthenticatorSelectionCriteria
*/
private $authenticatorSelection;
/**
* @var string
*/
private $attestation;
/**
* @param PublicKeyCredentialParameters[] $pubKeyCredParams
* @param PublicKeyCredentialDescriptor[] $excludeCredentials
*/
public function __construct(PublicKeyCredentialRpEntity $rp, PublicKeyCredentialUserEntity $user, string $challenge, array $pubKeyCredParams, ?int $timeout = null, array $excludeCredentials = [], ?AuthenticatorSelectionCriteria $authenticatorSelection = null, ?string $attestation = null, ?AuthenticationExtensionsClientInputs $extensions = null)
{
if (0 !== count($excludeCredentials)) {
@trigger_error('The argument "excludeCredentials" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "excludeCredentials" or "excludeCredential".', E_USER_DEPRECATED);
}
if (null !== $authenticatorSelection) {
@trigger_error('The argument "authenticatorSelection" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setAuthenticatorSelection".', E_USER_DEPRECATED);
}
if (null !== $attestation) {
@trigger_error('The argument "attestation" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setAttestation".', E_USER_DEPRECATED);
}
parent::__construct($challenge, $timeout, $extensions);
$this->rp = $rp;
$this->user = $user;
$this->pubKeyCredParams = $pubKeyCredParams;
$this->authenticatorSelection = $authenticatorSelection ?? new AuthenticatorSelectionCriteria();
$this->attestation = $attestation ?? self::ATTESTATION_CONVEYANCE_PREFERENCE_NONE;
$this->excludeCredentials($excludeCredentials)
;
}
/**
* @param PublicKeyCredentialParameters[] $pubKeyCredParams
*/
public static function create(PublicKeyCredentialRpEntity $rp, PublicKeyCredentialUserEntity $user, string $challenge, array $pubKeyCredParams): self
{
return new self($rp, $user, $challenge, $pubKeyCredParams);
}
public function addPubKeyCredParam(PublicKeyCredentialParameters $pubKeyCredParam): self
{
$this->pubKeyCredParams[] = $pubKeyCredParam;
return $this;
}
/**
* @param PublicKeyCredentialParameters[] $pubKeyCredParams
*/
public function addPubKeyCredParams(array $pubKeyCredParams): self
{
foreach ($pubKeyCredParams as $pubKeyCredParam) {
$this->addPubKeyCredParam($pubKeyCredParam);
}
return $this;
}
public function excludeCredential(PublicKeyCredentialDescriptor $excludeCredential): self
{
$this->excludeCredentials[] = $excludeCredential;
return $this;
}
/**
* @param PublicKeyCredentialDescriptor[] $excludeCredentials
*/
public function excludeCredentials(array $excludeCredentials): self
{
foreach ($excludeCredentials as $excludeCredential) {
$this->excludeCredential($excludeCredential);
}
return $this;
}
public function setAuthenticatorSelection(AuthenticatorSelectionCriteria $authenticatorSelection): self
{
$this->authenticatorSelection = $authenticatorSelection;
return $this;
}
public function setAttestation(string $attestation): self
{
Assertion::inArray($attestation, [
self::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
self::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT,
self::ATTESTATION_CONVEYANCE_PREFERENCE_INDIRECT,
self::ATTESTATION_CONVEYANCE_PREFERENCE_ENTERPRISE,
], 'Invalid attestation conveyance mode');
$this->attestation = $attestation;
return $this;
}
public function getRp(): PublicKeyCredentialRpEntity
{
return $this->rp;
}
public function getUser(): PublicKeyCredentialUserEntity
{
return $this->user;
}
/**
* @return PublicKeyCredentialParameters[]
*/
public function getPubKeyCredParams(): array
{
return $this->pubKeyCredParams;
}
/**
* @return PublicKeyCredentialDescriptor[]
*/
public function getExcludeCredentials(): array
{
return $this->excludeCredentials;
}
public function getAuthenticatorSelection(): AuthenticatorSelectionCriteria
{
return $this->authenticatorSelection;
}
public function getAttestation(): string
{
return $this->attestation;
}
public static function createFromString(string $data): PublicKeyCredentialOptions
{
$data = json_decode($data, true);
Assertion::isArray($data, 'Invalid data');
return self::createFromArray($data);
}
public static function createFromArray(array $json): PublicKeyCredentialOptions
{
Assertion::keyExists($json, 'rp', 'Invalid input. "rp" is missing.');
Assertion::keyExists($json, 'pubKeyCredParams', 'Invalid input. "pubKeyCredParams" is missing.');
Assertion::isArray($json['pubKeyCredParams'], 'Invalid input. "pubKeyCredParams" is not an array.');
Assertion::keyExists($json, 'challenge', 'Invalid input. "challenge" is missing.');
Assertion::keyExists($json, 'attestation', 'Invalid input. "attestation" is missing.');
Assertion::keyExists($json, 'user', 'Invalid input. "user" is missing.');
Assertion::keyExists($json, 'authenticatorSelection', 'Invalid input. "authenticatorSelection" is missing.');
$pubKeyCredParams = [];
foreach ($json['pubKeyCredParams'] as $pubKeyCredParam) {
$pubKeyCredParams[] = PublicKeyCredentialParameters::createFromArray($pubKeyCredParam);
}
$excludeCredentials = [];
if (isset($json['excludeCredentials'])) {
foreach ($json['excludeCredentials'] as $excludeCredential) {
$excludeCredentials[] = PublicKeyCredentialDescriptor::createFromArray($excludeCredential);
}
}
return self::create(
PublicKeyCredentialRpEntity::createFromArray($json['rp']),
PublicKeyCredentialUserEntity::createFromArray($json['user']),
Base64Url::decode($json['challenge']),
$pubKeyCredParams
)
->excludeCredentials($excludeCredentials)
->setAuthenticatorSelection(AuthenticatorSelectionCriteria::createFromArray($json['authenticatorSelection']))
->setAttestation($json['attestation'])
->setTimeout($json['timeout'] ?? null)
->setExtensions(isset($json['extensions']) ? AuthenticationExtensionsClientInputs::createFromArray($json['extensions']) : new AuthenticationExtensionsClientInputs())
;
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'rp' => $this->rp->jsonSerialize(),
'pubKeyCredParams' => array_map(static function (PublicKeyCredentialParameters $object): array {
return $object->jsonSerialize();
}, $this->pubKeyCredParams),
'challenge' => Base64Url::encode($this->challenge),
'attestation' => $this->attestation,
'user' => $this->user->jsonSerialize(),
'authenticatorSelection' => $this->authenticatorSelection->jsonSerialize(),
];
if (0 !== count($this->excludeCredentials)) {
$json['excludeCredentials'] = array_map(static function (PublicKeyCredentialDescriptor $object): array {
return $object->jsonSerialize();
}, $this->excludeCredentials);
}
if (0 !== $this->extensions->count()) {
$json['extensions'] = $this->extensions;
}
if (null !== $this->timeout) {
$json['timeout'] = $this->timeout;
}
return $json;
}
}

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use Base64Url\Base64Url;
use function count;
use JsonSerializable;
use function Safe\json_decode;
class PublicKeyCredentialDescriptor implements JsonSerializable
{
public const CREDENTIAL_TYPE_PUBLIC_KEY = 'public-key';
public const AUTHENTICATOR_TRANSPORT_USB = 'usb';
public const AUTHENTICATOR_TRANSPORT_NFC = 'nfc';
public const AUTHENTICATOR_TRANSPORT_BLE = 'ble';
public const AUTHENTICATOR_TRANSPORT_INTERNAL = 'internal';
/**
* @var string
*/
protected $type;
/**
* @var string
*/
protected $id;
/**
* @var string[]
*/
protected $transports;
/**
* @param string[] $transports
*/
public function __construct(string $type, string $id, array $transports = [])
{
$this->type = $type;
$this->id = $id;
$this->transports = $transports;
}
public function getType(): string
{
return $this->type;
}
public function getId(): string
{
return $this->id;
}
/**
* @return string[]
*/
public function getTransports(): array
{
return $this->transports;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true);
Assertion::isArray($data, 'Invalid data');
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
Assertion::keyExists($json, 'type', 'Invalid input. "type" is missing.');
Assertion::keyExists($json, 'id', 'Invalid input. "id" is missing.');
return new self(
$json['type'],
Base64Url::decode($json['id']),
$json['transports'] ?? []
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'type' => $this->type,
'id' => Base64Url::encode($this->id),
];
if (0 !== count($this->transports)) {
$json['transports'] = $this->transports;
}
return $json;
}
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use function array_key_exists;
use ArrayIterator;
use Assert\Assertion;
use function count;
use Countable;
use Iterator;
use IteratorAggregate;
use JsonSerializable;
use function Safe\json_decode;
class PublicKeyCredentialDescriptorCollection implements JsonSerializable, Countable, IteratorAggregate
{
/**
* @var PublicKeyCredentialDescriptor[]
*/
private $publicKeyCredentialDescriptors = [];
public function add(PublicKeyCredentialDescriptor $publicKeyCredentialDescriptor): void
{
$this->publicKeyCredentialDescriptors[$publicKeyCredentialDescriptor->getId()] = $publicKeyCredentialDescriptor;
}
public function has(string $id): bool
{
return array_key_exists($id, $this->publicKeyCredentialDescriptors);
}
public function remove(string $id): void
{
if (!$this->has($id)) {
return;
}
unset($this->publicKeyCredentialDescriptors[$id]);
}
/**
* @return Iterator<string, PublicKeyCredentialDescriptor>
*/
public function getIterator(): Iterator
{
return new ArrayIterator($this->publicKeyCredentialDescriptors);
}
public function count(int $mode = COUNT_NORMAL): int
{
return count($this->publicKeyCredentialDescriptors, $mode);
}
/**
* @return array[]
*/
public function jsonSerialize(): array
{
return array_map(static function (PublicKeyCredentialDescriptor $object): array {
return $object->jsonSerialize();
}, $this->publicKeyCredentialDescriptors);
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true);
Assertion::isArray($data, 'Invalid data');
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
$collection = new self();
foreach ($json as $item) {
$collection->add(PublicKeyCredentialDescriptor::createFromArray($item));
}
return $collection;
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use JsonSerializable;
abstract class PublicKeyCredentialEntity implements JsonSerializable
{
/**
* @var string
*/
protected $name;
/**
* @var string|null
*/
protected $icon;
public function __construct(string $name, ?string $icon)
{
$this->name = $name;
$this->icon = $icon;
}
public function getName(): string
{
return $this->name;
}
public function getIcon(): ?string
{
return $this->icon;
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'name' => $this->name,
];
if (null !== $this->icon) {
$json['icon'] = $this->icon;
}
return $json;
}
}

View file

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use function array_key_exists;
use Assert\Assertion;
use Base64Url\Base64Url;
use CBOR\Decoder;
use CBOR\MapObject;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use InvalidArgumentException;
use function ord;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Ramsey\Uuid\Uuid;
use function Safe\json_decode;
use function Safe\sprintf;
use function Safe\unpack;
use Throwable;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientOutputsLoader;
class PublicKeyCredentialLoader
{
private const FLAG_AT = 0b01000000;
private const FLAG_ED = 0b10000000;
/**
* @var AttestationObjectLoader
*/
private $attestationObjectLoader;
/**
* @var Decoder
*/
private $decoder;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(AttestationObjectLoader $attestationObjectLoader, ?LoggerInterface $logger = null)
{
if (null !== $logger) {
@trigger_error('The argument "logger" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setLogger".', E_USER_DEPRECATED);
}
$this->decoder = new Decoder(new TagObjectManager(), new OtherObjectManager());
$this->attestationObjectLoader = $attestationObjectLoader;
$this->logger = $logger ?? new NullLogger();
}
public static function create(AttestationObjectLoader $attestationObjectLoader): self
{
return new self($attestationObjectLoader);
}
public function setLogger(LoggerInterface $logger): self
{
$this->logger = $logger;
return $this;
}
/**
* @param mixed[] $json
*/
public function loadArray(array $json): PublicKeyCredential
{
$this->logger->info('Trying to load data from an array', ['data' => $json]);
try {
foreach (['id', 'rawId', 'type'] as $key) {
Assertion::keyExists($json, $key, sprintf('The parameter "%s" is missing', $key));
Assertion::string($json[$key], sprintf('The parameter "%s" shall be a string', $key));
}
Assertion::keyExists($json, 'response', 'The parameter "response" is missing');
Assertion::isArray($json['response'], 'The parameter "response" shall be an array');
Assertion::eq($json['type'], 'public-key', sprintf('Unsupported type "%s"', $json['type']));
$id = Base64Url::decode($json['id']);
$rawId = Base64Url::decode($json['rawId']);
Assertion::true(hash_equals($id, $rawId));
$publicKeyCredential = new PublicKeyCredential(
$json['id'],
$json['type'],
$rawId,
$this->createResponse($json['response'])
);
$this->logger->info('The data has been loaded');
$this->logger->debug('Public Key Credential', ['publicKeyCredential' => $publicKeyCredential]);
return $publicKeyCredential;
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
throw $throwable;
}
}
public function load(string $data): PublicKeyCredential
{
$this->logger->info('Trying to load data from a string', ['data' => $data]);
try {
$json = json_decode($data, true);
return $this->loadArray($json);
} catch (Throwable $throwable) {
$this->logger->error('An error occurred', [
'exception' => $throwable,
]);
throw $throwable;
}
}
/**
* @param mixed[] $response
*/
private function createResponse(array $response): AuthenticatorResponse
{
Assertion::keyExists($response, 'clientDataJSON', 'Invalid data. The parameter "clientDataJSON" is missing');
Assertion::string($response['clientDataJSON'], 'Invalid data. The parameter "clientDataJSON" is invalid');
switch (true) {
case array_key_exists('attestationObject', $response):
Assertion::string($response['attestationObject'], 'Invalid data. The parameter "attestationObject " is invalid');
$attestationObject = $this->attestationObjectLoader->load($response['attestationObject']);
return new AuthenticatorAttestationResponse(CollectedClientData::createFormJson($response['clientDataJSON']), $attestationObject);
case array_key_exists('authenticatorData', $response) && array_key_exists('signature', $response):
$authData = Base64Url::decode($response['authenticatorData']);
$authDataStream = new StringStream($authData);
$rp_id_hash = $authDataStream->read(32);
$flags = $authDataStream->read(1);
$signCount = $authDataStream->read(4);
$signCount = unpack('N', $signCount)[1];
$attestedCredentialData = null;
if (0 !== (ord($flags) & self::FLAG_AT)) {
$aaguid = Uuid::fromBytes($authDataStream->read(16));
$credentialLength = $authDataStream->read(2);
$credentialLength = unpack('n', $credentialLength)[1];
$credentialId = $authDataStream->read($credentialLength);
$credentialPublicKey = $this->decoder->decode($authDataStream);
Assertion::isInstanceOf($credentialPublicKey, MapObject::class, 'The data does not contain a valid credential public key.');
$attestedCredentialData = new AttestedCredentialData($aaguid, $credentialId, (string) $credentialPublicKey);
}
$extension = null;
if (0 !== (ord($flags) & self::FLAG_ED)) {
$extension = $this->decoder->decode($authDataStream);
$extension = AuthenticationExtensionsClientOutputsLoader::load($extension);
}
Assertion::true($authDataStream->isEOF(), 'Invalid authentication data. Presence of extra bytes.');
$authDataStream->close();
$authenticatorData = new AuthenticatorData($authData, $rp_id_hash, $flags, $signCount, $attestedCredentialData, $extension);
return new AuthenticatorAssertionResponse(
CollectedClientData::createFormJson($response['clientDataJSON']),
$authenticatorData,
Base64Url::decode($response['signature']),
$response['userHandle'] ?? null
);
default:
throw new InvalidArgumentException('Unable to create the response object');
}
}
}

View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use JsonSerializable;
use Webauthn\AuthenticationExtensions\AuthenticationExtension;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
abstract class PublicKeyCredentialOptions implements JsonSerializable
{
/**
* @var string
*/
protected $challenge;
/**
* @var int|null
*/
protected $timeout;
/**
* @var AuthenticationExtensionsClientInputs
*/
protected $extensions;
public function __construct(string $challenge, ?int $timeout = null, ?AuthenticationExtensionsClientInputs $extensions = null)
{
if (null !== $timeout) {
@trigger_error('The argument "timeout" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setTimeout".', E_USER_DEPRECATED);
}
if (null !== $extensions) {
@trigger_error('The argument "extensions" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "addExtension" or "addExtensions".', E_USER_DEPRECATED);
}
$this->challenge = $challenge;
$this->setTimeout($timeout);
$this->extensions = $extensions ?? new AuthenticationExtensionsClientInputs();
}
public function setTimeout(?int $timeout): self
{
$this->timeout = $timeout;
return $this;
}
public function addExtension(AuthenticationExtension $extension): self
{
$this->extensions->add($extension);
return $this;
}
/**
* @param AuthenticationExtension[] $extensions
*/
public function addExtensions(array $extensions): self
{
foreach ($extensions as $extension) {
$this->addExtension($extension);
}
return $this;
}
public function setExtensions(AuthenticationExtensionsClientInputs $extensions): self
{
$this->extensions = $extensions;
return $this;
}
public function getChallenge(): string
{
return $this->challenge;
}
public function getTimeout(): ?int
{
return $this->timeout;
}
public function getExtensions(): AuthenticationExtensionsClientInputs
{
return $this->extensions;
}
abstract public static function createFromString(string $data): self;
/**
* @param mixed[] $json
*/
abstract public static function createFromArray(array $json): self;
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use JsonSerializable;
use function Safe\json_decode;
class PublicKeyCredentialParameters implements JsonSerializable
{
/**
* @var string
*/
private $type;
/**
* @var int
*/
private $alg;
public function __construct(string $type, int $alg)
{
$this->type = $type;
$this->alg = $alg;
}
public function getType(): string
{
return $this->type;
}
public function getAlg(): int
{
return $this->alg;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true);
Assertion::isArray($data, 'Invalid data');
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
Assertion::keyExists($json, 'type', 'Invalid input. "type" is missing.');
Assertion::string($json['type'], 'Invalid input. "type" is not a string.');
Assertion::keyExists($json, 'alg', 'Invalid input. "alg" is missing.');
Assertion::integer($json['alg'], 'Invalid input. "alg" is not an integer.');
return new self(
$json['type'],
$json['alg']
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return [
'type' => $this->type,
'alg' => $this->alg,
];
}
}

View file

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use Base64Url\Base64Url;
use function count;
use function Safe\json_decode;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
class PublicKeyCredentialRequestOptions extends PublicKeyCredentialOptions
{
public const USER_VERIFICATION_REQUIREMENT_REQUIRED = 'required';
public const USER_VERIFICATION_REQUIREMENT_PREFERRED = 'preferred';
public const USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 'discouraged';
/**
* @var string|null
*/
private $rpId;
/**
* @var PublicKeyCredentialDescriptor[]
*/
private $allowCredentials = [];
/**
* @var string|null
*/
private $userVerification;
/**
* @param PublicKeyCredentialDescriptor[] $allowCredentials
*/
public function __construct(string $challenge, ?int $timeout = null, ?string $rpId = null, array $allowCredentials = [], ?string $userVerification = null, ?AuthenticationExtensionsClientInputs $extensions = null)
{
if (0 !== count($allowCredentials)) {
@trigger_error('The argument "allowCredentials" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "addAllowedCredentials" or "addAllowedCredential".', E_USER_DEPRECATED);
}
if (null !== $rpId) {
@trigger_error('The argument "rpId" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setRpId".', E_USER_DEPRECATED);
}
if (null !== $userVerification) {
@trigger_error('The argument "userVerification" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setUserVerification".', E_USER_DEPRECATED);
}
parent::__construct($challenge, $timeout, $extensions);
$this
->setRpId($rpId)
->allowCredentials($allowCredentials)
->setUserVerification($userVerification)
;
}
public static function create(string $challenge): self
{
return new self($challenge);
}
public function setRpId(?string $rpId): self
{
$this->rpId = $rpId;
return $this;
}
public function allowCredential(PublicKeyCredentialDescriptor $allowCredential): self
{
$this->allowCredentials[] = $allowCredential;
return $this;
}
/**
* @param PublicKeyCredentialDescriptor[] $allowCredentials
*/
public function allowCredentials(array $allowCredentials): self
{
foreach ($allowCredentials as $allowCredential) {
$this->allowCredential($allowCredential);
}
return $this;
}
public function setUserVerification(?string $userVerification): self
{
if (null === $userVerification) {
$this->rpId = null;
return $this;
}
Assertion::inArray($userVerification, [
self::USER_VERIFICATION_REQUIREMENT_REQUIRED,
self::USER_VERIFICATION_REQUIREMENT_PREFERRED,
self::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
], 'Invalid user verification requirement');
$this->userVerification = $userVerification;
return $this;
}
public function getRpId(): ?string
{
return $this->rpId;
}
/**
* @return PublicKeyCredentialDescriptor[]
*/
public function getAllowCredentials(): array
{
return $this->allowCredentials;
}
public function getUserVerification(): ?string
{
return $this->userVerification;
}
public static function createFromString(string $data): PublicKeyCredentialOptions
{
$data = json_decode($data, true);
Assertion::isArray($data, 'Invalid data');
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): PublicKeyCredentialOptions
{
Assertion::keyExists($json, 'challenge', 'Invalid input. "challenge" is missing.');
$allowCredentials = [];
$allowCredentialList = $json['allowCredentials'] ?? [];
foreach ($allowCredentialList as $allowCredential) {
$allowCredentials[] = PublicKeyCredentialDescriptor::createFromArray($allowCredential);
}
return self::create(Base64Url::decode($json['challenge']))
->setRpId($json['rpId'] ?? null)
->allowCredentials($allowCredentials)
->setUserVerification($json['userVerification'] ?? null)
->setTimeout($json['timeout'] ?? null)
->setExtensions(isset($json['extensions']) ? AuthenticationExtensionsClientInputs::createFromArray($json['extensions']) : new AuthenticationExtensionsClientInputs())
;
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = [
'challenge' => Base64Url::encode($this->challenge),
];
if (null !== $this->rpId) {
$json['rpId'] = $this->rpId;
}
if (null !== $this->userVerification) {
$json['userVerification'] = $this->userVerification;
}
if (0 !== count($this->allowCredentials)) {
$json['allowCredentials'] = array_map(static function (PublicKeyCredentialDescriptor $object): array {
return $object->jsonSerialize();
}, $this->allowCredentials);
}
if (0 !== $this->extensions->count()) {
$json['extensions'] = $this->extensions->jsonSerialize();
}
if (null !== $this->timeout) {
$json['timeout'] = $this->timeout;
}
return $json;
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
class PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity
{
/**
* @var string|null
*/
protected $id;
public function __construct(string $name, ?string $id = null, ?string $icon = null)
{
parent::__construct($name, $icon);
$this->id = $id;
}
public function getId(): ?string
{
return $this->id;
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
Assertion::keyExists($json, 'name', 'Invalid input. "name" is missing.');
return new self(
$json['name'],
$json['id'] ?? null,
$json['icon'] ?? null
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = parent::jsonSerialize();
if (null !== $this->id) {
$json['id'] = $this->id;
}
return $json;
}
}

View file

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use Base64Url\Base64Url;
use InvalidArgumentException;
use JsonSerializable;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use function Safe\base64_decode;
use function Safe\sprintf;
use Throwable;
use Webauthn\TrustPath\TrustPath;
use Webauthn\TrustPath\TrustPathLoader;
/**
* @see https://www.w3.org/TR/webauthn/#iface-pkcredential
*/
class PublicKeyCredentialSource implements JsonSerializable
{
/**
* @var string
*/
protected $publicKeyCredentialId;
/**
* @var string
*/
protected $type;
/**
* @var string[]
*/
protected $transports;
/**
* @var string
*/
protected $attestationType;
/**
* @var TrustPath
*/
protected $trustPath;
/**
* @var UuidInterface
*/
protected $aaguid;
/**
* @var string
*/
protected $credentialPublicKey;
/**
* @var string
*/
protected $userHandle;
/**
* @var int
*/
protected $counter;
/**
* @var array|null
*/
protected $otherUI;
/**
* @param string[] $transports
*/
public function __construct(string $publicKeyCredentialId, string $type, array $transports, string $attestationType, TrustPath $trustPath, UuidInterface $aaguid, string $credentialPublicKey, string $userHandle, int $counter, ?array $otherUI = null)
{
$this->publicKeyCredentialId = $publicKeyCredentialId;
$this->type = $type;
$this->transports = $transports;
$this->aaguid = $aaguid;
$this->credentialPublicKey = $credentialPublicKey;
$this->userHandle = $userHandle;
$this->counter = $counter;
$this->attestationType = $attestationType;
$this->trustPath = $trustPath;
$this->otherUI = $otherUI;
}
public function getPublicKeyCredentialId(): string
{
return $this->publicKeyCredentialId;
}
public function getPublicKeyCredentialDescriptor(): PublicKeyCredentialDescriptor
{
return new PublicKeyCredentialDescriptor(
$this->type,
$this->publicKeyCredentialId,
$this->transports
);
}
public function getAttestationType(): string
{
return $this->attestationType;
}
public function getTrustPath(): TrustPath
{
return $this->trustPath;
}
public function getAttestedCredentialData(): AttestedCredentialData
{
return new AttestedCredentialData(
$this->aaguid,
$this->publicKeyCredentialId,
$this->credentialPublicKey
);
}
public function getType(): string
{
return $this->type;
}
/**
* @return string[]
*/
public function getTransports(): array
{
return $this->transports;
}
public function getAaguid(): UuidInterface
{
return $this->aaguid;
}
public function getCredentialPublicKey(): string
{
return $this->credentialPublicKey;
}
public function getUserHandle(): string
{
return $this->userHandle;
}
public function getCounter(): int
{
return $this->counter;
}
public function setCounter(int $counter): void
{
$this->counter = $counter;
}
public function getOtherUI(): ?array
{
return $this->otherUI;
}
public function setOtherUI(?array $otherUI): self
{
$this->otherUI = $otherUI;
return $this;
}
/**
* @param mixed[] $data
*/
public static function createFromArray(array $data): self
{
$keys = array_keys(get_class_vars(self::class));
foreach ($keys as $key) {
if ('otherUI' === $key) {
continue;
}
Assertion::keyExists($data, $key, sprintf('The parameter "%s" is missing', $key));
}
switch (true) {
case 36 === mb_strlen($data['aaguid'], '8bit'):
$uuid = Uuid::fromString($data['aaguid']);
break;
default: // Kept for compatibility with old format
$decoded = base64_decode($data['aaguid'], true);
$uuid = Uuid::fromBytes($decoded);
}
try {
return new self(
Base64Url::decode($data['publicKeyCredentialId']),
$data['type'],
$data['transports'],
$data['attestationType'],
TrustPathLoader::loadTrustPath($data['trustPath']),
$uuid,
Base64Url::decode($data['credentialPublicKey']),
Base64Url::decode($data['userHandle']),
$data['counter'],
$data['otherUI'] ?? null
);
} catch (Throwable $throwable) {
throw new InvalidArgumentException('Unable to load the data', $throwable->getCode(), $throwable);
}
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return [
'publicKeyCredentialId' => Base64Url::encode($this->publicKeyCredentialId),
'type' => $this->type,
'transports' => $this->transports,
'attestationType' => $this->attestationType,
'trustPath' => $this->trustPath->jsonSerialize(),
'aaguid' => $this->aaguid->toString(),
'credentialPublicKey' => Base64Url::encode($this->credentialPublicKey),
'userHandle' => Base64Url::encode($this->userHandle),
'counter' => $this->counter,
'otherUI' => $this->otherUI,
];
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
interface PublicKeyCredentialSourceRepository
{
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource;
/**
* @return PublicKeyCredentialSource[]
*/
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array;
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void;
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use function Safe\base64_decode;
use function Safe\json_decode;
class PublicKeyCredentialUserEntity extends PublicKeyCredentialEntity
{
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $displayName;
public function __construct(string $name, string $id, string $displayName, ?string $icon = null)
{
parent::__construct($name, $icon);
Assertion::maxLength($id, 64, 'User ID max length is 64 bytes', 'id', '8bit');
$this->id = $id;
$this->displayName = $displayName;
}
public function getId(): string
{
return $this->id;
}
public function getDisplayName(): string
{
return $this->displayName;
}
public static function createFromString(string $data): self
{
$data = json_decode($data, true);
Assertion::isArray($data, 'Invalid data');
return self::createFromArray($data);
}
/**
* @param mixed[] $json
*/
public static function createFromArray(array $json): self
{
Assertion::keyExists($json, 'name', 'Invalid input. "name" is missing.');
Assertion::keyExists($json, 'id', 'Invalid input. "id" is missing.');
Assertion::keyExists($json, 'displayName', 'Invalid input. "displayName" is missing.');
$id = base64_decode($json['id'], true);
return new self(
$json['name'],
$id,
$json['displayName'],
$json['icon'] ?? null
);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
$json = parent::jsonSerialize();
$json['id'] = base64_encode($this->id);
$json['displayName'] = $this->displayName;
return $json;
}
}

View file

@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use Cose\Algorithm\Algorithm;
use Cose\Algorithm\ManagerFactory;
use Cose\Algorithm\Signature\ECDSA;
use Cose\Algorithm\Signature\EdDSA;
use Cose\Algorithm\Signature\RSA;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\RS256;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
use Webauthn\AttestationStatement\AndroidSafetyNetAttestationStatementSupport;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\Counter\CounterChecker;
use Webauthn\MetadataService\MetadataStatementRepository;
use Webauthn\TokenBinding\IgnoreTokenBindingHandler;
use Webauthn\TokenBinding\TokenBindingHandler;
class Server
{
/**
* @var int
*/
public $timeout = 60000;
/**
* @var int
*/
public $challengeSize = 32;
/**
* @var PublicKeyCredentialRpEntity
*/
private $rpEntity;
/**
* @var ManagerFactory
*/
private $coseAlgorithmManagerFactory;
/**
* @var PublicKeyCredentialSourceRepository
*/
private $publicKeyCredentialSourceRepository;
/**
* @var TokenBindingHandler
*/
private $tokenBindingHandler;
/**
* @var ExtensionOutputCheckerHandler
*/
private $extensionOutputCheckerHandler;
/**
* @var string[]
*/
private $selectedAlgorithms;
/**
* @var MetadataStatementRepository|null
*/
private $metadataStatementRepository;
/**
* @var ClientInterface|null
*/
private $httpClient;
/**
* @var string|null
*/
private $googleApiKey;
/**
* @var RequestFactoryInterface|null
*/
private $requestFactory;
/**
* @var CounterChecker|null
*/
private $counterChecker;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var string[]
*/
private $securedRelyingPartyId = [];
public function __construct(PublicKeyCredentialRpEntity $relyingParty, PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository, ?MetadataStatementRepository $metadataStatementRepository = null)
{
if (null !== $metadataStatementRepository) {
@trigger_error('The argument "metadataStatementRepository" is deprecated since version 3.3 and will be removed in 4.0. Please use the method "setMetadataStatementRepository".', E_USER_DEPRECATED);
}
$this->rpEntity = $relyingParty;
$this->logger = new NullLogger();
$this->coseAlgorithmManagerFactory = new ManagerFactory();
$this->coseAlgorithmManagerFactory->add('RS1', new RSA\RS1());
$this->coseAlgorithmManagerFactory->add('RS256', new RSA\RS256());
$this->coseAlgorithmManagerFactory->add('RS384', new RSA\RS384());
$this->coseAlgorithmManagerFactory->add('RS512', new RSA\RS512());
$this->coseAlgorithmManagerFactory->add('PS256', new RSA\PS256());
$this->coseAlgorithmManagerFactory->add('PS384', new RSA\PS384());
$this->coseAlgorithmManagerFactory->add('PS512', new RSA\PS512());
$this->coseAlgorithmManagerFactory->add('ES256', new ECDSA\ES256());
$this->coseAlgorithmManagerFactory->add('ES256K', new ECDSA\ES256K());
$this->coseAlgorithmManagerFactory->add('ES384', new ECDSA\ES384());
$this->coseAlgorithmManagerFactory->add('ES512', new ECDSA\ES512());
$this->coseAlgorithmManagerFactory->add('Ed25519', new EdDSA\Ed25519());
$this->selectedAlgorithms = ['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512', 'Ed25519'];
$this->publicKeyCredentialSourceRepository = $publicKeyCredentialSourceRepository;
$this->tokenBindingHandler = new IgnoreTokenBindingHandler();
$this->extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler();
$this->metadataStatementRepository = $metadataStatementRepository;
}
public function setMetadataStatementRepository(MetadataStatementRepository $metadataStatementRepository): self
{
$this->metadataStatementRepository = $metadataStatementRepository;
return $this;
}
/**
* @param string[] $selectedAlgorithms
*/
public function setSelectedAlgorithms(array $selectedAlgorithms): self
{
$this->selectedAlgorithms = $selectedAlgorithms;
return $this;
}
public function setTokenBindingHandler(TokenBindingHandler $tokenBindingHandler): self
{
$this->tokenBindingHandler = $tokenBindingHandler;
return $this;
}
public function addAlgorithm(string $alias, Algorithm $algorithm): self
{
$this->coseAlgorithmManagerFactory->add($alias, $algorithm);
$this->selectedAlgorithms[] = $alias;
$this->selectedAlgorithms = array_unique($this->selectedAlgorithms);
return $this;
}
public function setExtensionOutputCheckerHandler(ExtensionOutputCheckerHandler $extensionOutputCheckerHandler): self
{
$this->extensionOutputCheckerHandler = $extensionOutputCheckerHandler;
return $this;
}
/**
* @param string[] $securedRelyingPartyId
*/
public function setSecuredRelyingPartyId(array $securedRelyingPartyId): self
{
Assertion::allString($securedRelyingPartyId, 'Invalid list. Shall be a list of strings');
$this->securedRelyingPartyId = $securedRelyingPartyId;
return $this;
}
/**
* @param PublicKeyCredentialDescriptor[] $excludedPublicKeyDescriptors
*/
public function generatePublicKeyCredentialCreationOptions(PublicKeyCredentialUserEntity $userEntity, ?string $attestationMode = null, array $excludedPublicKeyDescriptors = [], ?AuthenticatorSelectionCriteria $criteria = null, ?AuthenticationExtensionsClientInputs $extensions = null): PublicKeyCredentialCreationOptions
{
$coseAlgorithmManager = $this->coseAlgorithmManagerFactory->create($this->selectedAlgorithms);
$publicKeyCredentialParametersList = [];
foreach ($coseAlgorithmManager->all() as $algorithm) {
$publicKeyCredentialParametersList[] = new PublicKeyCredentialParameters(
PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
$algorithm::identifier()
);
}
$criteria = $criteria ?? new AuthenticatorSelectionCriteria();
$extensions = $extensions ?? new AuthenticationExtensionsClientInputs();
$challenge = random_bytes($this->challengeSize);
return PublicKeyCredentialCreationOptions::create(
$this->rpEntity,
$userEntity,
$challenge,
$publicKeyCredentialParametersList
)
->excludeCredentials($excludedPublicKeyDescriptors)
->setAuthenticatorSelection($criteria)
->setAttestation($attestationMode ?? PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE)
->setExtensions($extensions)
->setTimeout($this->timeout)
;
}
/**
* @param PublicKeyCredentialDescriptor[] $allowedPublicKeyDescriptors
*/
public function generatePublicKeyCredentialRequestOptions(?string $userVerification = null, array $allowedPublicKeyDescriptors = [], ?AuthenticationExtensionsClientInputs $extensions = null): PublicKeyCredentialRequestOptions
{
return PublicKeyCredentialRequestOptions::create(random_bytes($this->challengeSize))
->setRpId($this->rpEntity->getId())
->setUserVerification($userVerification ?? PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED)
->allowCredentials($allowedPublicKeyDescriptors)
->setTimeout($this->timeout)
->setExtensions($extensions ?? new AuthenticationExtensionsClientInputs())
;
}
public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, ServerRequestInterface $serverRequest): PublicKeyCredentialSource
{
$attestationStatementSupportManager = $this->getAttestationStatementSupportManager();
$attestationObjectLoader = AttestationObjectLoader::create($attestationStatementSupportManager)
->setLogger($this->logger)
;
$publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader)
->setLogger($this->logger)
;
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
$authenticatorResponse = $publicKeyCredential->getResponse();
Assertion::isInstanceOf($authenticatorResponse, AuthenticatorAttestationResponse::class, 'Not an authenticator attestation response');
$authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
$attestationStatementSupportManager,
$this->publicKeyCredentialSourceRepository,
$this->tokenBindingHandler,
$this->extensionOutputCheckerHandler,
$this->metadataStatementRepository
);
$authenticatorAttestationResponseValidator->setLogger($this->logger);
return $authenticatorAttestationResponseValidator->check($authenticatorResponse, $publicKeyCredentialCreationOptions, $serverRequest, $this->securedRelyingPartyId);
}
public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, ?PublicKeyCredentialUserEntity $userEntity, ServerRequestInterface $serverRequest): PublicKeyCredentialSource
{
$attestationStatementSupportManager = $this->getAttestationStatementSupportManager();
$attestationObjectLoader = AttestationObjectLoader::create($attestationStatementSupportManager)
->setLogger($this->logger)
;
$publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader)
->setLogger($this->logger)
;
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
$authenticatorResponse = $publicKeyCredential->getResponse();
Assertion::isInstanceOf($authenticatorResponse, AuthenticatorAssertionResponse::class, 'Not an authenticator assertion response');
$authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
$this->publicKeyCredentialSourceRepository,
$this->tokenBindingHandler,
$this->extensionOutputCheckerHandler,
$this->coseAlgorithmManagerFactory->create($this->selectedAlgorithms),
$this->counterChecker
);
$authenticatorAssertionResponseValidator->setLogger($this->logger);
return $authenticatorAssertionResponseValidator->check(
$publicKeyCredential->getRawId(),
$authenticatorResponse,
$publicKeyCredentialRequestOptions,
$serverRequest,
null !== $userEntity ? $userEntity->getId() : null,
$this->securedRelyingPartyId
);
}
public function setCounterChecker(CounterChecker $counterChecker): self
{
$this->counterChecker = $counterChecker;
return $this;
}
public function setLogger(LoggerInterface $logger): self
{
$this->logger = $logger;
return $this;
}
public function enforceAndroidSafetyNetVerification(ClientInterface $client, string $apiKey, RequestFactoryInterface $requestFactory): self
{
$this->httpClient = $client;
$this->googleApiKey = $apiKey;
$this->requestFactory = $requestFactory;
return $this;
}
private function getAttestationStatementSupportManager(): AttestationStatementSupportManager
{
$attestationStatementSupportManager = new AttestationStatementSupportManager();
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());
$attestationStatementSupportManager->add(new FidoU2FAttestationStatementSupport());
if (class_exists(RS256::class) && class_exists(JWKFactory::class)) {
$androidSafetyNetAttestationStatementSupport = new AndroidSafetyNetAttestationStatementSupport();
if (null !== $this->httpClient && null !== $this->googleApiKey && null !== $this->requestFactory) {
$androidSafetyNetAttestationStatementSupport
->enableApiVerification($this->httpClient, $this->googleApiKey, $this->requestFactory)
->setLeeway(2000)
->setMaxAge(60000)
;
}
$attestationStatementSupportManager->add($androidSafetyNetAttestationStatementSupport);
}
$attestationStatementSupportManager->add(new AndroidKeyAttestationStatementSupport());
$attestationStatementSupportManager->add(new TPMAttestationStatementSupport());
$coseAlgorithmManager = $this->coseAlgorithmManagerFactory->create($this->selectedAlgorithms);
$attestationStatementSupportManager->add(new PackedAttestationStatementSupport($coseAlgorithmManager));
return $attestationStatementSupportManager;
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn;
use Assert\Assertion;
use CBOR\Stream;
use function Safe\fclose;
use function Safe\fopen;
use function Safe\fread;
use function Safe\fwrite;
use function Safe\rewind;
use function Safe\sprintf;
final class StringStream implements Stream
{
/**
* @var resource
*/
private $data;
/**
* @var int
*/
private $length;
/**
* @var int
*/
private $totalRead = 0;
public function __construct(string $data)
{
$this->length = mb_strlen($data, '8bit');
$resource = fopen('php://memory', 'rb+');
fwrite($resource, $data);
rewind($resource);
$this->data = $resource;
}
public function read(int $length): string
{
if (0 === $length) {
return '';
}
$read = fread($this->data, $length);
$bytesRead = mb_strlen($read, '8bit');
Assertion::length($read, $length, sprintf('Out of range. Expected: %d, read: %d.', $length, $bytesRead), null, '8bit');
$this->totalRead += $bytesRead;
return $read;
}
public function close(): void
{
fclose($this->data);
}
public function isEOF(): bool
{
return $this->totalRead === $this->length;
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TokenBinding;
use Psr\Http\Message\ServerRequestInterface;
final class IgnoreTokenBindingHandler implements TokenBindingHandler
{
public function check(TokenBinding $tokenBinding, ServerRequestInterface $request): void
{
//Does nothing
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TokenBinding;
use Assert\Assertion;
use Psr\Http\Message\ServerRequestInterface;
final class SecTokenBindingHandler implements TokenBindingHandler
{
public function check(TokenBinding $tokenBinding, ServerRequestInterface $request): void
{
if (TokenBinding::TOKEN_BINDING_STATUS_PRESENT !== $tokenBinding->getStatus()) {
return;
}
Assertion::true($request->hasHeader('Sec-Token-Binding'), 'The header parameter "Sec-Token-Binding" is missing.');
$tokenBindingIds = $request->getHeader('Sec-Token-Binding');
Assertion::count($tokenBindingIds, 1, 'The header parameter "Sec-Token-Binding" is invalid.');
$tokenBindingId = reset($tokenBindingIds);
Assertion::eq($tokenBindingId, $tokenBinding->getId(), 'The header parameter "Sec-Token-Binding" is invalid.');
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TokenBinding;
use function array_key_exists;
use Assert\Assertion;
use Base64Url\Base64Url;
use function Safe\sprintf;
class TokenBinding
{
public const TOKEN_BINDING_STATUS_PRESENT = 'present';
public const TOKEN_BINDING_STATUS_SUPPORTED = 'supported';
public const TOKEN_BINDING_STATUS_NOT_SUPPORTED = 'not-supported';
/**
* @var string
*/
private $status;
/**
* @var string|null
*/
private $id;
public function __construct(string $status, ?string $id)
{
Assertion::false(self::TOKEN_BINDING_STATUS_PRESENT === $status && null === $id, 'The member "id" is required when status is "present"');
$this->status = $status;
$this->id = $id;
}
/**
* @param mixed[] $json
*/
public static function createFormArray(array $json): self
{
Assertion::keyExists($json, 'status', 'The member "status" is required');
$status = $json['status'];
Assertion::inArray(
$status,
self::getSupportedStatus(),
sprintf('The member "status" is invalid. Supported values are: %s', implode(', ', self::getSupportedStatus()))
);
$id = array_key_exists('id', $json) ? Base64Url::decode($json['id']) : null;
return new self($status, $id);
}
public function getStatus(): string
{
return $this->status;
}
public function getId(): ?string
{
return $this->id;
}
/**
* @return string[]
*/
private static function getSupportedStatus(): array
{
return [
self::TOKEN_BINDING_STATUS_PRESENT,
self::TOKEN_BINDING_STATUS_SUPPORTED,
self::TOKEN_BINDING_STATUS_NOT_SUPPORTED,
];
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TokenBinding;
use Psr\Http\Message\ServerRequestInterface;
interface TokenBindingHandler
{
public function check(TokenBinding $tokenBinding, ServerRequestInterface $request): void;
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TokenBinding;
use Assert\Assertion;
use Psr\Http\Message\ServerRequestInterface;
final class TokenBindingNotSupportedHandler implements TokenBindingHandler
{
public function check(TokenBinding $tokenBinding, ServerRequestInterface $request): void
{
Assertion::true(TokenBinding::TOKEN_BINDING_STATUS_PRESENT !== $tokenBinding->getStatus(), 'Token binding not supported.');
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TrustPath;
use Assert\Assertion;
final class CertificateTrustPath implements TrustPath
{
/**
* @var string[]
*/
private $certificates;
/**
* @param string[] $certificates
*/
public function __construct(array $certificates)
{
$this->certificates = $certificates;
}
/**
* @return string[]
*/
public function getCertificates(): array
{
return $this->certificates;
}
/**
* {@inheritdoc}
*/
public static function createFromArray(array $data): TrustPath
{
Assertion::keyExists($data, 'x5c', 'The trust path type is invalid');
return new CertificateTrustPath($data['x5c']);
}
/**
* @return mixed[]
*/
public function jsonSerialize(): array
{
return [
'type' => self::class,
'x5c' => $this->certificates,
];
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TrustPath;
use Assert\Assertion;
final class EcdaaKeyIdTrustPath implements TrustPath
{
/**
* @var string
*/
private $ecdaaKeyId;
public function __construct(string $ecdaaKeyId)
{
$this->ecdaaKeyId = $ecdaaKeyId;
}
public function getEcdaaKeyId(): string
{
return $this->ecdaaKeyId;
}
/**
* @return string[]
*/
public function jsonSerialize(): array
{
return [
'type' => self::class,
'ecdaaKeyId' => $this->ecdaaKeyId,
];
}
/**
* {@inheritdoc}
*/
public static function createFromArray(array $data): TrustPath
{
Assertion::keyExists($data, 'ecdaaKeyId', 'The trust path type is invalid');
return new EcdaaKeyIdTrustPath($data['ecdaaKeyId']);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TrustPath;
final class EmptyTrustPath implements TrustPath
{
/**
* @return string[]
*/
public function jsonSerialize(): array
{
return [
'type' => self::class,
];
}
/**
* {@inheritdoc}
*/
public static function createFromArray(array $data): TrustPath
{
return new EmptyTrustPath();
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TrustPath;
use JsonSerializable;
interface TrustPath extends JsonSerializable
{
/**
* @param mixed[] $data
*/
public static function createFromArray(array $data): self;
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\TrustPath;
use function array_key_exists;
use Assert\Assertion;
use function in_array;
use InvalidArgumentException;
use function Safe\class_implements;
use function Safe\sprintf;
abstract class TrustPathLoader
{
/**
* @param mixed[] $data
*/
public static function loadTrustPath(array $data): TrustPath
{
Assertion::keyExists($data, 'type', 'The trust path type is missing');
$type = $data['type'];
$oldTypes = self::oldTrustPathTypes();
switch (true) {
case array_key_exists($type, $oldTypes):
return $oldTypes[$type]::createFromArray($data);
case class_exists($type):
$implements = class_implements($type);
if (in_array(TrustPath::class, $implements, true)) {
return $type::createFromArray($data);
}
// no break
default:
throw new InvalidArgumentException(sprintf('The trust path type "%s" is not supported', $data['type']));
}
}
/**
* @return string[]
*/
private static function oldTrustPathTypes(): array
{
return [
'empty' => EmptyTrustPath::class,
'ecdaa_key_id' => EcdaaKeyIdTrustPath::class,
'x5c' => CertificateTrustPath::class,
];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Webauthn;
use CBOR\ByteStringObject;
use CBOR\MapItem;
use CBOR\MapObject;
use CBOR\NegativeIntegerObject;
use CBOR\UnsignedIntegerObject;
class U2FPublicKey
{
public static function isU2FKey($publicKey): bool
{
return $publicKey[0] === "\x04";
}
public static function createCOSEKey($publicKey): string
{
$mapObject = new MapObject([
1 => MapItem::create(
new UnsignedIntegerObject(1, null),
new UnsignedIntegerObject(2, null)
),
3 => MapItem::create(
new UnsignedIntegerObject(3, null),
new NegativeIntegerObject(6, null)
),
-1 => MapItem::create(
new NegativeIntegerObject(0, null),
new UnsignedIntegerObject(1, null)
),
-2 => MapItem::create(
new NegativeIntegerObject(1, null),
new ByteStringObject(substr($publicKey, 1, 32))
),
-3 => MapItem::create(
new NegativeIntegerObject(2, null),
new ByteStringObject(substr($publicKey, 33))
),
]);
return $mapObject->__toString();
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2021 Spomky-Labs
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
namespace Webauthn\Util;
use Cose\Algorithm\Signature\ECDSA;
use Cose\Algorithm\Signature\Signature;
/**
* This class fixes the signature of the ECDSA based algorithms.
*
* @internal
*
* @see https://www.w3.org/TR/webauthn/#signature-attestation-types
*/
abstract class CoseSignatureFixer
{
public static function fix(string $signature, Signature $algorithm): string
{
switch ($algorithm::identifier()) {
case ECDSA\ES256K::ID:
case ECDSA\ES256::ID:
if (64 === mb_strlen($signature, '8bit')) {
return $signature;
}
return ECDSA\ECSignature::fromAsn1($signature, 64); //TODO: fix this hardcoded value by adding a dedicated method for the algorithms
case ECDSA\ES384::ID:
if (96 === mb_strlen($signature, '8bit')) {
return $signature;
}
return ECDSA\ECSignature::fromAsn1($signature, 96);
case ECDSA\ES512::ID:
if (132 === mb_strlen($signature, '8bit')) {
return $signature;
}
return ECDSA\ECSignature::fromAsn1($signature, 132);
}
return $signature;
}
}