forked from p15670423/monkey
Merge pull request #1481 from guardicore/1471/merge-encryptions
Refactor encryptors
This commit is contained in:
commit
8b7cb9c0b1
1
.flake8
1
.flake8
|
@ -5,6 +5,7 @@ exclude = monkey/monkey_island/cc/ui,vulture_allowlist.py
|
||||||
show-source = True
|
show-source = True
|
||||||
max-complexity = 10
|
max-complexity = 10
|
||||||
max-line-length = 100
|
max-line-length = 100
|
||||||
|
per-file-ignores = __init__.py:F401
|
||||||
|
|
||||||
### ignore "whitespace before ':'", "line break before binary operator" for
|
### ignore "whitespace before ':'", "line break before binary operator" for
|
||||||
### compatibility with black, and cyclomatic complexity (for now).
|
### compatibility with black, and cyclomatic complexity (for now).
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from monkey_island.cc.models.utils.field_encryptors.i_field_encryptor import IFieldEncryptor
|
from monkey_island.cc.models.utils.field_encryptors.i_field_encryptor import IFieldEncryptor
|
||||||
from monkey_island.cc.server_utils.encryptor import get_encryptor
|
from monkey_island.cc.server_utils.encryption import get_datastore_encryptor
|
||||||
|
|
||||||
|
|
||||||
class StringListEncryptor(IFieldEncryptor):
|
class StringListEncryptor(IFieldEncryptor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encrypt(value: List[str]):
|
def encrypt(value: List[str]):
|
||||||
return [get_encryptor().enc(string) for string in value]
|
return [get_datastore_encryptor().enc(string) for string in value]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decrypt(value: List[str]):
|
def decrypt(value: List[str]):
|
||||||
return [get_encryptor().dec(string) for string in value]
|
return [get_datastore_encryptor().dec(string) for string in value]
|
||||||
|
|
|
@ -4,8 +4,8 @@ import flask_restful
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from monkey_island.cc.resources.auth.auth import jwt_required
|
from monkey_island.cc.resources.auth.auth import jwt_required
|
||||||
|
from monkey_island.cc.server_utils.encryption import PasswordBasedEncryptor
|
||||||
from monkey_island.cc.services.config import ConfigService
|
from monkey_island.cc.services.config import ConfigService
|
||||||
from monkey_island.cc.services.utils.encryption import encrypt_string
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationExport(flask_restful.Resource):
|
class ConfigurationExport(flask_restful.Resource):
|
||||||
|
@ -20,6 +20,8 @@ class ConfigurationExport(flask_restful.Resource):
|
||||||
if should_encrypt:
|
if should_encrypt:
|
||||||
password = data["password"]
|
password = data["password"]
|
||||||
plaintext_config = json.dumps(plaintext_config)
|
plaintext_config = json.dumps(plaintext_config)
|
||||||
config_export = encrypt_string(plaintext_config, password)
|
|
||||||
|
pb_encryptor = PasswordBasedEncryptor(password)
|
||||||
|
config_export = pb_encryptor.encrypt(plaintext_config)
|
||||||
|
|
||||||
return {"config_export": config_export, "encrypted": should_encrypt}
|
return {"config_export": config_export, "encrypted": should_encrypt}
|
||||||
|
|
|
@ -8,13 +8,13 @@ from flask import request
|
||||||
|
|
||||||
from common.utils.exceptions import InvalidConfigurationError
|
from common.utils.exceptions import InvalidConfigurationError
|
||||||
from monkey_island.cc.resources.auth.auth import jwt_required
|
from monkey_island.cc.resources.auth.auth import jwt_required
|
||||||
from monkey_island.cc.services.config import ConfigService
|
from monkey_island.cc.server_utils.encryption import (
|
||||||
from monkey_island.cc.services.utils.encryption import (
|
|
||||||
InvalidCiphertextError,
|
InvalidCiphertextError,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
decrypt_ciphertext,
|
PasswordBasedEncryptor,
|
||||||
is_encrypted,
|
is_encrypted,
|
||||||
)
|
)
|
||||||
|
from monkey_island.cc.services.config import ConfigService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -72,7 +72,8 @@ class ConfigurationImport(flask_restful.Resource):
|
||||||
try:
|
try:
|
||||||
config = request_contents["config"]
|
config = request_contents["config"]
|
||||||
if ConfigurationImport.is_config_encrypted(request_contents["config"]):
|
if ConfigurationImport.is_config_encrypted(request_contents["config"]):
|
||||||
config = decrypt_ciphertext(config, request_contents["password"])
|
pb_encryptor = PasswordBasedEncryptor(request_contents["password"])
|
||||||
|
config = pb_encryptor.decrypt(config)
|
||||||
return json.loads(config)
|
return json.loads(config)
|
||||||
except (JSONDecodeError, InvalidCiphertextError):
|
except (JSONDecodeError, InvalidCiphertextError):
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|
|
@ -27,7 +27,7 @@ from monkey_island.cc.server_utils.consts import ( # noqa: E402
|
||||||
GEVENT_EXCEPTION_LOG,
|
GEVENT_EXCEPTION_LOG,
|
||||||
MONGO_CONNECTION_TIMEOUT,
|
MONGO_CONNECTION_TIMEOUT,
|
||||||
)
|
)
|
||||||
from monkey_island.cc.server_utils.encryptor import initialize_encryptor # noqa: E402
|
from monkey_island.cc.server_utils.encryption import initialize_datastore_encryptor # noqa: E402
|
||||||
from monkey_island.cc.server_utils.island_logger import reset_logger, setup_logging # noqa: E402
|
from monkey_island.cc.server_utils.island_logger import reset_logger, setup_logging # noqa: E402
|
||||||
from monkey_island.cc.services.initialize import initialize_services # noqa: E402
|
from monkey_island.cc.services.initialize import initialize_services # noqa: E402
|
||||||
from monkey_island.cc.services.reporting.exporter_init import populate_exporter_list # noqa: E402
|
from monkey_island.cc.services.reporting.exporter_init import populate_exporter_list # noqa: E402
|
||||||
|
@ -88,7 +88,7 @@ def _configure_logging(config_options):
|
||||||
def _initialize_globals(config_options: IslandConfigOptions, server_config_path: str):
|
def _initialize_globals(config_options: IslandConfigOptions, server_config_path: str):
|
||||||
env_singleton.initialize_from_file(server_config_path)
|
env_singleton.initialize_from_file(server_config_path)
|
||||||
|
|
||||||
initialize_encryptor(config_options.data_dir)
|
initialize_datastore_encryptor(config_options.data_dir)
|
||||||
initialize_services(config_options.data_dir)
|
initialize_services(config_options.data_dir)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
from monkey_island.cc.server_utils.encryption.i_encryptor import IEncryptor
|
||||||
|
from monkey_island.cc.server_utils.encryption.key_based_encryptor import KeyBasedEncryptor
|
||||||
|
from monkey_island.cc.server_utils.encryption.password_based_encryption import (
|
||||||
|
InvalidCiphertextError,
|
||||||
|
InvalidCredentialsError,
|
||||||
|
PasswordBasedEncryptor,
|
||||||
|
is_encrypted,
|
||||||
|
)
|
||||||
|
from monkey_island.cc.server_utils.encryption.data_store_encryptor import (
|
||||||
|
DataStoreEncryptor,
|
||||||
|
get_datastore_encryptor,
|
||||||
|
initialize_datastore_encryptor,
|
||||||
|
)
|
|
@ -0,0 +1,50 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
# PyCrypto is deprecated, but we use pycryptodome, which uses the exact same imports but
|
||||||
|
# is maintained.
|
||||||
|
from Crypto import Random # noqa: DUO133 # nosec: B413
|
||||||
|
|
||||||
|
from monkey_island.cc.server_utils.encryption import KeyBasedEncryptor
|
||||||
|
from monkey_island.cc.server_utils.file_utils import open_new_securely_permissioned_file
|
||||||
|
|
||||||
|
_encryptor = None
|
||||||
|
|
||||||
|
|
||||||
|
class DataStoreEncryptor:
|
||||||
|
_BLOCK_SIZE = 32
|
||||||
|
_KEY_FILENAME = "mongo_key.bin"
|
||||||
|
|
||||||
|
def __init__(self, key_file_dir):
|
||||||
|
key_file = os.path.join(key_file_dir, self._KEY_FILENAME)
|
||||||
|
|
||||||
|
if os.path.exists(key_file):
|
||||||
|
self._load_existing_key(key_file)
|
||||||
|
else:
|
||||||
|
self._init_key(key_file)
|
||||||
|
|
||||||
|
self._key_base_encryptor = KeyBasedEncryptor(self._cipher_key)
|
||||||
|
|
||||||
|
def _init_key(self, password_file_path: str):
|
||||||
|
self._cipher_key = Random.new().read(self._BLOCK_SIZE)
|
||||||
|
with open_new_securely_permissioned_file(password_file_path, "wb") as f:
|
||||||
|
f.write(self._cipher_key)
|
||||||
|
|
||||||
|
def _load_existing_key(self, key_file):
|
||||||
|
with open(key_file, "rb") as f:
|
||||||
|
self._cipher_key = f.read()
|
||||||
|
|
||||||
|
def enc(self, message: str):
|
||||||
|
return self._key_base_encryptor.encrypt(message)
|
||||||
|
|
||||||
|
def dec(self, enc_message: str):
|
||||||
|
return self._key_base_encryptor.decrypt(enc_message)
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_datastore_encryptor(key_file_dir):
|
||||||
|
global _encryptor
|
||||||
|
|
||||||
|
_encryptor = DataStoreEncryptor(key_file_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def get_datastore_encryptor():
|
||||||
|
return _encryptor
|
|
@ -0,0 +1,20 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class IEncryptor(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def encrypt(self, plaintext: Any) -> Any:
|
||||||
|
"""Encrypts data and returns the ciphertext.
|
||||||
|
:param plaintext: Data that will be encrypted
|
||||||
|
:return: Ciphertext generated by encrypting value
|
||||||
|
:rtype: Any
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def decrypt(self, ciphertext: Any):
|
||||||
|
"""Decrypts data and returns the plaintext.
|
||||||
|
:param ciphertext: Ciphertext that will be decrypted
|
||||||
|
:return: Plaintext generated by decrypting value
|
||||||
|
:rtype: Any
|
||||||
|
"""
|
|
@ -0,0 +1,47 @@
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# PyCrypto is deprecated, but we use pycryptodome, which uses the exact same imports but
|
||||||
|
# is maintained.
|
||||||
|
from Crypto import Random # noqa: DUO133 # nosec: B413
|
||||||
|
from Crypto.Cipher import AES # noqa: DUO133 # nosec: B413
|
||||||
|
|
||||||
|
from monkey_island.cc.server_utils.encryption import IEncryptor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# KeyBasedEncryptor is an encryption method which use random key of specific length
|
||||||
|
# and AES block cipher to encrypt/decrypt the data. The key is more complex
|
||||||
|
# one and hard to remember than user provided one. This class provides more secure way of
|
||||||
|
# encryption compared to PasswordBasedEncryptor because of the random and complex key.
|
||||||
|
# We can merge the two into the one encryption method but then we lose the entropy
|
||||||
|
# of the key with whatever key derivation function we use.
|
||||||
|
# Note: password != key
|
||||||
|
|
||||||
|
|
||||||
|
class KeyBasedEncryptor(IEncryptor):
|
||||||
|
|
||||||
|
_BLOCK_SIZE = 32
|
||||||
|
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
self._key = key
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str) -> str:
|
||||||
|
cipher_iv = Random.new().read(AES.block_size)
|
||||||
|
cipher = AES.new(self._key, AES.MODE_CBC, cipher_iv)
|
||||||
|
return base64.b64encode(cipher_iv + cipher.encrypt(self._pad(plaintext).encode())).decode()
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: str):
|
||||||
|
enc_message = base64.b64decode(ciphertext)
|
||||||
|
cipher_iv = enc_message[0 : AES.block_size]
|
||||||
|
cipher = AES.new(self._key, AES.MODE_CBC, cipher_iv)
|
||||||
|
return self._unpad(cipher.decrypt(enc_message[AES.block_size :]).decode())
|
||||||
|
|
||||||
|
# TODO: Review and evaluate the security of the padding function
|
||||||
|
def _pad(self, message):
|
||||||
|
return message + (self._BLOCK_SIZE - (len(message) % self._BLOCK_SIZE)) * chr(
|
||||||
|
self._BLOCK_SIZE - (len(message) % self._BLOCK_SIZE)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _unpad(self, message: str):
|
||||||
|
return message[0 : -ord(message[len(message) - 1])]
|
|
@ -0,0 +1,75 @@
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pyAesCrypt
|
||||||
|
|
||||||
|
from monkey_island.cc.server_utils.encryption import IEncryptor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# PasswordBasedEncryptor as implemented takes low-entropy, user provided password and it adds some
|
||||||
|
# entropy to it and encrypts/decrypts the data. This implementation uses AES256-CBC
|
||||||
|
# and it is less secure encryption then KeyBasedEncryptor.
|
||||||
|
# The security of it depends on what will the user provide as password.
|
||||||
|
# We can merge the two into the one encryption method but then we lose the entropy
|
||||||
|
# of the key with whatever key derivation function we use.
|
||||||
|
# Note: password != key
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordBasedEncryptor(IEncryptor):
|
||||||
|
|
||||||
|
_BUFFER_SIZE = pyAesCrypt.crypto.bufferSizeDef
|
||||||
|
|
||||||
|
def __init__(self, password: str):
|
||||||
|
self.password = password
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str) -> str:
|
||||||
|
plaintext_stream = io.BytesIO(plaintext.encode())
|
||||||
|
ciphertext_stream = io.BytesIO()
|
||||||
|
|
||||||
|
pyAesCrypt.encryptStream(
|
||||||
|
plaintext_stream, ciphertext_stream, self.password, self._BUFFER_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
|
ciphertext_b64 = base64.b64encode(ciphertext_stream.getvalue())
|
||||||
|
logger.info("String encrypted.")
|
||||||
|
|
||||||
|
return ciphertext_b64.decode()
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: str):
|
||||||
|
ciphertext = base64.b64decode(ciphertext)
|
||||||
|
ciphertext_stream = io.BytesIO(ciphertext)
|
||||||
|
plaintext_stream = io.BytesIO()
|
||||||
|
|
||||||
|
ciphertext_stream_len = len(ciphertext_stream.getvalue())
|
||||||
|
|
||||||
|
try:
|
||||||
|
pyAesCrypt.decryptStream(
|
||||||
|
ciphertext_stream,
|
||||||
|
plaintext_stream,
|
||||||
|
self.password,
|
||||||
|
self._BUFFER_SIZE,
|
||||||
|
ciphertext_stream_len,
|
||||||
|
)
|
||||||
|
except ValueError as ex:
|
||||||
|
if str(ex).startswith("Wrong password"):
|
||||||
|
logger.info("Wrong password provided for decryption.")
|
||||||
|
raise InvalidCredentialsError
|
||||||
|
else:
|
||||||
|
logger.info("The corrupt ciphertext provided.")
|
||||||
|
raise InvalidCiphertextError
|
||||||
|
return plaintext_stream.getvalue().decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCredentialsError(Exception):
|
||||||
|
""" Raised when password for decryption is invalid """
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCiphertextError(Exception):
|
||||||
|
""" Raised when ciphertext is corrupted """
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted(ciphertext: str) -> bool:
|
||||||
|
ciphertext = base64.b64decode(ciphertext)
|
||||||
|
return ciphertext.startswith(b"AES")
|
|
@ -1,62 +0,0 @@
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
# PyCrypto is deprecated, but we use pycryptodome, which uses the exact same imports but
|
|
||||||
# is maintained.
|
|
||||||
from Crypto import Random # noqa: DUO133 # nosec: B413
|
|
||||||
from Crypto.Cipher import AES # noqa: DUO133 # nosec: B413
|
|
||||||
|
|
||||||
from monkey_island.cc.server_utils.file_utils import open_new_securely_permissioned_file
|
|
||||||
|
|
||||||
_encryptor = None
|
|
||||||
|
|
||||||
|
|
||||||
class Encryptor:
|
|
||||||
_BLOCK_SIZE = 32
|
|
||||||
_PASSWORD_FILENAME = "mongo_key.bin"
|
|
||||||
|
|
||||||
def __init__(self, password_file_dir):
|
|
||||||
password_file = os.path.join(password_file_dir, self._PASSWORD_FILENAME)
|
|
||||||
|
|
||||||
if os.path.exists(password_file):
|
|
||||||
self._load_existing_key(password_file)
|
|
||||||
else:
|
|
||||||
self._init_key(password_file)
|
|
||||||
|
|
||||||
def _init_key(self, password_file_path: str):
|
|
||||||
self._cipher_key = Random.new().read(self._BLOCK_SIZE)
|
|
||||||
with open_new_securely_permissioned_file(password_file_path, "wb") as f:
|
|
||||||
f.write(self._cipher_key)
|
|
||||||
|
|
||||||
def _load_existing_key(self, password_file):
|
|
||||||
with open(password_file, "rb") as f:
|
|
||||||
self._cipher_key = f.read()
|
|
||||||
|
|
||||||
def _pad(self, message):
|
|
||||||
return message + (self._BLOCK_SIZE - (len(message) % self._BLOCK_SIZE)) * chr(
|
|
||||||
self._BLOCK_SIZE - (len(message) % self._BLOCK_SIZE)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _unpad(self, message: str):
|
|
||||||
return message[0 : -ord(message[len(message) - 1])]
|
|
||||||
|
|
||||||
def enc(self, message: str):
|
|
||||||
cipher_iv = Random.new().read(AES.block_size)
|
|
||||||
cipher = AES.new(self._cipher_key, AES.MODE_CBC, cipher_iv)
|
|
||||||
return base64.b64encode(cipher_iv + cipher.encrypt(self._pad(message).encode())).decode()
|
|
||||||
|
|
||||||
def dec(self, enc_message):
|
|
||||||
enc_message = base64.b64decode(enc_message)
|
|
||||||
cipher_iv = enc_message[0 : AES.block_size]
|
|
||||||
cipher = AES.new(self._cipher_key, AES.MODE_CBC, cipher_iv)
|
|
||||||
return self._unpad(cipher.decrypt(enc_message[AES.block_size :]).decode())
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_encryptor(password_file_dir):
|
|
||||||
global _encryptor
|
|
||||||
|
|
||||||
_encryptor = Encryptor(password_file_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def get_encryptor():
|
|
||||||
return _encryptor
|
|
|
@ -1,4 +1,4 @@
|
||||||
from monkey_island.cc.server_utils.encryptor import get_encryptor
|
from monkey_island.cc.server_utils.encryption import get_datastore_encryptor
|
||||||
|
|
||||||
|
|
||||||
def parse_creds(attempt):
|
def parse_creds(attempt):
|
||||||
|
@ -29,7 +29,7 @@ def censor_password(password, plain_chars=3, secret_chars=5):
|
||||||
"""
|
"""
|
||||||
if not password:
|
if not password:
|
||||||
return ""
|
return ""
|
||||||
password = get_encryptor().dec(password)
|
password = get_datastore_encryptor().dec(password)
|
||||||
return password[0:plain_chars] + "*" * secret_chars
|
return password[0:plain_chars] + "*" * secret_chars
|
||||||
|
|
||||||
|
|
||||||
|
@ -42,5 +42,5 @@ def censor_hash(hash_, plain_chars=5):
|
||||||
"""
|
"""
|
||||||
if not hash_:
|
if not hash_:
|
||||||
return ""
|
return ""
|
||||||
hash_ = get_encryptor().dec(hash_)
|
hash_ = get_datastore_encryptor().dec(hash_)
|
||||||
return hash_[0:plain_chars] + " ..."
|
return hash_[0:plain_chars] + " ..."
|
||||||
|
|
|
@ -19,7 +19,7 @@ from common.config_value_paths import (
|
||||||
USER_LIST_PATH,
|
USER_LIST_PATH,
|
||||||
)
|
)
|
||||||
from monkey_island.cc.database import mongo
|
from monkey_island.cc.database import mongo
|
||||||
from monkey_island.cc.server_utils.encryptor import get_encryptor
|
from monkey_island.cc.server_utils.encryption import get_datastore_encryptor
|
||||||
from monkey_island.cc.services.config_manipulator import update_config_per_mode
|
from monkey_island.cc.services.config_manipulator import update_config_per_mode
|
||||||
from monkey_island.cc.services.config_schema.config_schema import SCHEMA
|
from monkey_island.cc.services.config_schema.config_schema import SCHEMA
|
||||||
from monkey_island.cc.services.mode.island_mode_service import ModeNotSetError, get_mode
|
from monkey_island.cc.services.mode.island_mode_service import ModeNotSetError, get_mode
|
||||||
|
@ -90,9 +90,9 @@ class ConfigService:
|
||||||
if should_decrypt:
|
if should_decrypt:
|
||||||
if config_key_as_arr in ENCRYPTED_CONFIG_VALUES:
|
if config_key_as_arr in ENCRYPTED_CONFIG_VALUES:
|
||||||
if isinstance(config, str):
|
if isinstance(config, str):
|
||||||
config = get_encryptor().dec(config)
|
config = get_datastore_encryptor().dec(config)
|
||||||
elif isinstance(config, list):
|
elif isinstance(config, list):
|
||||||
config = [get_encryptor().dec(x) for x in config]
|
config = [get_datastore_encryptor().dec(x) for x in config]
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -130,7 +130,7 @@ class ConfigService:
|
||||||
if item_value in items_from_config:
|
if item_value in items_from_config:
|
||||||
return
|
return
|
||||||
if should_encrypt:
|
if should_encrypt:
|
||||||
item_value = get_encryptor().enc(item_value)
|
item_value = get_datastore_encryptor().enc(item_value)
|
||||||
mongo.db.config.update(
|
mongo.db.config.update(
|
||||||
{"name": "newconfig"}, {"$addToSet": {item_key: item_value}}, upsert=False
|
{"name": "newconfig"}, {"$addToSet": {item_key: item_value}}, upsert=False
|
||||||
)
|
)
|
||||||
|
@ -349,9 +349,11 @@ class ConfigService:
|
||||||
ConfigService.decrypt_ssh_key_pair(item) for item in flat_config[key]
|
ConfigService.decrypt_ssh_key_pair(item) for item in flat_config[key]
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
flat_config[key] = [get_encryptor().dec(item) for item in flat_config[key]]
|
flat_config[key] = [
|
||||||
|
get_datastore_encryptor().dec(item) for item in flat_config[key]
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
flat_config[key] = get_encryptor().dec(flat_config[key])
|
flat_config[key] = get_datastore_encryptor().dec(flat_config[key])
|
||||||
return flat_config
|
return flat_config
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -377,25 +379,25 @@ class ConfigService:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
config_arr[i] = (
|
config_arr[i] = (
|
||||||
get_encryptor().dec(config_arr[i])
|
get_datastore_encryptor().dec(config_arr[i])
|
||||||
if is_decrypt
|
if is_decrypt
|
||||||
else get_encryptor().enc(config_arr[i])
|
else get_datastore_encryptor().enc(config_arr[i])
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
parent_config_arr[config_arr_as_array[-1]] = (
|
parent_config_arr[config_arr_as_array[-1]] = (
|
||||||
get_encryptor().dec(config_arr)
|
get_datastore_encryptor().dec(config_arr)
|
||||||
if is_decrypt
|
if is_decrypt
|
||||||
else get_encryptor().enc(config_arr)
|
else get_datastore_encryptor().enc(config_arr)
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decrypt_ssh_key_pair(pair, encrypt=False):
|
def decrypt_ssh_key_pair(pair, encrypt=False):
|
||||||
if encrypt:
|
if encrypt:
|
||||||
pair["public_key"] = get_encryptor().enc(pair["public_key"])
|
pair["public_key"] = get_datastore_encryptor().enc(pair["public_key"])
|
||||||
pair["private_key"] = get_encryptor().enc(pair["private_key"])
|
pair["private_key"] = get_datastore_encryptor().enc(pair["private_key"])
|
||||||
else:
|
else:
|
||||||
pair["public_key"] = get_encryptor().dec(pair["public_key"])
|
pair["public_key"] = get_datastore_encryptor().dec(pair["public_key"])
|
||||||
pair["private_key"] = get_encryptor().dec(pair["private_key"])
|
pair["private_key"] = get_datastore_encryptor().dec(pair["private_key"])
|
||||||
return pair
|
return pair
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -3,7 +3,7 @@ import copy
|
||||||
import dateutil
|
import dateutil
|
||||||
|
|
||||||
from monkey_island.cc.models import Monkey
|
from monkey_island.cc.models import Monkey
|
||||||
from monkey_island.cc.server_utils.encryptor import get_encryptor
|
from monkey_island.cc.server_utils.encryption import get_datastore_encryptor
|
||||||
from monkey_island.cc.services.config import ConfigService
|
from monkey_island.cc.services.config import ConfigService
|
||||||
from monkey_island.cc.services.edge.displayed_edge import EdgeService
|
from monkey_island.cc.services.edge.displayed_edge import EdgeService
|
||||||
from monkey_island.cc.services.node import NodeService
|
from monkey_island.cc.services.node import NodeService
|
||||||
|
@ -76,4 +76,4 @@ def encrypt_exploit_creds(telemetry_json):
|
||||||
credential = attempts[i][field]
|
credential = attempts[i][field]
|
||||||
if credential: # PowerShell exploiter's telem may have `None` here
|
if credential: # PowerShell exploiter's telem may have `None` here
|
||||||
if len(credential) > 0:
|
if len(credential) > 0:
|
||||||
attempts[i][field] = get_encryptor().enc(credential)
|
attempts[i][field] = get_datastore_encryptor().enc(credential)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from monkey_island.cc.server_utils.encryptor import get_encryptor
|
from monkey_island.cc.server_utils.encryption import get_datastore_encryptor
|
||||||
from monkey_island.cc.services.config import ConfigService
|
from monkey_island.cc.services.config import ConfigService
|
||||||
from monkey_island.cc.services.node import NodeService
|
from monkey_island.cc.services.node import NodeService
|
||||||
from monkey_island.cc.services.telemetry.processing.system_info_collectors.system_info_telemetry_dispatcher import ( # noqa: E501
|
from monkey_island.cc.services.telemetry.processing.system_info_collectors.system_info_telemetry_dispatcher import ( # noqa: E501
|
||||||
|
@ -70,7 +70,7 @@ def encrypt_system_info_ssh_keys(ssh_info):
|
||||||
for idx, user in enumerate(ssh_info):
|
for idx, user in enumerate(ssh_info):
|
||||||
for field in ["public_key", "private_key", "known_hosts"]:
|
for field in ["public_key", "private_key", "known_hosts"]:
|
||||||
if ssh_info[idx][field]:
|
if ssh_info[idx][field]:
|
||||||
ssh_info[idx][field] = get_encryptor().enc(ssh_info[idx][field])
|
ssh_info[idx][field] = get_datastore_encryptor().enc(ssh_info[idx][field])
|
||||||
|
|
||||||
|
|
||||||
def process_credential_info(telemetry_json):
|
def process_credential_info(telemetry_json):
|
||||||
|
|
|
@ -1,59 +0,0 @@
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import pyAesCrypt
|
|
||||||
|
|
||||||
BUFFER_SIZE = pyAesCrypt.crypto.bufferSizeDef
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def encrypt_string(plaintext: str, password: str) -> str:
|
|
||||||
plaintext_stream = io.BytesIO(plaintext.encode())
|
|
||||||
ciphertext_stream = io.BytesIO()
|
|
||||||
|
|
||||||
pyAesCrypt.encryptStream(plaintext_stream, ciphertext_stream, password, BUFFER_SIZE)
|
|
||||||
|
|
||||||
ciphertext_b64 = base64.b64encode(ciphertext_stream.getvalue())
|
|
||||||
logger.info("String encrypted.")
|
|
||||||
|
|
||||||
return ciphertext_b64.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt_ciphertext(ciphertext: str, password: str) -> str:
|
|
||||||
ciphertext = base64.b64decode(ciphertext)
|
|
||||||
ciphertext_stream = io.BytesIO(ciphertext)
|
|
||||||
plaintext_stream = io.BytesIO()
|
|
||||||
|
|
||||||
ciphertext_stream_len = len(ciphertext_stream.getvalue())
|
|
||||||
|
|
||||||
try:
|
|
||||||
pyAesCrypt.decryptStream(
|
|
||||||
ciphertext_stream,
|
|
||||||
plaintext_stream,
|
|
||||||
password,
|
|
||||||
BUFFER_SIZE,
|
|
||||||
ciphertext_stream_len,
|
|
||||||
)
|
|
||||||
except ValueError as ex:
|
|
||||||
if str(ex).startswith("Wrong password"):
|
|
||||||
logger.info("Wrong password provided for decryption.")
|
|
||||||
raise InvalidCredentialsError
|
|
||||||
else:
|
|
||||||
logger.info("The corrupt ciphertext provided.")
|
|
||||||
raise InvalidCiphertextError
|
|
||||||
return plaintext_stream.getvalue().decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def is_encrypted(ciphertext: str) -> bool:
|
|
||||||
ciphertext = base64.b64decode(ciphertext)
|
|
||||||
return ciphertext.startswith(b"AES")
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidCredentialsError(Exception):
|
|
||||||
""" Raised when password for decryption is invalid """
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidCiphertextError(Exception):
|
|
||||||
""" Raised when ciphertext is corrupted """
|
|
|
@ -5,7 +5,7 @@ from ScoutSuite.providers.base.authentication_strategy import AuthenticationExce
|
||||||
from common.cloud.scoutsuite_consts import CloudProviders
|
from common.cloud.scoutsuite_consts import CloudProviders
|
||||||
from common.config_value_paths import AWS_KEYS_PATH
|
from common.config_value_paths import AWS_KEYS_PATH
|
||||||
from common.utils.exceptions import InvalidAWSKeys
|
from common.utils.exceptions import InvalidAWSKeys
|
||||||
from monkey_island.cc.server_utils.encryptor import get_encryptor
|
from monkey_island.cc.server_utils.encryption import get_datastore_encryptor
|
||||||
from monkey_island.cc.services.config import ConfigService
|
from monkey_island.cc.services.config import ConfigService
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ def set_aws_keys(access_key_id: str, secret_access_key: str, session_token: str)
|
||||||
|
|
||||||
def _set_aws_key(key_type: str, key_value: str):
|
def _set_aws_key(key_type: str, key_value: str):
|
||||||
path_to_keys = AWS_KEYS_PATH
|
path_to_keys = AWS_KEYS_PATH
|
||||||
encrypted_key = get_encryptor().enc(key_value)
|
encrypted_key = get_datastore_encryptor().enc(key_value)
|
||||||
ConfigService.set_config_value(path_to_keys + [key_type], encrypted_key)
|
ConfigService.set_config_value(path_to_keys + [key_type], encrypted_key)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from tests.unit_tests.monkey_island.cc.mongomock_fixtures import * # noqa: F401,F403,E402
|
from tests.unit_tests.monkey_island.cc.mongomock_fixtures import * # noqa: F401,F403,E402
|
||||||
from tests.unit_tests.monkey_island.cc.services.utils.test_encryption import (
|
from tests.unit_tests.monkey_island.cc.server_utils.encryption.test_password_based_encryption import ( # noqa: E501
|
||||||
MONKEY_CONFIGS_DIR_PATH,
|
MONKEY_CONFIGS_DIR_PATH,
|
||||||
STANDARD_PLAINTEXT_MONKEY_CONFIG_FILENAME,
|
STANDARD_PLAINTEXT_MONKEY_CONFIG_FILENAME,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from monkey_island.cc.models.utils.field_encryptors.string_list_encryptor import StringListEncryptor
|
from monkey_island.cc.models.utils.field_encryptors.string_list_encryptor import StringListEncryptor
|
||||||
from monkey_island.cc.server_utils.encryptor import initialize_encryptor
|
from monkey_island.cc.server_utils.encryption import initialize_datastore_encryptor
|
||||||
|
|
||||||
MOCK_STRING_LIST = ["test_1", "test_2"]
|
MOCK_STRING_LIST = ["test_1", "test_2"]
|
||||||
EMPTY_LIST = []
|
EMPTY_LIST = []
|
||||||
|
@ -9,7 +9,7 @@ EMPTY_LIST = []
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def uses_encryptor(data_for_tests_dir):
|
def uses_encryptor(data_for_tests_dir):
|
||||||
initialize_encryptor(data_for_tests_dir)
|
initialize_datastore_encryptor(data_for_tests_dir)
|
||||||
|
|
||||||
|
|
||||||
def test_encryption_and_decryption(uses_encryptor):
|
def test_encryption_and_decryption(uses_encryptor):
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.unit_tests.monkey_island.cc.server_utils.encryption.test_password_based_encryption import ( # noqa: E501
|
||||||
|
PASSWORD,
|
||||||
|
)
|
||||||
from tests.unit_tests.monkey_island.cc.services.utils.ciphertexts_for_encryption_test import (
|
from tests.unit_tests.monkey_island.cc.services.utils.ciphertexts_for_encryption_test import (
|
||||||
MALFORMED_CIPHER_TEXT_CORRUPTED,
|
MALFORMED_CIPHER_TEXT_CORRUPTED,
|
||||||
)
|
)
|
||||||
from tests.unit_tests.monkey_island.cc.services.utils.test_encryption import PASSWORD
|
|
||||||
|
|
||||||
from common.utils.exceptions import InvalidConfigurationError
|
from common.utils.exceptions import InvalidConfigurationError
|
||||||
from monkey_island.cc.resources.configuration_import import ConfigurationImport
|
from monkey_island.cc.resources.configuration_import import ConfigurationImport
|
||||||
from monkey_island.cc.services.utils.encryption import encrypt_string
|
from monkey_island.cc.server_utils.encryption import PasswordBasedEncryptor
|
||||||
|
|
||||||
|
|
||||||
def test_is_config_encrypted__json(monkey_config_json):
|
def test_is_config_encrypted__json(monkey_config_json):
|
||||||
|
@ -15,7 +17,8 @@ def test_is_config_encrypted__json(monkey_config_json):
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
def test_is_config_encrypted__ciphertext(monkey_config_json):
|
def test_is_config_encrypted__ciphertext(monkey_config_json):
|
||||||
encrypted_config = encrypt_string(monkey_config_json, PASSWORD)
|
pb_encryptor = PasswordBasedEncryptor(PASSWORD)
|
||||||
|
encrypted_config = pb_encryptor.encrypt(monkey_config_json)
|
||||||
assert ConfigurationImport.is_config_encrypted(encrypted_config)
|
assert ConfigurationImport.is_config_encrypted(encrypted_config)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from monkey_island.cc.server_utils.encryption import (
|
||||||
|
get_datastore_encryptor,
|
||||||
|
initialize_datastore_encryptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
PASSWORD_FILENAME = "mongo_key.bin"
|
||||||
|
|
||||||
|
PLAINTEXT = "Hello, Monkey!"
|
||||||
|
CYPHERTEXT = "vKgvD6SjRyIh1dh2AM/rnTa0NI/vjfwnbZLbMocWtE4e42WJmSUz2ordtbQrH1Fq"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aes_cbc_encryption(data_for_tests_dir):
|
||||||
|
initialize_datastore_encryptor(data_for_tests_dir)
|
||||||
|
|
||||||
|
assert get_datastore_encryptor().enc(PLAINTEXT) != PLAINTEXT
|
||||||
|
|
||||||
|
|
||||||
|
def test_aes_cbc_decryption(data_for_tests_dir):
|
||||||
|
initialize_datastore_encryptor(data_for_tests_dir)
|
||||||
|
|
||||||
|
assert get_datastore_encryptor().dec(CYPHERTEXT) == PLAINTEXT
|
||||||
|
|
||||||
|
|
||||||
|
def test_aes_cbc_enc_dec(data_for_tests_dir):
|
||||||
|
initialize_datastore_encryptor(data_for_tests_dir)
|
||||||
|
|
||||||
|
assert get_datastore_encryptor().dec(get_datastore_encryptor().enc(PLAINTEXT)) == PLAINTEXT
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_new_password_file(tmpdir):
|
||||||
|
initialize_datastore_encryptor(tmpdir)
|
||||||
|
|
||||||
|
assert os.path.isfile(os.path.join(tmpdir, PASSWORD_FILENAME))
|
|
@ -4,11 +4,7 @@ from tests.unit_tests.monkey_island.cc.services.utils.ciphertexts_for_encryption
|
||||||
VALID_CIPHER_TEXT,
|
VALID_CIPHER_TEXT,
|
||||||
)
|
)
|
||||||
|
|
||||||
from monkey_island.cc.services.utils.encryption import (
|
from monkey_island.cc.server_utils.encryption import InvalidCredentialsError, PasswordBasedEncryptor
|
||||||
InvalidCredentialsError,
|
|
||||||
decrypt_ciphertext,
|
|
||||||
encrypt_string,
|
|
||||||
)
|
|
||||||
|
|
||||||
MONKEY_CONFIGS_DIR_PATH = "monkey_configs"
|
MONKEY_CONFIGS_DIR_PATH = "monkey_configs"
|
||||||
STANDARD_PLAINTEXT_MONKEY_CONFIG_FILENAME = "monkey_config_standard.json"
|
STANDARD_PLAINTEXT_MONKEY_CONFIG_FILENAME = "monkey_config_standard.json"
|
||||||
|
@ -18,23 +14,27 @@ INCORRECT_PASSWORD = "goodbye321"
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
def test_encrypt_decrypt_string(monkey_config_json):
|
def test_encrypt_decrypt_string(monkey_config_json):
|
||||||
encrypted_config = encrypt_string(monkey_config_json, PASSWORD)
|
pb_encryptor = PasswordBasedEncryptor(PASSWORD)
|
||||||
assert decrypt_ciphertext(encrypted_config, PASSWORD) == monkey_config_json
|
encrypted_config = pb_encryptor.encrypt(monkey_config_json)
|
||||||
|
assert pb_encryptor.decrypt(encrypted_config) == monkey_config_json
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
def test_decrypt_string__wrong_password(monkey_config_json):
|
def test_decrypt_string__wrong_password(monkey_config_json):
|
||||||
|
pb_encryptor = PasswordBasedEncryptor(INCORRECT_PASSWORD)
|
||||||
with pytest.raises(InvalidCredentialsError):
|
with pytest.raises(InvalidCredentialsError):
|
||||||
decrypt_ciphertext(VALID_CIPHER_TEXT, INCORRECT_PASSWORD)
|
pb_encryptor.decrypt(VALID_CIPHER_TEXT)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
def test_decrypt_string__malformed_corrupted():
|
def test_decrypt_string__malformed_corrupted():
|
||||||
|
pb_encryptor = PasswordBasedEncryptor(PASSWORD)
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
decrypt_ciphertext(MALFORMED_CIPHER_TEXT_CORRUPTED, PASSWORD)
|
pb_encryptor.decrypt(MALFORMED_CIPHER_TEXT_CORRUPTED)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
def test_decrypt_string__no_password(monkey_config_json):
|
def test_decrypt_string__no_password(monkey_config_json):
|
||||||
|
pb_encryptor = PasswordBasedEncryptor("")
|
||||||
with pytest.raises(InvalidCredentialsError):
|
with pytest.raises(InvalidCredentialsError):
|
||||||
decrypt_ciphertext(VALID_CIPHER_TEXT, "")
|
pb_encryptor.decrypt(VALID_CIPHER_TEXT)
|
|
@ -1,32 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from monkey_island.cc.server_utils.encryptor import get_encryptor, initialize_encryptor
|
|
||||||
|
|
||||||
PASSWORD_FILENAME = "mongo_key.bin"
|
|
||||||
|
|
||||||
PLAINTEXT = "Hello, Monkey!"
|
|
||||||
CYPHERTEXT = "vKgvD6SjRyIh1dh2AM/rnTa0NI/vjfwnbZLbMocWtE4e42WJmSUz2ordtbQrH1Fq"
|
|
||||||
|
|
||||||
|
|
||||||
def test_aes_cbc_encryption(data_for_tests_dir):
|
|
||||||
initialize_encryptor(data_for_tests_dir)
|
|
||||||
|
|
||||||
assert get_encryptor().enc(PLAINTEXT) != PLAINTEXT
|
|
||||||
|
|
||||||
|
|
||||||
def test_aes_cbc_decryption(data_for_tests_dir):
|
|
||||||
initialize_encryptor(data_for_tests_dir)
|
|
||||||
|
|
||||||
assert get_encryptor().dec(CYPHERTEXT) == PLAINTEXT
|
|
||||||
|
|
||||||
|
|
||||||
def test_aes_cbc_enc_dec(data_for_tests_dir):
|
|
||||||
initialize_encryptor(data_for_tests_dir)
|
|
||||||
|
|
||||||
assert get_encryptor().dec(get_encryptor().enc(PLAINTEXT)) == PLAINTEXT
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_new_password_file(tmpdir):
|
|
||||||
initialize_encryptor(tmpdir)
|
|
||||||
|
|
||||||
assert os.path.isfile(os.path.join(tmpdir, PASSWORD_FILENAME))
|
|
|
@ -5,7 +5,10 @@ import pytest
|
||||||
|
|
||||||
from common.config_value_paths import AWS_KEYS_PATH
|
from common.config_value_paths import AWS_KEYS_PATH
|
||||||
from monkey_island.cc.database import mongo
|
from monkey_island.cc.database import mongo
|
||||||
from monkey_island.cc.server_utils.encryptor import get_encryptor, initialize_encryptor
|
from monkey_island.cc.server_utils.encryption import (
|
||||||
|
get_datastore_encryptor,
|
||||||
|
initialize_datastore_encryptor,
|
||||||
|
)
|
||||||
from monkey_island.cc.services.config import ConfigService
|
from monkey_island.cc.services.config import ConfigService
|
||||||
from monkey_island.cc.services.zero_trust.scoutsuite.scoutsuite_auth_service import (
|
from monkey_island.cc.services.zero_trust.scoutsuite.scoutsuite_auth_service import (
|
||||||
is_aws_keys_setup,
|
is_aws_keys_setup,
|
||||||
|
@ -27,8 +30,8 @@ def test_is_aws_keys_setup(tmp_path):
|
||||||
assert not is_aws_keys_setup()
|
assert not is_aws_keys_setup()
|
||||||
|
|
||||||
# Make sure noone changed config path and broke this function
|
# Make sure noone changed config path and broke this function
|
||||||
initialize_encryptor(tmp_path)
|
initialize_datastore_encryptor(tmp_path)
|
||||||
bogus_key_value = get_encryptor().enc("bogus_aws_key")
|
bogus_key_value = get_datastore_encryptor().enc("bogus_aws_key")
|
||||||
dpath.util.set(
|
dpath.util.set(
|
||||||
ConfigService.default_config, AWS_KEYS_PATH + ["aws_secret_access_key"], bogus_key_value
|
ConfigService.default_config, AWS_KEYS_PATH + ["aws_secret_access_key"], bogus_key_value
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,6 +12,7 @@ include_trailing_comma = true
|
||||||
force_grid_wrap = 0
|
force_grid_wrap = 0
|
||||||
use_parentheses = true
|
use_parentheses = true
|
||||||
ensure_newline_before_comments = true
|
ensure_newline_before_comments = true
|
||||||
|
skip_glob="**/__init__.py"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
minversion = "6.0"
|
minversion = "6.0"
|
||||||
|
|
Loading…
Reference in New Issue