Passed
Pull Request — master (#22)
by Tim
02:39
created

WebAuthnRegistrationEvent::__construct()   A

Complexity

Conditions 1
Paths 1

Size

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