EncryptHelper::planIndexSizes()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 11
rs 10
cc 3
nc 3
nop 0
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
    const DEFAULT_OUTPUT_SIZE = 15;
40
    const DEFAULT_DOMAIN_SIZE = 127;
41
    const BORING = "brng";
42
    const MODERN = "nacl";
43
    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
Unused Code introduced by
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
Unused Code introduced by
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) will result in a CryptoOperationException being thrown.
286
     * The recommended way to generate a key is to use this method
287
     *
288
     * @return string A 64 chars string like 4e1c44f87b4cdf21808762970b356891db180a9dd9850e7baf2a79ff3ab8a2fc
289
     */
290
    public static function generateKey()
291
    {
292
        return Hex::encode(random_bytes(32));
293
    }
294
295
    /**
296
     * @return array{"public_key": string, "private_key": string} Two 64 chars strings
297
     */
298
    public static function generateKeyPair()
299
    {
300
        $key_pair = sodium_crypto_box_keypair();
301
        $public_key = sodium_crypto_box_publickey($key_pair);
302
        $private_key = sodium_crypto_box_secretkey($key_pair);
303
304
        return [
305
            'public_key' => Hex::encode($public_key),
306
            'private_key' => Hex::encode($private_key),
307
        ];
308
    }
309
310
    /**
311
     * Get app encryption key
312
     * Encryption key should be provided in your $_ENV or .env file
313
     *
314
     * @return string
315
     */
316
    public static function getKey()
317
    {
318
        // Try our path variable
319
        $keyPath = Environment::getEnv('ENCRYPTION_KEY_PATH');
320
        $key = null;
321
        if ($keyPath) {
322
            $key = file_get_contents($keyPath);
323
            if (!$key || !is_string($key)) {
324
                throw new Exception("Could not read key from $keyPath");
325
            }
326
        }
327
        // Try regular env key
328
        if (!$key) {
329
            $key = Environment::getEnv('ENCRYPTION_KEY');
330
        }
331
        if (!$key) {
332
            $key = self::generateKey();
333
            if (Director::isDev()) {
334
                $envFile = rtrim(Director::baseFolder(), '/') . "/.env";
335
                if (is_file($envFile) && is_writable($envFile)) {
336
                    file_put_contents($envFile, 'ENCRYPTION_KEY="' . $key . '"', FILE_APPEND);
337
                    return $key;
338
                }
339
            }
340
            throw new Exception("Please define an ENCRYPTION_KEY in your environment. You can use this one: $key");
341
        }
342
        return $key;
343
    }
344
345
    /**
346
     * @return string
347
     */
348
    public static function getOldKey()
349
    {
350
        return Environment::getEnv('OLD_ENCRYPTION_KEY');
351
    }
352
353
    /**
354
     * @param string $key
355
     * @return StringProvider
356
     */
357
    public static function getProviderWithKey($key = null)
358
    {
359
        if ($key === null) {
360
            $key = self::getKey();
361
        }
362
        return new StringProvider($key);
363
    }
364
365
    /**
366
     * @return BackendInterface
367
     */
368
    public static function getRecommendedBackend()
369
    {
370
        return new BoringCrypto();
371
    }
372
373
    /**
374
     * @param string $encryption
375
     * @return BackendInterface
376
     */
377
    public static function getBackendForEncryption($encryption = null)
378
    {
379
        if (!$encryption) {
380
            return self::getRecommendedBackend();
381
        }
382
        switch ($encryption) {
383
            case self::BORING:
384
                return new BoringCrypto();
385
            case self::MODERN:
386
                return new ModernCrypto();
387
            case self::FIPS:
388
                return new FIPSCrypto();
389
        }
390
        throw new Exception("Unsupported encryption $encryption");
391
    }
392
393
    /**
394
     * @param string $encryption
395
     * @param string $key
396
     * @return CipherSweet
397
     */
398
    public static function getEngineForEncryption($encryption = null, $key = null)
399
    {
400
        return self::getEngine(self::getBackendForEncryption($encryption), $key);
401
    }
402
403
    /**
404
     * @param BackendInterface $backend
405
     * @param string $key
406
     * @return CipherSweet
407
     */
408
    public static function getEngine(BackendInterface $backend, $key = null)
409
    {
410
        $provider = self::getProviderWithKey($key);
411
        return new CipherSweet($provider, $backend);
412
    }
413
414
    /**
415
     * @param BackendInterface $backend
416
     * @param KeyProviderInterface $provider
417
     * @return CipherSweet
418
     */
419
    public static function getEngineWithProvider(BackendInterface $backend, KeyProviderInterface $provider)
420
    {
421
        return new CipherSweet($provider, $backend);
422
    }
423
424
    /**
425
     * @return CipherSweetEncryptedFile
426
     */
427
    public static function getEncryptedFileInstance()
428
    {
429
        if (self::$encryptedFile === null) {
430
            self::$encryptedFile = new CipherSweetEncryptedFile(self::getCipherSweet());
431
        }
432
        return self::$encryptedFile;
433
    }
434
435
    /**
436
     * @param int $ID
437
     * @return bool
438
     */
439
    public static function checkIfFileIsEncrypted($ID)
440
    {
441
        return (bool)DB::prepared_query("SELECT Encrypted FROM File WHERE ID = ?", [$ID])->value();
442
    }
443
444
    /**
445
     * @param KeyProviderInterface $provider
446
     * @return CipherSweet
447
     */
448
    public static function getCipherSweet($provider = null)
449
    {
450
        if (self::$ciphersweet !== null) {
451
            return self::$ciphersweet;
452
        }
453
        if ($provider === null) {
454
            $provider = self::getProviderWithKey();
455
        }
456
        if (self::getForcedEncryption()) {
457
            $backend = self::getBackendForEncryption(self::getForcedEncryption());
458
        } else {
459
            $backend = self::getRecommendedBackend();
460
        }
461
        self::$ciphersweet = new CipherSweet($provider, $backend);
462
        return self::$ciphersweet;
463
    }
464
465
    /**
466
     * @return void
467
     */
468
    public static function clearCipherSweet()
469
    {
470
        self::$ciphersweet = null;
471
        self::$encryptedFile = null;
472
    }
473
474
    /**
475
     * @return BackendInterface
476
     */
477
    public static function getCipherSweetBackend()
478
    {
479
        return self::getCipherSweet()->getBackend();
480
    }
481
482
    /**
483
     * Check if a value is encrypted
484
     *
485
     * @param string $value
486
     * @return boolean
487
     */
488
    public static function isEncrypted($value)
489
    {
490
        $prefix = substr($value, 0, 5);
491
        return in_array($prefix, ["brng:", "nacl:", "fips:"]);
492
    }
493
494
    /**
495
     * Check if a json value is encrypted
496
     *
497
     * @param string|array<mixed> $value
498
     * @return boolean
499
     */
500
    public static function isJsonEncrypted($value)
501
    {
502
        if (is_string($value)) {
503
            $value = json_decode($value, true);
504
        }
505
        // If any top level value is encrypted
506
        foreach ($value as $v) {
507
            if (self::isEncrypted($v)) {
508
                return true;
509
            }
510
        }
511
        return false;
512
    }
513
514
    /**
515
     * Convert map to a suitable DB field definition
516
     * @param JsonFieldMap $map
517
     * @return string
518
     */
519
    public static function convertJsonMapToDefinition($map)
520
    {
521
        return str_replace("\"", "\\\"", (string)$map);
522
    }
523
524
    /**
525
     * @param string $value
526
     * @return boolean
527
     */
528
    public static function isFips($value)
529
    {
530
        if (strpos($value, 'fips:') === 0) {
531
            return true;
532
        }
533
        return false;
534
    }
535
536
    /**
537
     * @param string $value
538
     * @return boolean
539
     */
540
    public static function isNacl($value)
541
    {
542
        if (strpos($value, 'nacl:') === 0) {
543
            return true;
544
        }
545
        return false;
546
    }
547
548
    /**
549
     * @param string $value
550
     * @return boolean
551
     */
552
    public static function isBoring($value)
553
    {
554
        if (strpos($value, 'brng:') === 0) {
555
            return true;
556
        }
557
        return false;
558
    }
559
560
    /**
561
     * @param ?string $value
562
     * @return ?string
563
     */
564
    public static function getEncryption($value)
565
    {
566
        if (!$value) {
567
            return null;
568
        }
569
        if (self::isBoring($value)) {
570
            return self::BORING;
571
        }
572
        if (self::isNacl($value)) {
573
            return self::MODERN;
574
        }
575
        if (self::isFips($value)) {
576
            return self::FIPS;
577
        }
578
        return null;
579
    }
580
581
    /**
582
     * Check if a field is encrypted on a class
583
     * This relies on a field class starting with Encrypted
584
     *
585
     * @param string $class
586
     * @param string $field
587
     * @return boolean
588
     */
589
    public static function isEncryptedField($class, $field)
590
    {
591
        $key = $class . '_' . $field;
592
        if (isset(self::$field_cache[$key])) {
593
            return self::$field_cache[$key];
594
        }
595
596
        $fields = $class::config()->db;
597
598
        if (isset($fields[$field])) {
599
            $dbClass = $fields[$field];
600
            self::$field_cache[$key] = self::isEncryptedDbClass($dbClass);
601
        } else {
602
            self::$field_cache[$key] = false;
603
        }
604
        return self::$field_cache[$key];
605
    }
606
607
    /**
608
     * @param string $dbClass
609
     * @return boolean
610
     */
611
    public static function isEncryptedDbClass($dbClass)
612
    {
613
        return strpos($dbClass, 'Encrypted') !== false;
614
    }
615
616
    /**
617
     * Filters parameters from database class config
618
     * @param string $dbClass
619
     * @return string
620
     */
621
    protected static function filterDbClass($dbClass)
622
    {
623
        $pos = strpos($dbClass, '(');
624
        if ($pos !== false) {
625
            $dbClass = substr($dbClass, 0, $pos);
626
        }
627
        return $dbClass;
628
    }
629
630
    /**
631
     * @param string $class
632
     * @param bool $dbFields Return actual database field value instead of field name
633
     * @return array<int|string,string> An associative array with the name of the field as key and the class as value
634
     */
635
    public static function getEncryptedFields($class, $dbFields = false)
636
    {
637
        $fields = $class::config()->db;
638
        $list = [];
639
        foreach ($fields as $field => $dbClass) {
640
            $dbClass = self::filterDbClass($dbClass);
641
            $key = $class . '_' . $field;
642
            if (isset($fields[$field])) {
643
                self::$field_cache[$key] = strpos($dbClass, 'Encrypted') !== false;
644
                if (self::$field_cache[$key]) {
645
                    // Sometimes we need actual db field name
646
                    if ($dbFields && is_subclass_of($dbClass, DBComposite::class)) {
647
                        $list[$field . "Value"] = $dbClass;
648
                    } else {
649
                        $list[$field] = $dbClass;
650
                    }
651
                }
652
            } else {
653
                self::$field_cache[$key] = false;
654
            }
655
        }
656
        return $list;
657
    }
658
659
    /**
660
     * A simple encryption
661
     * @param string $value
662
     * @return string
663
     */
664
    public static function encrypt($value)
665
    {
666
        // Do not encrypt twice
667
        $encryption = self::getEncryption($value);
668
        if ($encryption) {
669
            return $value;
670
        }
671
        $provider = self::getProviderWithKey();
672
        $backend = self::getBackendForEncryption($encryption);
673
        return $backend->encrypt($value, $provider->getSymmetricKey());
674
    }
675
676
    /**
677
     * A simple decryption
678
     * @param string $value
679
     * @return string
680
     */
681
    public static function decrypt($value)
682
    {
683
        // Only decrypt what we can decrypt
684
        $encryption = self::getEncryption($value);
685
        if (!$encryption) {
686
            return $value;
687
        }
688
        $provider = self::getProviderWithKey();
689
        $backend =  self::getBackendForEncryption($encryption);
690
        return $backend->decrypt($value, $provider->getSymmetricKey());
691
    }
692
693
    /**
694
     * Return a map of fields with their encrypted counterpart
695
     *
696
     * @return array<string,string>
697
     */
698
    public static function mapEncryptionDBField()
699
    {
700
        return [
701
            DBHTMLText::class => EncryptedDBHTMLText::class,
702
            DBText::class => EncryptedDBText::class,
703
            DBVarchar::class => EncryptedDBVarchar::class,
704
        ];
705
    }
706
707
    /**
708
     * Compute Blind Index Information Leaks
709
     *
710
     * @link https://ciphersweet.paragonie.com/php/blind-index-planning
711
     * @link https://ciphersweet.paragonie.com/security
712
     * @param array<mixed> $indexes an array of L (output size) / K (domaine size) pairs
713
     * @param int $R the number of encrypted records that use this blind index
714
     * @return float
715
     */
716
    public static function coincidenceCount(array $indexes, $R)
717
    {
718
        $exponent = 0;
719
        $count = count($indexes);
720
        for ($i = 0; $i < $count; ++$i) {
721
            $exponent += min($indexes[$i]['L'], $indexes[$i]['K']);
722
        }
723
        return (float) max(1, $R) / pow(2, $exponent);
724
    }
725
726
    /**
727
     * Alias of sendDecryptedFile
728
     * @deprecated
729
     * @param File $file
730
     * @return void
731
     */
732
    public static function sendEncryptedFile(File $file)
733
    {
734
        self::sendDecryptedFile($file);
735
    }
736
737
    /**
738
     * Send a decrypted file
739
     *
740
     * @param File $file
741
     * @param string $fileName
742
     * @param string $mimeType
743
     * @return void
744
     */
745
    public static function sendDecryptedFile(File $file, $fileName = null, $mimeType = null)
746
    {
747
        if (!$fileName) {
748
            $fileName = basename($file->getFilename());
749
        }
750
        if (!$mimeType) {
751
            $mimeType = 'application/octetstream';
752
        }
753
        header('Content-disposition: attachment; filename="' . $fileName . '"');
754
        header('Content-type: ' . $mimeType);
755
        header('Pragma: no-cache');
756
        header('Expires: 0');
757
        /** @var EncryptedDBFile $file */
758
        $file->sendDecryptedFile();
759
    }
760
}
761