Passed
Push — master ( 5fc211...351848 )
by Scrutinizer
01:21
created

CypherString::getKeyPublic()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
1
<?php
2
3
/**
4
 * This class creates a RSA key pair, encrypts and decrypts a string.
5
 *
6
 * Usage:
7
 *    $cypher   = new KEINOS\lib\CypherString('/path/to/key/pair.json');
8
 *    $data_enc = $cypher->encrypt('Sample data');
9
 *    $data_dec = $cypher->decrypt($data_enc);
10
 * Note:
11
 *    Be aware to be compatible with PHP 7.1 or higher.
12
 */
13
14
declare(strict_types=1);
15
16
namespace KEINOS\lib;
17
18
final class CypherString
19
{
20
    private const AS_ASSOCIATIVE_ARRAY = true;
21
22
    /** @var array<string,mixed> $config_ssl */
23
    private $config_ssl;
24
    /** @var string $key_private */
25
    private $key_private;
26
    /** @var string $key_public */
27
    private $key_public;
28
    /** @var string $passphrase */
29
    private $passphrase;
30
    /** @var string $path_file_config */
31
    private $path_file_config;
32
    /** @var bool $flag_keys_available */
33
    private $flag_keys_available = false;
34
35
    /**
36
     * Instantiate an object from the provided JSON file.
37
     *
38
     * @param  string $path_file_config  File path of the key pair and configuration in JSON format.
39
     *                                   It will generate a new one if the file doesn't exist.
40
     * @param  string $passphrase        The passphrase to use the key pair. (Optional)
41
     * @return void
42
     */
43
    public function __construct(string $path_file_config, string $passphrase = '')
44
    {
45
        $this->path_file_config = $path_file_config;
46
47
        // Load existing config file
48
        if (file_exists($path_file_config)) {
49
            $this->load($path_file_config);
50
            $this->setFlagAsKeysAvailable(true);
51
            return;
52
        }
53
54
        // Creates a key pair and saves to the provided file path
55
        $this->init($passphrase);
56
    }
57
58
    /**
59
     * Decodes JSON string from the configuration file to an array.
60
     *
61
     * @param  string $conf_json    JSON string from conf file.
62
     * @return array<string,mixed>
63
     * @throws \Exception           On any error occurred while decoding or missing requirements.
64
     */
65
    private function decodeJsonConf(string $conf_json): array
66
    {
67
        $data = json_decode($conf_json, self::AS_ASSOCIATIVE_ARRAY);
68
69
        if (is_null($data)) {
70
            throw new \Exception('Failed to decode JSON.' . PHP_EOL . 'JSON: ' . $conf_json);
71
        }
72
73
        // Check if $data contains must keys required
74
        $keys_required = [
75
            'key_private',
76
            'key_public',
77
            'passphrase',
78
            'config_ssl',
79
        ];
80
        if (array_diff_key(array_flip($keys_required), $data)) {
81
            throw new \Exception('Missing information in JSON config file.');
82
        }
83
84
        return $data;
85
    }
86
87
    /**
88
     * Decodes JSON string from the "encrypt()" method to an array.
89
     *
90
     * @param  string $data_json
91
     * @return array<string,mixed>
92
     * @throws \Exception
93
     *     On any error occurred while decoding or missing requirements.
94
     */
95
    private function decodeJsonData(string $data_json): array
96
    {
97
        $data = json_decode($data_json, self::AS_ASSOCIATIVE_ARRAY);
98
99
        if (is_null($data)) {
100
            throw new \Exception('Malformed JSON string given. Failed to decode JSON string to array.');
101
        }
102
103
        // Verify basic requirements
104
        if (! isset($data['data_encrypted'])) {
105
            throw new \Exception('"data_encrypted" key missing. The JSON string does not contain the encoded data.');
106
        }
107
        if (! isset($data['data_sealed'])) {
108
            throw new \Exception('"data_sealed" key missing. The JSON string does not contain the sealed data.');
109
        }
110
111
        // Decode base64 encoded basic requirements
112
        $data['data_encrypted'] = base64_decode($data['data_encrypted']);
113
        $data['data_sealed']    = base64_decode($data['data_sealed']);
114
115
        // Sets optional requirements
116
        $data['key_private_pem'] = isset($data['key_private_pem']) ? $data['key_private_pem'] : $this->getKeyPrivate();
117
        $data['passphrase']      = isset($data['passphrase'])      ? $data['passphrase']      : $this->getPassphrase();
118
119
        return $data;
120
    }
121
122
    /**
123
     * @param  string $data_json  JSON data from encrypt() method with additional data.
124
     * @return string
125
     * @throws \Exception         On any error occurred while decryption.
126
     */
127
    public function decrypt(string $data_json): string
128
    {
129
        if ($this->isKeysAvailable() === false) {
130
            throw new \Exception('No SSL configuration is loaded. Use init() method to initiate configuration.');
131
        }
132
133
        $data = $this->decodeJsonData($data_json);
134
135
        // Get resource id of the key
136
        $key_private = $data['key_private_pem'];
137
        $passphrase  = $data['passphrase'];
138
        $id_resource = openssl_pkey_get_private($key_private, $passphrase);
139
        if ($id_resource === false) {
140
            $msg  = PHP_EOL . 'Data:' . PHP_EOL . print_r($data, true) . PHP_EOL;
141
            throw new \Exception('Failed to decrypt data. Could NOT get resource ID of private key.' . $msg);
142
        }
143
144
        // Requirements to decrypt
145
        $data_sealed    = $data['data_sealed'];
146
        $data_decrypted = '';
147
        $data_encrypted = $data['data_encrypted'];
148
149
        // Decrypt data
150
        $result = openssl_open($data_sealed, $data_decrypted, $data_encrypted, $id_resource);
151
        if ($result === false) {
152
            $msg = 'Failed to decrypt data. Could NOT open the data with keys provided.' . PHP_EOL
153
                 . 'Keys provided:' . PHP_EOL . print_r($data, true);
154
            throw new \Exception($msg);
155
        }
156
157
        return $data_decrypted;
158
    }
159
160
    /**
161
     * @param  string $string      Data should be Base64 encoded.
162
     * @param  string $key_public  Public key in PEM to encrypt. (Optional)
163
     * @return string              JSON object string of the encrypted data and it's envelope key.
164
     * @throws \Exception          On any error occurred while encryption.
165
     */
166
    public function encrypt(string $string, string $key_public = ''): string
167
    {
168
        $key_public = empty($key_public) ? $this->getKeyPublic() : $key_public;
169
        $list_key_envelope = [];
170
        $data_sealed = '';
171
172
        // Encrypt/seal data
173
        $result = openssl_seal($string, $data_sealed, $list_key_envelope, [$key_public]);
174
        if ($result === false) {
175
            throw new \Exception('Failed to encrypt data.');
176
        }
177
178
        // Get envelope key
179
        if (! isset($list_key_envelope[0])) {
180
            throw new \Exception('Bad envelope keys returned.');
181
        }
182
183
        // Create a data pair of sealed and encrypted data to return
184
        $data = [
185
            'data_encrypted' => base64_encode($list_key_envelope[0]),
186
            'data_sealed'    => base64_encode($data_sealed),
187
        ];
188
189
        // Encode data to return as JSON string
190
        $result = json_encode($data, JSON_PRETTY_PRINT);
191
192
        if ($result === false) {
193
            $msg = print_r($data, true) . PHP_EOL . PHP_EOL;
194
            throw new \Exception('Failed to encode as JSON. Data to encode: ' . $msg);
195
        }
196
197
        return $result;
198
    }
199
200
    /**
201
     * @return array<string,mixed>
202
     */
203
    public function getConfigSSL(): array
204
    {
205
        return $this->config_ssl;
206
    }
207
208
    /**
209
     * Gets PEM format private key.
210
     *
211
     * @return string
212
     */
213
    public function getKeyPrivate(): string
214
    {
215
        return $this->key_private;
216
    }
217
218
    /**
219
     * Gets PEM format public key.
220
     *
221
     * @return string
222
     */
223
    public function getKeyPublic(): string
224
    {
225
        return $this->key_public;
226
    }
227
228
    /**
229
     * @return string
230
     */
231
    public function getPassphrase(): string
232
    {
233
        return $this->passphrase;
234
    }
235
236
    /**
237
     * @return string
238
     */
239
    public function getPathFileConfig(): string
240
    {
241
        return $this->path_file_config;
242
    }
243
244
    /**
245
     * Creates a brand-new SSL (SHA-512, 4096 bit RSA) key pair.
246
     *
247
     * @param  string $passphrase (Optional)
248
     * @return void
249
     * @throws \Exception         On any error occurred while creating the keys.
250
     */
251
    private function init(string $passphrase = ''): void
252
    {
253
        // Set/generate passphrase (Fix Bug #73833 on PHP 7.1.23)
254
        $this->setPassphrase($passphrase);
255
256
        // Configuration/settings of the key pair
257
        $this->config_ssl = [
258
            "digest_alg"       => "sha512",
259
            "private_key_bits" => 4096,
260
            "private_key_type" => OPENSSL_KEYTYPE_RSA,
261
        ];
262
263
        // Create the key pair
264
        $resource = openssl_pkey_new($this->config_ssl);
265
        if ($resource === false) {
266
            throw new \Exception('Failed to create key pair. Probably old OpenSSL module used.');
267
        }
268
269
        // Get and set private key
270
        $result = openssl_pkey_export($resource, $key_private, $this->getPassphrase());
271
        if ($result === false) {
272
            throw new \Exception('Failed to create key pair.');
273
        }
274
        if (empty($key_private)) {
275
            throw new \Exception('Failed to create key pair.');
276
        }
277
        $this->key_private = $key_private;
278
279
        // Get and set public key
280
        $array = openssl_pkey_get_details($resource);
281
        if ($array === false) {
282
            throw new \Exception('Failed to get public key.');
283
        }
284
        if (! isset($array['key'])) {
285
            throw new \Exception('Failed to get public key.');
286
        }
287
        $this->key_public = $array['key'];
288
289
        // Save the above data and flag up ready to use
290
        $this->save();
291
        $this->setFlagAsKeysAvailable(true);
292
    }
293
294
    /**
295
     * @return bool
296
     */
297
    private function isKeysAvailable(): bool
298
    {
299
        return $this->flag_keys_available;
300
    }
301
302
    /**
303
     * Loads the JSON file of SSL data such as key pair and the passphrase.
304
     *
305
     * @param  string $path_file_config
306
     * @return void
307
     * @throws \Exception
308
     *     On any error occurred while creating the keys.
309
     */
310
    public function load(string $path_file_config): void
311
    {
312
        if (! file_exists($path_file_config)) {
313
            throw new \Exception('File not found at: ' . $path_file_config);
314
        }
315
316
        $json = file_get_contents($path_file_config);
317
        if ($json === false) {
318
            throw new \Exception('Failed to read file from: ' . $path_file_config);
319
        }
320
        $data = $this->decodeJsonConf($json);
321
322
        // Re-dump properties.
323
        $this->key_private = $data['key_private'];
324
        $this->key_public  = $data['key_public'];
325
        $this->passphrase  = $data['passphrase'];
326
        $this->config_ssl  = $data['config_ssl'];
327
    }
328
329
    /**
330
     * Saves/overwrites the SSL data such as key pair and passphrase as JSON file.
331
     *
332
     * - NOTE:
333
     *   BE CAREFUL where to save the data! If the saved file gets public,
334
     *   then there's no meaning to encrypt the data.
335
     *
336
     * @param  string $path_file_config
337
     * @return bool
338
     */
339
    public function save(string $path_file_config = ''): bool
340
    {
341
        $path_file_config = (empty($path_file_config)) ? $this->getPathFileConfig() : $path_file_config;
342
343
        $data = [
344
            'key_private' => $this->getKeyPrivate(),
345
            'key_public'  => $this->getKeyPublic(),
346
            'passphrase'  => $this->getPassphrase(),
347
            'config_ssl'  => $this->getConfigSSL(),
348
        ];
349
350
        $data   = json_encode($data, JSON_PRETTY_PRINT);
351
        $result = file_put_contents($path_file_config, $data, LOCK_EX);
352
353
        return ($result !== false);
354
    }
355
356
    /**
357
     * @param  bool $flag
358
     * @return void
359
     */
360
    private function setFlagAsKeysAvailable(bool $flag): void
361
    {
362
        $this->flag_keys_available = $flag;
363
    }
364
365
    /**
366
     * @param  string $passphrase
367
     * @return void
368
     */
369
    public function setPassphrase(string $passphrase): void
370
    {
371
        // In PHP 7.1.23 there's a bug that with an empty pass phrase "openssl_pkey_get_private()"
372
        // fails to create the resource ID. So set a default pass phrase if empty.
373
        //   - Ref: Bug #73833 https://bugs.php.net/bug.php?id=73833
374
        $passphrase = (empty(trim($passphrase))) ? strval(hash_file('md5', __FILE__)) : trim($passphrase);
375
        $this->passphrase = $passphrase;
376
    }
377
}
378