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

WebAuthnRegistrationEvent::getAttestationLevel()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 2
rs 10
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