Passed
Push — master ( bbe026...b84b3e )
by Stefan
13:24
created

validateAttestationData()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 41
Code Lines 31

Duplication

Lines 0
Ratio 0 %

Importance

Changes 6
Bugs 1 Features 0
Metric Value
cc 8
eloc 31
c 6
b 1
f 0
nc 8
nop 1
dl 0
loc 41
rs 8.1795
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\webauthn\WebAuthn;
6
7
use Cose\Key\Ec2Key;
8
use Cose\Key\RsaKey;
9
use Exception;
10
use SimpleSAML\Error\Error;
11
use SimpleSAML\Error\InvalidCredential;
12
use SimpleSAML\Logger;
13
use SimpleSAML\Module\webauthn\WebAuthn\AAGUID;
14
use SimpleSAML\Utils;
15
use SimpleSAML\Utils\Config as SSPConfig;
16
use SpomkyLabs\Pki\ASN1\Type\UnspecifiedType;
17
18
/**
19
 * FIDO2/WebAuthn Authentication Processing filter
20
 *
21
 * Filter for registering or authenticating with a FIDO2/WebAuthn token after
22
 * having authenticated with the primary authsource.
23
 *
24
 * @author Stefan Winter <[email protected]>
25
 * @package SimpleSAMLphp
26
 */
27
class WebAuthnRegistrationEvent extends WebAuthnAbstractEvent
28
{
29
    /**
30
     * Public key algorithm supported. This is -7 - ECDSA with curve P-256, or -275 (RS256)
31
     */
32
    public const PK_ALGORITHM_ECDSA = "-7";
33
    public const PK_ALGORITHM_RSA = "-257";
34
    public const PK_ALGORITHM = [self::PK_ALGORITHM_ECDSA, self::PK_ALGORITHM_RSA];
35
    public const AAGUID_ASSURANCE_LEVEL_NONE = 'None';
36
    public const AAGUID_ASSURANCE_LEVEL_SELF = 'Self';
37
    public const AAGUID_ASSURANCE_LEVEL_BASIC = 'Basic';
38
    public const AAGUID_ASSURANCE_LEVEL_ATTCA = 'AttCA';
39
40
    // nomenclature from the MDS3 spec
41
    public const FIDO_REVOKED = "REVOKED";
42
    public const CERTIFICATION_NOT_REQUIRED = "CERTIFICATION_NOT_REQUIRED";
43
    public const FIDO_CERTIFIED_L1 = "FIDO_CERTIFIED_L1";
44
    public const FIDO_CERTIFIED_L1PLUS = "FIDO_CERTIFIED_L1plus";
45
    public const FIDO_CERTIFIED_L2 = "FIDO_CERTIFIED_L2";
46
    public const FIDO_CERTIFIED_L3 = "FIDO_CERTIFIED_L3";
47
    public const FIDO_CERTIFIED_L3PLUS = "FIDO_CERTIFIED_L3plus";
48
    /**
49
     * the AAGUID of the newly registered authenticator
50
     * @var string
51
     */
52
    protected string $AAGUID;
53
54
    /**
55
     * how sure are we about the AAGUID?
56
     * @var string
57
     */
58
    protected string $AAGUIDAssurance;
59
60
    /**
61
     * An array of known hardware tokens
62
     *
63
     * @var \SimpleSAML\Module\webauthn\WebAuthn\AAGUID
64
     */
65
    protected AAGUID $AAGUIDDictionary;
66
    protected string $AttFmt;
67
68
    /**
69
     * Initialize the event object.
70
     *
71
     * Validates and parses the configuration.
72
     *
73
     * @param string $pubkeyCredType  PublicKeyCredential.type
74
     * @param string $scope           the scope of the event
75
     * @param string $challenge       the challenge which was used to trigger this event
76
     * @param string $attestationData the attestation data CBOR blob
77
     * @param string $responseId      the response ID
78
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
79
     * @param bool $debugMode         print debugging statements?
80
     */
81
    public function __construct(
82
        string $pubkeyCredType,
83
        string $scope,
84
        string $challenge,
85
        string $attestationData,
86
        string $responseId,
87
        string $clientDataJSON,
88
        array $acceptabilityPolicy,
89
        bool $debugMode = false
90
    ) {
91
        $this->debugBuffer .= "attestationData raw: " . $attestationData . "<br/>";
92
        /**
93
         * §7.1 STEP 9 : CBOR decode attestationData.
94
         */
95
        $attestationArray = $this->cborDecode($attestationData);
96
        $authData = $attestationArray['authData'];
97
        $this->eventType = "REG";
98
        parent::__construct($pubkeyCredType, $scope, $challenge, $authData, $clientDataJSON, $debugMode);
99
100
        $this->AAGUIDDictionary = AAGUID::getInstance();
101
102
        // this function extracts the public key
103
        $this->validateAttestedCredentialData(substr($authData, 37), $responseId);
104
        // this function may need the public key to have been previously extracted
105
        $this->validateAttestationData($attestationData);
106
        // the following function sets the credential properties
107
        $this->debugBuffer .= "Attestation Data (bin2hex): " . bin2hex(substr($authData, 37)) . "<br/>";
108
        // now check if the authenticator is acceptable as per policy
109
        $this->verifyAcceptability($acceptabilityPolicy);
110
    }
111
112
    private function verifyAcceptability($acceptabilityPolicy)
113
    {
114
        if ($acceptabilityPolicy['minCertLevel'] == self::CERTIFICATION_NOT_REQUIRED) { // all is accepted
115
            return;
116
        }
117
118
        // if we care about the content of the attestation at all, make sure we
119
        // have a confidence level beyond "None".
120
        if ($this->AAGUIDAssurance == self::AAGUID_ASSURANCE_LEVEL_NONE) {
121
            throw new Exception("Authenticator did not provide a useful attestation level.");
122
        }
123
        if (in_array($this->AAGUID, $acceptabilityPolicy['aaguidWhitelist'])) {
124
            return;
125
        }
126
        if (in_array($this->AttFmt, $acceptabilityPolicy['attFmtWhitelist'])) {
127
            return;
128
        }
129
130
        $aaguidDb = AAGUID::getInstance();
131
        if (!$aaguidDb->hasToken($this->AAGUID)) {
132
            throw new Exception("Authenticator with AAGUID " . $this->AAGUID . " is not known to the FIDO MDS3 database.");
133
        }
134
        $authenticatorData = $aaguidDb->get($this->AAGUID);
135
        $certification = $authenticatorData['statusReports'][0]['status'];
136
137
        if ($certification == self::FIDO_REVOKED) {
138
            throw new InvalidCredential("FIDO Alliance has REVOKED certification of this device. It cannot be registered.");
139
        }
140
141
        switch ($acceptabilityPolicy['minCertLevel']) {
142
            case self::FIDO_CERTIFIED_L1:
143
                // note: always full string match - there is also a level NOT_FIDO_CERTIFIED !
144
                if ($certification == "FIDO_CERTIFIED" || $certification == self::FIDO_CERTIFIED_L1) {
145
                    return;
146
                }
147
            // intentional fall-through, higher levels are also okay
148
            case self::FIDO_CERTIFIED_L1PLUS:
149
                if ($certification == self::FIDO_CERTIFIED_L1PLUS) {
150
                    return;
151
                }
152
            // intentional fall-through, higher levels are also okay
153
            case self::FIDO_CERTIFIED_L2:
154
                if ($certification == self::FIDO_CERTIFIED_L2) {
155
                    return;
156
                }
157
            // intentional fall-through, higher levels are also okay
158
            case self::FIDO_CERTIFIED_L3:
159
                if ($certification == self::FIDO_CERTIFIED_L3) {
160
                    return;
161
                }
162
            // intentional fall-through, higher levels are also okay
163
            case self::FIDO_CERTIFIED_L3PLUS:
164
                if ($certification == self::FIDO_CERTIFIED_L3PLUS) {
165
                    return;
166
                }
167
                throw new Error("FIDO_CERTIFICATION_TOO_LOW");
168
            default:
169
                throw new Exception("Configuration error: unknown minimum certification level " . $acceptabilityPolicy['minCertLevel']);
170
        }
171
    }
172
173
    /**
174
     * Validate the incoming attestation data CBOR blob and return the embedded authData
175
     * @param string $attestationData
176
     * @return void
177
     */
178
    private function validateAttestationData(string $attestationData): void
179
    {
180
        /**
181
         * STEP 9 of the validation procedure in § 7.1 of the spec: CBOR-decode the attestationObject
182
         */
183
        $attestationArray = $this->cborDecode($attestationData);
184
        $this->debugBuffer .= "<pre>";
185
        $this->debugBuffer .= print_r($attestationArray, true);
186
        $this->debugBuffer .= "</pre>";
187
188
        /**
189
         * STEP 15 of the validation procedure in § 7.1 of the spec: verify attStmt values
190
         */
191
        $this->AttFmt = $attestationArray['fmt'];
192
        switch ($attestationArray['fmt']) {
193
            case "none":
194
                $this->validateAttestationFormatNone($attestationArray);
195
                break;
196
            case "packed":
197
                $this->validateAttestationFormatPacked($attestationArray);
198
                break;
199
            case "fido-u2f":
200
                $this->validateAttestationFormatFidoU2F($attestationArray);
201
                break;
202
            case "android-safetynet":
203
                $this->validateAttestationFormatAndroidSafetyNet($attestationArray);
204
                break;
205
            case "apple":
206
                $this->validateAttestationFormatApple($attestationArray);
207
                break;
208
            case "tpm":
209
                $this->fail("TPM attestation format not supported right now.");
210
                break;
211
            case "android-key":
212
                $this->validateAttestationFormatAndroidKey($attestationArray);
213
                break;
214
            default:
215
                $this->fail("Unknown attestation format.");
216
                break;
217
        }
218
        $this->AttFmt = $attestationArray['fmt'];
219
    }
220
221
    /**
222
     * @param array $attestationArray
223
     * @return void
224
     */
225
    private function validateAttestationFormatNone(array $attestationArray): void
226
    {
227
        // § 8.7 of the spec
228
        /**
229
         * § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array
230
         * § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy)
231
         */
232
        if (count($attestationArray['attStmt']) === 0) {
233
            $this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve.");
234
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_NONE;
235
            return;
236
        } else {
237
            $this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'.");
238
        }
239
    }
240
241
    /**
242
     * @param array $attestationArray
243
     */
244
    private function validateAttestationFormatApple(array $attestationArray): void
245
    {
246
        // found at: https://www.apple.com/certificateauthority/private/
247
248
        $APPLE_WEBAUTHN_ROOT_CA = "-----BEGIN CERTIFICATE-----
249
MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w
250
HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ
251
bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx
252
NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG
253
A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49
254
AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k
255
xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/
256
pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk
257
2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
258
MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3
259
jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B
260
1bWeT0vT
261
-----END CERTIFICATE-----";
262
        // § 8.8 Bullet 1 of the draft spec at https://pr-preview.s3.amazonaws.com/alanwaketan/webauthn/pull/1491.html#sctn-apple-anonymous-attestation
263
        // draft implemented in state of 11 Feb 2021
264
        // I can't help but notice that the verification procedure does NOTHING with CA certs from the chain, nor is there a root to validate to!
265
        // Found the root CA with Google, see above, and will perform chain validation even if the spec doesn't say so.
266
        // first, clear the openssl error backlog. We might need error data in case things go sideways.
267
        while (openssl_error_string() !== false);
268
269
        $stmtDecoded = $attestationArray['attStmt'];
270
        if (!isset($stmtDecoded['x5c'])) {
271
            $this->fail("Apple attestation statement does not contain an x5c attestation statement!");
272
        }
273
        // § 8.8 Bullet 2
274
        $nonceToHash = $attestationArray['authData'] . $this->clientDataHash;
275
        // § 8.8 Bullet 3
276
        $cryptoUtils = new Utils\Crypto();
277
        $nonce = hash("sha256", $nonceToHash, true); // does raw_output have to be FALSE or TRUE?
278
        $certProps = openssl_x509_parse($cryptoUtils->der2pem($stmtDecoded['x5c'][0]));
279
        // § 8.8 Bullet 4
280
        if (
281
                !isset($certProps['extensions']['1.2.840.113635.100.8.2']) ||
282
                empty($certProps['extensions']['1.2.840.113635.100.8.2'])
283
        ) {
284
            $this->fail("The required nonce value is not present in the OID.");
285
        }
286
        $toCompare = substr($certProps['extensions']['1.2.840.113635.100.8.2'], 6);
287
        if ($nonce != $toCompare) {
288
            $this->fail("There is a mismatch between the nonce and the OID (XXX $nonce XXX , XXX $toCompare XXX ).");
289
        }
290
291
        // chain validation first
292
        foreach ($stmtDecoded['x5c'] as $runIndex => $runCert) {
293
            if (isset($stmtDecoded['x5c'][$runIndex + 1])) { // there is a next cert, so follow the chain
294
                $certResource = openssl_x509_read($cryptoUtils->der2pem($runCert));
295
                $signerPubKey = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1]));
296
                if (openssl_x509_verify($certResource, $signerPubKey) != 1) {
297
                    $this->fail("Error during chain validation of the attestation certificate (while validating cert #$runIndex, which is "
298
                            . $cryptoUtils->der2pem($runCert)
299
                            . "; next cert was "
300
                            . $cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1]));
301
                }
302
            } else { // last cert, compare to the root
303
                $certResource = openssl_x509_read($cryptoUtils->der2pem($runCert));
304
                $signerPubKey = openssl_pkey_get_public($APPLE_WEBAUTHN_ROOT_CA);
305
                if (openssl_x509_verify($certResource, $signerPubKey) != 1) {
306
                    $this->fail(sprintf(
307
                        "Error during root CA validation of the attestation chain certificate, which is %s",
308
                        $cryptoUtils->der2pem($runCert)
309
                    ));
310
                }
311
            }
312
        }
313
314
        $keyResource = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][0]));
315
        if ($keyResource === false) {
316
            $this->fail(
317
                "Did not get a parseable X.509 structure out of the Apple attestation statement - x5c nr. 0 statement was: XXX "
318
                . $stmtDecoded['x5c'][0]
319
                . " XXX; PEM equivalent is "
320
                . $cryptoUtils->der2pem($stmtDecoded['x5c'][0])
321
                . ". OpenSSL error: "
322
                . openssl_error_string()
323
            );
324
        }
325
326
        // $this->credential is a public key in CBOR, not "PEM". We need to convert it first.
327
        $keyArray = $this->cborDecode(hex2bin($this->credential));
328
        $keyObject = new Ec2Key($keyArray);
329
        $credentialResource = openssl_pkey_get_public($keyObject->asPEM());
330
331
        if ($credentialResource === false) {
332
            $this->fail(
333
                "Could not create a public key from CBOR credential. XXX "
334
                . $this->credential
335
                . " XXX; PEM equivalent is "
336
                . $keyObject->asPEM()
337
                . ". OpenSSL error: "
338
                . openssl_error_string()
339
            );
340
        }
341
342
        // § 8.8 Bullet 5
343
        $credentialDetails = openssl_pkey_get_details($credentialResource);
344
        $keyDetails = openssl_pkey_get_details($keyResource);
345
        if (
346
            $credentialDetails['bits'] != $keyDetails['bits'] ||
347
            $credentialDetails['key'] != $keyDetails['key'] ||
348
            $credentialDetails['type'] != $keyDetails['type']
349
        ) {
350
            $this->fail(
351
                "The credential public key does not match the certificate public key in attestationData. ("
352
                . $credentialDetails['key']
353
                . " - "
354
                . $keyDetails['key']
355
                . ")"
356
            );
357
        }
358
        $this->pass("Apple attestation format verification passed.");
359
        return;
360
    }
361
362
    private function commonX5cSignatureChecks(array $attestationArray): void
363
    {
364
        $stmtDecoded = $attestationArray['attStmt'];
365
        /**
366
         * §8.2 Step 4 Bullet 1: check algorithm
367
         */
368
        if (!in_array($stmtDecoded['alg'], self::PK_ALGORITHM)) {
369
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
370
        }
371
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
372
        if ($keyResource === false) {
373
            $this->fail("Unable to construct public key resource from PEM.");
374
        }
375
        /**
376
         * §8.2 Step 2: check x5c attestation
377
         */
378
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
379
        /**
380
         * §8.2 Step 2 Bullet 1: check signature
381
         */
382
        $retCode = openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, "sha256");
383
        if ( $retCode !== 1) {
384
            $this->fail("Packed signature mismatch (return code $retCode, for :authdata:".$attestationArray['authData']." - :clientDataHash:".$this->clientDataHash." - :signature:".$stmtDecoded['sig']."), attestation failed.");
385
        }
386
        $this->pass("x5c sig check passed.");
387
    }
388
389
    /**
390
     * @param array $attestationArray
391
     */
392
    private function validateAttestationFormatPacked(array $attestationArray): void
393
    {
394
        $stmtDecoded = $attestationArray['attStmt'];
395
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
396
        /**
397
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
398
         */
399
        if (isset($stmtDecoded['x5c'])) {
400
        $this->commonX5cSignatureChecks($attestationArray);
401
            $this->validateAttestationFormatPackedX5C($attestationArray);
402
        } elseif (isset($stmtDecoded['ecdaa'])) {
403
            $this->fail("ecdaa attestation not supported right now.");
404
        } else {
405
            // if we are still here, we are in the "self" type.
406
            // signature checks already done, nothing more to do
407
            $this->pass("Self-Attestation verified.");
408
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
409
        }
410
    }
411
412
    /**
413
     * @param array $attestationArray
414
     * @return void
415
     */
416
    private function validateAttestationFormatPackedX5C(array $attestationArray): void
417
    {
418
        $stmtDecoded = $attestationArray['attStmt'];
419
        // still need to perform sanity checks on the attestation certificate
420
        /**
421
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
422
         */
423
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
424
        $this->debugBuffer .= "Attestation Certificate:" . /** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>";
425
        if (
426
                $certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
427
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
428
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
429
                !isset($certProps['extensions']['basicConstraints']) ||
430
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
431
        ) {
432
            $this->fail("Attestation certificate properties are no good.");
433
        }
434
435
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
436
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
437
            /**
438
             * Checking the OID is not programmatically possible. Text per spec:
439
             * "If the related attetation root certificate is used for multiple
440
             * authenticator models, the Extension OID ... MUST be present."
441
             *
442
             * FIDO MDS3 metadata does not disclose whether the root CAs are
443
             * used for multiple models.
444
             */
445
            /* if ($token['multi'] === true) { // need to check the OID
446
                if (
447
                        !isset($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']) || empty($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4'])
448
                ) { // §8.2.1 Bullet 3
449
                    $this->fail(
450
                            "This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID."
451
                    );
452
                }
453
                /**
454
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
455
                 */
456
                /* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
457
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
458
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
459
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
460
                }
461
            }*/
462
            // we would need to verify the attestation certificate against a known-good
463
            // root CA certificate to get more than basic
464
            /*
465
             * §7.1 Step 17 is to look at $token['RootPEMs']
466
             */
467
            foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) {
468
                openssl_x509_parse("-----BEGIN CERTIFICATE-----\n$oneRoot\n-----END CERTIFICATE-----", true);
469
            }
470
            /*
471
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
472
             */
473
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
474
        } else {
475
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
476
            // unable to verify all cert properties, so this is not enough for BASIC.
477
            // but it's our own fault, we should add the device to our DB.
478
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
479
        }
480
        $this->pass("x5c attestation passed.");
481
        return;
482
    }
483
484
    // Keymaster 3 - KeyMint ???
485
    private const ORIGINS_3 = [ // https://source.android.com/docs/security/features/keystore/tags#origin
486
        0 => "GENERATED",
487
        1 => "DERIVED",
488
        2 => "IMPORTED",
489
        3 => "UNKNOWN",
490
        ];
491
    private const PURPOSE_3 = [
492
        0 => "ENCRYPT",
493
        1 => "DECRYPT",
494
        2 => "SIGN",
495
        3 => "VERIFY",
496
        4 => "DERIVE_KEY",
497
        5 => "WRAP_KEY",
498
    ];
499
500
    private const MIN_SUPPORTED_KEYMASTER_VERSION = 3;
501
502
    private function validateAttestationFormatAndroidKey(array $attestationArray): void
503
    {
504
        $stmtDecoded = $attestationArray['attStmt'];
505
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
506
        $this->commonX5cSignatureChecks($attestationArray);
507
        // first certificate's properties
508
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
509
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
510
        $keyDetails = openssl_pkey_get_details($keyResource);
511
        switch ($keyDetails['type']) {
512
            case OPENSSL_KEYTYPE_EC:
513
                $certPubkey = $keyDetails['ec'];
514
                break;
515
            case OPENSSL_KEYTYPE_RSA:
516
                $certPubkey = $keyDetails['rsa'];
517
                break;
518
            default:
519
                throw new Exception("Public key was neither a RSA nor EC key.");
520
        }
521
        $statementKeyData = $this->cborDecode(hex2bin($this->credential));
522
        // this will only work for ECDSA keys, screw RSA
523
        if (
524
            $statementKeyData['x'] != $certPubkey[-2] || $statementKeyData['y'] != $certPubkey[-3]
525
        ) {
526
            $this->fail("Certificate public key does not match credentialPublicKey in authenticatorData (" . /** @scrutinizer ignore-type */ print_r($certPubkey, true) . "###" . /** @scrutinizer ignore-type */ print_r($statementKeyData, true) . ").");
527
        }
528
        // throw new Exception(print_r($certProps, true));
529
        $rawAsn1Oid = $certProps['extensions']['1.3.6.1.4.1.11129.2.1.17'];
530
        $keyDescription = UnspecifiedType::fromDER($rawAsn1Oid)->asSequence();
531
        $attestationVersion = $keyDescription->at(0)->asInteger()->intNumber();
532
        $attestationChallenge = $keyDescription->at(4)->asOctetString()->string();
533
        $softwareEnforced = $keyDescription->at(6)->asSequence();
534
        $teeEnforced = $keyDescription->at(7)->asSequence();
535
536
        if ($this->clientDataHash !== $attestationChallenge) {
537
            $this->fail("ClientDataHash is not in certificate's extension data (attestationChallenge).");
538
        }
539
540
        if ($attestationVersion < self::MIN_SUPPORTED_KEYMASTER_VERSION) {
541
            $this->fail("Attestation versions below " . self::MIN_SUPPORTED_KEYMASTER_VERSION . " not supported, found $attestationVersion.");
542
        }
543
544
        if ($softwareEnforced->hasTagged(600) || $teeEnforced->hasTagged(600)) {
545
            $this->fail("Tag allApplications found!");
546
        }
547
        // need to go through both software and TEE and check origins and purpose
548
549
        if (
550
                ($softwareEnforced->hasTagged(702) && ($softwareEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3))) ||
551
                ($teeEnforced->hasTagged(702) && ($teeEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3)))
552
        ) {
553
            $this->fail("Incorrect value for ORIGIN!");
554
        }
555
556
        if ($softwareEnforced->hasTagged(1)) {
557
            $purposesSoftware = $softwareEnforced->getTagged(1)->asExplicit()->asSet();
558
            foreach ($purposesSoftware->elements() as $onePurpose) {
559
                if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) {
560
                        $this->fail("Incorrect value for PURPOSE (softwareEnforced)!");
561
                }
562
            }
563
        }
564
        if ($teeEnforced->hasTagged(1)) {
565
            $purposesTee = $teeEnforced->getTagged(1)->asExplicit()->asSet();
566
            foreach ($purposesTee->elements() as $onePurpose) {
567
                if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) {
568
                        $this->fail("Incorrect value for PURPOSE (teeEnforced)!");
569
                }
570
            }
571
        }
572
573
        $this->pass("Android Key attestation passed.");
574
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
575
    }
576
577
    /**
578
     * support legacy U2F tokens
579
     *
580
     * @param array $attestationData the incoming attestation data
581
     * @return void
582
     */
583
    private function validateAttestationFormatFidoU2F(array $attestationData): void
584
    {
585
        /**
586
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
587
         * already successfully CBOR decoded
588
         */
589
        $stmtDecoded = $attestationData['attStmt'];
590
        if (!isset($stmtDecoded['x5c'])) {
591
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
592
        }
593
        /**
594
         * §8.6 Verification Step 2: extract attCert and sanity check it
595
         */
596
        if (count($stmtDecoded['x5c']) !== 1) {
597
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
598
        }
599
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
600
        $key = openssl_pkey_get_public($attCert);
601
        $keyProps = openssl_pkey_get_details($key);
602
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
603
            $this->fail("FIDO U2F attestation public key is not P-256!");
604
        }
605
        /**
606
         * §8.6 Verification Step 3 is a NOOP as these properties are already
607
         * available as class members:
608
         *
609
         * $this->rpIdHash;
610
         * $this->credentialId;
611
         * $this->credential;
612
         */
613
        /**
614
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
615
         */
616
        if (
617
                isset($this->credential[-2]) &&
618
                strlen($this->credential[-2]) === 32 &&
619
                isset($this->credential[-3]) &&
620
                strlen($this->credential[-3]) === 32
621
        ) {
622
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
623
        } else {
624
            $publicKeyU2F = false;
625
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
626
        }
627
        /**
628
         * §8.6 Verification Step 5: create verificationData
629
         *
630
         * @psalm-var string $publicKeyU2F
631
         */
632
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
633
        /**
634
         * §8.6 Verification Step 6: verify signature
635
         */
636
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
637
            $this->fail("FIDO U2F Attestation verification failed.");
638
        } else {
639
            $this->pass("Successfully verified FIDO U2F signature.");
640
        }
641
        /**
642
         * §8.6 Verification Step 7: not performed, this is optional as per spec
643
         */
644
        /**
645
         * §8.6 Verification Step 8: so we always settle for "Basic"
646
         */
647
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
648
    }
649
650
    /**
651
     * support Android authenticators (fingerprint etc.)
652
     *
653
     * @param array $attestationData the incoming attestation data
654
     * @return void
655
     */
656
    private function validateAttestationFormatAndroidSafetyNet(array $attestationData): void
657
    {
658
        $this->fail("Android SafetyNet attestation is historic and not supported ($attestationData).");
659
        // be sure to end execution even if the Exception is caught
660
        exit(1);
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
661
    }
662
663
    /**
664
     * The registration contains the actual credential. This function parses it.
665
     * @param string $attData    the attestation data binary blob
666
     * @param string $responseId the response ID
667
     * @return void
668
     */
669
    private function validateAttestedCredentialData(string $attData, string $responseId): void
670
    {
671
        $aaguid = substr($attData, 0, 16);
672
        $credIdLenBytes = substr($attData, 16, 2);
673
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
674
        $credId = substr($attData, 18, $credIdLen);
675
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
676
        $this->AAGUID = bin2hex($aaguid);
677
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
678
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
679
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
680
        if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) {
681
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
682
        } else {
683
            $this->fail(
684
                "Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" .
685
                bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ")."
686
            );
687
        }
688
        // so far so good. Now extract the actual public key from its COSE
689
        // encoding.
690
        // finding out the number of bytes to CBOR decode appears non-trivial.
691
        // The simple case is if no ED is present as the CBOR data then goes to
692
        // the end of the byte sequence.
693
        // since we don't know the algoritm yet, we don't know how many bytes
694
        // of credential CBOR follow. Let's read to the end; the CBOR decoder
695
        // silently ignores trailing extensions (if any)
696
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
697
        $arrayPK = $this->cborDecode($pubKeyCBOR);
698
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . /** @scrutinizer ignore-type */ print_r($arrayPK, true) . "</pre>";
699
        /**
700
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
701
         */
702
        if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here
703
            $this->algo = (int)$arrayPK['3'];
704
            $this->pass("Public Key Algorithm is expected (" . implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) . ").");
705
        } else {
706
            $this->fail("Public Key Algorithm mismatch!");
707
        }
708
        $this->credentialId = bin2hex($credId);
709
        $this->credential = bin2hex($pubKeyCBOR);
710
711
        // now that we know credential and its length, we can CBOR-decode the
712
        // trailing extensions
713
        switch ($this->algo) {
714
            case self::PK_ALGORITHM_ECDSA:
715
                $credentialLength = 77;
716
                break;
717
            case self::PK_ALGORITHM_RSA:
718
                $credentialLength = 272;
719
                break;
720
            default:
721
                $this->fail("No credential length information for $this->algo");
722
                // be sure to end execution even if the Exception is caught
723
                exit(1);
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
724
        }
725
        $extensions = substr($attData, 18 + $credIdLen + $credentialLength);
726
        if (strlen($extensions) !== 0) {
727
            $this->pass("Found the following extensions (" . strlen($extensions) . " bytes) during registration ceremony: ");
728
        }
729
    }
730
731
    /**
732
     * transform DER formatted certificate to PEM format
733
     *
734
     * @param string $derData blob of DER data
735
     * @return string the PEM representation of the certificate
736
     */
737
    private function der2pem(string $derData): string
738
    {
739
        $pem = chunk_split(base64_encode($derData), 64, "\n");
740
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
741
        return $pem;
742
    }
743
744
    /**
745
     * @return string
746
     */
747
    public function getAAGUID()
748
    {
749
        return $this->AAGUID;
750
    }
751
752
    /**
753
     * @return string
754
     */
755
    public function getAttestationLevel()
756
    {
757
        return $this->AAGUIDAssurance;
758
    }
759
}
760