Issues (84)

src/EncryptedDBField.php (3 issues)

1
<?php
2
3
namespace LeKoala\Encrypt;
4
5
use Exception;
6
use SilverStripe\ORM\DataList;
7
use SilverStripe\ORM\DataObject;
8
use SilverStripe\Forms\FormField;
9
use SilverStripe\Forms\TextField;
10
use ParagonIE\CipherSweet\BlindIndex;
11
use ParagonIE\CipherSweet\CipherSweet;
12
use SilverStripe\ORM\Queries\SQLSelect;
13
use ParagonIE\CipherSweet\EncryptedField;
14
use SilverStripe\ORM\FieldType\DBComposite;
15
use SilverStripe\Model\ModelData;
16
17
/**
18
 * Value will be set on parent record through built in getField
19
 * mechanisms for composite fields
20
 */
21
class EncryptedDBField extends DBComposite
22
{
23
    use HasBaseEncryption;
24
25
    public const LARGE_INDEX_SIZE = 32;
26
    public const SMALL_INDEX_SIZE = 16;
27
    public const VALUE_SUFFIX = "Value";
28
    public const INDEX_SUFFIX = "BlindIndex";
29
30
    /**
31
     * @config
32
     * @var int
33
     */
34
    private static $output_size = 15;
35
36
    /**
37
     * @config
38
     * @var int
39
     */
40
    private static $domain_size = 127;
41
42
    /**
43
     * @var array<string,string>
44
     */
45
    private static $composite_db = [
46
        "Value" => "Varchar(191)",
47
        "BlindIndex" => 'Varchar(32)',
48
    ];
49
50
    /**
51
     * Output size is the number of bits (not bytes) of a blind index.
52
     * Eg: 4 for a 4 digits year
53
     * Note: the larger the output size, the smaller the index should be
54
     * @return int
55
     */
56
    public function getOutputSize()
57
    {
58
        if (array_key_exists('output_size', $this->options)) {
59
            $outputSize = $this->options['output_size'];
60
        } else {
61
            $outputSize = static::config()->get('output_size');
62
        }
63
        return $outputSize;
64
    }
65
66
    /**
67
     * Input domain is the set of all possible distinct inputs.
68
     * Eg :
69
     * 4 digits have 10,000 possible values (10^4).
70
     * The log (base 2) of 10,000 is 13.2877; you would want to always round up (so 14).
71
     * @return int
72
     */
73
    public function getDomainSize()
74
    {
75
        if (array_key_exists('domain_size', $this->options)) {
76
            $domainSize = $this->options['domain_size'];
77
        } else {
78
            $domainSize = static::config()->get('domain_size');
79
        }
80
        return $domainSize;
81
    }
82
83
    /**
84
     * @param int $default
85
     * @return int
86
     */
87
    public function getIndexSize($default = null)
88
    {
89
        if (array_key_exists('index_size', $this->options)) {
90
            return $this->options['index_size'];
91
        }
92
        if ($default !== null) {
93
            return $default;
94
        }
95
        return self::LARGE_INDEX_SIZE;
96
    }
97
98
    /**
99
     * @return string
100
     */
101
    public function getValueField()
102
    {
103
        return $this->getField(self::VALUE_SUFFIX);
104
    }
105
106
    /**
107
     * @param mixed $value
108
     * @param bool $markChanged
109
     * @return $this
110
     */
111
    public function setValueField($value, $markChanged = true)
112
    {
113
        return $this->setField(self::VALUE_SUFFIX, $value, $markChanged);
114
    }
115
116
    /**
117
     * @return string
118
     */
119
    public function getBlindIndexField()
120
    {
121
        return $this->getField(self::INDEX_SUFFIX);
122
    }
123
124
    /**
125
     * @param mixed $value
126
     * @param bool $markChanged
127
     * @return $this
128
     */
129
    public function setBlindIndexField($value, $markChanged = true)
130
    {
131
        return $this->setField(self::INDEX_SUFFIX, $value, $markChanged);
132
    }
133
134
    /**
135
     * @param CipherSweet $engine
136
     * @param bool $fashHash
137
     * @return EncryptedField
138
     */
139
    public function getEncryptedField($engine = null, $fashHash = null)
140
    {
141
        if ($engine === null) {
142
            $engine = EncryptHelper::getCipherSweet();
143
        }
144
        if ($fashHash === null) {
145
            $fashHash = EncryptHelper::getFashHash();
146
        }
147
        $indexSize = $this->getIndexSize(self::LARGE_INDEX_SIZE);
148
149
        //TODO: review how naming is done (see: getEncryptedRow)
150
        // fieldName needs to match exact db name for row rotator to work properly
151
        $fieldName = $this->name . self::VALUE_SUFFIX;
152
        $indexName = $this->name . self::INDEX_SUFFIX;
153
154
        $encryptedField = (new EncryptedField($engine, $this->tableName, $fieldName))
0 ignored issues
show
It seems like $this->tableName can also be of type null; however, parameter $tableName of ParagonIE\CipherSweet\En...tedField::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

154
        $encryptedField = (new EncryptedField($engine, /** @scrutinizer ignore-type */ $this->tableName, $fieldName))
Loading history...
155
            ->addBlindIndex(new BlindIndex($indexName, [], $indexSize, $fashHash));
156
        return $encryptedField;
157
    }
158
159
    /**
160
     * Depending on your version, this may or may not be called
161
     * When not called, it works thanks to saveInto
162
     * @link https://github.com/silverstripe/silverstripe-framework/issues/8800
163
     * @link https://github.com/silverstripe/silverstripe-framework/pull/10913
164
     * @param array<string,mixed> $manipulation
165
     * @return void
166
     */
167
    public function writeToManipulation(array &$manipulation): void
168
    {
169
        $encryptedField = $this->getEncryptedField();
170
        $aad = $this->encryptionAad;
171
        if ($this->value) {
172
            $dataForStorage = $encryptedField->prepareForStorage($this->value, $aad);
173
            $encryptedValue = $this->prepValueForDB($dataForStorage[0]);
174
            /** @var array<string,string> $blindIndexes */
175
            $blindIndexes = $dataForStorage[1];
176
        } else {
177
            $encryptedValue = null;
178
            $blindIndexes = [];
179
        }
180
181
        $manipulation['fields'][$this->name . self::VALUE_SUFFIX] = $encryptedValue;
182
        foreach ($blindIndexes as $blindIndexName => $blindIndexValue) {
183
            $iv = $encryptedValue ? $blindIndexValue : null;
184
            $manipulation['fields'][$blindIndexName] = $iv;
185
        }
186
    }
187
188
    /**
189
     * @param SQLSelect $query
190
     * @return void
191
     */
192
    // public function addToQuery(&$query)
193
    // {
194
    //     parent::addToQuery($query);
195
    //     $query->selectField(sprintf('"%s' . self::VALUE_SUFFIX . '"', $this->name));
196
    //     $query->selectField(sprintf('"%s' . self::INDEX_SUFFIX . '"', $this->name));
197
    // }
198
199
    /**
200
     * Return the blind index value to search in the database
201
     *
202
     * @param string $val The unencrypted value
203
     * @param string $indexSuffix The blind index. Defaults to full index
204
     * @return string
205
     */
206
    public function getSearchValue($val, $indexSuffix = null)
207
    {
208
        if (!$this->tableName && $this->record && is_object($this->record)) {
209
            $this->tableName = DataObject::getSchema()->tableName(get_class($this->record));
210
        }
211
        if (!$this->tableName) {
212
            throw new Exception("Table name not set for search value. Please set a dataobject.");
213
        }
214
        if (!$this->name) {
215
            throw new Exception("Name not set for search value");
216
        }
217
        if (!$val) {
218
            throw new Exception("Cannot search an empty value");
219
        }
220
        if ($indexSuffix === null) {
221
            $indexSuffix = self::INDEX_SUFFIX;
222
        }
223
        $field = $this->getEncryptedField();
224
        $index = $field->getBlindIndex($val, $this->name . $indexSuffix);
225
        if (is_array($index)) {
226
            return $index['value'];
227
        }
228
        return $index;
229
    }
230
231
    /**
232
     * Return a ready to use array params for a where clause
233
     *
234
     * @param string $val The unencrypted value
235
     * @param string $indexSuffix The blind index. Defaults to full index
236
     * @return array<string,string>
237
     */
238
    public function getSearchParams($val, $indexSuffix = null)
239
    {
240
        if (!$indexSuffix) {
241
            $indexSuffix = self::INDEX_SUFFIX;
242
        }
243
        $searchValue = $this->getSearchValue($val, $indexSuffix);
244
        $blindIndexField = $this->name . $indexSuffix;
245
        return array($blindIndexField . ' = ?' => $searchValue);
246
    }
247
248
    /**
249
     * @param string $val The unencrypted value
250
     * @param string $indexSuffix The blind index. Defaults to full index
251
     * @param ?array $where Extra where parameters
252
     * @return DataList
253
     */
254
    public function fetchDataList($val, $indexSuffix = null, $where = null)
255
    {
256
        if (!$this->record || !is_object($this->record)) {
257
            throw new Exception("No record set for this field");
258
        }
259
        if (!$indexSuffix) {
260
            $indexSuffix = self::INDEX_SUFFIX;
261
        }
262
        $class = get_class($this->record);
263
264
        // A blind index can return false positives, use fetch record to make sure you get the record baased on value
265
        $params = $this->getSearchParams($val, $indexSuffix);
266
        if ($where) {
267
            $params = array_merge($params, $where);
268
        }
269
270
        /** @var DataList $list */
271
        $list = $class::get();
272
        $list = $list->where($params);
273
        return $list;
274
    }
275
276
    /**
277
     * @param string $val The unencrypted value
278
     * @param string $indexSuffix The blind index. Defaults to full index
279
     * @param string|array|null $ignoreID Allows to ignore one id or a list of ids
280
     * @param array|null Extra where parameters
281
     * @return DataObject|false
282
     */
283
    public function fetchRecord($val, $indexSuffix = null, $ignoreID = null, $where = null)
284
    {
285
        if (!$indexSuffix) {
286
            $indexSuffix = self::INDEX_SUFFIX;
287
        }
288
289
        if ($ignoreID) {
290
            if (!$where) {
291
                $where = [];
292
            }
293
            if (is_array($ignoreID)) {
294
                // Since we don't use parametrised query, make sure ids are valid ints
295
                $ignoreID = array_map("intval", $ignoreID);
296
                $ignoreID = implode(",", $ignoreID);
297
                $where = array_merge($where, [
298
                    '"ID" NOT IN (' . $ignoreID . ')'
299
                ]);
300
            } else {
301
                $where = array_merge($where, [
302
                    '"ID" != ?' => $ignoreID,
303
                ]);
304
            }
305
        }
306
307
        $list = $this->fetchDataList($val, $indexSuffix, $where);
308
        $blindIndexes = $this->getEncryptedField()->getBlindIndexObjects();
309
        $blindIndex = $blindIndexes[$this->name . $indexSuffix];
310
311
        // We will refetch the db object based on the field name for each record
312
        $name = $this->name;
313
        /** @var DataObject $record  */
314
        foreach ($list as $record) {
315
            /** @var EncryptedDBField $obj */
316
            $obj = $record->dbObject($name);
317
            $objValue = $obj->getValue() ?? '';
318
            // Value might be transformed
319
            if ($blindIndex->getTransformed($objValue) == $val) {
320
                return $record;
321
            }
322
        }
323
        // throw exception if there where matches but none with the right value
324
        if ($list->count()) {
325
            throw new Exception($list->count() . " records were found but none matched the right value");
326
        }
327
        return false;
328
    }
329
330
    public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static
331
    {
332
        $this->setEncryptionAad($record);
0 ignored issues
show
It seems like $record can also be of type array; however, parameter $record of LeKoala\Encrypt\Encrypte...eld::setEncryptionAad() does only seem to accept SilverStripe\ORM\DataObject, maybe add an additional type check? ( Ignorable by Annotation )

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

332
        $this->setEncryptionAad(/** @scrutinizer ignore-type */ $record);
Loading history...
333
334
        // Return early if we keep encrypted value in memory
335
        if (!EncryptHelper::getAutomaticDecryption()) {
336
            parent::setValue($value, $record, $markChanged);
337
            return $this;
338
        }
339
340
        if ($markChanged) {
341
            $this->isChanged = true;
342
        }
343
344
        // When given a dataobject, bind this field to that
345
        if ($record instanceof DataObject) {
346
            $this->bindTo($record);
347
        }
348
349
        // Convert an object to an array
350
        if ($record && $record instanceof DataObject) {
351
            $record = $record->getQueriedDatabaseFields();
352
            if (!$record) {
353
                throw new Exception("Could not convert record to array");
354
            }
355
        }
356
357
        // Set the table name if it was not set earlier
358
        if (!$this->tableName && $record) {
359
            $class = is_array($record) && isset($record['ClassName']) ? $record['ClassName'] : get_class($record);
360
            $this->tableName = DataObject::getSchema()->tableName($class);
361
            if (!$this->tableName) {
362
                throw new Exception("Could not get table name from record from " . gettype($record));
363
            }
364
        }
365
366
        // Value will store the decrypted value
367
        if ($value instanceof EncryptedDBField) {
368
            $this->value = $value->getValue();
369
        } elseif ($record && isset($record[$this->name . self::VALUE_SUFFIX])) {
370
            // In that case, the value come from the database and might be encrypted
371
            $encryptedValue = $record[$this->name . self::VALUE_SUFFIX];
372
            $this->value = $this->decryptValue($encryptedValue);
373
        } elseif (is_array($value)) {
374
            if (array_key_exists(self::VALUE_SUFFIX, $value)) {
375
                $this->value = $value;
376
            }
377
        } elseif (is_string($value) || !$value) {
378
            $this->value = $value;
379
        } else {
380
            throw new Exception("Unexcepted value of type " . gettype($value));
381
        }
382
383
        if (!$this->value) {
384
            // Forward changes otherwise old value may get restored from record
385
            // Can also help if manipulations are not executed properly
386
            $this->setValueField(null, $markChanged);
387
388
            // Make sure blind index gets nullified
389
            $this->setBlindIndexField(null);
390
        }
391
392
        return $this;
393
    }
394
395
    /**
396
     * @param array<string,mixed> $options
397
     * @return string
398
     */
399
    public function Nice($options = array())
0 ignored issues
show
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

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

399
    public function Nice(/** @scrutinizer ignore-unused */ $options = array())

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
400
    {
401
        return $this->getValue();
402
    }
403
404
    public function exists(): bool
405
    {
406
        return strlen($this->value ?? '') > 0;
407
    }
408
409
    /**
410
     * This is called by getChangedFields() to check if a field is changed
411
     */
412
    public function isChanged(): bool
413
    {
414
        return $this->isChanged;
415
    }
416
417
    /**
418
     * If we pass a DBField to the setField method, it will
419
     * trigger this method
420
     *
421
     * We save encrypted value on sub fields. They will be collected
422
     * by write() operation by prepareManipulationTable
423
     *
424
     * Currently prepareManipulationTable ignores composite fields
425
     * so we rely on the sub field mechanisms
426
     */
427
    public function saveInto(ModelData $model): void
428
    {
429
        $encryptedField = $this->getEncryptedField();
430
        $aad = $this->encryptionAad;
431
        if ($this->value) {
432
            $dataForStorage = $encryptedField->prepareForStorage($this->value, $aad);
433
            $encryptedValue = $this->prepValueForDB($dataForStorage[0]);
434
            /** @var array<string,string> $blindIndexes */
435
            $blindIndexes = $dataForStorage[1];
436
        } else {
437
            $encryptedValue = null;
438
            $blindIndexes = [];
439
        }
440
441
        // This cause infinite loops
442
        // $dataObject->setField($this->getName(), $this->value);
443
444
        // Encrypt value
445
        $key = $this->getName() . self::VALUE_SUFFIX;
446
        $model->setField($key, $encryptedValue);
447
448
        // Build blind indexes
449
        foreach ($blindIndexes as $blindIndexName => $blindIndexValue) {
450
            $iv = $this->value ? $blindIndexValue : null;
451
            $model->setField($blindIndexName, $iv);
452
        }
453
    }
454
455
    /**
456
     * @param string $title Optional. Localized title of the generated instance
457
     * @param array<mixed> $params
458
     * @return FormField
459
     */
460
    public function scaffoldFormField(?string $title = null, array $params = []): ?FormField
461
    {
462
        $field = TextField::create($this->getName());
463
        return $field;
464
    }
465
466
    /**
467
     * Returns the string value
468
     */
469
    public function __toString()
470
    {
471
        return (string) $this->getValue();
472
    }
473
474
    public function scalarValueOnly(): bool
475
    {
476
        return false;
477
    }
478
}
479