Webauthn::processAuthentication()   D
last analyzed

Complexity

Conditions 21
Paths 34

Size

Total Lines 118
Code Lines 44

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 21
eloc 44
c 1
b 0
f 0
nc 34
nop 8
dl 0
loc 118
rs 4.1666

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
/**
4
 * Platine Webauth
5
 *
6
 * Platine Webauthn is the implementation of webauthn specifications
7
 *
8
 * This content is released under the MIT License (MIT)
9
 *
10
 * Copyright (c) 2020 Platine Webauth
11
 * Copyright (c) Jakob Bennemann <[email protected]>
12
 *
13
 * Permission is hereby granted, free of charge, to any person obtaining a copy
14
 * of this software and associated documentation files (the "Software"), to deal
15
 * in the Software without restriction, including without limitation the rights
16
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
 * copies of the Software, and to permit persons to whom the Software is
18
 * furnished to do so, subject to the following conditions:
19
 *
20
 * The above copyright notice and this permission notice shall be included in all
21
 * copies or substantial portions of the Software.
22
 *
23
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
 * SOFTWARE.
30
 */
31
32
declare(strict_types=1);
33
34
namespace Platine\Webauthn;
35
36
use Exception;
37
use Platine\Http\Uri;
38
use Platine\Stdlib\Helper\Json;
39
use Platine\Stdlib\Helper\Path;
40
use Platine\Webauthn\Attestation\AttestationData;
41
use Platine\Webauthn\Attestation\AuthenticatorData;
42
use Platine\Webauthn\Entity\AuthenticatorSelection;
43
use Platine\Webauthn\Entity\PublicKey;
44
use Platine\Webauthn\Entity\PublicKeyAuthParam;
45
use Platine\Webauthn\Entity\RelyingParty;
46
use Platine\Webauthn\Entity\UserCredential;
47
use Platine\Webauthn\Entity\UserInfo;
48
use Platine\Webauthn\Enum\AttestationType;
49
use Platine\Webauthn\Enum\KeyFormat;
50
use Platine\Webauthn\Enum\TransportType;
51
use Platine\Webauthn\Exception\WebauthnException;
52
use Platine\Webauthn\Helper\ByteBuffer;
53
54
/**
55
 * @class Webauthn
56
 * @package Platine\Webauthn
57
 */
58
class Webauthn
59
{
60
    /**
61
     * The attestation data formats
62
     * @var array<string>
63
     */
64
    protected array $formats = [];
65
66
    /**
67
     * The challenge to use
68
     * @var ByteBuffer|null
69
     */
70
    protected ?ByteBuffer $challenge = null;
71
72
    /**
73
     * The signature counter
74
     * @var int
75
     */
76
    protected int $signatureCounter = 0;
77
78
    /**
79
     * The relying party entity
80
     * @var RelyingParty
81
     */
82
    protected RelyingParty $relyingParty;
83
84
    /**
85
     * The certificates files path
86
     * @var array<string>
87
     */
88
    protected array $certificates = [];
89
90
    /**
91
     * The configuration instance
92
     * @var WebauthnConfiguration
93
     */
94
    protected WebauthnConfiguration $config;
95
96
    /**
97
     * Create new instance
98
     * @param WebauthnConfiguration $config
99
     * @param array<string> $allowedFormats
100
     */
101
    public function __construct(WebauthnConfiguration $config, array $allowedFormats = [])
102
    {
103
        if (! function_exists('openssl_open')) {
104
            throw new WebauthnException('OpenSSL module not installed in this platform');
105
        }
106
107
        if (! in_array('SHA256', array_map('strtoupper', openssl_get_md_methods()))) {
108
            throw new WebauthnException('SHA256 is not supported by this OpenSSL installation');
109
        }
110
111
        $this->config = $config;
112
        $this->formats = $this->normalizeFormats($allowedFormats);
113
114
        $this->relyingParty = new RelyingParty(
115
            $config->get('relying_party_id'),
116
            $config->get('relying_party_name'),
117
            $config->get('relying_party_logo')
118
        );
119
    }
120
121
    /**
122
     * Add a root certificate to verify new registrations
123
     * @param string|array<string> $path
124
     * @return $this
125
     */
126
    public function addRootCertificate(string|array $path): self
127
    {
128
        if (is_array($path)) {
0 ignored issues
show
introduced by
The condition is_array($path) is always true.
Loading history...
129
            foreach ($path as $p) {
130
                $this->certificates[] = Path::realPath($p);
131
            }
132
        } else {
133
            $this->certificates[] = Path::realPath($path);
134
        }
135
136
        return $this;
137
    }
138
139
    /**
140
     * Return the parameters to be used for the registration
141
     * @param string $userId
142
     * @param string $userName
143
     * @param string $userDisplayName
144
     * @param string $userVerificationType
145
     * @param bool $crossPlatformAttachment
146
     * @param array<string> $excludeCredentialIds
147
     * @param bool $withoutAttestation
148
     * @return PublicKey
149
     */
150
    public function getRegistrationParams(
151
        string $userId,
152
        string $userName,
153
        string $userDisplayName,
154
        string $userVerificationType,
155
        bool $crossPlatformAttachment = false,
156
        array $excludeCredentialIds = [],
157
        bool $withoutAttestation = false
158
    ): PublicKey {
159
        $excludeCredentials = [];
160
        foreach ($excludeCredentialIds as $id) {
161
            $hex = hex2bin($id);
162
            if ($hex === false) {
163
                throw new WebauthnException(sprintf('Can not convert credential id [%s] to binary', $id));
164
            }
165
166
            $excludeCredentials[] = new UserCredential(
167
                new ByteBuffer($hex),
168
                array_values(TransportType::all())
169
            );
170
        }
171
172
        $attestation = AttestationType::INDIRECT;
173
        if (count($this->certificates) > 0) {
174
            $attestation = AttestationType::DIRECT;
175
        }
176
177
        if ($withoutAttestation) {
178
            $attestation = AttestationType::NONE;
179
        }
180
181
        $relyingParty = new RelyingParty(
182
            $this->config->get('relying_party_id'),
183
            $this->config->get('relying_party_name'),
184
            $this->config->get('relying_party_logo')
185
        );
186
187
        $userInfo = new UserInfo(
188
            new ByteBuffer($userId),
189
            $userName,
190
            $userDisplayName
191
        );
192
193
        $authenticatorSelection = new AuthenticatorSelection(
194
            $userVerificationType,
195
            false,
196
            $crossPlatformAttachment
197
        );
198
199
        $publicKey = (new PublicKey())
200
                      ->setUserInfo($userInfo)
201
                      ->setRelyingParty($relyingParty)
202
                      ->setAuthenticatorSelection($authenticatorSelection)
203
                      ->setExcludeCredentials($excludeCredentials)
204
                      ->setChallenge($this->createChallenge())
205
                      ->setTimeout($this->config->get('timeout'))
206
                      ->setExtensions()
207
                      ->addPublicKeys()
208
                      ->setAttestation($attestation);
209
210
        return $publicKey;
211
    }
212
213
    /**
214
     * Return the authentication parameters
215
     * @param string $userVerificationType
216
     * @param array<string> $credentialIds
217
     * @return PublicKey
218
     */
219
    public function getAuthenticationParams(
220
        string $userVerificationType,
221
        array $credentialIds = []
222
    ): PublicKey {
223
        $allowedCredentials = [];
224
        foreach ($credentialIds as $id) {
225
            $hex = hex2bin($id);
226
            if ($hex === false) {
227
                throw new WebauthnException(sprintf('Can not convert credential id [%s] to binary', $id));
228
            }
229
230
            $allowedCredentials[] = new PublicKeyAuthParam(
231
                new ByteBuffer($hex),
232
                $this->config->get('transport_types')
233
            );
234
        }
235
236
        $publicKey = (new PublicKey())
237
                      ->setRelyingPartyId($this->relyingParty->getId())
238
                      ->setAllowCredentials($allowedCredentials)
239
                      ->setChallenge($this->createChallenge())
240
                      ->setTimeout($this->config->get('timeout'))
241
                      ->setUserVerificationType($userVerificationType);
242
243
        return $publicKey;
244
    }
245
246
    /**
247
     * Process the user registration
248
     * @param string $clientDataJson
249
     * @param string $attestationObject
250
     * @param ByteBuffer|string $challenge
251
     * @param bool $requireUserVerification
252
     * @param bool $requireUserPresent
253
     * @param bool $failIfRootCertificateMismatch
254
     * @return array<string, mixed>
255
     */
256
    public function processRegistration(
257
        string $clientDataJson,
258
        string $attestationObject,
259
        ByteBuffer|string $challenge,
260
        bool $requireUserVerification = false,
261
        bool $requireUserPresent = true,
262
        bool $failIfRootCertificateMismatch = true
263
    ): array {
264
        $clientDataHash = hash('sha256', $clientDataJson, true);
265
        if (is_string($challenge)) {
266
            $challenge =  new ByteBuffer($challenge);
267
        }
268
269
        // security: https://www.w3.org/TR/webauthn/#registering-a-new-credential
270
        try {
271
            // 2. Let C, the client data claimed as collected during the credential creation,
272
            // be the result of running an implementation-specific JSON parser on JSONtext.
273
            $clientData = Json::decode($clientDataJson);
274
        } catch (Exception $ex) {
275
            throw new WebauthnException(sprintf('Invalid client data provided, [%s]', $ex->getMessage()));
276
        }
277
278
        // 3. Verify that the value of C.type is webauthn.create.
279
        if (! isset($clientData->type) || $clientData->type !== 'webauthn.create') {
280
            throw new WebauthnException('Invalid client type provided');
281
        }
282
283
        // 4. Verify that the value of C.challenge matches the challenge that was
284
        // sent to the authenticator in the create() call.
285
        if (
286
            ! isset($clientData->challenge) ||
287
            ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString() !== $challenge->getBinaryString()
288
        ) {
289
            throw new WebauthnException('Invalid challenge provided');
290
        }
291
292
        // 5. Verify that the value of C.origin matches the Relying Party's origin.
293
        if (! isset($clientData->origin) || $this->checkOrigin($clientData->origin) === false) {
294
            throw new WebauthnException('Invalid origin provided');
295
        }
296
297
        $attestation = $this->createAttestationData($attestationObject);
298
299
        // 9. Verify that the RP ID hash in authData is indeed the SHA-256
300
        // hash of the RP ID expected by the RP.
301
        if ($attestation->validateRelyingPartyIdHash($this->relyingParty->getHashId()) === false) {
302
            throw new WebauthnException('Invalid relying party id hash provided');
303
        }
304
305
        // 14. Verify that attStmt is a correct attestation statement, conveying
306
        // a valid attestation signature
307
        if ($attestation->validateAttestation($clientDataHash) === false) {
308
            throw new WebauthnException('Invalid certificate signature');
309
        }
310
311
        // 15. If validation is successful, obtain a list of acceptable trust anchors
312
        $isRootValid = count($this->certificates) > 0
313
                ? $attestation->validateRootCertificate($this->certificates)
314
                : false;
315
316
        if ($failIfRootCertificateMismatch && count($this->certificates) > 0 && $isRootValid === false) {
317
            throw new WebauthnException('Invalid root certificate');
318
        }
319
320
        // 10. Verify that the User Present bit of the flags in authData is set.
321
        $userPresent = $attestation->getAuthenticatorData()->isUserPresent();
322
        if ($requireUserPresent && $userPresent === false) {
323
            throw new WebauthnException('User is not present during authentication');
324
        }
325
326
        // 11. If user verification is required for this registration, verify
327
        // that the User Verified bit of the flags in authData is set.
328
        $userVerified = $attestation->getAuthenticatorData()->isUserVerified();
329
        if ($requireUserVerification && $userVerified === false) {
330
            throw new WebauthnException('User is not verified during authentication');
331
        }
332
333
        $signCount = $attestation->getAuthenticatorData()->getSignatureCount();
334
        if ($signCount > 0) {
335
            $this->signatureCounter = $signCount;
336
        }
337
338
        // Prepare data to store for future logins
339
        $data = [
340
            'rp_id' => $this->relyingParty->getId(),
341
            'attestation_format' => $attestation->getFormatName(),
342
            'credential_id' => bin2hex($attestation->getAuthenticatorData()->getCredentialId()),
343
            'credential_public_key' => $attestation->getAuthenticatorData()->getPublicKeyPEM(),
344
            'cert_chain' => $attestation->getCertificateChain(),
345
            'cert' => $attestation->getCertificatePem(),
346
            'cert_issuer' => $attestation->getCertificateIssuer(),
347
            'cert_subject' => $attestation->getCertificateSubject(),
348
            'is_root_cert_valid' => $isRootValid,
349
            'signature_counter' => $this->signatureCounter,
350
            'aaguid' => bin2hex($attestation->getAuthenticatorData()->getAaguid()),
351
            'is_user_present' => $userPresent,
352
            'is_user_verified' => $userVerified,
353
        ];
354
355
356
        return $data;
357
    }
358
359
    /**
360
     * Process the user authentication
361
     * @param string $clientDataJson
362
     * @param string $authenticatorData
363
     * @param string $signature
364
     * @param string $credentialPublicKey
365
     * @param ByteBuffer|string $challenge
366
     * @param int|null $previousSignatureCount
367
     * @param bool $requireUserVerification
368
     * @param bool $requireUserPresent
369
     * @return bool
370
     */
371
    public function processAuthentication(
372
        string $clientDataJson,
373
        string $authenticatorData,
374
        string $signature,
375
        string $credentialPublicKey,
376
        ByteBuffer|string $challenge,
377
        ?int $previousSignatureCount = null,
378
        bool $requireUserVerification = false,
379
        bool $requireUserPresent = true
380
    ): bool {
381
        if (is_string($challenge)) {
382
            $challenge =  new ByteBuffer($challenge);
383
        }
384
        $clientDataHash = hash('sha256', $clientDataJson, true);
385
        $authenticator = $this->createAuthenticatorData($authenticatorData);
386
        try {
387
            // 5. Let JSON text be the result of running UTF-8 decode on the value of cData.
388
            $clientData = Json::decode($clientDataJson);
389
        } catch (Exception $ex) {
390
            throw new WebauthnException(sprintf('Invalid client data provided, [%s]', $ex->getMessage()));
391
        }
392
393
        // https://www.w3.org/TR/webauthn/#verifying-assertion
394
395
        // 1. If the allowCredentials option was given when this authentication ceremony was initiated,
396
        //    verify that credential.id identifies one of the public key credentials
397
        //    that were listed in allowCredentials.
398
        //    -> TO BE VERIFIED BY IMPLEMENTATION
399
400
        // 2. If credential.response.userHandle is present, verify that the user identified
401
        //    by this value is the owner of the public key credential identified by credential.id.
402
        //    -> TO BE VERIFIED BY IMPLEMENTATION
403
404
        // 3. Using credential’s id attribute (or the corresponding rawId, if base64url encoding is
405
        //    inappropriate for your use case),
406
        //    look up the corresponding credential public key.
407
        //    -> TO BE LOOKED UP BY IMPLEMENTATION
408
409
        // 7. Verify that the value of C.type is the string webauthn.get.
410
        if (! isset($clientData->type) || $clientData->type !== 'webauthn.get') {
411
            throw new WebauthnException('Invalid client type provided');
412
        }
413
414
        // 8. Verify that the value of C.challenge matches the challenge that was sent to the
415
        //    authenticator in the PublicKeyCredentialRequestOptions passed to the get() call.
416
        if (
417
            ! isset($clientData->challenge) ||
418
            ByteBuffer::fromBase64Url($clientData->challenge)->getBinaryString() !== $challenge->getBinaryString()
419
        ) {
420
            throw new WebauthnException('Invalid challenge provided');
421
        }
422
423
        // 9. Verify that the value of C.origin matches the Relying Party's origin.
424
        if (! isset($clientData->origin) || $this->checkOrigin($clientData->origin) === false) {
425
            throw new WebauthnException('Invalid origin provided');
426
        }
427
428
        // 11. Verify that the rpIdHash in authData is the SHA-256 hash
429
        // of the RP ID expected by the Relying Party.
430
        if ($authenticator->getRelyingPartyIdHash() !== $this->relyingParty->getHashId()) {
431
            throw new WebauthnException('Invalid relying party id hash provided');
432
        }
433
434
        // 12. Verify that the User Present bit of the flags in authData is set
435
        if ($requireUserPresent && $authenticator->isUserPresent() === false) {
436
            throw new WebauthnException('User is not present during authentication');
437
        }
438
439
        // 13. If user verification is required for this assertion, verify that
440
        // the User Verified bit of the flags in authData is set.
441
        if ($requireUserVerification && $authenticator->isUserVerified() === false) {
442
            throw new WebauthnException('User is not verified during authentication');
443
        }
444
445
        // 14. Verify the values of the client extension outputs
446
        // TODO    (extensions not implemented)
447
448
        // 16. Using the credential public key looked up in step 3, verify
449
        // that sig is a valid signature over the binary
450
        //  concatenation of authData and hash.
451
        $dataToVerify = '';
452
        $dataToVerify .= $authenticatorData;
453
        $dataToVerify .= $clientDataHash;
454
455
        $publicKey = openssl_pkey_get_public($credentialPublicKey);
456
        if ($publicKey === false) {
457
            throw new WebauthnException('Invalid public key provided');
458
        }
459
460
        if (
461
            openssl_verify(
462
                $dataToVerify,
463
                $signature,
464
                $publicKey,
465
                OPENSSL_ALGO_SHA256
466
            ) !== 1
467
        ) {
468
            throw new WebauthnException('Invalid signature provided');
469
        }
470
471
        $signatureCount = $authenticator->getSignatureCount();
472
        if ($signatureCount !== 0) {
473
            $this->signatureCounter = $signatureCount;
474
        }
475
476
        // 17. If either of the signature counter value authData.signCount or
477
        //     previous signature count is non-zero, and if authData.signCount
478
        //     less than or equal to previous signature count, it's a signal
479
        //     that the authenticator may be cloned
480
        if ($previousSignatureCount !== null) {
481
            if ($signatureCount !== 0 || $previousSignatureCount !== 0) {
482
                if ($previousSignatureCount >= $signatureCount) {
483
                    throw new WebauthnException('Invalid signature counter provided');
484
                }
485
            }
486
        }
487
488
        return true;
489
    }
490
491
    /**
492
     * Return the challenge
493
     * @return ByteBuffer|null
494
     */
495
    public function getChallenge(): ?ByteBuffer
496
    {
497
        return $this->challenge;
498
    }
499
500
    /**
501
     * Return the current signature counter
502
     * @return int
503
     */
504
    public function getSignatureCounter(): int
505
    {
506
        return $this->signatureCounter;
507
    }
508
509
510
    /**
511
     * Create the attestation data instance
512
     * @param string $attestationObject
513
     * @return AttestationData
514
     */
515
    protected function createAttestationData(string $attestationObject): AttestationData
516
    {
517
        return new AttestationData($attestationObject, $this->formats);
518
    }
519
520
    /**
521
     * Create the authenticator data instance
522
     * @param string $authenticatorData
523
     * @return AuthenticatorData
524
     */
525
    protected function createAuthenticatorData(string $authenticatorData): AuthenticatorData
526
    {
527
        return new AuthenticatorData($authenticatorData);
528
    }
529
530
    /**
531
     * Check the given origin
532
     * @param string $origin
533
     * @return bool
534
     */
535
    protected function checkOrigin(string $origin): bool
536
    {
537
        // https://www.w3.org/TR/webauthn/#rp-id
538
539
        // The origin's scheme must be https and not be ignored/whitelisted
540
        $url = new Uri($origin);
541
        if (
542
            ! in_array($this->relyingParty->getId(), $this->config->get('ignore_origins')) &&
543
            $url->getScheme() !== 'https'
544
        ) {
545
            return false;
546
        }
547
548
        // The RP ID must be equal to the origin's effective domain, or a registrable
549
        // domain suffix of the origin's effective domain.
550
        return preg_match('/' . preg_quote($this->relyingParty->getId()) . '$/i', $url->getHost()) === 1;
551
    }
552
553
    /**
554
     * Create the challenge if not yet created
555
     * @return ByteBuffer
556
     */
557
    protected function createChallenge(): ByteBuffer
558
    {
559
        if ($this->challenge === null) {
560
            $length = $this->config->get('challenge_length');
561
            $this->challenge = ByteBuffer::randomBuffer($length);
562
        }
563
564
        return $this->challenge;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->challenge could return the type null which is incompatible with the type-hinted return Platine\Webauthn\Helper\ByteBuffer. Consider adding an additional type-check to rule them out.
Loading history...
565
    }
566
567
    /**
568
     * Normalize the formats
569
     * @param array<string> $formats
570
     * @return array<string>
571
     */
572
    protected function normalizeFormats(array $formats): array
573
    {
574
        $supportedFormats = KeyFormat::all();
575
        if (count($formats) === 0) {
576
            return $supportedFormats;
577
        }
578
579
        $desiredFormats = array_filter($formats, function ($entry) use ($supportedFormats) {
580
            return in_array($entry, $supportedFormats);
581
        });
582
583
        if (count($desiredFormats) > 0) {
584
            return $desiredFormats;
585
        }
586
587
        return $supportedFormats;
588
    }
589
}
590