Passed
Push — master ( 1e40bb...6078d7 )
by Stefan
02:21
created

WebAuthnRegistrationEvent::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 29
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 10
c 2
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
namespace SimpleSAML\Module\webauthn\WebAuthn;
4
5
use Cose\Key\Ec2Key;
6
use Cose\Key\RsaKey;
7
use SimpleSAML\Logger;
8
use SimpleSAML\Module\webauthn\WebAuthn\AAGUID;
9
use SimpleSAML\Utils;
10
use SimpleSAML\Utils\Config as SSPConfig;
11
use \Exception;
0 ignored issues
show
Bug introduced by
The type \Exception was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

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

348
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
Loading history...
349
        /**
350
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
351
         */
352
        if (isset($stmtDecoded['x5c'])) {
353
            $this->validateAttestationFormatPackedX5C($attestationArray);
354
        } elseif (isset($stmtDecoded['ecdaa'])) {
355
            $this->fail("ecdaa attestation not supported right now.");
356
        } else {
357
            // if we are still here, we are in the "self" type.
358
            $this->validateAttestationFormatPackedSelf($attestationArray);
359
        }
360
    }
361
362
    /**
363
     * @param array $attestationArray
364
     * @return void
365
     */
366
    private function validateAttestationFormatPackedX5C(array $attestationArray): void {
367
        $stmtDecoded = $attestationArray['attStmt'];
368
        /**
369
         * §8.2 Step 2: check x5c attestation
370
         */
371
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
372
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
373
        if ($keyResource === false) {
374
            $this->fail("Unable to construct public key resource from PEM.");
375
        }
376
        /**
377
         * §8.2 Step 2 Bullet 1: check signature
378
         */
379
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) !== 1) {
380
            $this->fail("x5c attestation failed.");
381
        }
382
        $this->pass("x5c sig check passed.");
383
        // still need to perform sanity checks on the attestation certificate
384
        /**
385
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
386
         */
387
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
388
        $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

388
        $this->debugBuffer .= "Attestation Certificate:" . /** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>";
Loading history...
389
        if (
390
                $certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
391
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
392
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
393
                !isset($certProps['extensions']['basicConstraints']) ||
394
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
395
        ) {
396
            $this->fail("Attestation certificate properties are no good.");
397
        }
398
399
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
400
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
401
            /**
402
             * Checking the OID is not programmatically possible. Text per spec:
403
             * "If the related attetation root certificate is used for multiple
404
             * authenticator models, the Extension OID ... MUST be present."
405
             * 
406
             * FIDO MDS3 metadata does not disclose whether the root CAs are
407
             * used for multiple models.
408
             */
409
            /* if ($token['multi'] === true) { // need to check the OID
410
                if (
411
                        !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'])
412
                ) { // §8.2.1 Bullet 3
413
                    $this->fail(
414
                            "This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID."
415
                    );
416
                }
417
                /**
418
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
419
                 */
420
                /* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
421
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
422
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
423
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
424
                }
425
            }*/
426
            // we would need to verify the attestation certificate against a known-good
427
            // root CA certificate to get more than basic
428
            /*
429
             * §7.1 Step 17 is to look at $token['RootPEMs']
430
             */
431
            foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) {
432
                $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...
433
                
434
            }
435
            /*
436
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
437
             */
438
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
439
        } else {
440
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
441
            // unable to verify all cert properties, so this is not enough for BASIC.
442
            // but it's our own fault, we should add the device to our DB.
443
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
444
        }
445
        $this->pass("x5c attestation passed.");
446
        return;
447
    }
448
449
    /**
450
     * @param array $attestationArray
451
     * @return void
452
     */
453
    private function validateAttestationFormatPackedSelf(array $attestationArray): void {
454
        $stmtDecoded = $attestationArray['attStmt'];
455
        /**
456
         * §8.2 Step 4 Bullet 1: check algorithm
457
         */
458
        if (!in_array($stmtDecoded['alg'], self::PK_ALGORITHM)) {
459
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
460
        }
461
        $keyObject = null;
462
        switch ($stmtDecoded['alg']) {
463
            case self::PK_ALGORITHM_ECDSA:
464
                $keyObject = new Ec2Key($this->cborDecode(hex2bin($this->credential)));
465
                $keyResource = openssl_pkey_get_public($keyObject->asPEM());
466
                if ($keyResource === false) {
467
                    $this->fail("Unable to construct ECDSA public key resource from PEM.");
468
                };
469
                break;
470
            case self::PK_ALGORITHM_RSA:
471
                $keyObject = new RsaKey($this->cborDecode(hex2bin($this->credential)));
472
                $keyResource = openssl_pkey_get_public($keyObject->asPEM());
473
                if ($keyResource === false) {
474
                    $this->fail("Unable to construct RSA public key resource from PEM.");
475
                }
476
                break;
477
            default:
478
                $this->fail("Unable to construct public key resource from PEM.");
479
        }
480
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
481
        /**
482
         * §8.2 Step 4 Bullet 2: verify signature
483
         */
484
        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...
485
            $this->pass("Self-Attestation veried.");
486
            /**
487
             * §8.2 Step 4 Bullet 3: return Self level
488
             */
489
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
490
        } else {
491
            $this->fail("Self-Attestation failed.");
492
        }
493
    }
494
495
    /**
496
     * support legacy U2F tokens
497
     *
498
     * @param array $attestationData the incoming attestation data
499
     * @return void
500
     */
501
    private function validateAttestationFormatFidoU2F(array $attestationData): void {
502
        /**
503
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
504
         * already successfully CBOR decoded
505
         */
506
        $stmtDecoded = $attestationData['attStmt'];
507
        if (!isset($stmtDecoded['x5c'])) {
508
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
509
        }
510
        /**
511
         * §8.6 Verification Step 2: extract attCert and sanity check it
512
         */
513
        if (count($stmtDecoded['x5c']) !== 1) {
514
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
515
        }
516
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
517
        $key = openssl_pkey_get_public($attCert);
518
        $keyProps = openssl_pkey_get_details($key);
519
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
520
            $this->fail("FIDO U2F attestation public key is not P-256!");
521
        }
522
        /**
523
         * §8.6 Verification Step 3 is a NOOP as these properties are already
524
         * available as class members:
525
         *
526
         * $this->rpIdHash;
527
         * $this->credentialId;
528
         * $this->credential;
529
         */
530
        /**
531
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
532
         */
533
        if (
534
                isset($this->credential[-2]) &&
535
                strlen($this->credential[-2]) === 32 &&
536
                isset($this->credential[-3]) &&
537
                strlen($this->credential[-3]) === 32
538
        ) {
539
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
540
        } else {
541
            $publicKeyU2F = false;
542
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
543
        }
544
        /**
545
         * §8.6 Verification Step 5: create verificationData
546
         *
547
         * @psalm-var string $publicKeyU2F
548
         */
549
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
550
        /**
551
         * §8.6 Verification Step 6: verify signature
552
         */
553
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
554
            $this->fail("FIDO U2F Attestation verification failed.");
555
        } else {
556
            $this->pass("Successfully verified FIDO U2F signature.");
557
        }
558
        /**
559
         * §8.6 Verification Step 7: not performed, this is optional as per spec
560
         */
561
        /**
562
         * §8.6 Verification Step 8: so we always settle for "Basic"
563
         */
564
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
565
    }
566
567
    /**
568
     * support Android authenticators (fingerprint etc.)
569
     *
570
     * @param array $attestationData the incoming attestation data
571
     * @return void
572
     */
573
    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

573
    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...
574
        
575
    }
576
577
    /**
578
     * The registration contains the actual credential. This function parses it.
579
     * @param string $attData    the attestation data binary blob
580
     * @param string $responseId the response ID
581
     * @return void
582
     */
583
    private function validateAttestedCredentialData(string $attData, string $responseId): void {
584
        $aaguid = substr($attData, 0, 16);
585
        $credIdLenBytes = substr($attData, 16, 2);
586
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
587
        $credId = substr($attData, 18, $credIdLen);
588
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
589
        $this->AAGUID = bin2hex($aaguid);
590
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
591
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
592
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
593
        if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) {
594
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
595
        } else {
596
            $this->fail(
597
                    "Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" .
598
                    bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ")."
599
            );
600
        }
601
        // so far so good. Now extract the actual public key from its COSE
602
        // encoding.
603
        // finding out the number of bytes to CBOR decode appears non-trivial.
604
        // The simple case is if no ED is present as the CBOR data then goes to
605
        // the end of the byte sequence.
606
        // since we don't know the algoritm yet, we don't know how many bytes
607
        // of credential CBOR follow. Let's read to the end; the CBOR decoder
608
        // silently ignores trailing extensions (if any)
609
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
610
        $arrayPK = $this->cborDecode($pubKeyCBOR);
611
        $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

611
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . /** @scrutinizer ignore-type */ print_r($arrayPK, true) . "</pre>";
Loading history...
612
        /**
613
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
614
         */
615
        if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here
616
            $this->algo = $arrayPK['3'];
617
            $this->pass("Public Key Algorithm is expected (" . implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) . ").");
618
        } else {
619
            $this->fail("Public Key Algorithm mismatch!");
620
        }
621
        $this->credentialId = bin2hex($credId);
622
        $this->credential = bin2hex($pubKeyCBOR);
623
624
        // now that we know credential and its length, we can CBOR-decode the
625
        // trailing extensions
626
        switch ($this->algo) {
627
            case self::PK_ALGORITHM_ECDSA:
628
                $credentialLength = 77;
629
                break;
630
            case self::PK_ALGORITHM_RSA:
631
                $credentialLength = 272;
632
                break;
633
            default:
634
                $this->fail("No credential length information for $this->algo");
635
        }
636
        $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...
637
        if (strlen($extensions) !== 0) {
638
            $this->pass("Found the following extensions (" . strlen($extensions) . " bytes) during registration ceremony: ");
639
        }
640
    }
641
642
    /**
643
     * transform DER formatted certificate to PEM format
644
     *
645
     * @param string $derData blob of DER data
646
     * @return string the PEM representation of the certificate
647
     */
648
    private function der2pem(string $derData): string {
649
        $pem = chunk_split(base64_encode($derData), 64, "\n");
650
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
651
        return $pem;
652
    }
653
654
    /**
655
     * @return string
656
     */
657
    public function getAAGUID() {
658
        return $this->AAGUID;
659
    }
660
661
        /**
662
     * @return string
663
     */
664
    public function getAttestationLevel() {
665
        return $this->AAGUIDAssurance;
666
    }
667
668
}
669