Completed
Push — master ( 55b06d...9f2a87 )
by Alexander
35:57
created

framework/base/Security.php (15 issues)

1
<?php
2
/**
3
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://www.yiiframework.com/license/
6
 */
7
8
namespace yii\base;
9
10
use Yii;
11
use yii\helpers\StringHelper;
12
13
/**
14
 * Security provides a set of methods to handle common security-related tasks.
15
 *
16
 * In particular, Security supports the following features:
17
 *
18
 * - Encryption/decryption: [[encryptByKey()]], [[decryptByKey()]], [[encryptByPassword()]] and [[decryptByPassword()]]
19
 * - Key derivation using standard algorithms: [[pbkdf2()]] and [[hkdf()]]
20
 * - Data tampering prevention: [[hashData()]] and [[validateData()]]
21
 * - Password validation: [[generatePasswordHash()]] and [[validatePassword()]]
22
 *
23
 * > Note: this class requires 'OpenSSL' PHP extension for random key/string generation on Windows and
24
 * for encryption/decryption on all platforms. For the highest security level PHP version >= 5.5.0 is recommended.
25
 *
26
 * For more details and usage information on Security, see the [guide article on security](guide:security-overview).
27
 *
28
 * @author Qiang Xue <[email protected]>
29
 * @author Tom Worster <[email protected]>
30
 * @author Klimov Paul <[email protected]>
31
 * @since 2.0
32
 */
33
class Security extends Component
34
{
35
    /**
36
     * @var string The cipher to use for encryption and decryption.
37
     */
38
    public $cipher = 'AES-128-CBC';
39
    /**
40
     * @var array[] Look-up table of block sizes and key sizes for each supported OpenSSL cipher.
41
     *
42
     * In each element, the key is one of the ciphers supported by OpenSSL (@see openssl_get_cipher_methods()).
43
     * The value is an array of two integers, the first is the cipher's block size in bytes and the second is
44
     * the key size in bytes.
45
     *
46
     * > Warning: All OpenSSL ciphers that we recommend are in the default value, i.e. AES in CBC mode.
47
     *
48
     * > Note: Yii's encryption protocol uses the same size for cipher key, HMAC signature key and key
49
     * derivation salt.
50
     */
51
    public $allowedCiphers = [
52
        'AES-128-CBC' => [16, 16],
53
        'AES-192-CBC' => [16, 24],
54
        'AES-256-CBC' => [16, 32],
55
    ];
56
    /**
57
     * @var string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512.
58
     * @see [hash_algos()](https://secure.php.net/manual/en/function.hash-algos.php)
59
     */
60
    public $kdfHash = 'sha256';
61
    /**
62
     * @var string Hash algorithm for message authentication. Recommend sha256, sha384 or sha512.
63
     * @see [hash_algos()](https://secure.php.net/manual/en/function.hash-algos.php)
64
     */
65
    public $macHash = 'sha256';
66
    /**
67
     * @var string HKDF info value for derivation of message authentication key.
68
     * @see hkdf()
69
     */
70
    public $authKeyInfo = 'AuthorizationKey';
71
    /**
72
     * @var int derivation iterations count.
73
     * Set as high as possible to hinder dictionary password attacks.
74
     */
75
    public $derivationIterations = 100000;
76
    /**
77
     * @var string strategy, which should be used to generate password hash.
78
     * Available strategies:
79
     * - 'password_hash' - use of PHP `password_hash()` function with PASSWORD_DEFAULT algorithm.
80
     *   This option is recommended, but it requires PHP version >= 5.5.0
81
     * - 'crypt' - use PHP `crypt()` function.
82
     * @deprecated since version 2.0.7, [[generatePasswordHash()]] ignores [[passwordHashStrategy]] and
83
     * uses `password_hash()` when available or `crypt()` when not.
84
     */
85
    public $passwordHashStrategy;
86
    /**
87
     * @var int Default cost used for password hashing.
88
     * Allowed value is between 4 and 31.
89
     * @see generatePasswordHash()
90
     * @since 2.0.6
91
     */
92
    public $passwordHashCost = 13;
93
94
95
    /**
96
     * Encrypts data using a password.
97
     * Derives keys for encryption and authentication from the password using PBKDF2 and a random salt,
98
     * which is deliberately slow to protect against dictionary attacks. Use [[encryptByKey()]] to
99
     * encrypt fast using a cryptographic key rather than a password. Key derivation time is
100
     * determined by [[$derivationIterations]], which should be set as high as possible.
101
     * The encrypted data includes a keyed message authentication code (MAC) so there is no need
102
     * to hash input or output data.
103
     * > Note: Avoid encrypting with passwords wherever possible. Nothing can protect against
104
     * poor-quality or compromised passwords.
105
     * @param string $data the data to encrypt
106
     * @param string $password the password to use for encryption
107
     * @return string the encrypted data
108
     * @see decryptByPassword()
109
     * @see encryptByKey()
110
     */
111 1
    public function encryptByPassword($data, $password)
112
    {
113 1
        return $this->encrypt($data, true, $password, null);
114
    }
115
116
    /**
117
     * Encrypts data using a cryptographic key.
118
     * Derives keys for encryption and authentication from the input key using HKDF and a random salt,
119
     * which is very fast relative to [[encryptByPassword()]]. The input key must be properly
120
     * random -- use [[generateRandomKey()]] to generate keys.
121
     * The encrypted data includes a keyed message authentication code (MAC) so there is no need
122
     * to hash input or output data.
123
     * @param string $data the data to encrypt
124
     * @param string $inputKey the input to use for encryption and authentication
125
     * @param string $info optional context and application specific information, see [[hkdf()]]
126
     * @return string the encrypted data
127
     * @see decryptByKey()
128
     * @see encryptByPassword()
129
     */
130 1
    public function encryptByKey($data, $inputKey, $info = null)
131
    {
132 1
        return $this->encrypt($data, false, $inputKey, $info);
133
    }
134
135
    /**
136
     * Verifies and decrypts data encrypted with [[encryptByPassword()]].
137
     * @param string $data the encrypted data to decrypt
138
     * @param string $password the password to use for decryption
139
     * @return bool|string the decrypted data or false on authentication failure
140
     * @see encryptByPassword()
141
     */
142 10
    public function decryptByPassword($data, $password)
143
    {
144 10
        return $this->decrypt($data, true, $password, null);
145
    }
146
147
    /**
148
     * Verifies and decrypts data encrypted with [[encryptByKey()]].
149
     * @param string $data the encrypted data to decrypt
150
     * @param string $inputKey the input to use for encryption and authentication
151
     * @param string $info optional context and application specific information, see [[hkdf()]]
152
     * @return bool|string the decrypted data or false on authentication failure
153
     * @see encryptByKey()
154
     */
155 10
    public function decryptByKey($data, $inputKey, $info = null)
156
    {
157 10
        return $this->decrypt($data, false, $inputKey, $info);
158
    }
159
160
    /**
161
     * Encrypts data.
162
     *
163
     * @param string $data data to be encrypted
164
     * @param bool $passwordBased set true to use password-based key derivation
165
     * @param string $secret the encryption password or key
166
     * @param string|null $info context/application specific information, e.g. a user ID
167
     * See [RFC 5869 Section 3.2](https://tools.ietf.org/html/rfc5869#section-3.2) for more details.
168
     *
169
     * @return string the encrypted data
170
     * @throws InvalidConfigException on OpenSSL not loaded
171
     * @throws Exception on OpenSSL error
172
     * @see decrypt()
173
     */
174 2
    protected function encrypt($data, $passwordBased, $secret, $info)
175
    {
176 2
        if (!extension_loaded('openssl')) {
177
            throw new InvalidConfigException('Encryption requires the OpenSSL PHP extension');
178
        }
179 2
        if (!isset($this->allowedCiphers[$this->cipher][0], $this->allowedCiphers[$this->cipher][1])) {
180
            throw new InvalidConfigException($this->cipher . ' is not an allowed cipher');
181
        }
182
183 2
        list($blockSize, $keySize) = $this->allowedCiphers[$this->cipher];
184
185 2
        $keySalt = $this->generateRandomKey($keySize);
186 2
        if ($passwordBased) {
187 1
            $key = $this->pbkdf2($this->kdfHash, $secret, $keySalt, $this->derivationIterations, $keySize);
188
        } else {
189 1
            $key = $this->hkdf($this->kdfHash, $secret, $keySalt, $info, $keySize);
190
        }
191
192 2
        $iv = $this->generateRandomKey($blockSize);
193
194 2
        $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $iv);
195 2
        if ($encrypted === false) {
196
            throw new \yii\base\Exception('OpenSSL failure on encryption: ' . openssl_error_string());
197
        }
198
199 2
        $authKey = $this->hkdf($this->kdfHash, $key, null, $this->authKeyInfo, $keySize);
200 2
        $hashed = $this->hashData($iv . $encrypted, $authKey);
201
202
        /*
203
         * Output: [keySalt][MAC][IV][ciphertext]
204
         * - keySalt is KEY_SIZE bytes long
205
         * - MAC: message authentication code, length same as the output of MAC_HASH
206
         * - IV: initialization vector, length $blockSize
207
         */
208 2
        return $keySalt . $hashed;
209
    }
210
211
    /**
212
     * Decrypts data.
213
     *
214
     * @param string $data encrypted data to be decrypted.
215
     * @param bool $passwordBased set true to use password-based key derivation
216
     * @param string $secret the decryption password or key
217
     * @param string|null $info context/application specific information, @see encrypt()
218
     *
219
     * @return bool|string the decrypted data or false on authentication failure
220
     * @throws InvalidConfigException on OpenSSL not loaded
221
     * @throws Exception on OpenSSL error
222
     * @see encrypt()
223
     */
224 20
    protected function decrypt($data, $passwordBased, $secret, $info)
225
    {
226 20
        if (!extension_loaded('openssl')) {
227
            throw new InvalidConfigException('Encryption requires the OpenSSL PHP extension');
228
        }
229 20
        if (!isset($this->allowedCiphers[$this->cipher][0], $this->allowedCiphers[$this->cipher][1])) {
230
            throw new InvalidConfigException($this->cipher . ' is not an allowed cipher');
231
        }
232
233 20
        list($blockSize, $keySize) = $this->allowedCiphers[$this->cipher];
234
235 20
        $keySalt = StringHelper::byteSubstr($data, 0, $keySize);
236 20
        if ($passwordBased) {
237 10
            $key = $this->pbkdf2($this->kdfHash, $secret, $keySalt, $this->derivationIterations, $keySize);
238
        } else {
239 10
            $key = $this->hkdf($this->kdfHash, $secret, $keySalt, $info, $keySize);
240
        }
241
242 20
        $authKey = $this->hkdf($this->kdfHash, $key, null, $this->authKeyInfo, $keySize);
243 20
        $data = $this->validateData(StringHelper::byteSubstr($data, $keySize, null), $authKey);
244 20
        if ($data === false) {
245 2
            return false;
246
        }
247
248 20
        $iv = StringHelper::byteSubstr($data, 0, $blockSize);
249 20
        $encrypted = StringHelper::byteSubstr($data, $blockSize, null);
250
251 20
        $decrypted = openssl_decrypt($encrypted, $this->cipher, $key, OPENSSL_RAW_DATA, $iv);
252 20
        if ($decrypted === false) {
253
            throw new \yii\base\Exception('OpenSSL failure on decryption: ' . openssl_error_string());
254
        }
255
256 20
        return $decrypted;
257
    }
258
259
    /**
260
     * Derives a key from the given input key using the standard HKDF algorithm.
261
     * Implements HKDF specified in [RFC 5869](https://tools.ietf.org/html/rfc5869).
262
     * Recommend use one of the SHA-2 hash algorithms: sha224, sha256, sha384 or sha512.
263
     * @param string $algo a hash algorithm supported by `hash_hmac()`, e.g. 'SHA-256'
264
     * @param string $inputKey the source key
265
     * @param string $salt the random salt
266
     * @param string $info optional info to bind the derived key material to application-
267
     * and context-specific information, e.g. a user ID or API version, see
268
     * [RFC 5869](https://tools.ietf.org/html/rfc5869)
269
     * @param int $length length of the output key in bytes. If 0, the output key is
270
     * the length of the hash algorithm output.
271
     * @throws InvalidArgumentException when HMAC generation fails.
272
     * @return string the derived key
273
     */
274 27
    public function hkdf($algo, $inputKey, $salt = null, $info = null, $length = 0)
275
    {
276 27
        if (function_exists('hash_hkdf')) {
277 27
            $outputKey = hash_hkdf($algo, $inputKey, $length, $info, $salt);
0 ignored issues
show
It seems like $info can also be of type null; however, parameter $info of hash_hkdf() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

277
            $outputKey = hash_hkdf($algo, $inputKey, $length, /** @scrutinizer ignore-type */ $info, $salt);
Loading history...
It seems like $salt can also be of type null; however, parameter $salt of hash_hkdf() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

277
            $outputKey = hash_hkdf($algo, $inputKey, $length, $info, /** @scrutinizer ignore-type */ $salt);
Loading history...
278 27
            if ($outputKey === false) {
279
                throw new InvalidArgumentException('Invalid parameters to hash_hkdf()');
280
            }
281
282 27
            return $outputKey;
283
        }
284
285
        $test = @hash_hmac($algo, '', '', true);
286
        if (!$test) {
287
            throw new InvalidArgumentException('Failed to generate HMAC with hash algorithm: ' . $algo);
288
        }
289
        $hashLength = StringHelper::byteLength($test);
290
        if (is_string($length) && preg_match('{^\d{1,16}$}', $length)) {
0 ignored issues
show
The condition is_string($length) is always false.
Loading history...
291
            $length = (int) $length;
292
        }
293
        if (!is_int($length) || $length < 0 || $length > 255 * $hashLength) {
0 ignored issues
show
The condition is_int($length) is always true.
Loading history...
294
            throw new InvalidArgumentException('Invalid length');
295
        }
296
        $blocks = $length !== 0 ? ceil($length / $hashLength) : 1;
297
298
        if ($salt === null) {
299
            $salt = str_repeat("\0", $hashLength);
300
        }
301
        $prKey = hash_hmac($algo, $inputKey, $salt, true);
302
303
        $hmac = '';
304
        $outputKey = '';
305
        for ($i = 1; $i <= $blocks; $i++) {
306
            $hmac = hash_hmac($algo, $hmac . $info . chr($i), $prKey, true);
307
            $outputKey .= $hmac;
308
        }
309
310
        if ($length !== 0) {
311
            $outputKey = StringHelper::byteSubstr($outputKey, 0, $length);
312
        }
313
314
        return $outputKey;
315
    }
316
317
    /**
318
     * Derives a key from the given password using the standard PBKDF2 algorithm.
319
     * Implements HKDF2 specified in [RFC 2898](http://tools.ietf.org/html/rfc2898#section-5.2)
320
     * Recommend use one of the SHA-2 hash algorithms: sha224, sha256, sha384 or sha512.
321
     * @param string $algo a hash algorithm supported by `hash_hmac()`, e.g. 'SHA-256'
322
     * @param string $password the source password
323
     * @param string $salt the random salt
324
     * @param int $iterations the number of iterations of the hash algorithm. Set as high as
325
     * possible to hinder dictionary password attacks.
326
     * @param int $length length of the output key in bytes. If 0, the output key is
327
     * the length of the hash algorithm output.
328
     * @return string the derived key
329
     * @throws InvalidArgumentException when hash generation fails due to invalid params given.
330
     */
331 19
    public function pbkdf2($algo, $password, $salt, $iterations, $length = 0)
332
    {
333 19
        if (function_exists('hash_pbkdf2')) {
334 19
            $outputKey = hash_pbkdf2($algo, $password, $salt, $iterations, $length, true);
335 19
            if ($outputKey === false) {
336
                throw new InvalidArgumentException('Invalid parameters to hash_pbkdf2()');
337
            }
338
339 19
            return $outputKey;
340
        }
341
342
        // todo: is there a nice way to reduce the code repetition in hkdf() and pbkdf2()?
343
        $test = @hash_hmac($algo, '', '', true);
344
        if (!$test) {
345
            throw new InvalidArgumentException('Failed to generate HMAC with hash algorithm: ' . $algo);
346
        }
347
        if (is_string($iterations) && preg_match('{^\d{1,16}$}', $iterations)) {
0 ignored issues
show
The condition is_string($iterations) is always false.
Loading history...
348
            $iterations = (int) $iterations;
349
        }
350
        if (!is_int($iterations) || $iterations < 1) {
0 ignored issues
show
The condition is_int($iterations) is always true.
Loading history...
351
            throw new InvalidArgumentException('Invalid iterations');
352
        }
353
        if (is_string($length) && preg_match('{^\d{1,16}$}', $length)) {
0 ignored issues
show
The condition is_string($length) is always false.
Loading history...
354
            $length = (int) $length;
355
        }
356
        if (!is_int($length) || $length < 0) {
0 ignored issues
show
The condition is_int($length) is always true.
Loading history...
357
            throw new InvalidArgumentException('Invalid length');
358
        }
359
        $hashLength = StringHelper::byteLength($test);
360
        $blocks = $length !== 0 ? ceil($length / $hashLength) : 1;
361
362
        $outputKey = '';
363
        for ($j = 1; $j <= $blocks; $j++) {
364
            $hmac = hash_hmac($algo, $salt . pack('N', $j), $password, true);
365
            $xorsum = $hmac;
366
            for ($i = 1; $i < $iterations; $i++) {
367
                $hmac = hash_hmac($algo, $hmac, $password, true);
368
                $xorsum ^= $hmac;
369
            }
370
            $outputKey .= $xorsum;
371
        }
372
373
        if ($length !== 0) {
374
            $outputKey = StringHelper::byteSubstr($outputKey, 0, $length);
375
        }
376
377
        return $outputKey;
378
    }
379
380
    /**
381
     * Prefixes data with a keyed hash value so that it can later be detected if it is tampered.
382
     * There is no need to hash inputs or outputs of [[encryptByKey()]] or [[encryptByPassword()]]
383
     * as those methods perform the task.
384
     * @param string $data the data to be protected
385
     * @param string $key the secret key to be used for generating hash. Should be a secure
386
     * cryptographic key.
387
     * @param bool $rawHash whether the generated hash value is in raw binary format. If false, lowercase
388
     * hex digits will be generated.
389
     * @return string the data prefixed with the keyed hash
390
     * @throws InvalidConfigException when HMAC generation fails.
391
     * @see validateData()
392
     * @see generateRandomKey()
393
     * @see hkdf()
394
     * @see pbkdf2()
395
     */
396 7
    public function hashData($data, $key, $rawHash = false)
397
    {
398 7
        $hash = hash_hmac($this->macHash, $data, $key, $rawHash);
399 7
        if (!$hash) {
400
            throw new InvalidConfigException('Failed to generate HMAC with hash algorithm: ' . $this->macHash);
401
        }
402
403 7
        return $hash . $data;
404
    }
405
406
    /**
407
     * Validates if the given data is tampered.
408
     * @param string $data the data to be validated. The data must be previously
409
     * generated by [[hashData()]].
410
     * @param string $key the secret key that was previously used to generate the hash for the data in [[hashData()]].
411
     * function to see the supported hashing algorithms on your system. This must be the same
412
     * as the value passed to [[hashData()]] when generating the hash for the data.
413
     * @param bool $rawHash this should take the same value as when you generate the data using [[hashData()]].
414
     * It indicates whether the hash value in the data is in binary format. If false, it means the hash value consists
415
     * of lowercase hex digits only.
416
     * hex digits will be generated.
417
     * @return string|false the real data with the hash stripped off. False if the data is tampered.
418
     * @throws InvalidConfigException when HMAC generation fails.
419
     * @see hashData()
420
     */
421 21
    public function validateData($data, $key, $rawHash = false)
422
    {
423 21
        $test = @hash_hmac($this->macHash, '', '', $rawHash);
424 21
        if (!$test) {
425
            throw new InvalidConfigException('Failed to generate HMAC with hash algorithm: ' . $this->macHash);
426
        }
427 21
        $hashLength = StringHelper::byteLength($test);
428 21
        if (StringHelper::byteLength($data) >= $hashLength) {
429 21
            $hash = StringHelper::byteSubstr($data, 0, $hashLength);
430 21
            $pureData = StringHelper::byteSubstr($data, $hashLength, null);
431
432 21
            $calculatedHash = hash_hmac($this->macHash, $pureData, $key, $rawHash);
433
434 21
            if ($this->compareString($hash, $calculatedHash)) {
435 21
                return $pureData;
436
            }
437
        }
438
439 3
        return false;
440
    }
441
442
    private $_useLibreSSL;
443
    private $_randomFile;
444
445
    /**
446
     * Generates specified number of random bytes.
447
     * Note that output may not be ASCII.
448
     * @see generateRandomString() if you need a string.
449
     *
450
     * @param int $length the number of bytes to generate
451
     * @return string the generated random bytes
452
     * @throws InvalidArgumentException if wrong length is specified
453
     * @throws Exception on failure.
454
     */
455 109
    public function generateRandomKey($length = 32)
456
    {
457 109
        if (!is_int($length)) {
0 ignored issues
show
The condition is_int($length) is always true.
Loading history...
458 3
            throw new InvalidArgumentException('First parameter ($length) must be an integer');
459
        }
460
461 106
        if ($length < 1) {
462 3
            throw new InvalidArgumentException('First parameter ($length) must be greater than 0');
463
        }
464
465
        // always use random_bytes() if it is available
466 103
        if (function_exists('random_bytes')) {
467 99
            return random_bytes($length);
468
        }
469
470
        // The recent LibreSSL RNGs are faster and likely better than /dev/urandom.
471
        // Parse OPENSSL_VERSION_TEXT because OPENSSL_VERSION_NUMBER is no use for LibreSSL.
472
        // https://bugs.php.net/bug.php?id=71143
473 4
        if ($this->_useLibreSSL === null) {
474 4
            $this->_useLibreSSL = defined('OPENSSL_VERSION_TEXT')
475 4
                && preg_match('{^LibreSSL (\d\d?)\.(\d\d?)\.(\d\d?)$}', OPENSSL_VERSION_TEXT, $matches)
476
                && (10000 * $matches[1]) + (100 * $matches[2]) + $matches[3] >= 20105;
477
        }
478
479
        // Since 5.4.0, openssl_random_pseudo_bytes() reads from CryptGenRandom on Windows instead
480
        // of using OpenSSL library. LibreSSL is OK everywhere but don't use OpenSSL on non-Windows.
481 4
        if (function_exists('openssl_random_pseudo_bytes')
482 1
            && ($this->_useLibreSSL
483
            || (
484 1
                DIRECTORY_SEPARATOR !== '/'
485 4
                && substr_compare(PHP_OS, 'win', 0, 3, true) === 0
486
            ))
487
        ) {
488
            $key = openssl_random_pseudo_bytes($length, $cryptoStrong);
489
            if ($cryptoStrong === false) {
490
                throw new Exception(
491
                    'openssl_random_pseudo_bytes() set $crypto_strong false. Your PHP setup is insecure.'
492
                );
493
            }
494
            if ($key !== false && StringHelper::byteLength($key) === $length) {
495
                return $key;
496
            }
497
        }
498
499
        // mcrypt_create_iv() does not use libmcrypt. Since PHP 5.3.7 it directly reads
500
        // CryptGenRandom on Windows. Elsewhere it directly reads /dev/urandom.
501 4
        if (function_exists('mcrypt_create_iv')) {
502
            $key = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);
0 ignored issues
show
Deprecated Code introduced by
The function mcrypt_create_iv() has been deprecated: 7.1 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

502
            $key = /** @scrutinizer ignore-deprecated */ mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
503
            if (StringHelper::byteLength($key) === $length) {
504
                return $key;
505
            }
506
        }
507
508
        // If not on Windows, try to open a random device.
509 4
        if ($this->_randomFile === null && DIRECTORY_SEPARATOR === '/') {
510
            // urandom is a symlink to random on FreeBSD.
511 4
            $device = PHP_OS === 'FreeBSD' ? '/dev/random' : '/dev/urandom';
512
            // Check random device for special character device protection mode. Use lstat()
513
            // instead of stat() in case an attacker arranges a symlink to a fake device.
514 4
            $lstat = @lstat($device);
515 4
            if ($lstat !== false && ($lstat['mode'] & 0170000) === 020000) {
516 4
                $this->_randomFile = fopen($device, 'rb') ?: null;
517
518 4
                if (is_resource($this->_randomFile)) {
519
                    // Reduce PHP stream buffer from default 8192 bytes to optimize data
520
                    // transfer from the random device for smaller values of $length.
521
                    // This also helps to keep future randoms out of user memory space.
522 3
                    $bufferSize = 8;
523
524 3
                    if (function_exists('stream_set_read_buffer')) {
525 3
                        stream_set_read_buffer($this->_randomFile, $bufferSize);
526
                    }
527
                    // stream_set_read_buffer() isn't implemented on HHVM
528 3
                    if (function_exists('stream_set_chunk_size')) {
529 3
                        stream_set_chunk_size($this->_randomFile, $bufferSize);
530
                    }
531
                }
532
            }
533
        }
534
535 4
        if (is_resource($this->_randomFile)) {
536 3
            $buffer = '';
537 3
            $stillNeed = $length;
538 3
            while ($stillNeed > 0) {
539 3
                $someBytes = fread($this->_randomFile, $stillNeed);
540 3
                if ($someBytes === false) {
541 1
                    break;
542
                }
543 2
                $buffer .= $someBytes;
544 2
                $stillNeed -= StringHelper::byteLength($someBytes);
545 2
                if ($stillNeed === 0) {
546
                    // Leaving file pointer open in order to make next generation faster by reusing it.
547 2
                    return $buffer;
548
                }
549
            }
550 1
            fclose($this->_randomFile);
551 1
            $this->_randomFile = null;
552
        }
553
554 2
        throw new Exception('Unable to generate a random key');
555
    }
556
557
    /**
558
     * Generates a random string of specified length.
559
     * The string generated matches [A-Za-z0-9_-]+ and is transparent to URL-encoding.
560
     *
561
     * @param int $length the length of the key in characters
562
     * @return string the generated random key
563
     * @throws Exception on failure.
564
     */
565 86
    public function generateRandomString($length = 32)
566
    {
567 86
        if (!is_int($length)) {
0 ignored issues
show
The condition is_int($length) is always true.
Loading history...
568
            throw new InvalidArgumentException('First parameter ($length) must be an integer');
569
        }
570
571 86
        if ($length < 1) {
572
            throw new InvalidArgumentException('First parameter ($length) must be greater than 0');
573
        }
574
575 86
        $bytes = $this->generateRandomKey($length);
576 86
        return substr(StringHelper::base64UrlEncode($bytes), 0, $length);
577
    }
578
579
    /**
580
     * Generates a secure hash from a password and a random salt.
581
     *
582
     * The generated hash can be stored in database.
583
     * Later when a password needs to be validated, the hash can be fetched and passed
584
     * to [[validatePassword()]]. For example,
585
     *
586
     * ```php
587
     * // generates the hash (usually done during user registration or when the password is changed)
588
     * $hash = Yii::$app->getSecurity()->generatePasswordHash($password);
589
     * // ...save $hash in database...
590
     *
591
     * // during login, validate if the password entered is correct using $hash fetched from database
592
     * if (Yii::$app->getSecurity()->validatePassword($password, $hash)) {
593
     *     // password is good
594
     * } else {
595
     *     // password is bad
596
     * }
597
     * ```
598
     *
599
     * @param string $password The password to be hashed.
600
     * @param int $cost Cost parameter used by the Blowfish hash algorithm.
601
     * The higher the value of cost,
602
     * the longer it takes to generate the hash and to verify a password against it. Higher cost
603
     * therefore slows down a brute-force attack. For best protection against brute-force attacks,
604
     * set it to the highest value that is tolerable on production servers. The time taken to
605
     * compute the hash doubles for every increment by one of $cost.
606
     * @return string The password hash string. When [[passwordHashStrategy]] is set to 'crypt',
607
     * the output is always 60 ASCII characters, when set to 'password_hash' the output length
608
     * might increase in future versions of PHP (https://secure.php.net/manual/en/function.password-hash.php)
609
     * @throws Exception on bad password parameter or cost parameter.
610
     * @see validatePassword()
611
     */
612 1
    public function generatePasswordHash($password, $cost = null)
613
    {
614 1
        if ($cost === null) {
615 1
            $cost = $this->passwordHashCost;
616
        }
617
618 1
        if (function_exists('password_hash')) {
619
            /* @noinspection PhpUndefinedConstantInspection */
620 1
            return password_hash($password, PASSWORD_DEFAULT, ['cost' => $cost]);
621
        }
622
623
        $salt = $this->generateSalt($cost);
624
        $hash = crypt($password, $salt);
625
        // strlen() is safe since crypt() returns only ascii
626
        if (!is_string($hash) || strlen($hash) !== 60) {
627
            throw new Exception('Unknown error occurred while generating hash.');
628
        }
629
630
        return $hash;
631
    }
632
633
    /**
634
     * Verifies a password against a hash.
635
     * @param string $password The password to verify.
636
     * @param string $hash The hash to verify the password against.
637
     * @return bool whether the password is correct.
638
     * @throws InvalidArgumentException on bad password/hash parameters or if crypt() with Blowfish hash is not available.
639
     * @see generatePasswordHash()
640
     */
641 1
    public function validatePassword($password, $hash)
642
    {
643 1
        if (!is_string($password) || $password === '') {
0 ignored issues
show
The condition is_string($password) is always true.
Loading history...
644
            throw new InvalidArgumentException('Password must be a string and cannot be empty.');
645
        }
646
647 1
        if (!preg_match('/^\$2[axy]\$(\d\d)\$[\.\/0-9A-Za-z]{22}/', $hash, $matches)
648 1
            || $matches[1] < 4
649 1
            || $matches[1] > 30
650
        ) {
651
            throw new InvalidArgumentException('Hash is invalid.');
652
        }
653
654 1
        if (function_exists('password_verify')) {
655 1
            return password_verify($password, $hash);
656
        }
657
658
        $test = crypt($password, $hash);
659
        $n = strlen($test);
660
        if ($n !== 60) {
661
            return false;
662
        }
663
664
        return $this->compareString($test, $hash);
665
    }
666
667
    /**
668
     * Generates a salt that can be used to generate a password hash.
669
     *
670
     * The PHP [crypt()](https://secure.php.net/manual/en/function.crypt.php) built-in function
671
     * requires, for the Blowfish hash algorithm, a salt string in a specific format:
672
     * "$2a$", "$2x$" or "$2y$", a two digit cost parameter, "$", and 22 characters
673
     * from the alphabet "./0-9A-Za-z".
674
     *
675
     * @param int $cost the cost parameter
676
     * @return string the random salt value.
677
     * @throws InvalidArgumentException if the cost parameter is out of the range of 4 to 31.
678
     */
679
    protected function generateSalt($cost = 13)
680
    {
681
        $cost = (int) $cost;
682
        if ($cost < 4 || $cost > 31) {
683
            throw new InvalidArgumentException('Cost must be between 4 and 31.');
684
        }
685
686
        // Get a 20-byte random string
687
        $rand = $this->generateRandomKey(20);
688
        // Form the prefix that specifies Blowfish (bcrypt) algorithm and cost parameter.
689
        $salt = sprintf('$2y$%02d$', $cost);
690
        // Append the random salt data in the required base64 format.
691
        $salt .= str_replace('+', '.', substr(base64_encode($rand), 0, 22));
692
693
        return $salt;
694
    }
695
696
    /**
697
     * Performs string comparison using timing attack resistant approach.
698
     * @see http://codereview.stackexchange.com/questions/13512
699
     * @param string $expected string to compare.
700
     * @param string $actual user-supplied string.
701
     * @return bool whether strings are equal.
702
     */
703 39
    public function compareString($expected, $actual)
704
    {
705 39
        if (!is_string($expected)) {
0 ignored issues
show
The condition is_string($expected) is always true.
Loading history...
706
            throw new InvalidArgumentException('Expected expected value to be a string, ' . gettype($expected) . ' given.');
707
        }
708
709 39
        if (!is_string($actual)) {
0 ignored issues
show
The condition is_string($actual) is always true.
Loading history...
710
            throw new InvalidArgumentException('Expected actual value to be a string, ' . gettype($actual) . ' given.');
711
        }
712
713 39
        if (function_exists('hash_equals')) {
714 39
            return hash_equals($expected, $actual);
715
        }
716
717
        $expected .= "\0";
718
        $actual .= "\0";
719
        $expectedLength = StringHelper::byteLength($expected);
720
        $actualLength = StringHelper::byteLength($actual);
721
        $diff = $expectedLength - $actualLength;
722
        for ($i = 0; $i < $actualLength; $i++) {
723
            $diff |= (ord($actual[$i]) ^ ord($expected[$i % $expectedLength]));
724
        }
725
726
        return $diff === 0;
727
    }
728
729
    /**
730
     * Masks a token to make it uncompressible.
731
     * Applies a random mask to the token and prepends the mask used to the result making the string always unique.
732
     * Used to mitigate BREACH attack by randomizing how token is outputted on each request.
733
     * @param string $token An unmasked token.
734
     * @return string A masked token.
735
     * @since 2.0.12
736
     */
737 80
    public function maskToken($token)
738
    {
739
        // The number of bytes in a mask is always equal to the number of bytes in a token.
740 80
        $mask = $this->generateRandomKey(StringHelper::byteLength($token));
741 79
        return StringHelper::base64UrlEncode($mask . ($mask ^ $token));
742
    }
743
744
    /**
745
     * Unmasks a token previously masked by `maskToken`.
746
     * @param string $maskedToken A masked token.
747
     * @return string An unmasked token, or an empty string in case of token format is invalid.
748
     * @since 2.0.12
749
     */
750 9
    public function unmaskToken($maskedToken)
751
    {
752 9
        $decoded = StringHelper::base64UrlDecode($maskedToken);
753 9
        $length = StringHelper::byteLength($decoded) / 2;
754
        // Check if the masked token has an even length.
755 9
        if (!is_int($length)) {
0 ignored issues
show
The condition is_int($length) is always true.
Loading history...
756 1
            return '';
757
        }
758
759 9
        return StringHelper::byteSubstr($decoded, $length, $length) ^ StringHelper::byteSubstr($decoded, 0, $length);
760
    }
761
}
762