From f9a7620e5b56976b5ed2e6ee84f0f82e33e03378 Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 11 Feb 2026 13:29:11 +0100 Subject: [PATCH 1/8] Create a new directory for non-user-facing scripts Create a directory for scripts that are useful to maintainers, and may be invoked as part of the CI, but are not part of the normal build. These scripts may require a recent Python version and may require additional third-party modules, unlike user-facing scripts where we try to minimize requirements. Signed-off-by: Gilles Peskine --- util/requirements.txt | 1 + util/scripts_path.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 util/requirements.txt create mode 100644 util/scripts_path.py diff --git a/util/requirements.txt b/util/requirements.txt new file mode 100644 index 000000000..7b557b678 --- /dev/null +++ b/util/requirements.txt @@ -0,0 +1 @@ +# Python module requirements for maintainer utilities diff --git a/util/scripts_path.py b/util/scripts_path.py new file mode 100644 index 000000000..49c33e777 --- /dev/null +++ b/util/scripts_path.py @@ -0,0 +1,18 @@ +"""Add our Python library directory to the module search path. + +Usage: + + import scripts_path # pylint: disable=unused-import + from mbedtls_framework import ... +""" + +# Copyright The Mbed TLS Contributors +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later +# + +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), + os.path.pardir, + 'scripts')) From 151585f1a130fed5d579b4394c8ae0930ff1a329 Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 11 Feb 2026 14:43:26 +0100 Subject: [PATCH 2/8] New utility Python module: generate_files_helper A new utility to give a common interface to committed generated files. For the time being, this module is only intended for committed generated files, and `make_generated_files_common.py` is only intended for non-committed generated files. The two may be unified at some point in the future. Signed-off-by: Gilles Peskine --- scripts/mbedtls_framework/config_macros.py | 24 ++- .../generate_files_helper.py | 147 ++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 scripts/mbedtls_framework/generate_files_helper.py diff --git a/scripts/mbedtls_framework/config_macros.py b/scripts/mbedtls_framework/config_macros.py index a30fb8af0..0fe1ac7b5 100644 --- a/scripts/mbedtls_framework/config_macros.py +++ b/scripts/mbedtls_framework/config_macros.py @@ -6,9 +6,10 @@ import glob import os import re -from typing import FrozenSet, Iterable, Iterator +from typing import FrozenSet, Iterable, Iterator, List from . import build_tree +from . import generate_files_helper class ConfigMacros: @@ -34,7 +35,7 @@ class ConfigMacros: for line in input_) -class Current(ConfigMacros): +class Current(ConfigMacros, generate_files_helper.Generator): """Information about config-like macros parsed from the source code.""" _SHADOW_FILE = 'scripts/data_files/config-options-current.txt' @@ -136,6 +137,25 @@ class Current(ConfigMacros): for name in sorted(self.live_config_options()): out.write(name + '\n') + # Implement the generate_files_helper.Generator interface + def generator_name(self) -> str: + """Name as a generate_files_helper.Generator.""" + return 'options' + + def target_files(self) -> List[str]: + """List the (single) generated file name.""" + return [os.path.join(self._submodule, self._SHADOW_FILE)] + + def outdated_files(self) -> List[str]: + """List the (single) generated file name if it is out of date.""" + if self.is_shadow_file_up_to_date(): + return [] + else: + return self.target_files() + + def update(self, always: bool) -> None: + """Update the shadow file from the live config file.""" + self.update_shadow_file(always) class History(ConfigMacros): diff --git a/scripts/mbedtls_framework/generate_files_helper.py b/scripts/mbedtls_framework/generate_files_helper.py new file mode 100644 index 000000000..a4ec4a54b --- /dev/null +++ b/scripts/mbedtls_framework/generate_files_helper.py @@ -0,0 +1,147 @@ +"""Utilities for intermediate files that are generated, but platform-independent +and configuration-independent. +""" + +# Copyright The Mbed TLS Contributors +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later + +import argparse +import sys +from typing import Dict, Iterable, List, Sequence, Set + + +class Generator: + """An abstract base class for generators of intermediate files.""" + + def generator_name(self) -> str: + """A name for this generator. + + Generator names must be unique and should not be identical to + the name of any target. + """ + raise NotImplementedError + + def target_files(self) -> List[str]: + """The list of files targeted by this generator. + + File names are relative to the project root. + """ + raise NotImplementedError + + def outdated_files(self) -> Iterable[str]: + """Return the list of targets that are out of date. + + This is empty after running update(). + Missing targets are considered out of date. + """ + raise NotImplementedError + + def update(self, always: bool) -> None: + """Update the target(s) of this generator. + + If always is false, avoid changing the output file if it already has + the desired content. If always is true, make sure to update the + time stamp on the output file even if it already has the desired content. + """ + raise NotImplementedError + + +def assemble(available: Iterable[Generator]) -> Dict[str, Generator]: + """Assemble the generators into a dictionary with both names and targets as keys.""" + by_ident = {} #type: Dict[str, Generator] + for generator in available: + ident = generator.generator_name() + if ident in by_ident: + raise Exception(f'Generator conflict: name "{ident}" of {generator} ' + f'already recorded for {by_ident[ident]}') + by_ident[ident] = generator + for ident in generator.target_files(): + if ident in by_ident: + raise Exception(f'Generator conflict: target "{ident}" of {generator} ' + f'already recorded for {by_ident[ident]}') + by_ident[ident] = generator + return by_ident + +def list_names(available: Iterable[Generator]) -> List[str]: + """Return the list of generator names.""" + return sorted(generator.generator_name() for generator in available) + +def list_targets(available: Iterable[Generator]) -> List[str]: + """Return the list of generator targets.""" + return sorted(target + for generator in available + for target in generator.target_files()) + +def select(available: Dict[str, Generator], + wanted: Iterable[str]) -> List[Generator]: + """Select generators by name or target.""" + wanted_names = set() #type: Set[str] + for ident in wanted: + if ident not in available: + raise Exception(f'No generator found for {ident}') + wanted_names.add(ident) + return [available[name] for name in sorted(wanted_names)] + +def main(generators: Sequence[Generator], + description: str) -> None: + #pylint: disable=too-many-branches + """Command line entry point. + """ + parser = argparse.ArgumentParser(description=description) + parser.add_argument('--always-update', '-U', + action='store_true', + help=('Update target files unconditionally ' + '(overrides --update)')) + parser.add_argument('--list', + action='store_true', + help='List generator names and targets and exit') + parser.add_argument('--list-names', + action='store_true', + help='List generator names and exit') + parser.add_argument('--list-targets', + action='store_true', + help='List generator targets and exit') + parser.add_argument('--update', '-u', + action='store_true', + help='Update target files if needed') + parser.add_argument('--verbose', '-v', + action='store_true', + help='Be more verbose') + parser.add_argument('idents', nargs='*', metavar='NAME|TARGET', + help='List of generator names or targets (all targets if empty)') + args = parser.parse_args() + + if args.list: + args.list_names = True + args.list_targets = True + if args.list_names: + for name in list_names(generators): + print(name) + if args.list_targets: + for target in list_targets(generators): + print(target) + if args.list_names or args.list_targets: + return + + if args.idents: + available = assemble(generators) + wanted = select(available, args.idents) #type: Sequence[Generator] + else: + wanted = generators + if args.update or args.always_update: + for generator in wanted: + if args.verbose: + sys.stderr.write(f'Running generator {generator.generator_name()}...\n') + generator.update(args.always_update) + else: + outdated = [] #type: List[str] + for generator in wanted: + if args.verbose: + sys.stderr.write(f'Checking targets of generator {generator.generator_name()}...\n') + outdated += generator.outdated_files() + if outdated: + sys.stderr.write(f'Some targets are missing or out of date.\n') + for target in outdated: + print(target) + sys.stderr.write(f'Run {sys.argv[0]} -u and commit the result.') + sys.exit(1) From 2fe235d289af697c108bdbb1a40957cc9e9741c8 Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 11 Feb 2026 16:36:23 +0100 Subject: [PATCH 3/8] Test data generators: add --list-outdated option As part of a long-term unification effort of generation scripts, add an option for test generators to list oudated targets without writing to files. This corresponds to functionality that the new generate_files_helper module offers. Signed-off-by: Gilles Peskine --- scripts/mbedtls_framework/test_case.py | 20 +++++++---- .../mbedtls_framework/test_data_generation.py | 35 ++++++++++++++++--- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/scripts/mbedtls_framework/test_case.py b/scripts/mbedtls_framework/test_case.py index 58e75a1ca..a4f81ce7f 100644 --- a/scripts/mbedtls_framework/test_case.py +++ b/scripts/mbedtls_framework/test_case.py @@ -127,6 +127,18 @@ class TestCase: out.write(prefix + self.function + ':' + ':'.join(self.arguments) + '\n') +def write_data_stream(out, + test_cases: Iterable[TestCase], + caller: Optional[str] = None) -> None: + """Write the test cases to the specified output stream.""" + if caller is None: + caller = os.path.basename(sys.argv[0]) + out.write('# Automatically generated by {}. Do not edit!\n' + .format(caller)) + for tc in test_cases: + tc.write(out) + out.write('\n# End of automatically generated file.\n') + def write_data_file(filename: str, test_cases: Iterable[TestCase], caller: Optional[str] = None) -> None: @@ -134,15 +146,9 @@ def write_data_file(filename: str, If the file already exists, it is overwritten. """ - if caller is None: - caller = os.path.basename(sys.argv[0]) tempfile = filename + '.new' with open(tempfile, 'w') as out: - out.write('# Automatically generated by {}. Do not edit!\n' - .format(caller)) - for tc in test_cases: - tc.write(out) - out.write('\n# End of automatically generated file.\n') + write_data_stream(out, test_cases, caller) os.replace(tempfile, filename) def psa_or_3_6_feature_macro(psa_name: str, diff --git a/scripts/mbedtls_framework/test_data_generation.py b/scripts/mbedtls_framework/test_data_generation.py index a84f7dd2f..959fc1515 100644 --- a/scripts/mbedtls_framework/test_data_generation.py +++ b/scripts/mbedtls_framework/test_data_generation.py @@ -11,6 +11,7 @@ These are used both by generate_psa_tests.py and generate_bignum_tests.py. # import argparse +import io import os import posixpath import re @@ -139,6 +140,11 @@ class BaseTarget: class TestGenerator: """Generate test cases and write to data files.""" + + # Note that targets whose names contain 'test_format' have their content + # validated by `abi_check.py`. + targets = {} # type: Dict[str, Callable[..., Iterable[test_case.TestCase]]] + def __init__(self, options) -> None: self.test_suite_directory = options.directory # Update `targets` with an entry for each child class of BaseTarget. @@ -163,10 +169,6 @@ class TestGenerator: filename = self.filename_for(basename) test_case.write_data_file(filename, test_cases) - # Note that targets whose names contain 'test_format' have their content - # validated by `abi_check.py`. - targets = {} # type: Dict[str, Callable[..., Iterable[test_case.TestCase]]] - def generate_target(self, name: str, *target_args) -> None: """Generate cases and write to data file for a target. @@ -176,6 +178,22 @@ class TestGenerator: test_cases = self.targets[name](*target_args) self.write_test_data_file(name, test_cases) + def is_up_to_date(self, target) -> bool: + """Check if the given target already has the expected content.""" + filename = self.filename_for(target) + if not os.path.exists(filename): + return False + test_cases = self.targets[target]() + out = io.StringIO() + test_case.write_data_stream(out, test_cases) + out.seek(0) + new_content = out.read() + out.close() + with open(filename) as current_file: + old_content = current_file.read() + return new_content == old_content + + def main(args, description: str, generator_class: Type[TestGenerator] = TestGenerator): """Command line entry point.""" parser = argparse.ArgumentParser(description=description) @@ -183,6 +201,9 @@ def main(args, description: str, generator_class: Type[TestGenerator] = TestGene help='List available targets and exit') parser.add_argument('--list-for-cmake', action='store_true', help='Print \';\'-separated list of available targets and exit') + parser.add_argument('--list-outdated', action='store_true', + help=('List outdated targets and exit ' + '(succeeds even if there are outdated or missing targets)')) # If specified explicitly, this option may be a path relative to the # current directory when the script is invoked. The default value # is relative to the mbedtls root, which we don't know yet. So we @@ -221,4 +242,8 @@ def main(args, description: str, generator_class: Type[TestGenerator] = TestGene else: options.targets = sorted(generator.targets) for target in options.targets: - generator.generate_target(target) + if options.list_outdated: + if not generator.is_up_to_date(target): + print(generator.filename_for(target)) + else: + generator.generate_target(target) From d85217d8f58aa5f5428e9b92f9d8ee65804a797b Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 11 Feb 2026 16:38:28 +0100 Subject: [PATCH 4/8] Support test data generators in generate_files_helper.py Support check and always-update mode. Update-if-needed mode falls back to always-update mode because test_data_generation.py doesn't support update-if-needed. Signed-off-by: Gilles Peskine --- .../generate_files_helper.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/scripts/mbedtls_framework/generate_files_helper.py b/scripts/mbedtls_framework/generate_files_helper.py index a4ec4a54b..10ab2e977 100644 --- a/scripts/mbedtls_framework/generate_files_helper.py +++ b/scripts/mbedtls_framework/generate_files_helper.py @@ -6,6 +6,8 @@ and configuration-independent. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later import argparse +import os +import subprocess import sys from typing import Dict, Iterable, List, Sequence, Set @@ -46,6 +48,39 @@ class Generator: raise NotImplementedError +class TestDataGenerator(Generator): + """A test data generator script. + + Even though the test data generator scripts are written in Python, we + run them as a separate process, because their output depends on the + program name (they write sys.argv[0] in a comment in the .data file). + """ + + def __init__(self, script: str) -> None: + """Run the specified test generator to generate files. + + Assume that the script is written in Python and has the command line + interface of test_data_generation.py. + """ + self.script = script + + def generator_name(self) -> str: + return os.path.basename(self.script) + + def target_files(self) -> List[str]: + output = subprocess.check_output([sys.executable, self.script, '--list'], + encoding='utf-8') + return output.splitlines() + + def outdated_files(self) -> List[str]: + output = subprocess.check_output([sys.executable, self.script, '--list-outdated'], + encoding='utf-8') + return output.splitlines() + + def update(self, _always) -> None: + subprocess.check_call([sys.executable, self.script]) + + def assemble(available: Iterable[Generator]) -> Dict[str, Generator]: """Assemble the generators into a dictionary with both names and targets as keys.""" by_ident = {} #type: Dict[str, Generator] From c88127f4885c80f67e7bd2887b14a17a6d148e1a Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Mon, 8 Dec 2025 10:44:26 +0100 Subject: [PATCH 5/8] ML-DSA test case generator Generate some basic test cases for the mldsa-native functions. Uses `dilithium_py` from https://github.com/GiacomoPope/dilithium-py (`pip install dilithium-py`), which requires Python >=3.9. Signed-off-by: Gilles Peskine --- util/generate_mldsa_tests.py | 201 +++++++++++++++++++++++++++++++++++ util/requirements.txt | 3 + 2 files changed, 204 insertions(+) create mode 100755 util/generate_mldsa_tests.py diff --git a/util/generate_mldsa_tests.py b/util/generate_mldsa_tests.py new file mode 100755 index 000000000..a49fed9fd --- /dev/null +++ b/util/generate_mldsa_tests.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Generate ML-DSA test cases. +""" + +# Copyright The Mbed TLS Contributors +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later + +import sys +from typing import Iterable, List, Optional + +# pip install dilithium-py +import dilithium_py.ml_dsa #type: ignore + +import scripts_path # pylint: disable=unused-import +from mbedtls_framework import test_case +from mbedtls_framework import test_data_generation + +# ML_DSA instances for pure ML-DSA +PURE = { + #44: dilithium_py.ml_dsa.ML_DSA_44, + #65: dilithium_py.ml_dsa.ML_DSA_65, + 87: dilithium_py.ml_dsa.ML_DSA_87, +} + +# ML_DSA instances for HashML-DSA +HASH = { + #44: dilithium_py.ml_dsa.HASH_ML_DSA_44_WITH_SHA512, + #65: dilithium_py.ml_dsa.HASH_ML_DSA_65_WITH_SHA512, + 87: dilithium_py.ml_dsa.HASH_ML_DSA_87_WITH_SHA512, +} + +# Seeds (i.e. private keys) to test with. +SEEDS = [ + b'There was once upon a time a ...', + b'\x00' * 32, +] + +class Key: + """An MLDSA key pair.""" + #pylint: disable=too-few-public-methods + + def __init__(self, kl: int, seed: bytes) -> None: + self.kl = kl #pylint: disable=invalid-name + self.seed = seed + self.public, self.secret = PURE[kl]._keygen_internal(seed) + + def sign_message(self, message: bytes, deterministic: bool) -> bytes: + return PURE[self.kl].sign(self.secret, message, + deterministic=deterministic) + +# Key pairs to test with. +KEYS = {kl: [Key(kl, seed) for seed in SEEDS] + for kl in sorted(PURE.keys())} + +# Input messages to test with. +MESSAGES = [ + (b'This is a test', ''), + (b'', 'empty message'), + (b'\x00', '"\\x00"'), + (b'\x01', '"\\x01"'), + (b'ACBDEFGHIJ' * 100, '1000B'), +] + + +class API: + """Abstract base class for the interface of the test functions.""" + + @classmethod + def function(cls, func: str, kl: int) -> str: + raise NotImplementedError + + @classmethod + def metadata_arguments(cls, + kl: int, + pair: bool, + deterministic: Optional[bool]) -> List[str]: + raise NotImplementedError + + @classmethod + def final_arguments(cls) -> List[str]: + return [] + + @classmethod + def secret_is_seed(cls) -> bool: + return True + + +class PQCPAPI(API): + """Test mldsa-native entry points.""" + + @classmethod + def function(cls, func: str, kl: int) -> str: + return f'{func}_{kl}' + + @classmethod + def metadata_arguments(cls, + _kl: int, + _pair: bool, + _deterministic: Optional[bool]) -> List[str]: + return [] + + @classmethod + def secret_is_seed(cls) -> bool: + return False + + +def one_mldsa_key_pair_from_seed(key: Key, + descr: str) -> test_case.TestCase: + """Construct one test case for mldsa-native keypair_internal().""" + tc = test_case.TestCase() + tc.set_function(f'key_pair_from_seed_{key.kl}') + tc.set_dependencies([f'TF_PSA_CRYPTO_PQCP_MLDSA_{key.kl}_ENABLED']) + tc.set_arguments([ + test_case.hex_string(key.seed), + test_case.hex_string(key.secret), + test_case.hex_string(key.public), + ]) + tc.set_description(f'MLDSA-{key.kl} key pair from seed {descr}') + return tc + +def gen_pqcp_key_management(kl: int) -> Iterable[test_case.TestCase]: + """Generate test cases for mldsa-native keypair_internal().""" + for i, key in enumerate(KEYS[kl], 1): + yield one_mldsa_key_pair_from_seed(key, f'key#{i}') + +def one_mldsa_sign_deterministic_pure(api: API, + key: Key, + message: bytes, + descr: str) -> test_case.TestCase: + """Construct one test case for deterministic signature.""" + signature = key.sign_message(message, deterministic=True) + tc = test_case.TestCase() + tc.set_function(api.function('sign_deterministic_pure', key.kl)) + tc.set_dependencies([f'TF_PSA_CRYPTO_PQCP_MLDSA_{key.kl}_ENABLED']) + tc.set_arguments(api.metadata_arguments(key.kl, True, True) + [ + test_case.hex_string(key.seed if api.secret_is_seed() else key.secret), + test_case.hex_string(message), + test_case.hex_string(signature), + ] + api.final_arguments()) + tc.set_description(f'MLDSA-{key.kl} sign deterministic {descr}') + return tc + +def one_mldsa_verify_pure(api: API, + key: Key, + message: bytes, + deterministic: bool, + descr: str) -> test_case.TestCase: + """Construct one test case for verification.""" + signature = key.sign_message(message, deterministic=True) + tc = test_case.TestCase() + tc.set_function(api.function('verify_pure', key.kl)) + tc.set_dependencies([f'TF_PSA_CRYPTO_PQCP_MLDSA_{key.kl}_ENABLED']) + tc.set_arguments(api.metadata_arguments(key.kl, False, True) + [ + test_case.hex_string(key.public), + test_case.hex_string(message), + test_case.hex_string(signature), + ] + api.final_arguments()) + variant = "deterministic" if deterministic else "randomized" + tc.set_description(f'MLDSA-{key.kl} verify {variant} {descr}') + return tc + +def gen_mldsa_pure(api: API, kl: int) -> Iterable[test_case.TestCase]: + """Generate all test cases for pure ML-DSA signature and verification.""" + for i, key in enumerate(KEYS[kl], 1): + yield one_mldsa_sign_deterministic_pure(api, key, MESSAGES[0][0], + f'key#{i}') + for message, descr in MESSAGES[1:]: + yield one_mldsa_sign_deterministic_pure(api, KEYS[kl][0], message, + f'key#1 {descr}') + for i, key in enumerate(KEYS[kl], 1): + yield one_mldsa_verify_pure(api, key, MESSAGES[0][0], True, + f'key#{i}') + for message, descr in MESSAGES[1:]: + yield one_mldsa_verify_pure(api, KEYS[kl][0], message, + True, f'key#1 {descr}') + for i, key in enumerate(KEYS[kl], 1): + yield one_mldsa_verify_pure(api, key, MESSAGES[0][0], False, + f'key#{i}') + for message, descr in MESSAGES[1:]: + yield one_mldsa_verify_pure(api, KEYS[kl][0], message, + False, f'key#1 {descr}') + +def gen_pqcp_mldsa_all() -> Iterable[test_case.TestCase]: + """Generate all test cases for mldsa-native.""" + api = PQCPAPI() + for kl in sorted(KEYS.keys()): + yield from gen_pqcp_key_management(kl) + yield from gen_mldsa_pure(api, kl) + +class MLDSATestGenerator(test_data_generation.TestGenerator): + """Generate test cases for ML-DSA.""" + + def __init__(self, settings) -> None: + self.targets = { + 'test_suite_pqcp_mldsa.dilithium_py': gen_pqcp_mldsa_all, + } + super().__init__(settings) + + +if __name__ == '__main__': + test_data_generation.main(sys.argv[1:], __doc__, MLDSATestGenerator) diff --git a/util/requirements.txt b/util/requirements.txt index 7b557b678..113cb712d 100644 --- a/util/requirements.txt +++ b/util/requirements.txt @@ -1 +1,4 @@ # Python module requirements for maintainer utilities + +# For generate_mldsa_tests.py +dilithium-py >= 1.3.0; python_version >= "3.9" From 3ddf7bc717904f5265b6954dfb39d8620f104e1a Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Tue, 24 Feb 2026 16:06:11 +0100 Subject: [PATCH 6/8] Fix verify test cases not having a randomized signature Signed-off-by: Gilles Peskine --- util/generate_mldsa_tests.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/util/generate_mldsa_tests.py b/util/generate_mldsa_tests.py index a49fed9fd..588fda6ff 100755 --- a/util/generate_mldsa_tests.py +++ b/util/generate_mldsa_tests.py @@ -145,8 +145,12 @@ def one_mldsa_verify_pure(api: API, message: bytes, deterministic: bool, descr: str) -> test_case.TestCase: - """Construct one test case for verification.""" - signature = key.sign_message(message, deterministic=True) + """Construct one test case for verification. + + When deterministic is true, the test case is a deterministic signature. + When deterministic is false, the test case is some other valid signature. + """ + signature = key.sign_message(message, deterministic=deterministic) tc = test_case.TestCase() tc.set_function(api.function('verify_pure', key.kl)) tc.set_dependencies([f'TF_PSA_CRYPTO_PQCP_MLDSA_{key.kl}_ENABLED']) @@ -171,14 +175,14 @@ def gen_mldsa_pure(api: API, kl: int) -> Iterable[test_case.TestCase]: yield one_mldsa_verify_pure(api, key, MESSAGES[0][0], True, f'key#{i}') for message, descr in MESSAGES[1:]: - yield one_mldsa_verify_pure(api, KEYS[kl][0], message, - True, f'key#1 {descr}') + yield one_mldsa_verify_pure(api, KEYS[kl][0], message, True, + f'key#1 {descr}') for i, key in enumerate(KEYS[kl], 1): yield one_mldsa_verify_pure(api, key, MESSAGES[0][0], False, f'key#{i}') for message, descr in MESSAGES[1:]: - yield one_mldsa_verify_pure(api, KEYS[kl][0], message, - False, f'key#1 {descr}') + yield one_mldsa_verify_pure(api, KEYS[kl][0], message, False, + f'key#1 {descr}') def gen_pqcp_mldsa_all() -> Iterable[test_case.TestCase]: """Generate all test cases for mldsa-native.""" From d6dce9b3f87a65c8cbbeb73ffb97647359832192 Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 25 Feb 2026 19:02:22 +0100 Subject: [PATCH 7/8] Make the output deterministic Tests using randomized signatures were currently randomized. We need the output of the script to be reproducible, so this won't do. Force randomized signatures to use a specific RNG which is reset before constructing each test case. This way, the script's output is reproducible. Signed-off-by: Gilles Peskine --- util/generate_mldsa_tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/util/generate_mldsa_tests.py b/util/generate_mldsa_tests.py index 588fda6ff..4cfe3e763 100755 --- a/util/generate_mldsa_tests.py +++ b/util/generate_mldsa_tests.py @@ -45,6 +45,7 @@ class Key: self.public, self.secret = PURE[kl]._keygen_internal(seed) def sign_message(self, message: bytes, deterministic: bool) -> bytes: + PURE[self.kl].set_drbg_seed(bytes(48)) return PURE[self.kl].sign(self.secret, message, deterministic=deterministic) From d1bb3a61a592decc8dad071094c66768182fd54a Mon Sep 17 00:00:00 2001 From: Gilles Peskine Date: Wed, 25 Feb 2026 21:12:46 +0100 Subject: [PATCH 8/8] dilithium_py reproducible output requires pycryptodome Signed-off-by: Gilles Peskine --- util/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/util/requirements.txt b/util/requirements.txt index 113cb712d..feb351edc 100644 --- a/util/requirements.txt +++ b/util/requirements.txt @@ -1,4 +1,5 @@ # Python module requirements for maintainer utilities # For generate_mldsa_tests.py +pycryptodome dilithium-py >= 1.3.0; python_version >= "3.9"