|
1
|
|
|
<?php |
|
2
|
|
|
|
|
3
|
|
|
/** |
|
4
|
|
|
* Class for encrypting/decrypting the user's secret with a symmetric key using PHP's openssl extension |
|
5
|
|
|
* |
|
6
|
|
|
* openssl is widely available, and is already a dependency of the tiqr library |
|
7
|
|
|
* |
|
8
|
|
|
* The intended purpose of this class is to encrypt the user's secret before storing it the UserSercretSorage, |
|
9
|
|
|
* allowing you to store the encryption key and the encrypted secrets in different locations so that access to the |
|
10
|
|
|
* storage backed, or the backups thereof, does not allow access to the user's secrets |
|
11
|
|
|
* |
|
12
|
|
|
* Along with the encrypted user secret the IV, cipher and key_id are encoded in each ciphertext. This allows you to: |
|
13
|
|
|
* - Change the cipher in the future and still decrypt key stored under the old cipher |
|
14
|
|
|
* - Rotate the key in the future and still decrypt data stored under the old key |
|
15
|
|
|
* |
|
16
|
|
|
* The intended use it to allow you to update the encryption configuration, and recrypt the data with the new configuration |
|
17
|
|
|
* at a later time. |
|
18
|
|
|
* |
|
19
|
|
|
* SECURITY: |
|
20
|
|
|
* The default cipher is AES-128-CBC, which is a secure symmetric cipher that requires a 128-bit (16-byte) key and that |
|
21
|
|
|
* should be supported by any openssl version currently in use as it is supported in versions even before openssl 1.0. |
|
22
|
|
|
* AES-128-CBC is a good choice for the purpose stated above. However, it is not an authenticated cipher, and CBC is |
|
23
|
|
|
* vulnerable to padding oracle attacks. But assuming that the encrypted data is not controllable by an attacker at runtime, |
|
24
|
|
|
* this is not a problem. |
|
25
|
|
|
* |
|
26
|
|
|
* An alternative to AES-128-CBC that does offer authenticated encryption is AES-128-GCM. |
|
27
|
|
|
* GCM uses a shorter (96-bit (12-byte) vs 128-bit (16-byte)) IV than AES-128-CBC, and being counter mode cipher, is much |
|
28
|
|
|
* more vulnerable to an IV collision. Generally, when a large number of secrets is encrypted under the same key, the |
|
29
|
|
|
* probability of an IV collision increases. For a k-bits IV and N secrets the probability of a collision is approximately: |
|
30
|
|
|
* p_collision = 1 - e^(-N^2 / 2*2^k) |
|
31
|
|
|
* For a 96-bit IV a 1/10^6 probability of a collision is reached at approximately 400,000,000,000 ( 4.0 * 10^11 ) |
|
32
|
|
|
* encryptions and a 1/10^9 probability of a collision is reached at approximately 12,600,000,000 (1.3 * 10^10) encryptions. |
|
33
|
|
|
* A probability of collisions of 1/2^32 (=~ 1/10^10) (NIST 800-38D) is reached after approximately 6 * 10^9 encryptions. |
|
34
|
|
|
* So for practical purposes, it should not matter much. |
|
35
|
|
|
* |
|
36
|
|
|
* For simplicity, and considering that the encryption is intended to protect data at rest, we chose not to implement |
|
37
|
|
|
* additional measures to prevent (key, IV) collisions, like using a KDF to derive the encryption key. |
|
38
|
|
|
*/ |
|
39
|
|
|
class Tiqr_UserSecretStorage_Encryption_OpenSSL implements Tiqr_UserSecretStorage_Encryption_Interface |
|
40
|
|
|
{ |
|
41
|
|
|
private $_cipher; |
|
42
|
|
|
private $_key_id; |
|
43
|
|
|
private $_keys; |
|
44
|
|
|
|
|
45
|
|
|
/* List the supported openssl cipher suites |
|
46
|
|
|
* tag: whether the cipher requires an authentication tag |
|
47
|
|
|
* key: the key length in bytes |
|
48
|
|
|
*/ |
|
49
|
|
|
|
|
50
|
|
|
private $_supportedCiphers = [ |
|
51
|
|
|
'aes-128-cbc' => [ 'tag' => false, 'key' => 16 ], |
|
52
|
|
|
'aes-128-gcm' => [ 'tag' => true, 'key' => 16 ], |
|
53
|
|
|
'aes-192-cbc' => [ 'tag' => false, 'key' => 24 ], |
|
54
|
|
|
'aes-192-gcm' => [ 'tag' => true, 'key' => 24 ], |
|
55
|
|
|
'aes-256-cbc' => [ 'tag' => false, 'key' => 32 ], |
|
56
|
|
|
'aes-256-gcm' => [ 'tag' => true, 'key' => 32 ], |
|
57
|
|
|
'chacha20' => [ 'tag' => false, 'key' => 32 ], |
|
58
|
|
|
'chacha20-poly1305' => [ 'tag' => false, 'key' => 32 ], |
|
59
|
|
|
'camellia-128-cbc' => [ 'tag' => false, 'key' => 16 ], |
|
60
|
|
|
'camellia-192-cbc' => [ 'tag' => false, 'key' => 24 ], |
|
61
|
|
|
'camellia-256-cbc' => [ 'tag' => false, 'key' => 32 ], |
|
62
|
|
|
'aria-128-cbc' => [ 'tag' => false, 'key' => 16 ], |
|
63
|
|
|
'aria-128-gcm' => [ 'tag' => true, 'key' => 16 ], |
|
64
|
|
|
'aria-192-cbc' => [ 'tag' => false, 'key' => 24 ], |
|
65
|
|
|
'aria-192-gcm' => [ 'tag' => true, 'key' => 24 ], |
|
66
|
|
|
'aria-256-cbc' => [ 'tag' => false, 'key' => 32 ], |
|
67
|
|
|
'aria-256-gcm' => [ 'tag' => true, 'key' => 32 ], |
|
68
|
|
|
]; |
|
69
|
|
|
|
|
70
|
|
|
/** |
|
71
|
|
|
* Construct an encryption instance. |
|
72
|
|
|
* |
|
73
|
|
|
* Supported options in $config: |
|
74
|
|
|
* cipher: The openssl cipher suite to use for encryption. This must be a symmetric cipher suite supported by your |
|
75
|
|
|
* version of openssl. The default is AES-128-CBC. Another good option would be AES-128-GCM |
|
76
|
|
|
* The name of the cipher suite is case insensitive. |
|
77
|
|
|
* key_id: The key id to use for encryption. This is used to identify the key to use for encryption. |
|
78
|
|
|
* ked_id is case insensitive. |
|
79
|
|
|
* keys: An array of key_id => key_value of keys to use for encryption and decryption. The key_value is the |
|
80
|
|
|
* hex encoded value of the symmetric encryption key. The key value must be the correct length for the cipher. |
|
81
|
|
|
* E.g. for a 128-bit key, the key_value must be 32 hex characters long. |
|
82
|
|
|
* The key with key_id must appear in this array, additional encryption key can be added to this array to |
|
83
|
|
|
* allow these keys to be used for decryption. |
|
84
|
|
|
* A key_id must not contain the character ':' as this is used as a separator in the ciphertext components. |
|
85
|
|
|
* |
|
86
|
|
|
* @param array $config The configuration |
|
87
|
|
|
* @throws RuntimeException |
|
88
|
|
|
*/ |
|
89
|
42 |
|
public function __construct(Array $config) |
|
90
|
|
|
{ |
|
91
|
42 |
|
$this->_cipher = strtolower($config['cipher'] ?? 'aes-128-cbc'); |
|
92
|
|
|
|
|
93
|
|
|
// Check if the cipher is supported by openssl, match case-insensitive because openssl_get_cipher_methods returns |
|
94
|
|
|
// the cipher names in different casings, depending on the openssl version |
|
95
|
42 |
|
$opensslSupportedCiphers = array_map('strtolower', openssl_get_cipher_methods()); |
|
96
|
42 |
|
if (!in_array($this->_cipher, $opensslSupportedCiphers)) { |
|
97
|
1 |
|
throw new RuntimeException("Cipher '{$this->_cipher}' is not supported by your version of openssl"); |
|
98
|
|
|
} |
|
99
|
41 |
|
if (!isset($this->_supportedCiphers[$this->_cipher])) { |
|
100
|
1 |
|
throw new RuntimeException("Cipher '{$this->_cipher}' is not supported by this version of the tiqr library"); |
|
101
|
|
|
} |
|
102
|
|
|
|
|
103
|
40 |
|
$this->_key_id = strtolower($config['key_id'] ?? 'default'); |
|
104
|
40 |
|
if (strpos($this->_key_id, ':') !== false) { |
|
105
|
1 |
|
throw new RuntimeException("Key id '{$this->_key_id}' contains invalid character ':'"); |
|
106
|
|
|
} |
|
107
|
|
|
|
|
108
|
39 |
|
$this->_keys = $config['keys'] ?? []; |
|
109
|
39 |
|
} |
|
110
|
|
|
|
|
111
|
|
|
/** |
|
112
|
|
|
* Encrypts the given data. |
|
113
|
|
|
* |
|
114
|
|
|
* @param String $data Data to encrypt. |
|
115
|
|
|
* @return string encrypted data |
|
116
|
|
|
* @throws RuntimeException |
|
117
|
|
|
*/ |
|
118
|
28 |
|
public function encrypt(string $data) : string |
|
119
|
|
|
{ |
|
120
|
28 |
|
$iv_length = openssl_cipher_iv_length($this->_cipher); |
|
121
|
|
|
// All the supported ciphers use an IV >= 12 |
|
122
|
28 |
|
if (($iv_length === false) || ($iv_length < 12)) { |
|
123
|
|
|
throw new RuntimeException("Failed to get IV length for cipher '{$this->_cipher}'"); |
|
124
|
|
|
} |
|
125
|
28 |
|
$iv = Tiqr_Random::randomBytes($iv_length); |
|
126
|
28 |
|
if (!isset($this->_keys[$this->_key_id])) { |
|
127
|
1 |
|
throw new RuntimeException("No key configured for key_id '{$this->_key_id}'"); |
|
128
|
|
|
} |
|
129
|
27 |
|
@$key = hex2bin($this->_keys[$this->_key_id]); |
|
130
|
27 |
|
if ($key === false) { |
|
131
|
1 |
|
throw new RuntimeException("Error decoding key with key_id '{$this->_key_id}'"); |
|
132
|
|
|
} |
|
133
|
|
|
|
|
134
|
|
|
// If the passphrase (key) to openssl_encrypt is shorter than expected, it is silently padded with NUL characters; |
|
135
|
|
|
// if the passphrase is longer than expected, it is silently truncated. |
|
136
|
|
|
// openssl_cipher_key_length() requires PHP >= 8.2, so we use a lookup table instead |
|
137
|
|
|
// A longer key is not a problem, but could indicate a configuration error |
|
138
|
26 |
|
$key_length = $this->_supportedCiphers[$this->_cipher]['key']; |
|
139
|
26 |
|
if (strlen($key) != $key_length) { |
|
140
|
2 |
|
throw new RuntimeException("Invalid length of key with key_id '{$this->_key_id}' used with cipher '{$this->_cipher}', expected {$key_length} bytes, got " . strlen($key) . " bytes"); |
|
141
|
|
|
} |
|
142
|
|
|
|
|
143
|
|
|
// openssl_encrypt returns the ciphertext as a base64 encoded string, so we don't need to encode it again |
|
144
|
|
|
// The tag is returned as a binary string, but only if the cipher requires a tag |
|
145
|
24 |
|
$tag=''; |
|
146
|
24 |
|
if ($this->_supportedCiphers[$this->_cipher]['tag']) { |
|
147
|
6 |
|
$encrypted = openssl_encrypt($data, $this->_cipher, $key, 0, $iv, $tag, '', 16); |
|
148
|
|
|
} else { |
|
149
|
18 |
|
$encrypted = openssl_encrypt($data, $this->_cipher, $key, 0, $iv); |
|
150
|
|
|
} |
|
151
|
24 |
|
if ($encrypted === false) { |
|
152
|
|
|
throw new RuntimeException("Error encrypting data"); |
|
153
|
|
|
} |
|
154
|
24 |
|
$tag = $this->_supportedCiphers[$this->_cipher]['tag'] ? $tag : ''; |
|
155
|
|
|
// Return the encoded ciphertext, including the IV, tag and cipher |
|
156
|
|
|
// <cipher>:<key_id>:iv<>:<tag>:<ciphertext> |
|
157
|
24 |
|
$encoded = $this->_cipher . ":" . $this->_key_id . ":" . base64_encode($iv) . ":" . base64_encode($tag) . ":" . $encrypted; |
|
158
|
|
|
|
|
159
|
24 |
|
return $encoded; |
|
160
|
|
|
} |
|
161
|
|
|
|
|
162
|
|
|
/** |
|
163
|
|
|
* Decrypts the given data. |
|
164
|
|
|
* |
|
165
|
|
|
* @param string $data Data to decrypt. |
|
166
|
|
|
* @return string decrypted data |
|
167
|
|
|
* @throws RuntimeException |
|
168
|
|
|
*/ |
|
169
|
33 |
|
public function decrypt(string $data) : string |
|
170
|
|
|
{ |
|
171
|
|
|
// Split the encoded data into its components |
|
172
|
|
|
// <cipher>:<key_id>:<iv>:<tag>:<ciphertext> |
|
173
|
33 |
|
$split_data = explode(':', $data); |
|
174
|
33 |
|
if (count($split_data) != 5) { |
|
175
|
2 |
|
throw new RuntimeException("Invalid ciphertext format"); |
|
176
|
|
|
} |
|
177
|
|
|
|
|
178
|
|
|
// Cipher |
|
179
|
31 |
|
$cipher = strtolower($split_data[0]); |
|
180
|
31 |
|
$supportedCiphers = array_map('strtolower', openssl_get_cipher_methods()); |
|
181
|
31 |
|
if (!in_array($cipher, $supportedCiphers)) { |
|
182
|
|
|
throw new RuntimeException("Cipher '$cipher' is not supported by your version of openssl"); |
|
183
|
|
|
} |
|
184
|
|
|
|
|
185
|
|
|
// Key id |
|
186
|
31 |
|
$key_id = strtolower($split_data[1]); |
|
187
|
31 |
|
if (!isset($this->_keys[$key_id])) { |
|
188
|
1 |
|
throw new RuntimeException("No key configured for key_id '$key_id'"); |
|
189
|
|
|
} |
|
190
|
30 |
|
@$key = hex2bin($this->_keys[$key_id]); |
|
191
|
30 |
|
if ($key === false) { |
|
192
|
|
|
throw new RuntimeException("Error decoding key with key_id '$key_id'"); |
|
193
|
|
|
} |
|
194
|
|
|
|
|
195
|
|
|
// IV |
|
196
|
30 |
|
$iv = base64_decode($split_data[2],true); |
|
197
|
30 |
|
if ($iv === false) { |
|
198
|
1 |
|
throw new RuntimeException("Error decoding IV"); |
|
199
|
|
|
} |
|
200
|
|
|
|
|
201
|
|
|
// Tag |
|
202
|
29 |
|
$tag = base64_decode($split_data[3],true); |
|
203
|
29 |
|
if ($tag === false) { |
|
204
|
1 |
|
throw new RuntimeException("Error decoding tag"); |
|
205
|
|
|
} |
|
206
|
28 |
|
$ciphertext = $split_data[4]; |
|
207
|
|
|
|
|
208
|
28 |
|
$plaintext=openssl_decrypt($ciphertext, $cipher, $key, 0, $iv, $tag); |
|
209
|
28 |
|
if ($plaintext === false) { |
|
210
|
4 |
|
throw new RuntimeException("Error decrypting data"); |
|
211
|
|
|
} |
|
212
|
|
|
|
|
213
|
26 |
|
return $plaintext; |
|
214
|
|
|
} |
|
215
|
|
|
|
|
216
|
2 |
|
public function get_type() : string |
|
217
|
|
|
{ |
|
218
|
2 |
|
return 'openssl'; |
|
219
|
|
|
} |
|
220
|
|
|
} |
|
221
|
|
|
|