Passed
Push — master ( 618096...f2b69a )
by Thomas
02:57
created

EncryptHelper::getForcedEncryption()   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 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;
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...
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;
77
78
    /**
79
     * @var EncryptedFile
80
     */
81
    protected static $encryptedFile;
82
83
    /**
84
     * @var array
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 $setAutomaticRotation
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
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 $dataObject
196
     * @return array
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 $ciphertext
272
     * @return array
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 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
        if (version_compare(phpversion(), '7.2', '<')) {
371
            return new FIPSCrypto();
372
        }
373
        return new BoringCrypto();
374
    }
375
376
    /**
377
     * @param string $encryption
378
     * @return BackendInterface
379
     */
380
    public static function getBackendForEncryption($encryption = null)
381
    {
382
        if (!$encryption) {
383
            return self::getRecommendedBackend();
384
        }
385
        switch ($encryption) {
386
            case self::BORING:
387
                return new BoringCrypto();
388
            case self::MODERN:
389
                return new ModernCrypto();
390
            case self::FIPS:
391
                return new FIPSCrypto();
392
        }
393
        throw new Exception("Unsupported encryption $encryption");
394
    }
395
396
    /**
397
     * @param BackendInterface $backend
398
     * @param string $key
399
     * @return CipherSweet
400
     */
401
    public static function getEngineForEncryption($encryption = null, $key = null)
402
    {
403
        return self::getEngine(self::getBackendForEncryption($encryption), $key);
404
    }
405
406
    /**
407
     * @param BackendInterface $backend
408
     * @param string $key
409
     * @return CipherSweet
410
     */
411
    public static function getEngine(BackendInterface $backend, $key = null)
412
    {
413
        $provider = self::getProviderWithKey($key);
414
        return new CipherSweet($provider, $backend);
415
    }
416
417
    /**
418
     * @param BackendInterface $backend
419
     * @param KeyProviderInterface $provider
420
     * @return CipherSweet
421
     */
422
    public static function getEngineWithProvider(BackendInterface $backend, KeyProviderInterface $provider)
423
    {
424
        return new CipherSweet($provider, $backend);
425
    }
426
427
    /**
428
     * @return EncryptedFile
429
     */
430
    public static function getEncryptedFileInstance()
431
    {
432
        if (!self::$encryptedFile) {
433
            self::$encryptedFile = new EncryptedFile(self::getCipherSweet());
434
        }
435
        return self::$encryptedFile;
436
    }
437
438
    /**
439
     * @param int $ID
440
     * @return bool
441
     */
442
    public static function checkIfFileIsEncrypted($ID)
443
    {
444
        return (bool)DB::prepared_query("SELECT Encrypted FROM File WHERE ID = ?", [$ID])->value();
445
    }
446
447
    /**
448
     * @param KeyProviderInterface $provider
449
     * @return CipherSweet
450
     */
451
    public static function getCipherSweet($provider = null)
452
    {
453
        if (self::$ciphersweet) {
454
            return self::$ciphersweet;
455
        }
456
        if ($provider === null) {
457
            $provider = self::getProviderWithKey();
458
        }
459
        if (self::getForcedEncryption()) {
460
            $backend = self::getBackendForEncryption(self::getForcedEncryption());
461
        } else {
462
            $backend = self::getRecommendedBackend();
463
        }
464
        self::$ciphersweet = new CipherSweet($provider, $backend);
465
        return self::$ciphersweet;
466
    }
467
468
    /**
469
     * @return void
470
     */
471
    public static function clearCipherSweet()
472
    {
473
        self::$ciphersweet = null;
474
        self::$encryptedFile = null;
475
    }
476
477
    /**
478
     * @return BackendInterface
479
     */
480
    public static function getCipherSweetBackend()
481
    {
482
        return self::getCipherSweet()->getBackend();
483
    }
484
485
    /**
486
     * Check if a value is encrypted
487
     *
488
     * @param string $value
489
     * @return boolean
490
     */
491
    public static function isEncrypted($value)
492
    {
493
        $prefix = substr($value, 0, 5);
494
        return in_array($prefix, ["brng:", "nacl:", "fips:"]);
495
    }
496
497
    /**
498
     * Check if a json value is encrypted
499
     *
500
     * @param string|array $value
501
     * @return boolean
502
     */
503
    public static function isJsonEncrypted($value)
504
    {
505
        if (is_string($value)) {
506
            $value = json_decode($value, JSON_OBJECT_AS_ARRAY);
0 ignored issues
show
Bug introduced by
LeKoala\Encrypt\JSON_OBJECT_AS_ARRAY of type integer is incompatible with the type boolean|null expected by parameter $associative of json_decode(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

506
            $value = json_decode($value, /** @scrutinizer ignore-type */ JSON_OBJECT_AS_ARRAY);
Loading history...
507
        }
508
        // If any top level value is encrypted
509
        foreach ($value as $v) {
510
            if (self::isEncrypted($v)) {
511
                return true;
512
            }
513
        }
514
        return false;
515
    }
516
517
    /**
518
     * Convert map to a suitable DB field definition
519
     * @param JsonFieldMap $map
520
     * @return string
521
     */
522
    public static function convertJsonMapToDefinition($map)
523
    {
524
        return str_replace("\"", "\\\"", (string)$map);
525
    }
526
527
    /**
528
     * @param string $value
529
     * @return boolean
530
     */
531
    public static function isFips($value)
532
    {
533
        if (strpos($value, 'fips:') === 0) {
534
            return true;
535
        }
536
        return false;
537
    }
538
539
    /**
540
     * @param string $value
541
     * @return boolean
542
     */
543
    public static function isNacl($value)
544
    {
545
        if (strpos($value, 'nacl:') === 0) {
546
            return true;
547
        }
548
        return false;
549
    }
550
551
    /**
552
     * @param string $value
553
     * @return boolean
554
     */
555
    public static function isBoring($value)
556
    {
557
        if (strpos($value, 'brng:') === 0) {
558
            return true;
559
        }
560
        return false;
561
    }
562
563
    /**
564
     * @param string $value
565
     * @return string
566
     */
567
    public static function getEncryption($value)
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 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...
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] = strpos($dbClass, 'Encrypted') !== false;
601
        } else {
602
            self::$field_cache[$key] = false;
603
        }
604
        return self::$field_cache[$key];
605
    }
606
607
    /**
608
     * Filters parameters from database class config
609
     * @return string
610
     */
611
    protected static function filterDbClass($dbClass)
612
    {
613
        $pos = strpos($dbClass, '(');
614
        if ($pos !== false) {
615
            $dbClass = substr($dbClass, 0, $pos);
616
        }
617
        return $dbClass;
618
    }
619
620
    /**
621
     * @param string $class
622
     * @param bool $dbFields Return actual database field value instead of field name
623
     * @return array An associative array with the name of the field as key and the class as value
624
     */
625
    public static function getEncryptedFields($class, $dbFields = false)
626
    {
627
        $fields = $class::config()->db;
628
        $list = [];
629
        foreach ($fields as $field => $dbClass) {
630
            $dbClass = self::filterDbClass($dbClass);
631
            $key = $class . '_' . $field;
632
            if (isset($fields[$field])) {
633
                self::$field_cache[$key] = strpos($dbClass, 'Encrypted') !== false;
634
                if (self::$field_cache[$key]) {
635
                    // Sometimes we need actual db field name
636
                    if ($dbFields && is_subclass_of($dbClass, DBComposite::class)) {
637
                        $list[$field . "Value"] = $dbClass;
638
                    } else {
639
                        $list[$field] = $dbClass;
640
                    }
641
                }
642
            } else {
643
                self::$field_cache[$key] = false;
644
            }
645
        }
646
        return $list;
647
    }
648
649
    /**
650
     * A simple encryption
651
     * @param string $value
652
     * @return string
653
     */
654
    public static function encrypt($value)
655
    {
656
        // Do not encrypt twice
657
        $encryption = self::getEncryption($value);
658
        if ($encryption) {
659
            return $value;
660
        }
661
        $provider = self::getProviderWithKey();
662
        $backend = self::getBackendForEncryption($encryption);
663
        return $backend->encrypt($value, $provider->getSymmetricKey());
664
    }
665
666
    /**
667
     * A simple decryption
668
     * @param string $value
669
     * @return string
670
     */
671
    public static function decrypt($value)
672
    {
673
        // Only decrypt what we can decrypt
674
        $encryption = self::getEncryption($value);
675
        if (!$encryption) {
676
            return $value;
677
        }
678
        $provider = self::getProviderWithKey();
679
        $backend =  self::getBackendForEncryption($encryption);
680
        return $backend->decrypt($value, $provider->getSymmetricKey());
681
    }
682
683
    /**
684
     * Return a map of fields with their encrypted counterpart
685
     *
686
     * @return array
687
     */
688
    public static function mapEncryptionDBField()
689
    {
690
        return [
691
            DBHTMLText::class => EncryptedDBHTMLText::class,
692
            DBText::class => EncryptedDBText::class,
693
            DBVarchar::class => EncryptedDBVarchar::class,
694
        ];
695
    }
696
697
    /**
698
     * Compute Blind Index Information Leaks
699
     *
700
     * @link https://ciphersweet.paragonie.com/php/blind-index-planning
701
     * @link https://ciphersweet.paragonie.com/security
702
     * @param array $indexes an array of L (output size) / K (domaine size) pairs
703
     * @param int $R the number of encrypted records that use this blind index
704
     * @return float
705
     */
706
    public static function coincidenceCount(array $indexes, $R)
707
    {
708
        $exponent = 0;
709
        $count = count($indexes);
710
        for ($i = 0; $i < $count; ++$i) {
711
            $exponent += min($indexes[$i]['L'], $indexes[$i]['K']);
712
        }
713
        return (float) max(1, $R) / pow(2, $exponent);
714
    }
715
716
    /**
717
     * Alias of sendDecryptedFile
718
     * @deprecated
719
     * @param File|EncryptedDBFile $file
720
     * @return void
721
     */
722
    public static function sendEncryptedFile(File $file)
723
    {
724
        self::sendDecryptedFile($file);
725
    }
726
727
    /**
728
     * Send a decrypted file
729
     *
730
     * @param File|EncryptedDBFile $file
731
     * @param string $fileName
732
     * @param string $mimeType
733
     * @return void
734
     */
735
    public static function sendDecryptedFile(File $file, $fileName = null, $mimeType = null)
736
    {
737
        if (!$fileName) {
738
            $fileName = basename($file->getFilename());
739
        }
740
        if (!$mimeType) {
741
            $mimeType = 'application/octetstream';
742
        }
743
        header('Content-disposition: attachment; filename="' . $fileName . '"');
744
        header('Content-type: ' . $mimeType);
745
        header('Pragma: no-cache');
746
        header('Expires: 0');
747
        $file->sendDecryptedFile();
748
    }
749
}
750