Passed
Push — master ( 461cd7...06c7d6 )
by Thomas
02:35
created

EncryptHelper::getAutomaticDecryption()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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