Passed
Pull Request — develop (#50)
by Pieter van der
03:28
created

Tiqr_UserSecretStorage_Encryption_OpenSSL   A

Complexity

Total Complexity 22

Size/Duplication

Total Lines 180
Duplicated Lines 0 %

Test Coverage

Coverage 93.44%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 22
eloc 79
c 1
b 0
f 0
dl 0
loc 180
ccs 57
cts 61
cp 0.9344
rs 10

4 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 20 4
A get_type() 0 3 1
B encrypt() 0 42 9
B decrypt() 0 45 8
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