6b3a89a9fa2940836e68ad1878770af7f3affbf9
[osm/common.git] / osm_common / dbbase.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Telefonica S.A.
4 #
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
8 #
9 # http://www.apache.org/licenses/LICENSE-2.0
10 #
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14 # implied.
15 # See the License for the specific language governing permissions and
16 # limitations under the License.
17 import asyncio
18 from base64 import b64decode, b64encode
19 from copy import deepcopy
20 from http import HTTPStatus
21 import logging
22 import re
23 from threading import Lock
24 import typing
25
26
27 from Crypto.Cipher import AES
28 from motor.motor_asyncio import AsyncIOMotorClient
29 from osm_common.common_utils import FakeLock
30 import yaml
31
32 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
33
34
35 DB_NAME = "osm"
36
37
38 class DbException(Exception):
39 def __init__(self, message, http_code=HTTPStatus.NOT_FOUND):
40 self.http_code = http_code
41 Exception.__init__(self, "database exception " + str(message))
42
43
44 class DbBase(object):
45 def __init__(self, encoding_type="ascii", logger_name="db", lock=False):
46 """
47 Constructor of dbBase
48 :param logger_name: logging name
49 :param lock: Used to protect simultaneous access to the same instance class by several threads:
50 False, None: Do not protect, this object will only be accessed by one thread
51 True: This object needs to be protected by several threads accessing.
52 Lock object. Use thi Lock for the threads access protection
53 """
54 self.logger = logging.getLogger(logger_name)
55 self.secret_key = None # 32 bytes length array used for encrypt/decrypt
56 self.encrypt_mode = AES.MODE_ECB
57 self.encoding_type = encoding_type
58 if not lock:
59 self.lock = FakeLock()
60 elif lock is True:
61 self.lock = Lock()
62 elif isinstance(lock, Lock):
63 self.lock = lock
64 else:
65 raise ValueError("lock parameter must be a Lock classclass or boolean")
66
67 def db_connect(self, config, target_version=None):
68 """
69 Connect to database
70 :param config: Configuration of database. Contains among others:
71 host: database host (mandatory)
72 port: database port (mandatory)
73 name: database name (mandatory)
74 user: database username
75 password: database password
76 commonkey: common OSM key used for sensible information encryption
77 materpassword: same as commonkey, for backward compatibility. Deprecated, to be removed in the future
78 :param target_version: if provided it checks if database contains required version, raising exception otherwise.
79 :return: None or raises DbException on error
80 """
81 raise DbException("Method 'db_connect' not implemented")
82
83 def db_disconnect(self):
84 """
85 Disconnect from database
86 :return: None
87 """
88 pass
89
90 def get_list(self, table, q_filter=None):
91 """
92 Obtain a list of entries matching q_filter
93 :param table: collection or table
94 :param q_filter: Filter
95 :return: a list (can be empty) with the found entries. Raises DbException on error
96 """
97 raise DbException("Method 'get_list' not implemented")
98
99 def count(self, table, q_filter=None):
100 """
101 Count the number of entries matching q_filter
102 :param table: collection or table
103 :param q_filter: Filter
104 :return: number of entries found (can be zero)
105 :raise: DbException on error
106 """
107 raise DbException("Method 'count' not implemented")
108
109 def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
110 """
111 Obtain one entry matching q_filter
112 :param table: collection or table
113 :param q_filter: Filter
114 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
115 it raises a DbException
116 :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
117 that it raises a DbException
118 :return: The requested element, or None
119 """
120 raise DbException("Method 'get_one' not implemented")
121
122 def del_list(self, table, q_filter=None):
123 """
124 Deletes all entries that match q_filter
125 :param table: collection or table
126 :param q_filter: Filter
127 :return: Dict with the number of entries deleted
128 """
129 raise DbException("Method 'del_list' not implemented")
130
131 def del_one(self, table, q_filter=None, fail_on_empty=True):
132 """
133 Deletes one entry that matches q_filter
134 :param table: collection or table
135 :param q_filter: Filter
136 :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
137 which case it raises a DbException
138 :return: Dict with the number of entries deleted
139 """
140 raise DbException("Method 'del_one' not implemented")
141
142 def create(self, table, indata):
143 """
144 Add a new entry at database
145 :param table: collection or table
146 :param indata: content to be added
147 :return: database '_id' of the inserted element. Raises a DbException on error
148 """
149 raise DbException("Method 'create' not implemented")
150
151 def create_list(self, table, indata_list):
152 """
153 Add several entries at once
154 :param table: collection or table
155 :param indata_list: list of elements to insert. Each element must be a dictionary.
156 An '_id' key based on random uuid is added at each element if missing
157 :return: list of inserted '_id's. Exception on error
158 """
159 raise DbException("Method 'create_list' not implemented")
160
161 def set_one(
162 self,
163 table,
164 q_filter,
165 update_dict,
166 fail_on_empty=True,
167 unset=None,
168 pull=None,
169 push=None,
170 push_list=None,
171 pull_list=None,
172 ):
173 """
174 Modifies an entry at database
175 :param table: collection or table
176 :param q_filter: Filter
177 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
178 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
179 it raises a DbException
180 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
181 ignored. If not exist, it is ignored
182 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
183 if exist in the array is removed. If not exist, it is ignored
184 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
185 is appended to the end of the array
186 :param pull_list: Same as pull but values are arrays where each item is removed from the array
187 :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
188 whole array
189 :return: Dict with the number of entries modified. None if no matching is found.
190 """
191 raise DbException("Method 'set_one' not implemented")
192
193 def set_list(
194 self,
195 table,
196 q_filter,
197 update_dict,
198 unset=None,
199 pull=None,
200 push=None,
201 push_list=None,
202 pull_list=None,
203 ):
204 """
205 Modifies al matching entries at database
206 :param table: collection or table
207 :param q_filter: Filter
208 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
209 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
210 ignored. If not exist, it is ignored
211 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
212 if exist in the array is removed. If not exist, it is ignored
213 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
214 is appended to the end of the array
215 :param pull_list: Same as pull but values are arrays where each item is removed from the array
216 :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
217 whole array
218 :return: Dict with the number of entries modified
219 """
220 raise DbException("Method 'set_list' not implemented")
221
222 def replace(self, table, _id, indata, fail_on_empty=True):
223 """
224 Replace the content of an entry
225 :param table: collection or table
226 :param _id: internal database id
227 :param indata: content to replace
228 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
229 it raises a DbException
230 :return: Dict with the number of entries replaced
231 """
232 raise DbException("Method 'replace' not implemented")
233
234 def _join_secret_key(self, update_key):
235 """
236 Returns a xor byte combination of the internal secret_key and the provided update_key.
237 It does not modify the internal secret_key. Used for adding salt, join keys, etc.
238 :param update_key: Can be a string, byte or None. Recommended a long one (e.g. 32 byte length)
239 :return: joined key in bytes with a 32 bytes length. Can be None if both internal secret_key and update_key
240 are None
241 """
242 if not update_key:
243 return self.secret_key
244 elif isinstance(update_key, str):
245 update_key_bytes = update_key.encode()
246 else:
247 update_key_bytes = update_key
248
249 new_secret_key = (
250 bytearray(self.secret_key) if self.secret_key else bytearray(32)
251 )
252 for i, b in enumerate(update_key_bytes):
253 new_secret_key[i % 32] ^= b
254 return bytes(new_secret_key)
255
256 def set_secret_key(self, new_secret_key, replace=False):
257 """
258 Updates internal secret_key used for encryption, with a byte xor
259 :param new_secret_key: string or byte array. It is recommended a 32 byte length
260 :param replace: if True, old value of internal secret_key is ignored and replaced. If false, a byte xor is used
261 :return: None
262 """
263 if replace:
264 self.secret_key = None
265 self.secret_key = self._join_secret_key(new_secret_key)
266
267 def get_secret_key(self):
268 """
269 Get the database secret key in case it is not done when "connect" is called. It can happens when database is
270 empty after an initial install. It should skip if secret is already obtained.
271 """
272 pass
273
274 @staticmethod
275 def pad_data(value: str) -> str:
276 if not isinstance(value, str):
277 raise DbException(
278 f"Incorrect data type: type({value}), string is expected."
279 )
280 return value + ("\0" * ((16 - len(value)) % 16))
281
282 @staticmethod
283 def unpad_data(value: str) -> str:
284 if not isinstance(value, str):
285 raise DbException(
286 f"Incorrect data type: type({value}), string is expected."
287 )
288 return value.rstrip("\0")
289
290 def _encrypt_value(self, value: str, schema_version: str, salt: str):
291 """Encrypt a value.
292
293 Args:
294 value (str): value to be encrypted. It is string/unicode
295 schema_version (str): used for version control. If None or '1.0' no encryption is done.
296 If '1.1' symmetric AES encryption is done
297 salt (str): optional salt to be used. Must be str
298
299 Returns:
300 Encrypted content of value (str)
301
302 """
303 if not self.secret_key or not schema_version or schema_version == "1.0":
304 return value
305
306 else:
307 # Secret key as bytes
308 secret_key = self._join_secret_key(salt)
309 cipher = AES.new(secret_key, self.encrypt_mode)
310 # Padded data as string
311 padded_private_msg = self.pad_data(value)
312 # Padded data as bytes
313 padded_private_msg_bytes = padded_private_msg.encode(self.encoding_type)
314 # Encrypt padded data
315 encrypted_msg = cipher.encrypt(padded_private_msg_bytes)
316 # Base64 encoded encrypted data
317 encoded_encrypted_msg = b64encode(encrypted_msg)
318 # Converting to string
319 return encoded_encrypted_msg.decode(self.encoding_type)
320
321 def encrypt(self, value: str, schema_version: str = None, salt: str = None) -> str:
322 """Encrypt a value.
323
324 Args:
325 value (str): value to be encrypted. It is string/unicode
326 schema_version (str): used for version control. If None or '1.0' no encryption is done.
327 If '1.1' symmetric AES encryption is done
328 salt (str): optional salt to be used. Must be str
329
330 Returns:
331 Encrypted content of value (str)
332
333 """
334 self.get_secret_key()
335 return self._encrypt_value(value, schema_version, salt)
336
337 def _decrypt_value(self, value: str, schema_version: str, salt: str) -> str:
338 """Decrypt an encrypted value.
339 Args:
340
341 value (str): value to be decrypted. It is a base64 string
342 schema_version (str): used for known encryption method used.
343 If None or '1.0' no encryption has been done.
344 If '1.1' symmetric AES encryption has been done
345 salt (str): optional salt to be used
346
347 Returns:
348 Plain content of value (str)
349
350 """
351 if not self.secret_key or not schema_version or schema_version == "1.0":
352 return value
353
354 else:
355 secret_key = self._join_secret_key(salt)
356 # Decoding encrypted data, output bytes
357 encrypted_msg = b64decode(value)
358 cipher = AES.new(secret_key, self.encrypt_mode)
359 # Decrypted data, output bytes
360 decrypted_msg = cipher.decrypt(encrypted_msg)
361 try:
362 # Converting to string
363 private_msg = decrypted_msg.decode(self.encoding_type)
364 except UnicodeDecodeError:
365 raise DbException(
366 "Cannot decrypt information. Are you using same COMMONKEY in all OSM components?",
367 http_code=HTTPStatus.INTERNAL_SERVER_ERROR,
368 )
369 # Unpadded data as string
370 return self.unpad_data(private_msg)
371
372 def decrypt(self, value: str, schema_version: str = None, salt: str = None) -> str:
373 """Decrypt an encrypted value.
374 Args:
375
376 value (str): value to be decrypted. It is a base64 string
377 schema_version (str): used for known encryption method used.
378 If None or '1.0' no encryption has been done.
379 If '1.1' symmetric AES encryption has been done
380 salt (str): optional salt to be used
381
382 Returns:
383 Plain content of value (str)
384
385 """
386 self.get_secret_key()
387 return self._decrypt_value(value, schema_version, salt)
388
389 def encrypt_decrypt_fields(
390 self, item, action, fields=None, flags=None, schema_version=None, salt=None
391 ):
392 if not fields:
393 return
394 self.get_secret_key()
395 actions = ["encrypt", "decrypt"]
396 if action.lower() not in actions:
397 raise DbException(
398 "Unknown action ({}): Must be one of {}".format(action, actions),
399 http_code=HTTPStatus.INTERNAL_SERVER_ERROR,
400 )
401 method = self.encrypt if action.lower() == "encrypt" else self.decrypt
402 if flags is None:
403 flags = re.I
404
405 def process(_item):
406 if isinstance(_item, list):
407 for elem in _item:
408 process(elem)
409 elif isinstance(_item, dict):
410 for key, val in _item.items():
411 if isinstance(val, str):
412 if any(re.search(f, key, flags) for f in fields):
413 _item[key] = method(val, schema_version, salt)
414 else:
415 process(val)
416
417 process(item)
418
419
420 def deep_update_rfc7396(dict_to_change, dict_reference, key_list=None):
421 """
422 Modifies one dictionary with the information of the other following https://tools.ietf.org/html/rfc7396
423 Basically is a recursive python 'dict_to_change.update(dict_reference)', but a value of None is used to delete.
424 It implements an extra feature that allows modifying an array. RFC7396 only allows replacing the entire array.
425 For that, dict_reference should contains a dict with keys starting by "$" with the following meaning:
426 $[index] <index> is an integer for targeting a concrete index from dict_to_change array. If the value is None
427 the element of the array is deleted, otherwise it is edited.
428 $+[index] The value is inserted at this <index>. A value of None has not sense and an exception is raised.
429 $+ The value is appended at the end. A value of None has not sense and an exception is raised.
430 $val It looks for all the items in the array dict_to_change equal to <val>. <val> is evaluated as yaml,
431 that is, numbers are taken as type int, true/false as boolean, etc. Use quotes to force string.
432 Nothing happens if no match is found. If the value is None the matched elements are deleted.
433 $key: val In case a dictionary is passed in yaml format, if looks for all items in the array dict_to_change
434 that are dictionaries and contains this <key> equal to <val>. Several keys can be used by yaml
435 format '{key: val, key: val, ...}'; and all of them must match. Nothing happens if no match is
436 found. If value is None the matched items are deleted, otherwise they are edited.
437 $+val If no match if found (see '$val'), the value is appended to the array. If any match is found nothing
438 is changed. A value of None has not sense.
439 $+key: val If no match if found (see '$key: val'), the value is appended to the array. If any match is found
440 nothing is changed. A value of None has not sense.
441 If there are several editions, insertions and deletions; editions and deletions are done first in reverse index
442 order; then insertions also in reverse index order; and finally appends in any order. So indexes used at
443 insertions must take into account the deleted items.
444 :param dict_to_change: Target dictionary to be changed.
445 :param dict_reference: Dictionary that contains changes to be applied.
446 :param key_list: This is used internally for recursive calls. Do not fill this parameter.
447 :return: none or raises and exception only at array modification when there is a bad format or conflict.
448 """
449
450 def _deep_update_array(array_to_change, _dict_reference, _key_list):
451 to_append = {}
452 to_insert_at_index = {}
453 values_to_edit_delete = {}
454 indexes_to_edit_delete = []
455 array_edition = None
456 _key_list.append("")
457 for k in _dict_reference:
458 _key_list[-1] = str(k)
459 if not isinstance(k, str) or not k.startswith("$"):
460 if array_edition is True:
461 raise DbException(
462 "Found array edition (keys starting with '$') and pure dictionary edition in the"
463 " same dict at '{}'".format(":".join(_key_list[:-1]))
464 )
465 array_edition = False
466 continue
467 else:
468 if array_edition is False:
469 raise DbException(
470 "Found array edition (keys starting with '$') and pure dictionary edition in the"
471 " same dict at '{}'".format(":".join(_key_list[:-1]))
472 )
473 array_edition = True
474 insert = False
475 indexes = [] # indexes to edit or insert
476 kitem = k[1:]
477 if kitem.startswith("+"):
478 insert = True
479 kitem = kitem[1:]
480 if _dict_reference[k] is None:
481 raise DbException(
482 "A value of None has not sense for insertions at '{}'".format(
483 ":".join(_key_list)
484 )
485 )
486
487 if kitem.startswith("[") and kitem.endswith("]"):
488 try:
489 index = int(kitem[1:-1])
490 if index < 0:
491 index += len(array_to_change)
492 if index < 0:
493 index = 0 # skip outside index edition
494 indexes.append(index)
495 except Exception:
496 raise DbException(
497 "Wrong format at '{}'. Expecting integer index inside quotes".format(
498 ":".join(_key_list)
499 )
500 )
501 elif kitem:
502 # match_found_skip = False
503 try:
504 filter_in = yaml.safe_load(kitem)
505 except Exception:
506 raise DbException(
507 "Wrong format at '{}'. Expecting '$<yaml-format>'".format(
508 ":".join(_key_list)
509 )
510 )
511 if isinstance(filter_in, dict):
512 for index, item in enumerate(array_to_change):
513 for filter_k, filter_v in filter_in.items():
514 if (
515 not isinstance(item, dict)
516 or filter_k not in item
517 or item[filter_k] != filter_v
518 ):
519 break
520 else: # match found
521 if insert:
522 # match_found_skip = True
523 insert = False
524 break
525 else:
526 indexes.append(index)
527 else:
528 index = 0
529 try:
530 while True: # if not match a ValueError exception will be raise
531 index = array_to_change.index(filter_in, index)
532 if insert:
533 # match_found_skip = True
534 insert = False
535 break
536 indexes.append(index)
537 index += 1
538 except ValueError:
539 pass
540
541 # if match_found_skip:
542 # continue
543 elif not insert:
544 raise DbException(
545 "Wrong format at '{}'. Expecting '$+', '$[<index]' or '$[<filter>]'".format(
546 ":".join(_key_list)
547 )
548 )
549 for index in indexes:
550 if insert:
551 if (
552 index in to_insert_at_index
553 and to_insert_at_index[index] != _dict_reference[k]
554 ):
555 # Several different insertions on the same item of the array
556 raise DbException(
557 "Conflict at '{}'. Several insertions on same array index {}".format(
558 ":".join(_key_list), index
559 )
560 )
561 to_insert_at_index[index] = _dict_reference[k]
562 else:
563 if (
564 index in indexes_to_edit_delete
565 and values_to_edit_delete[index] != _dict_reference[k]
566 ):
567 # Several different editions on the same item of the array
568 raise DbException(
569 "Conflict at '{}'. Several editions on array index {}".format(
570 ":".join(_key_list), index
571 )
572 )
573 indexes_to_edit_delete.append(index)
574 values_to_edit_delete[index] = _dict_reference[k]
575 if not indexes:
576 if insert:
577 to_append[k] = _dict_reference[k]
578 # elif _dict_reference[k] is not None:
579 # raise DbException("Not found any match to edit in the array, or wrong format at '{}'".format(
580 # ":".join(_key_list)))
581
582 # edition/deletion is done before insertion
583 indexes_to_edit_delete.sort(reverse=True)
584 for index in indexes_to_edit_delete:
585 _key_list[-1] = str(index)
586 try:
587 if values_to_edit_delete[index] is None: # None->Anything
588 try:
589 del array_to_change[index]
590 except IndexError:
591 pass # it is not consider an error if this index does not exist
592 elif not isinstance(
593 values_to_edit_delete[index], dict
594 ): # NotDict->Anything
595 array_to_change[index] = deepcopy(values_to_edit_delete[index])
596 elif isinstance(array_to_change[index], dict): # Dict->Dict
597 deep_update_rfc7396(
598 array_to_change[index], values_to_edit_delete[index], _key_list
599 )
600 else: # Dict->NotDict
601 if isinstance(
602 array_to_change[index], list
603 ): # Dict->List. Check extra array edition
604 if _deep_update_array(
605 array_to_change[index],
606 values_to_edit_delete[index],
607 _key_list,
608 ):
609 continue
610 array_to_change[index] = deepcopy(values_to_edit_delete[index])
611 # calling deep_update_rfc7396 to delete the None values
612 deep_update_rfc7396(
613 array_to_change[index], values_to_edit_delete[index], _key_list
614 )
615 except IndexError:
616 raise DbException(
617 "Array edition index out of range at '{}'".format(
618 ":".join(_key_list)
619 )
620 )
621
622 # insertion with indexes
623 to_insert_indexes = list(to_insert_at_index.keys())
624 to_insert_indexes.sort(reverse=True)
625 for index in to_insert_indexes:
626 array_to_change.insert(index, to_insert_at_index[index])
627
628 # append
629 for k, insert_value in to_append.items():
630 _key_list[-1] = str(k)
631 insert_value_copy = deepcopy(insert_value)
632 if isinstance(insert_value_copy, dict):
633 # calling deep_update_rfc7396 to delete the None values
634 deep_update_rfc7396(insert_value_copy, insert_value, _key_list)
635 array_to_change.append(insert_value_copy)
636
637 _key_list.pop()
638 if array_edition:
639 return True
640 return False
641
642 if key_list is None:
643 key_list = []
644 key_list.append("")
645 for k in dict_reference:
646 key_list[-1] = str(k)
647 if dict_reference[k] is None: # None->Anything
648 if k in dict_to_change:
649 del dict_to_change[k]
650 elif not isinstance(dict_reference[k], dict): # NotDict->Anything
651 dict_to_change[k] = deepcopy(dict_reference[k])
652 elif k not in dict_to_change: # Dict->Empty
653 dict_to_change[k] = deepcopy(dict_reference[k])
654 # calling deep_update_rfc7396 to delete the None values
655 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
656 elif isinstance(dict_to_change[k], dict): # Dict->Dict
657 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
658 else: # Dict->NotDict
659 if isinstance(
660 dict_to_change[k], list
661 ): # Dict->List. Check extra array edition
662 if _deep_update_array(dict_to_change[k], dict_reference[k], key_list):
663 continue
664 dict_to_change[k] = deepcopy(dict_reference[k])
665 # calling deep_update_rfc7396 to delete the None values
666 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
667 key_list.pop()
668
669
670 def deep_update(dict_to_change, dict_reference):
671 """Maintained for backward compatibility. Use deep_update_rfc7396 instead"""
672 return deep_update_rfc7396(dict_to_change, dict_reference)
673
674
675 class Encryption(DbBase):
676 def __init__(self, uri, config, encoding_type="ascii", loop=None, logger_name="db"):
677 """Constructor.
678
679 Args:
680 uri (str): Connection string to connect to the database.
681 config (dict): Additional database info
682 encoding_type (str): ascii, utf-8 etc.
683 loop (object): Asyncio Loop
684 logger_name (str): Logger name
685
686 """
687 self.loop = loop or asyncio.get_event_loop()
688 self._secret_key = None # 32 bytes length array used for encrypt/decrypt
689 self.encrypt_mode = AES.MODE_ECB
690 super(Encryption, self).__init__(
691 encoding_type=encoding_type, logger_name=logger_name
692 )
693 self._client = AsyncIOMotorClient(uri)
694 self._config = config
695
696 @property
697 def secret_key(self):
698 return self._secret_key
699
700 @secret_key.setter
701 def secret_key(self, value):
702 self._secret_key = value
703
704 @property
705 def _database(self):
706 return self._client[DB_NAME]
707
708 @property
709 def _admin_collection(self):
710 return self._database["admin"]
711
712 @property
713 def database_key(self):
714 return self._config.get("database_commonkey")
715
716 async def decrypt_fields(
717 self,
718 item: dict,
719 fields: typing.List[str],
720 schema_version: str = None,
721 salt: str = None,
722 ) -> None:
723 """Decrypt fields from a dictionary. Follows the same logic as in osm_common.
724
725 Args:
726
727 item (dict): Dictionary with the keys to be decrypted
728 fields (list): List of keys to decrypt
729 schema version (str): Schema version. (i.e. 1.11)
730 salt (str): Salt for the decryption
731
732 """
733 flags = re.I
734
735 async def process(_item):
736 if isinstance(_item, list):
737 for elem in _item:
738 await process(elem)
739 elif isinstance(_item, dict):
740 for key, val in _item.items():
741 if isinstance(val, str):
742 if any(re.search(f, key, flags) for f in fields):
743 _item[key] = await self.decrypt(val, schema_version, salt)
744 else:
745 await process(val)
746
747 await process(item)
748
749 async def encrypt(
750 self, value: str, schema_version: str = None, salt: str = None
751 ) -> str:
752 """Encrypt a value.
753
754 Args:
755 value (str): value to be encrypted. It is string/unicode
756 schema_version (str): used for version control. If None or '1.0' no encryption is done.
757 If '1.1' symmetric AES encryption is done
758 salt (str): optional salt to be used. Must be str
759
760 Returns:
761 Encrypted content of value (str)
762
763 """
764 await self.get_secret_key()
765 return self._encrypt_value(value, schema_version, salt)
766
767 async def decrypt(
768 self, value: str, schema_version: str = None, salt: str = None
769 ) -> str:
770 """Decrypt an encrypted value.
771 Args:
772
773 value (str): value to be decrypted. It is a base64 string
774 schema_version (str): used for known encryption method used.
775 If None or '1.0' no encryption has been done.
776 If '1.1' symmetric AES encryption has been done
777 salt (str): optional salt to be used
778
779 Returns:
780 Plain content of value (str)
781
782 """
783 await self.get_secret_key()
784 return self._decrypt_value(value, schema_version, salt)
785
786 def _join_secret_key(self, update_key: typing.Any) -> bytes:
787 """Join key with secret key.
788
789 Args:
790
791 update_key (str or bytes): str or bytes with the to update
792
793 Returns:
794
795 Joined key (bytes)
796 """
797 return self._join_keys(update_key, self.secret_key)
798
799 def _join_keys(self, key: typing.Any, secret_key: bytes) -> bytes:
800 """Join key with secret_key.
801
802 Args:
803
804 key (str or bytes): str or bytes of the key to update
805 secret_key (bytes): bytes of the secret key
806
807 Returns:
808
809 Joined key (bytes)
810 """
811 if isinstance(key, str):
812 update_key_bytes = key.encode(self.encoding_type)
813 else:
814 update_key_bytes = key
815 new_secret_key = bytearray(secret_key) if secret_key else bytearray(32)
816 for i, b in enumerate(update_key_bytes):
817 new_secret_key[i % 32] ^= b
818 return bytes(new_secret_key)
819
820 async def get_secret_key(self):
821 """Get secret key using the database key and the serial key in the DB.
822 The key is populated in the property self.secret_key.
823 """
824 if self.secret_key:
825 return
826 secret_key = None
827 if self.database_key:
828 secret_key = self._join_keys(self.database_key, None)
829 version_data = await self._admin_collection.find_one({"_id": "version"})
830 if version_data and version_data.get("serial"):
831 secret_key = self._join_keys(b64decode(version_data["serial"]), secret_key)
832 self._secret_key = secret_key