first commit

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

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 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,10 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm;
interface Algorithm
{
public static function identifier(): int;
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Mac;
final class HS256 extends Hmac
{
public const ID = 5;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): string
{
return 'sha256';
}
protected function getSignatureLength(): int
{
return 256;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Mac;
final class HS256Truncated64 extends Hmac
{
public const ID = 4;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): string
{
return 'sha256';
}
protected function getSignatureLength(): int
{
return 64;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Mac;
final class HS384 extends Hmac
{
public const ID = 6;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): string
{
return 'sha384';
}
protected function getSignatureLength(): int
{
return 384;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Mac;
final class HS512 extends Hmac
{
public const ID = 7;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): string
{
return 'sha512';
}
protected function getSignatureLength(): int
{
return 512;
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Mac;
use Cose\Key\Key;
use Cose\Key\SymmetricKey;
use InvalidArgumentException;
/**
* @see \Cose\Tests\Algorithm\Mac\HmacTest
*/
abstract class Hmac implements Mac
{
public function hash(string $data, Key $key): string
{
$this->checKey($key);
$signature = hash_hmac($this->getHashAlgorithm(), $data, (string) $key->get(SymmetricKey::DATA_K), true);
return mb_substr($signature, 0, intdiv($this->getSignatureLength(), 8), '8bit');
}
public function verify(string $data, Key $key, string $signature): bool
{
return hash_equals($this->hash($data, $key), $signature);
}
abstract protected function getHashAlgorithm(): string;
abstract protected function getSignatureLength(): int;
private function checKey(Key $key): void
{
if ($key->type() !== Key::TYPE_OCT && $key->type() !== Key::TYPE_NAME_OCT) {
throw new InvalidArgumentException('Invalid key. Must be of type symmetric');
}
if (! $key->has(SymmetricKey::DATA_K)) {
throw new InvalidArgumentException('Invalid key. The value of the key is missing');
}
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Mac;
use Cose\Algorithm\Algorithm;
use Cose\Key\Key;
interface Mac extends Algorithm
{
public function hash(string $data, Key $key): string;
public function verify(string $data, Key $key, string $signature): bool;
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm;
use InvalidArgumentException;
use function array_key_exists;
final class Manager
{
/**
* @var array<int, Algorithm>
*/
private array $algorithms = [];
public static function create(): self
{
return new self();
}
public function add(Algorithm ...$algorithms): self
{
foreach ($algorithms as $algorithm) {
$identifier = $algorithm::identifier();
$this->algorithms[$identifier] = $algorithm;
}
return $this;
}
/**
* @return iterable<int>
*/
public function list(): iterable
{
yield from array_keys($this->algorithms);
}
/**
* @return iterable<int, Algorithm>
*/
public function all(): iterable
{
yield from $this->algorithms;
}
public function has(int $identifier): bool
{
return array_key_exists($identifier, $this->algorithms);
}
public function get(int $identifier): Algorithm
{
if (! $this->has($identifier)) {
throw new InvalidArgumentException('Unsupported algorithm');
}
return $this->algorithms[$identifier];
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm;
use InvalidArgumentException;
use function array_key_exists;
final class ManagerFactory
{
/**
* @var array<string, Algorithm>
*/
private array $algorithms = [];
public static function create(): self
{
return new self();
}
public function add(string $alias, Algorithm $algorithm): self
{
$this->algorithms[$alias] = $algorithm;
return $this;
}
/**
* @return string[]
*/
public function list(): iterable
{
yield from array_keys($this->algorithms);
}
/**
* @return Algorithm[]
*/
public function all(): iterable
{
yield from $this->algorithms;
}
public function generate(string ...$aliases): Manager
{
$manager = Manager::create();
foreach ($aliases as $alias) {
if (! array_key_exists($alias, $this->algorithms)) {
throw new InvalidArgumentException(sprintf('The algorithm with alias "%s" is not supported', $alias));
}
$manager->add($this->algorithms[$alias]);
}
return $manager;
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\ECDSA;
use Cose\Algorithm\Signature\Signature;
use Cose\Key\Ec2Key;
use Cose\Key\Key;
use InvalidArgumentException;
use function openssl_sign;
use function openssl_verify;
/**
* @see \Cose\Tests\Algorithm\Signature\ECDSA\ECDSATest
*/
abstract class ECDSA implements Signature
{
public function sign(string $data, Key $key): string
{
$key = $this->handleKey($key);
openssl_sign($data, $signature, $key->asPEM(), $this->getHashAlgorithm());
return ECSignature::fromAsn1($signature, $this->getSignaturePartLength());
}
public function verify(string $data, Key $key, string $signature): bool
{
$key = $this->handleKey($key);
$publicKey = $key->toPublic();
$signature = ECSignature::toAsn1($signature, $this->getSignaturePartLength());
return openssl_verify($data, $signature, $publicKey->asPEM(), $this->getHashAlgorithm()) === 1;
}
abstract protected function getCurve(): int;
abstract protected function getHashAlgorithm(): int;
abstract protected function getSignaturePartLength(): int;
private function handleKey(Key $key): Ec2Key
{
$key = Ec2Key::create($key->getData());
if ($key->curve() !== $this->getCurve()) {
throw new InvalidArgumentException('This key cannot be used with this algorithm');
}
return $key;
}
}

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\ECDSA;
use InvalidArgumentException;
use function bin2hex;
use function dechex;
use function hex2bin;
use function hexdec;
use function mb_strlen;
use function mb_substr;
use function str_pad;
use const STR_PAD_LEFT;
/**
* @internal
*/
final class ECSignature
{
private const ASN1_SEQUENCE = '30';
private const ASN1_INTEGER = '02';
private const ASN1_MAX_SINGLE_BYTE = 128;
private const ASN1_LENGTH_2BYTES = '81';
private const ASN1_BIG_INTEGER_LIMIT = '7f';
private const ASN1_NEGATIVE_INTEGER = '00';
private const BYTE_SIZE = 2;
public static function toAsn1(string $signature, int $length): string
{
$signature = bin2hex($signature);
if (self::octetLength($signature) !== $length) {
throw new InvalidArgumentException('Invalid signature length.');
}
$pointR = self::preparePositiveInteger(mb_substr($signature, 0, $length, '8bit'));
$pointS = self::preparePositiveInteger(mb_substr($signature, $length, null, '8bit'));
$lengthR = self::octetLength($pointR);
$lengthS = self::octetLength($pointS);
$totalLength = $lengthR + $lengthS + self::BYTE_SIZE + self::BYTE_SIZE;
$lengthPrefix = $totalLength > self::ASN1_MAX_SINGLE_BYTE ? self::ASN1_LENGTH_2BYTES : '';
return hex2bin(
self::ASN1_SEQUENCE
. $lengthPrefix . dechex($totalLength)
. self::ASN1_INTEGER . dechex($lengthR) . $pointR
. self::ASN1_INTEGER . dechex($lengthS) . $pointS
);
}
public static function fromAsn1(string $signature, int $length): string
{
$message = bin2hex($signature);
$position = 0;
if (self::readAsn1Content($message, $position, self::BYTE_SIZE) !== self::ASN1_SEQUENCE) {
throw new InvalidArgumentException('Invalid data. Should start with a sequence.');
}
// @phpstan-ignore-next-line
if (self::readAsn1Content($message, $position, self::BYTE_SIZE) === self::ASN1_LENGTH_2BYTES) {
$position += self::BYTE_SIZE;
}
$pointR = self::retrievePositiveInteger(self::readAsn1Integer($message, $position));
$pointS = self::retrievePositiveInteger(self::readAsn1Integer($message, $position));
return hex2bin(str_pad($pointR, $length, '0', STR_PAD_LEFT) . str_pad($pointS, $length, '0', STR_PAD_LEFT));
}
private static function octetLength(string $data): int
{
return intdiv(mb_strlen($data, '8bit'), self::BYTE_SIZE);
}
private static function preparePositiveInteger(string $data): string
{
if (mb_substr($data, 0, self::BYTE_SIZE, '8bit') > self::ASN1_BIG_INTEGER_LIMIT) {
return self::ASN1_NEGATIVE_INTEGER . $data;
}
while (
mb_strpos($data, self::ASN1_NEGATIVE_INTEGER, 0, '8bit') === 0
&& mb_substr($data, 2, self::BYTE_SIZE, '8bit') <= self::ASN1_BIG_INTEGER_LIMIT
) {
$data = mb_substr($data, 2, null, '8bit');
}
return $data;
}
private static function readAsn1Content(string $message, int &$position, int $length): string
{
$content = mb_substr($message, $position, $length, '8bit');
$position += $length;
return $content;
}
private static function readAsn1Integer(string $message, int &$position): string
{
if (self::readAsn1Content($message, $position, self::BYTE_SIZE) !== self::ASN1_INTEGER) {
throw new InvalidArgumentException('Invalid data. Should contain an integer.');
}
$length = (int) hexdec(self::readAsn1Content($message, $position, self::BYTE_SIZE));
return self::readAsn1Content($message, $position, $length * self::BYTE_SIZE);
}
private static function retrievePositiveInteger(string $data): string
{
while (
mb_strpos($data, self::ASN1_NEGATIVE_INTEGER, 0, '8bit') === 0
&& mb_substr($data, 2, self::BYTE_SIZE, '8bit') > self::ASN1_BIG_INTEGER_LIMIT
) {
$data = mb_substr($data, 2, null, '8bit');
}
return $data;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\ECDSA;
use Cose\Key\Ec2Key;
use const OPENSSL_ALGO_SHA256;
final class ES256 extends ECDSA
{
public const ID = -7;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): int
{
return OPENSSL_ALGO_SHA256;
}
protected function getCurve(): int
{
return Ec2Key::CURVE_P256;
}
protected function getSignaturePartLength(): int
{
return 64;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\ECDSA;
use Cose\Key\Ec2Key;
use const OPENSSL_ALGO_SHA256;
final class ES256K extends ECDSA
{
public const ID = -46;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): int
{
return OPENSSL_ALGO_SHA256;
}
protected function getCurve(): int
{
return Ec2Key::CURVE_P256K;
}
protected function getSignaturePartLength(): int
{
return 64;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\ECDSA;
use Cose\Key\Ec2Key;
use const OPENSSL_ALGO_SHA384;
final class ES384 extends ECDSA
{
public const ID = -35;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): int
{
return OPENSSL_ALGO_SHA384;
}
protected function getCurve(): int
{
return Ec2Key::CURVE_P384;
}
protected function getSignaturePartLength(): int
{
return 96;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\ECDSA;
use Cose\Key\Ec2Key;
use const OPENSSL_ALGO_SHA512;
final class ES512 extends ECDSA
{
public const ID = -36;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): int
{
return OPENSSL_ALGO_SHA512;
}
protected function getCurve(): int
{
return Ec2Key::CURVE_P521;
}
protected function getSignaturePartLength(): int
{
return 132;
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\EdDSA;
final class Ed25519 extends EdDSA
{
public const ID = -8;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\EdDSA;
use Cose\Key\Key;
final class Ed256 extends EdDSA
{
public const ID = -260;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
public function sign(string $data, Key $key): string
{
$hashedData = hash('sha256', $data, true);
return parent::sign($hashedData, $key);
}
public function verify(string $data, Key $key, string $signature): bool
{
$hashedData = hash('sha256', $data, true);
return parent::verify($hashedData, $key, $signature);
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\EdDSA;
use Cose\Key\Key;
final class Ed512 extends EdDSA
{
public const ID = -261;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
public function sign(string $data, Key $key): string
{
$hashedData = hash('sha512', $data, true);
return parent::sign($hashedData, $key);
}
public function verify(string $data, Key $key, string $signature): bool
{
$hashedData = hash('sha512', $data, true);
return parent::verify($hashedData, $key, $signature);
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\EdDSA;
use Cose\Algorithm\Signature\Signature;
use Cose\Algorithms;
use Cose\Key\Key;
use Cose\Key\OkpKey;
use InvalidArgumentException;
use Throwable;
use function sodium_crypto_sign_detached;
use function sodium_crypto_sign_verify_detached;
/**
* @see \Cose\Tests\Algorithm\Signature\EdDSA\EdDSATest
*/
class EdDSA implements Signature
{
public function sign(string $data, Key $key): string
{
$key = $this->handleKey($key);
if (! $key->isPrivate()) {
throw new InvalidArgumentException('The key is not private.');
}
$x = $key->x();
$d = $key->d();
$secret = $d . $x;
return match ($key->curve()) {
OkpKey::CURVE_ED25519 => sodium_crypto_sign_detached($data, $secret),
OkpKey::CURVE_NAME_ED25519 => sodium_crypto_sign_detached($data, $secret),
default => throw new InvalidArgumentException('Unsupported curve'),
};
}
public function verify(string $data, Key $key, string $signature): bool
{
$key = $this->handleKey($key);
if ($key->curve() !== OkpKey::CURVE_ED25519 && $key->curve() !== OkpKey::CURVE_NAME_ED25519) {
throw new InvalidArgumentException('Unsupported curve');
}
try {
sodium_crypto_sign_verify_detached($signature, $data, $key->x());
} catch (Throwable) {
return false;
}
return true;
}
public static function identifier(): int
{
return Algorithms::COSE_ALGORITHM_EDDSA;
}
private function handleKey(Key $key): OkpKey
{
return OkpKey::create($key->getData());
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use Cose\Hash;
final class PS256 extends PSSRSA
{
public const ID = -37;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): Hash
{
return Hash::sha256();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use Cose\Hash;
final class PS384 extends PSSRSA
{
public const ID = -38;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): Hash
{
return Hash::sha384();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use Cose\Hash;
final class PS512 extends PSSRSA
{
public const ID = -39;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): Hash
{
return Hash::sha512();
}
}

View File

@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use Cose\Algorithm\Signature\Signature;
use Cose\BigInteger;
use Cose\Hash;
use Cose\Key\Key;
use Cose\Key\RsaKey;
use InvalidArgumentException;
use RuntimeException;
use function ceil;
use function chr;
use function hash_equals;
use function mb_strlen;
use function mb_substr;
use function ord;
use function pack;
use function random_bytes;
use function str_pad;
use function str_repeat;
use const STR_PAD_LEFT;
/**
* @internal
*/
abstract class PSSRSA implements Signature
{
public function sign(string $data, Key $key): string
{
$key = $this->handleKey($key);
$modulusLength = mb_strlen($key->n(), '8bit');
$em = $this->encodeEMSAPSS($data, 8 * $modulusLength - 1, $this->getHashAlgorithm());
$message = BigInteger::createFromBinaryString($em);
$signature = $this->exponentiate($key, $message);
return $this->convertIntegerToOctetString($signature, $modulusLength);
}
public function verify(string $data, Key $key, string $signature): bool
{
$key = $this->handleKey($key);
$modulusLength = mb_strlen($key->n(), '8bit');
if (mb_strlen($signature, '8bit') !== $modulusLength) {
throw new InvalidArgumentException('Invalid modulus length');
}
$s2 = BigInteger::createFromBinaryString($signature);
$m2 = $this->exponentiate($key, $s2);
$em = $this->convertIntegerToOctetString($m2, $modulusLength);
$modBits = 8 * $modulusLength;
return $this->verifyEMSAPSS($data, $em, $modBits - 1, $this->getHashAlgorithm());
}
/**
* Exponentiate with or without Chinese Remainder Theorem. Operation with primes 'p' and 'q' is appox. 2x faster.
*/
public function exponentiate(RsaKey $key, BigInteger $c): BigInteger
{
if ($c->compare(BigInteger::createFromDecimal(0)) < 0 || $c->compare(
BigInteger::createFromBinaryString($key->n())
) > 0) {
throw new RuntimeException();
}
if ($key->isPublic() || ! $key->hasPrimes() || ! $key->hasExponents() || ! $key->hasCoefficient()) {
return $c->modPow(
BigInteger::createFromBinaryString($key->e()),
BigInteger::createFromBinaryString($key->n())
);
}
[$pS, $qS] = $key->primes();
[$dPS, $dQS] = $key->exponents();
$qInv = BigInteger::createFromBinaryString($key->QInv());
$p = BigInteger::createFromBinaryString($pS);
$q = BigInteger::createFromBinaryString($qS);
$dP = BigInteger::createFromBinaryString($dPS);
$dQ = BigInteger::createFromBinaryString($dQS);
$m1 = $c->modPow($dP, $p);
$m2 = $c->modPow($dQ, $q);
$h = $qInv->multiply($m1->subtract($m2)->add($p))
->mod($p)
;
return $m2->add($h->multiply($q));
}
abstract protected function getHashAlgorithm(): Hash;
private function handleKey(Key $key): RsaKey
{
return RsaKey::create($key->getData());
}
private function convertIntegerToOctetString(BigInteger $x, int $xLen): string
{
$xB = $x->toBytes();
if (mb_strlen($xB, '8bit') > $xLen) {
throw new RuntimeException('Unable to convert the integer');
}
return str_pad($xB, $xLen, chr(0), STR_PAD_LEFT);
}
/**
* MGF1.
*/
private function getMGF1(string $mgfSeed, int $maskLen, Hash $mgfHash): string
{
$t = '';
$count = ceil($maskLen / $mgfHash->getLength());
for ($i = 0; $i < $count; ++$i) {
$c = pack('N', $i);
$t .= $mgfHash->hash($mgfSeed . $c);
}
return mb_substr($t, 0, $maskLen, '8bit');
}
/**
* EMSA-PSS-ENCODE.
*/
private function encodeEMSAPSS(string $message, int $modulusLength, Hash $hash): string
{
$emLen = ($modulusLength + 1) >> 3;
$sLen = $hash->getLength();
$mHash = $hash->hash($message);
if ($emLen <= $hash->getLength() + $sLen + 2) {
throw new RuntimeException();
}
$salt = random_bytes($sLen);
$m2 = "\0\0\0\0\0\0\0\0" . $mHash . $salt;
$h = $hash->hash($m2);
$ps = str_repeat(chr(0), $emLen - $sLen - $hash->getLength() - 2);
$db = $ps . chr(1) . $salt;
$dbMask = $this->getMGF1($h, $emLen - $hash->getLength() - 1, $hash);
$maskedDB = $db ^ $dbMask;
$maskedDB[0] = ~chr(0xFF << ($modulusLength & 7)) & $maskedDB[0];
return $maskedDB . $h . chr(0xBC);
}
/**
* EMSA-PSS-VERIFY.
*/
private function verifyEMSAPSS(string $m, string $em, int $emBits, Hash $hash): bool
{
$emLen = ($emBits + 1) >> 3;
$sLen = $hash->getLength();
$mHash = $hash->hash($m);
if ($emLen < $hash->getLength() + $sLen + 2) {
throw new InvalidArgumentException();
}
if ($em[mb_strlen($em, '8bit') - 1] !== chr(0xBC)) {
throw new InvalidArgumentException();
}
$maskedDB = mb_substr($em, 0, -$hash->getLength() - 1, '8bit');
$h = mb_substr($em, -$hash->getLength() - 1, $hash->getLength(), '8bit');
$temp = chr(0xFF << ($emBits & 7));
if ((~$maskedDB[0] & $temp) !== $temp) {
throw new InvalidArgumentException();
}
$dbMask = $this->getMGF1($h, $emLen - $hash->getLength() - 1, $hash/*MGF*/);
$db = $maskedDB ^ $dbMask;
$db[0] = ~chr(0xFF << ($emBits & 7)) & $db[0];
$temp = $emLen - $hash->getLength() - $sLen - 2;
if (mb_strpos($db, str_repeat(chr(0), $temp), 0, '8bit') !== 0) {
throw new InvalidArgumentException();
}
if (ord($db[$temp]) !== 1) {
throw new InvalidArgumentException();
}
$salt = mb_substr($db, $temp + 1, null, '8bit'); // should be $sLen long
$m2 = "\0\0\0\0\0\0\0\0" . $mHash . $salt;
$h2 = $hash->hash($m2);
return hash_equals($h, $h2);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use const OPENSSL_ALGO_SHA1;
final class RS1 extends RSA
{
public const ID = -65535;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): int
{
return OPENSSL_ALGO_SHA1;
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use const OPENSSL_ALGO_SHA256;
final class RS256 extends RSA
{
public const ID = -257;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): int
{
return OPENSSL_ALGO_SHA256;
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use const OPENSSL_ALGO_SHA384;
final class RS384 extends RSA
{
public const ID = -258;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): int
{
return OPENSSL_ALGO_SHA384;
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use const OPENSSL_ALGO_SHA512;
final class RS512 extends RSA
{
public const ID = -259;
public static function create(): self
{
return new self();
}
public static function identifier(): int
{
return self::ID;
}
protected function getHashAlgorithm(): int
{
return OPENSSL_ALGO_SHA512;
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature\RSA;
use Cose\Algorithm\Signature\Signature;
use Cose\Key\Key;
use Cose\Key\RsaKey;
use InvalidArgumentException;
use Throwable;
use function openssl_sign;
use function openssl_verify;
/**
* @see \Cose\Tests\Algorithm\Signature\RSA\RSATest
*/
abstract class RSA implements Signature
{
public function sign(string $data, Key $key): string
{
$key = $this->handleKey($key);
if (! $key->isPrivate()) {
throw new InvalidArgumentException('The key is not private.');
}
try {
openssl_sign($data, $signature, $key->asPem(), $this->getHashAlgorithm());
} catch (Throwable $e) {
throw new InvalidArgumentException('Unable to sign the data', 0, $e);
}
return $signature;
}
public function verify(string $data, Key $key, string $signature): bool
{
$key = $this->handleKey($key);
return openssl_verify($data, $signature, $key->toPublic()->asPem(), $this->getHashAlgorithm()) === 1;
}
abstract protected function getHashAlgorithm(): int;
private function handleKey(Key $key): RsaKey
{
return RsaKey::create($key->getData());
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Cose\Algorithm\Signature;
use Cose\Algorithm\Algorithm;
use Cose\Key\Key;
interface Signature extends Algorithm
{
public function sign(string $data, Key $key): string;
public function verify(string $data, Key $key, string $signature): bool;
}

View File

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Cose;
use InvalidArgumentException;
use function array_key_exists;
use const OPENSSL_ALGO_SHA1;
use const OPENSSL_ALGO_SHA256;
use const OPENSSL_ALGO_SHA384;
use const OPENSSL_ALGO_SHA512;
/**
* @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms
*/
abstract class Algorithms
{
final public const COSE_ALGORITHM_AES_CCM_64_128_256 = 33;
final public const COSE_ALGORITHM_AES_CCM_64_128_128 = 32;
final public const COSE_ALGORITHM_AES_CCM_16_128_256 = 31;
final public const COSE_ALGORITHM_AES_CCM_16_128_128 = 30;
final public const COSE_ALGORITHM_AES_MAC_256_128 = 26;
final public const COSE_ALGORITHM_AES_MAC_128_128 = 25;
final public const COSE_ALGORITHM_CHACHA20_POLY1305 = 24;
final public const COSE_ALGORITHM_AES_MAC_256_64 = 15;
final public const COSE_ALGORITHM_AES_MAC_128_64 = 14;
final public const COSE_ALGORITHM_AES_CCM_64_64_256 = 13;
final public const COSE_ALGORITHM_AES_CCM_64_64_128 = 12;
final public const COSE_ALGORITHM_AES_CCM_16_64_256 = 11;
final public const COSE_ALGORITHM_AES_CCM_16_64_128 = 10;
final public const COSE_ALGORITHM_HS512 = 7;
final public const COSE_ALGORITHM_HS384 = 6;
final public const COSE_ALGORITHM_HS256 = 5;
final public const COSE_ALGORITHM_HS256_64 = 4;
final public const COSE_ALGORITHM_A256GCM = 3;
final public const COSE_ALGORITHM_A192GCM = 2;
final public const COSE_ALGORITHM_A128GCM = 1;
final public const COSE_ALGORITHM_A128KW = -3;
final public const COSE_ALGORITHM_A192KW = -4;
final public const COSE_ALGORITHM_A256KW = -5;
final public const COSE_ALGORITHM_DIRECT = -6;
final public const COSE_ALGORITHM_ES256 = -7;
/**
* @deprecated since v4.0.6. Please use COSE_ALGORITHM_EDDSA instead. Will be removed in v5.0.0
*/
final public const COSE_ALGORITHM_EdDSA = -8;
final public const COSE_ALGORITHM_EDDSA = -8;
final public const COSE_ALGORITHM_ED256 = -260;
final public const COSE_ALGORITHM_ED512 = -261;
final public const COSE_ALGORITHM_DIRECT_HKDF_SHA_256 = -10;
final public const COSE_ALGORITHM_DIRECT_HKDF_SHA_512 = -11;
final public const COSE_ALGORITHM_DIRECT_HKDF_AES_128 = -12;
final public const COSE_ALGORITHM_DIRECT_HKDF_AES_256 = -13;
final public const COSE_ALGORITHM_ECDH_ES_HKDF_256 = -25;
final public const COSE_ALGORITHM_ECDH_ES_HKDF_512 = -26;
final public const COSE_ALGORITHM_ECDH_SS_HKDF_256 = -27;
final public const COSE_ALGORITHM_ECDH_SS_HKDF_512 = -28;
final public const COSE_ALGORITHM_ECDH_ES_A128KW = -29;
final public const COSE_ALGORITHM_ECDH_ES_A192KW = -30;
final public const COSE_ALGORITHM_ECDH_ES_A256KW = -31;
final public const COSE_ALGORITHM_ECDH_SS_A128KW = -32;
final public const COSE_ALGORITHM_ECDH_SS_A192KW = -33;
final public const COSE_ALGORITHM_ECDH_SS_A256KW = -34;
final public const COSE_ALGORITHM_ES384 = -35;
final public const COSE_ALGORITHM_ES512 = -36;
final public const COSE_ALGORITHM_PS256 = -37;
final public const COSE_ALGORITHM_PS384 = -38;
final public const COSE_ALGORITHM_PS512 = -39;
final public const COSE_ALGORITHM_RSAES_OAEP = -40;
final public const COSE_ALGORITHM_RSAES_OAEP_256 = -41;
final public const COSE_ALGORITHM_RSAES_OAEP_512 = -42;
final public const COSE_ALGORITHM_ES256K = -46;
final public const COSE_ALGORITHM_RS256 = -257;
final public const COSE_ALGORITHM_RS384 = -258;
final public const COSE_ALGORITHM_RS512 = -259;
final public const COSE_ALGORITHM_RS1 = -65535;
final public const COSE_ALGORITHM_MAP = [
self::COSE_ALGORITHM_ES256 => OPENSSL_ALGO_SHA256,
self::COSE_ALGORITHM_ES384 => OPENSSL_ALGO_SHA384,
self::COSE_ALGORITHM_ES512 => OPENSSL_ALGO_SHA512,
self::COSE_ALGORITHM_RS256 => OPENSSL_ALGO_SHA256,
self::COSE_ALGORITHM_RS384 => OPENSSL_ALGO_SHA384,
self::COSE_ALGORITHM_RS512 => OPENSSL_ALGO_SHA512,
self::COSE_ALGORITHM_RS1 => OPENSSL_ALGO_SHA1,
];
final public const COSE_HASH_MAP = [
self::COSE_ALGORITHM_ES256K => 'sha256',
self::COSE_ALGORITHM_ES256 => 'sha256',
self::COSE_ALGORITHM_ES384 => 'sha384',
self::COSE_ALGORITHM_ES512 => 'sha512',
self::COSE_ALGORITHM_RS256 => 'sha256',
self::COSE_ALGORITHM_RS384 => 'sha384',
self::COSE_ALGORITHM_RS512 => 'sha512',
self::COSE_ALGORITHM_PS256 => 'sha256',
self::COSE_ALGORITHM_PS384 => 'sha384',
self::COSE_ALGORITHM_PS512 => 'sha512',
self::COSE_ALGORITHM_RS1 => 'sha1',
];
public static function getOpensslAlgorithmFor(int $algorithmIdentifier): int
{
if (! array_key_exists($algorithmIdentifier, self::COSE_ALGORITHM_MAP)) {
throw new InvalidArgumentException('The specified algorithm identifier is not supported');
}
return self::COSE_ALGORITHM_MAP[$algorithmIdentifier];
}
public static function getHashAlgorithmFor(int $algorithmIdentifier): string
{
if (! array_key_exists($algorithmIdentifier, self::COSE_HASH_MAP)) {
throw new InvalidArgumentException('The specified algorithm identifier is not supported');
}
return self::COSE_HASH_MAP[$algorithmIdentifier];
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Cose;
use Brick\Math\BigInteger as BrickBigInteger;
use function chr;
use function hex2bin;
use function unpack;
/**
* @internal
*/
final class BigInteger
{
private function __construct(
private readonly BrickBigInteger $value
) {
}
public static function createFromBinaryString(string $value): self
{
$res = unpack('H*', $value);
$data = current($res);
return new self(BrickBigInteger::fromBase($data, 16));
}
public static function createFromDecimal(int $value): self
{
return new self(BrickBigInteger::of($value));
}
/**
* Converts a BigInteger to a binary string.
*/
public function toBytes(): string
{
if ($this->value->isEqualTo(BrickBigInteger::zero())) {
return '';
}
$temp = $this->value->toBase(16);
$temp = 0 !== (mb_strlen($temp, '8bit') & 1) ? '0' . $temp : $temp;
$temp = hex2bin($temp);
return ltrim($temp, chr(0));
}
/**
* Adds two BigIntegers.
*/
public function add(self $y): self
{
$value = $this->value->plus($y->value);
return new self($value);
}
/**
* Subtracts two BigIntegers.
*/
public function subtract(self $y): self
{
$value = $this->value->minus($y->value);
return new self($value);
}
/**
* Multiplies two BigIntegers.
*/
public function multiply(self $x): self
{
$value = $this->value->multipliedBy($x->value);
return new self($value);
}
/**
* Performs modular exponentiation.
*/
public function modPow(self $e, self $n): self
{
$value = $this->value->modPow($e->value, $n->value);
return new self($value);
}
/**
* Performs modular exponentiation.
*/
public function mod(self $d): self
{
$value = $this->value->mod($d->value);
return new self($value);
}
/**
* Compares two numbers.
*/
public function compare(self $y): int
{
return $this->value->compareTo($y->value);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Cose;
/**
* @internal
*/
final class Hash
{
private function __construct(
private readonly string $hash,
private readonly int $length,
private readonly string $t
) {
}
public static function sha1(): self
{
return new self('sha1', 20, "\x30\x21\x30\x09\x06\x05\x2b\x0e\x03\x02\x1a\x05\x00\x04\x14");
}
public static function sha256(): self
{
return new self('sha256', 32, "\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20");
}
public static function sha384(): self
{
return new self('sha384', 48, "\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x02\x05\x00\x04\x30");
}
public static function sha512(): self
{
return new self('sha512', 64, "\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40");
}
public function getLength(): int
{
return $this->length;
}
/**
* Compute the HMAC.
*/
public function hash(string $text): string
{
return hash($this->hash, $text, true);
}
public function name(): string
{
return $this->hash;
}
public function t(): string
{
return $this->t;
}
}

View File

@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace Cose\Key;
use InvalidArgumentException;
use SpomkyLabs\Pki\ASN1\Type\Constructed\Sequence;
use SpomkyLabs\Pki\ASN1\Type\Primitive\BitString;
use SpomkyLabs\Pki\ASN1\Type\Primitive\Integer;
use SpomkyLabs\Pki\ASN1\Type\Primitive\ObjectIdentifier;
use SpomkyLabs\Pki\ASN1\Type\Primitive\OctetString;
use SpomkyLabs\Pki\ASN1\Type\Tagged\ExplicitlyTaggedType;
use function array_key_exists;
use function in_array;
use function is_int;
/**
* @final
* @see \Cose\Tests\Key\Ec2KeyTest
*/
class Ec2Key extends Key
{
final public const CURVE_P256 = 1;
final public const CURVE_P256K = 8;
final public const CURVE_P384 = 2;
final public const CURVE_P521 = 3;
final public const CURVE_NAME_P256 = 'P-256';
final public const CURVE_NAME_P256K = 'P-256K';
final public const CURVE_NAME_P384 = 'P-384';
final public const CURVE_NAME_P521 = 'P-521';
final public const DATA_CURVE = -1;
final public const DATA_X = -2;
final public const DATA_Y = -3;
final public const DATA_D = -4;
private const SUPPORTED_CURVES_INT = [self::CURVE_P256, self::CURVE_P256K, self::CURVE_P384, self::CURVE_P521];
private const SUPPORTED_CURVES_NAMES = [
self::CURVE_NAME_P256,
self::CURVE_NAME_P256K,
self::CURVE_NAME_P384,
self::CURVE_NAME_P521,
];
private const NAMED_CURVE_OID = [
self::CURVE_P256 => '1.2.840.10045.3.1.7',
// NIST P-256 / secp256r1
self::CURVE_P256K => '1.3.132.0.10',
// NIST P-256K / secp256k1
self::CURVE_P384 => '1.3.132.0.34',
// NIST P-384 / secp384r1
self::CURVE_P521 => '1.3.132.0.35',
// NIST P-521 / secp521r1
];
private const CURVE_KEY_LENGTH = [
self::CURVE_P256 => 32,
self::CURVE_P256K => 32,
self::CURVE_P384 => 48,
self::CURVE_P521 => 66,
self::CURVE_NAME_P256 => 32,
self::CURVE_NAME_P256K => 32,
self::CURVE_NAME_P384 => 48,
self::CURVE_NAME_P521 => 66,
];
/**
* @param array<int|string, mixed> $data
*/
public function __construct(array $data)
{
foreach ([self::DATA_CURVE, self::TYPE] as $key) {
if (is_numeric($data[$key])) {
$data[$key] = (int) $data[$key];
}
}
parent::__construct($data);
if ($data[self::TYPE] !== self::TYPE_EC2 && $data[self::TYPE] !== self::TYPE_NAME_EC2) {
throw new InvalidArgumentException('Invalid EC2 key. The key type does not correspond to an EC2 key');
}
if (! isset($data[self::DATA_CURVE], $data[self::DATA_X], $data[self::DATA_Y])) {
throw new InvalidArgumentException('Invalid EC2 key. The curve or the "x/y" coordinates are missing');
}
if (mb_strlen((string) $data[self::DATA_X], '8bit') !== self::CURVE_KEY_LENGTH[$data[self::DATA_CURVE]]) {
throw new InvalidArgumentException('Invalid length for x coordinate');
}
if (mb_strlen((string) $data[self::DATA_Y], '8bit') !== self::CURVE_KEY_LENGTH[$data[self::DATA_CURVE]]) {
throw new InvalidArgumentException('Invalid length for y coordinate');
}
if (is_int($data[self::DATA_CURVE])) {
if (! in_array($data[self::DATA_CURVE], self::SUPPORTED_CURVES_INT, true)) {
throw new InvalidArgumentException('The curve is not supported');
}
} elseif (! in_array($data[self::DATA_CURVE], self::SUPPORTED_CURVES_NAMES, true)) {
throw new InvalidArgumentException('The curve is not supported');
}
}
/**
* @param array<int|string, mixed> $data
*/
public static function create(array $data): self
{
return new self($data);
}
public function toPublic(): self
{
$data = $this->getData();
unset($data[self::DATA_D]);
return new self($data);
}
public function x(): string
{
return $this->get(self::DATA_X);
}
public function y(): string
{
return $this->get(self::DATA_Y);
}
public function isPrivate(): bool
{
return array_key_exists(self::DATA_D, $this->getData());
}
public function d(): string
{
if (! $this->isPrivate()) {
throw new InvalidArgumentException('The key is not private.');
}
return $this->get(self::DATA_D);
}
public function curve(): int|string
{
return $this->get(self::DATA_CURVE);
}
public function asPEM(): string
{
if ($this->isPrivate()) {
$der = Sequence::create(
Integer::create(1),
OctetString::create($this->d()),
ExplicitlyTaggedType::create(0, ObjectIdentifier::create($this->getCurveOid())),
ExplicitlyTaggedType::create(1, BitString::create($this->getUncompressedCoordinates())),
);
return $this->pem('EC PRIVATE KEY', $der->toDER());
}
$der = Sequence::create(
Sequence::create(
ObjectIdentifier::create('1.2.840.10045.2.1'),
ObjectIdentifier::create($this->getCurveOid())
),
BitString::create($this->getUncompressedCoordinates())
);
return $this->pem('PUBLIC KEY', $der->toDER());
}
public function getUncompressedCoordinates(): string
{
return "\x04" . $this->x() . $this->y();
}
private function getCurveOid(): string
{
return self::NAMED_CURVE_OID[$this->curve()];
}
private function pem(string $type, string $der): string
{
return sprintf("-----BEGIN %s-----\n", mb_strtoupper($type)) .
chunk_split(base64_encode($der), 64, "\n") .
sprintf("-----END %s-----\n", mb_strtoupper($type));
}
}

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Cose\Key;
use InvalidArgumentException;
use function array_key_exists;
class Key
{
public const TYPE = 1;
public const TYPE_OKP = 1;
public const TYPE_EC2 = 2;
public const TYPE_RSA = 3;
public const TYPE_OCT = 4;
public const TYPE_NAME_OKP = 'OKP';
public const TYPE_NAME_EC2 = 'EC';
public const TYPE_NAME_RSA = 'RSA';
public const TYPE_NAME_OCT = 'oct';
public const KID = 2;
public const ALG = 3;
public const KEY_OPS = 4;
public const BASE_IV = 5;
/**
* @var array<int|string, mixed>
*/
private readonly array $data;
/**
* @param array<int|string, mixed> $data
*/
public function __construct(array $data)
{
if (! array_key_exists(self::TYPE, $data)) {
throw new InvalidArgumentException('Invalid key: the type is not defined');
}
$this->data = $data;
}
/**
* @param array<int|string, mixed> $data
*/
public static function create(array $data): self
{
return new self($data);
}
/**
* @param array<int, mixed> $data
*/
public static function createFromData(array $data): self
{
if (! array_key_exists(self::TYPE, $data)) {
throw new InvalidArgumentException('Invalid key: the type is not defined');
}
return match ($data[self::TYPE]) {
'1' => new OkpKey($data),
'2' => new Ec2Key($data),
'3' => new RsaKey($data),
'4' => new SymmetricKey($data),
default => self::create($data),
};
}
public function type(): int|string
{
return $this->data[self::TYPE];
}
public function alg(): int
{
return (int) $this->get(self::ALG);
}
/**
* @return array<int|string, mixed>
*/
public function getData(): array
{
return $this->data;
}
public function has(int|string $key): bool
{
return array_key_exists($key, $this->data);
}
public function get(int|string $key): mixed
{
if (! array_key_exists($key, $this->data)) {
throw new InvalidArgumentException(sprintf('The key has no data at index %d', $key));
}
return $this->data[$key];
}
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Cose\Key;
use InvalidArgumentException;
use function array_key_exists;
use function in_array;
/**
* @final
* @see \Cose\Tests\Key\OkpKeyTest
*/
class OkpKey extends Key
{
final public const CURVE_X25519 = 4;
final public const CURVE_X448 = 5;
final public const CURVE_ED25519 = 6;
final public const CURVE_ED448 = 7;
final public const CURVE_NAME_X25519 = 'X25519';
final public const CURVE_NAME_X448 = 'X448';
final public const CURVE_NAME_ED25519 = 'Ed25519';
final public const CURVE_NAME_ED448 = 'Ed448';
final public const DATA_CURVE = -1;
final public const DATA_X = -2;
final public const DATA_D = -4;
private const SUPPORTED_CURVES_INT = [
self::CURVE_X25519,
self::CURVE_X448,
self::CURVE_ED25519,
self::CURVE_ED448,
];
private const SUPPORTED_CURVES_NAME = [
self::CURVE_NAME_X25519,
self::CURVE_NAME_X448,
self::CURVE_NAME_ED25519,
self::CURVE_NAME_ED448,
];
/**
* @param array<int|string, mixed> $data
*/
public function __construct(array $data)
{
foreach ([self::DATA_CURVE, self::TYPE] as $key) {
if (is_numeric($data[$key])) {
$data[$key] = (int) $data[$key];
}
}
parent::__construct($data);
if ($data[self::TYPE] !== self::TYPE_OKP && $data[self::TYPE] !== self::TYPE_NAME_OKP) {
throw new InvalidArgumentException('Invalid OKP key. The key type does not correspond to an OKP key');
}
if (! isset($data[self::DATA_CURVE], $data[self::DATA_X])) {
throw new InvalidArgumentException('Invalid EC2 key. The curve or the "x" coordinate is missing');
}
if (is_numeric($data[self::DATA_CURVE])) {
if (! in_array((int) $data[self::DATA_CURVE], self::SUPPORTED_CURVES_INT, true)) {
throw new InvalidArgumentException('The curve is not supported');
}
} elseif (! in_array($data[self::DATA_CURVE], self::SUPPORTED_CURVES_NAME, true)) {
throw new InvalidArgumentException('The curve is not supported');
}
}
/**
* @param array<int|string, mixed> $data
*/
public static function create(array $data): self
{
return new self($data);
}
public function x(): string
{
return $this->get(self::DATA_X);
}
public function isPrivate(): bool
{
return array_key_exists(self::DATA_D, $this->getData());
}
public function d(): string
{
if (! $this->isPrivate()) {
throw new InvalidArgumentException('The key is not private.');
}
return $this->get(self::DATA_D);
}
public function curve(): int|string
{
return $this->get(self::DATA_CURVE);
}
}

View File

@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace Cose\Key;
use Brick\Math\BigInteger;
use InvalidArgumentException;
use SpomkyLabs\Pki\CryptoTypes\Asymmetric\PublicKeyInfo;
use SpomkyLabs\Pki\CryptoTypes\Asymmetric\RSA\RSAPrivateKey;
use SpomkyLabs\Pki\CryptoTypes\Asymmetric\RSA\RSAPublicKey;
use function array_key_exists;
use function in_array;
/**
* @final
* @see \Cose\Tests\Key\RsaKeyTest
*/
class RsaKey extends Key
{
final public const DATA_N = -1;
final public const DATA_E = -2;
final public const DATA_D = -3;
final public const DATA_P = -4;
final public const DATA_Q = -5;
final public const DATA_DP = -6;
final public const DATA_DQ = -7;
final public const DATA_QI = -8;
final public const DATA_OTHER = -9;
final public const DATA_RI = -10;
final public const DATA_DI = -11;
final public const DATA_TI = -12;
/**
* @param array<int|string, mixed> $data
*/
public function __construct(array $data)
{
foreach ([self::TYPE] as $key) {
if (is_numeric($data[$key])) {
$data[$key] = (int) $data[$key];
}
}
parent::__construct($data);
if ($data[self::TYPE] !== self::TYPE_RSA && $data[self::TYPE] !== self::TYPE_NAME_RSA) {
throw new InvalidArgumentException('Invalid RSA key. The key type does not correspond to a RSA key');
}
if (! isset($data[self::DATA_N], $data[self::DATA_E])) {
throw new InvalidArgumentException('Invalid RSA key. The modulus or the exponent is missing');
}
}
/**
* @param array<int|string, mixed> $data
*/
public static function create(array $data): self
{
return new self($data);
}
public function n(): string
{
return $this->get(self::DATA_N);
}
public function e(): string
{
return $this->get(self::DATA_E);
}
public function d(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_D);
}
public function p(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_P);
}
public function q(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_Q);
}
public function dP(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_DP);
}
public function dQ(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_DQ);
}
public function QInv(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_QI);
}
/**
* @return array<mixed>
*/
public function other(): array
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_OTHER);
}
public function rI(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_RI);
}
public function dI(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_DI);
}
public function tI(): string
{
$this->checkKeyIsPrivate();
return $this->get(self::DATA_TI);
}
public function hasPrimes(): bool
{
return $this->has(self::DATA_P) && $this->has(self::DATA_Q);
}
/**
* @return string[]
*/
public function primes(): array
{
return [$this->p(), $this->q()];
}
public function hasExponents(): bool
{
return $this->has(self::DATA_DP) && $this->has(self::DATA_DQ);
}
/**
* @return string[]
*/
public function exponents(): array
{
return [$this->dP(), $this->dQ()];
}
public function hasCoefficient(): bool
{
return $this->has(self::DATA_QI);
}
public function isPublic(): bool
{
return ! $this->isPrivate();
}
public function isPrivate(): bool
{
return array_key_exists(self::DATA_D, $this->getData());
}
public function asPem(): string
{
if ($this->isPrivate()) {
$privateKey = RSAPrivateKey::create(
$this->binaryToBigInteger($this->n()),
$this->binaryToBigInteger($this->e()),
$this->binaryToBigInteger($this->d()),
$this->binaryToBigInteger($this->p()),
$this->binaryToBigInteger($this->q()),
$this->binaryToBigInteger($this->dP()),
$this->binaryToBigInteger($this->dQ()),
$this->binaryToBigInteger($this->QInv())
);
return $privateKey->toPEM()
->string();
}
$publicKey = RSAPublicKey::create(
$this->binaryToBigInteger($this->n()),
$this->binaryToBigInteger($this->e())
);
$rsaKey = PublicKeyInfo::fromPublicKey($publicKey);
return $rsaKey->toPEM()
->string();
}
public function toPublic(): static
{
$toBeRemoved = [
self::DATA_D,
self::DATA_P,
self::DATA_Q,
self::DATA_DP,
self::DATA_DQ,
self::DATA_QI,
self::DATA_OTHER,
self::DATA_RI,
self::DATA_DI,
self::DATA_TI,
];
$data = $this->getData();
foreach ($data as $k => $v) {
if (in_array($k, $toBeRemoved, true)) {
unset($data[$k]);
}
}
return new static($data);
}
private function checkKeyIsPrivate(): void
{
if (! $this->isPrivate()) {
throw new InvalidArgumentException('The key is not private.');
}
}
private function binaryToBigInteger(string $data): string
{
$res = unpack('H*', $data);
$res = current($res);
return BigInteger::fromBase($res, 16)->toBase(10);
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Cose\Key;
use InvalidArgumentException;
/**
* @final
*/
class SymmetricKey extends Key
{
final public const DATA_K = -1;
/**
* @param array<int|string, mixed> $data
*/
public function __construct(array $data)
{
parent::__construct($data);
if (! isset($data[self::TYPE]) || (int) $data[self::TYPE] !== self::TYPE_OCT) {
throw new InvalidArgumentException(
'Invalid symmetric key. The key type does not correspond to a symmetric key'
);
}
if (! isset($data[self::DATA_K])) {
throw new InvalidArgumentException('Invalid symmetric key. The parameter "k" is missing');
}
}
/**
* @param array<int|string, mixed> $data
*/
public static function create(array $data): self
{
return new self($data);
}
public function k(): string
{
return $this->get(self::DATA_K);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Webauthn\MetadataService\Statement;
use function array_key_exists;
use JsonSerializable;
use ParagonIE\ConstantTime\Base64UrlSafe;
use Webauthn\MetadataService\Exception\MetadataStatementLoadingException;
use Webauthn\MetadataService\Utils;
/**
* @deprecated since 4.2.0 and will be removed in 5.0.0. The ECDAA Trust Anchor does no longer exist in Webauthn specification.
* @final
*/
class EcdaaTrustAnchor implements JsonSerializable
{
public function __construct(
private readonly string $X,
private readonly string $Y,
private readonly string $c,
private readonly string $sx,
private readonly string $sy,
private readonly string $G1Curve
) {
}
public function getX(): string
{
return $this->X;
}
public function getY(): string
{
return $this->Y;
}
public function getC(): string
{
return $this->c;
}
public function getSx(): string
{
return $this->sx;
}
public function getSy(): string
{
return $this->sy;
}
public function getG1Curve(): string
{
return $this->G1Curve;
}
/**
* @param array<string, mixed> $data
*/
public static function createFromArray(array $data): self
{
$data = Utils::filterNullValues($data);
foreach (['X', 'Y', 'c', 'sx', 'sy', 'G1Curve'] as $key) {
array_key_exists($key, $data) || throw MetadataStatementLoadingException::create(sprintf(
'Invalid data. The key "%s" is missing',
$key
));
}
return new self(
Base64UrlSafe::decode($data['X']),
Base64UrlSafe::decode($data['Y']),
Base64UrlSafe::decode($data['c']),
Base64UrlSafe::decode($data['sx']),
Base64UrlSafe::decode($data['sy']),
$data['G1Curve']
);
}
/**
* @return array<string, string>
*/
public function jsonSerialize(): array
{
$data = [
'X' => Base64UrlSafe::encodeUnpadded($this->X),
'Y' => Base64UrlSafe::encodeUnpadded($this->Y),
'c' => Base64UrlSafe::encodeUnpadded($this->c),
'sx' => Base64UrlSafe::encodeUnpadded($this->sx),
'sy' => Base64UrlSafe::encodeUnpadded($this->sy),
'G1Curve' => $this->G1Curve,
];
return Utils::filterNullValues($data);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More