defender-security
/
lib
/
packages
/
Webauthn
/
AttestationStatement
/
TPMAttestationStatementSupport.php
TPMAttestationStatementSupport.php in Defender Security – Malware Scanner, Login Security & Firewall 6.3.0, at lib/packages/Webauthn/AttestationStatement/TPMAttestationStatementSupport.php
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /* |
| 6 | * The MIT License (MIT) |
| 7 | * |
| 8 | * Copyright (c) 2014-2021 Spomky-Labs |
| 9 | * |
| 10 | * This software may be modified and distributed under the terms |
| 11 | * of the MIT license. See the LICENSE file for details. |
| 12 | */ |
| 13 | |
| 14 | namespace WP_DEFENDER_VENDOR\Webauthn\AttestationStatement; |
| 15 | |
| 16 | use WP_DEFENDER_VENDOR\Assert\Assertion; |
| 17 | use WP_DEFENDER_VENDOR\Base64Url\Base64Url; |
| 18 | use WP_DEFENDER_VENDOR\CBOR\Decoder; |
| 19 | use WP_DEFENDER_VENDOR\CBOR\MapObject; |
| 20 | use WP_DEFENDER_VENDOR\CBOR\OtherObject\OtherObjectManager; |
| 21 | use WP_DEFENDER_VENDOR\CBOR\Tag\TagObjectManager; |
| 22 | use WP_DEFENDER_VENDOR\Cose\Algorithms; |
| 23 | use WP_DEFENDER_VENDOR\Cose\Key\Ec2Key; |
| 24 | use WP_DEFENDER_VENDOR\Cose\Key\Key; |
| 25 | use WP_DEFENDER_VENDOR\Cose\Key\OkpKey; |
| 26 | use WP_DEFENDER_VENDOR\Cose\Key\RsaKey; |
| 27 | use function count; |
| 28 | use function in_array; |
| 29 | use InvalidArgumentException; |
| 30 | use function is_array; |
| 31 | use RuntimeException; |
| 32 | use WP_DEFENDER_VENDOR\Safe\DateTimeImmutable; |
| 33 | use function WP_DEFENDER_VENDOR\Safe\sprintf; |
| 34 | use function WP_DEFENDER_VENDOR\Safe\unpack; |
| 35 | use WP_DEFENDER_VENDOR\Webauthn\AuthenticatorData; |
| 36 | use WP_DEFENDER_VENDOR\Webauthn\CertificateToolbox; |
| 37 | use WP_DEFENDER_VENDOR\Webauthn\StringStream; |
| 38 | use WP_DEFENDER_VENDOR\Webauthn\TrustPath\CertificateTrustPath; |
| 39 | use WP_DEFENDER_VENDOR\Webauthn\TrustPath\EcdaaKeyIdTrustPath; |
| 40 | |
| 41 | final class TPMAttestationStatementSupport implements AttestationStatementSupport |
| 42 | { |
| 43 | public function name(): string |
| 44 | { |
| 45 | return 'tpm'; |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * @param mixed[] $attestation |
| 50 | */ |
| 51 | public function load(array $attestation): AttestationStatement |
| 52 | { |
| 53 | Assertion::keyExists($attestation, 'attStmt', 'Invalid attestation object'); |
| 54 | Assertion::keyNotExists($attestation['attStmt'], 'ecdaaKeyId', 'ECDAA not supported'); |
| 55 | foreach (['ver', 'ver', 'sig', 'alg', 'certInfo', 'pubArea'] as $key) { |
| 56 | Assertion::keyExists($attestation['attStmt'], $key, sprintf('The attestation statement value "%s" is missing.', $key)); |
| 57 | } |
| 58 | Assertion::eq('2.0', $attestation['attStmt']['ver'], 'Invalid attestation object'); |
| 59 | |
| 60 | $certInfo = $this->checkCertInfo($attestation['attStmt']['certInfo']); |
| 61 | Assertion::eq('8017', bin2hex($certInfo['type']), 'Invalid attestation object'); |
| 62 | |
| 63 | $pubArea = $this->checkPubArea($attestation['attStmt']['pubArea']); |
| 64 | $pubAreaAttestedNameAlg = mb_substr($certInfo['attestedName'], 0, 2, '8bit'); |
| 65 | $pubAreaHash = hash($this->getTPMHash($pubAreaAttestedNameAlg), $attestation['attStmt']['pubArea'], true); |
| 66 | $attestedName = $pubAreaAttestedNameAlg.$pubAreaHash; |
| 67 | Assertion::eq($attestedName, $certInfo['attestedName'], 'Invalid attested name'); |
| 68 | |
| 69 | $attestation['attStmt']['parsedCertInfo'] = $certInfo; |
| 70 | $attestation['attStmt']['parsedPubArea'] = $pubArea; |
| 71 | |
| 72 | $certificates = CertificateToolbox::convertAllDERToPEM($attestation['attStmt']['x5c']); |
| 73 | Assertion::minCount($certificates, 1, 'The attestation statement value "x5c" must be a list with at least one certificate.'); |
| 74 | |
| 75 | return AttestationStatement::createAttCA( |
| 76 | $this->name(), |
| 77 | $attestation['attStmt'], |
| 78 | new CertificateTrustPath($certificates) |
| 79 | ); |
| 80 | } |
| 81 | |
| 82 | public function isValid(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool |
| 83 | { |
| 84 | $attToBeSigned = $authenticatorData->getAuthData().$clientDataJSONHash; |
| 85 | $attToBeSignedHash = hash(Algorithms::getHashAlgorithmFor((int) $attestationStatement->get('alg')), $attToBeSigned, true); |
| 86 | Assertion::eq($attestationStatement->get('parsedCertInfo')['extraData'], $attToBeSignedHash, 'Invalid attestation hash'); |
| 87 | $this->checkUniquePublicKey( |
| 88 | $attestationStatement->get('parsedPubArea')['unique'], |
| 89 | $authenticatorData->getAttestedCredentialData()->getCredentialPublicKey() |
| 90 | ); |
| 91 | |
| 92 | switch (true) { |
| 93 | case $attestationStatement->getTrustPath() instanceof CertificateTrustPath: |
| 94 | return $this->processWithCertificate($clientDataJSONHash, $attestationStatement, $authenticatorData); |
| 95 | case $attestationStatement->getTrustPath() instanceof EcdaaKeyIdTrustPath: |
| 96 | return $this->processWithECDAA(); |
| 97 | default: |
| 98 | throw new InvalidArgumentException('Unsupported attestation statement'); |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | private function checkUniquePublicKey(string $unique, string $cborPublicKey): void |
| 103 | { |
| 104 | $cborDecoder = new Decoder(new TagObjectManager(), new OtherObjectManager()); |
| 105 | $publicKey = $cborDecoder->decode(new StringStream($cborPublicKey)); |
| 106 | Assertion::isInstanceOf($publicKey, MapObject::class, 'Invalid public key'); |
| 107 | $key = new Key($publicKey->getNormalizedData(false)); |
| 108 | |
| 109 | switch ($key->type()) { |
| 110 | case Key::TYPE_OKP: |
| 111 | $uniqueFromKey = (new OkpKey($key->getData()))->x(); |
| 112 | break; |
| 113 | case Key::TYPE_EC2: |
| 114 | $ec2Key = new Ec2Key($key->getData()); |
| 115 | $uniqueFromKey = "\x04".$ec2Key->x().$ec2Key->y(); |
| 116 | break; |
| 117 | case Key::TYPE_RSA: |
| 118 | $uniqueFromKey = (new RsaKey($key->getData()))->n(); |
| 119 | break; |
| 120 | default: |
| 121 | throw new InvalidArgumentException('Invalid or unsupported key type.'); |
| 122 | } |
| 123 | |
| 124 | Assertion::eq($unique, $uniqueFromKey, 'Invalid pubArea.unique value'); |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * @return mixed[] |
| 129 | */ |
| 130 | private function checkCertInfo(string $data): array |
| 131 | { |
| 132 | $certInfo = new StringStream($data); |
| 133 | |
| 134 | $magic = $certInfo->read(4); |
| 135 | Assertion::eq('ff544347', bin2hex($magic), 'Invalid attestation object'); |
| 136 | |
| 137 | $type = $certInfo->read(2); |
| 138 | |
| 139 | $qualifiedSignerLength = unpack('n', $certInfo->read(2))[1]; |
| 140 | $qualifiedSigner = $certInfo->read($qualifiedSignerLength); //Ignored |
| 141 | |
| 142 | $extraDataLength = unpack('n', $certInfo->read(2))[1]; |
| 143 | $extraData = $certInfo->read($extraDataLength); |
| 144 | |
| 145 | $clockInfo = $certInfo->read(17); //Ignore |
| 146 | |
| 147 | $firmwareVersion = $certInfo->read(8); |
| 148 | |
| 149 | $attestedNameLength = unpack('n', $certInfo->read(2))[1]; |
| 150 | $attestedName = $certInfo->read($attestedNameLength); |
| 151 | |
| 152 | $attestedQualifiedNameLength = unpack('n', $certInfo->read(2))[1]; |
| 153 | $attestedQualifiedName = $certInfo->read($attestedQualifiedNameLength); //Ignore |
| 154 | Assertion::true($certInfo->isEOF(), 'Invalid certificate information. Presence of extra bytes.'); |
| 155 | $certInfo->close(); |
| 156 | |
| 157 | return [ |
| 158 | 'magic' => $magic, |
| 159 | 'type' => $type, |
| 160 | 'qualifiedSigner' => $qualifiedSigner, |
| 161 | 'extraData' => $extraData, |
| 162 | 'clockInfo' => $clockInfo, |
| 163 | 'firmwareVersion' => $firmwareVersion, |
| 164 | 'attestedName' => $attestedName, |
| 165 | 'attestedQualifiedName' => $attestedQualifiedName, |
| 166 | ]; |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * @return mixed[] |
| 171 | */ |
| 172 | private function checkPubArea(string $data): array |
| 173 | { |
| 174 | $pubArea = new StringStream($data); |
| 175 | |
| 176 | $type = $pubArea->read(2); |
| 177 | |
| 178 | $nameAlg = $pubArea->read(2); |
| 179 | |
| 180 | $objectAttributes = $pubArea->read(4); |
| 181 | |
| 182 | $authPolicyLength = unpack('n', $pubArea->read(2))[1]; |
| 183 | $authPolicy = $pubArea->read($authPolicyLength); |
| 184 | |
| 185 | $parameters = $this->getParameters($type, $pubArea); |
| 186 | |
| 187 | $uniqueLength = unpack('n', $pubArea->read(2))[1]; |
| 188 | $unique = $pubArea->read($uniqueLength); |
| 189 | Assertion::true($pubArea->isEOF(), 'Invalid public area. Presence of extra bytes.'); |
| 190 | $pubArea->close(); |
| 191 | |
| 192 | return [ |
| 193 | 'type' => $type, |
| 194 | 'nameAlg' => $nameAlg, |
| 195 | 'objectAttributes' => $objectAttributes, |
| 196 | 'authPolicy' => $authPolicy, |
| 197 | 'parameters' => $parameters, |
| 198 | 'unique' => $unique, |
| 199 | ]; |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * @return mixed[] |
| 204 | */ |
| 205 | private function getParameters(string $type, StringStream $stream): array |
| 206 | { |
| 207 | switch (bin2hex($type)) { |
| 208 | case '0001': |
| 209 | case '0014': |
| 210 | case '0016': |
| 211 | return [ |
| 212 | 'symmetric' => $stream->read(2), |
| 213 | 'scheme' => $stream->read(2), |
| 214 | 'keyBits' => unpack('n', $stream->read(2))[1], |
| 215 | 'exponent' => $this->getExponent($stream->read(4)), |
| 216 | ]; |
| 217 | case '0018': |
| 218 | return [ |
| 219 | 'symmetric' => $stream->read(2), |
| 220 | 'scheme' => $stream->read(2), |
| 221 | 'curveId' => $stream->read(2), |
| 222 | 'kdf' => $stream->read(2), |
| 223 | ]; |
| 224 | default: |
| 225 | throw new InvalidArgumentException('Unsupported type'); |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | private function getExponent(string $exponent): string |
| 230 | { |
| 231 | return '00000000' === bin2hex($exponent) ? Base64Url::decode('AQAB') : $exponent; |
| 232 | } |
| 233 | |
| 234 | private function getTPMHash(string $nameAlg): string |
| 235 | { |
| 236 | switch (bin2hex($nameAlg)) { |
| 237 | case '0004': |
| 238 | return 'sha1'; //: "TPM_ALG_SHA1", |
| 239 | case '000b': |
| 240 | return 'sha256'; //: "TPM_ALG_SHA256", |
| 241 | case '000c': |
| 242 | return 'sha384'; //: "TPM_ALG_SHA384", |
| 243 | case '000d': |
| 244 | return 'sha512'; //: "TPM_ALG_SHA512", |
| 245 | default: |
| 246 | throw new InvalidArgumentException('Unsupported hash algorithm'); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | private function processWithCertificate(string $clientDataJSONHash, AttestationStatement $attestationStatement, AuthenticatorData $authenticatorData): bool |
| 251 | { |
| 252 | $trustPath = $attestationStatement->getTrustPath(); |
| 253 | Assertion::isInstanceOf($trustPath, CertificateTrustPath::class, 'Invalid trust path'); |
| 254 | |
| 255 | $certificates = $trustPath->getCertificates(); |
| 256 | |
| 257 | // Check certificate CA chain and returns the Attestation Certificate |
| 258 | $this->checkCertificate($certificates[0], $authenticatorData); |
| 259 | |
| 260 | // Get the COSE algorithm identifier and the corresponding OpenSSL one |
| 261 | $coseAlgorithmIdentifier = (int) $attestationStatement->get('alg'); |
| 262 | $opensslAlgorithmIdentifier = Algorithms::getOpensslAlgorithmFor($coseAlgorithmIdentifier); |
| 263 | |
| 264 | $result = openssl_verify($attestationStatement->get('certInfo'), $attestationStatement->get('sig'), $certificates[0], $opensslAlgorithmIdentifier); |
| 265 | |
| 266 | return 1 === $result; |
| 267 | } |
| 268 | |
| 269 | private function checkCertificate(string $attestnCert, AuthenticatorData $authenticatorData): void |
| 270 | { |
| 271 | $parsed = openssl_x509_parse($attestnCert); |
| 272 | Assertion::isArray($parsed, 'Invalid certificate'); |
| 273 | |
| 274 | //Check version |
| 275 | Assertion::false(!isset($parsed['version']) || 2 !== $parsed['version'], 'Invalid certificate version'); |
| 276 | |
| 277 | //Check subject field is empty |
| 278 | Assertion::false(!isset($parsed['subject']) || !is_array($parsed['subject']) || 0 !== count($parsed['subject']), 'Invalid certificate name. The Subject should be empty'); |
| 279 | |
| 280 | // Check period of validity |
| 281 | Assertion::keyExists($parsed, 'validFrom_time_t', 'Invalid certificate start date.'); |
| 282 | Assertion::integer($parsed['validFrom_time_t'], 'Invalid certificate start date.'); |
| 283 | $startDate = (new DateTimeImmutable())->setTimestamp($parsed['validFrom_time_t']); |
| 284 | Assertion::true($startDate < new DateTimeImmutable(), 'Invalid certificate start date.'); |
| 285 | |
| 286 | Assertion::keyExists($parsed, 'validTo_time_t', 'Invalid certificate end date.'); |
| 287 | Assertion::integer($parsed['validTo_time_t'], 'Invalid certificate end date.'); |
| 288 | $endDate = (new DateTimeImmutable())->setTimestamp($parsed['validTo_time_t']); |
| 289 | Assertion::true($endDate > new DateTimeImmutable(), 'Invalid certificate end date.'); |
| 290 | |
| 291 | //Check extensions |
| 292 | Assertion::false(!isset($parsed['extensions']) || !is_array($parsed['extensions']), 'Certificate extensions are missing'); |
| 293 | |
| 294 | //Check subjectAltName |
| 295 | Assertion::false(!isset($parsed['extensions']['subjectAltName']), 'The "subjectAltName" is missing'); |
| 296 | |
| 297 | //Check extendedKeyUsage |
| 298 | Assertion::false(!isset($parsed['extensions']['extendedKeyUsage']), 'The "subjectAltName" is missing'); |
| 299 | Assertion::eq($parsed['extensions']['extendedKeyUsage'], '2.23.133.8.3', 'The "extendedKeyUsage" is invalid'); |
| 300 | |
| 301 | // id-fido-gen-ce-aaguid OID check |
| 302 | Assertion::false(in_array('1.3.6.1.4.1.45724.1.1.4', $parsed['extensions'], true) && !hash_equals($authenticatorData->getAttestedCredentialData()->getAaguid()->getBytes(), $parsed['extensions']['1.3.6.1.4.1.45724.1.1.4']), 'The value of the "aaguid" does not match with the certificate'); |
| 303 | } |
| 304 | |
| 305 | private function processWithECDAA(): bool |
| 306 | { |
| 307 | throw new RuntimeException('ECDAA not supported'); |
| 308 | } |
| 309 | } |
| 310 |