Passed
Push — master ( bbe026...b84b3e )
by Stefan
13:24
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
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
372
        if ($keyResource === false) {
373
            $this->fail("Unable to construct public key resource from PEM.");
374
        }
375
        /**
376
         * §8.2 Step 2: check x5c attestation
377
         */
378
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
379
        /**
380
         * §8.2 Step 2 Bullet 1: check signature
381
         */
382
        $retCode = openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, "sha256");
383
        if ( $retCode !== 1) {
384
            $this->fail("Packed signature mismatch (return code $retCode, for :authdata:".$attestationArray['authData']." - :clientDataHash:".$this->clientDataHash." - :signature:".$stmtDecoded['sig']."), attestation failed.");
385
        }
386
        $this->pass("x5c sig check passed.");
387
    }
388
389
    /**
390
     * @param array $attestationArray
391
     */
392
    private function validateAttestationFormatPacked(array $attestationArray): void
393
    {
394
        $stmtDecoded = $attestationArray['attStmt'];
395
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
396
        /**
397
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
398
         */
399
        if (isset($stmtDecoded['x5c'])) {
400
        $this->commonX5cSignatureChecks($attestationArray);
401
            $this->validateAttestationFormatPackedX5C($attestationArray);
402
        } elseif (isset($stmtDecoded['ecdaa'])) {
403
            $this->fail("ecdaa attestation not supported right now.");
404
        } else {
405
            // if we are still here, we are in the "self" type.
406
            // signature checks already done, nothing more to do
407
            $this->pass("Self-Attestation verified.");
408
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
409
        }
410
    }
411
412
    /**
413
     * @param array $attestationArray
414
     * @return void
415
     */
416
    private function validateAttestationFormatPackedX5C(array $attestationArray): void
417
    {
418
        $stmtDecoded = $attestationArray['attStmt'];
419
        // still need to perform sanity checks on the attestation certificate
420
        /**
421
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
422
         */
423
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
424
        $this->debugBuffer .= "Attestation Certificate:" . /** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>";
425
        if (
426
                $certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
427
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
428
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
429
                !isset($certProps['extensions']['basicConstraints']) ||
430
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
431
        ) {
432
            $this->fail("Attestation certificate properties are no good.");
433
        }
434
435
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
436
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
437
            /**
438
             * Checking the OID is not programmatically possible. Text per spec:
439
             * "If the related attetation root certificate is used for multiple
440
             * authenticator models, the Extension OID ... MUST be present."
441
             *
442
             * FIDO MDS3 metadata does not disclose whether the root CAs are
443
             * used for multiple models.
444
             */
445
            /* if ($token['multi'] === true) { // need to check the OID
446
                if (
447
                        !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'])
448
                ) { // §8.2.1 Bullet 3
449
                    $this->fail(
450
                            "This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID."
451
                    );
452
                }
453
                /**
454
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
455
                 */
456
                /* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
457
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
458
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
459
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
460
                }
461
            }*/
462
            // we would need to verify the attestation certificate against a known-good
463
            // root CA certificate to get more than basic
464
            /*
465
             * §7.1 Step 17 is to look at $token['RootPEMs']
466
             */
467
            foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) {
468
                openssl_x509_parse("-----BEGIN CERTIFICATE-----\n$oneRoot\n-----END CERTIFICATE-----", true);
469
            }
470
            /*
471
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
472
             */
473
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
474
        } else {
475
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
476
            // unable to verify all cert properties, so this is not enough for BASIC.
477
            // but it's our own fault, we should add the device to our DB.
478
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
479
        }
480
        $this->pass("x5c attestation passed.");
481
        return;
482
    }
483
484
    // Keymaster 3 - KeyMint ???
485
    private const ORIGINS_3 = [ // https://source.android.com/docs/security/features/keystore/tags#origin
486
        0 => "GENERATED",
487
        1 => "DERIVED",
488
        2 => "IMPORTED",
489
        3 => "UNKNOWN",
490
        ];
491
    private const PURPOSE_3 = [
492
        0 => "ENCRYPT",
493
        1 => "DECRYPT",
494
        2 => "SIGN",
495
        3 => "VERIFY",
496
        4 => "DERIVE_KEY",
497
        5 => "WRAP_KEY",
498
    ];
499
500
    private const MIN_SUPPORTED_KEYMASTER_VERSION = 3;
501
502
    private function validateAttestationFormatAndroidKey(array $attestationArray): void
503
    {
504
        $stmtDecoded = $attestationArray['attStmt'];
505
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
506
        $this->commonX5cSignatureChecks($attestationArray);
507
        // first certificate's properties
508
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
509
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
510
        $keyDetails = openssl_pkey_get_details($keyResource);
511
        switch ($keyDetails['type']) {
512
            case OPENSSL_KEYTYPE_EC:
513
                $certPubkey = $keyDetails['ec'];
514
                break;
515
            case OPENSSL_KEYTYPE_RSA:
516
                $certPubkey = $keyDetails['rsa'];
517
                break;
518
            default:
519
                throw new Exception("Public key was neither a RSA nor EC key.");
520
        }
521
        $statementKeyData = $this->cborDecode(hex2bin($this->credential));
522
        // this will only work for ECDSA keys, screw RSA
523
        if (
524
            $statementKeyData['x'] != $certPubkey[-2] || $statementKeyData['y'] != $certPubkey[-3]
525
        ) {
526
            $this->fail("Certificate public key does not match credentialPublicKey in authenticatorData (" . /** @scrutinizer ignore-type */ print_r($certPubkey, true) . "###" . /** @scrutinizer ignore-type */ print_r($statementKeyData, true) . ").");
527
        }
528
        // throw new Exception(print_r($certProps, true));
529
        $rawAsn1Oid = $certProps['extensions']['1.3.6.1.4.1.11129.2.1.17'];
530
        $keyDescription = UnspecifiedType::fromDER($rawAsn1Oid)->asSequence();
531
        $attestationVersion = $keyDescription->at(0)->asInteger()->intNumber();
532
        $attestationChallenge = $keyDescription->at(4)->asOctetString()->string();
533
        $softwareEnforced = $keyDescription->at(6)->asSequence();
534
        $teeEnforced = $keyDescription->at(7)->asSequence();
535
536
        if ($this->clientDataHash !== $attestationChallenge) {
537
            $this->fail("ClientDataHash is not in certificate's extension data (attestationChallenge).");
538
        }
539
540
        if ($attestationVersion < self::MIN_SUPPORTED_KEYMASTER_VERSION) {
541
            $this->fail("Attestation versions below " . self::MIN_SUPPORTED_KEYMASTER_VERSION . " not supported, found $attestationVersion.");
542
        }
543
544
        if ($softwareEnforced->hasTagged(600) || $teeEnforced->hasTagged(600)) {
545
            $this->fail("Tag allApplications found!");
546
        }
547
        // need to go through both software and TEE and check origins and purpose
548
549
        if (
550
                ($softwareEnforced->hasTagged(702) && ($softwareEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3))) ||
551
                ($teeEnforced->hasTagged(702) && ($teeEnforced->getTagged(702)->asExplicit()->asInteger()->intNumber() != array_search("GENERATED", self::ORIGINS_3)))
552
        ) {
553
            $this->fail("Incorrect value for ORIGIN!");
554
        }
555
556
        if ($softwareEnforced->hasTagged(1)) {
557
            $purposesSoftware = $softwareEnforced->getTagged(1)->asExplicit()->asSet();
558
            foreach ($purposesSoftware->elements() as $onePurpose) {
559
                if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) {
560
                        $this->fail("Incorrect value for PURPOSE (softwareEnforced)!");
561
                }
562
            }
563
        }
564
        if ($teeEnforced->hasTagged(1)) {
565
            $purposesTee = $teeEnforced->getTagged(1)->asExplicit()->asSet();
566
            foreach ($purposesTee->elements() as $onePurpose) {
567
                if ($onePurpose->asInteger()->intNumber() != array_search("SIGN", self::PURPOSE_3)) {
568
                        $this->fail("Incorrect value for PURPOSE (teeEnforced)!");
569
                }
570
            }
571
        }
572
573
        $this->pass("Android Key attestation passed.");
574
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
575
    }
576
577
    /**
578
     * support legacy U2F tokens
579
     *
580
     * @param array $attestationData the incoming attestation data
581
     * @return void
582
     */
583
    private function validateAttestationFormatFidoU2F(array $attestationData): void
584
    {
585
        /**
586
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
587
         * already successfully CBOR decoded
588
         */
589
        $stmtDecoded = $attestationData['attStmt'];
590
        if (!isset($stmtDecoded['x5c'])) {
591
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
592
        }
593
        /**
594
         * §8.6 Verification Step 2: extract attCert and sanity check it
595
         */
596
        if (count($stmtDecoded['x5c']) !== 1) {
597
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
598
        }
599
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
600
        $key = openssl_pkey_get_public($attCert);
601
        $keyProps = openssl_pkey_get_details($key);
602
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
603
            $this->fail("FIDO U2F attestation public key is not P-256!");
604
        }
605
        /**
606
         * §8.6 Verification Step 3 is a NOOP as these properties are already
607
         * available as class members:
608
         *
609
         * $this->rpIdHash;
610
         * $this->credentialId;
611
         * $this->credential;
612
         */
613
        /**
614
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
615
         */
616
        if (
617
                isset($this->credential[-2]) &&
618
                strlen($this->credential[-2]) === 32 &&
619
                isset($this->credential[-3]) &&
620
                strlen($this->credential[-3]) === 32
621
        ) {
622
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
623
        } else {
624
            $publicKeyU2F = false;
625
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
626
        }
627
        /**
628
         * §8.6 Verification Step 5: create verificationData
629
         *
630
         * @psalm-var string $publicKeyU2F
631
         */
632
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
633
        /**
634
         * §8.6 Verification Step 6: verify signature
635
         */
636
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
637
            $this->fail("FIDO U2F Attestation verification failed.");
638
        } else {
639
            $this->pass("Successfully verified FIDO U2F signature.");
640
        }
641
        /**
642
         * §8.6 Verification Step 7: not performed, this is optional as per spec
643
         */
644
        /**
645
         * §8.6 Verification Step 8: so we always settle for "Basic"
646
         */
647
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
648
    }
649
650
    /**
651
     * support Android authenticators (fingerprint etc.)
652
     *
653
     * @param array $attestationData the incoming attestation data
654
     * @return void
655
     */
656
    private function validateAttestationFormatAndroidSafetyNet(array $attestationData): void
657
    {
658
        $this->fail("Android SafetyNet attestation is historic and not supported ($attestationData).");
659
        // be sure to end execution even if the Exception is caught
660
        exit(1);
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
661
    }
662
663
    /**
664
     * The registration contains the actual credential. This function parses it.
665
     * @param string $attData    the attestation data binary blob
666
     * @param string $responseId the response ID
667
     * @return void
668
     */
669
    private function validateAttestedCredentialData(string $attData, string $responseId): void
670
    {
671
        $aaguid = substr($attData, 0, 16);
672
        $credIdLenBytes = substr($attData, 16, 2);
673
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
674
        $credId = substr($attData, 18, $credIdLen);
675
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
676
        $this->AAGUID = bin2hex($aaguid);
677
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
678
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
679
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
680
        if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) {
681
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
682
        } else {
683
            $this->fail(
684
                "Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" .
685
                bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ")."
686
            );
687
        }
688
        // so far so good. Now extract the actual public key from its COSE
689
        // encoding.
690
        // finding out the number of bytes to CBOR decode appears non-trivial.
691
        // The simple case is if no ED is present as the CBOR data then goes to
692
        // the end of the byte sequence.
693
        // since we don't know the algoritm yet, we don't know how many bytes
694
        // of credential CBOR follow. Let's read to the end; the CBOR decoder
695
        // silently ignores trailing extensions (if any)
696
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
697
        $arrayPK = $this->cborDecode($pubKeyCBOR);
698
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . /** @scrutinizer ignore-type */ print_r($arrayPK, true) . "</pre>";
699
        /**
700
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
701
         */
702
        if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here
703
            $this->algo = (int)$arrayPK['3'];
704
            $this->pass("Public Key Algorithm is expected (" . implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) . ").");
705
        } else {
706
            $this->fail("Public Key Algorithm mismatch!");
707
        }
708
        $this->credentialId = bin2hex($credId);
709
        $this->credential = bin2hex($pubKeyCBOR);
710
711
        // now that we know credential and its length, we can CBOR-decode the
712
        // trailing extensions
713
        switch ($this->algo) {
714
            case self::PK_ALGORITHM_ECDSA:
715
                $credentialLength = 77;
716
                break;
717
            case self::PK_ALGORITHM_RSA:
718
                $credentialLength = 272;
719
                break;
720
            default:
721
                $this->fail("No credential length information for $this->algo");
722
                // be sure to end execution even if the Exception is caught
723
                exit(1);
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
724
        }
725
        $extensions = substr($attData, 18 + $credIdLen + $credentialLength);
726
        if (strlen($extensions) !== 0) {
727
            $this->pass("Found the following extensions (" . strlen($extensions) . " bytes) during registration ceremony: ");
728
        }
729
    }
730
731
    /**
732
     * transform DER formatted certificate to PEM format
733
     *
734
     * @param string $derData blob of DER data
735
     * @return string the PEM representation of the certificate
736
     */
737
    private function der2pem(string $derData): string
738
    {
739
        $pem = chunk_split(base64_encode($derData), 64, "\n");
740
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
741
        return $pem;
742
    }
743
744
    /**
745
     * @return string
746
     */
747
    public function getAAGUID()
748
    {
749
        return $this->AAGUID;
750
    }
751
752
    /**
753
     * @return string
754
     */
755
    public function getAttestationLevel()
756
    {
757
        return $this->AAGUIDAssurance;
758
    }
759
}
760