Passed
Push — master ( b0eeb8...f10971 )
by Thomas
12:15 queued 18s
created

EncryptHelper::convertHashType()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 31
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 20
c 0
b 0
f 0
dl 0
loc 31
rs 9.6
cc 3
nc 3
nop 3
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\Control\Director;
12
use SilverStripe\Core\Environment;
13
use ParagonIE\CipherSweet\CipherSweet;
14
use SilverStripe\ORM\FieldType\DBText;
15
use ParagonIE\CipherSweet\EncryptedFile;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, LeKoala\Encrypt\EncryptedFile. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

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