Revision control

Copy as Markdown

Other Tools

"""
(C) 2026 Jack Lloyd
(C) 2026 René Meusel, Rohde & Schwarz Cybersecurity
Botan is released under the Simplified BSD License (see license.txt)
"""
import json
import os
import unittest
from abc import ABC, abstractmethod
from collections import Counter
from urllib.request import urlopen
from typing import BinaryIO
from pathlib import Path
import botan3 as botan
class WycheproofTextTestResult(unittest.TextTestResult):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._skip_reason_counts: Counter[str] = Counter()
def addSkip(self, test, reason):
self._skip_reason_counts[str(reason)] += 1
super().addSkip(test, reason)
def stopTestRun(self):
super().stopTestRun()
if not self._skip_reason_counts:
return
self.stream.writeln("")
self.stream.writeln("Skipped tests summary (by reason):")
for reason, count in self._skip_reason_counts.most_common():
self.stream.writeln(f" - {count}x: {reason}")
# When these Wycheproof tests are run via `python -m unittest`, the CLI uses
# `unittest.TextTestRunner` and its `resultclass`. Since all tests import this
# module, we can centrally augment the default output without changing each test.
if getattr(unittest.TextTestRunner, "resultclass", None) is unittest.TextTestResult:
unittest.TextTestRunner.resultclass = WycheproofTextTestResult
class FixedOutputRNG(botan.RandomNumberGenerator):
def __init__(self, entropy_pool: bytes = b""):
"""
A random number generator that outputs a pre-determined sequence of bytes.
This is exclusively useful for testing.
"""
super().__init__(
"custom", get_callback=self._get, add_entropy_callback=self._add_entropy
)
self._entropy_pool = entropy_pool
def _get(self, length: int) -> bytes:
if length > len(self._entropy_pool):
raise ValueError("Not enough entropy in pool")
entropy = self._entropy_pool[:length]
self._entropy_pool = self._entropy_pool[length:]
return entropy
def _add_entropy(self, data: bytes) -> None:
self._entropy_pool += data
# An RNG implementation called NullRNG that raises an exception if it is used
class NullRNG(botan.RandomNumberGenerator):
def __init__(self):
super().__init__(
"custom", get_callback=self._get, add_entropy_callback=self._add_entropy
)
def _get(self, length: int) -> bytes:
raise botan.BotanException("Unexpected request to get entropy from RNG", rc=-23)
def _add_entropy(self, data: bytes) -> None:
raise botan.BotanException("Unexpected request to add entropy to RNG", rc=-23)
class WycheproofTests(ABC):
"""
Base mixin class for Wycheproof tests.
This class is intended to be used as a mixin with unittest.TestCase.
It explicitly relies on methods provided by unittest.TestCase
class MyTest(WycheproofTests, unittest.TestCase):
...
"""
@abstractmethod
def input_files(self) -> list[str]:
"""
Return the list of input files to test. Those files are expected to
be in the `testvectors_v1` directory of the Wycheproof repository.
"""
@abstractmethod
def run_test(self, data: dict, group: dict, test: dict) -> None:
"""
Run a single test.
Args:
data: The data for the test. This is the entire JSON file.
group: The group for the test. This is the group of test vectors.
test: The test itself. This is the individual test vector.
"""
@staticmethod
def _get_cached_or_downloaded_file(filename: str) -> BinaryIO:
"""
Returns an open file handle to the JSON file with given filename.
If WYCHEPROOF_TESTDATA_CACHE_DIR is set, the file is cached in that directory.
"""
base_url = os.environ.get("WYCHEPROOF_TESTDATA_URL")
if base_url is None:
raise RuntimeError("Environment variable WYCHEPROOF_TESTDATA_URL not set")
url = f"{base_url}/{filename}"
# Check if the cache can be used
cache_dir = os.environ.get("WYCHEPROOF_TESTDATA_CACHE_DIR")
if cache_dir is not None:
cache_path = Path(cache_dir)
cache_path.mkdir(parents=True, exist_ok=True)
cache_entry = cache_path / filename
if not cache_entry.exists():
with urlopen(url, timeout=30) as response:
with open(cache_entry, "wb") as f:
f.write(response.read())
return open(cache_entry, mode="rb")
return urlopen(url, timeout=30)
@staticmethod
def _read_datafile(filename: str) -> dict | None:
"""
Reads the datafile named 'filename' from cache (if available) or downloads and caches it.
Returns the loaded JSON content.
"""
with WycheproofTests._get_cached_or_downloaded_file(filename) as f:
return json.load(f) if f is not None else None
def _validate_unittest_mixin(self) -> None:
if not isinstance(self, unittest.TestCase):
raise TypeError(
f"{self.__class__.__name__} must be used as a mixin with unittest.TestCase"
)
def _wycheproof_subtest(self, test: dict, filename: str):
self._validate_unittest_mixin()
params = {}
tc_id = test.get("tcId")
if tc_id is not None:
params["tcId"] = tc_id
comment = test.get("comment")
if comment:
params["comment"] = comment
flags = test.get("flags")
if flags:
params["flags"] = ",".join(flags)
params["filename"] = filename
return unittest.TestCase.subTest(self, **params)
def test_wycheproof(self) -> None:
self._validate_unittest_mixin()
for filename in self.input_files():
data = self._read_datafile(filename)
for group in data["testGroups"]:
for test in group["tests"]:
with self._wycheproof_subtest(test, filename):
self.run_test(data, group, test)