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

WebAuthnRegistrationEvent::validateAttestationFormatAndroidSafetyNet()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

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