Passed
Pull Request — master (#56)
by Stefan
09:30
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.");
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment if this fall-through is intended.
Loading history...
209
            case "android-key":
210
                $this->validateAttestationFormatAndroidKey($attestationArray);
211
                break;
212
            default:
213
                $this->fail("Unknown attestation format.");
214
                break;
215
        }
216
        $this->AttFmt = $attestationArray['fmt'];
217
    }
218
219
    /**
220
     * @param array $attestationArray
221
     * @return void
222
     */
223
    private function validateAttestationFormatNone(array $attestationArray): void
224
    {
225
        // § 8.7 of the spec
226
        /**
227
         * § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array
228
         * § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy)
229
         */
230
        if (count($attestationArray['attStmt']) === 0) {
231
            $this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve.");
232
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_NONE;
233
            return;
234
        } else {
235
            $this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'.");
236
        }
237
    }
238
239
    /**
240
     * @param array $attestationArray
241
     */
242
    private function validateAttestationFormatApple(array $attestationArray): void
243
    {
244
        // found at: https://www.apple.com/certificateauthority/private/
245
246
        $APPLE_WEBAUTHN_ROOT_CA = "-----BEGIN CERTIFICATE-----
247
MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w
248
HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ
249
bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx
250
NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG
251
A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49
252
AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k
253
xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/
254
pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk
255
2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
256
MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3
257
jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B
258
1bWeT0vT
259
-----END CERTIFICATE-----";
260
        // § 8.8 Bullet 1 of the draft spec at https://pr-preview.s3.amazonaws.com/alanwaketan/webauthn/pull/1491.html#sctn-apple-anonymous-attestation
261
        // draft implemented in state of 11 Feb 2021
262
        // 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!
263
        // Found the root CA with Google, see above, and will perform chain validation even if the spec doesn't say so.
264
        // first, clear the openssl error backlog. We might need error data in case things go sideways.
265
        while (openssl_error_string() !== false);
266
267
        $stmtDecoded = $attestationArray['attStmt'];
268
        if (!isset($stmtDecoded['x5c'])) {
269
            $this->fail("Apple attestation statement does not contain an x5c attestation statement!");
270
        }
271
        // § 8.8 Bullet 2
272
        $nonceToHash = $attestationArray['authData'] . $this->clientDataHash;
273
        // § 8.8 Bullet 3
274
        $cryptoUtils = new Utils\Crypto();
275
        $nonce = hash("sha256", $nonceToHash, true); // does raw_output have to be FALSE or TRUE?
276
        $certProps = openssl_x509_parse($cryptoUtils->der2pem($stmtDecoded['x5c'][0]));
277
        // § 8.8 Bullet 4
278
        if (
279
                !isset($certProps['extensions']['1.2.840.113635.100.8.2']) ||
280
                empty($certProps['extensions']['1.2.840.113635.100.8.2'])
281
        ) {
282
            $this->fail("The required nonce value is not present in the OID.");
283
        }
284
        $toCompare = substr($certProps['extensions']['1.2.840.113635.100.8.2'], 6);
285
        if ($nonce != $toCompare) {
286
            $this->fail("There is a mismatch between the nonce and the OID (XXX $nonce XXX , XXX $toCompare XXX ).");
287
        }
288
289
        // chain validation first
290
        foreach ($stmtDecoded['x5c'] as $runIndex => $runCert) {
291
            if (isset($stmtDecoded['x5c'][$runIndex + 1])) { // there is a next cert, so follow the chain
292
                $certResource = openssl_x509_read($cryptoUtils->der2pem($runCert));
293
                $signerPubKey = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1]));
294
                if (openssl_x509_verify($certResource, $signerPubKey) != 1) {
295
                    $this->fail("Error during chain validation of the attestation certificate (while validating cert #$runIndex, which is "
296
                            . $cryptoUtils->der2pem($runCert)
297
                            . "; next cert was "
298
                            . $cryptoUtils->der2pem($stmtDecoded['x5c'][$runIndex + 1]));
299
                }
300
            } else { // last cert, compare to the root
301
                $certResource = openssl_x509_read($cryptoUtils->der2pem($runCert));
302
                $signerPubKey = openssl_pkey_get_public($APPLE_WEBAUTHN_ROOT_CA);
303
                if (openssl_x509_verify($certResource, $signerPubKey) != 1) {
304
                    $this->fail(sprintf(
305
                        "Error during root CA validation of the attestation chain certificate, which is %s",
306
                        $cryptoUtils->der2pem($runCert)
307
                    ));
308
                }
309
            }
310
        }
311
312
        $keyResource = openssl_pkey_get_public($cryptoUtils->der2pem($stmtDecoded['x5c'][0]));
313
        if ($keyResource === false) {
314
            $this->fail(
315
                "Did not get a parseable X.509 structure out of the Apple attestation statement - x5c nr. 0 statement was: XXX "
316
                . $stmtDecoded['x5c'][0]
317
                . " XXX; PEM equivalent is "
318
                . $cryptoUtils->der2pem($stmtDecoded['x5c'][0])
319
                . ". OpenSSL error: "
320
                . openssl_error_string()
321
            );
322
        }
323
324
        // $this->credential is a public key in CBOR, not "PEM". We need to convert it first.
325
        $keyArray = $this->cborDecode(hex2bin($this->credential));
326
        $keyObject = new Ec2Key($keyArray);
327
        $credentialResource = openssl_pkey_get_public($keyObject->asPEM());
328
329
        if ($credentialResource === false) {
330
            $this->fail(
331
                "Could not create a public key from CBOR credential. XXX "
332
                . $this->credential
333
                . " XXX; PEM equivalent is "
334
                . $keyObject->asPEM()
335
                . ". OpenSSL error: "
336
                . openssl_error_string()
337
            );
338
        }
339
340
        // § 8.8 Bullet 5
341
        $credentialDetails = openssl_pkey_get_details($credentialResource);
342
        $keyDetails = openssl_pkey_get_details($keyResource);
343
        if (
344
            $credentialDetails['bits'] != $keyDetails['bits'] ||
345
            $credentialDetails['key'] != $keyDetails['key'] ||
346
            $credentialDetails['type'] != $keyDetails['type']
347
        ) {
348
            $this->fail(
349
                "The credential public key does not match the certificate public key in attestationData. ("
350
                . $credentialDetails['key']
351
                . " - "
352
                . $keyDetails['key']
353
                . ")"
354
            );
355
        }
356
        $this->pass("Apple attestation format verification passed.");
357
        return;
358
    }
359
360
private function commonX5cSignatureChecks(array $attestationArray): void
361
    {
362
        $stmtDecoded = $attestationArray['attStmt'];
363
        /**
364
         * §8.2 Step 4 Bullet 1: check algorithm
365
         */
366
        if (!in_array($stmtDecoded['alg'], self::PK_ALGORITHM)) {
367
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
368
        }
369
        $keyObject = null;
370
        switch ($stmtDecoded['alg']) {
371
            case self::PK_ALGORITHM_ECDSA:
372
                $keyObject = new Ec2Key($this->cborDecode(hex2bin($this->credential)));
373
                $keyResource = openssl_pkey_get_public($keyObject->asPEM());
374
                if ($keyResource === false) {
375
                    $this->fail("Unable to construct ECDSA public key resource from PEM.");
376
                };
377
                break;
378
            case self::PK_ALGORITHM_RSA:
379
                $keyObject = new RsaKey($this->cborDecode(hex2bin($this->credential)));
380
                $keyResource = openssl_pkey_get_public($keyObject->asPEM());
381
                if ($keyResource === false) {
382
                    $this->fail("Unable to construct RSA public key resource from PEM.");
383
                }
384
                break;
385
            default:
386
                $this->fail("Unable to construct public key resource from PEM.");
387
        }
388
        /**
389
         * §8.2 Step 2: check x5c attestation
390
         */
391
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
392
        /**
393
         * §8.2 Step 2 Bullet 1: check signature
394
         */
395
        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...
396
            $this->fail("x5c attestation failed.");
397
        }
398
        $this->pass("x5c sig check passed.");        
399
    }
400
    
401
    /**
402
     * @param array $attestationArray
403
     */
404
    private function validateAttestationFormatPacked(array $attestationArray): void
405
    {
406
        $stmtDecoded = $attestationArray['attStmt'];
407
        $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

407
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
Loading history...
408
        $this->commonX5cSignatureChecks($attestationArray);
409
        /**
410
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
411
         */
412
        if (isset($stmtDecoded['x5c'])) {
413
            $this->validateAttestationFormatPackedX5C($attestationArray);
414
        } elseif (isset($stmtDecoded['ecdaa'])) {
415
            $this->fail("ecdaa attestation not supported right now.");
416
        } else {
417
            // if we are still here, we are in the "self" type.
418
            $this->validateAttestationFormatPackedSelf($attestationArray);
419
        }
420
    }
421
422
    /**
423
     * @param array $attestationArray
424
     * @return void
425
     */
426
    private function validateAttestationFormatPackedX5C(array $attestationArray): void
427
    {
428
        $stmtDecoded = $attestationArray['attStmt'];
429
        // still need to perform sanity checks on the attestation certificate
430
        /**
431
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
432
         */
433
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
434
        $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

434
        $this->debugBuffer .= "Attestation Certificate:" . /** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>";
Loading history...
435
        if (
436
                $certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
437
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
438
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
439
                !isset($certProps['extensions']['basicConstraints']) ||
440
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
441
        ) {
442
            $this->fail("Attestation certificate properties are no good.");
443
        }
444
445
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
446
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
447
            /**
448
             * Checking the OID is not programmatically possible. Text per spec:
449
             * "If the related attetation root certificate is used for multiple
450
             * authenticator models, the Extension OID ... MUST be present."
451
             *
452
             * FIDO MDS3 metadata does not disclose whether the root CAs are
453
             * used for multiple models.
454
             */
455
            /* if ($token['multi'] === true) { // need to check the OID
456
                if (
457
                        !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'])
458
                ) { // §8.2.1 Bullet 3
459
                    $this->fail(
460
                            "This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID."
461
                    );
462
                }
463
                /**
464
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
465
                 */
466
                /* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
467
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
468
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
469
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
470
                }
471
            }*/
472
            // we would need to verify the attestation certificate against a known-good
473
            // root CA certificate to get more than basic
474
            /*
475
             * §7.1 Step 17 is to look at $token['RootPEMs']
476
             */
477
            foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) {
478
                $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...
479
            }
480
            /*
481
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
482
             */
483
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
484
        } else {
485
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
486
            // unable to verify all cert properties, so this is not enough for BASIC.
487
            // but it's our own fault, we should add the device to our DB.
488
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
489
        }
490
        $this->pass("x5c attestation passed.");
491
        return;
492
    }
493
494
    /**
495
     * @param array $attestationArray
496
     * @return void
497
     */
498
    private function validateAttestationFormatPackedSelf(array $attestationArray): void
499
    {
500
        $stmtDecoded = $attestationArray['attStmt'];
501
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
502
        /**
503
         * §8.2 Step 4 Bullet 2: verify signature
504
         */
505
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) === 1) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $keyResource seems to be never defined.
Loading history...
506
            $this->pass("Self-Attestation veried.");
507
            /**
508
             * §8.2 Step 4 Bullet 3: return Self level
509
             */
510
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
511
        } else {
512
            $this->fail("Self-Attestation failed.");
513
        }
514
    }
515
516
private function validateAttestationFormatAndroidKey(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

516
private function validateAttestationFormatAndroidKey(/** @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...
517
    {
518
        $stmtDecoded = $attestationArray['attStmt'];
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $attestationArray does not exist. Did you maybe mean $attestationData?
Loading history...
519
        $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

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

625
    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...
626
    {
627
    }
628
629
    /**
630
     * The registration contains the actual credential. This function parses it.
631
     * @param string $attData    the attestation data binary blob
632
     * @param string $responseId the response ID
633
     * @return void
634
     */
635
    private function validateAttestedCredentialData(string $attData, string $responseId): void
636
    {
637
        $aaguid = substr($attData, 0, 16);
638
        $credIdLenBytes = substr($attData, 16, 2);
639
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
640
        $credId = substr($attData, 18, $credIdLen);
641
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
642
        $this->AAGUID = bin2hex($aaguid);
643
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
644
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
645
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
646
        if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) {
647
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
648
        } else {
649
            $this->fail(
650
                "Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" .
651
                bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ")."
652
            );
653
        }
654
        // so far so good. Now extract the actual public key from its COSE
655
        // encoding.
656
        // finding out the number of bytes to CBOR decode appears non-trivial.
657
        // The simple case is if no ED is present as the CBOR data then goes to
658
        // the end of the byte sequence.
659
        // since we don't know the algoritm yet, we don't know how many bytes
660
        // of credential CBOR follow. Let's read to the end; the CBOR decoder
661
        // silently ignores trailing extensions (if any)
662
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
663
        $arrayPK = $this->cborDecode($pubKeyCBOR);
664
        $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

664
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . /** @scrutinizer ignore-type */ print_r($arrayPK, true) . "</pre>";
Loading history...
665
        /**
666
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
667
         */
668
        if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here
669
            $this->algo = (int)$arrayPK['3'];
670
            $this->pass("Public Key Algorithm is expected (" . implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) . ").");
671
        } else {
672
            $this->fail("Public Key Algorithm mismatch!");
673
        }
674
        $this->credentialId = bin2hex($credId);
675
        $this->credential = bin2hex($pubKeyCBOR);
676
677
        // now that we know credential and its length, we can CBOR-decode the
678
        // trailing extensions
679
        switch ($this->algo) {
680
            case self::PK_ALGORITHM_ECDSA:
681
                $credentialLength = 77;
682
                break;
683
            case self::PK_ALGORITHM_RSA:
684
                $credentialLength = 272;
685
                break;
686
            default:
687
                $this->fail("No credential length information for $this->algo");
688
        }
689
        $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...
690
        if (strlen($extensions) !== 0) {
691
            $this->pass("Found the following extensions (" . strlen($extensions) . " bytes) during registration ceremony: ");
692
        }
693
    }
694
695
    /**
696
     * transform DER formatted certificate to PEM format
697
     *
698
     * @param string $derData blob of DER data
699
     * @return string the PEM representation of the certificate
700
     */
701
    private function der2pem(string $derData): string
702
    {
703
        $pem = chunk_split(base64_encode($derData), 64, "\n");
704
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
705
        return $pem;
706
    }
707
708
    /**
709
     * @return string
710
     */
711
    public function getAAGUID()
712
    {
713
        return $this->AAGUID;
714
    }
715
716
    /**
717
     * @return string
718
     */
719
    public function getAttestationLevel()
720
    {
721
        return $this->AAGUIDAssurance;
722
    }
723
}
724