Passed
Pull Request — master (#56)
by Stefan
09:30
created

validateAttestationFormatPackedX5C()   B

Complexity

Conditions 8
Paths 4

Size

Total Lines 66
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 8
eloc 20
c 3
b 0
f 0
nc 4
nop 1
dl 0
loc 66
rs 8.4444

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\webauthn\WebAuthn;
6
7
use Cose\Key\Ec2Key;
8
use 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