Passed
Push — master ( 5a582b...461cd7 )
by Thomas
02:36
created

EncryptHelper::getEngineWithProvider()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 2
1
<?php
2
3
namespace LeKoala\Encrypt;
4
5
use Exception;
6
use InvalidArgumentException;
7
use SilverStripe\Assets\File;
8
use ParagonIE\ConstantTime\Hex;
9
use SilverStripe\Core\ClassInfo;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\Core\Environment;
12
use ParagonIE\CipherSweet\CipherSweet;
13
use SilverStripe\ORM\FieldType\DBText;
14
use SilverStripe\ORM\FieldType\DBVarchar;
15
use SilverStripe\Core\Config\Configurable;
16
use SilverStripe\ORM\FieldType\DBHTMLText;
17
use SilverStripe\ORM\FieldType\DBComposite;
18
use ParagonIE\CipherSweet\Backend\FIPSCrypto;
19
use ParagonIE\CipherSweet\Backend\BoringCrypto;
20
use ParagonIE\CipherSweet\Backend\ModernCrypto;
21
use ParagonIE\CipherSweet\Contract\BackendInterface;
22
use ParagonIE\CipherSweet\Planner\FieldIndexPlanner;
23
use ParagonIE\CipherSweet\KeyProvider\StringProvider;
24
use ParagonIE\CipherSweet\Contract\KeyProviderInterface;
25
26
/**
27
 * @link https://ciphersweet.paragonie.com/php
28
 * @link https://paragonie.com/blog/2017/05/building-searchable-encrypted-databases-with-php-and-sql
29
 * @link https://paragonie.com/book/pecl-libsodium/read/09-recipes.md
30
 */
31
class EncryptHelper
32
{
33
    use Configurable;
34
35
    const DEFAULT_OUTPUT_SIZE = 15;
36
    const DEFAULT_DOMAIN_SIZE = 127;
37
    const BORING = "brng";
38
    const MODERN = "nacl";
39
    const FIPS = "fips";
40
41
    /**
42
     * @config
43
     * @var string
44
     */
45
    private static $forced_encryption = null;
46
47
    /**
48
     * @config
49
     * @var bool
50
     */
51
    private static $automatic_rotation = true;
52
53
    /**
54
     * @var CipherSweet
55
     */
56
    protected static $ciphersweet;
57
58
    /**
59
     * @var array
60
     */
61
    protected static $field_cache = [];
62
63
    /**
64
     * @return string
65
     */
66
    public static function getForcedEncryption()
67
    {
68
        return self::config()->forced_encryption;
69
    }
70
71
    /**
72
     * @param string $forcedEncryption brng|nacl|fips
73
     * @return void
74
     */
75
    public static function setForcedEncryption($forcedEncryption)
76
    {
77
        if (!in_array($forcedEncryption, ["brng", "nacl", "fips"])) {
78
            throw new InvalidArgumentException("$forcedEncryption is not supported");
79
        }
80
        self::config()->forced_encryption = $forcedEncryption;
81
    }
82
83
    /**
84
     * This would only work if you changed from algorithm
85
     * @return bool
86
     */
87
    public static function getAutomaticRotation()
88
    {
89
        return self::config()->automatic_rotation;
90
    }
91
92
    /**
93
     * @param bool $setAutomaticRotation
94
     * @return void
95
     */
96
    public static function setAutomaticRotation($automaticRotation)
97
    {
98
        self::config()->automatic_rotation = $automaticRotation;
99
    }
100
101
    /**
102
     * @link https://ciphersweet.paragonie.com/php/blind-index-planning
103
     * @return array
104
     */
105
    public static function planIndexSizes()
106
    {
107
        $dataObjects = ClassInfo::subclassesFor(DataObject::class);
108
        $indexes = [];
109
        foreach ($dataObjects as $dataObject) {
110
            if (!class_uses(HasEncryptedFields::class)) {
111
                continue;
112
            }
113
            $index[$dataObject] = self::planIndexSizesForClass($dataObject);
114
        }
115
        return $indexes;
116
    }
117
118
    /**
119
     * @param string $dataObject
120
     * @return array
121
     */
122
    public static function planIndexSizesForClass($class)
123
    {
124
        $sng = singleton($class);
125
        $encryptedFields = self::getEncryptedFields($class);
126
        // By default, plan for a large number of rows
127
        $estimatedPopulation = $class::config()->estimated_population ?? PHP_INT_MAX;
128
        $planner = new FieldIndexPlanner();
129
        $planner->setEstimatedPopulation($estimatedPopulation);
130
        $indexes = [];
131
        foreach ($encryptedFields as $encryptedField => $encryptedClass) {
132
            if (!is_subclass_of($encryptedClass, DBComposite::class)) {
133
                continue;
134
            }
135
            $dbObject = $sng->dbObject($encryptedField);
136
            $outputSize = $dbObject->getOutputSize() ?? self::DEFAULT_OUTPUT_SIZE;
137
            $domainSize = $dbObject->getDomainSize() ?? self::DEFAULT_DOMAIN_SIZE;
138
            $planner->addExistingIndex($encryptedField . "BlindIndex", $outputSize, $domainSize);
139
            // The smaller of the two values will be used to compute coincidences
140
            $indexes[] = ["L" => $outputSize, "K" => $domainSize];
141
        }
142
        $coincidenceCount = round(self::coincidenceCount($indexes, $estimatedPopulation));
143
        $recommended = $planner->recommend();
144
        $recommended['indexes'] = count($indexes);
145
        // If there is no coincidence, it means the index is not safe for use because it means
146
        // that two identical plaintexts will give the same output
147
        $recommended['coincidence_count'] = $coincidenceCount;
148
        $recommended['coincidence_ratio'] = $coincidenceCount / $estimatedPopulation * 100;
149
        $recommended['estimated_population'] = $estimatedPopulation;
150
        return $recommended;
151
    }
152
153
    /**
154
     * @link https://github.com/paragonie/ciphersweet/issues/62
155
     * @param array $ciphertext
156
     * @return array
157
     */
158
    public static function removeNulls($ciphertext)
159
    {
160
        foreach ($ciphertext as $k => $v) {
161
            if ($v === null) {
162
                $ciphertext[$k] = '';
163
            }
164
        }
165
        return $ciphertext;
166
    }
167
168
    /**
169
     * Attempting to pass a key of an invalid size (i.e. not 256-bit) will result in a CryptoOperationException being thrown.
170
     * The recommended way to generate a key is to use this method
171
     *
172
     * @return string Something like 4e1c44f87b4cdf21808762970b356891db180a9dd9850e7baf2a79ff3ab8a2fc
173
     */
174
    public static function generateKey()
175
    {
176
        return Hex::encode(random_bytes(32));
177
    }
178
179
    /**
180
     * Get app encryption key
181
     * Encryption key should be provided in your $_ENV or .env file
182
     *
183
     * @return string
184
     */
185
    public static function getKey()
186
    {
187
        // Try our path variable
188
        $keyPath = Environment::getEnv('ENCRYPTION_KEY_PATH');
189
        $key = null;
190
        if ($keyPath) {
191
            $key = file_get_contents($keyPath);
192
            if (!$key || !is_string($key)) {
193
                throw new Exception("Could not read key from $keyPath");
194
            }
195
        }
196
        // Try regular env key
197
        if (!$key) {
198
            $key = Environment::getEnv('ENCRYPTION_KEY');
199
        }
200
        if (!$key) {
201
            $key = self::generateKey();
202
            throw new Exception("Please define an ENCRYPTION_KEY in your environment. You can use this one: $key");
203
        }
204
        return $key;
205
    }
206
207
    /**
208
     * @return string
209
     */
210
    public static function getOldKey()
211
    {
212
        return Environment::getEnv('OLD_ENCRYPTION_KEY');
213
    }
214
215
    /**
216
     * @param string $key
217
     * @return StringProvider
218
     */
219
    public static function getProviderWithKey($key = null)
220
    {
221
        if ($key === null) {
222
            $key = self::getKey();
223
        }
224
        return new StringProvider($key);
225
    }
226
227
    /**
228
     * @return BackendInterface
229
     */
230
    public static function getRecommendedBackend()
231
    {
232
        if (version_compare(phpversion(), '7.2', '<')) {
233
            return new FIPSCrypto();
234
        }
235
        return new BoringCrypto();
236
    }
237
238
    /**
239
     * @param string $encryption
240
     * @return BackendInterface
241
     */
242
    public static function getBackendForEncryption($encryption = null)
243
    {
244
        if (!$encryption) {
245
            return self::getRecommendedBackend();
246
        }
247
        switch ($encryption) {
248
            case self::BORING:
249
                return new BoringCrypto();
250
            case self::MODERN:
251
                return new ModernCrypto();
252
            case self::FIPS:
253
                return new FIPSCrypto();
254
        }
255
        throw new Exception("Unsupported encryption $encryption");
256
    }
257
258
    /**
259
     * @param BackendInterface $backend
260
     * @param string $key
261
     * @return CipherSweet
262
     */
263
    public static function getEngineForEncryption($encryption = null, $key = null)
264
    {
265
        return self::getEngine(self::getBackendForEncryption($encryption), $key);
266
    }
267
268
    /**
269
     * @param BackendInterface $backend
270
     * @param string $key
271
     * @return CipherSweet
272
     */
273
    public static function getEngine(BackendInterface $backend, $key = null)
274
    {
275
        $provider = self::getProviderWithKey($key);
276
        return new CipherSweet($provider, $backend);
277
    }
278
279
    /**
280
     * @param BackendInterface $backend
281
     * @param KeyProviderInterface $provider
282
     * @return CipherSweet
283
     */
284
    public static function getEngineWithProvider(BackendInterface $backend, KeyProviderInterface $provider)
285
    {
286
        return new CipherSweet($provider, $backend);
287
    }
288
289
    /**
290
     * @param KeyProviderInterface $provider
291
     * @return CipherSweet
292
     */
293
    public static function getCipherSweet($provider = null)
294
    {
295
        if (self::$ciphersweet) {
296
            return self::$ciphersweet;
297
        }
298
        if ($provider === null) {
299
            $provider = self::getProviderWithKey();
300
        }
301
        if (self::getForcedEncryption()) {
302
            $backend = self::getBackendForEncryption(self::getForcedEncryption());
303
        } else {
304
            $backend = self::getRecommendedBackend();
305
        }
306
        self::$ciphersweet = new CipherSweet($provider, $backend);
307
        return self::$ciphersweet;
308
    }
309
310
    /**
311
     * @return void
312
     */
313
    public static function clearCipherSweet()
314
    {
315
        self::$ciphersweet = null;
316
    }
317
318
    /**
319
     * @return BackendInterface
320
     */
321
    public static function getCipherSweetBackend()
322
    {
323
        return self::getCipherSweet()->getBackend();
324
    }
325
326
    /**
327
     * Check if a value is encrypted
328
     *
329
     * @param string $value
330
     * @return boolean
331
     */
332
    public static function isEncrypted($value)
333
    {
334
        $prefix = substr($value, 0, 5);
335
        return in_array($prefix, ["brng:", "nacl:", "fips:"]);
336
    }
337
338
    /**
339
     * @param string $value
340
     * @return boolean
341
     */
342
    public static function isFips($value)
343
    {
344
        if (strpos($value, 'fips:') === 0) {
345
            return true;
346
        }
347
        return false;
348
    }
349
350
    /**
351
     * @param string $value
352
     * @return boolean
353
     */
354
    public static function isNacl($value)
355
    {
356
        if (strpos($value, 'nacl:') === 0) {
357
            return true;
358
        }
359
        return false;
360
    }
361
362
    /**
363
     * @param string $value
364
     * @return boolean
365
     */
366
    public static function isBoring($value)
367
    {
368
        if (strpos($value, 'brng:') === 0) {
369
            return true;
370
        }
371
        return false;
372
    }
373
374
    /**
375
     * @param string $value
376
     * @return string
377
     */
378
    public static function getEncryption($value)
379
    {
380
        if (self::isBoring($value)) {
381
            return self::BORING;
382
        }
383
        if (self::isNacl($value)) {
384
            return self::MODERN;
385
        }
386
        if (self::isFips($value)) {
387
            return self::FIPS;
388
        }
389
        return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
390
    }
391
392
    /**
393
     * Check if a field is encrypted on a class
394
     * This relies on a field class starting with Encrypted
395
     *
396
     * @param string $class
397
     * @param string $field
398
     * @return boolean
399
     */
400
    public static function isEncryptedField($class, $field)
401
    {
402
        $key = $class . '_' . $field;
403
        if (isset(self::$field_cache[$key])) {
404
            return self::$field_cache[$key];
405
        }
406
407
        $fields = $class::config()->db;
408
409
        if (isset($fields[$field])) {
410
            $dbClass = $fields[$field];
411
            self::$field_cache[$key] = strpos($dbClass, 'Encrypted') !== false;
412
        } else {
413
            self::$field_cache[$key] = false;
414
        }
415
        return self::$field_cache[$key];
416
    }
417
418
    /**
419
     * Filters parameters from database class config
420
     * @return string
421
     */
422
    protected static function filterDbClass($dbClass)
423
    {
424
        $pos = strpos($dbClass, '(');
425
        if ($pos !== false) {
426
            $dbClass = substr($dbClass, 0, $pos);
427
        }
428
        return $dbClass;
429
    }
430
431
    /**
432
     * @param string $class
433
     * @param bool $dbFields Return actual database field value instead of field name
434
     * @return array An associative array with the name of the field as key and the class as value
435
     */
436
    public static function getEncryptedFields($class, $dbFields = false)
437
    {
438
        $fields = $class::config()->db;
439
        $list = [];
440
        foreach ($fields as $field => $dbClass) {
441
            $dbClass = self::filterDbClass($dbClass);
442
            $key = $class . '_' . $field;
443
            if (isset($fields[$field])) {
444
                self::$field_cache[$key] = strpos($dbClass, 'Encrypted') !== false;
445
                if (self::$field_cache[$key]) {
446
                    // Sometimes we need actual db field name
447
                    if ($dbFields && is_subclass_of($dbClass, DBComposite::class)) {
448
                        $list[$field . "Value"] = $dbClass;
449
                    } else {
450
                        $list[$field] = $dbClass;
451
                    }
452
                }
453
            } else {
454
                self::$field_cache[$key] = false;
455
            }
456
        }
457
        return $list;
458
    }
459
460
    /**
461
     * A simple encryption
462
     * @param string $value
463
     * @return string
464
     */
465
    public static function encrypt($value)
466
    {
467
        // Do not encrypt twice
468
        $encryption = self::getEncryption($value);
469
        if ($encryption) {
470
            return $value;
471
        }
472
        $provider = self::getProviderWithKey();
473
        $backend = self::getBackendForEncryption($encryption);
474
        return $backend->encrypt($value, $provider->getSymmetricKey());
475
    }
476
477
    /**
478
     * A simple decryption
479
     * @param string $value
480
     * @return string
481
     */
482
    public static function decrypt($value)
483
    {
484
        // Only decrypt what we can decrypt
485
        $encryption = self::getEncryption($value);
486
        if (!$encryption) {
487
            return $value;
488
        }
489
        $provider = self::getProviderWithKey();
490
        $backend =  self::getBackendForEncryption($encryption);
491
        return $backend->decrypt($value, $provider->getSymmetricKey());
492
    }
493
494
    /**
495
     * Return a map of fields with their encrypted counterpart
496
     *
497
     * @return array
498
     */
499
    public static function mapEncryptionDBField()
500
    {
501
        return [
502
            DBHTMLText::class => EncryptedDBHTMLText::class,
503
            DBText::class => EncryptedDBText::class,
504
            DBVarchar::class => EncryptedDBVarchar::class,
505
        ];
506
    }
507
508
    /**
509
     * Compute Blind Index Information Leaks
510
     *
511
     * @link https://ciphersweet.paragonie.com/php/blind-index-planning
512
     * @link https://ciphersweet.paragonie.com/security
513
     * @param array $indexes an array of L (output size) / K (domaine size) pairs
514
     * @param int $R the number of encrypted records that use this blind index
515
     * @return float
516
     */
517
    public static function coincidenceCount(array $indexes, $R)
518
    {
519
        $exponent = 0;
520
        $count = count($indexes);
521
        for ($i = 0; $i < $count; ++$i) {
522
            $exponent += min($indexes[$i]['L'], $indexes[$i]['K']);
523
        }
524
        return (float) max(1, $R) / pow(2, $exponent);
525
    }
526
527
    /**
528
     * Send a decrypted file
529
     *
530
     * @param File $file
531
     * @return void
532
     */
533
    public static function sendEncryptedFile(File $file)
534
    {
535
        header('Content-disposition: attachment; filename="' . basename($file->getFilename()) . '"');
536
        header('Content-type: application/octetstream');
537
        header('Pragma: no-cache');
538
        header('Expires: 0');
539
        $file->sendDecryptedFile();
540
    }
541
}
542