Passed
Push — master ( 22b305...33bedb )
by Thomas
02:16
created

EncryptHelper   F

Complexity

Total Complexity 80

Size/Duplication

Total Lines 586
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 1
Metric Value
wmc 80
eloc 186
c 4
b 0
f 1
dl 0
loc 586
rs 2

38 Methods

Rating   Name   Duplication   Size   Complexity  
A generateKey() 0 3 1
A getEngineWithProvider() 0 3 1
A getEngineForEncryption() 0 3 1
A setForcedEncryption() 0 6 3
A isNacl() 0 6 2
A generateKeyPair() 0 9 1
A getBackendForEncryption() 0 14 5
A getProviderWithKey() 0 6 2
A setAutomaticRotation() 0 3 1
A planIndexSizesForClass() 0 29 3
A getEncryptedFields() 0 22 6
A isFips() 0 6 2
A planIndexSizes() 0 11 3
A setAutomaticDecryption() 0 3 1
A getCipherSweet() 0 15 4
A clearCipherSweet() 0 3 1
A removeNulls() 0 8 3
A isEncrypted() 0 4 1
A isBoring() 0 6 2
A isEncryptedField() 0 16 3
A filterDbClass() 0 7 2
B getKey() 0 27 9
A decrypt() 0 10 2
A setAadSource() 0 3 1
A encrypt() 0 10 2
A coincidenceCount() 0 8 2
A getRecommendedBackend() 0 6 2
A getAutomaticDecryption() 0 3 1
A getEncryption() 0 12 4
A getForcedEncryption() 0 3 1
A mapEncryptionDBField() 0 6 1
A getEngine() 0 4 1
A getOldKey() 0 3 1
A getCipherSweetBackend() 0 3 1
A getAutomaticRotation() 0 3 1
A getAadSource() 0 3 1
A sendDecryptedFile() 0 7 1
A sendEncryptedFile() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like EncryptHelper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use EncryptHelper, and based on these observations, apply Extract Interface, too.

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