EncryptedDBField::fetchRecord()   B
last analyzed

Complexity

Conditions 8
Paths 50

Size

Total Lines 45
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 25
c 1
b 0
f 0
dl 0
loc 45
rs 8.4444
cc 8
nc 50
nop 4
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
16
/**
17
 * Value will be set on parent record through built in getField
18
 * mechanisms for composite fields
19
 */
20
class EncryptedDBField extends DBComposite
21
{
22
    use HasBaseEncryption;
23
24
    const LARGE_INDEX_SIZE = 32;
25
    const SMALL_INDEX_SIZE = 16;
26
    const VALUE_SUFFIX = "Value";
27
    const INDEX_SUFFIX = "BlindIndex";
28
29
    /**
30
     * @config
31
     * @var int
32
     */
33
    private static $output_size = 15;
0 ignored issues
show
introduced by
The private property $output_size is not used, and could be removed.
Loading history...
34
35
    /**
36
     * @config
37
     * @var int
38
     */
39
    private static $domain_size = 127;
0 ignored issues
show
introduced by
The private property $domain_size is not used, and could be removed.
Loading history...
40
41
    /**
42
     * @var array<string,string>
43
     */
44
    private static $composite_db = array(
0 ignored issues
show
introduced by
The private property $composite_db is not used, and could be removed.
Loading history...
45
        "Value" => "Varchar(191)",
46
        "BlindIndex" => 'Varchar(32)',
47
    );
48
49
    /**
50
     * Output size is the number of bits (not bytes) of a blind index.
51
     * Eg: 4 for a 4 digits year
52
     * Note: the larger the output size, the smaller the index should be
53
     * @return int
54
     */
55
    public function getOutputSize()
56
    {
57
        if (array_key_exists('output_size', $this->options)) {
58
            $outputSize = $this->options['output_size'];
59
        } else {
60
            $outputSize = static::config()->get('output_size');
61
        }
62
        return $outputSize;
63
    }
64
65
    /**
66
     * Input domain is the set of all possible distinct inputs.
67
     * Eg : 4 digits have 10,000 possible values (10^4). The log (base 2) of 10,000 is 13.2877; you would want to always round up (so 14).
68
     * @return int
69
     */
70
    public function getDomainSize()
71
    {
72
        if (array_key_exists('domain_size', $this->options)) {
73
            $domainSize = $this->options['domain_size'];
74
        } else {
75
            $domainSize = static::config()->get('domain_size');
76
        }
77
        return $domainSize;
78
    }
79
80
    /**
81
     * @param int $default
82
     * @return int
83
     */
84
    public function getIndexSize($default = null)
85
    {
86
        if (array_key_exists('index_size', $this->options)) {
87
            return $this->options['index_size'];
88
        }
89
        if ($default !== null) {
90
            return $default;
91
        }
92
        return self::LARGE_INDEX_SIZE;
93
    }
94
95
    /**
96
     * @return string
97
     */
98
    public function getValueField()
99
    {
100
        return $this->getField(self::VALUE_SUFFIX);
101
    }
102
103
    /**
104
     * @param mixed $value
105
     * @param bool $markChanged
106
     * @return $this
107
     */
108
    public function setValueField($value, $markChanged = true)
109
    {
110
        return $this->setField(self::VALUE_SUFFIX, $value, $markChanged);
111
    }
112
113
    /**
114
     * @return string
115
     */
116
    public function getBlindIndexField()
117
    {
118
        return $this->getField(self::INDEX_SUFFIX);
119
    }
120
121
    /**
122
     * @param mixed $value
123
     * @param bool $markChanged
124
     * @return $this
125
     */
126
    public function setBlindIndexField($value, $markChanged = true)
127
    {
128
        return $this->setField(self::INDEX_SUFFIX, $value, $markChanged);
129
    }
130
131
    /**
132
     * @param CipherSweet $engine
133
     * @param bool $fashHash
134
     * @return EncryptedField
135
     */
136
    public function getEncryptedField($engine = null, $fashHash = null)
137
    {
138
        if ($engine === null) {
139
            $engine = EncryptHelper::getCipherSweet();
140
        }
141
        if ($fashHash === null) {
142
            $fashHash = EncryptHelper::getFashHash();
143
        }
144
        $indexSize = $this->getIndexSize(self::LARGE_INDEX_SIZE);
145
146
        //TODO: review how naming is done (see: getEncryptedRow)
147
        // fieldName needs to match exact db name for row rotator to work properly
148
        $fieldName = $this->name . self::VALUE_SUFFIX;
149
        $indexName = $this->name . self::INDEX_SUFFIX;
150
151
        $encryptedField = (new EncryptedField($engine, $this->tableName, $fieldName))
152
            ->addBlindIndex(new BlindIndex($indexName, [], $indexSize, $fashHash));
153
        return $encryptedField;
154
    }
155
156
    /**
157
     * Depending on your version, this may or may not be called
158
     * When not called, it works thanks to saveInto
159
     * @link https://github.com/silverstripe/silverstripe-framework/issues/8800
160
     * @link https://github.com/silverstripe/silverstripe-framework/pull/10913
161
     * @param array<string,mixed> $manipulation
162
     * @return void
163
     */
164
    public function writeToManipulation(&$manipulation)
165
    {
166
        $encryptedField = $this->getEncryptedField();
167
        $aad = $this->encryptionAad;
168
        if ($this->value) {
169
            $dataForStorage = $encryptedField->prepareForStorage($this->value, $aad);
170
            $encryptedValue = $this->prepValueForDB($dataForStorage[0]);
171
            /** @var array<string,string> $blindIndexes */
172
            $blindIndexes = $dataForStorage[1];
173
        } else {
174
            $encryptedValue = null;
175
            $blindIndexes = [];
176
        }
177
178
        $manipulation['fields'][$this->name . self::VALUE_SUFFIX] = $encryptedValue;
179
        foreach ($blindIndexes as $blindIndexName => $blindIndexValue) {
180
            $iv = $encryptedValue ? $blindIndexValue : null;
181
            $manipulation['fields'][$blindIndexName] = $iv;
182
        }
183
    }
184
185
    /**
186
     * @param SQLSelect $query
187
     * @return void
188
     */
189
    // public function addToQuery(&$query)
190
    // {
191
    //     parent::addToQuery($query);
192
    //     $query->selectField(sprintf('"%s' . self::VALUE_SUFFIX . '"', $this->name));
193
    //     $query->selectField(sprintf('"%s' . self::INDEX_SUFFIX . '"', $this->name));
194
    // }
195
196
    /**
197
     * Return the blind index value to search in the database
198
     *
199
     * @param string $val The unencrypted value
200
     * @param string $indexSuffix The blind index. Defaults to full index
201
     * @return string
202
     */
203
    public function getSearchValue($val, $indexSuffix = null)
204
    {
205
        if (!$this->tableName && $this->record && is_object($this->record)) {
206
            $this->tableName = DataObject::getSchema()->tableName(get_class($this->record));
207
        }
208
        if (!$this->tableName) {
209
            throw new Exception("Table name not set for search value. Please set a dataobject.");
210
        }
211
        if (!$this->name) {
212
            throw new Exception("Name not set for search value");
213
        }
214
        if (!$val) {
215
            throw new Exception("Cannot search an empty value");
216
        }
217
        if ($indexSuffix === null) {
218
            $indexSuffix = self::INDEX_SUFFIX;
219
        }
220
        $field = $this->getEncryptedField();
221
        $index = $field->getBlindIndex($val, $this->name . $indexSuffix);
222
        if (is_array($index)) {
223
            return $index['value'];
224
        }
225
        return $index;
226
    }
227
228
    /**
229
     * Return a ready to use array params for a where clause
230
     *
231
     * @param string $val The unencrypted value
232
     * @param string $indexSuffix The blind index. Defaults to full index
233
     * @return array<string,string>
234
     */
235
    public function getSearchParams($val, $indexSuffix = null)
236
    {
237
        if (!$indexSuffix) {
238
            $indexSuffix = self::INDEX_SUFFIX;
239
        }
240
        $searchValue = $this->getSearchValue($val, $indexSuffix);
241
        $blindIndexField = $this->name . $indexSuffix;
242
        return array($blindIndexField . ' = ?' => $searchValue);
243
    }
244
245
    /**
246
     * @param string $val The unencrypted value
247
     * @param string $indexSuffix The blind index. Defaults to full index
248
     * @param ?array $where Extra where parameters
249
     * @return DataList
250
     */
251
    public function fetchDataList($val, $indexSuffix = null, $where = null)
252
    {
253
        if (!$this->record || !is_object($this->record)) {
254
            throw new Exception("No record set for this field");
255
        }
256
        if (!$indexSuffix) {
257
            $indexSuffix = self::INDEX_SUFFIX;
258
        }
259
        $class = get_class($this->record);
260
261
        // A blind index can return false positives, use fetch record to make sure you get the record baased on value
262
        $params = $this->getSearchParams($val, $indexSuffix);
263
        if ($where) {
264
            $params = array_merge($params, $where);
265
        }
266
267
        /** @var DataList $list */
268
        $list = $class::get();
269
        $list = $list->where($params);
270
        return $list;
271
    }
272
273
    /**
274
     * @param string $val The unencrypted value
275
     * @param string $indexSuffix The blind index. Defaults to full index
276
     * @param string|array|null $ignoreID Allows to ignore one id or a list of ids
277
     * @param array|null Extra where parameters
0 ignored issues
show
Bug introduced by
The type LeKoala\Encrypt\Extra was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
278
     * @return DataObject|false
279
     */
280
    public function fetchRecord($val, $indexSuffix = null, $ignoreID = null, $where = null)
281
    {
282
        if (!$indexSuffix) {
283
            $indexSuffix = self::INDEX_SUFFIX;
284
        }
285
286
        if ($ignoreID) {
287
            if (!$where) {
288
                $where = [];
289
            }
290
            if (is_array($ignoreID)) {
291
                // Since we don't use parametrised query, make sure ids are valid ints
292
                $ignoreID = array_map("intval", $ignoreID);
293
                $ignoreID = implode(",", $ignoreID);
294
                $where = array_merge($where, [
295
                    '"ID" NOT IN (' . $ignoreID . ')'
296
                ]);
297
            } else {
298
                $where = array_merge($where, [
299
                    '"ID" != ?' => $ignoreID,
300
                ]);
301
            }
302
        }
303
304
        $list = $this->fetchDataList($val, $indexSuffix, $where);
305
        $blindIndexes = $this->getEncryptedField()->getBlindIndexObjects();
306
        $blindIndex = $blindIndexes[$this->name . $indexSuffix];
307
308
        // We will refetch the db object based on the field name for each record
309
        $name = $this->name;
310
        /** @var DataObject $record  */
311
        foreach ($list as $record) {
312
            /** @var EncryptedDBField $obj */
313
            $obj = $record->dbObject($name);
314
            $objValue = $obj->getValue() ?? '';
315
            // Value might be transformed
316
            if ($blindIndex->getTransformed($objValue) == $val) {
317
                return $record;
318
            }
319
        }
320
        // throw exception if there where matches but none with the right value
321
        if ($list->count()) {
322
            throw new Exception($list->count() . " records were found but none matched the right value");
323
        }
324
        return false;
325
    }
326
327
    public function setValue($value, $record = null, $markChanged = true)
328
    {
329
        $this->setEncryptionAad($record);
330
331
        // Return early if we keep encrypted value in memory
332
        if (!EncryptHelper::getAutomaticDecryption()) {
333
            parent::setValue($value, $record, $markChanged);
334
            return $this;
335
        }
336
337
        if ($markChanged) {
338
            $this->isChanged = true;
339
        }
340
341
        // When given a dataobject, bind this field to that
342
        if ($record instanceof DataObject) {
343
            $this->bindTo($record);
344
        }
345
346
        // Convert an object to an array
347
        if ($record && $record instanceof DataObject) {
348
            $record = $record->getQueriedDatabaseFields();
349
            if (!$record) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $record of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
350
                throw new Exception("Could not convert record to array");
351
            }
352
        }
353
354
        // Set the table name if it was not set earlier
355
        if (!$this->tableName && $record) {
356
            $class = is_array($record) && isset($record['ClassName']) ? $record['ClassName'] : get_class($record);
357
            $this->tableName = DataObject::getSchema()->tableName($class);
358
            if (!$this->tableName) {
359
                throw new Exception("Could not get table name from record from " . gettype($record));
360
            }
361
        }
362
363
        // Value will store the decrypted value
364
        if ($value instanceof EncryptedDBField) {
365
            $this->value = $value->getValue();
366
        } elseif ($record && isset($record[$this->name . self::VALUE_SUFFIX])) {
367
            // In that case, the value come from the database and might be encrypted
368
            $encryptedValue = $record[$this->name . self::VALUE_SUFFIX];
369
            $this->value = $this->decryptValue($encryptedValue);
370
        } elseif (is_array($value)) {
371
            if (array_key_exists(self::VALUE_SUFFIX, $value)) {
372
                $this->value = $value;
373
            }
374
        } elseif (is_string($value) || !$value) {
375
            $this->value = $value;
376
        } else {
377
            throw new Exception("Unexcepted value of type " . gettype($value));
378
        }
379
380
        if (!$this->value) {
381
            // Forward changes otherwise old value may get restored from record
382
            // Can also help if manipulations are not executed properly
383
            $this->setValueField(null, $markChanged);
384
385
            // Make sure blind index gets nullified
386
            $this->setBlindIndexField(null);
387
        }
388
389
        return $this;
390
    }
391
392
    /**
393
     * @param array<string,mixed> $options
394
     * @return string
395
     */
396
    public function Nice($options = array())
0 ignored issues
show
Unused Code introduced by
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

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