#!/usr/bin/env python3 # Copyright The Mbed TLS Contributors # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later """ Test Mbed TLS with a subset of algorithms. This script can be divided into several steps: First, include/mbedtls/mbedtls_config.h or a different config file passed in the arguments is parsed to extract any configuration options (using config.py). Then, test domains (groups of jobs, tests) are built based on predefined data collected in the DomainData class. Here, each domain has five major traits: - domain name, can be used to run only specific tests via command-line; - configuration building method, described in detail below; - list of symbols passed to the configuration building method; - commands to be run on each job (only build, build and test, or any other custom); - optional list of symbols to be excluded from testing. The configuration building method can be one of the three following: - ComplementaryDomain - build a job for each passed symbol by disabling a single symbol and its reverse dependencies (defined in REVERSE_DEPENDENCIES); - ExclusiveDomain - build a job where, for each passed symbol, only this particular one is defined and other symbols from the list are unset. For each job look for any non-standard symbols to set/unset in EXCLUSIVE_GROUPS. These are usually not direct dependencies, but rather non-trivial results of other configs missing. Then look for any unset symbols and handle their reverse dependencies. Examples of EXCLUSIVE_GROUPS usage: - MBEDTLS_SHA512_C job turns off all hashes except SHA512. MBEDTLS_SSL_COOKIE_C requires either SHA256 or SHA384 to work, so it also has to be disabled. This is not a dependency on SHA512_C, but a result of an exclusive domain config building method. Relevant field: 'MBEDTLS_SHA512_C': ['-MBEDTLS_SSL_COOKIE_C'], - DualDomain - combination of the two above - both complementary and exclusive domain job generation code will be run. Currently only used for hashes. Lastly, the collected jobs are executed and (optionally) tested, with error reporting and coloring as configured in options. Each test starts with a full config without a couple of slowing down or unnecessary options (see set_reference_config), then the specific job config is derived. """ import argparse import os import re import shutil import subprocess import sys import traceback from typing import Union # Add the Mbed TLS Python library directory to the module search path import scripts_path # pylint: disable=unused-import import config class Colors: # pylint: disable=too-few-public-methods """Minimalistic support for colored output. Each field of an object of this class is either None if colored output is not possible or not desired, or a pair of strings (start, stop) such that outputting start switches the text color to the desired color and stop switches the text color back to the default.""" red = None green = None cyan = None bold_red = None bold_green = None def __init__(self, options=None): """Initialize color profile according to passed options.""" if not options or options.color in ['no', 'never']: want_color = False elif options.color in ['yes', 'always']: want_color = True else: want_color = sys.stderr.isatty() if want_color: # Assume ANSI compatible terminal normal = '\033[0m' self.red = ('\033[31m', normal) self.green = ('\033[32m', normal) self.cyan = ('\033[36m', normal) self.bold_red = ('\033[1;31m', normal) self.bold_green = ('\033[1;32m', normal) NO_COLORS = Colors(None) def log_line(text, prefix='depends.py:', suffix='', color=None): """Print a status message.""" if color is not None: prefix = color[0] + prefix suffix = suffix + color[1] sys.stderr.write(prefix + ' ' + text + suffix + '\n') sys.stderr.flush() def log_command(cmd): """Print a trace of the specified command. cmd is a list of strings: a command name and its arguments.""" log_line(' '.join(cmd), prefix='+') def backup_config(options): """Back up the library configuration file (mbedtls_config.h). If the backup file already exists, it is presumed to be the desired backup, so don't make another backup.""" if os.path.exists(options.config_backup): options.own_backup = False else: options.own_backup = True shutil.copy(options.config, options.config_backup) def restore_config(options): """Restore the library configuration file (mbedtls_config.h). Remove the backup file if it was saved earlier.""" if options.own_backup: shutil.move(options.config_backup, options.config) else: shutil.copy(options.config_backup, options.config) def option_exists(conf, option): return option in conf.settings def set_config_option_value(conf, option, colors, value: Union[bool, str]): """Set/unset a configuration option, optionally specifying a value. value can be either True/False (set/unset config option), or a string, which will make a symbol defined with a certain value.""" if not option_exists(conf, option): log_line('Symbol {} was not found in {}'.format(option, conf.filename), color=colors.red) return False if value is False: log_command(['config.py', 'unset', option]) conf.unset(option) elif value is True: log_command(['config.py', 'set', option]) conf.set(option) else: log_command(['config.py', 'set', option, value]) conf.set(option, value) return True def set_reference_config(conf, options, colors): """Change the library configuration file (mbedtls_config.h) to the reference state. The reference state is the one from which the tested configurations are derived.""" # Turn off options that are not relevant to the tests and slow them down. log_command(['config.py', 'full']) conf.adapt(config.full_adapter) set_config_option_value(conf, 'MBEDTLS_TEST_HOOKS', colors, False) set_config_option_value(conf, 'MBEDTLS_PSA_CRYPTO_CONFIG', colors, False) if options.unset_use_psa: set_config_option_value(conf, 'MBEDTLS_USE_PSA_CRYPTO', colors, False) class Job: """A job builds the library in a specific configuration and runs some tests.""" def __init__(self, name, config_settings, commands): """Build a job object. The job uses the configuration described by config_settings. This is a dictionary where the keys are preprocessor symbols and the values are booleans or strings. A boolean indicates whether or not to #define the symbol. With a string, the symbol is #define'd to that value. After setting the configuration, the job runs the programs specified by commands. This is a list of lists of strings; each list of string is a command name and its arguments and is passed to subprocess.call with shell=False.""" self.name = name self.config_settings = config_settings self.commands = commands def announce(self, colors, what): '''Announce the start or completion of a job. If what is None, announce the start of the job. If what is True, announce that the job has passed. If what is False, announce that the job has failed.''' if what is True: log_line(self.name + ' PASSED', color=colors.green) elif what is False: log_line(self.name + ' FAILED', color=colors.red) else: log_line('starting ' + self.name, color=colors.cyan) def configure(self, conf, options, colors): '''Set library configuration options as required for the job.''' set_reference_config(conf, options, colors) for key, value in sorted(self.config_settings.items()): ret = set_config_option_value(conf, key, colors, value) if ret is False: return False return True def test(self, options): '''Run the job's build and test commands. Return True if all the commands succeed and False otherwise. If options.keep_going is false, stop as soon as one command fails. Otherwise run all the commands, except that if the first command fails, none of the other commands are run (typically, the first command is a build command and subsequent commands are tests that cannot run if the build failed).''' built = False success = True for command in self.commands: log_command(command) ret = subprocess.call(command) if ret != 0: if command[0] not in ['make', options.make_command]: log_line('*** [{}] Error {}'.format(' '.join(command), ret)) if not options.keep_going or not built: return False success = False built = True return success # If the configuration option A requires B, make sure that # B in REVERSE_DEPENDENCIES[A]. # All the information here should be contained in check_config.h. This # file includes a copy because it changes rarely and it would be a pain # to extract automatically. REVERSE_DEPENDENCIES = { 'MBEDTLS_AES_C': ['MBEDTLS_CTR_DRBG_C', 'MBEDTLS_NIST_KW_C'], 'MBEDTLS_CHACHA20_C': ['MBEDTLS_CHACHAPOLY_C'], 'MBEDTLS_ECDSA_C': ['MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA_ENABLED'], 'MBEDTLS_ECP_C': ['MBEDTLS_ECDSA_C', 'MBEDTLS_ECDH_C', 'MBEDTLS_ECJPAKE_C', 'MBEDTLS_ECP_RESTARTABLE', 'MBEDTLS_PK_PARSE_EC_EXTENDED', 'MBEDTLS_PK_PARSE_EC_COMPRESSED', 'MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECDH_RSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECDHE_PSK_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED', 'MBEDTLS_SSL_TLS1_3_KEY_EXCHANGE_MODE_EPHEMERAL_ENABLED', 'MBEDTLS_SSL_TLS1_3_KEY_EXCHANGE_MODE_PSK_EPHEMERAL_ENABLED'], 'MBEDTLS_ECP_DP_SECP256R1_ENABLED': ['MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED'], 'MBEDTLS_PKCS1_V21': ['MBEDTLS_X509_RSASSA_PSS_SUPPORT'], 'MBEDTLS_PKCS1_V15': ['MBEDTLS_KEY_EXCHANGE_DHE_RSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_RSA_PSK_ENABLED', 'MBEDTLS_KEY_EXCHANGE_RSA_ENABLED'], 'MBEDTLS_RSA_C': ['MBEDTLS_X509_RSASSA_PSS_SUPPORT', 'MBEDTLS_KEY_EXCHANGE_DHE_RSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_RSA_PSK_ENABLED', 'MBEDTLS_KEY_EXCHANGE_RSA_ENABLED', 'MBEDTLS_KEY_EXCHANGE_ECDH_RSA_ENABLED'], 'MBEDTLS_SHA256_C': ['MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED', 'MBEDTLS_ENTROPY_FORCE_SHA256', 'MBEDTLS_SHA256_USE_ARMV8_A_CRYPTO_IF_PRESENT', 'MBEDTLS_SHA256_USE_ARMV8_A_CRYPTO_ONLY', 'MBEDTLS_LMS_C', 'MBEDTLS_LMS_PRIVATE'], 'MBEDTLS_SHA512_C': ['MBEDTLS_SHA512_USE_A64_CRYPTO_IF_PRESENT', 'MBEDTLS_SHA512_USE_A64_CRYPTO_ONLY'], 'MBEDTLS_SHA224_C': ['MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED', 'MBEDTLS_ENTROPY_FORCE_SHA256', 'MBEDTLS_SHA256_USE_ARMV8_A_CRYPTO_IF_PRESENT', 'MBEDTLS_SHA256_USE_ARMV8_A_CRYPTO_ONLY'], 'MBEDTLS_X509_RSASSA_PSS_SUPPORT': [] } # If an option is tested in an exclusive test, alter the following defines. # These are not necessarily dependencies, but just minimal required changes # if a given define is the only one enabled from an exclusive group. EXCLUSIVE_GROUPS = { 'MBEDTLS_SHA512_C': ['-MBEDTLS_SSL_COOKIE_C', '-MBEDTLS_SSL_TLS_C'], 'MBEDTLS_ECP_DP_CURVE448_ENABLED': ['-MBEDTLS_ECDSA_C', '-MBEDTLS_ECDSA_DETERMINISTIC', '-MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED', '-MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA_ENABLED', '-MBEDTLS_ECJPAKE_C', '-MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED'], 'MBEDTLS_ECP_DP_CURVE25519_ENABLED': ['-MBEDTLS_ECDSA_C', '-MBEDTLS_ECDSA_DETERMINISTIC', '-MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED', '-MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA_ENABLED', '-MBEDTLS_ECJPAKE_C', '-MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED'], 'MBEDTLS_ARIA_C': ['-MBEDTLS_CMAC_C'], 'MBEDTLS_CAMELLIA_C': ['-MBEDTLS_CMAC_C'], 'MBEDTLS_CHACHA20_C': ['-MBEDTLS_CMAC_C', '-MBEDTLS_CCM_C', '-MBEDTLS_GCM_C'], 'MBEDTLS_DES_C': ['-MBEDTLS_CCM_C', '-MBEDTLS_GCM_C', '-MBEDTLS_SSL_TICKET_C', '-MBEDTLS_SSL_CONTEXT_SERIALIZATION'], } def handle_exclusive_groups(config_settings, symbol): """For every symbol tested in an exclusive group check if there are other defines to be altered. """ for dep in EXCLUSIVE_GROUPS.get(symbol, []): unset = dep.startswith('-') dep = dep[1:] config_settings[dep] = not unset def turn_off_dependencies(config_settings): """For every option turned off config_settings, also turn off what depends on it. An option O is turned off if config_settings[O] is False.""" for key, value in sorted(config_settings.items()): if value is not False: continue for dep in REVERSE_DEPENDENCIES.get(key, []): config_settings[dep] = False class BaseDomain: # pylint: disable=too-few-public-methods, unused-argument """A base class for all domains.""" def __init__(self, symbols, commands, exclude): """Initialize the jobs container""" self.jobs = [] class ExclusiveDomain(BaseDomain): # pylint: disable=too-few-public-methods """A domain consisting of a set of conceptually-equivalent settings. Establish a list of configuration symbols. For each symbol, run a test job with this symbol set and the others unset.""" def __init__(self, symbols, commands, exclude=None): """Build a domain for the specified list of configuration symbols. The domain contains a set of jobs that enable one of the elements of symbols and disable the others. Each job runs the specified commands. If exclude is a regular expression, skip generated jobs whose description would match this regular expression.""" super().__init__(symbols, commands, exclude) base_config_settings = {} for symbol in symbols: base_config_settings[symbol] = False for symbol in symbols: description = symbol if exclude and re.match(exclude, description): continue config_settings = base_config_settings.copy() config_settings[symbol] = True handle_exclusive_groups(config_settings, symbol) turn_off_dependencies(config_settings) job = Job(description, config_settings, commands) self.jobs.append(job) class ComplementaryDomain(BaseDomain): # pylint: disable=too-few-public-methods """A domain consisting of a set of loosely-related settings. Establish a list of configuration symbols. For each symbol, run a test job with this symbol unset. If exclude is a regular expression, skip generated jobs whose description would match this regular expression.""" def __init__(self, symbols, commands, exclude=None): """Build a domain for the specified list of configuration symbols. Each job in the domain disables one of the specified symbols. Each job runs the specified commands.""" super().__init__(symbols, commands, exclude) for symbol in symbols: description = '!' + symbol if exclude and re.match(exclude, description): continue config_settings = {symbol: False} turn_off_dependencies(config_settings) job = Job(description, config_settings, commands) self.jobs.append(job) class DualDomain(ExclusiveDomain, ComplementaryDomain): # pylint: disable=too-few-public-methods """A domain that contains both the ExclusiveDomain and BaseDomain tests. Both parent class __init__ calls are performed in any order and each call adds respective jobs. The job array initialization is done once in BaseDomain, before the parent __init__ calls.""" class CipherInfo: # pylint: disable=too-few-public-methods """Collect data about cipher.h.""" def __init__(self): self.base_symbols = set() with open('include/mbedtls/cipher.h', encoding="utf-8") as fh: for line in fh: m = re.match(r' *MBEDTLS_CIPHER_ID_(\w+),', line) if m and m.group(1) not in ['NONE', 'NULL', '3DES']: self.base_symbols.add('MBEDTLS_' + m.group(1) + '_C') class DomainData: """A container for domains and jobs, used to structurize testing.""" def config_symbols_matching(self, regexp): """List the mbedtls_config.h settings matching regexp.""" return [symbol for symbol in self.all_config_symbols if re.match(regexp, symbol)] def __init__(self, options, conf): """Gather data about the library and establish a list of domains to test.""" build_command = [options.make_command] + options.make_vars.split(' ') + \ ['CFLAGS=-Werror -O2'] build_and_test = [build_command, [options.make_command, 'test']] self.all_config_symbols = set(conf.settings.keys()) # Find hash modules by name. hash_symbols = self.config_symbols_matching(r'MBEDTLS_(MD|RIPEMD|SHA)[0-9]+_C\Z') # Find elliptic curve enabling macros by name. curve_symbols = self.config_symbols_matching(r'MBEDTLS_ECP_DP_\w+_ENABLED\Z') # Find key exchange enabling macros by name. key_exchange_symbols = self.config_symbols_matching(r'MBEDTLS_KEY_EXCHANGE_\w+_ENABLED\Z') # Find cipher IDs (block permutations and stream ciphers --- chaining # and padding modes are exercised separately) information by parsing # cipher.h, as the information is not readily available in mbedtls_config.h. cipher_info = CipherInfo() # Find block cipher chaining and padding mode enabling macros by name. cipher_chaining_symbols = self.config_symbols_matching(r'MBEDTLS_CIPHER_MODE_\w+\Z') cipher_padding_symbols = self.config_symbols_matching(r'MBEDTLS_CIPHER_PADDING_\w+\Z') self.domains = { # Cipher IDs, chaining modes and padding modes. Run the test suites. 'cipher_id': ExclusiveDomain(cipher_info.base_symbols, build_and_test), 'cipher_chaining': ExclusiveDomain(cipher_chaining_symbols, build_and_test), 'cipher_padding': ExclusiveDomain(cipher_padding_symbols, build_and_test), # Elliptic curves. Run the test suites. 'curves': ExclusiveDomain(curve_symbols, build_and_test), # Hash algorithms. Excluding exclusive domains of MD, RIPEMD, SHA1, # SHA224 and SHA384 because MBEDTLS_ENTROPY_C is extensively used # across various modules, but it depends on either SHA256 or SHA512. # As a consequence an "exclusive" test of anything other than SHA256 # or SHA512 with MBEDTLS_ENTROPY_C enabled is not possible. 'hashes': DualDomain(hash_symbols, build_and_test, exclude=r'MBEDTLS_(MD|RIPEMD|SHA1_)' \ '|MBEDTLS_SHA224_' \ '|MBEDTLS_SHA384_' \ '|MBEDTLS_SHA3_'), # Key exchange types. 'kex': ExclusiveDomain(key_exchange_symbols, build_and_test), 'pkalgs': ComplementaryDomain(['MBEDTLS_ECDSA_C', 'MBEDTLS_ECP_C', 'MBEDTLS_PKCS1_V21', 'MBEDTLS_PKCS1_V15', 'MBEDTLS_RSA_C', 'MBEDTLS_X509_RSASSA_PSS_SUPPORT'], build_and_test), } self.jobs = {} for domain in self.domains.values(): for job in domain.jobs: self.jobs[job.name] = job def get_jobs(self, name): """Return the list of jobs identified by the given name. A name can either be the name of a domain or the name of one specific job.""" if name in self.domains: return sorted(self.domains[name].jobs, key=lambda job: job.name) else: return [self.jobs[name]] def run(options, job, conf, colors=NO_COLORS): """Run the specified job (a Job instance).""" subprocess.check_call([options.make_command, 'clean']) job.announce(colors, None) if not job.configure(conf, options, colors): job.announce(colors, False) return False conf.write() success = job.test(options) job.announce(colors, success) return success def run_tests(options, domain_data, conf): """Run the desired jobs. domain_data should be a DomainData instance that describes the available domains and jobs. Run the jobs listed in options.tasks.""" if not hasattr(options, 'config_backup'): options.config_backup = options.config + '.bak' colors = Colors(options) jobs = [] failures = [] successes = [] for name in options.tasks: jobs += domain_data.get_jobs(name) backup_config(options) try: for job in jobs: success = run(options, job, conf, colors=colors) if not success: if options.keep_going: failures.append(job.name) else: return False else: successes.append(job.name) restore_config(options) except: # Restore the configuration, except in stop-on-error mode if there # was an error, where we leave the failing configuration up for # developer convenience. if options.keep_going: restore_config(options) raise if successes: log_line('{} passed'.format(' '.join(successes)), color=colors.bold_green) if failures: log_line('{} FAILED'.format(' '.join(failures)), color=colors.bold_red) return False else: return True def main(): try: parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, description= "Test Mbed TLS with a subset of algorithms.\n\n" "Example usage:\n" r"./tests/scripts/depends.py \!MBEDTLS_SHA1_C MBEDTLS_SHA256_C""\n" "./tests/scripts/depends.py MBEDTLS_AES_C hashes\n" "./tests/scripts/depends.py cipher_id cipher_chaining\n") parser.add_argument('--color', metavar='WHEN', help='Colorize the output (always/auto/never)', choices=['always', 'auto', 'never'], default='auto') parser.add_argument('-c', '--config', metavar='FILE', help='Configuration file to modify', default='include/mbedtls/mbedtls_config.h') parser.add_argument('-C', '--directory', metavar='DIR', help='Change to this directory before anything else', default='.') parser.add_argument('-k', '--keep-going', help='Try all configurations even if some fail (default)', action='store_true', dest='keep_going', default=True) parser.add_argument('-e', '--no-keep-going', help='Stop as soon as a configuration fails', action='store_false', dest='keep_going') parser.add_argument('--list-jobs', help='List supported jobs and exit', action='append_const', dest='list', const='jobs') parser.add_argument('--list-domains', help='List supported domains and exit', action='append_const', dest='list', const='domains') parser.add_argument('--make-command', metavar='CMD', help='Command to run instead of make (e.g. gmake)', action='store', default='make') parser.add_argument('--make-vars', help='optional variable/value pairs to pass to make', action='store', default='') parser.add_argument('--unset-use-psa', help='Unset MBEDTLS_USE_PSA_CRYPTO before any test', action='store_true', dest='unset_use_psa') parser.add_argument('tasks', metavar='TASKS', nargs='*', help='The domain(s) or job(s) to test (default: all).', default=True) options = parser.parse_args() os.chdir(options.directory) conf = config.ConfigFile(options.config) domain_data = DomainData(options, conf) if options.tasks is True: options.tasks = sorted(domain_data.domains.keys()) if options.list: for arg in options.list: for domain_name in sorted(getattr(domain_data, arg).keys()): print(domain_name) sys.exit(0) else: sys.exit(0 if run_tests(options, domain_data, conf) else 1) except Exception: # pylint: disable=broad-except traceback.print_exc() sys.exit(3) if __name__ == '__main__': main()