Passed
Push — master ( 5a9348...48ccd3 )
by Thomas
03:36
created

EncryptHelper::isEncrypted()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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