WebAuthnRegistrationEvent::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 29
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 1
eloc 10
c 3
b 0
f 0
nc 1
nop 8
dl 0
loc 29
rs 9.9332

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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 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 " .
133
                $this->AAGUID .
134
                " is not known to the FIDO MDS3 database.");
135
        }
136
        $authenticatorData = $aaguidDb->get($this->AAGUID);
137
        $certification = $authenticatorData['statusReports'][0]['status'];
138
139
        if ($certification == self::FIDO_REVOKED) {
140
            // phpcs:ignore Generic.Files.LineLength.TooLong
141
            throw new InvalidCredential("FIDO Alliance has REVOKED certification of this device. It cannot be registered.");
142
        }
143
144
        switch ($acceptabilityPolicy['minCertLevel']) {
145
            case self::FIDO_CERTIFIED_L1:
146
                // note: always full string match - there is also a level NOT_FIDO_CERTIFIED !
147
                if ($certification == "FIDO_CERTIFIED" || $certification == self::FIDO_CERTIFIED_L1) {
148
                    return;
149
                }
150
            // intentional fall-through, higher levels are also okay
151
            case self::FIDO_CERTIFIED_L1PLUS:
152
                if ($certification == self::FIDO_CERTIFIED_L1PLUS) {
153
                    return;
154
                }
155
            // intentional fall-through, higher levels are also okay
156
            case self::FIDO_CERTIFIED_L2:
157
                if ($certification == self::FIDO_CERTIFIED_L2) {
158
                    return;
159
                }
160
            // intentional fall-through, higher levels are also okay
161
            case self::FIDO_CERTIFIED_L3:
162
                if ($certification == self::FIDO_CERTIFIED_L3) {
163
                    return;
164
                }
165
            // intentional fall-through, higher levels are also okay
166
            case self::FIDO_CERTIFIED_L3PLUS:
167
                if ($certification == self::FIDO_CERTIFIED_L3PLUS) {
168
                    return;
169
                }
170
                throw new Error("FIDO_CERTIFICATION_TOO_LOW");
171
            default:
172
                throw new Exception("Configuration error: unknown minimum certification level " .
173
                    $acceptabilityPolicy['minCertLevel']);
174
        }
175
    }
176
177
    /**
178
     * Validate the incoming attestation data CBOR blob and return the embedded authData
179
     * @param string $attestationData
180
     * @return void
181
     */
182
    private function validateAttestationData(string $attestationData): void
183
    {
184
        /**
185
         * STEP 9 of the validation procedure in § 7.1 of the spec: CBOR-decode the attestationObject
186
         */
187
        $attestationArray = $this->cborDecode($attestationData);
188
        $this->debugBuffer .= "<pre>";
189
        $this->debugBuffer .= print_r($attestationArray, true);
190
        $this->debugBuffer .= "</pre>";
191
192
        /**
193
         * STEP 15 of the validation procedure in § 7.1 of the spec: verify attStmt values
194
         */
195
        $this->AttFmt = $attestationArray['fmt'];
196
        switch ($attestationArray['fmt']) {
197
            case "none":
198
                $this->validateAttestationFormatNone($attestationArray);
199
                break;
200
            case "packed":
201
                $this->validateAttestationFormatPacked($attestationArray);
202
                break;
203
            case "fido-u2f":
204
                $this->validateAttestationFormatFidoU2F($attestationArray);
205
                break;
206
            case "android-safetynet":
207
                $this->validateAttestationFormatAndroidSafetyNet($attestationArray);
208
                break;
209
            case "apple":
210
                $this->validateAttestationFormatApple($attestationArray);
211
                break;
212
            case "tpm":
213
                $this->fail("TPM attestation format not supported right now.");
214
                break;
215
            case "android-key":
216
                $this->validateAttestationFormatAndroidKey($attestationArray);
217
                break;
218
            default:
219
                $this->fail("Unknown attestation format.");
220
                break;
221
        }
222
        $this->AttFmt = $attestationArray['fmt'];
223
    }
224
225
    /**
226
     * @param array $attestationArray
227
     * @return void
228
     */
229
    private function validateAttestationFormatNone(array $attestationArray): void
230
    {
231
        // § 8.7 of the spec
232
        /**
233
         * § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array
234
         * § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy)
235
         */
236
        if (count($attestationArray['attStmt']) === 0) {
237
            $this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve.");
238
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_NONE;
239
            return;
240
        } else {
241
            $this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'.");
242
        }
243
    }
244
245
    /**
246
     * @param array $attestationArray
247
     */
248
    private function validateAttestationFormatApple(array $attestationArray): void
249
    {
250
        // found at: https://www.apple.com/certificateauthority/private/
251
252
        $APPLE_WEBAUTHN_ROOT_CA = "-----BEGIN CERTIFICATE-----
253
MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w
254
HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ
255
bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx
256
NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG
257
A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49
258
AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k
259
xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/
260
pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk
261
2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
262
MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3
263
jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B
264
1bWeT0vT
265
-----END CERTIFICATE-----";
266
        // § 8.8 Bullet 1 of the draft spec at
267
        // https://pr-preview.s3.amazonaws.com/alanwaketan/webauthn/pull/1491.html#sctn-apple-anonymous-attestation
268
        // draft implemented in state of 11 Feb 2021
269
        // I can't help but notice that the verification procedure does NOTHING with CA certs from the chain,
270
        // nor is there a root to validate to!
271
        // Found the root CA with Google, see above, and will perform chain validation even if the spec doesn't say so.
272
        // first, clear the openssl error backlog. We might need error data in case things go sideways.
273
        while (openssl_error_string() !== false);
274
275
        $stmtDecoded = $attestationArray['attStmt'];
276
        if (!isset($stmtDecoded['x5c'])) {
277
            $this->fail("Apple attestation statement does not contain an x5c attestation statement!");
278
        }
279
        // § 8.8 Bullet 2
280
        $nonceToHash = $attestationArray['authData'] . $this->clientDataHash;
281
        // § 8.8 Bullet 3
282
        $cryptoUtils = new Utils\Crypto();
283
        $nonce = hash("sha256", $nonceToHash, true); // does raw_output have to be FALSE or TRUE?
284
        $certProps = openssl_x509_parse($cryptoUtils->der2pem($stmtDecoded['x5c'][0]));
285
        // § 8.8 Bullet 4
286
        if (
287
                !isset($certProps['extensions']['1.2.840.113635.100.8.2']) ||
288
                empty($certProps['extensions']['1.2.840.113635.100.8.2'])
289
        ) {
290
            $this->fail("The required nonce value is not present in the OID.");
291
        }
292
        $toCompare = substr($certProps['extensions']['1.2.840.113635.100.8.2'], 6);
293
        if ($nonce != $toCompare) {
294
            $this->fail("There is a mismatch between the nonce and the OID (XXX $nonce XXX , XXX $toCompare XXX ).");
295
        }
296
297
        // chain validation first
298
        foreach ($stmtDecoded['x5c'] as $runIndex => $runCert) {
299
            if (isset($stmtDecoded['x5c'][$runIndex + 1])) { // there is a next cert, so follow the chain
300
                $certResource = openssl_x509_read($cryptoUtils->der2pem($runCert));
301
                $signerPubKey = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1]));
302
                if (openssl_x509_verify($certResource, $signerPubKey) != 1) {
303
                    // phpcs:ignore Generic.Files.LineLength.TooLong
304
                    $this->fail("Error during chain validation of the attestation certificate (while validating cert #$runIndex, which is "
305
                            . $cryptoUtils->der2pem($runCert)
306
                            . "; next cert was "
307
                            . $cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1]));
308
                }
309
            } else { // last cert, compare to the root
310
                $certResource = openssl_x509_read($cryptoUtils->der2pem($runCert));
311
                $signerPubKey = openssl_pkey_get_public($APPLE_WEBAUTHN_ROOT_CA);
312
                if (openssl_x509_verify($certResource, $signerPubKey) != 1) {
313
                    $this->fail(sprintf(
314
                        "Error during root CA validation of the attestation chain certificate, which is %s",
315
                        $cryptoUtils->der2pem($runCert),
316
                    ));
317
                }
318
            }
319
        }
320
321
        $keyResource = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][0]));
322
        if ($keyResource === false) {
323
            $this->fail(
324
            // phpcs:ignore Generic.Files.LineLength.TooLong
325
                "Did not get a parseable X.509 structure out of the Apple attestation statement - x5c nr. 0 statement was: XXX "
326
                . $stmtDecoded['x5c'][0]
327
                . " XXX; PEM equivalent is "
328
                . $cryptoUtils->der2pem($stmtDecoded['x5c'][0])
329
                . ". OpenSSL error: "
330
                . openssl_error_string(),
331
            );
332
        }
333
334
        // $this->credential is a public key in CBOR, not "PEM". We need to convert it first.
335
        $keyArray = $this->cborDecode(hex2bin($this->credential));
336
        $keyObject = new Ec2Key($keyArray);
337
        $credentialResource = openssl_pkey_get_public($keyObject->asPEM());
338
339
        if ($credentialResource === false) {
340
            $this->fail(
341
                "Could not create a public key from CBOR credential. XXX "
342
                . $this->credential
343
                . " XXX; PEM equivalent is "
344
                . $keyObject->asPEM()
345
                . ". OpenSSL error: "
346
                . openssl_error_string(),
347
            );
348
        }
349
350
        // § 8.8 Bullet 5
351
        $credentialDetails = openssl_pkey_get_details($credentialResource);
352
        $keyDetails = openssl_pkey_get_details($keyResource);
353
        if (
354
            $credentialDetails['bits'] != $keyDetails['bits'] ||
355
            $credentialDetails['key'] != $keyDetails['key'] ||
356
            $credentialDetails['type'] != $keyDetails['type']
357
        ) {
358
            $this->fail(
359
                "The credential public key does not match the certificate public key in attestationData. ("
360
                . $credentialDetails['key']
361
                . " - "
362
                . $keyDetails['key']
363
                . ")",
364
            );
365
        }
366
        $this->pass("Apple attestation format verification passed.");
367
        return;
368
    }
369
370
    private function commonX5cSignatureChecks(array $attestationArray): void
371
    {
372
        $stmtDecoded = $attestationArray['attStmt'];
373
        /**
374
         * §8.2 Step 4 Bullet 1: check algorithm
375
         */
376
        if (!in_array($stmtDecoded['alg'], self::PK_ALGORITHM)) {
377
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
378
        }
379
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
380
        if ($keyResource === false) {
381
            $this->fail("Unable to construct public key resource from PEM.");
382
        }
383
        /**
384
         * §8.2 Step 2: check x5c attestation
385
         */
386
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
387
        /**
388
         * §8.2 Step 2 Bullet 1: check signature
389
         */
390
        $retCode = openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, "sha256");
391
        if ($retCode !== 1) {
392
            $this->fail(sprintf(
393
            // phpcs:ignore Generic.Files.LineLength.TooLong
394
                "Packed signature mismatch (return code $retCode, for :authdata:%s - :clientDataHash:%s - :signature:%s), attestation failed.",
395
                $attestationArray['authData'],
396
                $this->clientDataHash,
397
                $stmtDecoded['sig'],
398
            ));
399
        }
400
        $this->pass("x5c sig check passed.");
401
    }
402
403
    /**
404
     * @param array $attestationArray
405
     */
406
    private function validateAttestationFormatPacked(array $attestationArray): void
407
    {
408
        $stmtDecoded = $attestationArray['attStmt'];
409
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
410
        /**
411
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
412
         */
413
        if (isset($stmtDecoded['x5c'])) {
414
            $this->commonX5cSignatureChecks($attestationArray);
415
            $this->validateAttestationFormatPackedX5C($attestationArray);
416
        } elseif (isset($stmtDecoded['ecdaa'])) {
417
            $this->fail("ecdaa attestation not supported right now.");
418
        } else {
419
            // if we are still here, we are in the "self" type.
420
            // signature checks already done, nothing more to do
421
            $this->pass("Self-Attestation verified.");
422
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
423
        }
424
    }
425
426
    /**
427
     * @param array $attestationArray
428
     * @return void
429
     */
430
    private function validateAttestationFormatPackedX5C(array $attestationArray): void
431
    {
432
        $stmtDecoded = $attestationArray['attStmt'];
433
        // still need to perform sanity checks on the attestation certificate
434
        /**
435
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
436
         */
437
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
438
        $this->debugBuffer .= "Attestation Certificate:" .
439
            /** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>";
440
        if (
441
            $certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
442
            $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
443
            !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
444
            !isset($certProps['extensions']['basicConstraints']) ||
445
            strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
446
        ) {
447
            $this->fail("Attestation certificate properties are no good.");
448
        }
449
450
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
451
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
452
            /**
453
             * Checking the OID is not programmatically possible. Text per spec:
454
             * "If the related attetation root certificate is used for multiple
455
             * authenticator models, the Extension OID ... MUST be present."
456
             *
457
             * FIDO MDS3 metadata does not disclose whether the root CAs are
458
             * used for multiple models.
459
             */
460
            /* if ($token['multi'] === true) { // need to check the OID
461
                if (
462
                    !isset($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']) ||
463
                     empty($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4'])
464
                ) { // §8.2.1 Bullet 3
465
                    $this->fail(
466
                        // phpcs:ignore Generic.Files.LineLength.TooLong
467
                        "This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID.",
468
                    );
469
                }
470
                /**
471
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
472
                 */
473
                /* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
474
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
475
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
476
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
477
                }
478
            }*/
479
            // we would need to verify the attestation certificate against a known-good
480
            // root CA certificate to get more than basic
481
            /*
482
             * §7.1 Step 17 is to look at $token['RootPEMs']
483
             */
484
            foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) {
485
                openssl_x509_parse("-----BEGIN CERTIFICATE-----\n$oneRoot\n-----END CERTIFICATE-----", true);
486
            }
487
            /*
488
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
489
             */
490
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
491
        } else {
492
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
493
            // unable to verify all cert properties, so this is not enough for BASIC.
494
            // but it's our own fault, we should add the device to our DB.
495
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
496
        }
497
        $this->pass("x5c attestation passed.");
498
        return;
499
    }
500
501
    // Keymaster 3 - KeyMint ???
502
    private const ORIGINS_3 = [ // https://source.android.com/docs/security/features/keystore/tags#origin
503
        0 => "GENERATED",
504
        1 => "DERIVED",
505
        2 => "IMPORTED",
506
        3 => "UNKNOWN",
507
        ];
508
    private const PURPOSE_3 = [
509
        0 => "ENCRYPT",
510
        1 => "DECRYPT",
511
        2 => "SIGN",
512
        3 => "VERIFY",
513
        4 => "DERIVE_KEY",
514
        5 => "WRAP_KEY",
515
    ];
516
517
    private const MIN_SUPPORTED_KEYMASTER_VERSION = 3;
518
519
    private function validateAttestationFormatAndroidKey(array $attestationArray): void
520
    {
521
        $stmtDecoded = $attestationArray['attStmt'];
522
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
523
        $this->commonX5cSignatureChecks($attestationArray);
524
        // first certificate's properties
525
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
526
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
527
        $keyDetails = openssl_pkey_get_details($keyResource);
528
        switch ($keyDetails['type']) {
529
            case OPENSSL_KEYTYPE_EC:
530
                $certPubkey = $keyDetails['ec'];
531
                break;
532
            case OPENSSL_KEYTYPE_RSA:
533
                $certPubkey = $keyDetails['rsa'];
534
                break;
535
            default:
536
                throw new Exception("Public key was neither a RSA nor EC key.");
537
        }
538
        $statementKeyData = $this->cborDecode(hex2bin($this->credential));
539
        // this will only work for ECDSA keys, screw RSA
540
        if (
541
            $statementKeyData['x'] != $certPubkey[-2] || $statementKeyData['y'] != $certPubkey[-3]
542
        ) {
543
            $this->fail("Certificate public key does not match credentialPublicKey in authenticatorData (" .
544
                /** @scrutinizer ignore-type */ print_r($certPubkey, true) .
545
                "###" .
546
                /** @scrutinizer ignore-type */ print_r($statementKeyData, true) .
547
                ").");
548
        }
549
        // throw new Exception(print_r($certProps, true));
550
        $rawAsn1Oid = $certProps['extensions']['1.3.6.1.4.1.11129.2.1.17'];
551
        $keyDescription = UnspecifiedType::fromDER($rawAsn1Oid)->asSequence();
552
        $attestationVersion = $keyDescription->at(0)->asInteger()->intNumber();
553
        $attestationChallenge = $keyDescription->at(4)->asOctetString()->string();
554
        $softwareEnforced = $keyDescription->at(6)->asSequence();
555
        $teeEnforced = $keyDescription->at(7)->asSequence();
556
557
        if ($this->clientDataHash !== $attestationChallenge) {
558
            $this->fail("ClientDataHash is not in certificate's extension data (attestationChallenge).");
559
        }
560
561
        if ($attestationVersion < self::MIN_SUPPORTED_KEYMASTER_VERSION) {
562
            $this->fail("Attestation versions below " .
563
                self::MIN_SUPPORTED_KEYMASTER_VERSION .
564
                " not supported, found $attestationVersion.");
565
        }
566
567
        if ($softwareEnforced->hasTagged(600) || $teeEnforced->hasTagged(600)) {
568
            $this->fail("Tag allApplications found!");
569
        }
570
        // need to go through both software and TEE and check origins and purpose
571
572
        // phpcs:disable Generic.Files.LineLength.TooLong
573
        if (
574
            ($softwareEnforced->hasTagged(702) && ($softwareEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3))) ||
575
            ($teeEnforced->hasTagged(702) && ($teeEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3)))
576
        ) {
577
            $this->fail("Incorrect value for ORIGIN!");
578
        }
579
        // phpcs:enable Generic.Files.LineLength.TooLong
580
581
        if ($softwareEnforced->hasTagged(1)) {
582
            $purposesSoftware = $softwareEnforced->getTagged(1)->asExplicit()->asSet();
583
            foreach ($purposesSoftware->elements() as $onePurpose) {
584
                if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) {
585
                    $this->fail("Incorrect value for PURPOSE (softwareEnforced)!");
586
                }
587
            }
588
        }
589
        if ($teeEnforced->hasTagged(1)) {
590
            $purposesTee = $teeEnforced->getTagged(1)->asExplicit()->asSet();
591
            foreach ($purposesTee->elements() as $onePurpose) {
592
                if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) {
593
                    $this->fail("Incorrect value for PURPOSE (teeEnforced)!");
594
                }
595
            }
596
        }
597
598
        $this->pass("Android Key attestation passed.");
599
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
600
    }
601
602
    /**
603
     * support legacy U2F tokens
604
     *
605
     * @param array $attestationData the incoming attestation data
606
     * @return void
607
     */
608
    private function validateAttestationFormatFidoU2F(array $attestationData): void
609
    {
610
        /**
611
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
612
         * already successfully CBOR decoded
613
         */
614
        $stmtDecoded = $attestationData['attStmt'];
615
        if (!isset($stmtDecoded['x5c'])) {
616
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
617
        }
618
        /**
619
         * §8.6 Verification Step 2: extract attCert and sanity check it
620
         */
621
        if (count($stmtDecoded['x5c']) !== 1) {
622
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
623
        }
624
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
625
        $key = openssl_pkey_get_public($attCert);
626
        $keyProps = openssl_pkey_get_details($key);
627
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
628
            $this->fail("FIDO U2F attestation public key is not P-256!");
629
        }
630
        /**
631
         * §8.6 Verification Step 3 is a NOOP as these properties are already
632
         * available as class members:
633
         *
634
         * $this->rpIdHash;
635
         * $this->credentialId;
636
         * $this->credential;
637
         */
638
        /**
639
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
640
         */
641
        if (
642
            isset($this->credential[-2]) &&
643
            strlen($this->credential[-2]) === 32 &&
644
            isset($this->credential[-3]) &&
645
            strlen($this->credential[-3]) === 32
646
        ) {
647
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
648
        } else {
649
            $publicKeyU2F = false;
650
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
651
        }
652
        /**
653
         * §8.6 Verification Step 5: create verificationData
654
         */
655
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
656
        /**
657
         * §8.6 Verification Step 6: verify signature
658
         */
659
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
660
            $this->fail("FIDO U2F Attestation verification failed.");
661
        } else {
662
            $this->pass("Successfully verified FIDO U2F signature.");
663
        }
664
        /**
665
         * §8.6 Verification Step 7: not performed, this is optional as per spec
666
         */
667
        /**
668
         * §8.6 Verification Step 8: so we always settle for "Basic"
669
         */
670
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
671
    }
672
673
    /**
674
     * support Android authenticators (fingerprint etc.)
675
     *
676
     * @param array $attestationData the incoming attestation data
677
     * @return void
678
     */
679
    private function validateAttestationFormatAndroidSafetyNet(array $attestationData): void
680
    {
681
        $this->fail(sprintf(
682
            "Android SafetyNet attestation is historic and not supported (%s).",
683
            print_r($attestationData, true),
0 ignored issues
show
Bug introduced by
It seems like print_r($attestationData, true) can also be of type true; however, parameter $values of sprintf() does only seem to accept double|integer|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

683
            /** @scrutinizer ignore-type */ print_r($attestationData, true),
Loading history...
684
        ));
685
        // be sure to end execution even if the Exception is caught
686
        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...
687
    }
688
689
    /**
690
     * The registration contains the actual credential. This function parses it.
691
     * @param string $attData    the attestation data binary blob
692
     * @param string $responseId the response ID
693
     * @return void
694
     */
695
    private function validateAttestedCredentialData(string $attData, string $responseId): void
696
    {
697
        $aaguid = substr($attData, 0, 16);
698
        $credIdLenBytes = substr($attData, 16, 2);
699
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
700
        $credId = substr($attData, 18, $credIdLen);
701
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
702
        $this->AAGUID = bin2hex($aaguid);
703
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
704
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
705
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
706
        if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) {
707
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
708
        } else {
709
            $this->fail(
710
                "Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" .
711
                bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ").",
712
            );
713
        }
714
        // so far so good. Now extract the actual public key from its COSE
715
        // encoding.
716
        // finding out the number of bytes to CBOR decode appears non-trivial.
717
        // The simple case is if no ED is present as the CBOR data then goes to
718
        // the end of the byte sequence.
719
        // since we don't know the algoritm yet, we don't know how many bytes
720
        // of credential CBOR follow. Let's read to the end; the CBOR decoder
721
        // silently ignores trailing extensions (if any)
722
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
723
        $arrayPK = $this->cborDecode($pubKeyCBOR);
724
        $this->debugBuffer .= "pubKey in canonical form: <pre>" .
725
            /** @scrutinizer ignore-type */ print_r($arrayPK, true) .
726
            "</pre>";
727
        /**
728
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
729
         */
730
        if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here
731
            $this->algo = (int)$arrayPK['3'];
732
            $this->pass("Public Key Algorithm is expected (" .
733
                implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) .
734
                ").");
735
        } else {
736
            $this->fail("Public Key Algorithm mismatch!");
737
        }
738
        $this->credentialId = bin2hex($credId);
739
        $this->credential = bin2hex($pubKeyCBOR);
740
741
        // now that we know credential and its length, we can CBOR-decode the
742
        // trailing extensions
743
        switch ($this->algo) {
744
            case self::PK_ALGORITHM_ECDSA:
745
                $credentialLength = 77;
746
                break;
747
            case self::PK_ALGORITHM_RSA:
748
                $credentialLength = 272;
749
                break;
750
            default:
751
                $this->fail("No credential length information for $this->algo");
752
                // be sure to end execution even if the Exception is caught
753
                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...
754
        }
755
        $extensions = substr($attData, 18 + $credIdLen + $credentialLength);
756
        if (strlen($extensions) !== 0) {
757
            $this->pass("Found the following extensions (" .
758
                strlen($extensions) .
759
                " bytes) during registration ceremony: ");
760
        }
761
    }
762
763
    /**
764
     * transform DER formatted certificate to PEM format
765
     *
766
     * @param string $derData blob of DER data
767
     * @return string the PEM representation of the certificate
768
     */
769
    private function der2pem(string $derData): string
770
    {
771
        $pem = chunk_split(base64_encode($derData), 64, "\n");
772
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
773
        return $pem;
774
    }
775
776
    /**
777
     * @return string
778
     */
779
    public function getAAGUID()
780
    {
781
        return $this->AAGUID;
782
    }
783
784
    /**
785
     * @return string
786
     */
787
    public function getAttestationLevel()
788
    {
789
        return $this->AAGUIDAssurance;
790
    }
791
}
792