Passed
Push — master ( aeea93...08b2ed )
by Tim
05:13 queued 03:26
created

WebAuthnRegistrationEvent::verifyAcceptability()   D

Complexity

Conditions 18
Paths 69

Size

Total Lines 62
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Importance

Changes 6
Bugs 1 Features 0
Metric Value
cc 18
eloc 37
c 6
b 1
f 0
nc 69
nop 1
dl 0
loc 62
rs 4.8666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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