1 # -*- coding: utf-8 -*-
3 # Copyright 2018 Telefonica S.A.
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
9 # http://www.apache.org/licenses/LICENSE-2.0
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
15 # See the License for the specific language governing permissions and
16 # limitations under the License.
18 from base64
import b64decode
, b64encode
19 from copy
import deepcopy
20 from http
import HTTPStatus
23 from threading
import Lock
27 from Crypto
.Cipher
import AES
28 from motor
.motor_asyncio
import AsyncIOMotorClient
29 from osm_common
.common_utils
import FakeLock
32 __author__
= "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
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
))
45 def __init__(self
, encoding_type
="ascii", logger_name
="db", lock
=False):
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
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
59 self
.lock
= FakeLock()
62 elif isinstance(lock
, Lock
):
65 raise ValueError("lock parameter must be a Lock classclass or boolean")
67 def db_connect(self
, config
, target_version
=None):
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
81 raise DbException("Method 'db_connect' not implemented")
83 def db_disconnect(self
):
85 Disconnect from database
90 def get_list(self
, table
, q_filter
=None):
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
97 raise DbException("Method 'get_list' not implemented")
99 def count(self
, table
, q_filter
=None):
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
107 raise DbException("Method 'count' not implemented")
109 def get_one(self
, table
, q_filter
=None, fail_on_empty
=True, fail_on_more
=True):
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
120 raise DbException("Method 'get_one' not implemented")
122 def del_list(self
, table
, q_filter
=None):
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
129 raise DbException("Method 'del_list' not implemented")
131 def del_one(self
, table
, q_filter
=None, fail_on_empty
=True):
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
140 raise DbException("Method 'del_one' not implemented")
142 def create(self
, table
, indata
):
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
149 raise DbException("Method 'create' not implemented")
151 def create_list(self
, table
, indata_list
):
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
159 raise DbException("Method 'create_list' not implemented")
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
189 :return: Dict with the number of entries modified. None if no matching is found.
191 raise DbException("Method 'set_one' not implemented")
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
218 :return: Dict with the number of entries modified
220 raise DbException("Method 'set_list' not implemented")
222 def replace(self
, table
, _id
, indata
, fail_on_empty
=True):
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
232 raise DbException("Method 'replace' not implemented")
234 def _join_secret_key(self
, update_key
):
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
243 return self
.secret_key
244 elif isinstance(update_key
, str):
245 update_key_bytes
= update_key
.encode()
247 update_key_bytes
= update_key
250 bytearray(self
.secret_key
) if self
.secret_key
else bytearray(32)
252 for i
, b
in enumerate(update_key_bytes
):
253 new_secret_key
[i
% 32] ^
= b
254 return bytes(new_secret_key
)
256 def set_secret_key(self
, new_secret_key
, replace
=False):
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
264 self
.secret_key
= None
265 self
.secret_key
= self
._join
_secret
_key
(new_secret_key
)
267 def get_secret_key(self
):
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.
275 def pad_data(value
: str) -> str:
276 if not isinstance(value
, str):
278 f
"Incorrect data type: type({value}), string is expected."
280 return value
+ ("\0" * ((16 - len(value
)) % 16))
283 def unpad_data(value
: str) -> str:
284 if not isinstance(value
, str):
286 f
"Incorrect data type: type({value}), string is expected."
288 return value
.rstrip("\0")
290 def _encrypt_value(self
, value
: str, schema_version
: str, salt
: str):
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
300 Encrypted content of value (str)
303 if not self
.secret_key
or not schema_version
or schema_version
== "1.0":
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
)
321 def encrypt(self
, value
: str, schema_version
: str = None, salt
: str = None) -> str:
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
331 Encrypted content of value (str)
334 self
.get_secret_key()
335 return self
._encrypt
_value
(value
, schema_version
, salt
)
337 def _decrypt_value(self
, value
: str, schema_version
: str, salt
: str) -> str:
338 """Decrypt an encrypted value.
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
348 Plain content of value (str)
351 if not self
.secret_key
or not schema_version
or schema_version
== "1.0":
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
)
362 # Converting to string
363 private_msg
= decrypted_msg
.decode(self
.encoding_type
)
364 except UnicodeDecodeError:
366 "Cannot decrypt information. Are you using same COMMONKEY in all OSM components?",
367 http_code
=HTTPStatus
.INTERNAL_SERVER_ERROR
,
369 # Unpadded data as string
370 return self
.unpad_data(private_msg
)
372 def decrypt(self
, value
: str, schema_version
: str = None, salt
: str = None) -> str:
373 """Decrypt an encrypted value.
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
383 Plain content of value (str)
386 self
.get_secret_key()
387 return self
._decrypt
_value
(value
, schema_version
, salt
)
389 def encrypt_decrypt_fields(
390 self
, item
, action
, fields
=None, flags
=None, schema_version
=None, salt
=None
394 self
.get_secret_key()
395 actions
= ["encrypt", "decrypt"]
396 if action
.lower() not in actions
:
398 "Unknown action ({}): Must be one of {}".format(action
, actions
),
399 http_code
=HTTPStatus
.INTERNAL_SERVER_ERROR
,
401 method
= self
.encrypt
if action
.lower() == "encrypt" else self
.decrypt
406 if isinstance(_item
, list):
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
)
420 def deep_update_rfc7396(dict_to_change
, dict_reference
, key_list
=None):
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.
450 def _deep_update_array(array_to_change
, _dict_reference
, _key_list
):
452 to_insert_at_index
= {}
453 values_to_edit_delete
= {}
454 indexes_to_edit_delete
= []
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:
462 "Found array edition (keys starting with '$') and pure dictionary edition in the"
463 " same dict at '{}'".format(":".join(_key_list
[:-1]))
465 array_edition
= False
468 if array_edition
is False:
470 "Found array edition (keys starting with '$') and pure dictionary edition in the"
471 " same dict at '{}'".format(":".join(_key_list
[:-1]))
475 indexes
= [] # indexes to edit or insert
477 if kitem
.startswith("+"):
480 if _dict_reference
[k
] is None:
482 "A value of None has not sense for insertions at '{}'".format(
487 if kitem
.startswith("[") and kitem
.endswith("]"):
489 index
= int(kitem
[1:-1])
491 index
+= len(array_to_change
)
493 index
= 0 # skip outside index edition
494 indexes
.append(index
)
497 "Wrong format at '{}'. Expecting integer index inside quotes".format(
502 # match_found_skip = False
504 filter_in
= yaml
.safe_load(kitem
)
507 "Wrong format at '{}'. Expecting '$<yaml-format>'".format(
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():
515 not isinstance(item
, dict)
516 or filter_k
not in item
517 or item
[filter_k
] != filter_v
522 # match_found_skip = True
526 indexes
.append(index
)
530 while True: # if not match a ValueError exception will be raise
531 index
= array_to_change
.index(filter_in
, index
)
533 # match_found_skip = True
536 indexes
.append(index
)
541 # if match_found_skip:
545 "Wrong format at '{}'. Expecting '$+', '$[<index]' or '$[<filter>]'".format(
549 for index
in indexes
:
552 index
in to_insert_at_index
553 and to_insert_at_index
[index
] != _dict_reference
[k
]
555 # Several different insertions on the same item of the array
557 "Conflict at '{}'. Several insertions on same array index {}".format(
558 ":".join(_key_list
), index
561 to_insert_at_index
[index
] = _dict_reference
[k
]
564 index
in indexes_to_edit_delete
565 and values_to_edit_delete
[index
] != _dict_reference
[k
]
567 # Several different editions on the same item of the array
569 "Conflict at '{}'. Several editions on array index {}".format(
570 ":".join(_key_list
), index
573 indexes_to_edit_delete
.append(index
)
574 values_to_edit_delete
[index
] = _dict_reference
[k
]
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)))
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
)
587 if values_to_edit_delete
[index
] is None: # None->Anything
589 del array_to_change
[index
]
591 pass # it is not consider an error if this index does not exist
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
598 array_to_change
[index
], values_to_edit_delete
[index
], _key_list
600 else: # Dict->NotDict
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
],
610 array_to_change
[index
] = deepcopy(values_to_edit_delete
[index
])
611 # calling deep_update_rfc7396 to delete the None values
613 array_to_change
[index
], values_to_edit_delete
[index
], _key_list
617 "Array edition index out of range at '{}'".format(
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
])
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
)
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
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
):
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
)
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
)
675 class Encryption(DbBase
):
676 def __init__(self
, uri
, config
, encoding_type
="ascii", logger_name
="db"):
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 logger_name (str): Logger name
686 self
._secret
_key
= None # 32 bytes length array used for encrypt/decrypt
687 self
.encrypt_mode
= AES
.MODE_ECB
688 super(Encryption
, self
).__init
__(
689 encoding_type
=encoding_type
, logger_name
=logger_name
691 self
._client
= AsyncIOMotorClient(uri
)
692 self
._config
= config
695 def secret_key(self
):
696 return self
._secret
_key
699 def secret_key(self
, value
):
700 self
._secret
_key
= value
704 return self
._client
[DB_NAME
]
707 def _admin_collection(self
):
708 return self
._database
["admin"]
711 def database_key(self
):
712 return self
._config
.get("database_commonkey")
714 async def decrypt_fields(
717 fields
: typing
.List
[str],
718 schema_version
: str = None,
721 """Decrypt fields from a dictionary. Follows the same logic as in osm_common.
725 item (dict): Dictionary with the keys to be decrypted
726 fields (list): List of keys to decrypt
727 schema version (str): Schema version. (i.e. 1.11)
728 salt (str): Salt for the decryption
733 async def process(_item
):
734 if isinstance(_item
, list):
737 elif isinstance(_item
, dict):
738 for key
, val
in _item
.items():
739 if isinstance(val
, str):
740 if any(re
.search(f
, key
, flags
) for f
in fields
):
741 _item
[key
] = await self
.decrypt(val
, schema_version
, salt
)
748 self
, value
: str, schema_version
: str = None, salt
: str = None
753 value (str): value to be encrypted. It is string/unicode
754 schema_version (str): used for version control. If None or '1.0' no encryption is done.
755 If '1.1' symmetric AES encryption is done
756 salt (str): optional salt to be used. Must be str
759 Encrypted content of value (str)
762 await self
.get_secret_key()
763 return self
._encrypt
_value
(value
, schema_version
, salt
)
766 self
, value
: str, schema_version
: str = None, salt
: str = None
768 """Decrypt an encrypted value.
771 value (str): value to be decrypted. It is a base64 string
772 schema_version (str): used for known encryption method used.
773 If None or '1.0' no encryption has been done.
774 If '1.1' symmetric AES encryption has been done
775 salt (str): optional salt to be used
778 Plain content of value (str)
781 await self
.get_secret_key()
782 return self
._decrypt
_value
(value
, schema_version
, salt
)
784 def _join_secret_key(self
, update_key
: typing
.Any
) -> bytes
:
785 """Join key with secret key.
789 update_key (str or bytes): str or bytes with the to update
795 return self
._join
_keys
(update_key
, self
.secret_key
)
797 def _join_keys(self
, key
: typing
.Any
, secret_key
: bytes
) -> bytes
:
798 """Join key with secret_key.
802 key (str or bytes): str or bytes of the key to update
803 secret_key (bytes): bytes of the secret key
809 if isinstance(key
, str):
810 update_key_bytes
= key
.encode(self
.encoding_type
)
812 update_key_bytes
= key
813 new_secret_key
= bytearray(secret_key
) if secret_key
else bytearray(32)
814 for i
, b
in enumerate(update_key_bytes
):
815 new_secret_key
[i
% 32] ^
= b
816 return bytes(new_secret_key
)
818 async def get_secret_key(self
):
819 """Get secret key using the database key and the serial key in the DB.
820 The key is populated in the property self.secret_key.
825 if self
.database_key
:
826 secret_key
= self
._join
_keys
(self
.database_key
, None)
827 version_data
= await self
._admin
_collection
.find_one({"_id": "version"})
828 if version_data
and version_data
.get("serial"):
829 secret_key
= self
._join
_keys
(b64decode(version_data
["serial"]), secret_key
)
830 self
._secret
_key
= secret_key