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

commonX5cSignatureChecks()   B

Complexity

Conditions 7
Paths 20

Size

Total Lines 39
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

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