Tiqr_UserSecretStorage_Encryption_OpenSSL   A
last analyzed

Complexity

Total Complexity 22

Size/Duplication

Total Lines 179
Duplicated Lines 0 %

Test Coverage

Coverage 93.33%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 22
eloc 78
c 1
b 0
f 0
dl 0
loc 179
ccs 56
cts 60
cp 0.9333
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 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