Passed
Push — master ( 71d23f...5a582b )
by Thomas
02:20
created

EncryptHelper::filterDbClass()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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