Passed
Push — master ( 6b2453...625b1d )
by Jaime Pérez
02:24
created

WebAuthnRegistrationEvent::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 27
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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

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
/**
11
 * FIDO2/WebAuthn Authentication Processing filter
12
 *
13
 * Filter for registering or authenticating with a FIDO2/WebAuthn token after
14
 * having authenticated with the primary authsource.
15
 *
16
 * @author Stefan Winter <[email protected]>
17
 * @package SimpleSAMLphp
18
 */
19
class WebAuthnRegistrationEvent extends WebAuthnAbstractEvent {
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 name of the configuration file where we should expect the AAGUID dictionary.
32
     */
33
    public const AAGUID_CONFIG_FILE = 'webauthn-aaguid.json';
34
35
    /**
36
     * the AAGUID of the newly registered authenticator
37
     * @var string
38
     */
39
    public $AAGUID;
40
41
    /**
42
     * how sure are we about the AAGUID?
43
     * @var int
44
     */
45
    public $AAGUIDAssurance;
46
47
    /**
48
     * An array of known hardware tokens
49
     *
50
     * @var array
51
     */
52
    protected $AAGUIDTable;
53
54
55
56
    /**
57
     * Initialize the event object.
58
     *
59
     * Validates and parses the configuration.
60
     *
61
     * @param string $pubkeyCredType  PublicKeyCredential.type
62
     * @param string $scope           the scope of the event
63
     * @param string $challenge       the challenge which was used to trigger this event
64
     * @param string $idpEntityId     the entity ID of our IdP
65
     * @param string $attestationData the attestation data CBOR blob
66
     * @param string $responseId      the response ID
67
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
68
     * @param bool $debugMode         print debugging statements?
69
     */
70
    public function __construct(
71
            string $pubkeyCredType,
72
            string $scope,
73
            string $challenge,
74
            string $idpEntityId,
75
            string $attestationData,
76
            string $responseId,
77
            string $clientDataJSON,
78
            bool $debugMode = false
79
    ) {
80
        $this->debugBuffer .= "attestationData raw: " . $attestationData . "<br/>";
81
        /**
82
         * §7.1 STEP 9 : CBOR decode attestationData.
83
         */
84
        $attestationArray = $this->cborDecode($attestationData);
85
        $authData = $attestationArray['authData'];
86
        $this->eventType = "REG";
87
        parent::__construct($pubkeyCredType, $scope, $challenge, $idpEntityId, $authData, $clientDataJSON, $debugMode);
88
89
        $this->AAGUIDTable = $this->loadAAGUIDTable();
90
91
        // this function extracts the public key
92
        $this->validateAttestedCredentialData(substr($authData, 37), $responseId);
93
        // this function may need the public key to have been previously extracted
94
        $this->validateAttestationData($attestationData);
95
        // the following function sets the credential properties
96
        $this->debugBuffer .= "Attestation Data (bin2hex): " . bin2hex(substr($authData, 37)) . "<br/>";
97
    }
98
99
100
    /**
101
     * Retrieve the current mappings for vendors and models.
102
     *
103
     * @return array An array with information for each known token, or an empty array if configuration is missing.
104
     */
105
    private function loadAAGUIDTable()
106
    {
107
        $path = SSPConfig::getConfigDir().'/'.self::AAGUID_CONFIG_FILE;
108
        if (!file_exists($path)) {
109
           Logger::warning('Missing "webauthn_tokens.json" configuration file. No device will be recognized.');
110
           return [];
111
        }
112
113
        $data = file_get_contents($path);
114
        $json = json_decode($data, true);
115
        if (!is_array($json)) {
116
            // there was probably an error decoding the config, log the error and pray for the best
117
            Logger::warning('Broken configuration file "'.$path.'": could not JSON-decode it.');
118
            return [];
119
        }
120
        return $json;
121
    }
122
123
124
    /**
125
     * validate the incoming attestation data CBOR blob and return the embedded authData
126
     * @param string $attestationData
127
     * @return void
128
     */
129
    private function validateAttestationData(string $attestationData): void {
130
        /**
131
         * STEP 9 of the validation procedure in § 7.1 of the spec: CBOR-decode the attestationObject
132
         */
133
        $attestationArray = $this->cborDecode($attestationData);
134
        $this->debugBuffer .= "<pre>";
135
        $this->debugBuffer .= print_r($attestationArray, true);
136
        $this->debugBuffer .= "</pre>";
137
138
        /**
139
         * STEP 15 of the validation procedure in § 7.1 of the spec: verify attStmt values
140
         */
141
        switch ($attestationArray['fmt']) {
142
            case "none":
143
                $this->validateAttestationFormatNone($attestationArray);
144
                break;
145
            case "packed":
146
                $this->validateAttestationFormatPacked($attestationArray);
147
                break;
148
            case "fido-u2f":
149
                $this->validateAttestationFormatFidoU2F($attestationArray);
150
                break;
151
            case "android-safetynet":
152
                $this->validateAttestationFormatAndroidSafetyNet($attestationArray);
153
                break;
154
            case "tpm":
155
            case "android-key":
156
                $this->fail("Attestation format " . $attestationArray['fmt'] . " validation not supported right now.");
157
                break;
158
            default:
159
                $this->fail("Unknown attestation format.");
160
                break;
161
        }
162
    }
163
164
    /**
165
     * @param array $attestationArray
166
     * @return void
167
     */
168
    private function validateAttestationFormatNone(array $attestationArray): void {
169
        // § 8.7 of the spec
170
        /**
171
         * § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array
172
         * § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy)
173
         */
174
        if (count($attestationArray['attStmt']) === 0) {
175
            $this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve.");
176
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_NONE;
177
            return;
178
        } else {
179
            $this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'.");
180
        }
181
    }
182
183
    /**
184
     * @param array $attestationArray
185
     * @return void
186
     */
187
    private function validateAttestationFormatPacked(array $attestationArray): void {
188
        $stmtDecoded = $attestationArray['attStmt'];
189
        $this->debugBuffer .= "AttStmt: " . print_r($stmtDecoded, true) . "<br/>";
190
        /**
191
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
192
         */
193
        if (isset($stmtDecoded['x5c'])) {
194
            $this->validateAttestationFormatPackedX5C($attestationArray);
195
        } elseif (isset($stmtDecoded['ecdaa'])) {
196
            $this->fail("ecdaa attestation not supported right now.");
197
        } else {
198
            // if we are still here, we are in the "self" type.
199
            $this->validateAttestationFormatPackedSelf($attestationArray);
200
        }
201
    }
202
203
    private function validateAttestationFormatPackedX5C($attestationArray) {
204
        $stmtDecoded = $attestationArray['attStmt'];
205
        /**
206
         * §8.2 Step 2: check x5c attestation
207
         */
208
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
209
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
210
        if ($keyResource === false) {
211
            $this->fail("Unable to construct public key resource from PEM.");
212
        }
213
        /**
214
         * §8.2 Step 2 Bullet 1: check signature
215
         */
216
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) !== 1) {
217
            $this->fail("x5c attestation failed.");
218
        }
219
        $this->pass("x5c sig check passed.");
220
        // still need to perform sanity checks on the attestation certificate
221
        /**
222
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
223
         */
224
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
225
        $this->debugBuffer .= "Attestation Certificate:" . print_r($certProps, true) . "<br/>";
226
        if ($certProps['version'] !== 2 || /** §8.2.1 Bullet 1 */
227
                $certProps['subject']['OU'] !== "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
228
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
229
                !isset($certProps['extensions']['basicConstraints']) ||
230
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
231
        ) {
232
            $this->fail("Attestation certificate properties are no good.");
233
        }
234
        if (array_key_exists(strtolower($this->AAGUID), $this->AAGUIDTable)) {
235
            if ($certProps['subject']['O'] !== $this->AAGUIDTable[strtolower($this->AAGUID)]['O'] ||
236
                // §8.2.1 Bullet 2 [Subject-O]
237
                $certProps['subject']['C'] !== $this->AAGUIDTable[strtolower($this->AAGUID)]['C']
238
                // §8.2ubject-C]
239
            ) {
240
                $this->fail("AAGUID does not match vendor data.");
241
            }
242
            if ($this->AAGUIDTable[strtolower($this->AAGUID)]['multi'] === true) { // need to check the OID
243
                if (!isset($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4'])) { /** §8.2.1 Bullet 3 */
244
                    $this->fail("This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID.");
245
                }
246
                /**
247
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
248
                 */
249
                $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
250
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
251
                if (strtolower($AAGUIDFromOid) !== strtolower($this->AAGUID)) {
252
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
253
                }
254
            }
255
            // we would need to verify the attestation certificate against a known-good root CA certificate to get more than basic
256
            /*
257
             * §7.1 Step 17 is to look at $this->AAGUIDTable[strtolower($this->AAGUID)]['RootPEMs']
258
             */
259
            /*
260
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
261
             */
262
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
263
        } else {
264
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
265
            // unable to verify all cert properties, so this is not enough for BASIC.
266
            // but it's our own fault, we should add the device to our DB.
267
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
268
        }
269
        $this->pass("x5c attestation passed.");
270
        return;
271
    }
272
273
    private function validateAttestationFormatPackedSelf($attestationArray) {
274
        $stmtDecoded = $attestationArray['attStmt'];
275
        /**
276
         * §8.2 Step 4 Bullet 1: check algorithm
277
         */
278
        if ($stmtDecoded['alg'] !== self::PK_ALGORITHM) {
279
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
280
        }
281
        $keyObject = new Ec2Key($this->cborDecode(hex2bin($this->credential)));
282
        $keyResource = openssl_pkey_get_public($keyObject->asPEM());
283
        if ($keyResource === false) {
284
            $this->fail("Unable to construct public key resource from PEM.");
285
        }
286
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
287
        /**
288
         * §8.2 Step 4 Bullet 2: verify signature
289
         */
290
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) === 1) {
291
            $this->pass("Self-Attestation veried.");
292
            /**
293
             * §8.2 Step 4 Bullet 3: return Self level
294
             */
295
            $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_SELF;
296
        } else {
297
            $this->fail("Self-Attestation failed.");
298
        }
299
    }
300
301
    /**
302
     * support legacy U2F tokens
303
     *
304
     * @param array $attestationData the incoming attestation data
305
     */
306
    private function validateAttestationFormatFidoU2F($attestationData) {
307
        /**
308
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
309
         * already successfully CBOR decoded
310
         */
311
        $stmtDecoded = $attestationData['attStmt'];
312
        if (!isset($stmtDecoded['x5c'])) {
313
            $this->fail("FIDO U2F attestation needs to have the 'x5c' key");
314
        }
315
        /**
316
         * §8.6 Verification Step 2: extract attCert and sanity check it
317
         */
318
        if (count($stmtDecoded['x5c']) !== 1) {
319
            $this->fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
320
        }
321
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
322
        $key = openssl_pkey_get_public($attCert);
323
        $keyProps = openssl_pkey_get_details($key);
324
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] !== "prime256v1") {
325
            $this->fail("FIDO U2F attestation public key is not P-256!");
326
        }
327
        /**
328
         * §8.6 Verification Step 3 is a NOOP as these properties are already
329
         * available as class members:
330
         *
331
         * $this->rpIdHash;
332
         * $this->credentialId;
333
         * $this->credential;
334
         */
335
        /**
336
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
337
         */
338
        if (isset($this->credential[-2]) && sizeof($this->credential[-2]) === 32 &&
339
                isset($this->credential[-3]) && sizeof($this->credential[-3]) === 32) {
340
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
341
        } else {
342
            $publicKeyU2F = FALSE;
343
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
344
        }
345
        /**
346
         * §8.6 Verification Step 5: create verificationData
347
         */
348
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
349
        /**
350
         * §8.6 Verification Step 6: verify signature
351
         */
352
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
353
            $this->fail("FIDO U2F Attestation verification failed.");
354
        } else {
355
            $this->pass("Successfully verified FIDO U2F signature.");
356
        }
357
        /**
358
         * §8.6 Verification Step 7: not performed, this is optional as per spec
359
         */
360
        /**
361
         * §8.6 Verification Step 8: so we always settle for "Basic"
362
         */
363
        $this->AAGUIDAssurance = self::AAGUID_ASSURANCE_LEVEL_BASIC;
364
    }
365
366
    /**
367
     * support Android authenticators (fingerprint etc.)
368
     *
369
     * @param array $attestationData the incoming attestation data
370
     */
371
    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

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