Passed
Branch master (1a26c1)
by Stefan
17:14
created

WebAuthnRegistrationEvent::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 29
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 1
eloc 10
c 3
b 0
f 0
nc 1
nop 8
dl 0
loc 29
rs 9.9332

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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

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

438
        $this->debugBuffer .= "Attestation Certificate:" . /** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>";
Loading history...
439
        if (
440
                $certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
441
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
442
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
443
                !isset($certProps['extensions']['basicConstraints']) ||
444
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
445
        ) {
446
            $this->fail("Attestation certificate properties are no good.");
447
        }
448
449
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
450
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
451
            /**
452
             * Checking the OID is not programmatically possible. Text per spec:
453
             * "If the related attetation root certificate is used for multiple
454
             * authenticator models, the Extension OID ... MUST be present."
455
             *
456
             * FIDO MDS3 metadata does not disclose whether the root CAs are
457
             * used for multiple models.
458
             */
459
            /* if ($token['multi'] === true) { // need to check the OID
460
                if (
461
                        !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'])
462
                ) { // §8.2.1 Bullet 3
463
                    $this->fail(
464
                            "This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID."
465
                    );
466
                }
467
                /**
468
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
469
                 */
470
                /* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
471
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
472
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
473
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
474
                }
475
            }*/
476
            // we would need to verify the attestation certificate against a known-good
477
            // root CA certificate to get more than basic
478
            /*
479
             * §7.1 Step 17 is to look at $token['RootPEMs']
480
             */
481
            foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) {
482
                $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...
483
            }
484
            /*
485
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
486
             */
487
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
488
        } else {
489
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
490
            // unable to verify all cert properties, so this is not enough for BASIC.
491
            // but it's our own fault, we should add the device to our DB.
492
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
493
        }
494
        $this->pass("x5c attestation passed.");
495
        return;
496
    }
497
498
    // Keymaster 3 - KeyMint ???
499
    private const ORIGINS_3 = [ // https://source.android.com/docs/security/features/keystore/tags#origin
500
        0 => "GENERATED",
501
        1 => "DERIVED",
502
        2 => "IMPORTED",
503
        3 => "UNKNOWN",
504
        ];
505
    private const PURPOSE_3 = [
506
        0 => "ENCRYPT",
507
        1 => "DECRYPT",
508
        2 => "SIGN",
509
        3 => "VERIFY",
510
        4 => "DERIVE_KEY",
511
        5 => "WRAP_KEY",
512
    ];
513
514
    private const MIN_SUPPORTED_KEYMASTER_VERSION = 3;
515
516
    private function validateAttestationFormatAndroidKey(array $attestationArray): void
517
    {
518
        $stmtDecoded = $attestationArray['attStmt'];
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
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
524
        $keyDetails = openssl_pkey_get_details($keyResource);
525
        switch ($keyDetails['type']) {
526
            case OPENSSL_KEYTYPE_EC:
527
                $certPubkey = $keyDetails['ec'];
528
                break;
529
            case OPENSSL_KEYTYPE_RSA:
530
                $certPubkey = $keyDetails['rsa'];
531
                break;
532
            default:
533
                throw new Exception("Public key was neither a RSA nor EC key.");
534
        }
535
        $statementKeyData = $this->cborDecode(hex2bin($this->credential));
536
        // this will only work for ECDSA keys, screw RSA
537
        if (
538
            $statementKeyData['x'] != $certPubkey[-2] || $statementKeyData['y'] != $certPubkey[-3]
539
        ) {
540
            $this->fail("Certificate public key does not match credentialPublicKey in authenticatorData (" . print_r($certPubkey, true) . "###" . print_r($statementKeyData, true) . ").");
0 ignored issues
show
Bug introduced by
Are you sure print_r($statementKeyData, 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

540
            $this->fail("Certificate public key does not match credentialPublicKey in authenticatorData (" . print_r($certPubkey, true) . "###" . /** @scrutinizer ignore-type */ print_r($statementKeyData, true) . ").");
Loading history...
Bug introduced by
Are you sure print_r($certPubkey, 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

540
            $this->fail("Certificate public key does not match credentialPublicKey in authenticatorData (" . /** @scrutinizer ignore-type */ print_r($certPubkey, true) . "###" . print_r($statementKeyData, true) . ").");
Loading history...
541
        }
542
        // throw new Exception(print_r($certProps, true));
543
        $rawAsn1Oid = $certProps['extensions']['1.3.6.1.4.1.11129.2.1.17'];
544
        $keyDescription = UnspecifiedType::fromDER($rawAsn1Oid)->asSequence();
545
        $attestationVersion = $keyDescription->at(0)->asInteger()->intNumber();
546
        $attestationChallenge = $keyDescription->at(4)->asOctetString()->string();
547
        $softwareEnforced = $keyDescription->at(6)->asSequence();
548
        $teeEnforced = $keyDescription->at(7)->asSequence();
549
550
        if ($this->clientDataHash !== $attestationChallenge) {
551
            $this->fail("ClientDataHash is not in certificate's extension data (attestationChallenge).");
552
        }
553
554
        if ($attestationVersion < self::MIN_SUPPORTED_KEYMASTER_VERSION) {
555
            $this->fail("Attestation versions below " . self::MIN_SUPPORTED_KEYMASTER_VERSION . " not supported, found $attestationVersion.");
556
        }
557
558
        if ($softwareEnforced->hasTagged(600) || $teeEnforced->hasTagged(600)) {
559
            $this->fail("Tag allApplications found!");
560
        }
561
        // need to go through both software and TEE and check origins and purpose
562
563
        if (
564
                ($softwareEnforced->hasTagged(702) && ($softwareEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3))) ||
565
                ($teeEnforced->hasTagged(702) && ($teeEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3)))
566
        ) {
567
            $this->fail("Incorrect value for ORIGIN!");
568
        }
569
570
        if ($softwareEnforced->hasTagged(1)) {
571
            $purposesSoftware = $softwareEnforced->getTagged(1)->asExplicit()->asSet();
572
            foreach ($purposesSoftware->elements() as $onePurpose) {
573
                if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) {
574
                        $this->fail("Incorrect value for PURPOSE (softwareEnforced)!");
575
                }
576
            }
577
        }
578
        if ($teeEnforced->hasTagged(1)) {
579
            $purposesTee = $teeEnforced->getTagged(1)->asExplicit()->asSet();
580
            foreach ($purposesTee->elements() as $onePurpose) {
581
                if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) {
582
                        $this->fail("Incorrect value for PURPOSE (teeEnforced)!");
583
                }
584
            }
585
        }
586
587
        $this->pass("Android Key attestation passed.");
588
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
589
    }
590
591
    /**
592
     * support legacy U2F tokens
593
     *
594
     * @param array $attestationData the incoming attestation data
595
     * @return void
596
     */
597
    private function validateAttestationFormatFidoU2F(array $attestationData): void
598
    {
599
        /**
600
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
601
         * already successfully CBOR decoded
602
         */
603
        $stmtDecoded = $attestationData['attStmt'];
604
        if (!isset($stmtDecoded['x5c'])) {
605
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
606
        }
607
        /**
608
         * §8.6 Verification Step 2: extract attCert and sanity check it
609
         */
610
        if (count($stmtDecoded['x5c']) !== 1) {
611
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
612
        }
613
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
614
        $key = openssl_pkey_get_public($attCert);
615
        $keyProps = openssl_pkey_get_details($key);
616
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
617
            $this->fail("FIDO U2F attestation public key is not P-256!");
618
        }
619
        /**
620
         * §8.6 Verification Step 3 is a NOOP as these properties are already
621
         * available as class members:
622
         *
623
         * $this->rpIdHash;
624
         * $this->credentialId;
625
         * $this->credential;
626
         */
627
        /**
628
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
629
         */
630
        if (
631
                isset($this->credential[-2]) &&
632
                strlen($this->credential[-2]) === 32 &&
633
                isset($this->credential[-3]) &&
634
                strlen($this->credential[-3]) === 32
635
        ) {
636
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
637
        } else {
638
            $publicKeyU2F = false;
639
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
640
        }
641
        /**
642
         * §8.6 Verification Step 5: create verificationData
643
         *
644
         * @psalm-var string $publicKeyU2F
645
         */
646
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
647
        /**
648
         * §8.6 Verification Step 6: verify signature
649
         */
650
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
651
            $this->fail("FIDO U2F Attestation verification failed.");
652
        } else {
653
            $this->pass("Successfully verified FIDO U2F signature.");
654
        }
655
        /**
656
         * §8.6 Verification Step 7: not performed, this is optional as per spec
657
         */
658
        /**
659
         * §8.6 Verification Step 8: so we always settle for "Basic"
660
         */
661
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
662
    }
663
664
    /**
665
     * support Android authenticators (fingerprint etc.)
666
     *
667
     * @param array $attestationData the incoming attestation data
668
     * @return void
669
     */
670
    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

670
    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...
671
    {
672
    }
673
674
    /**
675
     * The registration contains the actual credential. This function parses it.
676
     * @param string $attData    the attestation data binary blob
677
     * @param string $responseId the response ID
678
     * @return void
679
     */
680
    private function validateAttestedCredentialData(string $attData, string $responseId): void
681
    {
682
        $aaguid = substr($attData, 0, 16);
683
        $credIdLenBytes = substr($attData, 16, 2);
684
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
685
        $credId = substr($attData, 18, $credIdLen);
686
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
687
        $this->AAGUID = bin2hex($aaguid);
688
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
689
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
690
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
691
        if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) {
692
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
693
        } else {
694
            $this->fail(
695
                "Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" .
696
                bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ")."
697
            );
698
        }
699
        // so far so good. Now extract the actual public key from its COSE
700
        // encoding.
701
        // finding out the number of bytes to CBOR decode appears non-trivial.
702
        // The simple case is if no ED is present as the CBOR data then goes to
703
        // the end of the byte sequence.
704
        // since we don't know the algoritm yet, we don't know how many bytes
705
        // of credential CBOR follow. Let's read to the end; the CBOR decoder
706
        // silently ignores trailing extensions (if any)
707
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
708
        $arrayPK = $this->cborDecode($pubKeyCBOR);
709
        $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

709
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . /** @scrutinizer ignore-type */ print_r($arrayPK, true) . "</pre>";
Loading history...
710
        /**
711
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
712
         */
713
        if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here
714
            $this->algo = (int)$arrayPK['3'];
715
            $this->pass("Public Key Algorithm is expected (" . implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) . ").");
716
        } else {
717
            $this->fail("Public Key Algorithm mismatch!");
718
        }
719
        $this->credentialId = bin2hex($credId);
720
        $this->credential = bin2hex($pubKeyCBOR);
721
722
        // now that we know credential and its length, we can CBOR-decode the
723
        // trailing extensions
724
        switch ($this->algo) {
725
            case self::PK_ALGORITHM_ECDSA:
726
                $credentialLength = 77;
727
                break;
728
            case self::PK_ALGORITHM_RSA:
729
                $credentialLength = 272;
730
                break;
731
            default:
732
                $this->fail("No credential length information for $this->algo");
733
        }
734
        $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...
735
        if (strlen($extensions) !== 0) {
736
            $this->pass("Found the following extensions (" . strlen($extensions) . " bytes) during registration ceremony: ");
737
        }
738
    }
739
740
    /**
741
     * transform DER formatted certificate to PEM format
742
     *
743
     * @param string $derData blob of DER data
744
     * @return string the PEM representation of the certificate
745
     */
746
    private function der2pem(string $derData): string
747
    {
748
        $pem = chunk_split(base64_encode($derData), 64, "\n");
749
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
750
        return $pem;
751
    }
752
753
    /**
754
     * @return string
755
     */
756
    public function getAAGUID()
757
    {
758
        return $this->AAGUID;
759
    }
760
761
    /**
762
     * @return string
763
     */
764
    public function getAttestationLevel()
765
    {
766
        return $this->AAGUIDAssurance;
767
    }
768
}
769