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 in the UserSecretStorage, |
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 is 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
|
|
|
'camellia-128-cbc' => [ 'tag' => false, 'key' => 16 ], |
59
|
|
|
'camellia-192-cbc' => [ 'tag' => false, 'key' => 24 ], |
60
|
|
|
'camellia-256-cbc' => [ 'tag' => false, 'key' => 32 ], |
61
|
|
|
'aria-128-cbc' => [ 'tag' => false, 'key' => 16 ], |
62
|
|
|
'aria-128-gcm' => [ 'tag' => true, 'key' => 16 ], |
63
|
|
|
'aria-192-cbc' => [ 'tag' => false, 'key' => 24 ], |
64
|
|
|
'aria-192-gcm' => [ 'tag' => true, 'key' => 24 ], |
65
|
|
|
'aria-256-cbc' => [ 'tag' => false, 'key' => 32 ], |
66
|
|
|
'aria-256-gcm' => [ 'tag' => true, 'key' => 32 ], |
67
|
|
|
]; |
68
|
|
|
|
69
|
|
|
/** |
70
|
|
|
* Construct an encryption instance. |
71
|
|
|
* |
72
|
|
|
* Supported options in $config: |
73
|
|
|
* cipher: The openssl cipher suite to use for encryption. This must be a symmetric cipher suite supported by your |
74
|
|
|
* version of openssl. The default is AES-128-CBC. Another good option would be AES-128-GCM |
75
|
|
|
* The name of the cipher suite is case insensitive. |
76
|
|
|
* key_id: The key id to use for encryption. This is used to identify the key to use for encryption. |
77
|
|
|
* ked_id is case insensitive. |
78
|
|
|
* keys: An array of key_id => key_value of keys to use for encryption and decryption. The key_value is the |
79
|
|
|
* hex encoded value of the symmetric encryption key. The key value must be the correct length for the cipher. |
80
|
|
|
* E.g. for a 128-bit key, the key_value must be 32 hex characters long. |
81
|
|
|
* The key with key_id must appear in this array, additional encryption key can be added to this array to |
82
|
|
|
* allow these keys to be used for decryption. |
83
|
|
|
* A key_id must not contain the character ':' as this is used as a separator in the ciphertext components. |
84
|
|
|
* |
85
|
|
|
* @param array $config The configuration |
86
|
|
|
* @throws RuntimeException |
87
|
|
|
*/ |
88
|
40 |
|
public function __construct(Array $config) |
89
|
|
|
{ |
90
|
40 |
|
$this->_cipher = strtolower($config['cipher'] ?? 'aes-128-cbc'); |
91
|
|
|
|
92
|
|
|
// Check if the cipher is supported by openssl, match case-insensitive because openssl_get_cipher_methods returns |
93
|
|
|
// the cipher names in different casings, depending on the openssl version |
94
|
40 |
|
$opensslSupportedCiphers = array_map('strtolower', openssl_get_cipher_methods()); |
95
|
40 |
|
if (!in_array($this->_cipher, $opensslSupportedCiphers)) { |
96
|
1 |
|
throw new RuntimeException("Cipher '{$this->_cipher}' is not supported by your version of openssl"); |
97
|
|
|
} |
98
|
39 |
|
if (!isset($this->_supportedCiphers[$this->_cipher])) { |
99
|
1 |
|
throw new RuntimeException("Cipher '{$this->_cipher}' is not supported by this version of the tiqr library"); |
100
|
|
|
} |
101
|
|
|
|
102
|
38 |
|
$this->_key_id = strtolower($config['key_id'] ?? 'default'); |
103
|
38 |
|
if (strpos($this->_key_id, ':') !== false) { |
104
|
1 |
|
throw new RuntimeException("Key id '{$this->_key_id}' contains invalid character ':'"); |
105
|
|
|
} |
106
|
|
|
|
107
|
37 |
|
$this->_keys = $config['keys'] ?? []; |
108
|
|
|
} |
109
|
|
|
|
110
|
|
|
/** |
111
|
|
|
* Encrypts the given data. |
112
|
|
|
* |
113
|
|
|
* @param String $data Data to encrypt. |
114
|
|
|
* @return string encrypted data |
115
|
|
|
* @throws RuntimeException |
116
|
|
|
*/ |
117
|
26 |
|
public function encrypt(string $data) : string |
118
|
|
|
{ |
119
|
26 |
|
$iv_length = openssl_cipher_iv_length($this->_cipher); |
120
|
|
|
// All the supported ciphers use an IV >= 12 |
121
|
26 |
|
if (($iv_length === false) || ($iv_length < 12)) { |
122
|
|
|
throw new RuntimeException("Failed to get IV length for cipher '{$this->_cipher}'"); |
123
|
|
|
} |
124
|
26 |
|
$iv = Tiqr_Random::randomBytes($iv_length); |
125
|
26 |
|
if (!isset($this->_keys[$this->_key_id])) { |
126
|
1 |
|
throw new RuntimeException("No key configured for key_id '{$this->_key_id}'"); |
127
|
|
|
} |
128
|
25 |
|
@$key = hex2bin($this->_keys[$this->_key_id]); |
129
|
25 |
|
if ($key === false) { |
130
|
1 |
|
throw new RuntimeException("Error decoding key with key_id '{$this->_key_id}'"); |
131
|
|
|
} |
132
|
|
|
|
133
|
|
|
// If the passphrase (key) to openssl_encrypt is shorter than expected, it is silently padded with NUL characters; |
134
|
|
|
// if the passphrase is longer than expected, it is silently truncated. |
135
|
|
|
// openssl_cipher_key_length() requires PHP >= 8.2, so we use a lookup table instead |
136
|
|
|
// A longer key is not a problem, but could indicate a configuration error |
137
|
24 |
|
$key_length = $this->_supportedCiphers[$this->_cipher]['key']; |
138
|
24 |
|
if (strlen($key) != $key_length) { |
139
|
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"); |
140
|
|
|
} |
141
|
|
|
|
142
|
|
|
// openssl_encrypt returns the ciphertext as a base64 encoded string, so we don't need to encode it again |
143
|
|
|
// The tag is returned as a binary string, but only if the cipher requires a tag |
144
|
22 |
|
$tag=''; |
145
|
22 |
|
if ($this->_supportedCiphers[$this->_cipher]['tag']) { |
146
|
6 |
|
$encrypted = openssl_encrypt($data, $this->_cipher, $key, 0, $iv, $tag, '', 16); |
147
|
|
|
} else { |
148
|
16 |
|
$encrypted = openssl_encrypt($data, $this->_cipher, $key, 0, $iv); |
149
|
|
|
} |
150
|
22 |
|
if ($encrypted === false) { |
151
|
|
|
throw new RuntimeException("Error encrypting data"); |
152
|
|
|
} |
153
|
22 |
|
$tag = $this->_supportedCiphers[$this->_cipher]['tag'] ? $tag : ''; |
154
|
|
|
// Return the encoded ciphertext, including the IV, tag and cipher |
155
|
|
|
// <cipher>:<key_id>:iv<>:<tag>:<ciphertext> |
156
|
22 |
|
$encoded = $this->_cipher . ":" . $this->_key_id . ":" . base64_encode($iv) . ":" . base64_encode($tag) . ":" . $encrypted; |
157
|
|
|
|
158
|
22 |
|
return $encoded; |
159
|
|
|
} |
160
|
|
|
|
161
|
|
|
/** |
162
|
|
|
* Decrypts the given data. |
163
|
|
|
* |
164
|
|
|
* @param string $data Data to decrypt. |
165
|
|
|
* @return string decrypted data |
166
|
|
|
* @throws RuntimeException |
167
|
|
|
*/ |
168
|
31 |
|
public function decrypt(string $data) : string |
169
|
|
|
{ |
170
|
|
|
// Split the encoded data into its components |
171
|
|
|
// <cipher>:<key_id>:<iv>:<tag>:<ciphertext> |
172
|
31 |
|
$split_data = explode(':', $data); |
173
|
31 |
|
if (count($split_data) != 5) { |
174
|
2 |
|
throw new RuntimeException("Invalid ciphertext format"); |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
// Cipher |
178
|
29 |
|
$cipher = strtolower($split_data[0]); |
179
|
29 |
|
$supportedCiphers = array_map('strtolower', openssl_get_cipher_methods()); |
180
|
29 |
|
if (!in_array($cipher, $supportedCiphers)) { |
181
|
|
|
throw new RuntimeException("Cipher '$cipher' is not supported by your version of openssl"); |
182
|
|
|
} |
183
|
|
|
|
184
|
|
|
// Key id |
185
|
29 |
|
$key_id = strtolower($split_data[1]); |
186
|
29 |
|
if (!isset($this->_keys[$key_id])) { |
187
|
1 |
|
throw new RuntimeException("No key configured for key_id '$key_id'"); |
188
|
|
|
} |
189
|
28 |
|
@$key = hex2bin($this->_keys[$key_id]); |
190
|
28 |
|
if ($key === false) { |
191
|
|
|
throw new RuntimeException("Error decoding key with key_id '$key_id'"); |
192
|
|
|
} |
193
|
|
|
|
194
|
|
|
// IV |
195
|
28 |
|
$iv = base64_decode($split_data[2],true); |
196
|
28 |
|
if ($iv === false) { |
197
|
1 |
|
throw new RuntimeException("Error decoding IV"); |
198
|
|
|
} |
199
|
|
|
|
200
|
|
|
// Tag |
201
|
27 |
|
$tag = base64_decode($split_data[3],true); |
202
|
27 |
|
if ($tag === false) { |
203
|
1 |
|
throw new RuntimeException("Error decoding tag"); |
204
|
|
|
} |
205
|
26 |
|
$ciphertext = $split_data[4]; |
206
|
|
|
|
207
|
26 |
|
$plaintext=openssl_decrypt($ciphertext, $cipher, $key, 0, $iv, $tag); |
208
|
26 |
|
if ($plaintext === false) { |
209
|
4 |
|
throw new RuntimeException("Error decrypting data"); |
210
|
|
|
} |
211
|
|
|
|
212
|
24 |
|
return $plaintext; |
213
|
|
|
} |
214
|
|
|
|
215
|
2 |
|
public function get_type() : string |
216
|
|
|
{ |
217
|
2 |
|
return 'openssl'; |
218
|
|
|
} |
219
|
|
|
} |
220
|
|
|
|