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; |
||
34 | |||
35 | /** |
||
36 | * @config |
||
37 | * @var int |
||
38 | */ |
||
39 | private static $domain_size = 127; |
||
40 | |||
41 | /** |
||
42 | * @var array<string,string> |
||
43 | */ |
||
44 | private static $composite_db = array( |
||
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 |
||
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
|
|||
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()) |
||
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 |
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.