Merge pull request #4214 from gilles-peskine-arm/psa-storage-format-test-types

PSA storage format test case generator
This commit is contained in:
Gilles Peskine 2021-03-22 12:16:17 +01:00 committed by GitHub
commit 01196d0464
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 2255 additions and 163 deletions

View file

@ -106,4 +106,4 @@ check scripts/generate_query_config.pl programs/test/query_config.c
check scripts/generate_features.pl library/version_features.c
check scripts/generate_visualc_files.pl visualc/VS2010
check scripts/generate_psa_constants.py programs/psa/psa_constant_names_generated.c
check tests/scripts/generate_psa_tests.py tests/suites/test_suite_psa_crypto_not_supported.generated.data
check tests/scripts/generate_psa_tests.py $(tests/scripts/generate_psa_tests.py --list)

View file

@ -1,5 +1,8 @@
#!/usr/bin/env python3
"""Generate test data for PSA cryptographic mechanisms.
With no arguments, generate all test data. With non-option arguments,
generate only the specified files.
"""
# Copyright The Mbed TLS Contributors
@ -21,11 +24,12 @@ import argparse
import os
import re
import sys
from typing import FrozenSet, Iterable, List, Optional, TypeVar
from typing import Callable, Dict, FrozenSet, Iterable, Iterator, List, Optional, TypeVar
import scripts_path # pylint: disable=unused-import
from mbedtls_dev import crypto_knowledge
from mbedtls_dev import macro_collector
from mbedtls_dev import psa_storage
from mbedtls_dev import test_case
T = TypeVar('T') #pylint: disable=invalid-name
@ -54,6 +58,18 @@ def finish_family_dependencies(dependencies: List[str], bits: int) -> List[str]:
"""
return [finish_family_dependency(dep, bits) for dep in dependencies]
def automatic_dependencies(*expressions: str) -> List[str]:
"""Infer dependencies of a test case by looking for PSA_xxx symbols.
The arguments are strings which should be C expressions. Do not use
string literals or comments as this function is not smart enough to
skip them.
"""
used = set()
for expr in expressions:
used.update(re.findall(r'PSA_(?:ALG|ECC_FAMILY|KEY_TYPE)_\w+', expr))
return sorted(psa_want_symbol(name) for name in used)
# A temporary hack: at the time of writing, not all dependency symbols
# are implemented yet. Skip test cases for which the dependency symbols are
# not available. Once all dependency symbols are available, this hack must
@ -69,41 +85,13 @@ def hack_dependencies_not_implemented(dependencies: List[str]) -> None:
for dep in dependencies):
dependencies.append('DEPENDENCY_NOT_IMPLEMENTED_YET')
def test_case_for_key_type_not_supported(
verb: str, key_type: str, bits: int,
dependencies: List[str],
*args: str,
param_descr: str = ''
) -> test_case.TestCase:
"""Return one test case exercising a key creation method
for an unsupported key type or size.
"""
hack_dependencies_not_implemented(dependencies)
tc = test_case.TestCase()
short_key_type = re.sub(r'PSA_(KEY_TYPE|ECC_FAMILY)_', r'', key_type)
adverb = 'not' if dependencies else 'never'
if param_descr:
adverb = param_descr + ' ' + adverb
tc.set_description('PSA {} {} {}-bit {} supported'
.format(verb, short_key_type, bits, adverb))
tc.set_dependencies(dependencies)
tc.set_function(verb + '_not_supported')
tc.set_arguments([key_type] + list(args))
return tc
class TestGenerator:
"""Gather information and generate test data."""
class Information:
"""Gather information about PSA constructors."""
def __init__(self, options):
self.test_suite_directory = self.get_option(options, 'directory',
'tests/suites')
def __init__(self) -> None:
self.constructors = self.read_psa_interface()
@staticmethod
def get_option(options, name: str, default: T) -> T:
value = getattr(options, name, None)
return default if value is None else value
@staticmethod
def remove_unwanted_macros(
constructors: macro_collector.PSAMacroCollector
@ -126,14 +114,34 @@ class TestGenerator:
self.remove_unwanted_macros(constructors)
return constructors
def write_test_data_file(self, basename: str,
test_cases: Iterable[test_case.TestCase]) -> None:
"""Write the test cases to a .data file.
The output file is ``basename + '.data'`` in the test suite directory.
"""
filename = os.path.join(self.test_suite_directory, basename + '.data')
test_case.write_data_file(filename, test_cases)
def test_case_for_key_type_not_supported(
verb: str, key_type: str, bits: int,
dependencies: List[str],
*args: str,
param_descr: str = ''
) -> test_case.TestCase:
"""Return one test case exercising a key creation method
for an unsupported key type or size.
"""
hack_dependencies_not_implemented(dependencies)
tc = test_case.TestCase()
short_key_type = re.sub(r'PSA_(KEY_TYPE|ECC_FAMILY)_', r'', key_type)
adverb = 'not' if dependencies else 'never'
if param_descr:
adverb = param_descr + ' ' + adverb
tc.set_description('PSA {} {} {}-bit {} supported'
.format(verb, short_key_type, bits, adverb))
tc.set_dependencies(dependencies)
tc.set_function(verb + '_not_supported')
tc.set_arguments([key_type] + list(args))
return tc
class NotSupported:
"""Generate test cases for when something is not supported."""
def __init__(self, info: Information) -> None:
self.constructors = info.constructors
ALWAYS_SUPPORTED = frozenset([
'PSA_KEY_TYPE_DERIVE',
@ -144,7 +152,7 @@ class TestGenerator:
kt: crypto_knowledge.KeyType,
param: Optional[int] = None,
param_descr: str = '',
) -> List[test_case.TestCase]:
) -> Iterator[test_case.TestCase]:
"""Return test cases exercising key creation when the given type is unsupported.
If param is present and not None, emit test cases conditioned on this
@ -154,7 +162,7 @@ class TestGenerator:
if kt.name in self.ALWAYS_SUPPORTED:
# Don't generate test cases for key types that are always supported.
# They would be skipped in all configurations, which is noise.
return []
return
import_dependencies = [('!' if param is None else '') +
psa_want_symbol(kt.name)]
if kt.params is not None:
@ -165,56 +173,275 @@ class TestGenerator:
generate_dependencies = []
else:
generate_dependencies = import_dependencies
test_cases = []
for bits in kt.sizes_to_test():
test_cases.append(test_case_for_key_type_not_supported(
yield test_case_for_key_type_not_supported(
'import', kt.expression, bits,
finish_family_dependencies(import_dependencies, bits),
test_case.hex_string(kt.key_material(bits)),
param_descr=param_descr,
))
)
if not generate_dependencies and param is not None:
# If generation is impossible for this key type, rather than
# supported or not depending on implementation capabilities,
# only generate the test case once.
continue
test_cases.append(test_case_for_key_type_not_supported(
yield test_case_for_key_type_not_supported(
'generate', kt.expression, bits,
finish_family_dependencies(generate_dependencies, bits),
str(bits),
param_descr=param_descr,
))
)
# To be added: derive
return test_cases
def generate_not_supported(self) -> None:
def test_cases_for_not_supported(self) -> Iterator[test_case.TestCase]:
"""Generate test cases that exercise the creation of keys of unsupported types."""
test_cases = []
for key_type in sorted(self.constructors.key_types):
kt = crypto_knowledge.KeyType(key_type)
test_cases += self.test_cases_for_key_type_not_supported(kt)
# To be added: parametrized key types FFDH
yield from self.test_cases_for_key_type_not_supported(kt)
for curve_family in sorted(self.constructors.ecc_curves):
for constr in ('PSA_KEY_TYPE_ECC_KEY_PAIR',
'PSA_KEY_TYPE_ECC_PUBLIC_KEY'):
kt = crypto_knowledge.KeyType(constr, [curve_family])
test_cases += self.test_cases_for_key_type_not_supported(
yield from self.test_cases_for_key_type_not_supported(
kt, param_descr='type')
test_cases += self.test_cases_for_key_type_not_supported(
yield from self.test_cases_for_key_type_not_supported(
kt, 0, param_descr='curve')
self.write_test_data_file(
'test_suite_psa_crypto_not_supported.generated',
test_cases)
def generate_all(self):
self.generate_not_supported()
class StorageKey(psa_storage.Key):
"""Representation of a key for storage format testing."""
def __init__(self, *, description: str, **kwargs) -> None:
super().__init__(**kwargs)
self.description = description #type: str
class StorageFormat:
"""Storage format stability test cases."""
def __init__(self, info: Information, version: int, forward: bool) -> None:
"""Prepare to generate test cases for storage format stability.
* `info`: information about the API. See the `Information` class.
* `version`: the storage format version to generate test cases for.
* `forward`: if true, generate forward compatibility test cases which
save a key and check that its representation is as intended. Otherwise
generate backward compatibility test cases which inject a key
representation and check that it can be read and used.
"""
self.constructors = info.constructors
self.version = version
self.forward = forward
def make_test_case(self, key: StorageKey) -> test_case.TestCase:
"""Construct a storage format test case for the given key.
If ``forward`` is true, generate a forward compatibility test case:
create a key and validate that it has the expected representation.
Otherwise generate a backward compatibility test case: inject the
key representation into storage and validate that it can be read
correctly.
"""
verb = 'save' if self.forward else 'read'
tc = test_case.TestCase()
tc.set_description('PSA storage {}: {}'.format(verb, key.description))
dependencies = automatic_dependencies(
key.lifetime.string, key.type.string,
key.usage.string, key.alg.string, key.alg2.string,
)
dependencies = finish_family_dependencies(dependencies, key.bits)
tc.set_dependencies(dependencies)
tc.set_function('key_storage_' + verb)
if self.forward:
extra_arguments = []
else:
# Some test keys have the RAW_DATA type and attributes that don't
# necessarily make sense. We do this to validate numerical
# encodings of the attributes.
# Raw data keys have no useful exercise anyway so there is no
# loss of test coverage.
exercise = key.type.string != 'PSA_KEY_TYPE_RAW_DATA'
extra_arguments = ['1' if exercise else '0']
tc.set_arguments([key.lifetime.string,
key.type.string, str(key.bits),
key.usage.string, key.alg.string, key.alg2.string,
'"' + key.material.hex() + '"',
'"' + key.hex() + '"',
*extra_arguments])
return tc
def key_for_usage_flags(
self,
usage_flags: List[str],
short: Optional[str] = None
) -> StorageKey:
"""Construct a test key for the given key usage."""
usage = ' | '.join(usage_flags) if usage_flags else '0'
if short is None:
short = re.sub(r'\bPSA_KEY_USAGE_', r'', usage)
description = 'usage: ' + short
key = StorageKey(version=self.version,
id=1, lifetime=0x00000001,
type='PSA_KEY_TYPE_RAW_DATA', bits=8,
usage=usage, alg=0, alg2=0,
material=b'K',
description=description)
return key
def all_keys_for_usage_flags(self) -> Iterator[StorageKey]:
"""Generate test keys covering usage flags."""
known_flags = sorted(self.constructors.key_usage_flags)
yield self.key_for_usage_flags(['0'])
for usage_flag in known_flags:
yield self.key_for_usage_flags([usage_flag])
for flag1, flag2 in zip(known_flags,
known_flags[1:] + [known_flags[0]]):
yield self.key_for_usage_flags([flag1, flag2])
yield self.key_for_usage_flags(known_flags, short='all known')
def keys_for_type(
self,
key_type: str,
params: Optional[Iterable[str]] = None
) -> Iterator[StorageKey]:
"""Generate test keys for the given key type.
For key types that depend on a parameter (e.g. elliptic curve family),
`param` is the parameter to pass to the constructor. Only a single
parameter is supported.
"""
kt = crypto_knowledge.KeyType(key_type, params)
for bits in kt.sizes_to_test():
usage_flags = 'PSA_KEY_USAGE_EXPORT'
alg = 0
alg2 = 0
key_material = kt.key_material(bits)
short_expression = re.sub(r'\bPSA_(?:KEY_TYPE|ECC_FAMILY)_',
r'',
kt.expression)
description = 'type: {} {}-bit'.format(short_expression, bits)
key = StorageKey(version=self.version,
id=1, lifetime=0x00000001,
type=kt.expression, bits=bits,
usage=usage_flags, alg=alg, alg2=alg2,
material=key_material,
description=description)
yield key
def all_keys_for_types(self) -> Iterator[StorageKey]:
"""Generate test keys covering key types and their representations."""
for key_type in sorted(self.constructors.key_types):
yield from self.keys_for_type(key_type)
for key_type in sorted(self.constructors.key_types_from_curve):
for curve in sorted(self.constructors.ecc_curves):
yield from self.keys_for_type(key_type, [curve])
## Diffie-Hellman (FFDH) is not supported yet, either in
## crypto_knowledge.py or in Mbed TLS.
# for key_type in sorted(self.constructors.key_types_from_group):
# for group in sorted(self.constructors.dh_groups):
# yield from self.keys_for_type(key_type, [group])
def keys_for_algorithm(self, alg: str) -> Iterator[StorageKey]:
"""Generate test keys for the specified algorithm."""
# For now, we don't have information on the compatibility of key
# types and algorithms. So we just test the encoding of algorithms,
# and not that operations can be performed with them.
descr = alg
usage = 'PSA_KEY_USAGE_EXPORT'
key1 = StorageKey(version=self.version,
id=1, lifetime=0x00000001,
type='PSA_KEY_TYPE_RAW_DATA', bits=8,
usage=usage, alg=alg, alg2=0,
material=b'K',
description='alg: ' + descr)
yield key1
key2 = StorageKey(version=self.version,
id=1, lifetime=0x00000001,
type='PSA_KEY_TYPE_RAW_DATA', bits=8,
usage=usage, alg=0, alg2=alg,
material=b'L',
description='alg2: ' + descr)
yield key2
def all_keys_for_algorithms(self) -> Iterator[StorageKey]:
"""Generate test keys covering algorithm encodings."""
for alg in sorted(self.constructors.algorithms):
yield from self.keys_for_algorithm(alg)
# To do: algorithm constructors with parameters
def all_test_cases(self) -> Iterator[test_case.TestCase]:
"""Generate all storage format test cases."""
for key in self.all_keys_for_usage_flags():
yield self.make_test_case(key)
for key in self.all_keys_for_types():
yield self.make_test_case(key)
for key in self.all_keys_for_algorithms():
yield self.make_test_case(key)
# To do: vary id, lifetime
class TestGenerator:
"""Generate test data."""
def __init__(self, options) -> None:
self.test_suite_directory = self.get_option(options, 'directory',
'tests/suites')
self.info = Information()
@staticmethod
def get_option(options, name: str, default: T) -> T:
value = getattr(options, name, None)
return default if value is None else value
def filename_for(self, basename: str) -> str:
"""The location of the data file with the specified base name."""
return os.path.join(self.test_suite_directory, basename + '.data')
def write_test_data_file(self, basename: str,
test_cases: Iterable[test_case.TestCase]) -> None:
"""Write the test cases to a .data file.
The output file is ``basename + '.data'`` in the test suite directory.
"""
filename = self.filename_for(basename)
test_case.write_data_file(filename, test_cases)
TARGETS = {
'test_suite_psa_crypto_not_supported.generated':
lambda info: NotSupported(info).test_cases_for_not_supported(),
'test_suite_psa_crypto_storage_format.current':
lambda info: StorageFormat(info, 0, True).all_test_cases(),
'test_suite_psa_crypto_storage_format.v0':
lambda info: StorageFormat(info, 0, False).all_test_cases(),
} #type: Dict[str, Callable[[Information], Iterable[test_case.TestCase]]]
def generate_target(self, name: str) -> None:
test_cases = self.TARGETS[name](self.info)
self.write_test_data_file(name, test_cases)
def main(args):
"""Command line entry point."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--list', action='store_true',
help='List available targets and exit')
parser.add_argument('targets', nargs='*', metavar='TARGET',
help='Target file to generate (default: all; "-": none)')
options = parser.parse_args(args)
generator = TestGenerator(options)
generator.generate_all()
if options.list:
for name in sorted(generator.TARGETS):
print(generator.filename_for(name))
return
if options.targets:
# Allow "-" as a special case so you can run
# ``generate_psa_tests.py - $targets`` and it works uniformly whether
# ``$targets`` is empty or not.
options.targets = [os.path.basename(re.sub(r'\.data\Z', r'', target))
for target in options.targets
if target != '-']
else:
options.targets = sorted(generator.TARGETS)
for target in options.targets:
generator.generate_target(target)
if __name__ == '__main__':
main(sys.argv[1:])

View file

@ -24,7 +24,6 @@ or 1 (with a Python backtrace) if there was an operational error.
import argparse
from collections import namedtuple
import itertools
import os
import re
import subprocess
@ -32,6 +31,7 @@ import sys
import scripts_path # pylint: disable=unused-import
from mbedtls_dev import c_build_helper
from mbedtls_dev import macro_collector
class ReadFileLineException(Exception):
def __init__(self, filename, line_number):
@ -78,7 +78,7 @@ class read_file_lines:
raise ReadFileLineException(self.filename, self.line_number) \
from exc_value
class Inputs:
class InputsForTest(macro_collector.PSAMacroEnumerator):
# pylint: disable=too-many-instance-attributes
"""Accumulate information about macros to test.
@ -87,27 +87,29 @@ class Inputs:
"""
def __init__(self):
super().__init__()
self.all_declared = set()
# Sets of names per type
self.statuses = set(['PSA_SUCCESS'])
self.algorithms = set(['0xffffffff'])
self.ecc_curves = set(['0xff'])
self.dh_groups = set(['0xff'])
self.key_types = set(['0xffff'])
self.key_usage_flags = set(['0x80000000'])
self.statuses.add('PSA_SUCCESS')
self.algorithms.add('0xffffffff')
self.ecc_curves.add('0xff')
self.dh_groups.add('0xff')
self.key_types.add('0xffff')
self.key_usage_flags.add('0x80000000')
# Hard-coded values for unknown algorithms
#
# These have to have values that are correct for their respective
# PSA_ALG_IS_xxx macros, but are also not currently assigned and are
# not likely to be assigned in the near future.
self.hash_algorithms = set(['0x020000fe']) # 0x020000ff is PSA_ALG_ANY_HASH
self.mac_algorithms = set(['0x03007fff'])
self.ka_algorithms = set(['0x09fc0000'])
self.kdf_algorithms = set(['0x080000ff'])
self.hash_algorithms.add('0x020000fe') # 0x020000ff is PSA_ALG_ANY_HASH
self.mac_algorithms.add('0x03007fff')
self.ka_algorithms.add('0x09fc0000')
self.kdf_algorithms.add('0x080000ff')
# For AEAD algorithms, the only variability is over the tag length,
# and this only applies to known algorithms, so don't test an
# unknown algorithm.
self.aead_algorithms = set()
# Identifier prefixes
self.table_by_prefix = {
'ERROR': self.statuses,
@ -140,15 +142,10 @@ class Inputs:
'asymmetric_encryption_algorithm': [],
'other_algorithm': [],
}
# macro name -> list of argument names
self.argspecs = {}
# argument name -> list of values
self.arguments_for = {
'mac_length': ['1', '63'],
'tag_length': ['1', '63'],
'min_mac_length': ['1', '63'],
'min_tag_length': ['1', '63'],
}
self.arguments_for['mac_length'] += ['1', '63']
self.arguments_for['min_mac_length'] += ['1', '63']
self.arguments_for['tag_length'] += ['1', '63']
self.arguments_for['min_tag_length'] += ['1', '63']
def get_names(self, type_word):
"""Return the set of known names of values of the given type."""
@ -161,62 +158,6 @@ class Inputs:
'key_usage': self.key_usage_flags,
}[type_word]
def gather_arguments(self):
"""Populate the list of values for macro arguments.
Call this after parsing all the inputs.
"""
self.arguments_for['hash_alg'] = sorted(self.hash_algorithms)
self.arguments_for['mac_alg'] = sorted(self.mac_algorithms)
self.arguments_for['ka_alg'] = sorted(self.ka_algorithms)
self.arguments_for['kdf_alg'] = sorted(self.kdf_algorithms)
self.arguments_for['aead_alg'] = sorted(self.aead_algorithms)
self.arguments_for['curve'] = sorted(self.ecc_curves)
self.arguments_for['group'] = sorted(self.dh_groups)
@staticmethod
def _format_arguments(name, arguments):
"""Format a macro call with arguments.."""
return name + '(' + ', '.join(arguments) + ')'
def distribute_arguments(self, name):
"""Generate macro calls with each tested argument set.
If name is a macro without arguments, just yield "name".
If name is a macro with arguments, yield a series of
"name(arg1,...,argN)" where each argument takes each possible
value at least once.
"""
try:
if name not in self.argspecs:
yield name
return
argspec = self.argspecs[name]
if argspec == []:
yield name + '()'
return
argument_lists = [self.arguments_for[arg] for arg in argspec]
arguments = [values[0] for values in argument_lists]
yield self._format_arguments(name, arguments)
# Dear Pylint, enumerate won't work here since we're modifying
# the array.
# pylint: disable=consider-using-enumerate
for i in range(len(arguments)):
for value in argument_lists[i][1:]:
arguments[i] = value
yield self._format_arguments(name, arguments)
arguments[i] = argument_lists[0][0]
except BaseException as e:
raise Exception('distribute_arguments({})'.format(name)) from e
def generate_expressions(self, names):
return itertools.chain(*map(self.distribute_arguments, names))
_argument_split_re = re.compile(r' *, *')
@classmethod
def _argument_split(cls, arguments):
return re.split(cls._argument_split_re, arguments)
# Regex for interesting header lines.
# Groups: 1=macro name, 2=type, 3=argument list (optional).
_header_line_re = \
@ -301,7 +242,7 @@ class Inputs:
if m:
self.add_test_case_line(m.group(1), m.group(2))
def gather_inputs(headers, test_suites, inputs_class=Inputs):
def gather_inputs(headers, test_suites, inputs_class=InputsForTest):
"""Read the list of inputs to test psa_constant_names with."""
inputs = inputs_class()
for header in headers: