Passed
Pull Request — master (#56)
by Stefan
02:28
created

commonX5cSignatureChecks()   B

Complexity

Conditions 7
Paths 20

Size

Total Lines 39
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

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

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

437
        $this->debugBuffer .= "Attestation Certificate:" . /** @scrutinizer ignore-type */ print_r($certProps, true) . "<br/>";
Loading history...
438
        if (
439
                $certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
440
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
441
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
442
                !isset($certProps['extensions']['basicConstraints']) ||
443
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
444
        ) {
445
            $this->fail("Attestation certificate properties are no good.");
446
        }
447
448
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
449
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
450
            /**
451
             * Checking the OID is not programmatically possible. Text per spec:
452
             * "If the related attetation root certificate is used for multiple
453
             * authenticator models, the Extension OID ... MUST be present."
454
             *
455
             * FIDO MDS3 metadata does not disclose whether the root CAs are
456
             * used for multiple models.
457
             */
458
            /* if ($token['multi'] === true) { // need to check the OID
459
                if (
460
                        !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'])
461
                ) { // §8.2.1 Bullet 3
462
                    $this->fail(
463
                            "This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID."
464
                    );
465
                }
466
                /**
467
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
468
                 */
469
                /* $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
470
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
471
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
472
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
473
                }
474
            }*/
475
            // we would need to verify the attestation certificate against a known-good
476
            // root CA certificate to get more than basic
477
            /*
478
             * §7.1 Step 17 is to look at $token['RootPEMs']
479
             */
480
            foreach ($token['metadataStatement']['attestationRootCertificates'] as $oneRoot) {
481
                $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...
482
            }
483
            /*
484
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
485
             */
486
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
487
        } else {
488
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
489
            // unable to verify all cert properties, so this is not enough for BASIC.
490
            // but it's our own fault, we should add the device to our DB.
491
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
492
        }
493
        $this->pass("x5c attestation passed.");
494
        return;
495
    }
496
497
private function validateAttestationFormatAndroidKey(array $attestationArray): void
498
    {
499
        $stmtDecoded = $attestationArray['attStmt'];
500
        $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

500
        $this->debugBuffer .= "AttStmt: " . /** @scrutinizer ignore-type */ print_r($stmtDecoded, true) . "<br/>";
Loading history...
501
        $this->commonX5cSignatureChecks($attestationArray);    
502
        // first certificate's properties
503
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
504
        
505
        if (
506
            $attestationArray['authData']['attestedCredentialData']['credentialPublicKey']
507
            !==
508
            $certProps['publicKey']
509
            )
510
        {
511
            $this->fail("Certificate public key does not match credentialPublicKey in authenticatorData.");
512
        }
513
        if (
514
            $this->clientDataHash 
515
            !==
516
            $certProps['policyOID']['1.3.6.1.4.1.11129.2.1.17']['attestationChallenge']
517
            ) 
518
        {
519
            $this->fail("ClientDataHash is not in certificate's extension data.");
520
        }
521
            
522
            
523
            
524
        $this->fail("Still need to do Android-Key specific further checks.");
525
    }
526
    
527
    /**
528
     * support legacy U2F tokens
529
     *
530
     * @param array $attestationData the incoming attestation data
531
     * @return void
532
     */
533
    private function validateAttestationFormatFidoU2F(array $attestationData): void
534
    {
535
        /**
536
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
537
         * already successfully CBOR decoded
538
         */
539
        $stmtDecoded = $attestationData['attStmt'];
540
        if (!isset($stmtDecoded['x5c'])) {
541
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
542
        }
543
        /**
544
         * §8.6 Verification Step 2: extract attCert and sanity check it
545
         */
546
        if (count($stmtDecoded['x5c']) !== 1) {
547
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
548
        }
549
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
550
        $key = openssl_pkey_get_public($attCert);
551
        $keyProps = openssl_pkey_get_details($key);
552
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
553
            $this->fail("FIDO U2F attestation public key is not P-256!");
554
        }
555
        /**
556
         * §8.6 Verification Step 3 is a NOOP as these properties are already
557
         * available as class members:
558
         *
559
         * $this->rpIdHash;
560
         * $this->credentialId;
561
         * $this->credential;
562
         */
563
        /**
564
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
565
         */
566
        if (
567
                isset($this->credential[-2]) &&
568
                strlen($this->credential[-2]) === 32 &&
569
                isset($this->credential[-3]) &&
570
                strlen($this->credential[-3]) === 32
571
        ) {
572
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
573
        } else {
574
            $publicKeyU2F = false;
575
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
576
        }
577
        /**
578
         * §8.6 Verification Step 5: create verificationData
579
         *
580
         * @psalm-var string $publicKeyU2F
581
         */
582
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
583
        /**
584
         * §8.6 Verification Step 6: verify signature
585
         */
586
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
587
            $this->fail("FIDO U2F Attestation verification failed.");
588
        } else {
589
            $this->pass("Successfully verified FIDO U2F signature.");
590
        }
591
        /**
592
         * §8.6 Verification Step 7: not performed, this is optional as per spec
593
         */
594
        /**
595
         * §8.6 Verification Step 8: so we always settle for "Basic"
596
         */
597
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
598
    }
599
600
    /**
601
     * support Android authenticators (fingerprint etc.)
602
     *
603
     * @param array $attestationData the incoming attestation data
604
     * @return void
605
     */
606
    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

606
    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...
607
    {
608
    }
609
610
    /**
611
     * The registration contains the actual credential. This function parses it.
612
     * @param string $attData    the attestation data binary blob
613
     * @param string $responseId the response ID
614
     * @return void
615
     */
616
    private function validateAttestedCredentialData(string $attData, string $responseId): void
617
    {
618
        $aaguid = substr($attData, 0, 16);
619
        $credIdLenBytes = substr($attData, 16, 2);
620
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
621
        $credId = substr($attData, 18, $credIdLen);
622
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
623
        $this->AAGUID = bin2hex($aaguid);
624
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
625
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
626
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
627
        if (bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) === bin2hex($credId)) {
628
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
629
        } else {
630
            $this->fail(
631
                "Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" .
632
                bin2hex(WebAuthnAbstractEvent::base64urlDecode($responseId)) . ")."
633
            );
634
        }
635
        // so far so good. Now extract the actual public key from its COSE
636
        // encoding.
637
        // finding out the number of bytes to CBOR decode appears non-trivial.
638
        // The simple case is if no ED is present as the CBOR data then goes to
639
        // the end of the byte sequence.
640
        // since we don't know the algoritm yet, we don't know how many bytes
641
        // of credential CBOR follow. Let's read to the end; the CBOR decoder
642
        // silently ignores trailing extensions (if any)
643
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
644
        $arrayPK = $this->cborDecode($pubKeyCBOR);
645
        $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

645
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . /** @scrutinizer ignore-type */ print_r($arrayPK, true) . "</pre>";
Loading history...
646
        /**
647
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
648
         */
649
        if (in_array($arrayPK['3'], self::PK_ALGORITHM)) { // we requested -7 or -257, so want to see it here
650
            $this->algo = (int)$arrayPK['3'];
651
            $this->pass("Public Key Algorithm is expected (" . implode(' or ', WebAuthnRegistrationEvent::PK_ALGORITHM) . ").");
652
        } else {
653
            $this->fail("Public Key Algorithm mismatch!");
654
        }
655
        $this->credentialId = bin2hex($credId);
656
        $this->credential = bin2hex($pubKeyCBOR);
657
658
        // now that we know credential and its length, we can CBOR-decode the
659
        // trailing extensions
660
        switch ($this->algo) {
661
            case self::PK_ALGORITHM_ECDSA:
662
                $credentialLength = 77;
663
                break;
664
            case self::PK_ALGORITHM_RSA:
665
                $credentialLength = 272;
666
                break;
667
            default:
668
                $this->fail("No credential length information for $this->algo");
669
        }
670
        $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...
671
        if (strlen($extensions) !== 0) {
672
            $this->pass("Found the following extensions (" . strlen($extensions) . " bytes) during registration ceremony: ");
673
        }
674
    }
675
676
    /**
677
     * transform DER formatted certificate to PEM format
678
     *
679
     * @param string $derData blob of DER data
680
     * @return string the PEM representation of the certificate
681
     */
682
    private function der2pem(string $derData): string
683
    {
684
        $pem = chunk_split(base64_encode($derData), 64, "\n");
685
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
686
        return $pem;
687
    }
688
689
    /**
690
     * @return string
691
     */
692
    public function getAAGUID()
693
    {
694
        return $this->AAGUID;
695
    }
696
697
    /**
698
     * @return string
699
     */
700
    public function getAttestationLevel()
701
    {
702
        return $this->AAGUIDAssurance;
703
    }
704
}
705