Passed
Push — master ( 5383e7...548176 )
by Thomas
02:56
created

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