Passed
Pull Request — master (#11)
by Alexander
14:10
created

Crypt::withAuthorizationKeyInfo()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 3
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 5
ccs 0
cts 0
cp 0
crap 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Security;
6
7
use Yiisoft\Strings\StringHelper;
8
9
final class Crypt
10
{
11
    /**
12
     * @var array[] Look-up table of block sizes and key sizes for each supported OpenSSL cipher.
13
     *
14
     * In each element, the key is one of the ciphers supported by OpenSSL {@see openssl_get_cipher_methods()}.
15
     * The value is an array of two integers, the first is the cipher's block size in bytes and the second is
16
     * the key size in bytes.
17
     *
18
     * > Note: Yii's encryption protocol uses the same size for cipher key, HMAC signature key and key
19
     * derivation salt.
20
     */
21
    private const ALLOWED_CIPHERS = [
22
        'AES-128-CBC' => [16, 16],
23
        'AES-192-CBC' => [16, 24],
24
        'AES-256-CBC' => [16, 32],
25
    ];
26
27
    /**
28
     * @var string The cipher to use for encryption and decryption.
29
     */
30
    private string $cipher;
31
32
    /**
33
     * @var string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512.
34
     * @see http://php.net/manual/en/function.hash-algos.php
35
     */
36
    private string $kdfAlgorithm = 'sha256';
37
38
    /**
39
     * @var string HKDF info value for derivation of message authentication key.
40
     */
41
    private string $authorizationKeyInfo = 'AuthorizationKey';
42
    /**
43
     * @var int Derivation iterations count.
44
     * Set as high as possible to hinder dictionary password attacks.
45
     */
46
    private int $derivationIterations = 100000;
47
48 48
    /**
49
     * @param string $cipher The cipher to use for encryption and decryption.
50 48
     */
51 1
    public function __construct(string $cipher = 'AES-128-CBC')
52
    {
53 47
        if (!extension_loaded('openssl')) {
54 1
            throw new \RuntimeException('Encryption requires the OpenSSL PHP extension');
55
        }
56
        if (!isset(self::ALLOWED_CIPHERS[$cipher][0], self::ALLOWED_CIPHERS[$cipher][1])) {
57 46
            throw new \RuntimeException($cipher . ' is not an allowed cipher');
58 46
        }
59
60 1
        $this->cipher = $cipher;
61
    }
62 1
63 1
    /**
64 1
     * @psalm-mutation-free
65
     * @param string $algorithm Hash algorithm for key derivation. Recommend sha256, sha384 or sha512.
66
     * @return self
67 1
     */
68
    public function withKdfAlgorithm(string $algorithm): self
69 1
    {
70 1
        $new = clone $this;
71 1
        $new->kdfAlgorithm = $algorithm;
72
        return $new;
73
    }
74 44
75
    /**
76 44
     * @psalm-mutation-free
77 44
     * @param string $info HKDF info value for derivation of message authentication key.
78 44
     * @return self
79
     */
80
    public function withAuthorizationKeyInfo(string $info): self
81
    {
82
        $new = clone $this;
83
        $new->authorizationKeyInfo = $info;
84
        return $new;
85
    }
86
87
    /**
88
     * @psalm-mutation-free
89
     * @param int $iterations Derivation iterations count.
90
     * Set as high as possible to hinder dictionary password attacks.
91
     * @return self
92
     */
93
    public function withDerivationIterations(int $iterations): self
94
    {
95
        $new = clone $this;
96
        $new->derivationIterations = $iterations;
97
        return $new;
98
    }
99 4
100
    /**
101 4
     * Encrypts data using a password.
102
     *
103
     * Derives keys for encryption and authentication from the password using PBKDF2 and a random salt,
104
     * which is deliberately slow to protect against dictionary attacks. Use {@see encryptByKey()} to
105
     * encrypt fast using a cryptographic key rather than a password. Key derivation time is
106
     * determined by {@see $derivationIterations}}, which should be set as high as possible.
107
     *
108
     * The encrypted data includes a keyed message authentication code (MAC) so there is no need
109
     * to hash input or output data.
110
     *
111
     * > Note: Avoid encrypting with passwords wherever possible. Nothing can protect against
112
     * poor-quality or compromised passwords.
113
     *
114
     * @param string $data The data to encrypt.
115
     * @param string $password The password to use for encryption.
116
     * @return string The encrypted data as byte string.
117
     * @throws \RuntimeException On OpenSSL not loaded.
118
     * @throws \Exception On OpenSSL error.
119
     * @see decryptByPassword()
120
     * @see encryptByKey()
121 4
     */
122
    public function encryptByPassword(string $data, string $password): string
123 4
    {
124
        return $this->encrypt($data, true, $password, '');
125
    }
126
127
    /**
128
     * Encrypts data using a cryptographic key.
129
     *
130
     * Derives keys for encryption and authentication from the input key using HKDF and a random salt,
131
     * which is very fast relative to {@see encryptByPassword()}. The input key must be properly
132
     * random — use {@see random_bytes()} to generate keys.
133
     * The encrypted data includes a keyed message authentication code (MAC) so there is no need
134
     * to hash input or output data.
135
     *
136 21
     * @param string $data The data to encrypt.
137
     * @param string $inputKey The input to use for encryption and authentication.
138 21
     * @param string $info Context/application specific information, e.g. a user ID
139
     * See [RFC 5869 Section 3.2](https://tools.ietf.org/html/rfc5869#section-3.2) for more details.
140
     * @return string The encrypted data as byte string.
141
     * @throws \RuntimeException On OpenSSL not loaded.
142
     * @throws \Exception On OpenSSL error.
143
     * @see decryptByKey()
144
     * @see encryptByPassword()
145
     */
146
    public function encryptByKey(string $data, string $inputKey, string $info = ''): string
147
    {
148
        return $this->encrypt($data, false, $inputKey, $info);
149
    }
150
151
    /**
152
     * Verifies and decrypts data encrypted with {@see encryptByPassword()}.
153 22
     *
154
     * @param string $data The encrypted data to decrypt.
155 22
     * @param string $password The password to use for decryption.
156
     * @return string The decrypted data.
157
     * @throws \RuntimeException On OpenSSL not loaded.
158
     * @throws \Exception On OpenSSL errors.
159
     * @throws AuthenticationException On authentication failure.
160
     * @see encryptByPassword()
161
     */
162
    public function decryptByPassword(string $data, string $password): string
163
    {
164
        return $this->decrypt($data, true, $password, '');
165
    }
166
167
    /**
168
     * Verifies and decrypts data encrypted with {@see encryptByKey()}.
169
     *
170
     * @param string $data The encrypted data to decrypt.
171
     * @param string $inputKey The input to use for encryption and authentication.
172 8
     * @param string $info Context/application specific information, e.g. a user ID
173
     * See [RFC 5869 Section 3.2](https://tools.ietf.org/html/rfc5869#section-3.2) for more details.
174 8
     * @return string The decrypted data.
175
     * @throws \RuntimeException On OpenSSL not loaded.
176 8
     * @throws \Exception On OpenSSL errors.
177 8
     * @throws AuthenticationException On authentication failure.
178 4
     * @see encryptByKey()
179
     */
180 4
    public function decryptByKey(string $data, string $inputKey, string $info = ''): string
181
    {
182
        return $this->decrypt($data, false, $inputKey, $info);
183 8
    }
184
185 8
    /**
186 8
     * Encrypts data.
187 1
     *
188
     * @param string $data data to be encrypted
189
     * @param bool $passwordBased set true to use password-based key derivation
190 7
     * @param string $secret the encryption password or key
191 7
     * @param string $info context/application specific information, e.g. a user ID
192
     * See [RFC 5869 Section 3.2](https://tools.ietf.org/html/rfc5869#section-3.2) for more details.
193
     *
194
     * @return string the encrypted data as byte string
195
     * @throws \RuntimeException on OpenSSL not loaded
196
     * @throws \Exception on OpenSSL error
197
     * @see decrypt()
198
     */
199 7
    private function encrypt(string $data, bool $passwordBased, string $secret, string $info = ''): string
200
    {
201
        [$blockSize, $keySize] = self::ALLOWED_CIPHERS[$this->cipher];
202
203
        $keySalt = random_bytes($keySize);
204
        if ($passwordBased) {
205
            $key = hash_pbkdf2($this->kdfAlgorithm, $secret, $keySalt, $this->derivationIterations, $keySize, true);
206
        } else {
207
            $key = hash_hkdf($this->kdfAlgorithm, $secret, $keySize, $info, $keySalt);
208
        }
209
210
        $iv = random_bytes($blockSize);
211
212
        $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $iv);
213
        if ($encrypted === false) {
214
            throw new \RuntimeException('OpenSSL failure on encryption: ' . openssl_error_string());
215
        }
216 43
217
        $authKey = hash_hkdf($this->kdfAlgorithm, $key, $keySize, $this->authorizationKeyInfo);
218 43
        $signed = (new Mac())->sign($iv . $encrypted, $authKey);
0 ignored issues
show
Bug introduced by
Are you sure $encrypted of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

218
        $signed = (new Mac())->sign($iv . /** @scrutinizer ignore-type */ $encrypted, $authKey);
Loading history...
219
220 43
        /*
221 43
         * Output: [keySalt][MAC][IV][ciphertext]
222 21
         * - keySalt is KEY_SIZE bytes long
223
         * - MAC: message authentication code, length same as the output of MAC_HASH
224 22
         * - IV: initialization vector, length $blockSize
225
         */
226
        return $keySalt . $signed;
227 43
    }
228
229
    /**
230 43
     * Decrypts data.
231 3
     *
232 3
     * @param string $data encrypted data to be decrypted.
233
     * @param bool $passwordBased set true to use password-based key derivation
234
     * @param string $secret the decryption password or key
235 40
     * @param string $info context/application specific information, @see encrypt()
236 40
     *
237
     * @return string the decrypted data
238 40
     * @throws \RuntimeException on OpenSSL not loaded
239 40
     * @throws \Exception on OpenSSL errors
240 1
     * @throws AuthenticationException on authentication failure
241
     * @see encrypt()
242
     */
243 39
    private function decrypt(string $data, bool $passwordBased, string $secret, string $info): string
244
    {
245
        [$blockSize, $keySize] = self::ALLOWED_CIPHERS[$this->cipher];
246
247
        $keySalt = StringHelper::byteSubstring($data, 0, $keySize);
248
        if ($passwordBased) {
249
            $key = hash_pbkdf2($this->kdfAlgorithm, $secret, $keySalt, $this->derivationIterations, $keySize, true);
250
        } else {
251
            $key = hash_hkdf($this->kdfAlgorithm, $secret, $keySize, $info, $keySalt);
252
        }
253
254
        $authKey = hash_hkdf($this->kdfAlgorithm, $key, $keySize, $this->authorizationKeyInfo);
255
256
        try {
257
            $data = (new Mac())->getMessage(StringHelper::byteSubstring($data, $keySize), $authKey);
258
        } catch (DataIsTamperedException $e) {
259
            throw new AuthenticationException('Failed to decrypt data');
260
        }
261
262
        $iv = StringHelper::byteSubstring($data, 0, $blockSize);
263
        $encrypted = StringHelper::byteSubstring($data, $blockSize);
264
265
        $decrypted = openssl_decrypt($encrypted, $this->cipher, $key, OPENSSL_RAW_DATA, $iv);
266
        if ($decrypted === false) {
267
            throw new \RuntimeException('OpenSSL failure on decryption: ' . openssl_error_string());
268
        }
269
270
        return $decrypted;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $decrypted could return the type true which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
271
    }
272
}
273