Passed
Push — master ( 36255b...622d7a )
by Stefan
02:54 queued 12s
created

WebAuthnRegistrationEvent::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 1
eloc 8
nc 1
nop 8
dl 0
loc 24
rs 10
c 3
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
7
include_once 'AAGUID.php';
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
    const PK_ALGORITHM = -7;
24
    const AAGUID_ASSURANCE_LEVEL_NONE = 0;
25
    const AAGUID_ASSURANCE_LEVEL_SELF = 1;
26
    const AAGUID_ASSURANCE_LEVEL_BASIC = 2;
27
    const AAGUID_ASSURANCE_LEVEL_ATTCA = 3;
28
29
    /**
30
     * the AAGUID of the newly registered authenticator
31
     * @var string
32
     */
33
    public $AAGUID;
34
35
    /**
36
     * how sure are we about the AAGUID?
37
     * @var int
38
     */
39
    public $AAGUIDAssurance;
40
41
    /**
42
     * Initialize the event object.
43
     *
44
     * Validates and parses the configuration.
45
     *
46
     * @param string $pubkeyCredType  PublicKeyCredential.type
47
     * @param string $scope           the scope of the event
48
     * @param string $challenge       the challenge which was used to trigger this event
49
     * @param string $idpEntityId     the entity ID of our IdP
50
     * @param string $attestationData the attestation data CBOR blob
51
     * @param string $responseId      the response ID
52
     * @param string $clientDataJSON  the client data JSON string which is present in all types of events
53
     * @param bool $debugMode         print debugging statements?
54
     */
55
    public function __construct(
56
            string $pubkeyCredType,
57
            string $scope,
58
            string $challenge,
59
            string $idpEntityId,
60
            string $attestationData,
61
            string $responseId,
62
            string $clientDataJSON,
63
            bool $debugMode = false
64
    ) {
65
        $this->debugBuffer .= "attestationData raw: " . $attestationData . "<br/>";
66
        /**
67
         * §7.1 STEP 9 : CBOR decode attestationData.
68
         */
69
        $attestationArray = $this->cborDecode($attestationData);
70
        $authData = $attestationArray['authData'];
71
        $this->eventType = "REG";
72
        parent::__construct($pubkeyCredType, $scope, $challenge, $idpEntityId, $authData, $clientDataJSON, $debugMode);
73
        // this function extracts the public key
74
        $this->validateAttestedCredentialData(substr($authData, 37), $responseId);
75
        // this function may need the public key to have been previously extracted
76
        $this->validateAttestationData($attestationData, $clientDataJSON);
0 ignored issues
show
Unused Code introduced by
The call to SimpleSAML\Module\webaut...lidateAttestationData() has too many arguments starting with $clientDataJSON. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

76
        $this->/** @scrutinizer ignore-call */ 
77
               validateAttestationData($attestationData, $clientDataJSON);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
77
        // the following function sets the credential properties
78
        $this->debugBuffer .= "Attestation Data (bin2hex): " . bin2hex(substr($authData, 37)) . "<br/>";
79
    }
80
81
    /**
82
     * validate the incoming attestation data CBOR blob and return the embedded authData
83
     * @param string $attestationData
84
     * @return void
85
     */
86
    private function validateAttestationData(string $attestationData): void {
87
        /**
88
         * STEP 9 of the validation procedure in § 7.1 of the spec: CBOR-decode the attestationObject
89
         */
90
        $attestationArray = $this->cborDecode($attestationData);
91
        $this->debugBuffer .= "<pre>";
92
        $this->debugBuffer .= print_r($attestationArray, true);
93
        $this->debugBuffer .= "</pre>";
94
95
        /**
96
         * STEP 15 of the validation procedure in § 7.1 of the spec: verify attStmt values
97
         */
98
        switch ($attestationArray['fmt']) {
99
            case "none":
100
                $this->validateAttestationFormatNone($attestationArray);
101
                break;
102
            case "packed":
103
                $this->validateAttestationFormatPacked($attestationArray);
104
                break;
105
            case "fido-u2f":
106
                $this->validateAttestationFormatFidoU2F($attestationArray);
107
                break;
108
            case "android-safetynet":
109
                $this->validateAttestationFormatAndroidSafetyNet($attestationArray);
110
                break;
111
            case "tpm":
112
            case "android-key":
113
                $this->fail("Attestation format " . $attestationArray['fmt'] . " validation not supported right now.");
114
                break;
115
            default:
116
                $this->fail("Unknown attestation format.");
117
                break;
118
        }
119
    }
120
121
    /**
122
     * @param array $attestationArray
123
     * @return void
124
     */
125
    private function validateAttestationFormatNone(array $attestationArray): void {
126
        // § 8.7 of the spec
127
        /**
128
         * § 7.1 Step 16 && §8.7 Verification Procedure: stmt must be an empty array
129
         * § 7.1 Step 17+18 are a NOOP if the format was "none" (which is acceptable as per this RPs policy)
130
         */
131
        if (count($attestationArray['attStmt']) == 0) {
132
            $this->pass("Attestation format and statement as expected, and no attestation authorities to retrieve.");
133
            $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_NONE;
134
            return;
135
        } else {
136
            $this->fail("Non-empty attestation authorities are not expected with 'attestationFormat = none'.");
137
        }
138
    }
139
140
    /**
141
     * @param array $attestationArray
142
     * @return void
143
     */
144
    private function validateAttestationFormatPacked(array $attestationArray): void {
145
        $stmtDecoded = $attestationArray['attStmt'];
146
        $this->debugBuffer .= "AttStmt: " . print_r($stmtDecoded, true) . "<br/>";
147
        /**
148
         * §7.1 Step 16: attestation is either done with x5c or ecdaa.
149
         */
150
        if (isset($stmtDecoded['x5c'])) {
151
            $this->validateAttestationFormatPackedX5C($attestationArray);
152
        } elseif (isset($stmtDecoded['ecdaa'])) {
153
            $this->fail("ecdaa attestation not supported right now.");
154
        } else {
155
            // if we are still here, we are in the "self" type.
156
            $this->validateAttestationFormatPackedSelf($attestationArray);
157
        }
158
    }
159
160
    private function validateAttestationFormatPackedX5C($attestationArray) {
161
        $stmtDecoded = $attestationArray['attStmt'];
162
        /**
163
         * §8.2 Step 2: check x5c attestation
164
         */
165
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
166
        $keyResource = openssl_pkey_get_public($this->der2pem($stmtDecoded['x5c'][0]));
167
        if ($keyResource === false) {
168
            $this->fail("Unable to construct public key resource from PEM.");
169
        }
170
        /**
171
         * §8.2 Step 2 Bullet 1: check signature
172
         */
173
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) != 1) {
174
            $this->fail("x5c attestation failed.");
175
        }
176
        $this->pass("x5c sig check passed.");
177
        // still need to perform sanity checks on the attestation certificate
178
        /**
179
         * §8.2 Step 2 Bullet 2: check certificate properties listed in §8.2.1
180
         */
181
        $certProps = openssl_x509_parse($this->der2pem($stmtDecoded['x5c'][0]));
182
        $this->debugBuffer .= "Attestation Certificate:" . print_r($certProps, true) . "<br/>";
183
        if ($certProps['version'] != 2 || /** §8.2.1 Bullet 1 */
184
                $certProps['subject']['OU'] != "Authenticator Attestation" || /** §8.2.1 Bullet 2 [Subject-OU] */
185
                !isset($certProps['subject']['CN']) || /** §8.2.1 Bullet 2 [Subject-CN] */
186
                !isset($certProps['extensions']['basicConstraints']) ||
187
                strstr("CA:FALSE", $certProps['extensions']['basicConstraints']) === false /** §8.2.1 Bullet 4 */
188
        ) {
189
            $this->fail("Attestation certificate properties are no good.");
190
        }
191
        if (isset(AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)])) {
192
            if ($certProps['subject']['O'] != AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)]['O'] || /** §8.2.1 Bullet 2 [Subject-O] */
193
                    $certProps['subject']['C'] != AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)]['C']) { /** §8.2.1 Bullet 2 [Subject-C] */
194
                $this->fail("AAGUID does not match vendor data.");
195
            }
196
            if (AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)]['multi'] === true) { // need to check the OID
197
                if (!isset($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4'])) { /** §8.2.1 Bullet 3 */
198
                    $this->fail("This vendor uses one cert for multiple authenticator model attestations, but lacks the AAGUID OID.");
199
                }
200
                /**
201
                 * §8.2 Step 2 Bullet 3: compare AAGUID values
202
                 */
203
                $AAGUIDFromOid = substr(bin2hex($certProps['extensions']['1.3.6.1.4.1.45724.1.1.4']), 4);
204
                $this->debugBuffer .= "AAGUID from OID = $AAGUIDFromOid<br/>";
205
                if (strtolower($AAGUIDFromOid) != strtolower($this->AAGUID)) {
206
                    $this->fail("AAGUID mismatch between attestation certificate and attestation statement.");
207
                }
208
            }
209
            // we would need to verify the attestation certificate against a known-good root CA certificate to get more than basic
210
            /*
211
             * §7.1 Step 17 is to look at AAGUID::AAGUID_DICTIONARY[strtolower($this->AAGUID)]['RootPEMs']
212
             */
213
            /*
214
             * §7.1 Step 18 is skipped, and we unconditionally return "only" Basic.
215
             */
216
            $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_BASIC;
217
        } else {
218
            $this->warn("Unknown authenticator model found: " . $this->AAGUID . ".");
219
            // unable to verify all cert properties, so this is not enough for BASIC.
220
            // but it's our own fault, we should add the device to our DB.
221
            $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_SELF;
222
        }
223
        $this->pass("x5c attestation passed.");
224
        return;
225
    }
226
227
    private function validateAttestationFormatPackedSelf($attestationArray) {
228
        $stmtDecoded = $attestationArray['attStmt'];
229
        /**
230
         * §8.2 Step 4 Bullet 1: check algorithm
231
         */
232
        if ($stmtDecoded['alg'] != WebAuthnRegistrationEvent::PK_ALGORITHM) {
233
            $this->fail("Unexpected algorithm type in packed basic attestation: " . $stmtDecoded['alg'] . ".");
234
        }
235
        $keyObject = new Ec2Key($this->cborDecode(hex2bin($this->credential)));
236
        $keyResource = openssl_pkey_get_public($keyObject->asPEM());
237
        if ($keyResource === false) {
238
            $this->fail("Unable to construct public key resource from PEM.");
239
        }
240
        $sigdata = $attestationArray['authData'] . $this->clientDataHash;
241
        /**
242
         * §8.2 Step 4 Bullet 2: verify signature
243
         */
244
        if (openssl_verify($sigdata, $stmtDecoded['sig'], $keyResource, OPENSSL_ALGO_SHA256) == 1) {
245
            $this->pass("Self-Attestation veried.");
246
            /**
247
             * §8.2 Step 4 Bullet 3: return Self level
248
             */
249
            $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_SELF;
250
        } else {
251
            $this->fail("Self-Attestation failed.");
252
        }
253
    }
254
255
    /**
256
     * support legacy U2F tokens
257
     * 
258
     * @param array $attestationData the incoming attestation data
259
     */
260
    private function validateAttestationFormatFidoU2F($attestationData) {
261
        /**
262
         * §8.6 Verification Step 1 is a NOOP: if we're here, the attStmt was
263
         * already successfully CBOR decoded
264
         */
265
        $stmtDecoded = $attestationData['attStmt'];
266
        if (!isset($stmtDecoded['x5c'])) {
267
            fail("FIDO U2F attestation needs to have the 'x5c' key");
0 ignored issues
show
Bug introduced by
The function fail was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

267
            /** @scrutinizer ignore-call */ 
268
            fail("FIDO U2F attestation needs to have the 'x5c' key");
Loading history...
268
        }
269
        /**
270
         * §8.6 Verification Step 2: extract attCert and sanity check it
271
         */
272
        if (count($stmtDecoded['x5c']) != 1) {
273
            fail("FIDO U2F attestation requires 'x5c' to have only exactly one key.");
274
        }
275
        $attCert = $this->der2pem($stmtDecoded['x5c'][0]);
276
        $key = openssl_pkey_get_public($attCert);
277
        $keyProps = openssl_pkey_get_details($key);
278
        if (!isset($keyProps['ec']['curve_name']) || $keyProps['ec']['curve_name'] != "prime256v1") {
279
            $this->fail("FIDO U2F attestation public key is not P-256!");
280
        }
281
        /**
282
         * §8.6 Verification Step 3 is a NOOP as these properties are already
283
         * available as class members:
284
         *
285
         * $this->rpIdHash;
286
         * $this->credentialId;
287
         * $this->credential;
288
         */
289
        /**
290
         * §8.6 Verification Step 4: encode the public key in ANSI X9.62 format
291
         */
292
        if (isset($this->credential[-2]) && sizeof($this->credential[-2]) == 32 &&
293
                isset($this->credential[-3]) && sizeof($this->credential[-3]) == 32) {
294
            $publicKeyU2F = chr(4) . $this->credential[-2] . $this->credential[-3];
295
        } else {
296
            $this->fail("FIDO U2F attestation: the public key is not as expected.");
297
        }
298
        /**
299
         * §8.6 Verification Step 5: create verificationData
300
         */
301
        $verificationData = chr(0) . $this->rpIdHash . $this->clientDataHash . $this->credentialId . $publicKeyU2F;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $publicKeyU2F does not seem to be defined for all execution paths leading up to this point.
Loading history...
302
        /**
303
         * §8.6 Verification Step 6: verify signature
304
         */
305
        if (openssl_verify($verificationData, $stmtDecoded['sig'], $attCert, OPENSSL_ALGO_SHA256) !== 1) {
306
            $this->fail("FIDO U2F Attestation verification failed.");
307
        } else {
308
            $this->pass("Successfully verified FIDO U2F signature.");
309
        }
310
        /**
311
         * §8.6 Verification Step 7: not performed, this is optional as per spec
312
         */
313
        /**
314
         * §8.6 Verification Step 8: so we always settle for "Basic"
315
         */
316
        $this->AAGUIDAssurance = WebAuthnRegistrationEvent::AAGUID_ASSURANCE_LEVEL_BASIC;
317
    }
318
319
    /**
320
     * support Android authenticators (fingerprint etc.)
321
     * 
322
     * @param array $attestationData the incoming attestation data
323
     */
324
    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

324
    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...
325
        
326
    }
327
328
    /**
329
     * The registration contains the actual credential. This function parses it.
330
     * @param string $attData    the attestation data binary blob
331
     * @param string $responseId the response ID
332
     * @return void
333
     */
334
    private function validateAttestedCredentialData(string $attData, string $responseId): void {
335
        $aaguid = substr($attData, 0, 16);
336
        $credIdLenBytes = substr($attData, 16, 2);
337
        $credIdLen = intval(bin2hex($credIdLenBytes), 16);
338
        $credId = substr($attData, 18, $credIdLen);
339
        $this->debugBuffer .= "AAGUID (hex) = " . bin2hex($aaguid) . "</br/>";
340
        $this->AAGUID = bin2hex($aaguid);
341
        $this->debugBuffer .= "Length Raw = " . bin2hex($credIdLenBytes) . "<br/>";
342
        $this->debugBuffer .= "Credential ID Length (decimal) = " . $credIdLen . "<br/>";
343
        $this->debugBuffer .= "Credential ID (hex) = " . bin2hex($credId) . "<br/>";
344
        if (bin2hex(WebAuthnAbstractEvent::base64url_decode($responseId)) == bin2hex($credId)) {
345
            $this->pass("Credential IDs in authenticator response and in attestation data match.");
346
        } else {
347
            $this->fail("Mismatch of credentialId (" . bin2hex($credId) . ") vs. response ID (" . bin2hex(WebAuthnAbstractEvent::base64url_decode($responseId)) . ").");
348
        }
349
        // so far so good. Now extract the actual public key from its COSE 
350
        // encoding.
351
        // finding out the number of bytes to CBOR decode appears non-trivial. 
352
        // The simple case is if no ED is present as the CBOR data then goes to 
353
        // the end of the byte sequence.
354
        // Since we made sure above that no ED is in the sequence, take the rest
355
        // of the sequence in its entirety.
356
        $pubKeyCBOR = substr($attData, 18 + $credIdLen);
357
        $arrayPK = $this->cborDecode($pubKeyCBOR);
358
        $this->debugBuffer .= "pubKey in canonical form: <pre>" . print_r($arrayPK, true) . "</pre>";
359
        /**
360
         * STEP 13 of the validation procedure in § 7.1 of the spec: is the algorithm the expected one?
361
         */
362
        if ($arrayPK['3'] == WebAuthnRegistrationEvent::PK_ALGORITHM) { // we requested -7, so want to see it here
363
            $this->pass("Public Key Algorithm is the expected one (-7, ECDSA).");
364
        } else {
365
            $this->fail("Public Key Algorithm mismatch!");
366
        }
367
        $this->credentialId = bin2hex($credId);
368
        $this->credential = bin2hex($pubKeyCBOR);
369
    }
370
371
    /**
372
     * transform DER formatted certificate to PEM format
373
     * 
374
     * @param string $derData blob of DER data
375
     * @return string the PEM representation of the certificate
376
     */
377
    private function der2pem(string $derData): string {
378
        $pem = chunk_split(base64_encode($derData), 64, "\n");
379
        $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
380
        return $pem;
381
    }
382
383
}
384