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

WebAuthnRegistrationEvent::loadAAGUIDTable()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
c 1
b 0
f 0
nc 3
nop 0
dl 0
loc 16
rs 9.9332
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