""" (C) 2026 Jack Lloyd (C) 2026 René Meusel, Rohde & Schwarz Cybersecurity Botan is released under the Simplified BSD License (see license.txt) """ import binascii import unittest import botan3 as botan from .wycheproof import WycheproofTests, NullRNG def _from_hex(value: str) -> bytes: return binascii.unhexlify(value) def _map_algorithm_to_mode(algorithm: str) -> str: """Map Wycheproof algorithm name to Botan ML-DSA mode name.""" mapping = { "ML-DSA-44": "ML-DSA-4x4", "ML-DSA-65": "ML-DSA-6x5", "ML-DSA-87": "ML-DSA-8x7", } return mapping.get(algorithm, algorithm) class TestMLDSA(WycheproofTests, unittest.TestCase): def input_files(self) -> list[str]: # TODO: enable *_noseed_*.json once support for expanded private keys is implemented return [ "mldsa_44_sign_seed_test.json", # "mldsa_44_sign_noseed_test.json", "mldsa_44_verify_test.json", "mldsa_65_sign_seed_test.json", # "mldsa_65_sign_noseed_test.json", "mldsa_65_verify_test.json", "mldsa_87_sign_seed_test.json", # "mldsa_87_sign_noseed_test.json", "mldsa_87_verify_test.json", ] def run_test(self, data: dict, group: dict, test: dict) -> None: ctx = test.get("ctx") if ctx is not None and ctx != "": # Currently Botan doesn't support context (ctx), skip... self.skipTest("ML-DSA ctx not supported") if "msg" not in test or ("flags" in test and "Internal" in test["flags"]): # Currently Botan does not provide the "Sign_internal" interface of ML-DSA, # hence, signing without "msg" and only using "mu" isn't possible. self.skipTest("ML-DSA's Sign_internal interface is not exposed") mldsa_mode = _map_algorithm_to_mode(data.get("algorithm", "")) group_type = group.get("type") if group_type == "MlDsaSign": self._run_sign_test(mldsa_mode, group, test) elif group_type == "MlDsaVerify": self._run_verify_test(mldsa_mode, group, test) else: self.fail(f"Unknown test group type: {group_type}") def _run_sign_test(self, mldsa_mode: str, group: dict, test: dict) -> None: """Run a signing test (either seed or noseed variant).""" # Load the private key priv = None try: if "privateSeed" in group: priv = botan.PrivateKey.load_ml_dsa( mldsa_mode, _from_hex(group["privateSeed"]) ) # TODO: implement loading from expanded private key and enable the relevant # input files with *_noseed_*.json except botan.BotanException: if test["result"] == "invalid": return raise if priv is None: self.fail("No private key available for signing test") # Derive the public key and validate it against the expected public key pub = priv.get_public_key() if "publicKey" in group and group["publicKey"] is not None: self.assertEqual( pub.to_raw(), _from_hex(group["publicKey"]), "Deserialized public key does not match expected public key from test group", ) # Perform signing try: signer = botan.PKSign(priv, "Deterministic") signer.update(_from_hex(test["msg"])) actual_sig = signer.finish(NullRNG()) except botan.BotanException: if test["result"] == "invalid": return raise # Validate the generated signature self.assertEqual( actual_sig, _from_hex(test["sig"]), "Generated signature does not match expected signature in test vector", ) def _run_verify_test(self, mldsa_mode: str, group: dict, test: dict) -> None: """Run a verification test.""" # Load public key try: pub = botan.PublicKey.load_ml_dsa(mldsa_mode, _from_hex(group["publicKey"])) except botan.BotanException: if test["result"] == "invalid": return raise # Perform verification verifier = botan.PKVerify(pub, "") verifier.update(_from_hex(test["msg"])) valid = verifier.check_signature(_from_hex(test["sig"])) # Validate the verification result self.assertEqual( valid, test["result"] == "valid", "Signature should be valid" if test["result"] == "valid" else "Signature should be invalid", )