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

validateAttestationData()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 33
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 7
eloc 24
nc 7
nop 1
dl 0
loc 33
rs 8.6026
c 4
b 0
f 0
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