Completed
Push — master ( cf3697...a53139 )
by Klas
10s
created

U2F::doAuthenticate()   D

Complexity

Conditions 21
Paths 67

Size

Total Lines 82
Code Lines 50

Duplication

Lines 6
Ratio 7.32 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 6
loc 82
rs 4.9116
cc 21
eloc 50
nc 67
nop 3

How to fix   Long Method    Complexity   

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:

1
<?php
2
/* Copyright (c) 2014 Yubico AB
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are
7
 * met:
8
 *
9
 *   * Redistributions of source code must retain the above copyright
10
 *     notice, this list of conditions and the following disclaimer.
11
 *
12
 *   * Redistributions in binary form must reproduce the above
13
 *     copyright notice, this list of conditions and the following
14
 *     disclaimer in the documentation and/or other materials provided
15
 *     with the distribution.
16
 *
17
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
 */
29
30
namespace u2flib_server;
31
32
/** Constant for the version of the u2f protocol */
33
const U2F_VERSION = "U2F_V2";
34
35
/** Constant for the type value in registration clientData */
36
const REQUEST_TYPE_REGISTER = "navigator.id.finishEnrollment";
37
38
/** Constant for the type value in authentication clientData */
39
const REQUEST_TYPE_AUTHENTICATE = "navigator.id.getAssertion";
40
41
/** Error for the authentication message not matching any outstanding
42
 * authentication request */
43
const ERR_NO_MATCHING_REQUEST = 1;
44
45
/** Error for the authentication message not matching any registration */
46
const ERR_NO_MATCHING_REGISTRATION = 2;
47
48
/** Error for the signature on the authentication message not verifying with
49
 * the correct key */
50
const ERR_AUTHENTICATION_FAILURE = 3;
51
52
/** Error for the challenge in the registration message not matching the
53
 * registration challenge */
54
const ERR_UNMATCHED_CHALLENGE = 4;
55
56
/** Error for the attestation signature on the registration message not
57
 * verifying */
58
const ERR_ATTESTATION_SIGNATURE = 5;
59
60
/** Error for the attestation verification not verifying */
61
const ERR_ATTESTATION_VERIFICATION = 6;
62
63
/** Error for not getting good random from the system */
64
const ERR_BAD_RANDOM = 7;
65
66
/** Error when the counter is lower than expected */
67
const ERR_COUNTER_TOO_LOW = 8;
68
69
/** Error decoding public key */
70
const ERR_PUBKEY_DECODE = 9;
71
72
/** Error user-agent returned error */
73
const ERR_BAD_UA_RETURNING = 10;
74
75
/** Error old OpenSSL version */
76
const ERR_OLD_OPENSSL = 11;
77
78
/** Error for the origin not matching the appId */
79
const ERR_NO_MATCHING_ORIGIN = 12;
80
81
/** Error for the type in clientData being invalid */
82
const ERR_BAD_TYPE = 13;
83
84
/** Error for bad user presence byte value */
85
const ERR_BAD_USER_PRESENCE = 14;
86
87
/** @internal */
88
const PUBKEY_LEN = 65;
89
90
class U2F
91
{
92
    /** @var string  */
93
    private $appId;
94
95
    /** @var null|string */
96
    private $attestDir;
97
98
    /** @internal */
99
    private $FIXCERTS = array(
100
        '349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
101
        'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
102
        '1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
103
        'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
104
        '6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
105
        'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511'
106
    );
107
108
    /**
109
     * @param string $appId Application id for the running application
110
     * @param string|null $attestDir Directory where trusted attestation roots may be found
111
     * @throws Error If OpenSSL older than 1.0.0 is used
112
     */
113
    public function __construct($appId, $attestDir = null)
114
    {
115
        if(OPENSSL_VERSION_NUMBER < 0x10000000) {
116
            throw new Error('OpenSSL has to be at least version 1.0.0, this is ' . OPENSSL_VERSION_TEXT, ERR_OLD_OPENSSL);
117
        }
118
        $this->appId = $appId;
119
        $this->attestDir = $attestDir;
120
    }
121
122
    /**
123
     * Called to get a registration request to send to a user.
124
     * Returns an array of one registration request and a array of sign requests.
125
     *
126
     * @param array $registrations List of current registrations for this
127
     * user, to prevent the user from registering the same authenticator several
128
     * times.
129
     * @return array An array of two elements, the first containing a
130
     * RegisterRequest the second being an array of SignRequest
131
     * @throws Error
132
     */
133
    public function getRegisterData(array $registrations = array())
134
    {
135
        $challenge = $this->createChallenge();
136
        $request = new RegisterRequest($challenge, $this->appId);
137
        $signs = $this->getAuthenticateData($registrations);
138
        return array($request, $signs);
139
    }
140
141
    /**
142
     * Called to verify and unpack a registration message.
143
     *
144
     * @param RegisterRequest $request this is a reply to
145
     * @param object $response response from a user
146
     * @param bool $includeCert set to true if the attestation certificate should be
147
     * included in the returned Registration object
148
     * @return Registration
149
     * @throws Error
150
     */
151
    public function doRegister($request, $response, $includeCert = true)
152
    {
153
        if( !is_object( $request ) ) {
154
            throw new \InvalidArgumentException('$request of doRegister() method only accepts object.');
155
        }
156
157
        if( !is_object( $response ) ) {
158
            throw new \InvalidArgumentException('$response of doRegister() method only accepts object.');
159
        }
160
161 View Code Duplication
        if( property_exists( $response, 'errorCode') && $response->errorCode !== 0 ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
162
            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
163
        }
164
165
        if( !is_bool( $includeCert ) ) {
166
            throw new \InvalidArgumentException('$include_cert of doRegister() method only accepts boolean.');
167
        }
168
169
        $rawReg = $this->base64u_decode($response->registrationData);
170
        $regData = array_values(unpack('C*', $rawReg));
171
        $clientData = $this->base64u_decode($response->clientData);
172
        $cli = json_decode($clientData);
173
174
        if($cli->challenge !== $request->challenge) {
175
            throw new Error('Registration challenge does not match', ERR_UNMATCHED_CHALLENGE );
176
        }
177
178
        if(isset($cli->typ) && $cli->typ !== REQUEST_TYPE_REGISTER) {
179
            throw new Error('ClientData type is invalid', ERR_BAD_TYPE);
180
        }
181
182 View Code Duplication
        if(isset($cli->origin) && $cli->origin !== $request->appId) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
183
            throw new Error('App ID does not match the origin', ERR_NO_MATCHING_ORIGIN);
184
        }
185
186
        $registration = new Registration();
187
        $offs = 1;
188
        $pubKey = substr($rawReg, $offs, PUBKEY_LEN);
189
        $offs += PUBKEY_LEN;
190
        // decode the pubKey to make sure it's good
191
        $tmpKey = $this->pubkey_to_pem($pubKey);
192
        if($tmpKey === null) {
193
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
194
        }
195
        $registration->publicKey = base64_encode($pubKey);
196
        $khLen = $regData[$offs++];
197
        $kh = substr($rawReg, $offs, $khLen);
198
        $offs += $khLen;
199
        $registration->keyHandle = $this->base64u_encode($kh);
200
201
        // length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes)
202
        $certLen = 4;
203
        $certLen += ($regData[$offs + 2] << 8);
204
        $certLen += $regData[$offs + 3];
205
206
        $rawCert = $this->fixSignatureUnusedBits(substr($rawReg, $offs, $certLen));
207
        $offs += $certLen;
208
        $pemCert  = "-----BEGIN CERTIFICATE-----\r\n";
209
        $pemCert .= chunk_split(base64_encode($rawCert), 64);
210
        $pemCert .= "-----END CERTIFICATE-----";
211
        if($includeCert) {
212
            $registration->certificate = base64_encode($rawCert);
213
        }
214
        if($this->attestDir) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->attestDir of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
215
            if(openssl_x509_checkpurpose($pemCert, -1, $this->get_certs()) !== true) {
216
                throw new Error('Attestation certificate can not be validated', ERR_ATTESTATION_VERIFICATION );
217
            }
218
        }
219
220
        if(!openssl_pkey_get_public($pemCert)) {
221
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
222
        }
223
        $signature = substr($rawReg, $offs);
224
225
        $dataToVerify  = chr(0);
226
        $dataToVerify .= hash('sha256', $request->appId, true);
227
        $dataToVerify .= hash('sha256', $clientData, true);
228
        $dataToVerify .= $kh;
229
        $dataToVerify .= $pubKey;
230
231
        if(openssl_verify($dataToVerify, $signature, $pemCert, 'sha256') === 1) {
232
            return $registration;
233
        } else {
234
            throw new Error('Attestation signature does not match', ERR_ATTESTATION_SIGNATURE );
235
        }
236
    }
237
238
    /**
239
     * Called to get an authentication request.
240
     *
241
     * @param array $registrations An array of the registrations to create authentication requests for.
242
     * @return array An array of SignRequest
243
     * @throws Error
244
     */
245
    public function getAuthenticateData(array $registrations)
246
    {
247
        $sigs = array();
248
        $challenge = $this->createChallenge();
249
        foreach ($registrations as $reg) {
250
            if( !is_object( $reg ) ) {
251
                throw new \InvalidArgumentException('$registrations of getAuthenticateData() method only accepts array of object.');
252
            }
253
254
            $sig = new SignRequest();
255
            $sig->appId = $this->appId;
256
            $sig->keyHandle = $reg->keyHandle;
257
            $sig->challenge = $challenge;
258
            $sigs[] = $sig;
259
        }
260
        return $sigs;
261
    }
262
263
    /**
264
     * Called to verify an authentication response
265
     *
266
     * @param array $requests An array of outstanding authentication requests
267
     * @param array $registrations An array of current registrations
268
     * @param object $response A response from the authenticator
269
     * @return Registration
270
     * @throws Error
271
     *
272
     * The Registration object returned on success contains an updated counter
273
     * that should be saved for future authentications.
274
     * If the Error returned is ERR_COUNTER_TOO_LOW this is an indication of
275
     * token cloning or similar and appropriate action should be taken.
276
     */
277
    public function doAuthenticate(array $requests, array $registrations, $response)
278
    {
279
        if( !is_object( $response ) ) {
280
            throw new \InvalidArgumentException('$response of doAuthenticate() method only accepts object.');
281
        }
282
283 View Code Duplication
        if( property_exists( $response, 'errorCode') && $response->errorCode !== 0 ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
284
            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
285
        }
286
287
        /** @var object|null $req */
288
        $req = null;
289
290
        /** @var object|null $reg */
291
        $reg = null;
292
293
        $clientData = $this->base64u_decode($response->clientData);
294
        $decodedClient = json_decode($clientData);
295
296
        if(isset($decodedClient->typ) && $decodedClient->typ !== REQUEST_TYPE_AUTHENTICATE) {
297
            throw new Error('ClientData type is invalid', ERR_BAD_TYPE);
298
        }
299
300
        foreach ($requests as $req) {
301
            if( !is_object( $req ) ) {
302
                throw new \InvalidArgumentException('$requests of doAuthenticate() method only accepts array of object.');
303
            }
304
305
            if($req->keyHandle === $response->keyHandle && $req->challenge === $decodedClient->challenge) {
306
                break;
307
            }
308
309
            $req = null;
310
        }
311
        if($req === null) {
312
            throw new Error('No matching request found', ERR_NO_MATCHING_REQUEST );
313
        }
314 View Code Duplication
        if(isset($decodedClient->origin) && $decodedClient->origin !== $req->appId) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
315
            throw new Error('App ID does not match the origin', ERR_NO_MATCHING_ORIGIN);
316
        }
317
        foreach ($registrations as $reg) {
318
            if( !is_object( $reg ) ) {
319
                throw new \InvalidArgumentException('$registrations of doAuthenticate() method only accepts array of object.');
320
            }
321
322
            if($reg->keyHandle === $response->keyHandle) {
323
                break;
324
            }
325
            $reg = null;
326
        }
327
        if($reg === null) {
328
            throw new Error('No matching registration found', ERR_NO_MATCHING_REGISTRATION );
329
        }
330
        $pemKey = $this->pubkey_to_pem($this->base64u_decode($reg->publicKey));
331
        if($pemKey === null) {
332
            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
333
        }
334
335
        $signData = $this->base64u_decode($response->signatureData);
336
        $dataToVerify  = hash('sha256', $req->appId, true);
337
        $dataToVerify .= substr($signData, 0, 5);
338
        $dataToVerify .= hash('sha256', $clientData, true);
339
        $signature = substr($signData, 5);
340
341
        if(openssl_verify($dataToVerify, $signature, $pemKey, 'sha256') === 1) {
342
            $upb = unpack("Cupb", substr($signData, 0, 1)); 
343
            if($upb['upb'] !== 1) { 
344
                throw new Error('User presence byte value is invalid', ERR_BAD_USER_PRESENCE );
345
            }
346
            $ctr = unpack("Nctr", substr($signData, 1, 4));
347
            $counter = $ctr['ctr'];
348
            /* TODO: wrap-around should be handled somehow.. */
349
            if($counter > $reg->counter) {
350
                $reg->counter = $counter;
351
                return $reg;
352
            } else {
353
                throw new Error('Counter too low.', ERR_COUNTER_TOO_LOW );
354
            }
355
        } else {
356
            throw new Error('Authentication failed', ERR_AUTHENTICATION_FAILURE );
357
        }
358
    }
359
360
    /**
361
     * @return array
362
     */
363
    private function get_certs()
364
    {
365
        $files = array();
366
        $dir = $this->attestDir;
367
        if($dir && $handle = opendir($dir)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $dir of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
368
            while(false !== ($entry = readdir($handle))) {
369
                if(is_file("$dir/$entry")) {
370
                    $files[] = "$dir/$entry";
371
                }
372
            }
373
            closedir($handle);
374
        }
375
        return $files;
376
    }
377
378
    /**
379
     * @param string $data
380
     * @return string
381
     */
382
    private function base64u_encode($data)
383
    {
384
        return trim(strtr(base64_encode($data), '+/', '-_'), '=');
385
    }
386
387
    /**
388
     * @param string $data
389
     * @return string
390
     */
391
    private function base64u_decode($data)
392
    {
393
        return base64_decode(strtr($data, '-_', '+/'));
394
    }
395
396
    /**
397
     * @param string $key
398
     * @return null|string
399
     */
400
    private function pubkey_to_pem($key)
401
    {
402
        if(strlen($key) !== PUBKEY_LEN || $key[0] !== "\x04") {
403
            return null;
404
        }
405
406
        /*
407
         * Convert the public key to binary DER format first
408
         * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480
409
         *
410
         *  SEQUENCE(2 elem)                        30 59
411
         *   SEQUENCE(2 elem)                       30 13
412
         *    OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01
413
         *    OID1.2.840.10045.3.1.7 (secp256r1)    06 08 2a 86 48 ce 3d 03 01 07
414
         *   BIT STRING(520 bit)                    03 42 ..key..
415
         */
416
        $der  = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01";
417
        $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42";
418
        $der .= "\0".$key;
419
420
        $pem  = "-----BEGIN PUBLIC KEY-----\r\n";
421
        $pem .= chunk_split(base64_encode($der), 64);
422
        $pem .= "-----END PUBLIC KEY-----";
423
424
        return $pem;
425
    }
426
427
    /**
428
     * @return string
429
     * @throws Error
430
     */
431
    private function createChallenge()
432
    {
433
        $challenge = openssl_random_pseudo_bytes(32, $crypto_strong );
434
        if( $crypto_strong !== true ) {
435
            throw new Error('Unable to obtain a good source of randomness', ERR_BAD_RANDOM);
436
        }
437
438
        $challenge = $this->base64u_encode( $challenge );
439
440
        return $challenge;
441
    }
442
443
    /**
444
     * Fixes a certificate where the signature contains unused bits.
445
     *
446
     * @param string $cert
447
     * @return mixed
448
     */
449
    private function fixSignatureUnusedBits($cert)
450
    {
451
        if(in_array(hash('sha256', $cert), $this->FIXCERTS)) {
452
            $cert[strlen($cert) - 257] = "\0";
453
        }
454
        return $cert;
455
    }
456
}
457
458
/**
459
 * Class for building a registration request
460
 *
461
 * @package u2flib_server
462
 */
463
class RegisterRequest
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
464
{
465
    /** Protocol version */
466
    public $version = U2F_VERSION;
467
468
    /** Registration challenge */
469
    public $challenge;
470
471
    /** Application id */
472
    public $appId;
473
474
    /**
475
     * @param string $challenge
476
     * @param string $appId
477
     * @internal
478
     */
479
    public function __construct($challenge, $appId)
480
    {
481
        $this->challenge = $challenge;
482
        $this->appId = $appId;
483
    }
484
}
485
486
/**
487
 * Class for building up an authentication request
488
 *
489
 * @package u2flib_server
490
 */
491
class SignRequest
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
492
{
493
    /** Protocol version */
494
    public $version = U2F_VERSION;
495
496
    /** Authentication challenge */
497
    public $challenge;
498
499
    /** Key handle of a registered authenticator */
500
    public $keyHandle;
501
502
    /** Application id */
503
    public $appId;
504
}
505
506
/**
507
 * Class returned for successful registrations
508
 *
509
 * @package u2flib_server
510
 */
511
class Registration
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
512
{
513
    /** The key handle of the registered authenticator */
514
    public $keyHandle;
515
516
    /** The public key of the registered authenticator */
517
    public $publicKey;
518
519
    /** The attestation certificate of the registered authenticator */
520
    public $certificate;
521
522
    /** The counter associated with this registration */
523
    public $counter = -1;
524
}
525
526
/**
527
 * Error class, returned on errors
528
 *
529
 * @package u2flib_server
530
 */
531
class Error extends \Exception
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
532
{
533
    /**
534
     * Override constructor and make message and code mandatory
535
     * @param string $message
536
     * @param int $code
537
     * @param \Exception|null $previous
538
     */
539
    public function __construct($message, $code, \Exception $previous = null) {
540
        parent::__construct($message, $code, $previous);
541
    }
542
}
543