Issues (84)

src/EncryptHelper.php (2 issues)

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