validateAttestedCredentialData()   B
last analyzed

Complexity

Conditions 6
Paths 20

Size

Total Lines 65
Code Lines 44

Duplication

Lines 0
Ratio 0 %

Importance

Changes 6
Bugs 2 Features 0
Metric Value
cc 6
eloc 44
c 6
b 2
f 0
nc 20
nop 2
dl 0
loc 65
rs 8.5937

How to fix   Long Method   

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
    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