diff --git a/app/service/auth_svc.py b/app/service/auth_svc.py index 17b2c1aea..751802cdb 100644 --- a/app/service/auth_svc.py +++ b/app/service/auth_svc.py @@ -1,6 +1,5 @@ import base64 from collections import namedtuple -from hmac import compare_digest from importlib import import_module from aiohttp import web, web_request @@ -17,6 +16,7 @@ from app.service.interfaces.i_login_handler import LoginHandlerInterface from app.service.login_handlers.default import DefaultLoginHandler from app.utility.base_service import BaseService +from app.utility.config_util import verify_hash HEADER_API_KEY = 'KEY' @@ -143,8 +143,8 @@ def request_has_valid_api_key(self, request): if request_api_key is None: return False for i in [CONFIG_API_KEY_RED, CONFIG_API_KEY_BLUE]: - api_key = self.get_config(i) - if api_key is not None and compare_digest(request_api_key, api_key): + hashed_api_key = self.get_config(i) + if hashed_api_key is not None and verify_hash(hashed_api_key, request_api_key): return True return False @@ -170,9 +170,9 @@ async def get_permissions(self, request): identity = await identity_policy.identify(request) if identity in self.user_map: return [self.Access[p.upper()] for p in self.user_map[identity].permissions] - elif request.headers.get(HEADER_API_KEY) == self.get_config(CONFIG_API_KEY_RED): + elif verify_hash(self.get_config(CONFIG_API_KEY_RED), request.headers.get(HEADER_API_KEY)): return self.Access.RED, self.Access.APP - elif request.headers.get(HEADER_API_KEY) == self.get_config(CONFIG_API_KEY_BLUE): + elif verify_hash(self.get_config(CONFIG_API_KEY_BLUE), request.headers.get(HEADER_API_KEY)): return self.Access.BLUE, self.Access.APP return () diff --git a/app/service/login_handlers/default.py b/app/service/login_handlers/default.py index 804373f0c..b26faf225 100644 --- a/app/service/login_handlers/default.py +++ b/app/service/login_handlers/default.py @@ -6,6 +6,7 @@ from ldap3.core.exceptions import LDAPAttributeError, LDAPException from app.service.interfaces.i_login_handler import LoginHandlerInterface +from app.utility.config_util import verify_hash HANDLER_NAME = 'Default Login Handler' @@ -51,7 +52,7 @@ async def _check_credentials(user_map, username, password): user = user_map.get(username) if not user: return False - return user.password == password + return verify_hash(user.password, password) async def _ldap_login(self, username, password): server = ldap3.Server(self._ldap_config.get('server')) diff --git a/app/utility/base_world.py b/app/utility/base_world.py index f86de97c2..5fe028aa7 100644 --- a/app/utility/base_world.py +++ b/app/utility/base_world.py @@ -14,6 +14,8 @@ import marshmallow as ma import marshmallow_enum as ma_enum +from app.utility.config_util import hash_config_creds + class BaseWorld: """ @@ -26,7 +28,17 @@ class BaseWorld: TIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ' @staticmethod - def apply_config(name, config): + def apply_config(name, config, apply_hash=False, overwrite_path=''): + """ + Hashes credentials and API keys if needed. Overwrites config at path with secure + version if any changes are made. + """ + if apply_hash: + changes_made = hash_config_creds(config) + if changes_made and overwrite_path: + logging.debug(f'Overwriting config file {overwrite_path} with secure values') + with open(overwrite_path, 'w') as cfg_file: + yaml.safe_dump(config, cfg_file, default_flow_style=False) BaseWorld._app_configuration[name] = config @staticmethod diff --git a/app/utility/config_generator.py b/app/utility/config_generator.py deleted file mode 100644 index 20b023045..000000000 --- a/app/utility/config_generator.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -import pathlib -import secrets - -import jinja2 -import yaml - - -CONFIG_MSG_TEMPLATE = jinja2.Template(""" -Log into Caldera with the following admin credentials: - Red: - {%- if users.red.red %} - USERNAME: red - PASSWORD: {{ users.red.red }} - {%- endif %} - API_TOKEN: {{ api_key_red }} - Blue: - {%- if users.blue.blue %} - USERNAME: blue - PASSWORD: {{ users.blue.blue }} - {%- endif %} - API_TOKEN: {{ api_key_blue }} -To modify these values, edit the {{ config_path }} file. -""") - - -def log_config_message(config_path): - with pathlib.Path(config_path).open('r') as fle: - config = yaml.safe_load(fle) - logging.info(CONFIG_MSG_TEMPLATE.render(config_path=str(config_path), **config)) - - -def make_secure_config(): - with open('conf/default.yml', 'r') as fle: - config = yaml.safe_load(fle) - - secret_options = ('api_key_blue', 'api_key_red', 'crypt_salt', 'encryption_key') - for option in secret_options: - config[option] = secrets.token_urlsafe() - - config['users'] = dict(red=dict(red=secrets.token_urlsafe()), - blue=dict(blue=secrets.token_urlsafe())) - - return config - - -def ensure_local_config(): - """ - Checks if a local.yml config file exists. If not, generates a new local.yml file using secure random values. - """ - local_conf_path = pathlib.Path('conf/local.yml') - if local_conf_path.exists(): - return - - logging.info('Creating new secure config in %s' % local_conf_path) - with local_conf_path.open('w') as fle: - yaml.safe_dump(make_secure_config(), fle, default_flow_style=False) - - log_config_message(local_conf_path) diff --git a/app/utility/config_util.py b/app/utility/config_util.py new file mode 100644 index 000000000..fa903693a --- /dev/null +++ b/app/utility/config_util.py @@ -0,0 +1,104 @@ +import logging +import pathlib +import secrets + +import jinja2 +import yaml + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError + + +CONFIG_MSG_TEMPLATE = jinja2.Template(""" +Log into Caldera with the following admin credentials: + Red: + {%- if users.red.red %} + USERNAME: red + PASSWORD: {{ users.red.red }} + {%- endif %} + API_TOKEN: {{ api_key_red }} + Blue: + {%- if users.blue.blue %} + USERNAME: blue + PASSWORD: {{ users.blue.blue }} + {%- endif %} + API_TOKEN: {{ api_key_blue }} +To modify these values, edit the {{ config_path }} file and restart Caldera. +""") +LOCAL_CONF_PATH = 'conf/local.yml' +SECRET_OPTIONS = ('api_key_blue', 'api_key_red', 'crypt_salt', 'encryption_key') +HASHED_OPTIONS = ('api_key_blue', 'api_key_red') + + +def _is_hashed(val): + return isinstance(val, str) and val.startswith('$argon2id$') + + +def verify_hash(hash_val, target): + """ + Returns True if the argon2 hash for the target matches hash_val, False otherwise. + Returns False for None or non-string inputs. + """ + if not isinstance(hash_val, str) or not isinstance(target, str): + return False + ph = PasswordHasher() + try: + return ph.verify(hash_val, target) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def hash_config_creds(config): + """ + Hashes the red/blue API keys and any user passwords in the config dictionary. + Modifies the configuration dictionary parameter. + Returns True if any values were modified (hashed), False otherwise. + """ + ph = PasswordHasher() + any_hashed = False + for option in HASHED_OPTIONS: + val = config.get(option, '') + + # Skip any values that are already hashed + if val and not _is_hashed(val): + config[option] = ph.hash(val) + any_hashed = True + + # Hash credentials + for group_name, group_dict in config.get('users', dict()).items(): + for username, val in group_dict.items(): + if not _is_hashed(val): + config['users'][group_name][username] = ph.hash(val) + any_hashed = True + + return any_hashed + + +def make_secure_config(): + with open('conf/default.yml', 'r') as fle: + config = yaml.safe_load(fle) + + for option in SECRET_OPTIONS: + config[option] = secrets.token_urlsafe() + + config['users'] = dict(red=dict(red=secrets.token_urlsafe()), + blue=dict(blue=secrets.token_urlsafe())) + + # Display API keys and user credentials, then hash them + logging.info(CONFIG_MSG_TEMPLATE.render(config_path=LOCAL_CONF_PATH, **config)) + hash_config_creds(config) + + return config + + +def ensure_local_config(): + """ + Checks if a local.yml config file exists. If not, generates a new local.yml file using secure random values. + """ + local_conf_path = pathlib.Path(LOCAL_CONF_PATH) + if local_conf_path.exists(): + return + + logging.info('Creating new secure config in %s' % local_conf_path) + with local_conf_path.open('w') as fle: + yaml.safe_dump(make_secure_config(), fle, default_flow_style=False) diff --git a/requirements.txt b/requirements.txt index 8dc9de0e8..42ee26a5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ aiohttp==3.13.3 aiohttp_session==2.12.0 aiohttp-security==0.4.0 aiohttp-apispec==3.0.0b2 +argon2-cffi==25.1.0 jinja2==3.1.6 pyyaml==6.0.1 cryptography==46.0.5 diff --git a/server.py b/server.py index 2c2d0702d..420382206 100644 --- a/server.py +++ b/server.py @@ -35,7 +35,7 @@ from app.service.rest_svc import RestService from app.utility.base_object import AppConfigGlobalVariableIdentifier from app.utility.base_world import BaseWorld -from app.utility.config_generator import ensure_local_config +from app.utility.config_util import ensure_local_config MAGMA_PATH = "./plugins/magma" @@ -239,7 +239,8 @@ def list_str(values): ensure_local_config() main_config_path = "conf/%s.yml" % args.environment - BaseWorld.apply_config("main", BaseWorld.strip_yml(main_config_path)[0]) + BaseWorld.apply_config("main", BaseWorld.strip_yml(main_config_path)[0], apply_hash=True, + overwrite_path=main_config_path) logging.info("Using main config from %s" % main_config_path) BaseWorld.apply_config("agents", BaseWorld.strip_yml("conf/agents.yml")[0]) BaseWorld.apply_config("payloads", BaseWorld.strip_yml("conf/payloads.yml")[0]) diff --git a/tests/api/v2/managers/test_config_api_manager.py b/tests/api/v2/managers/test_config_api_manager.py index 4ba0affe4..8ed748705 100644 --- a/tests/api/v2/managers/test_config_api_manager.py +++ b/tests/api/v2/managers/test_config_api_manager.py @@ -18,7 +18,7 @@ async def locate(self, key): @pytest.fixture def base_world(app_config, agent_config): BaseWorld.clear_config() - BaseWorld.apply_config('main', app_config) + BaseWorld.apply_config('main', app_config, apply_hash=True) BaseWorld.apply_config('agents', agent_config) yield BaseWorld diff --git a/tests/api/v2/test_knowledge.py b/tests/api/v2/test_knowledge.py index 2ec53133a..fdbad598b 100644 --- a/tests/api/v2/test_knowledge.py +++ b/tests/api/v2/test_knowledge.py @@ -34,7 +34,8 @@ def base_world(): 'crypt_salt': 'thisisdefinitelynotkosher', # Salt for file service instantiation 'encryption_key': 'andneitheristhis', # fake encryption key for file service instantiation - } + }, + apply_hash=True ) yield BaseWorld diff --git a/tests/api/v2/test_responses.py b/tests/api/v2/test_responses.py index f2acaa4c5..80de02a5d 100644 --- a/tests/api/v2/test_responses.py +++ b/tests/api/v2/test_responses.py @@ -17,7 +17,8 @@ def base_world(): 'red': {'reduser': 'redpass'}, 'blue': {'blueuser': 'bluepass'} } - } + }, + apply_hash=True ) yield BaseWorld diff --git a/tests/api/v2/test_security.py b/tests/api/v2/test_security.py index eb8bf87b5..77ac4a51f 100644 --- a/tests/api/v2/test_security.py +++ b/tests/api/v2/test_security.py @@ -19,7 +19,8 @@ def base_world(): 'red': {'reduser': 'redpass'}, 'blue': {'blueuser': 'bluepass'} } - } + }, + apply_hash=True ) yield BaseWorld diff --git a/tests/conftest.py b/tests/conftest.py index 3fe3e7554..5f3c2b1fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,7 +73,7 @@ @pytest.fixture(scope='session') def init_base_world(): with open(os.path.join(CONFIG_DIR, 'default.yml')) as c: - BaseWorld.apply_config('main', yaml.load(c, Loader=yaml.FullLoader)) + BaseWorld.apply_config('main', yaml.load(c, Loader=yaml.FullLoader), apply_hash=True) BaseWorld.apply_config('agents', BaseWorld.strip_yml(os.path.join(CONFIG_DIR, 'agents.yml'))[0]) BaseWorld.apply_config('payloads', BaseWorld.strip_yml(os.path.join(CONFIG_DIR, 'payloads.yml'))[0]) @@ -363,7 +363,7 @@ def make_app(svcs): async def initialize(): with open(Path(__file__).parents[1] / 'conf' / 'default.yml', 'r') as fle: - BaseWorld.apply_config('main', yaml.safe_load(fle)) + BaseWorld.apply_config('main', yaml.safe_load(fle), apply_hash=True) with open(Path(__file__).parents[1] / 'conf' / 'payloads.yml', 'r') as fle: BaseWorld.apply_config('payloads', yaml.safe_load(fle)) with open(Path(__file__).parents[1] / 'conf' / 'agents.yml', 'r') as fle: diff --git a/tests/services/test_rest_svc.py b/tests/services/test_rest_svc.py index db2b75739..a1d163e3d 100644 --- a/tests/services/test_rest_svc.py +++ b/tests/services/test_rest_svc.py @@ -20,7 +20,7 @@ async def setup_rest_svc_test(data_svc): 'crypt_salt': 'BLAH', 'api_key': 'ADMIN123', 'encryption_key': 'ADMIN123', - 'exfil_dir': '/tmp'}) + 'exfil_dir': '/tmp'}, apply_hash=True) await data_svc.store( Ability(ability_id='123', name='testA', executors=[ Executor(name='psh', platform='windows', command='curl #{app.contact.http}') diff --git a/tests/utility/test_config_util.py b/tests/utility/test_config_util.py new file mode 100644 index 000000000..a0868cbc8 --- /dev/null +++ b/tests/utility/test_config_util.py @@ -0,0 +1,112 @@ +import builtins +import copy +import pathlib +import secrets +import yaml + +from unittest import mock +from argon2 import PasswordHasher + +from app.utility.config_util import hash_config_creds, verify_hash, ensure_local_config, make_secure_config + + +NON_SENSITIVE_CONF = { + 'app.contact.http': '0.0.0.0', + 'plugins': ['sandcat', 'stockpile'], +} +SENSITIVE_CONF = { + 'app.contact.http': '0.0.0.0', + 'plugins': ['sandcat', 'stockpile'], + 'api_key_blue': 'testapikeyblue', + 'api_key_red': 'testapikeyred', + 'users': { + 'group1': { + 'user1': 'testpassword1' + }, + 'group2': { + 'user2': 'testpassword2' + }, + } +} + + +class TestConfigUtil: + + def test_verify_hash(self): + hash_val = '$argon2id$v=19$m=65536,t=3,p=4$87lgOXDGx/9JUHuCsxlaZw$bcJp3dQcqMiYdZOCm8LLJ8ncaEwjoS1xVcPHUGs/ajU' + plaintext = 'testpassword' + assert verify_hash(hash_val, plaintext) + assert not verify_hash(hash_val, 'testpassword2') + assert not verify_hash('$argon2id$v=19$m=65536,t=3,p=4$K/WRrQC6CaEkiDF+KhKfMQ$y4dB2W/sqiCcyJX3SYPYhHenEmLv4xDuKV38Ca9FrGc', plaintext) + assert not verify_hash('notahash', plaintext) + assert not verify_hash('$argon2id$v=19$m=65536,t=2,p=4$87lgOXDGx/9JUHuCsxlaZw$bcJp3dQcqMiYdZOCm8LLJ8ncaEwjoS1xVcPHUGs/ajU', plaintext) + assert not verify_hash('$argon2$v=19$m=65536,t=3,p=4$87lgOXDGx/9JUHuCsxlaZw$bcJp3dQcqMiYdZOCm8LLJ8ncaEwjoS1xVcPHUGs/ajU', plaintext) + assert not verify_hash('$argon2idasdkl$v=19$m=65536,t=2,p=4$87lgOXDGx/laksdj$bcJp3dQcqMiYdZOCm8LLJ8ncaEwjoS1xVcPHUGs/ajU', plaintext) + assert not verify_hash(None, plaintext) + assert not verify_hash(hash_val, None) + + def test_hash_config_creds(self): + config = copy.deepcopy(NON_SENSITIVE_CONF) + assert not hash_config_creds(config) + assert config == NON_SENSITIVE_CONF + + config = copy.deepcopy(SENSITIVE_CONF) + assert hash_config_creds(config) + assert SENSITIVE_CONF != config + assert verify_hash(config['api_key_blue'], 'testapikeyblue') + assert verify_hash(config['api_key_red'], 'testapikeyred') + assert verify_hash(config['users']['group1']['user1'], 'testpassword1') + assert verify_hash(config['users']['group2']['user2'], 'testpassword2') + + @mock.patch.object(PasswordHasher, 'hash', return_value='mockhash') + @mock.patch.object(yaml, 'safe_load', side_effect=lambda *a, **kw: copy.deepcopy(SENSITIVE_CONF)) + @mock.patch.object(yaml, 'safe_dump') + @mock.patch.object(secrets, 'token_urlsafe', return_value='mocksecret') + def test_ensure_local_config(self, mock_token_urlsafe, mock_safe_dump, mock_safe_load, mock_hashs): + with mock.patch.object(pathlib.Path, 'open', spec=open): + with mock.patch.object(pathlib.Path, 'exists', return_value=True): + ensure_local_config() + mock_safe_dump.assert_not_called() + with mock.patch.object(pathlib.Path, 'exists', return_value=False): + want_config = { + 'app.contact.http': '0.0.0.0', + 'plugins': ['sandcat', 'stockpile'], + 'api_key_blue': 'mockhash', + 'api_key_red': 'mockhash', + 'crypt_salt': 'mocksecret', + 'encryption_key': 'mocksecret', + 'users': { + 'red': { + 'red': 'mockhash', + }, + 'blue': { + 'blue': 'mockhash', + }, + } + } + ensure_local_config() + mock_safe_dump.assert_called_once_with(want_config, mock.ANY, default_flow_style=False) + + @mock.patch('logging.info') + @mock.patch.object(yaml, 'safe_load', return_value={'app.contact.http': '0.0.0.0', 'plugins': ['sandcat']}) + @mock.patch.object(secrets, 'token_urlsafe', return_value='plaintextsecret') + def test_make_secure_config_logs_plaintext_then_hashes(self, mock_token_urlsafe, mock_safe_load, mock_logging_info): + with mock.patch.object(builtins, 'open', mock.mock_open()): + config = make_secure_config() + + # logging.info must have been called exactly once (to display startup credentials) + mock_logging_info.assert_called_once() + logged_message = mock_logging_info.call_args[0][0] + + # The logged message must contain the plaintext secret so the admin can read their credentials + assert 'plaintextsecret' in logged_message, ( + "Expected plaintext secret in logged startup message, got: %r" % logged_message + ) + + # The returned config must store argon2 hashes, not the plaintext secret + assert config['api_key_blue'].startswith('$argon2id$'), ( + "api_key_blue should be an argon2 hash after make_secure_config, got: %r" % config['api_key_blue'] + ) + assert config['api_key_red'].startswith('$argon2id$'), ( + "api_key_red should be an argon2 hash after make_secure_config, got: %r" % config['api_key_red'] + ) diff --git a/tests/web_server/test_core_endpoints.py b/tests/web_server/test_core_endpoints.py index 8090d739e..400d27740 100644 --- a/tests/web_server/test_core_endpoints.py +++ b/tests/web_server/test_core_endpoints.py @@ -22,7 +22,7 @@ async def aiohttp_client(aiohttp_client): async def initialize(): with open(Path(__file__).parents[2] / 'conf' / 'default.yml', 'r') as fle: - BaseWorld.apply_config('main', yaml.safe_load(fle)) + BaseWorld.apply_config('main', yaml.safe_load(fle), apply_hash=True) with open(Path(__file__).parents[2] / 'conf' / 'payloads.yml', 'r') as fle: BaseWorld.apply_config('payloads', yaml.safe_load(fle)) with open(Path(__file__).parents[2] / 'conf' / 'agents.yml', 'r') as fle: