validateAttestationFormatApple()   F
last analyzed

Complexity

Conditions 15
Paths 640

Size

Total Lines 120
Code Lines 66

Duplication

Lines 0
Ratio 0 %

Importance

Changes 7
Bugs 1 Features 0
Metric Value
cc 15
eloc 66
c 7
b 1
f 0
nc 640
nop 1
dl 0
loc 120
rs 2.25

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

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