Passed
Pull Request — master (#56)
by Stefan
02:28
created

WebAuthnRegistrationEvent::__construct()   A

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 Cose\Key\RsaKey;
9
use Exception;
10
use SimpleSAML\Error\Error;
11
use SimpleSAML\Error\InvalidCredential;
12
use SimpleSAML\Logger;
13
use SimpleSAML\Module\webauthn\WebAuthn\AAGUID;
14
use SimpleSAML\Utils;
15
use SimpleSAML\Utils\Config as SSPConfig;
16
17
/**
18
 * FIDO2/WebAuthn Authentication Processing filter
19
 *
20
 * Filter for registering or authenticating with a FIDO2/WebAuthn token after
21
 * having authenticated with the primary authsource.
22
 *
23
 * @author Stefan Winter <[email protected]>
24
 * @package SimpleSAMLphp
25
 */
26
class WebAuthnRegistrationEvent extends WebAuthnAbstractEvent
27
{
28
    /**
29
     * Public key algorithm supported. This is -7 - ECDSA with curve P-256, or -275 (RS256)
30
     */
31
    public const PK_ALGORITHM_ECDSA = "-7";
32
    public const PK_ALGORITHM_RSA = "-257";
33
    public const PK_ALGORITHM = [self::PK_ALGORITHM_ECDSA, self::PK_ALGORITHM_RSA];
34
    public const AAGUID_ASSURANCE_LEVEL_NONE = 'None';
35
    public const AAGUID_ASSURANCE_LEVEL_SELF = 'Self';
36
    public const AAGUID_ASSURANCE_LEVEL_BASIC = 'Basic';
37
    public const AAGUID_ASSURANCE_LEVEL_ATTCA = 'AttCA';
38
39
    // nomenclature from the MDS3 spec
40
    public const FIDO_REVOKED = "REVOKED";
41
    public const CERTIFICATION_NOT_REQUIRED = "CERTIFICATION_NOT_REQUIRED";
42
    public const FIDO_CERTIFIED_L1 = "FIDO_CERTIFIED_L1";
43
    public const FIDO_CERTIFIED_L1PLUS = "FIDO_CERTIFIED_L1plus";
44
    public const FIDO_CERTIFIED_L2 = "FIDO_CERTIFIED_L2";
45
    public const FIDO_CERTIFIED_L3 = "FIDO_CERTIFIED_L3";
46
    public const FIDO_CERTIFIED_L3PLUS = "FIDO_CERTIFIED_L3plus";
47
    /**
48
     * the AAGUID of the newly registered authenticator
49
     * @var string
50
     */
51
    protected string $AAGUID;
52
53
    /**
54
     * how sure are we about the AAGUID?
55
     * @var string
56
     */
57
    protected string $AAGUIDAssurance;
58
59
    /**
60
     * An array of known hardware tokens
61
     *
62
     * @var \SimpleSAML\Module\webauthn\WebAuthn\AAGUID
63
     */
64
    protected AAGUID $AAGUIDDictionary;
65
    protected string $AttFmt;
66
67
    /**
68
     * Initialize the event object.
69
     *
70
     * Validates and parses the configuration.
71
     *
72
     * @param string $pubkeyCredType  PublicKeyCredential.type
73
     * @param string $scope           the scope of the event
74
     * @param string $challenge       the challenge which was used to trigger this event
75
     * @param string $attestationData the attestation data CBOR blob
76
     * @param string $responseId      the response ID
77
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
78
     * @param bool $debugMode         print debugging statements?
79
     */
80
    public function __construct(
81
        string $pubkeyCredType,
82
        string $scope,
83
        string $challenge,
84
        string $attestationData,
85
        string $responseId,
86
        string $clientDataJSON,
87
        array $acceptabilityPolicy,
88
        bool $debugMode = false
89
    ) {
90
        $this->debugBuffer .= "attestationData raw: " . $attestationData . "<br/>";
91
        /**
92
         * §7.1 STEP 9 : CBOR decode attestationData.
93
         */
94
        $attestationArray = $this->cborDecode($attestationData);
95
        $authData = $attestationArray['authData'];
96
        $this->eventType = "REG";
97
        parent::__construct($pubkeyCredType, $scope, $challenge, $authData, $clientDataJSON, $debugMode);
98
99
        $this->AAGUIDDictionary = AAGUID::getInstance();
100
101
        // this function extracts the public key
102
        $this->validateAttestedCredentialData(substr($authData, 37), $responseId);
103
        // this function may need the public key to have been previously extracted
104
        $this->validateAttestationData($attestationData);
105
        // the following function sets the credential properties
106
        $this->debugBuffer .= "Attestation Data (bin2hex): " . bin2hex(substr($authData, 37)) . "<br/>";
107
        // now check if the authenticator is acceptable as per policy
108
        $this->verifyAcceptability($acceptabilityPolicy);
109
    }
110
111
    private function verifyAcceptability($acceptabilityPolicy)
112
    {
113
        if ($acceptabilityPolicy['minCertLevel'] == self::CERTIFICATION_NOT_REQUIRED) { // all is accepted
114
            return;
115
        }
116
117
        // if we care about the content of the attestation at all, make sure we
118
        // have a confidence level beyond "None".
119
        if ($this->AAGUIDAssurance == self::AAGUID_ASSURANCE_LEVEL_NONE) {
120
            throw new Exception("Authenticator did not provide a useful attestation level.");
121
        }
122
        if (in_array($this->AAGUID, $acceptabilityPolicy['aaguidWhitelist'])) {
123
            return;
124
        }
125
        if (in_array($this->AttFmt, $acceptabilityPolicy['attFmtWhitelist'])) {
126
            return;
127
        }
128
129
        $aaguidDb = AAGUID::getInstance();
130
        if (!$aaguidDb->hasToken($this->AAGUID)) {
131
            throw new Exception("Authenticator with AAGUID " . $this->AAGUID . " is not known to the FIDO MDS3 database.");
132
        }
133
        $authenticatorData = $aaguidDb->get($this->AAGUID);
134
        $certification = $authenticatorData['statusReports'][0]['status'];
135
136
        if ($certification == self::FIDO_REVOKED) {
137
            throw new InvalidCredential("FIDO Alliance has REVOKED certification of this device. It cannot be registered.");
138
        }
139
140
        switch ($acceptabilityPolicy['minCertLevel']) {
141
            case self::FIDO_CERTIFIED_L1:
142
                // note: always full string match - there is also a level NOT_FIDO_CERTIFIED !
143
                if ($certification == "FIDO_CERTIFIED" || $certification == self::FIDO_CERTIFIED_L1) {
144
                    return;
145
                }
146
            // intentional fall-through, higher levels are also okay
147
            case self::FIDO_CERTIFIED_L1PLUS:
148
                if ($certification == self::FIDO_CERTIFIED_L1PLUS) {
149
                    return;
150
                }
151
            // intentional fall-through, higher levels are also okay
152
            case self::FIDO_CERTIFIED_L2:
153
                if ($certification == self::FIDO_CERTIFIED_L2) {
154
                    return;
155
                }
156
            // intentional fall-through, higher levels are also okay
157
            case self::FIDO_CERTIFIED_L3:
158
                if ($certification == self::FIDO_CERTIFIED_L3) {
159
                    return;
160
                }
161
            // intentional fall-through, higher levels are also okay
162
            case self::FIDO_CERTIFIED_L3PLUS:
163
                if ($certification == self::FIDO_CERTIFIED_L3PLUS) {
164
                    return;
165
                }
166
                throw new Error("FIDO_CERTIFICATION_TOO_LOW");
167
            default:
168
                throw new Exception("Configuration error: unknown minimum certification level " . $acceptabilityPolicy['minCertLevel']);
169
        }
170
    }
171
172
    /**
173
     * Validate the incoming attestation data CBOR blob and return the embedded authData
174
     * @param string $attestationData
175
     * @return void
176
     */
177
    private function validateAttestationData(string $attestationData): void
178
    {
179
        /**
180
         * STEP 9 of the validation procedure in § 7.1 of the spec: CBOR-decode the attestationObject
181
         */
182
        $attestationArray = $this->cborDecode($attestationData);
183
        $this->debugBuffer .= "<pre>";
184
        $this->debugBuffer .= print_r($attestationArray, true);
185
        $this->debugBuffer .= "</pre>";
186
187
        /**
188
         * STEP 15 of the validation procedure in § 7.1 of the spec: verify attStmt values
189
         */
190
        $this->AttFmt = $attestationArray['fmt'];
191
        switch ($attestationArray['fmt']) {
192
            case "none":
193
                $this->validateAttestationFormatNone($attestationArray);
194
                break;
195
            case "packed":
196
                $this->validateAttestationFormatPacked($attestationArray);
197
                break;
198
            case "fido-u2f":
199
                $this->validateAttestationFormatFidoU2F($attestationArray);
200
                break;
201
            case "android-safetynet":
202
                $this->validateAttestationFormatAndroidSafetyNet($attestationArray);
203
                break;
204
            case "apple":
205
                $this->validateAttestationFormatApple($attestationArray);
206
                break;
207
            case "tpm":
208
                $this->fail("TPM attestation format not supported right now.");
209
                break;
210
            case "android-key":
211
                $this->validateAttestationFormatAndroidKey($attestationArray);
212
                break;
213
            default:
214
                $this->fail("Unknown attestation format.");
215
                break;
216
        }
217
        $this->AttFmt = $attestationArray['fmt'];
218
    }
219
220
    /**
221
     * @param array $attestationArray
222
     * @return void
223
     */
224
    private function validateAttestationFormatNone(array $attestationArray): void
225
    {
226
        // § 8.7 of the spec
227
        /**
228
         * § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array
229
         * § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy)
230
         */
231
        if (count($attestationArray['attStmt']) === 0) {
232
            $this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve.");
233
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_NONE;
234
            return;
235
        } else {
236
            $this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'.");
237
        }
238
    }
239
240
    /**
241
     * @param array $attestationArray
242
     */
243
    private function validateAttestationFormatApple(array $attestationArray): void
244
    {
245
        // found at: https://www.apple.com/certificateauthority/private/
246
247
        $APPLE_WEBAUTHN_ROOT_CA = "-----BEGIN CERTIFICATE-----
248
MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w
249
HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ
250
bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx
251
NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG
252
A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49
253
AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k
254
xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/
255
pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk
256
2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
257
MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3
258
jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B
259
1bWeT0vT
260
-----END CERTIFICATE-----";
261
        // § 8.8 Bullet 1 of the draft spec at https://pr-preview.s3.amazonaws.com/alanwaketan/webauthn/pull/1491.html#sctn-apple-anonymous-attestation
262
        // draft implemented in state of 11 Feb 2021
263
        // I can't help but notice that the verification procedure does NOTHING with CA certs from the chain, nor is there a root to validate to!
264
        // Found the root CA with Google, see above, and will perform chain validation even if the spec doesn't say so.
265
        // first, clear the openssl error backlog. We might need error data in case things go sideways.
266
        while (openssl_error_string() !== false);
267
268
        $stmtDecoded = $attestationArray['attStmt'];
269
        if (!isset($stmtDecoded['x5c'])) {
270
            $this->fail("Apple attestation statement does not contain an x5c attestation statement!");
271
        }
272
        // § 8.8 Bullet 2
273
        $nonceToHash = $attestationArray['authData'] . $this->clientDataHash;
274
        // § 8.8 Bullet 3
275
        $cryptoUtils = new Utils\Crypto();
276
        $nonce = hash("sha256", $nonceToHash, true); // does raw_output have to be FALSE or TRUE?
277
        $certProps = openssl_x509_parse($cryptoUtils->der2pem($stmtDecoded['x5c'][0]));
278
        // § 8.8 Bullet 4
279
        if (
280
                !isset($certProps['extensions']['1.2.840.113635.100.8.2']) ||
281
                empty($certProps['extensions']['1.2.840.113635.100.8.2'])
282
        ) {
283
            $this->fail("The required nonce value is not present in the OID.");
284
        }
285
        $toCompare = substr($certProps['extensions']['1.2.840.113635.100.8.2'], 6);
286
        if ($nonce != $toCompare) {
287
            $this->fail("There is a mismatch between the nonce and the OID (XXX $nonce XXX , XXX $toCompare XXX ).");
288
        }
289
290
        // chain validation first
291
        foreach ($stmtDecoded['x5c'] as $runIndex => $runCert) {
292
            if (isset($stmtDecoded['x5c'][$runIndex + 1])) { // there is a next cert, so follow the chain
293
                $certResource = openssl_x509_read($cryptoUtils->der2pem($runCert));
294
                $signerPubKey = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1]));
295
                if (openssl_x509_verify($certResource, $signerPubKey) != 1) {
296
                    $this->fail("Error during chain validation of the attestation certificate (while validating cert #$runIndex, which is "
297
                            . $cryptoUtils->der2pem($runCert)
298
                            . "; next cert was "
299
                            . $cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1]));
300
                }
301
            } else { // last cert, compare to the root
302
                $certResource = openssl_x509_read($cryptoUtils->der2pem($runCert));
303
                $signerPubKey = openssl_pkey_get_public($APPLE_WEBAUTHN_ROOT_CA);
304
                if (openssl_x509_verify($certResource, $signerPubKey) != 1) {
305
                    $this->fail(sprintf(
306
                        "Error during root CA validation of the attestation chain certificate, which is %s",
307
                        $cryptoUtils->der2pem($runCert)
308
                    ));
309
                }
310
            }
311
        }
312
313
        $keyResource = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][0]));
314
        if ($keyResource === false) {
315
            $this->fail(
316
                "Did not get a parseable X.509 structure out of the Apple attestation statement - x5c nr. 0 statement was: XXX "
317
                . $stmtDecoded['x5c'][0]
318
                . " XXX; PEM equivalent is "
319
                . $cryptoUtils->der2pem($stmtDecoded['x5c'][0])
320
                . ". OpenSSL error: "
321
                . openssl_error_string()
322
            );
323
        }
324
325
        // $this->credential is a public key in CBOR, not "PEM". We need to convert it first.
326
        $keyArray = $this->cborDecode(hex2bin($this->credential));
327
        $keyObject = new Ec2Key($keyArray);
328
        $credentialResource = openssl_pkey_get_public($keyObject->asPEM());
329
330
        if ($credentialResource === false) {
331
            $this->fail(
332
                "Could not create a public key from CBOR credential. XXX "
333
                . $this->credential
334
                . " XXX; PEM equivalent is "
335
                . $keyObject->asPEM()
336
                . ". OpenSSL error: "
337
                . openssl_error_string()
338
            );
339
        }
340
341
        // § 8.8 Bullet 5
342
        $credentialDetails = openssl_pkey_get_details($credentialResource);
343
        $keyDetails = openssl_pkey_get_details($keyResource);
344
        if (
345
            $credentialDetails['bits'] != $keyDetails['bits'] ||
346
            $credentialDetails['key'] != $keyDetails['key'] ||
347
            $credentialDetails['type'] != $keyDetails['type']
348
        ) {
349
            $this->fail(
350
                "The credential public key does not match the certificate public key in attestationData. ("
351
                . $credentialDetails['key']
352
                . " - "
353
                . $keyDetails['key']
354
                . ")"
355
            );
356
        }
357
        $this->pass("Apple attestation format verification passed.");
358
        return;
359
    }
360
361
private function commonX5cSignatureChecks(array $attestationArray): void
362
    {
363
        $stmtDecoded = $attestationArray['attStmt'];
364
        /**
365
         * §8.2 Step 4 Bullet 1: check algorithm
366
         */
367
        if (!in_array($stmtDecoded['alg'], self::PK_ALGORITHM)) {
368
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
369
        }
370
        $keyObject = null;
371
        switch ($stmtDecoded['alg']) {
372
            case self::PK_ALGORITHM_ECDSA:
373
                $keyObject = new Ec2Key($this->cborDecode(hex2bin($this->credential)));
374
                $keyResource = openssl_pkey_get_public($keyObject->asPEM());
375
                if ($keyResource === false) {
376
                    $this->fail("Unable to construct ECDSA public key resource from PEM.");
377
                };
378
                break;
379
            case self::PK_ALGORITHM_RSA:
380
                $keyObject = new RsaKey($this->cborDecode(hex2bin($this->credential)));
381
                $keyResource = openssl_pkey_get_public($keyObject->asPEM());
382
                if ($keyResource === false) {
383
                    $this->fail("Unable to construct RSA public key resource from PEM.");
384
                }
385
                break;
386
            default:
387
                $this->fail("Unable to construct public key resource from PEM.");
388
        }
389
        /**
390
         * §8.2 Step 2: check x5c attestation
391
         */
392
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
393
        /**
394
         * §8.2 Step 2 Bullet 1: check signature
395
         */
396
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) !== 1) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $keyResource does not seem to be defined for all execution paths leading up to this point.
Loading history...
397
            $this->fail("x5c attestation failed.");
398
        }
399
        $this->pass("x5c sig check passed.");        
400
    }
401
    
402
    /**
403
     * @param array $attestationArray
404
     */
405
    private function validateAttestationFormatPacked(array $attestationArray): void
406
    {
407
        $stmtDecoded = $attestationArray['attStmt'];
408
        $this->debugBuffer .= "AttStmt: " . print_r($stmtDecoded, true) . "<br/>";
0 ignored issues
show
Bug introduced by
Are you sure print_r($stmtDecoded, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

408
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
Loading history...
409
        $this->commonX5cSignatureChecks($attestationArray);
410
        /**
411
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
412
         */
413
        if (isset($stmtDecoded['x5c'])) {
414
            $this->validateAttestationFormatPackedX5C($attestationArray);
415
        } elseif (isset($stmtDecoded['ecdaa'])) {
416
            $this->fail("ecdaa attestation not supported right now.");
417
        } else {
418
            // if we are still here, we are in the "self" type.
419
            // signature checks already done, nothing more to do
420
            $this->pass("Self-Attestation veried.");
421
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
422
        }
423
    }
424
425
    /**
426
     * @param array $attestationArray
427
     * @return void
428
     */
429
    private function validateAttestationFormatPackedX5C(array $attestationArray): void
430
    {
431
        $stmtDecoded = $attestationArray['attStmt'];
432
        // still need to perform sanity checks on the attestation certificate
433
        /**
434
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
435
         */
436
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
437
        $this->debugBuffer .= "Attestation Certificate:" . print_r($certProps, true) . "<br/>";
0 ignored issues
show
Bug introduced by
Are you sure print_r($certProps, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

437
        $this->debugBuffer .= "Attestation Certificate:" . /** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>";
Loading history...
438
        if (
439
                $certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
440
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
441
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
442
                !isset($certProps['extensions']['basicConstraints']) ||
443
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
444
        ) {
445
            $this->fail("Attestation certificate properties are no good.");
446
        }
447
448
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
449
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
450
            /**
451
             * Checking the OID is not programmatically possible. Text per spec:
452
             * "If the related attetation root certificate is used for multiple
453
             * authenticator models, the Extension OID ... MUST be present."
454
             *
455
             * FIDO MDS3 metadata does not disclose whether the root CAs are
456
             * used for multiple models.
457
             */
458
            /* if ($token['multi'] === true) { // need to check the OID
459
                if (
460
                        !isset($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']) || empty($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4'])
461
                ) { // §8.2.1 Bullet 3
462
                    $this->fail(
463
                            "This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID."
464
                    );
465
                }
466
                /**
467
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
468
                 */
469
                /* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
470
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
471
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
472
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
473
                }
474
            }*/
475
            // we would need to verify the attestation certificate against a known-good
476
            // root CA certificate to get more than basic
477
            /*
478
             * §7.1 Step 17 is to look at $token['RootPEMs']
479
             */
480
            foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) {
481
                $caData = openssl_x509_parse("-----BEGIN CERTIFICATE-----\n$oneRoot\n-----END CERTIFICATE-----", true);
0 ignored issues
show
Unused Code introduced by
The assignment to $caData is dead and can be removed.
Loading history...
482
            }
483
            /*
484
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
485
             */
486
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
487
        } else {
488
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
489
            // unable to verify all cert properties, so this is not enough for BASIC.
490
            // but it's our own fault, we should add the device to our DB.
491
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
492
        }
493
        $this->pass("x5c attestation passed.");
494
        return;
495
    }
496
497
private function validateAttestationFormatAndroidKey(array $attestationArray): void
498
    {
499
        $stmtDecoded = $attestationArray['attStmt'];
500
        $this->debugBuffer .= "AttStmt: " . print_r($stmtDecoded, true) . "<br/>";
0 ignored issues
show
Bug introduced by
Are you sure print_r($stmtDecoded, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

500
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
Loading history...
501
        $this->commonX5cSignatureChecks($attestationArray);    
502
        // first certificate's properties
503
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
504
        
505
        if (
506
            $attestationArray['authData']['attestedCredentialData']['credentialPublicKey']
507
            !==
508
            $certProps['publicKey']
509
            )
510
        {
511
            $this->fail("Certificate public key does not match credentialPublicKey in authenticatorData.");
512
        }
513
        if (
514
            $this->clientDataHash 
515
            !==
516
            $certProps['policyOID']['1.3.6.1.4.1.11129.2.1.17']['attestationChallenge']
517
            ) 
518
        {
519
            $this->fail("ClientDataHash is not in certificate's extension data.");
520
        }
521
            
522
            
523
            
524
        $this->fail("Still need to do Android-Key specific further checks.");
525
    }
526
    
527
    /**
528
     * support legacy U2F tokens
529
     *
530
     * @param array $attestationData the incoming attestation data
531
     * @return void
532
     */
533
    private function validateAttestationFormatFidoU2F(array $attestationData): void
534
    {
535
        /**
536
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
537
         * already successfully CBOR decoded
538
         */
539
        $stmtDecoded = $attestationData['attStmt'];
540
        if (!isset($stmtDecoded['x5c'])) {
541
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
542
        }
543
        /**
544
         * §8.6 Verification Step 2: extract attCert and sanity check it
545
         */
546
        if (count($stmtDecoded['x5c']) !== 1) {
547
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
548
        }
549
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
550
        $key = openssl_pkey_get_public($attCert);
551
        $keyProps = openssl_pkey_get_details($key);
552
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
553
            $this->fail("FIDO U2F attestation public key is not P-256!");
554
        }
555
        /**
556
         * §8.6 Verification Step 3 is a NOOP as these properties are already
557
         * available as class members:
558
         *
559
         * $this->rpIdHash;
560
         * $this->credentialId;
561
         * $this->credential;
562
         */
563
        /**
564
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
565
         */
566
        if (
567
                isset($this->credential[-2]) &&
568
                strlen($this->credential[-2]) === 32 &&
569
                isset($this->credential[-3]) &&
570
                strlen($this->credential[-3]) === 32
571
        ) {
572
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
573
        } else {
574
            $publicKeyU2F = false;
575
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
576
        }
577
        /**
578
         * §8.6 Verification Step 5: create verificationData
579
         *
580
         * @psalm-var string $publicKeyU2F
581
         */
582
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
583
        /**
584
         * §8.6 Verification Step 6: verify signature
585
         */
586
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
587
            $this->fail("FIDO U2F Attestation verification failed.");
588
        } else {
589
            $this->pass("Successfully verified FIDO U2F signature.");
590
        }
591
        /**
592
         * §8.6 Verification Step 7: not performed, this is optional as per spec
593
         */
594
        /**
595
         * §8.6 Verification Step 8: so we always settle for "Basic"
596
         */
597
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
598
    }
599
600
    /**
601
     * support Android authenticators (fingerprint etc.)
602
     *
603
     * @param array $attestationData the incoming attestation data
604
     * @return void
605
     */
606
    private function validateAttestationFormatAndroidSafetyNet(array $attestationData): void
0 ignored issues
show
Unused Code introduced by
The parameter $attestationData is not used and could be removed. ( Ignorable by Annotation )

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

606
    private function validateAttestationFormatAndroidSafetyNet(/** @scrutinizer ignore-unused */ array $attestationData): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
607
    {
608
    }
609
610
    /**
611
     * The registration contains the actual credential. This function parses it.
612
     * @param string $attData    the attestation data binary blob
613
     * @param string $responseId the response ID
614
     * @return void
615
     */
616
    private function validateAttestedCredentialData(string $attData, string $responseId): void
617
    {
618
        $aaguid = substr($attData, 0, 16);
619
        $credIdLenBytes = substr($attData, 16, 2);
620
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
621
        $credId = substr($attData, 18, $credIdLen);
622
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
623
        $this->AAGUID = bin2hex($aaguid);
624
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
625
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
626
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
627
        if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) {
628
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
629
        } else {
630
            $this->fail(
631
                "Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" .
632
                bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ")."
633
            );
634
        }
635
        // so far so good. Now extract the actual public key from its COSE
636
        // encoding.
637
        // finding out the number of bytes to CBOR decode appears non-trivial.
638
        // The simple case is if no ED is present as the CBOR data then goes to
639
        // the end of the byte sequence.
640
        // since we don't know the algoritm yet, we don't know how many bytes
641
        // of credential CBOR follow. Let's read to the end; the CBOR decoder
642
        // silently ignores trailing extensions (if any)
643
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
644
        $arrayPK = $this->cborDecode($pubKeyCBOR);
645
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . print_r($arrayPK, true) . "</pre>";
0 ignored issues
show
Bug introduced by
Are you sure print_r($arrayPK, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

645
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . /** @scrutinizer ignore-type */ print_r($arrayPK, true) . "</pre>";
Loading history...
646
        /**
647
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
648
         */
649
        if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here
650
            $this->algo = (int)$arrayPK['3'];
651
            $this->pass("Public Key Algorithm is expected (" . implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) . ").");
652
        } else {
653
            $this->fail("Public Key Algorithm mismatch!");
654
        }
655
        $this->credentialId = bin2hex($credId);
656
        $this->credential = bin2hex($pubKeyCBOR);
657
658
        // now that we know credential and its length, we can CBOR-decode the
659
        // trailing extensions
660
        switch ($this->algo) {
661
            case self::PK_ALGORITHM_ECDSA:
662
                $credentialLength = 77;
663
                break;
664
            case self::PK_ALGORITHM_RSA:
665
                $credentialLength = 272;
666
                break;
667
            default:
668
                $this->fail("No credential length information for $this->algo");
669
        }
670
        $extensions = substr($attData, 18 + $credIdLen + $credentialLength);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $credentialLength does not seem to be defined for all execution paths leading up to this point.
Loading history...
671
        if (strlen($extensions) !== 0) {
672
            $this->pass("Found the following extensions (" . strlen($extensions) . " bytes) during registration ceremony: ");
673
        }
674
    }
675
676
    /**
677
     * transform DER formatted certificate to PEM format
678
     *
679
     * @param string $derData blob of DER data
680
     * @return string the PEM representation of the certificate
681
     */
682
    private function der2pem(string $derData): string
683
    {
684
        $pem = chunk_split(base64_encode($derData), 64, "\n");
685
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
686
        return $pem;
687
    }
688
689
    /**
690
     * @return string
691
     */
692
    public function getAAGUID()
693
    {
694
        return $this->AAGUID;
695
    }
696
697
    /**
698
     * @return string
699
     */
700
    public function getAttestationLevel()
701
    {
702
        return $this->AAGUIDAssurance;
703
    }
704
}
705