Passed
Push — master ( ee8c83...2ef946 )
by Jaime Pérez
04:32 queued 02:10
created

WebAuthnRegistrationEvent::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 28
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 1
eloc 9
nc 1
nop 8
dl 0
loc 28
rs 9.9666
c 5
b 0
f 0

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
namespace SimpleSAML\Module\webauthn\WebAuthn;
4
5
use Cose\Key\Ec2Key;
6
use SimpleSAML\Logger;
7
use SimpleSAML\Utils\Config as SSPConfig;
8
9
/**
10
 * FIDO2/WebAuthn Authentication Processing filter
11
 *
12
 * Filter for registering or authenticating with a FIDO2/WebAuthn token after
13
 * having authenticated with the primary authsource.
14
 *
15
 * @author Stefan Winter <[email protected]>
16
 * @package SimpleSAMLphp
17
 */
18
class WebAuthnRegistrationEvent extends WebAuthnAbstractEvent
19
{
20
21
    /**
22
     * Public key algorithm supported. This is -7 - ECDSA with curve P-256
23
     */
24
    const PK_ALGORITHM = -7;
25
    const AAGUID_ASSURANCE_LEVEL_NONE = 0;
26
    const AAGUID_ASSURANCE_LEVEL_SELF = 1;
27
    const AAGUID_ASSURANCE_LEVEL_BASIC = 2;
28
    const AAGUID_ASSURANCE_LEVEL_ATTCA = 3;
29
30
    /**
31
     * the AAGUID of the newly registered authenticator
32
     * @var string
33
     */
34
    public $AAGUID;
35
36
    /**
37
     * how sure are we about the AAGUID?
38
     * @var int
39
     */
40
    public $AAGUIDAssurance;
41
42
    /**
43
     * An array of known hardware tokens
44
     *
45
     * @var array
46
     */
47
    protected $AAGUIDDictionary;
48
49
    /**
50
     * Initialize the event object.
51
     *
52
     * Validates and parses the configuration.
53
     *
54
     * @param string $pubkeyCredType  PublicKeyCredential.type
55
     * @param string $scope           the scope of the event
56
     * @param string $challenge       the challenge which was used to trigger this event
57
     * @param string $idpEntityId     the entity ID of our IdP
58
     * @param string $attestationData the attestation data CBOR blob
59
     * @param string $responseId      the response ID
60
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
61
     * @param bool $debugMode         print debugging statements?
62
     */
63
    public function __construct(
64
            string $pubkeyCredType,
65
            string $scope,
66
            string $challenge,
67
            string $idpEntityId,
68
            string $attestationData,
69
            string $responseId,
70
            string $clientDataJSON,
71
            bool $debugMode = false
72
    )
73
    {
74
        $this->debugBuffer .= "attestationData raw: " . $attestationData . "<br/>";
75
        /**
76
         * §7.1 STEP 9 : CBOR decode attestationData.
77
         */
78
        $attestationArray = $this->cborDecode($attestationData);
79
        $authData = $attestationArray['authData'];
80
        $this->eventType = "REG";
81
        parent::__construct($pubkeyCredType, $scope, $challenge, $idpEntityId, $authData, $clientDataJSON, $debugMode);
82
83
        $this->AAGUIDDictionary = AAGUID::getInstance();
0 ignored issues
show
Documentation Bug introduced by
It seems like SimpleSAML\Module\webaut...n\AAGUID::getInstance() of type SimpleSAML\Module\webauthn\WebAuthn\AAGUID is incompatible with the declared type array of property $AAGUIDDictionary.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
84
85
        // this function extracts the public key
86
        $this->validateAttestedCredentialData(substr($authData, 37), $responseId);
87
        // this function may need the public key to have been previously extracted
88
        $this->validateAttestationData($attestationData);
89
        // the following function sets the credential properties
90
        $this->debugBuffer .= "Attestation Data (bin2hex): " . bin2hex(substr($authData, 37)) . "<br/>";
91
    }
92
93
    /**
94
     * validate the incoming attestation data CBOR blob and return the embedded authData
95
     * @param string $attestationData
96
     * @return void
97
     */
98
    private function validateAttestationData(string $attestationData): void
99
    {
100
        /**
101
         * STEP 9 of the validation procedure in § 7.1 of the spec: CBOR-decode the attestationObject
102
         */
103
        $attestationArray = $this->cborDecode($attestationData);
104
        $this->debugBuffer .= "<pre>";
105
        $this->debugBuffer .= print_r($attestationArray, true);
106
        $this->debugBuffer .= "</pre>";
107
108
        /**
109
         * STEP 15 of the validation procedure in § 7.1 of the spec: verify attStmt values
110
         */
111
        switch ($attestationArray['fmt']) {
112
            case "none":
113
                $this->validateAttestationFormatNone($attestationArray);
114
                break;
115
            case "packed":
116
                $this->validateAttestationFormatPacked($attestationArray);
117
                break;
118
            case "fido-u2f":
119
                $this->validateAttestationFormatFidoU2F($attestationArray);
120
                break;
121
            case "android-safetynet":
122
                $this->validateAttestationFormatAndroidSafetyNet($attestationArray);
123
                break;
124
            case "tpm":
125
            case "android-key":
126
                $this->fail("Attestation format " . $attestationArray['fmt'] . " validation not supported right now.");
127
                break;
128
            default:
129
                $this->fail("Unknown attestation format.");
130
                break;
131
        }
132
    }
133
134
    /**
135
     * @param array $attestationArray
136
     * @return void
137
     */
138
    private function validateAttestationFormatNone(array $attestationArray): void
139
    {
140
        // § 8.7 of the spec
141
        /**
142
         * § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array
143
         * § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy)
144
         */
145
        if (count($attestationArray['attStmt']) === 0) {
146
            $this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve.");
147
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_NONE;
148
            return;
149
        } else {
150
            $this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'.");
151
        }
152
    }
153
154
    /**
155
     * @param array $attestationArray
156
     * @return void
157
     */
158
    private function validateAttestationFormatPacked(array $attestationArray): void
159
    {
160
        $stmtDecoded = $attestationArray['attStmt'];
161
        $this->debugBuffer .= "AttStmt: " . print_r($stmtDecoded, true) . "<br/>";
162
        /**
163
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
164
         */
165
        if (isset($stmtDecoded['x5c'])) {
166
            $this->validateAttestationFormatPackedX5C($attestationArray);
167
        } elseif (isset($stmtDecoded['ecdaa'])) {
168
            $this->fail("ecdaa attestation not supported right now.");
169
        } else {
170
            // if we are still here, we are in the "self" type.
171
            $this->validateAttestationFormatPackedSelf($attestationArray);
172
        }
173
    }
174
175
    private function validateAttestationFormatPackedX5C($attestationArray)
176
    {
177
        $stmtDecoded = $attestationArray['attStmt'];
178
        /**
179
         * §8.2 Step 2: check x5c attestation
180
         */
181
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
182
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
183
        if ($keyResource === false) {
184
            $this->fail("Unable to construct public key resource from PEM.");
185
        }
186
        /**
187
         * §8.2 Step 2 Bullet 1: check signature
188
         */
189
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) !== 1) {
190
            $this->fail("x5c attestation failed.");
191
        }
192
        $this->pass("x5c sig check passed.");
193
        // still need to perform sanity checks on the attestation certificate
194
        /**
195
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
196
         */
197
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
198
        $this->debugBuffer .= "Attestation Certificate:" . print_r($certProps, true) . "<br/>";
199
        if ($certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
200
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
201
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
202
                !isset($certProps['extensions']['basicConstraints']) ||
203
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
204
        ) {
205
            $this->fail("Attestation certificate properties are no good.");
206
        }
207
208
        if ($this->AAGUIDDictionary->hasToken($this->AAGUID)) {
209
            $token = $this->AAGUIDDictionary->get($this->AAGUID);
210
            if ($certProps['subject']['O'] !== $token['O'] ||
211
                // §8.2.1 Bullet 2 [Subject-O]
212
                $certProps['subject']['C'] !== $token['C']
213
                // §8.2ubject-C]
214
            ) {
215
                $this->fail("AAGUID does not match vendor data.");
216
            }
217
            if ($token['multi'] === true) { // need to check the OID
218
                if (!isset($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4'])) { /** §8.2.1 Bullet 3 */
219
                    $this->fail("This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID.");
220
                }
221
                /**
222
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
223
                 */
224
                $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
225
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
226
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
227
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
228
                }
229
            }
230
            // we would need to verify the attestation certificate against a known-good root CA certificate to get more than basic
231
            /*
232
             * §7.1 Step 17 is to look at $token['RootPEMs']
233
             */
234
            /*
235
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
236
             */
237
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
238
        } else {
239
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
240
            // unable to verify all cert properties, so this is not enough for BASIC.
241
            // but it's our own fault, we should add the device to our DB.
242
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
243
        }
244
        $this->pass("x5c attestation passed.");
245
        return;
246
    }
247
248
    private function validateAttestationFormatPackedSelf($attestationArray)
249
    {
250
        $stmtDecoded = $attestationArray['attStmt'];
251
        /**
252
         * §8.2 Step 4 Bullet 1: check algorithm
253
         */
254
        if ($stmtDecoded['alg'] !== self::PK_ALGORITHM) {
255
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
256
        }
257
        $keyObject = new Ec2Key($this->cborDecode(hex2bin($this->credential)));
258
        $keyResource = openssl_pkey_get_public($keyObject->asPEM());
259
        if ($keyResource === false) {
260
            $this->fail("Unable to construct public key resource from PEM.");
261
        }
262
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
263
        /**
264
         * §8.2 Step 4 Bullet 2: verify signature
265
         */
266
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) === 1) {
267
            $this->pass("Self-Attestation veried.");
268
            /**
269
             * §8.2 Step 4 Bullet 3: return Self level
270
             */
271
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
272
        } else {
273
            $this->fail("Self-Attestation failed.");
274
        }
275
    }
276
277
    /**
278
     * support legacy U2F tokens
279
     *
280
     * @param array $attestationData the incoming attestation data
281
     */
282
    private function validateAttestationFormatFidoU2F($attestationData)
283
    {
284
        /**
285
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
286
         * already successfully CBOR decoded
287
         */
288
        $stmtDecoded = $attestationData['attStmt'];
289
        if (!isset($stmtDecoded['x5c'])) {
290
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
291
        }
292
        /**
293
         * §8.6 Verification Step 2: extract attCert and sanity check it
294
         */
295
        if (count($stmtDecoded['x5c']) !== 1) {
296
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
297
        }
298
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
299
        $key = openssl_pkey_get_public($attCert);
300
        $keyProps = openssl_pkey_get_details($key);
301
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
302
            $this->fail("FIDO U2F attestation public key is not P-256!");
303
        }
304
        /**
305
         * §8.6 Verification Step 3 is a NOOP as these properties are already
306
         * available as class members:
307
         *
308
         * $this->rpIdHash;
309
         * $this->credentialId;
310
         * $this->credential;
311
         */
312
        /**
313
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
314
         */
315
        if (isset($this->credential[-2]) && sizeof($this->credential[-2]) === 32 &&
316
                isset($this->credential[-3]) && sizeof($this->credential[-3]) === 32) {
317
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
318
        } else {
319
            $publicKeyU2F = FALSE;
320
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
321
        }
322
        /**
323
         * §8.6 Verification Step 5: create verificationData
324
         */
325
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
326
        /**
327
         * §8.6 Verification Step 6: verify signature
328
         */
329
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
330
            $this->fail("FIDO U2F Attestation verification failed.");
331
        } else {
332
            $this->pass("Successfully verified FIDO U2F signature.");
333
        }
334
        /**
335
         * §8.6 Verification Step 7: not performed, this is optional as per spec
336
         */
337
        /**
338
         * §8.6 Verification Step 8: so we always settle for "Basic"
339
         */
340
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
341
    }
342
343
    /**
344
     * support Android authenticators (fingerprint etc.)
345
     *
346
     * @param array $attestationData the incoming attestation data
347
     */
348
    private function validateAttestationFormatAndroidSafetyNet($attestationData)
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

348
    private function validateAttestationFormatAndroidSafetyNet(/** @scrutinizer ignore-unused */ $attestationData)

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...
349
    {
350
351
    }
352
353
    /**
354
     * The registration contains the actual credential. This function parses it.
355
     * @param string $attData    the attestation data binary blob
356
     * @param string $responseId the response ID
357
     * @return void
358
     */
359
    private function validateAttestedCredentialData(string $attData, string $responseId): void
360
    {
361
        $aaguid = substr($attData, 0, 16);
362
        $credIdLenBytes = substr($attData, 16, 2);
363
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
364
        $credId = substr($attData, 18, $credIdLen);
365
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
366
        $this->AAGUID = bin2hex($aaguid);
367
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
368
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
369
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
370
        if (bin2hex(WebAuthnAbstractEvent::base64url_decode($responseId)) === bin2hex($credId)) {
371
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
372
        } else {
373
            $this->fail("Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" . bin2hex(WebAuthnAbstractEvent::base64url_decode($responseId)) . ").");
374
        }
375
        // so far so good. Now extract the actual public key from its COSE
376
        // encoding.
377
        // finding out the number of bytes to CBOR decode appears non-trivial.
378
        // The simple case is if no ED is present as the CBOR data then goes to
379
        // the end of the byte sequence.
380
        // Since we made sure above that no ED is in the sequence, take the rest
381
        // of the sequence in its entirety.
382
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
383
        $arrayPK = $this->cborDecode($pubKeyCBOR);
384
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . print_r($arrayPK, true) . "</pre>";
385
        /**
386
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
387
         */
388
        if ($arrayPK['3'] === self::PK_ALGORITHM) { // we requested -7, so want to see it here
389
            $this->pass("Public Key Algorithm is the expected one (-7, ECDSA).");
390
        } else {
391
            $this->fail("Public Key Algorithm mismatch!");
392
        }
393
        $this->credentialId = bin2hex($credId);
394
        $this->credential = bin2hex($pubKeyCBOR);
395
    }
396
397
    /**
398
     * transform DER formatted certificate to PEM format
399
     *
400
     * @param string $derData blob of DER data
401
     * @return string the PEM representation of the certificate
402
     */
403
    private function der2pem(string $derData): string
404
    {
405
        $pem = chunk_split(base64_encode($derData), 64, "\n");
406
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
407
        return $pem;
408
    }
409
410
}
411