From 2e03fa636052cd56ec8080fb20480352c9faf715 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 16:38:26 +0000 Subject: [PATCH 01/26] Disarm ChaCha when set_iv() rejects the nonce length (F-10069) set_iv() now validates the nonce before storing it and clears the IV-set state first, so any failed set_iv() blocks encrypt()/decrypt() until a valid nonce is set. --- tests/test_chacha_iv.py | 31 +++++++++++++++++++++++++++++++ wolfcrypt/ciphers.py | 9 +++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/tests/test_chacha_iv.py b/tests/test_chacha_iv.py index b1006d9..24f8fea 100644 --- a/tests/test_chacha_iv.py +++ b/tests/test_chacha_iv.py @@ -82,3 +82,34 @@ def test_failed_set_iv_keeps_encrypt_blocked(monkeypatch): # encrypt() would instead run with a stale IV. with pytest.raises(WolfCryptError): cipher.encrypt(b"A" * 16) + + +def test_invalid_nonce_length_disarms_cipher(): + """ + F-10069: a set_iv() that fails on nonce length must not leave the cipher + usable with the rejected nonce, even after an earlier successful set_iv(). + """ + cipher = ChaCha(KEY) + cipher.set_iv(NONCE) + + with pytest.raises(ValueError): + cipher.set_iv(b"\x02" * 5) + + with pytest.raises(WolfCryptError): + cipher.encrypt(b"A" * 16) + with pytest.raises(WolfCryptError): + cipher.decrypt(b"A" * 16) + + +def test_set_iv_after_invalid_nonce_rearms(): + enc = ChaCha(KEY) + enc.set_iv(NONCE) + with pytest.raises(ValueError): + enc.set_iv(b"\x02" * 5) + enc.set_iv(NONCE) + plaintext = b"the quick brown fox" + ciphertext = enc.encrypt(plaintext) + + dec = ChaCha(KEY) + dec.set_iv(NONCE) + assert dec.decrypt(ciphertext) == plaintext diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index d66071e..ca09fb7 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -635,11 +635,12 @@ def _decrypt(self, destination: _ffi.CData, source: bytes) -> int: _NONCE_SIZE = 12 def set_iv(self, nonce: BytesOrStr, counter: int = 0) -> None: - self._IV_nonce = t2b(nonce) - if len(self._IV_nonce) != self._NONCE_SIZE: - raise ValueError(f"nonce must be {self._NONCE_SIZE} bytes, got {len(self._IV_nonce)}") - self._IV_counter = counter self._iv_set = False + nonce = t2b(nonce) + if len(nonce) != self._NONCE_SIZE: + raise ValueError(f"nonce must be {self._NONCE_SIZE} bytes, got {len(nonce)}") + self._IV_nonce = nonce + self._IV_counter = counter ret = self._set_key(self._REKEY_BOTH) if ret < 0: raise WolfCryptApiError("ChaCha set_iv error", ret) From ebd1f7628ec911ddb9f455debb6f0c2d0b9ebddf Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 16:50:55 +0000 Subject: [PATCH 02/26] Validate ECC public keys with wc_ecc_check_key on import (F-8277) EccPublic.decode_key_raw() accepted any point of the right length because wc_ecc_import_unsigned() does not check it. decode_key_raw(), import_x963() and decode_key() now call wc_ecc_check_key() and raise WolfCryptApiError when the point is not on the curve, is out of range, or has the wrong order. wolfSSL 5.9.0 and later already check X9.63 and DER imports; older local builds do not. --- scripts/build_ffi.py | 1 + tests/test_ciphers.py | 37 +++++++++++++++++++++++++++++++++++++ wolfcrypt/_ffi/lib.pyi | 1 + wolfcrypt/ciphers.py | 8 ++++++++ 4 files changed, 47 insertions(+) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 6807edf..2710b61 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -1124,6 +1124,7 @@ def build_ffi(local_wolfssl, features): int wc_ecc_export_public_raw(ecc_key* key, byte* qx, word32* qxLen, byte* qy, word32* qyLen); int wc_ecc_get_curve_size_from_id(int curve_id); + int wc_ecc_check_key(ecc_key* key); int wc_ecc_shared_secret(ecc_key* private_key, ecc_key* public_key, diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index ffbf29c..9b1936b 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -29,6 +29,7 @@ from wolfcrypt._ffi import lib as _lib from wolfcrypt.ciphers import MODE_CBC, MODE_CTR, MODE_ECB, WolfCryptError +from wolfcrypt.exceptions import WolfCryptApiError from wolfcrypt.random import Random from wolfcrypt.utils import h2b, t2b @@ -646,6 +647,42 @@ def test_ecc_decode_key_raw_rejects_wrong_length(vectors): raw_priv.decode_key_raw(qx_good, qy_good, d_good) + P256_PRIME = h2b( + "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff") + + + @pytest.mark.parametrize("bad", ["off_curve", "x_not_below_p"]) + def test_ecc_decode_key_raw_rejects_invalid_point(vectors, bad): + """ + F-8277: wc_ecc_import_unsigned does not validate the point, so + decode_key_raw must reject points that are not on the curve. + """ + key = vectors[EccPublic].raw_key + qx, qy = key[0:32], key[32:64] + if bad == "off_curve": + qy = qy[:-1] + bytes([qy[-1] ^ 1]) + else: + qx = P256_PRIME + + with pytest.raises(WolfCryptApiError): + EccPublic().decode_key_raw(qx, qy) + + + def test_ecc_import_rejects_off_curve_point(vectors): + """ + F-8277: import_x963 and decode_key reject a point that is not on + the curve. + """ + key = vectors[EccPublic].raw_key + bad_qy = key[32:63] + bytes([key[63] ^ 1]) + with pytest.raises(WolfCryptApiError): + EccPublic().import_x963(b"\x04" + key[0:32] + bad_qy) + + der = vectors[EccPublic].key + with pytest.raises(WolfCryptApiError): + EccPublic(der[:-1] + bytes([der[-1] ^ 1])) + + def test_x963(ecc_private, ecc_public): assert ecc_private.export_x963() == ecc_public.export_x963() diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index ca50324..95d6f8c 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -477,6 +477,7 @@ def wc_ecc_free(key: EccKey) -> int: ... def wc_ecc_size(key: EccKey) -> int: ... def wc_ecc_sig_size(key: EccKey) -> int: ... def wc_ecc_get_curve_size_from_id(curve_id: int) -> int: ... +def wc_ecc_check_key(key: EccKey) -> int: ... def wc_ecc_import_unsigned(key: EccKey, qx: bytes, qy: bytes, d: bytes | FFI.CData, curve_id: int) -> int: ... def wc_ecc_export_public_raw(key: EccKey, qx: BytePtr, qx_len: IntPtr, qy: BytePtr, qy_len: IntPtr) -> int: ... def wc_ecc_import_x963(x963: bytes, x963_len: int, key: EccKey) -> int: ... diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index ca09fb7..04aea96 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -1167,6 +1167,11 @@ def __init__(self, key: BytesOrStr | None = None) -> None: if key: self.decode_key(key) + def _check_key(self) -> None: + ret = _lib.wc_ecc_check_key(self.native_object) + if ret != 0: + raise WolfCryptApiError("Key check error", ret) + def decode_key(self, key: BytesOrStr) -> None: """ Decodes an ECC public key from an ASN sequence. @@ -1184,6 +1189,7 @@ def decode_key(self, key: BytesOrStr) -> None: raise WolfCryptError(f"Key decode error ({self.size})") if self.max_signature_size <= 0: # pragma: no cover raise WolfCryptError(f"Key decode error ({self.max_signature_size})") + self._check_key() def decode_key_raw(self, qx: BytesOrStr, qy: BytesOrStr, curve_id: int = ECC_SECP256R1) -> None: """ @@ -1202,6 +1208,7 @@ def decode_key_raw(self, qx: BytesOrStr, qy: BytesOrStr, curve_id: int = ECC_SEC _ffi.NULL, curve_id) if ret != 0: raise WolfCryptApiError("Key decode error", ret) + self._check_key() def encode_key(self, with_curve: bool = True) -> bytes: """ @@ -1246,6 +1253,7 @@ def import_x963(self, x963: bytes) -> None: ret = _lib.wc_ecc_import_x963(x963, len(x963), self.native_object) if ret != 0: raise WolfCryptApiError("x963 import error", ret) + self._check_key() def export_x963(self) -> bytes: """ From e0f0bc916be351887501881df25b81dec2fe237e Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 17:17:56 +0000 Subject: [PATCH 03/26] Size ECC private key DER buffer with wc_EccKeyDerSize (F-10070) EccPrivate.encode_key() allocated four times the curve field size for the DER output. On curves up to P-192 the encoding needs more than that (97 bytes for P-192, which FIPS builds enable), so wc_EccKeyToDer returned BAD_FUNC_ARG. Query the exact length with wc_EccKeyDerSize. --- scripts/build_ffi.py | 1 + tests/test_ciphers.py | 44 +++++++++++++++++++++++++++++++++++++++++- wolfcrypt/_ffi/lib.pyi | 1 + wolfcrypt/ciphers.py | 7 +++++-- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 2710b61..f46c8b3 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -1110,6 +1110,7 @@ def build_ffi(local_wolfssl, features): int wc_EccPrivateKeyDecode(const byte*, word32*, ecc_key*, word32); int wc_EccKeyToDer(ecc_key*, byte* output, word32 inLen); + int wc_EccKeyDerSize(ecc_key*, int pub); int wc_EccPublicKeyDecode(const byte*, word32*, ecc_key*, word32); int wc_EccPublicKeyToDer(ecc_key*, byte* output, diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 9b1936b..9245121 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -51,7 +51,7 @@ from wolfcrypt.ciphers import HASH_TYPE_SHA, HASH_TYPE_SHA256, RsaPrivate, RsaPublic if _lib.ECC_ENABLED: - from wolfcrypt.ciphers import EccPrivate, EccPublic + from wolfcrypt.ciphers import ECC_SECP112R1, ECC_SECP128R1, ECC_SECP160R1, ECC_SECP192R1, EccPrivate, EccPublic if _lib.ED25519_ENABLED: from wolfcrypt.ciphers import Ed25519Private, Ed25519Public @@ -614,6 +614,48 @@ def test_key_encoding(vectors): assert qy[0:32] == vectors[EccPublic].raw_key[32:64] + def test_ecc_encode_key_buffer_not_from_field_size(vectors, monkeypatch): + """ + F-10070: the private key DER is larger than four times the field + size on small curves, so the output buffer must not be sized from it. + """ + monkeypatch.setattr(EccPrivate, "size", property(lambda self: 14)) + priv = EccPrivate(vectors[EccPrivate].key) + assert priv.encode_key() == vectors[EccPrivate].key + + + @pytest.mark.parametrize("curve_id", [ECC_SECP112R1, ECC_SECP128R1, ECC_SECP160R1, ECC_SECP192R1]) + def test_ecc_encode_key_small_curves(curve_id): + """ + F-10070: private key DER encoding round-trips on small curves. + """ + size = _lib.wc_ecc_get_curve_size_from_id(curve_id) + if size <= 0: + pytest.skip("curve not enabled") + if _lib.FIPS_ENABLED and _lib.FIPS_VERSION >= 6 and size < 28: + pytest.skip("FIPS 140-3 does not generate keys under 224 bits") + key = EccPrivate.make_key(size) + assert key.size == size + der = key.encode_key() + assert EccPrivate(der).encode_key() == der + + + def test_ecc_encode_key_p192_vector(): + """ + F-10070: an imported P-192 private key re-encodes to the same DER. + FIPS 140-3 builds can import P-192 keys but not generate them. + """ + if _lib.wc_ecc_get_curve_size_from_id(ECC_SECP192R1) <= 0: + pytest.skip("curve not enabled") + der = h2b( + "305f02010104189611a7935930fa834b7de535a371d3461d1ff4a309d15cb8a0" + "0a06082a8648ce3d030101a13403320004a9afcfd908947032399677a3ae1d9d" + "5ec75906e27f7d3ddeec030107d246b3550a48208cbcb9b1f15978f938b652ff" + "0b" + ) + assert EccPrivate(der).encode_key() == der + + def test_ecc_decode_key_raw_rejects_wrong_length(vectors): """ wc_ecc_import_unsigned reads exactly curve_size bytes from each diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 95d6f8c..c863091 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -495,6 +495,7 @@ def wc_EccPublicKeyDecode(pub_der: bytes, in_out_idx: IntPtr, key: EccKey, pub_d def wc_EccPrivateKeyDecode(priv_der: bytes, in_out_idx: IntPtr, key: EccKey, priv_der_len: int) -> int: ... def wc_EccPublicKeyToDer(key: EccKey, pub_der: BytePtr, pub_der_len: int, with_alg_curve: int) -> int: ... def wc_EccKeyToDer(key: EccKey, priv_key_der: BytePtr, priv_key_len: int) -> int: ... +def wc_EccKeyDerSize(key: EccKey, pub: int) -> int: ... Ed25519Key: TypeAlias = FFI.CData diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 04aea96..4df20b0 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -1409,9 +1409,12 @@ def encode_key(self) -> bytes: Returns the encoded key. """ - key = _ffi.new(f"byte[{self.size * 4}]") + size = _lib.wc_EccKeyDerSize(self.native_object, 1) + if size <= 0: # pragma: no cover + raise WolfCryptApiError("Key encode error", size) + key = _ffi.new(f"byte[{size}]") - ret = _lib.wc_EccKeyToDer(self.native_object, key, len(key)) + ret = _lib.wc_EccKeyToDer(self.native_object, key, size) if ret <= 0: # pragma: no cover raise WolfCryptApiError("Key encode error", ret) From c9685f961bd71355e83e9bbadeda65a2d02b757b Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 17:36:02 +0000 Subject: [PATCH 04/26] Detect AES-CTR support before declaring wc_AesCtrEncrypt (F-11250) build_ffi.py declared wc_AesCtrEncrypt whenever AES was enabled, but wolfSSL only provides it with WOLFSSL_AES_COUNTER. Against a local wolfSSL without AES-CTR, the extension then failed to import with an undefined symbol. Detect WOLFSSL_AES_COUNTER, expose AES_CTR_ENABLED, and declare wc_AesCtrEncrypt only when it is set. Aes now raises NotImplementedError for MODE_CTR when AES-CTR is not compiled in. To make detection and cdef generation testable, split the build script into detect_features(), make_source(), make_cdef() and default_features(). main() now runs only when the script runs directly or through cffi's setuptools hook. get_platform falls back to sysconfig when distutils is unavailable. For the bundled options.h and both Windows user_settings.h files, the generated source and cdef are unchanged except for the new flag. --- scripts/build_ffi.py | 57 ++++++++++++++++---- tests/test_build_ffi.py | 112 ++++++++++++++++++++++++++++++++++++++++ tests/test_ciphers.py | 11 +++- wolfcrypt/_ffi/lib.pyi | 1 + wolfcrypt/ciphers.py | 6 +++ 5 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 tests/test_build_ffi.py diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index f46c8b3..d06f752 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -23,7 +23,11 @@ import re import subprocess from contextlib import contextmanager -from distutils.util import get_platform +try: + from distutils.util import get_platform +except ImportError: + # Python >= 3.12 without setuptools' distutils shim (unit tests). + from sysconfig import get_platform from cffi import FFI import shutil from wolfcrypt._version import __wolfssl_version__ as version @@ -350,6 +354,15 @@ def get_features(local_wolfssl, features): with open(file) as f: defines += f.read().splitlines() + return detect_features(defines, features, fips) + +def detect_features(defines, features, fips=False): + """Set features from the lines of options.h/user_settings.h.""" + text = "\n".join(defines) + + def defined(name): + return re.search(rf"^\s*#\s*define\s+{name}\b", text, re.MULTILINE) is not None + features["MPAPI"] = 1 if '#define WOLFSSL_PUBLIC_MP' in defines else 0 features["SHA"] = 0 if '#define NO_SHA' in defines else 1 features["SHA256"] = 0 if '#define NO_SHA256' in defines else 1 @@ -394,6 +407,8 @@ def get_features(local_wolfssl, features): # Unlike the other fatures, HASHDRBG is enabled by default in random.h, unless WC_NO_HASHDRBG or # CUSTOM_RAND_GENERATE_BLOCK is defined. features["HASHDRBG"] = 0 if ("#define WC_NO_HASHDRBG" in defines or "#define CUSTOM_RAND_GENERATE_BLOCK" in defines) else 1 + # aes.h declares wc_AesCtrEncrypt only with WOLFSSL_AES_COUNTER. + features["AES_CTR"] = 1 if features["AES"] and defined("WOLFSSL_AES_COUNTER") else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -430,6 +445,15 @@ def build_ffi(local_wolfssl, features): else: cffi_libraries.append("wolfssl") + ffibuilder.set_source( "wolfcrypt._ffi", make_source(features), + include_dirs=cffi_include_dirs, + library_dirs=[wolfssl_lib_dir(local_wolfssl, features["FIPS"])], + libraries=cffi_libraries) + + ffibuilder.cdef(make_cdef(features)) + +def make_source(features): + """Return the C source passed to set_source().""" includes_string = "" if sys.platform == 'win32': @@ -515,13 +539,13 @@ def build_ffi(local_wolfssl, features): int ML_DSA_NO_CTX_ENABLED = {features["ML_DSA_NO_CTX"]}; int HKDF_ENABLED = {features["HKDF"]}; int HASHDRBG_ENABLED = {features["HASHDRBG"]}; + int AES_CTR_ENABLED = {features["AES_CTR"]}; """ - ffibuilder.set_source( "wolfcrypt._ffi", init_source_string, - include_dirs=cffi_include_dirs, - library_dirs=[wolfssl_lib_dir(local_wolfssl, features["FIPS"])], - libraries=cffi_libraries) + return init_source_string +def make_cdef(features): + """Return the cdef for the given features.""" # TODO: change cdef to cdef. # cdef = "" cdef = """ @@ -558,6 +582,7 @@ def build_ffi(local_wolfssl, features): extern int ML_DSA_NO_CTX_ENABLED; extern int HKDF_ENABLED; extern int HASHDRBG_ENABLED; + extern int AES_CTR_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -937,8 +962,11 @@ def build_ffi(local_wolfssl, features): int wc_AesSetKey(Aes*, const byte*, word32, const byte*, int); int wc_AesCbcEncrypt(Aes*, byte*, const byte*, word32); int wc_AesCbcDecrypt(Aes*, byte*, const byte*, word32); - int wc_AesCtrEncrypt(Aes*, byte*, const byte*, word32); """ + if features["AES_CTR"]: + cdef += """ + int wc_AesCtrEncrypt(Aes*, byte*, const byte*, word32); + """ if features["AES"] and features["AESGCM_STREAM"]: cdef += """ @@ -1365,10 +1393,10 @@ def build_ffi(local_wolfssl, features): int wc_dilithium_verify_msg(const byte* sig, word32 sigLen, const byte* msg, word32 msgLen, int* res, dilithium_key* key); """ - ffibuilder.cdef(cdef) + return cdef -def main(ffibuilder): - # Default features. +def default_features(): + """Return the default features, before detection.""" features = { "MPAPI": 1, "SHA": 1, @@ -1402,6 +1430,7 @@ def main(ffibuilder): "ML_DSA_NO_CTX": 0, "HKDF": 1, "HASHDRBG": 1, + "AES_CTR": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. @@ -1410,6 +1439,11 @@ def main(ffibuilder): else: features["ED448"] = 1 + return features + +def main(ffibuilder): + features = default_features() + local_wolfssl = os.environ.get("USE_LOCAL_WOLFSSL") if local_wolfssl: print(f"Using local wolfSSL at {local_wolfssl}.") @@ -1432,7 +1466,10 @@ def main(ffibuilder): ffibuilder = FFI() -main(ffibuilder) + +# cffi's setuptools integration runs this file with __name__ == "__cffi__". +if __name__ in ("__main__", "__cffi__"): + main(ffibuilder) if __name__ == "__main__": ffibuilder.compile(verbose=True) diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py new file mode 100644 index 0000000..58a4cd7 --- /dev/null +++ b/tests/test_build_ffi.py @@ -0,0 +1,112 @@ +# test_build_ffi.py +# +# Copyright (C) 2006-2026 wolfSSL Inc. +# +# This file is part of wolfSSL. +# +# wolfSSL is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# wolfSSL is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA + +# pylint: disable=redefined-outer-name + +"""Tests for feature detection and cdef generation in scripts/build_ffi.py. + +Nothing here builds wolfSSL or compiles C code. +""" + +import importlib.util +import os +import re + +import pytest +from cffi import FFI + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BUILD_FFI = os.path.join(ROOT, "scripts", "build_ffi.py") + +pytestmark = pytest.mark.filterwarnings("ignore:The distutils package is deprecated:DeprecationWarning") + +# Sub-capability -> the capability it depends on. Reference configurations +# must enable every sub-capability whenever its parent is enabled. +SUBCAPABILITIES = { + "AES_CTR": "AES", +} + + +@pytest.fixture(scope="module") +def bf(): + if not os.path.exists(BUILD_FFI): + pytest.skip("scripts/build_ffi.py not available") + spec = importlib.util.spec_from_file_location("wolfcrypt_build_ffi", BUILD_FFI) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def detect(bf, *defines, fips=False): + return bf.detect_features(list(defines), bf.default_features(), fips) + + +def cdef_for(bf, features): + cdef = bf.make_cdef(features) + FFI().cdef(cdef) # parse only, no compiler + return cdef + + +def test_import_has_no_side_effects(bf): + # set_source() stores cffi's private _assigned_source attribute. + assert not hasattr(bf.ffibuilder, "_assigned_source") + + +def test_flags_declared_and_defined(bf): + features = bf.default_features() + declared = set(re.findall(r"extern int (\w+);", cdef_for(bf, features))) + defined = set(re.findall(r"^\s*int (\w+) = ", bf.make_source(features), re.MULTILINE)) + assert declared + assert declared == defined + + +@pytest.mark.parametrize("config", ["non_fips", "fips_ready", "bundled"]) +def test_reference_configs_enable_all_subcapabilities(bf, config): + if config == "bundled": + path = os.path.join(ROOT, "lib", "wolfssl", bf.get_platform(), bf.version, + "include", "wolfssl", "options.h") + else: + path = os.path.join(ROOT, "windows", config, "user_settings.h") + if not os.path.exists(path): + pytest.skip(f"{path} not available") + with open(path) as f: + features = detect(bf, *f.read().splitlines(), fips=config == "fips_ready") + for sub, parent in SUBCAPABILITIES.items(): + assert features[sub] == features[parent], sub + + +def test_detection_matches_indented_defines_with_values(bf): + assert detect(bf, " # define WOLFSSL_AES_COUNTER 1 /* CTR */")["AES_CTR"] == 1 + assert detect(bf, "/* #define WOLFSSL_AES_COUNTER */")["AES_CTR"] == 0 + assert detect(bf, "#define WOLFSSL_AES_COUNTER_X")["AES_CTR"] == 0 + + +def test_aes_ctr_needs_aes_counter(bf): + features = detect(bf) + assert features["AES_CTR"] == 0 + assert "wc_AesCtrEncrypt" not in cdef_for(bf, features) + + features = detect(bf, "#define WOLFSSL_AES_COUNTER") + assert features["AES_CTR"] == 1 + assert "wc_AesCtrEncrypt" in cdef_for(bf, features) + + features = detect(bf, "#define NO_AES", "#define WOLFSSL_AES_COUNTER") + assert features["AES_CTR"] == 0 + assert "wc_AesCtrEncrypt" not in cdef_for(bf, features) diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 9245121..f5fc503 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -263,7 +263,7 @@ def test_block_cipher(cipher_cls, vectors): # Test AES in counter mode - if ciphertext_ctr is not None: + if ciphertext_ctr is not None and _lib.AES_CTR_ENABLED: cipher_obj = cipher_cls.new(key, MODE_CTR, iv) res = cipher_obj.encrypt(plaintext) assert res == ciphertext_ctr @@ -1090,6 +1090,15 @@ def test_des3_rejects_mode_ecb(): Des3.new(key, MODE_ECB, iv) +if _lib.AES_ENABLED: + def test_aes_ctr_rejected_when_not_compiled_in(monkeypatch): + """F-11250: MODE_CTR needs AES-CTR support in the linked wolfSSL.""" + monkeypatch.setattr(_lib, "AES_CTR_ENABLED", 0) + with pytest.raises(NotImplementedError, match="AES-CTR"): + Aes.new(b"0" * 16, MODE_CTR, b"0" * 16) + assert Aes.new(b"0" * 16, MODE_CBC, b"0" * 16).encrypt(b"0" * 16) + + if _lib.CHACHA_ENABLED: def test_chacha_non_block_aligned(): key = b"\x00" * 32 diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index c863091..093653a 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -24,6 +24,7 @@ from typing import TypeAlias INVALID_DEVID: int AES_ENABLED: int +AES_CTR_ENABLED: int AES_SIV_ENABLED: int AESGCM_STREAM_ENABLED: int ASN_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 4df20b0..b61e727 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -280,6 +280,12 @@ class Aes(_Cipher): _key_sizes = [16, 24, 32] _native_type = "Aes *" + @override + def __init__(self, key: BytesOrStr, mode: int, IV: BytesOrStr | None = None) -> None: + if mode == MODE_CTR and not _lib.AES_CTR_ENABLED: + raise NotImplementedError("AES-CTR is not supported by this wolfSSL build") + super().__init__(key, mode, IV) + @override def _set_key(self, direction: int) -> int: if direction == _ENCRYPTION: From e52e769026c00c7fcf43c7f48d0a6587ccfbc903 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 17:49:40 +0000 Subject: [PATCH 05/26] Declare AES-CBC functions only when wolfSSL provides them (F-12224) wolfSSL declares wc_AesCbcEncrypt/wc_AesCbcDecrypt only with HAVE_AES_CBC and builds CBC decryption only with HAVE_AES_DECRYPT. settings.h sets both unless NO_AES_CBC or NO_AES_DECRYPT is defined. Against a local wolfSSL built with either of those, the extension failed to import with an undefined symbol. Detect both, expose AES_CBC_ENABLED and AES_DECRYPT_ENABLED, and gate the declarations. Aes now raises NotImplementedError for MODE_CBC without AES-CBC and from CBC decrypt() without AES decryption. CTR decryption is unaffected. --- scripts/build_ffi.py | 19 +++++++++++++++-- tests/test_build_ffi.py | 31 +++++++++++++++++++++++++++ tests/test_ciphers.py | 46 ++++++++++++++++++++++++++++++++++------- wolfcrypt/_ffi/lib.pyi | 2 ++ wolfcrypt/ciphers.py | 8 +++++++ 5 files changed, 97 insertions(+), 9 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index d06f752..9b0d1f6 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -409,6 +409,9 @@ def defined(name): features["HASHDRBG"] = 0 if ("#define WC_NO_HASHDRBG" in defines or "#define CUSTOM_RAND_GENERATE_BLOCK" in defines) else 1 # aes.h declares wc_AesCtrEncrypt only with WOLFSSL_AES_COUNTER. features["AES_CTR"] = 1 if features["AES"] and defined("WOLFSSL_AES_COUNTER") else 0 + # settings.h derives HAVE_AES_CBC and HAVE_AES_DECRYPT unless NO_AES_CBC/NO_AES_DECRYPT. + features["AES_CBC"] = 1 if features["AES"] and not defined("NO_AES_CBC") else 0 + features["AES_DECRYPT"] = 1 if features["AES"] and not defined("NO_AES_DECRYPT") else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -540,6 +543,8 @@ def make_source(features): int HKDF_ENABLED = {features["HKDF"]}; int HASHDRBG_ENABLED = {features["HASHDRBG"]}; int AES_CTR_ENABLED = {features["AES_CTR"]}; + int AES_CBC_ENABLED = {features["AES_CBC"]}; + int AES_DECRYPT_ENABLED = {features["AES_DECRYPT"]}; """ return init_source_string @@ -583,6 +588,8 @@ def make_cdef(features): extern int HKDF_ENABLED; extern int HASHDRBG_ENABLED; extern int AES_CTR_ENABLED; + extern int AES_CBC_ENABLED; + extern int AES_DECRYPT_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -960,9 +967,15 @@ def make_cdef(features): typedef struct { ...; } Aes; int wc_AesSetKey(Aes*, const byte*, word32, const byte*, int); - int wc_AesCbcEncrypt(Aes*, byte*, const byte*, word32); - int wc_AesCbcDecrypt(Aes*, byte*, const byte*, word32); """ + if features["AES_CBC"]: + cdef += """ + int wc_AesCbcEncrypt(Aes*, byte*, const byte*, word32); + """ + if features["AES_CBC"] and features["AES_DECRYPT"]: + cdef += """ + int wc_AesCbcDecrypt(Aes*, byte*, const byte*, word32); + """ if features["AES_CTR"]: cdef += """ int wc_AesCtrEncrypt(Aes*, byte*, const byte*, word32); @@ -1431,6 +1444,8 @@ def default_features(): "HKDF": 1, "HASHDRBG": 1, "AES_CTR": 1, + "AES_CBC": 1, + "AES_DECRYPT": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 58a4cd7..860bae3 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -41,6 +41,8 @@ # must enable every sub-capability whenever its parent is enabled. SUBCAPABILITIES = { "AES_CTR": "AES", + "AES_CBC": "AES", + "AES_DECRYPT": "AES", } @@ -110,3 +112,32 @@ def test_aes_ctr_needs_aes_counter(bf): features = detect(bf, "#define NO_AES", "#define WOLFSSL_AES_COUNTER") assert features["AES_CTR"] == 0 assert "wc_AesCtrEncrypt" not in cdef_for(bf, features) + + +def test_aes_cbc_needs_cbc_support(bf): + features = detect(bf) + assert features["AES_CBC"] == 1 + cdef = cdef_for(bf, features) + assert "wc_AesCbcEncrypt" in cdef + assert "wc_AesCbcDecrypt" in cdef + + for define in ("#define NO_AES_CBC", " #define NO_AES_CBC 1", "#define NO_AES"): + features = detect(bf, define) + assert features["AES_CBC"] == 0, define + cdef = cdef_for(bf, features) + assert "wc_AesCbcEncrypt" not in cdef, define + assert "wc_AesCbcDecrypt" not in cdef, define + + +def test_aes_cbc_decrypt_needs_aes_decrypt(bf): + features = detect(bf) + assert features["AES_DECRYPT"] == 1 + + features = detect(bf, "#define NO_AES_DECRYPT") + assert features["AES_DECRYPT"] == 0 + assert features["AES_CBC"] == 1 + cdef = cdef_for(bf, features) + assert "wc_AesCbcEncrypt" in cdef + assert "wc_AesCbcDecrypt" not in cdef + + assert detect(bf, "#define NO_AES")["AES_DECRYPT"] == 0 diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index f5fc503..4a3e58a 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -245,9 +245,13 @@ def test_block_cipher(cipher_cls, vectors): plaintext = vectors[cipher_cls].plaintext ciphertext = vectors[cipher_cls].ciphertext ciphertext_ctr = vectors[cipher_cls].ciphertext_ctr + is_aes = _lib.AES_ENABLED and cipher_cls is Aes + # Without AES-CBC, MODE_CBC is rejected before its arguments are checked. + cbc = not (is_aes and not _lib.AES_CBC_ENABLED) - with pytest.raises(ValueError): - cipher_cls.new(key[:-1], MODE_CBC, iv) # invalid key length + if cbc: + with pytest.raises(ValueError): + cipher_cls.new(key[:-1], MODE_CBC, iv) # invalid key length with pytest.raises(ValueError): cipher_cls.new(key, -1, iv) # invalid mode @@ -255,11 +259,12 @@ def test_block_cipher(cipher_cls, vectors): with pytest.raises(ValueError): cipher_cls.new(key, MODE_ECB, iv) # unsuported mode - with pytest.raises(ValueError): - cipher_cls.new(key, MODE_CBC, None) # invalid iv + if cbc: + with pytest.raises(ValueError): + cipher_cls.new(key, MODE_CBC, None) # invalid iv - with pytest.raises(ValueError): - cipher_cls.new(key, MODE_CBC, iv[:-1]) # invalid iv length + with pytest.raises(ValueError): + cipher_cls.new(key, MODE_CBC, iv[:-1]) # invalid iv length # Test AES in counter mode @@ -270,6 +275,9 @@ def test_block_cipher(cipher_cls, vectors): cipher_obj = cipher_cls.new(key, MODE_CTR, iv) assert plaintext == cipher_obj.decrypt(res) + if not cbc: + return + # single encryption cipher_obj = cipher_new(cipher_cls, vectors) @@ -288,6 +296,9 @@ def test_block_cipher(cipher_cls, vectors): assert result == ciphertext + if is_aes and not _lib.AES_DECRYPT_ENABLED: + return + # single decryption cipher_obj = cipher_new(cipher_cls, vectors) @@ -1096,7 +1107,28 @@ def test_aes_ctr_rejected_when_not_compiled_in(monkeypatch): monkeypatch.setattr(_lib, "AES_CTR_ENABLED", 0) with pytest.raises(NotImplementedError, match="AES-CTR"): Aes.new(b"0" * 16, MODE_CTR, b"0" * 16) - assert Aes.new(b"0" * 16, MODE_CBC, b"0" * 16).encrypt(b"0" * 16) + if _lib.AES_CBC_ENABLED: + assert Aes.new(b"0" * 16, MODE_CBC, b"0" * 16).encrypt(b"0" * 16) + + def test_aes_cbc_rejected_when_not_compiled_in(monkeypatch): + """F-12224: MODE_CBC needs AES-CBC support in the linked wolfSSL.""" + monkeypatch.setattr(_lib, "AES_CBC_ENABLED", 0) + with pytest.raises(NotImplementedError, match="AES-CBC"): + Aes.new(b"0" * 16, MODE_CBC, b"0" * 16) + + def test_aes_cbc_decrypt_rejected_when_not_compiled_in(monkeypatch, vectors): + """F-12224: CBC decryption needs AES decryption in the linked wolfSSL.""" + monkeypatch.setattr(_lib, "AES_DECRYPT_ENABLED", 0) + vector = vectors[Aes] + if _lib.AES_CBC_ENABLED: + cipher_obj = Aes.new(vector.key, MODE_CBC, vector.iv) + assert cipher_obj.encrypt(vector.plaintext) == vector.ciphertext + with pytest.raises(NotImplementedError, match="AES-CBC decryption"): + cipher_obj.decrypt(vector.ciphertext) + # CTR decryption only uses the encryption key schedule. + if _lib.AES_CTR_ENABLED: + cipher_obj = Aes.new(vector.key, MODE_CTR, vector.iv) + assert cipher_obj.decrypt(vector.ciphertext_ctr) == vector.plaintext if _lib.CHACHA_ENABLED: diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 093653a..de4dd32 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -24,7 +24,9 @@ from typing import TypeAlias INVALID_DEVID: int AES_ENABLED: int +AES_CBC_ENABLED: int AES_CTR_ENABLED: int +AES_DECRYPT_ENABLED: int AES_SIV_ENABLED: int AESGCM_STREAM_ENABLED: int ASN_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index b61e727..80423fc 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -284,8 +284,16 @@ class Aes(_Cipher): def __init__(self, key: BytesOrStr, mode: int, IV: BytesOrStr | None = None) -> None: if mode == MODE_CTR and not _lib.AES_CTR_ENABLED: raise NotImplementedError("AES-CTR is not supported by this wolfSSL build") + if mode == MODE_CBC and not _lib.AES_CBC_ENABLED: + raise NotImplementedError("AES-CBC is not supported by this wolfSSL build") super().__init__(key, mode, IV) + @override + def decrypt(self, string: BytesOrStr) -> bytes: + if self.mode == MODE_CBC and not _lib.AES_DECRYPT_ENABLED: + raise NotImplementedError("AES-CBC decryption is not supported by this wolfSSL build") + return super().decrypt(string) + @override def _set_key(self, direction: int) -> int: if direction == _ENCRYPTION: From 4a612c7bc923007578776f532fbb89b233340d87 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 18:06:33 +0000 Subject: [PATCH 06/26] Gate streaming AES-GCM decryption on wolfSSL decrypt support (F-12225) wolfSSL builds wc_AesGcmDecryptInit/Update/Final only with HAVE_AES_DECRYPT or HAVE_AESGCM_DECRYPT. Against a local wolfSSL with WOLFSSL_AESGCM_STREAM and NO_AES_DECRYPT, the extension failed to import with an undefined symbol. Detect this, expose AESGCM_STREAM_DECRYPT_ENABLED, and declare the streaming decrypt functions only when they are available. AesGcmStream.decrypt() now raises NotImplementedError when streaming decryption is not compiled in. Encryption is unaffected. --- scripts/build_ffi.py | 21 +++++++++++++++------ tests/test_aesgcmstream.py | 23 +++++++++++++++++++++++ tests/test_build_ffi.py | 28 ++++++++++++++++++++++++++++ wolfcrypt/_ffi/lib.pyi | 1 + wolfcrypt/ciphers.py | 2 ++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 9b0d1f6..37eacdb 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -412,6 +412,9 @@ def defined(name): # settings.h derives HAVE_AES_CBC and HAVE_AES_DECRYPT unless NO_AES_CBC/NO_AES_DECRYPT. features["AES_CBC"] = 1 if features["AES"] and not defined("NO_AES_CBC") else 0 features["AES_DECRYPT"] = 1 if features["AES"] and not defined("NO_AES_DECRYPT") else 0 + # aes.c builds streaming GCM decryption only with HAVE_AES_DECRYPT or HAVE_AESGCM_DECRYPT. + features["AESGCM_STREAM_DECRYPT"] = 1 if features["AES"] and features["AESGCM_STREAM"] and ( + features["AES_DECRYPT"] or defined("HAVE_AESGCM_DECRYPT")) else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -545,6 +548,7 @@ def make_source(features): int AES_CTR_ENABLED = {features["AES_CTR"]}; int AES_CBC_ENABLED = {features["AES_CBC"]}; int AES_DECRYPT_ENABLED = {features["AES_DECRYPT"]}; + int AESGCM_STREAM_DECRYPT_ENABLED = {features["AESGCM_STREAM_DECRYPT"]}; """ return init_source_string @@ -590,6 +594,7 @@ def make_cdef(features): extern int AES_CTR_ENABLED; extern int AES_CBC_ENABLED; extern int AES_DECRYPT_ENABLED; + extern int AESGCM_STREAM_DECRYPT_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -994,14 +999,17 @@ def make_cdef(features): word32 sz, const byte* authIn, word32 authInSz); int wc_AesGcmEncryptFinal(Aes* aes, byte* authTag, word32 authTagSz); - int wc_AesGcmDecryptInit(Aes* aes, const byte* key, word32 len, - const byte* iv, word32 ivSz); - int wc_AesGcmDecryptUpdate(Aes* aes, byte* out, const byte* in, - word32 sz, const byte* authIn, word32 authInSz); - int wc_AesGcmDecryptFinal(Aes* aes, const byte* authTag, - word32 authTagSz); void wc_AesFree(Aes* aes); """ + if features["AESGCM_STREAM_DECRYPT"]: + cdef += """ + int wc_AesGcmDecryptInit(Aes* aes, const byte* key, word32 len, + const byte* iv, word32 ivSz); + int wc_AesGcmDecryptUpdate(Aes* aes, byte* out, const byte* in, + word32 sz, const byte* authIn, word32 authInSz); + int wc_AesGcmDecryptFinal(Aes* aes, const byte* authTag, + word32 authTagSz); + """ if features["AES"] and features["AES_SIV"]: cdef += """ @@ -1446,6 +1454,7 @@ def default_features(): "AES_CTR": 1, "AES_CBC": 1, "AES_DECRYPT": 1, + "AESGCM_STREAM_DECRYPT": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_aesgcmstream.py b/tests/test_aesgcmstream.py index a587b3b..f93c0a1 100644 --- a/tests/test_aesgcmstream.py +++ b/tests/test_aesgcmstream.py @@ -32,6 +32,10 @@ from binascii import hexlify as b2h from wolfcrypt.ciphers import AesGcmStream + def skip_without_decrypt(): + if not _lib.AESGCM_STREAM_DECRYPT_ENABLED: + pytest.skip("AES-GCM streaming decryption is not compiled in") + def test_encrypt(): """Known answer encrypt-decrypt test with default authentication tag size of 16 bytes""" key = "fedcba9876543210" @@ -42,6 +46,7 @@ def test_encrypt(): assert authTag is not None assert b2h(authTag) == bytes('ac8fcee96dc6ef8e5236da19b6197d2e', 'utf-8') assert b2h(buf) == bytes('5ba7d42e1bf01d7998e932', "utf-8") + skip_without_decrypt() gcmdec = AesGcmStream(key, iv) bufdec = gcmdec.decrypt(buf) gcmdec.final(authTag) @@ -58,6 +63,7 @@ def test_encrypt_short_tag(): assert authTag is not None assert b2h(authTag) == bytes('ac8fcee96dc6ef8e5236da19', 'utf-8') assert b2h(buf) == bytes('5ba7d42e1bf01d7998e932', "utf-8") + skip_without_decrypt() gcmdec = AesGcmStream(key, iv, 12) bufdec = gcmdec.decrypt(buf) gcmdec.final(authTag) @@ -73,6 +79,7 @@ def test_multipart(): assert authTag is not None assert b2h(authTag) == bytes('ac8fcee96dc6ef8e5236da19b6197d2e', 'utf-8') assert b2h(buf) == bytes('5ba7d42e1bf01d7998e932', "utf-8") + skip_without_decrypt() gcmdec = AesGcmStream(key, iv) bufdec = gcmdec.decrypt(buf[:5]) bufdec += gcmdec.decrypt(buf[5:]) @@ -91,6 +98,7 @@ def test_encrypt_aad(): print(b2h(authTag)) assert b2h(authTag) == bytes('8f85338aa0b13f48f8b17482dbb8acca', 'utf-8') assert b2h(buf) == bytes('5ba7d42e1bf01d7998e932', "utf-8") + skip_without_decrypt() gcmdec = AesGcmStream(key, iv) gcmdec.set_aad(aad) bufdec = gcmdec.decrypt(buf) @@ -109,6 +117,7 @@ def test_multipart_aad(): assert authTag is not None assert b2h(authTag) == bytes('8f85338aa0b13f48f8b17482dbb8acca', 'utf-8') assert b2h(buf) == bytes('5ba7d42e1bf01d7998e932', "utf-8") + skip_without_decrypt() gcmdec = AesGcmStream(key, iv) gcmdec.set_aad(aad) bufdec = gcmdec.decrypt(buf[:5]) @@ -117,6 +126,7 @@ def test_multipart_aad(): assert bufdec == t2b("hello world") def test_encrypt_aad_bad(): + skip_without_decrypt() key = "fedcba9876543210" iv = "0123456789abcdef" aad = "aad data" @@ -163,6 +173,7 @@ def test_invalid_tag_bytes(): assert len(tag) == good def test_decrypt_rejects_wrong_tag_length(): + skip_without_decrypt() key = "fedcba9876543210" iv = "0123456789abcdef" gcm = AesGcmStream(key, iv, tag_bytes=16) @@ -200,3 +211,15 @@ def test_repeated_construction_destruction(): gcm.final() del gcm gc.collect() + + def test_decrypt_rejected_when_not_compiled_in(monkeypatch): + """F-12225: streaming decryption needs AES-GCM decryption in the linked wolfSSL.""" + monkeypatch.setattr(_lib, "AESGCM_STREAM_DECRYPT_ENABLED", 0) + key = "fedcba9876543210" + iv = "0123456789abcdef" + gcm = AesGcmStream(key, iv) + with pytest.raises(NotImplementedError, match="AES-GCM streaming decryption"): + gcm.decrypt(bytes.fromhex("5ba7d42e1bf01d7998e932")) + # The rejected call leaves the object usable for encryption. + assert gcm.encrypt("hello world") == bytes.fromhex("5ba7d42e1bf01d7998e932") + assert gcm.final() == bytes.fromhex("ac8fcee96dc6ef8e5236da19b6197d2e") diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 860bae3..aba0c65 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -43,6 +43,7 @@ "AES_CTR": "AES", "AES_CBC": "AES", "AES_DECRYPT": "AES", + "AESGCM_STREAM_DECRYPT": "AESGCM_STREAM", } @@ -141,3 +142,30 @@ def test_aes_cbc_decrypt_needs_aes_decrypt(bf): assert "wc_AesCbcDecrypt" not in cdef assert detect(bf, "#define NO_AES")["AES_DECRYPT"] == 0 + + +def test_aesgcm_stream_decrypt_needs_decrypt_support(bf): + stream = "#define WOLFSSL_AESGCM_STREAM" + decrypt_funcs = ("wc_AesGcmDecryptInit", "wc_AesGcmDecryptUpdate", "wc_AesGcmDecryptFinal") + + features = detect(bf, stream) + assert features["AESGCM_STREAM_DECRYPT"] == 1 + cdef = cdef_for(bf, features) + for name in decrypt_funcs: + assert name in cdef, name + + features = detect(bf, stream, "#define NO_AES_DECRYPT") + assert features["AESGCM_STREAM_DECRYPT"] == 0 + cdef = cdef_for(bf, features) + assert "wc_AesGcmEncryptUpdate" in cdef + assert "wc_AesFree" in cdef + for name in decrypt_funcs: + assert name not in cdef, name + + # aes.c also builds GCM decryption with HAVE_AESGCM_DECRYPT. + features = detect(bf, stream, "#define NO_AES_DECRYPT", "#define HAVE_AESGCM_DECRYPT") + assert features["AESGCM_STREAM_DECRYPT"] == 1 + assert "wc_AesGcmDecryptUpdate" in cdef_for(bf, features) + + assert detect(bf, "#define NO_AES", stream, "#define HAVE_AESGCM_DECRYPT")["AESGCM_STREAM_DECRYPT"] == 0 + assert detect(bf)["AESGCM_STREAM_DECRYPT"] == 0 diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index de4dd32..2dcd5ca 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -29,6 +29,7 @@ AES_CTR_ENABLED: int AES_DECRYPT_ENABLED: int AES_SIV_ENABLED: int AESGCM_STREAM_ENABLED: int +AESGCM_STREAM_DECRYPT_ENABLED: int ASN_ENABLED: int CHACHA_ENABLED: int CHACHA_STREAM_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 80423fc..90b536d 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -519,6 +519,8 @@ def decrypt(self, data: BytesOrStr) -> bytes: """ Add more data to the decryption stream """ + if not _lib.AESGCM_STREAM_DECRYPT_ENABLED: + raise NotImplementedError("AES-GCM streaming decryption is not supported by this wolfSSL build") aad = b"" data = t2b(data) if self._mode is None: From af6c329cb495ef76b38f0a1ebb659426b14c59be Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 18:19:40 +0000 Subject: [PATCH 07/26] Declare SHA-3 functions only for sizes wolfSSL provides (F-12226) wolfSSL builds each SHA-3 size unless WOLFSSL_NOSHA3_224/256/384/512 is defined, and settings.h keeps only SHA3-384 on Xilinx. Against a local wolfSSL with one of those sizes disabled, the extension failed to import with an undefined symbol. Detect each size, expose SHA3__ENABLED, and declare the SHA-3 functions only for the sizes that are available. Sha3 raises NotImplementedError for a size that is not compiled in, before allocating the state. Invalid sizes are unchanged. --- scripts/build_ffi.py | 47 +++++++++++++++++++-------------- tests/test_build_ffi.py | 44 +++++++++++++++++++++++++++++++ tests/test_hashes.py | 23 ++++++++++++++-- wolfcrypt/_ffi/lib.pyi | 4 +++ wolfcrypt/hashes.py | 58 +++++++++++++++++++++++------------------ 5 files changed, 129 insertions(+), 47 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 37eacdb..dcfc003 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -415,6 +415,12 @@ def defined(name): # aes.c builds streaming GCM decryption only with HAVE_AES_DECRYPT or HAVE_AESGCM_DECRYPT. features["AESGCM_STREAM_DECRYPT"] = 1 if features["AES"] and features["AESGCM_STREAM"] and ( features["AES_DECRYPT"] or defined("HAVE_AESGCM_DECRYPT")) else 0 + # sha3.c builds each SHA-3 size unless WOLFSSL_NOSHA3_. settings.h + # leaves only SHA3-384 on Xilinx. + xilinx_sha3 = defined("WOLFSSL_XILINX_CRYPT") or defined("WOLFSSL_AFALG_XILINX") + for bits in (224, 256, 384, 512): + disabled = defined(f"WOLFSSL_NOSHA3_{bits}") or (xilinx_sha3 and bits != 384) + features[f"SHA3_{bits}"] = 1 if features["SHA3"] and not disabled else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -549,6 +555,10 @@ def make_source(features): int AES_CBC_ENABLED = {features["AES_CBC"]}; int AES_DECRYPT_ENABLED = {features["AES_DECRYPT"]}; int AESGCM_STREAM_DECRYPT_ENABLED = {features["AESGCM_STREAM_DECRYPT"]}; + int SHA3_224_ENABLED = {features["SHA3_224"]}; + int SHA3_256_ENABLED = {features["SHA3_256"]}; + int SHA3_384_ENABLED = {features["SHA3_384"]}; + int SHA3_512_ENABLED = {features["SHA3_512"]}; """ return init_source_string @@ -595,6 +605,10 @@ def make_cdef(features): extern int AES_CBC_ENABLED; extern int AES_DECRYPT_ENABLED; extern int AESGCM_STREAM_DECRYPT_ENABLED; + extern int SHA3_224_ENABLED; + extern int SHA3_256_ENABLED; + extern int SHA3_384_ENABLED; + extern int SHA3_512_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -937,27 +951,16 @@ def make_cdef(features): if features["SHA3"]: cdef += """ typedef struct { ...; } wc_Sha3; - int wc_InitSha3_224(wc_Sha3*, void *, int); - int wc_InitSha3_256(wc_Sha3*, void *, int); - int wc_InitSha3_384(wc_Sha3*, void *, int); - int wc_InitSha3_512(wc_Sha3*, void *, int); - int wc_Sha3_224_Update(wc_Sha3*, const byte*, word32); - int wc_Sha3_256_Update(wc_Sha3*, const byte*, word32); - int wc_Sha3_384_Update(wc_Sha3*, const byte*, word32); - int wc_Sha3_512_Update(wc_Sha3*, const byte*, word32); - int wc_Sha3_224_Final(wc_Sha3*, byte*); - int wc_Sha3_256_Final(wc_Sha3*, byte*); - int wc_Sha3_384_Final(wc_Sha3*, byte*); - int wc_Sha3_512_Final(wc_Sha3*, byte*); - void wc_Sha3_224_Free(wc_Sha3*); - void wc_Sha3_256_Free(wc_Sha3*); - void wc_Sha3_384_Free(wc_Sha3*); - void wc_Sha3_512_Free(wc_Sha3*); - int wc_Sha3_224_Copy(wc_Sha3*, wc_Sha3*); - int wc_Sha3_256_Copy(wc_Sha3*, wc_Sha3*); - int wc_Sha3_384_Copy(wc_Sha3*, wc_Sha3*); - int wc_Sha3_512_Copy(wc_Sha3*, wc_Sha3*); """ + for bits in (224, 256, 384, 512): + if features[f"SHA3_{bits}"]: + cdef += f""" + int wc_InitSha3_{bits}(wc_Sha3*, void *, int); + int wc_Sha3_{bits}_Update(wc_Sha3*, const byte*, word32); + int wc_Sha3_{bits}_Final(wc_Sha3*, byte*); + void wc_Sha3_{bits}_Free(wc_Sha3*); + int wc_Sha3_{bits}_Copy(wc_Sha3*, wc_Sha3*); + """ if features["DES3"]: cdef += """ @@ -1455,6 +1458,10 @@ def default_features(): "AES_CBC": 1, "AES_DECRYPT": 1, "AESGCM_STREAM_DECRYPT": 1, + "SHA3_224": 1, + "SHA3_256": 1, + "SHA3_384": 1, + "SHA3_512": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index aba0c65..7f3093b 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -44,6 +44,10 @@ "AES_CBC": "AES", "AES_DECRYPT": "AES", "AESGCM_STREAM_DECRYPT": "AESGCM_STREAM", + "SHA3_224": "SHA3", + "SHA3_256": "SHA3", + "SHA3_384": "SHA3", + "SHA3_512": "SHA3", } @@ -169,3 +173,43 @@ def test_aesgcm_stream_decrypt_needs_decrypt_support(bf): assert detect(bf, "#define NO_AES", stream, "#define HAVE_AESGCM_DECRYPT")["AESGCM_STREAM_DECRYPT"] == 0 assert detect(bf)["AESGCM_STREAM_DECRYPT"] == 0 + + +SHA3_BITS = (224, 256, 384, 512) + + +def sha3_funcs(bits): + return (f"wc_InitSha3_{bits}", f"wc_Sha3_{bits}_Update", f"wc_Sha3_{bits}_Final", + f"wc_Sha3_{bits}_Free", f"wc_Sha3_{bits}_Copy") + + +def test_sha3_variants_follow_nosha3_macros(bf): + sha3 = "#define WOLFSSL_SHA3" + + features = detect(bf, sha3) + cdef = cdef_for(bf, features) + for bits in SHA3_BITS: + assert features[f"SHA3_{bits}"] == 1, bits + for name in sha3_funcs(bits): + assert name in cdef, name + + for disabled in SHA3_BITS: + features = detect(bf, sha3, f"#define WOLFSSL_NOSHA3_{disabled}") + cdef = cdef_for(bf, features) + assert "wc_Sha3;" in cdef + for bits in SHA3_BITS: + enabled = bits != disabled + assert features[f"SHA3_{bits}"] == enabled, (disabled, bits) + for name in sha3_funcs(bits): + assert (name in cdef) == enabled, (disabled, name) + + assert detect(bf, sha3, " #define WOLFSSL_NOSHA3_512 1")["SHA3_512"] == 0 + + # settings.h keeps only SHA3-384 on Xilinx. + for xilinx in ("#define WOLFSSL_XILINX_CRYPT", "#define WOLFSSL_AFALG_XILINX"): + features = detect(bf, sha3, xilinx) + assert [features[f"SHA3_{bits}"] for bits in SHA3_BITS] == [0, 0, 1, 0], xilinx + + features = detect(bf) + for bits in SHA3_BITS: + assert features[f"SHA3_{bits}"] == 0, bits diff --git a/tests/test_hashes.py b/tests/test_hashes.py index 12b282a..eea3cee 100644 --- a/tests/test_hashes.py +++ b/tests/test_hashes.py @@ -84,7 +84,7 @@ def vectors(): "e8fff55e644ee8a106aae19c07f91b3f" "2a2a6d40dfa7302c0fa6a1a9a5bfa03f") ) - if _lib.SHA3_ENABLED: + if _lib.SHA3_ENABLED and _lib.SHA3_384_ENABLED: vectorArray[Sha3]=TestVector( digest=t2b("6170dedf06f83c3305ec18b7558384a5" "a62d86e42c143d416aaec32f971986c1" @@ -124,7 +124,7 @@ def vectors(): hash_params.append(Sha384) # ty: ignore[possibly-unresolved-reference] if _lib.SHA512_ENABLED: hash_params.append(Sha512) # ty: ignore[possibly-unresolved-reference] -if _lib.SHA3_ENABLED: +if _lib.SHA3_ENABLED and _lib.SHA3_384_ENABLED: hash_params.append(Sha3) # ty: ignore[possibly-unresolved-reference] hmac_params = [] @@ -218,3 +218,22 @@ def test_hash_copy_destroy_lifecycle(hash_cls, vectors): assert c.hexdigest() == digest del c gc.collect() + + +if _lib.SHA3_ENABLED: + SHA3_FLAGS = { + 28: "SHA3_224_ENABLED", + 32: "SHA3_256_ENABLED", + 48: "SHA3_384_ENABLED", + 64: "SHA3_512_ENABLED", + } + + @pytest.mark.parametrize("size", sorted(SHA3_FLAGS)) + def test_sha3_size_not_compiled_in(monkeypatch, size): + """F-12226: a SHA-3 size missing from the linked wolfSSL raises NotImplementedError.""" + monkeypatch.setattr(_lib, SHA3_FLAGS[size], 0) + with pytest.raises(NotImplementedError, match="SHA3"): + Sha3("wolfcrypt", size) # ty: ignore[possibly-unresolved-reference] + for other, flag in SHA3_FLAGS.items(): + if other != size and getattr(_lib, flag): + assert len(Sha3("wolfcrypt", other).digest()) == other # ty: ignore[possibly-unresolved-reference] diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 2dcd5ca..a383651 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -53,6 +53,10 @@ RSA_BLINDING_ENABLED: int RSA_PSS_ENABLED: int SHA_ENABLED: int SHA3_ENABLED: int +SHA3_224_ENABLED: int +SHA3_256_ENABLED: int +SHA3_384_ENABLED: int +SHA3_512_ENABLED: int SHA256_ENABLED: int SHA384_ENABLED: int SHA512_ENABLED: int diff --git a/wolfcrypt/hashes.py b/wolfcrypt/hashes.py index 76180af..875aaa4 100644 --- a/wolfcrypt/hashes.py +++ b/wolfcrypt/hashes.py @@ -32,6 +32,8 @@ from wolfcrypt.utils import t2b, b2h, BytesOrStr if TYPE_CHECKING: + from collections.abc import Callable + from _cffi_backend import FFI @@ -323,19 +325,22 @@ class Sha3(_Sha): SHA3_384_DIGEST_SIZE = 48 SHA3_512_DIGEST_SIZE = 64 - _SHA3_FREE = { - 28: _lib.wc_Sha3_224_Free, - 32: _lib.wc_Sha3_256_Free, - 48: _lib.wc_Sha3_384_Free, - 64: _lib.wc_Sha3_512_Free, - } - - _SHA3_COPY = { - 28: _lib.wc_Sha3_224_Copy, - 32: _lib.wc_Sha3_256_Copy, - 48: _lib.wc_Sha3_384_Copy, - 64: _lib.wc_Sha3_512_Copy, - } + _SHA3_FREE: dict[int, Callable[[FFI.CData], None]] = {} + _SHA3_COPY: dict[int, Callable[[FFI.CData, FFI.CData], int]] = {} + if _lib.SHA3_224_ENABLED: + _SHA3_FREE[28] = _lib.wc_Sha3_224_Free + _SHA3_COPY[28] = _lib.wc_Sha3_224_Copy + if _lib.SHA3_256_ENABLED: + _SHA3_FREE[32] = _lib.wc_Sha3_256_Free + _SHA3_COPY[32] = _lib.wc_Sha3_256_Copy + if _lib.SHA3_384_ENABLED: + _SHA3_FREE[48] = _lib.wc_Sha3_384_Free + _SHA3_COPY[48] = _lib.wc_Sha3_384_Copy + if _lib.SHA3_512_ENABLED: + _SHA3_FREE[64] = _lib.wc_Sha3_512_Free + _SHA3_COPY[64] = _lib.wc_Sha3_512_Copy + _SHA3_FLAGS = {28: "SHA3_224_ENABLED", 32: "SHA3_256_ENABLED", + 48: "SHA3_384_ENABLED", 64: "SHA3_512_ENABLED"} def __del__(self) -> None: # Unlike the SHA-1/2 classes, Sha3's _delete is set per-instance @@ -350,6 +355,9 @@ def __del__(self) -> None: self._delete(self._native_object) def __init__(self, string: BytesOrStr | None = None, size: int = SHA3_384_DIGEST_SIZE) -> None: # pylint: disable=W0231 + flag = self._SHA3_FLAGS.get(size) + if flag is not None and not getattr(_lib, flag): + raise NotImplementedError(f"SHA3-{size * 8} is not supported by this wolfSSL build") self._native_object = _ffi.new(self._native_type) self._shallow_copy = False self.digest_size = size @@ -393,37 +401,37 @@ def copy(self) -> Sha3: @override def _init(self) -> int: - if self.digest_size == Sha3.SHA3_224_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_224_DIGEST_SIZE and _lib.SHA3_224_ENABLED: return _lib.wc_InitSha3_224(self._native_object, _ffi.NULL, 0) - if self.digest_size == Sha3.SHA3_256_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_256_DIGEST_SIZE and _lib.SHA3_256_ENABLED: return _lib.wc_InitSha3_256(self._native_object, _ffi.NULL, 0) - if self.digest_size == Sha3.SHA3_384_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_384_DIGEST_SIZE and _lib.SHA3_384_ENABLED: return _lib.wc_InitSha3_384(self._native_object, _ffi.NULL, 0) - if self.digest_size == Sha3.SHA3_512_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_512_DIGEST_SIZE and _lib.SHA3_512_ENABLED: return _lib.wc_InitSha3_512(self._native_object, _ffi.NULL, 0) return -1 @override def _update(self, data: bytes) -> int: - if self.digest_size == Sha3.SHA3_224_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_224_DIGEST_SIZE and _lib.SHA3_224_ENABLED: return _lib.wc_Sha3_224_Update(self._native_object, data, len(data)) - if self.digest_size == Sha3.SHA3_256_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_256_DIGEST_SIZE and _lib.SHA3_256_ENABLED: return _lib.wc_Sha3_256_Update(self._native_object, data, len(data)) - if self.digest_size == Sha3.SHA3_384_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_384_DIGEST_SIZE and _lib.SHA3_384_ENABLED: return _lib.wc_Sha3_384_Update(self._native_object, data, len(data)) - if self.digest_size == Sha3.SHA3_512_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_512_DIGEST_SIZE and _lib.SHA3_512_ENABLED: return _lib.wc_Sha3_512_Update(self._native_object, data, len(data)) return -1 @override def _final(self, obj: FFI.CData, ret: FFI.CData) -> int: - if self.digest_size == Sha3.SHA3_224_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_224_DIGEST_SIZE and _lib.SHA3_224_ENABLED: return _lib.wc_Sha3_224_Final(obj, ret) - if self.digest_size == Sha3.SHA3_256_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_256_DIGEST_SIZE and _lib.SHA3_256_ENABLED: return _lib.wc_Sha3_256_Final(obj, ret) - if self.digest_size == Sha3.SHA3_384_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_384_DIGEST_SIZE and _lib.SHA3_384_ENABLED: return _lib.wc_Sha3_384_Final(obj, ret) - if self.digest_size == Sha3.SHA3_512_DIGEST_SIZE: + if self.digest_size == Sha3.SHA3_512_DIGEST_SIZE and _lib.SHA3_512_ENABLED: return _lib.wc_Sha3_512_Final(obj, ret) return -1 From c505da1e2a462f8d16aa701cd619777f47244eca Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 18:27:19 +0000 Subject: [PATCH 08/26] Detect HKDF only when wolfSSL also provides HMAC (F-13020) wolfSSL builds wc_HKDF* only without NO_HMAC, so a HAVE_HKDF build with --disable-hmac no longer declares the HKDF functions. The HKDF tests import the module only when HKDF is enabled. --- scripts/build_ffi.py | 3 ++- tests/test_build_ffi.py | 21 +++++++++++++++++++++ tests/test_hkdf.py | 6 ++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index dcfc003..bbe7a2e 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -403,7 +403,8 @@ def defined(name): have_mldsa_no_context_support = re.search(r'#define\s+(' + '|'.join(mldsa_no_context_defines) + r')\s+', '\n'.join(defines)) features["ML_DSA_NO_CTX"] = 1 if have_mldsa_no_context_support else 0 features["ML_KEM"] = 1 if '#define WOLFSSL_HAVE_MLKEM' in defines else 0 - features["HKDF"] = 1 if "#define HAVE_HKDF" in defines else 0 + # hmac.h and hmac.c provide HKDF only without NO_HMAC. + features["HKDF"] = 1 if "#define HAVE_HKDF" in defines and features["HMAC"] else 0 # Unlike the other fatures, HASHDRBG is enabled by default in random.h, unless WC_NO_HASHDRBG or # CUSTOM_RAND_GENERATE_BLOCK is defined. features["HASHDRBG"] = 0 if ("#define WC_NO_HASHDRBG" in defines or "#define CUSTOM_RAND_GENERATE_BLOCK" in defines) else 1 diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 7f3093b..bbbd70c 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -48,6 +48,7 @@ "SHA3_256": "SHA3", "SHA3_384": "SHA3", "SHA3_512": "SHA3", + "HKDF": "HMAC", } @@ -213,3 +214,23 @@ def test_sha3_variants_follow_nosha3_macros(bf): features = detect(bf) for bits in SHA3_BITS: assert features[f"SHA3_{bits}"] == 0, bits + + +def test_hkdf_needs_hmac(bf): + hkdf_funcs = ("wc_HKDF(", "wc_HKDF_Extract(", "wc_HKDF_Extract_ex(", "wc_HKDF_Expand(", "wc_HKDF_Expand_ex(") + + features = detect(bf, "#define HAVE_HKDF") + assert features["HKDF"] == 1 + cdef = cdef_for(bf, features) + for name in hkdf_funcs: + assert name in cdef, name + + # hmac.h and hmac.c provide HKDF only without NO_HMAC. + features = detect(bf, "#define HAVE_HKDF", "#define NO_HMAC") + assert features["HMAC"] == 0 + assert features["HKDF"] == 0 + cdef = cdef_for(bf, features) + for name in hkdf_funcs: + assert name not in cdef, name + + assert detect(bf)["HKDF"] == 0 diff --git a/tests/test_hkdf.py b/tests/test_hkdf.py index a19b483..3f14288 100644 --- a/tests/test_hkdf.py +++ b/tests/test_hkdf.py @@ -24,8 +24,10 @@ import pytest from wolfcrypt._ffi import lib as _lib -from wolfcrypt.hkdf import HKDF, HKDF_Extract, HKDF_Expand -from wolfcrypt.hashes import HmacSha, HmacSha256 + +if _lib.HKDF_ENABLED: + from wolfcrypt.hkdf import HKDF, HKDF_Extract, HKDF_Expand + from wolfcrypt.hashes import HmacSha, HmacSha256 # Skip the whole module if required features are not available. pytestmark = pytest.mark.skipif( From d121fb39e889af8c0d4c287877d9ce7ae7be801e Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 18:40:50 +0000 Subject: [PATCH 09/26] Declare wc_PBKDF2 only when wolfSSL builds PBKDF2 with HMAC (F-12232) wolfSSL builds wc_PBKDF2 only with HAVE_PBKDF2 and without NO_HMAC. Detect a PBKDF2 capability from PWDBASED, HMAC and the settings.h HAVE_PBKDF2 derivation, and use it for the wc_PBKDF2 declaration and wolfcrypt.pwdbased.PBKDF2. A pwdbased build with --disable-hmac now builds and imports. --- scripts/build_ffi.py | 10 +++++++++- tests/test_build_ffi.py | 21 +++++++++++++++++++++ tests/test_pwdbased.py | 17 +++++++++++++++-- wolfcrypt/_ffi/lib.pyi | 1 + wolfcrypt/pwdbased.py | 2 +- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index bbe7a2e..60cf2b0 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -422,6 +422,11 @@ def defined(name): for bits in (224, 256, 384, 512): disabled = defined(f"WOLFSSL_NOSHA3_{bits}") or (xilinx_sha3 and bits != 384) features[f"SHA3_{bits}"] = 1 if features["SHA3"] and not disabled else 0 + # pwdbased.c builds wc_PBKDF2 only with HAVE_PBKDF2 and without NO_HMAC. + # settings.h defines HAVE_PBKDF2 unless NO_PBKDF2, and always for PKCS7 and scrypt. + features["PBKDF2"] = 1 if features["PWDBASED"] and features["HMAC"] and ( + not defined("NO_PBKDF2") or defined("HAVE_PBKDF2") or defined("HAVE_PKCS7") + or defined("HAVE_SCRYPT")) else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -560,6 +565,7 @@ def make_source(features): int SHA3_256_ENABLED = {features["SHA3_256"]}; int SHA3_384_ENABLED = {features["SHA3_384"]}; int SHA3_512_ENABLED = {features["SHA3_512"]}; + int PBKDF2_ENABLED = {features["PBKDF2"]}; """ return init_source_string @@ -610,6 +616,7 @@ def make_cdef(features): extern int SHA3_256_ENABLED; extern int SHA3_384_ENABLED; extern int SHA3_512_ENABLED; + extern int PBKDF2_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1299,7 +1306,7 @@ def make_cdef(features): void* heap, int devId); """ - if features["PWDBASED"]: + if features["PBKDF2"]: cdef += """ int wc_PBKDF2(byte* output, const byte* passwd, int pLen, const byte* salt, int sLen, int iterations, int kLen, @@ -1463,6 +1470,7 @@ def default_features(): "SHA3_256": 1, "SHA3_384": 1, "SHA3_512": 1, + "PBKDF2": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index bbbd70c..3853688 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -49,6 +49,7 @@ "SHA3_384": "SHA3", "SHA3_512": "SHA3", "HKDF": "HMAC", + "PBKDF2": "PWDBASED", } @@ -234,3 +235,23 @@ def test_hkdf_needs_hmac(bf): assert name not in cdef, name assert detect(bf)["HKDF"] == 0 + + +def test_pbkdf2_needs_hmac_and_pbkdf2(bf): + features = detect(bf) + assert features["PBKDF2"] == 1 + assert "wc_PBKDF2(" in cdef_for(bf, features) + + # pwdbased.c builds wc_PBKDF2 only with HAVE_PBKDF2 and without NO_HMAC. + for define in ("#define NO_HMAC", "#define NO_PBKDF2", " #define NO_PBKDF2 1", "#define NO_PWDBASED"): + features = detect(bf, define) + assert features["PBKDF2"] == 0, define + assert "wc_PBKDF2(" not in cdef_for(bf, features), define + + # settings.h defines HAVE_PBKDF2 for PKCS7 and scrypt even with NO_PBKDF2. + for define in ("#define HAVE_PBKDF2", "#define HAVE_PKCS7", "#define HAVE_SCRYPT"): + features = detect(bf, "#define NO_PBKDF2", define) + assert features["PBKDF2"] == 1, define + assert "wc_PBKDF2(" in cdef_for(bf, features), define + + assert detect(bf, "#define NO_HMAC", "#define HAVE_PKCS7")["PBKDF2"] == 0 diff --git a/tests/test_pwdbased.py b/tests/test_pwdbased.py index 2444860..ca3f693 100644 --- a/tests/test_pwdbased.py +++ b/tests/test_pwdbased.py @@ -22,10 +22,12 @@ # ty: ignore[possibly-missing-import] from collections import namedtuple +import importlib.util import pytest +from wolfcrypt import pwdbased from wolfcrypt._ffi import lib as _lib -if _lib.PWDBASED_ENABLED: +if _lib.PBKDF2_ENABLED: from wolfcrypt.pwdbased import PBKDF2 if _lib.SHA_ENABLED: @@ -41,7 +43,7 @@ def pbkdf2_vectors(): vectors = [] - if _lib.PWDBASED_ENABLED and _lib.SHA_ENABLED and _lib.HMAC_ENABLED: + if _lib.PBKDF2_ENABLED and _lib.SHA_ENABLED and _lib.HMAC_ENABLED: # HMAC requires a key, which in this case is the password. Do not # shorten the length of the password below the FIPS requirement. # See HMAC_FIPS_MIN_KEY. @@ -60,3 +62,14 @@ def test_pbkdf2(pbkdf2_vectors): key = PBKDF2(vector.password, vector.salt, vector.iterations, vector.key_length, vector.hash_type) assert len(key) == vector.key_length + +def test_pbkdf2_defined_only_when_enabled(monkeypatch): + """F-12232: PBKDF2 needs wc_PBKDF2 in the linked wolfSSL.""" + assert hasattr(pwdbased, "PBKDF2") == bool(_lib.PBKDF2_ENABLED) + + # Load a fresh copy of the module as if wc_PBKDF2 were not compiled in. + monkeypatch.setattr(_lib, "PBKDF2_ENABLED", 0) + spec = importlib.util.find_spec("wolfcrypt.pwdbased") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + assert not hasattr(module, "PBKDF2") diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index a383651..6076cb6 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -47,6 +47,7 @@ ML_DSA_ENABLED: int ML_DSA_NO_CTX_ENABLED: int ML_KEM_ENABLED: int MPAPI_ENABLED: int +PBKDF2_ENABLED: int PWDBASED_ENABLED: int RSA_ENABLED: int RSA_BLINDING_ENABLED: int diff --git a/wolfcrypt/pwdbased.py b/wolfcrypt/pwdbased.py index e679923..70cc302 100644 --- a/wolfcrypt/pwdbased.py +++ b/wolfcrypt/pwdbased.py @@ -27,7 +27,7 @@ from wolfcrypt.exceptions import WolfCryptApiError -if _lib.PWDBASED_ENABLED: +if _lib.PBKDF2_ENABLED: def PBKDF2(password: bytes | str, salt: bytes | str, iterations: int, key_length: int, hash_type: int) -> bytes: if isinstance(salt, str): salt = str.encode(salt) From 34809177914b5c33d20eadeb8588c7b5a1ff6f6a Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 18:54:39 +0000 Subject: [PATCH 10/26] Detect PEM-to-DER and DER-to-PEM support separately (F-12233) wolfSSL builds wc_PemToDer only with WOLFSSL_PEM_TO_DER and wc_DerToPemEx only with WOLFSSL_DER_TO_PEM. settings.h derives these macros, and a plain --disable-keygen build has no DER-to-PEM. Detect each direction on its own, declare the functions only when they exist, and define pem_to_der, der_to_pem and the RSA from_pem classmethods only when the matching direction is available. --- scripts/build_ffi.py | 27 ++++++++++++++++++++-- tests/test_asn.py | 39 +++++++++++++++++++++++++++----- tests/test_build_ffi.py | 50 +++++++++++++++++++++++++++++++++++++++++ tests/test_ciphers.py | 16 +++++++++++++ wolfcrypt/_ffi/lib.pyi | 2 ++ wolfcrypt/asn.py | 4 +++- wolfcrypt/ciphers.py | 6 ++--- 7 files changed, 132 insertions(+), 12 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 60cf2b0..0956bc0 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -427,6 +427,15 @@ def defined(name): features["PBKDF2"] = 1 if features["PWDBASED"] and features["HMAC"] and ( not defined("NO_PBKDF2") or defined("HAVE_PBKDF2") or defined("HAVE_PKCS7") or defined("HAVE_SCRYPT")) else 0 + # asn.c builds the PEM/DER conversions only with certificate support. + # settings.h derives WOLFSSL_PEM_TO_DER and WOLFSSL_DER_TO_PEM. + certs = features["ASN"] and not defined("NO_CERTS") + features["PEM_TO_DER"] = 1 if certs and (defined("WOLFSSL_PEM_TO_DER") or not ( + defined("WOLFSSL_NO_PEM") or defined("NO_CODING"))) else 0 + features["DER_TO_PEM"] = 1 if certs and (defined("WOLFSSL_DER_TO_PEM") + or defined("WOLFSSL_CERT_GEN") or defined("OPENSSL_EXTRA") or defined("OPENSSL_ALL") + or defined("WOLFSSL_DUAL_ALG_CERTS") + or (defined("WOLFSSL_KEY_GEN") and not defined("WOLFSSL_NO_DER_TO_PEM"))) else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -566,6 +575,8 @@ def make_source(features): int SHA3_384_ENABLED = {features["SHA3_384"]}; int SHA3_512_ENABLED = {features["SHA3_512"]}; int PBKDF2_ENABLED = {features["PBKDF2"]}; + int PEM_TO_DER_ENABLED = {features["PEM_TO_DER"]}; + int DER_TO_PEM_ENABLED = {features["DER_TO_PEM"]}; """ return init_source_string @@ -617,6 +628,8 @@ def make_cdef(features): extern int SHA3_384_ENABLED; extern int SHA3_512_ENABLED; extern int PBKDF2_ENABLED; + extern int PEM_TO_DER_ENABLED; + extern int DER_TO_PEM_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1324,6 +1337,12 @@ def make_cdef(features): static const long SHA384h; static const long SHA512h; + word32 wc_EncodeSignature(byte* out, const byte* digest, word32 digSz, + int hashOID); + """ + + if features["PEM_TO_DER"]: + cdef += """ typedef struct DerBuffer { byte* buffer; void* heap; @@ -1333,12 +1352,14 @@ def make_cdef(features): } DerBuffer; typedef struct { ...; } EncryptedInfo; - word32 wc_EncodeSignature(byte* out, const byte* digest, word32 digSz, - int hashOID); int wc_PemToDer(const unsigned char* buff, long longSz, int type, DerBuffer** pDer, void* heap, EncryptedInfo* info, int* keyFormat); void wc_FreeDer(DerBuffer** pDer); + """ + + if features["DER_TO_PEM"]: + cdef += """ int wc_DerToPemEx(const byte* der, word32 derSz, byte* output, word32 outSz, byte *cipher_info, int type); """ @@ -1471,6 +1492,8 @@ def default_features(): "SHA3_384": 1, "SHA3_512": 1, "PBKDF2": 1, + "PEM_TO_DER": 1, + "DER_TO_PEM": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_asn.py b/tests/test_asn.py index 6904d30..833a64f 100644 --- a/tests/test_asn.py +++ b/tests/test_asn.py @@ -22,13 +22,19 @@ # ty: ignore[possibly-missing-import] from collections import namedtuple +import importlib.util import pytest import os +from wolfcrypt import asn from wolfcrypt._ffi import lib as _lib from wolfcrypt.utils import h2b if _lib.ASN_ENABLED: - from wolfcrypt.asn import pem_to_der, der_to_pem, make_signature, check_signature + from wolfcrypt.asn import make_signature, check_signature +if _lib.PEM_TO_DER_ENABLED: + from wolfcrypt.asn import pem_to_der +if _lib.DER_TO_PEM_ENABLED: + from wolfcrypt.asn import der_to_pem if _lib.SHA256_ENABLED: from wolfcrypt.hashes import Sha256 if _lib.RSA_ENABLED: @@ -77,7 +83,7 @@ def signature_vectors(): # Signature computed with: # echo -n "wolfcrypt is the best crypto around" | \ # openssl dgst -hex -sha256 -sign tests/certs/server-key.pem - if _lib.ASN_ENABLED and _lib.SHA256_ENABLED and _lib.RSA_ENABLED: + if _lib.PEM_TO_DER_ENABLED and _lib.SHA256_ENABLED and _lib.RSA_ENABLED: vectors.append(TestVector( data="wolfcrypt is the best crypto around", signature=h2b("1d65f21df8fdc9f3c2351792840423481c6b0f2332105abd9248" @@ -99,14 +105,35 @@ def signature_vectors(): def test_pem_der_conversion(pem_der_conversion_vectors): for vector in pem_der_conversion_vectors: - computed_der = pem_to_der(vector.pem, vector.type) - assert computed_der == vector.der + if _lib.PEM_TO_DER_ENABLED: + computed_der = pem_to_der(vector.pem, vector.type) + assert computed_der == vector.der - computed_pem = der_to_pem(vector.der, vector.type) - assert computed_pem == vector.pem + if _lib.DER_TO_PEM_ENABLED: + computed_pem = der_to_pem(vector.der, vector.type) + assert computed_pem == vector.pem def test_signature(signature_vectors): for vector in signature_vectors: assert make_signature(vector.data, vector.hash_cls, vector.priv_key) == vector.signature assert check_signature(vector.signature, vector.data, vector.hash_cls, vector.pub_key) + +def test_pem_der_helpers_defined_only_when_enabled(monkeypatch): + """F-12233: each conversion direction needs its own wolfSSL function.""" + helpers = {"PEM_TO_DER_ENABLED": "pem_to_der", "DER_TO_PEM_ENABLED": "der_to_pem"} + for flag, name in helpers.items(): + assert hasattr(asn, name) == bool(getattr(_lib, flag)), name + + # Load fresh copies of the module as if one direction were not compiled in. + for disabled, disabled_name in helpers.items(): + with monkeypatch.context() as m: + m.setattr(_lib, disabled, 0) + spec = importlib.util.find_spec("wolfcrypt.asn") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + assert not hasattr(module, disabled_name), disabled + for flag, name in helpers.items(): + if flag != disabled: + assert hasattr(module, name) == bool(getattr(_lib, flag)), (disabled, name) + assert hasattr(module, "make_signature") == bool(_lib.ASN_ENABLED), disabled diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 3853688..318c9f3 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -50,6 +50,8 @@ "SHA3_512": "SHA3", "HKDF": "HMAC", "PBKDF2": "PWDBASED", + "PEM_TO_DER": "ASN", + "DER_TO_PEM": "ASN", } @@ -255,3 +257,51 @@ def test_pbkdf2_needs_hmac_and_pbkdf2(bf): assert "wc_PBKDF2(" in cdef_for(bf, features), define assert detect(bf, "#define NO_HMAC", "#define HAVE_PKCS7")["PBKDF2"] == 0 + + +PEM_TO_DER_DECLS = ("DerBuffer", "EncryptedInfo", "wc_PemToDer(", "wc_FreeDer(") + + +def test_pem_der_conversions_follow_their_own_macros(bf): + # settings.h derives WOLFSSL_DER_TO_PEM only from KEY_GEN, CERT_GEN or OPENSSL_EXTRA. + features = detect(bf) + assert features["PEM_TO_DER"] == 1 + assert features["DER_TO_PEM"] == 0 + cdef = cdef_for(bf, features) + assert "wc_EncodeSignature(" in cdef + for name in PEM_TO_DER_DECLS: + assert name in cdef, name + assert "wc_DerToPemEx(" not in cdef + + for define in ("#define WOLFSSL_KEY_GEN", " #define WOLFSSL_KEY_GEN 1", "#define WOLFSSL_CERT_GEN", + "#define OPENSSL_EXTRA", "#define OPENSSL_ALL", "#define WOLFSSL_DUAL_ALG_CERTS", + "#define WOLFSSL_DER_TO_PEM"): + features = detect(bf, define) + assert features["DER_TO_PEM"] == 1, define + assert "wc_DerToPemEx(" in cdef_for(bf, features), define + + features = detect(bf, "#define WOLFSSL_KEY_GEN", "#define WOLFSSL_NO_DER_TO_PEM") + assert features["DER_TO_PEM"] == 0 + assert "wc_DerToPemEx(" not in cdef_for(bf, features) + assert detect(bf, "#define WOLFSSL_CERT_GEN", "#define WOLFSSL_NO_DER_TO_PEM")["DER_TO_PEM"] == 1 + + # settings.h derives WOLFSSL_PEM_TO_DER unless WOLFSSL_NO_PEM or NO_CODING. + for define in ("#define WOLFSSL_NO_PEM", "#define NO_CODING", " #define NO_CODING 1"): + features = detect(bf, "#define WOLFSSL_KEY_GEN", define) + assert features["PEM_TO_DER"] == 0, define + assert features["DER_TO_PEM"] == 1, define + cdef = cdef_for(bf, features) + assert "wc_EncodeSignature(" in cdef, define + assert "wc_DerToPemEx(" in cdef, define + for name in PEM_TO_DER_DECLS: + assert name not in cdef, (define, name) + assert detect(bf, "#define WOLFSSL_NO_PEM", "#define WOLFSSL_PEM_TO_DER")["PEM_TO_DER"] == 1 + + # asn.c builds both directions only with ASN and certificate support. + for define in ("#define NO_ASN", "#define NO_CERTS"): + features = detect(bf, "#define WOLFSSL_KEY_GEN", define) + assert features["PEM_TO_DER"] == 0, define + assert features["DER_TO_PEM"] == 0, define + cdef = cdef_for(bf, features) + for name in (*PEM_TO_DER_DECLS, "wc_DerToPemEx("): + assert name not in cdef, (define, name) diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 4a3e58a..25cdedb 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -21,6 +21,7 @@ # pylint: disable=redefined-outer-name # ty: ignore[possibly-missing-import] +import importlib.util import os import random from collections import namedtuple @@ -513,6 +514,7 @@ def test_rsa_pss_sign_verify(rsa_private_pss, rsa_public_pss): assert 1024 / 8 == len(signature) == rsa_private_pss.output_size assert rsa_private_pss.verify_pss(plaintext, signature) is True + @pytest.mark.skipif(not _lib.PEM_TO_DER_ENABLED, reason="PEM to DER not enabled") def test_rsa_sign_verify_pem(rsa_private_pem, rsa_public_pem): plaintext = t2b("Everyone gets Friday off.") @@ -529,6 +531,7 @@ def test_rsa_sign_verify_pem(rsa_private_pem, rsa_public_pem): assert 256 == len(signature) == rsa_private_pem.output_size assert plaintext == rsa_private_pem.verify(signature) + @pytest.mark.skipif(not _lib.PEM_TO_DER_ENABLED, reason="PEM to DER not enabled") def test_rsa_sign_verify_pem_rng(rsa_private_pem_rng, rsa_public_pem_rng): plaintext = t2b("Everyone gets Friday off.") @@ -1236,3 +1239,16 @@ def test_decrypt_oaep_requires_hash_type(vectors): rsa = RsaPrivate(vectors[RsaPrivate].key) with pytest.raises(WolfCryptError, match="Hash type not set"): rsa.decrypt_oaep(b"\x00" * rsa.output_size) + + def test_rsa_from_pem_defined_only_when_enabled(monkeypatch): + """F-12233: from_pem needs wc_PemToDer in the linked wolfSSL.""" + for cls in (RsaPublic, RsaPrivate): + assert hasattr(cls, "from_pem") == bool(_lib.PEM_TO_DER_ENABLED), cls + + # Load a fresh copy of the module as if wc_PemToDer were not compiled in. + monkeypatch.setattr(_lib, "PEM_TO_DER_ENABLED", 0) + spec = importlib.util.find_spec("wolfcrypt.ciphers") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + for cls in (module.RsaPublic, module.RsaPrivate): + assert not hasattr(cls, "from_pem"), cls diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 6076cb6..8eef396 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -34,6 +34,7 @@ ASN_ENABLED: int CHACHA_ENABLED: int CHACHA_STREAM_ENABLED: int CHACHA20_POLY1305_ENABLED: int +DER_TO_PEM_ENABLED: int DES3_ENABLED: int ECC_ENABLED: int ED25519_ENABLED: int @@ -48,6 +49,7 @@ ML_DSA_NO_CTX_ENABLED: int ML_KEM_ENABLED: int MPAPI_ENABLED: int PBKDF2_ENABLED: int +PEM_TO_DER_ENABLED: int PWDBASED_ENABLED: int RSA_ENABLED: int RSA_BLINDING_ENABLED: int diff --git a/wolfcrypt/asn.py b/wolfcrypt/asn.py index 395d413..83a8436 100644 --- a/wolfcrypt/asn.py +++ b/wolfcrypt/asn.py @@ -42,7 +42,7 @@ if _lib.SHA512_ENABLED: from wolfcrypt.hashes import Sha512 # ty: ignore[possibly-missing-import] -if _lib.ASN_ENABLED: +if _lib.PEM_TO_DER_ENABLED: def pem_to_der(pem: bytes, pem_type: int) -> bytes: der = _ffi.new("DerBuffer**") ret = _lib.wc_PemToDer(pem, len(pem), pem_type, der, _ffi.NULL, @@ -56,6 +56,7 @@ def pem_to_der(pem: bytes, pem_type: int) -> bytes: _lib.wc_FreeDer(der) return result +if _lib.DER_TO_PEM_ENABLED: def der_to_pem(der: bytes, pem_type: int) -> bytes: pem_length = _lib.wc_DerToPemEx(der, len(der), _ffi.NULL, 0, _ffi.NULL, pem_type) @@ -70,6 +71,7 @@ def der_to_pem(der: bytes, pem_type: int) -> bytes: return _ffi.buffer(pem, pem_length)[:] +if _lib.ASN_ENABLED: def hash_oid_from_class(hash_cls: type[_Hash]) -> int: if _lib.SHA_ENABLED and hash_cls == Sha: return _lib.SHAh diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 90b536d..ca564c9 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -38,7 +38,7 @@ from wolfcrypt.utils import BytesOrStr, t2b from .wc_types import SupportsRsaSign, SupportsRsaVerify -if _lib.ASN_ENABLED: +if _lib.PEM_TO_DER_ENABLED: from wolfcrypt.asn import pem_to_der # ty: ignore[possibly-missing-import] @@ -844,7 +844,7 @@ def __init__(self, key: BytesOrStr, hash_type: int | None = None, rng: Random | if self.output_size <= 0: # pragma: no cover raise WolfCryptApiError("Invalid key error", self.output_size) - if _lib.ASN_ENABLED: + if _lib.PEM_TO_DER_ENABLED: @classmethod def from_pem(cls, file: bytes, hash_type: int | None = None, rng: Random | None = None) -> RsaPublic: der = pem_to_der(file, _lib.PUBLICKEY_TYPE) @@ -1013,7 +1013,7 @@ def __init__(self, key: BytesOrStr | None = None, hash_type: int | None = None, if self.output_size <= 0: # pragma: no cover raise WolfCryptApiError("Invalid key size error", self.output_size) - if _lib.ASN_ENABLED: + if _lib.PEM_TO_DER_ENABLED: @override @classmethod def from_pem(cls, file: bytes, hash_type: int | None = None, rng: Random | None = None) -> RsaPrivate: From d3b631d00323e83074138ca047a8cab992645316 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 19:10:41 +0000 Subject: [PATCH 11/26] Declare wc_GetPkcs8TraditionalOffset only with PKCS#8 support (F-12234) asn.c builds wc_GetPkcs8TraditionalOffset only with HAVE_PKCS8 and without NO_ASN. settings.h defines HAVE_PKCS8 unless both NO_PKCS8 and NO_PKCS12 are set. Detect PKCS8 from those macros, declare the helper only when it exists, and skip the PKCS#8 fallback in RsaPrivate when it is absent. wc_RsaPrivateKeyDecode already skips a PKCS#8 header itself when PKCS#8 support is built. --- scripts/build_ffi.py | 10 ++++++++-- tests/test_build_ffi.py | 29 +++++++++++++++++++++++++++++ tests/test_ciphers.py | 21 +++++++++++++++++++++ wolfcrypt/_ffi/lib.pyi | 1 + wolfcrypt/ciphers.py | 2 ++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 0956bc0..fc6e326 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -436,6 +436,10 @@ def defined(name): or defined("WOLFSSL_CERT_GEN") or defined("OPENSSL_EXTRA") or defined("OPENSSL_ALL") or defined("WOLFSSL_DUAL_ALG_CERTS") or (defined("WOLFSSL_KEY_GEN") and not defined("WOLFSSL_NO_DER_TO_PEM"))) else 0 + # asn.c builds wc_GetPkcs8TraditionalOffset only with HAVE_PKCS8. + # settings.h defines HAVE_PKCS8 unless both NO_PKCS8 and NO_PKCS12. + features["PKCS8"] = 1 if features["ASN"] and (not defined("NO_PKCS8") or not defined("NO_PKCS12") + or defined("HAVE_PKCS8") or defined("HAVE_PKCS12")) else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -577,6 +581,7 @@ def make_source(features): int PBKDF2_ENABLED = {features["PBKDF2"]}; int PEM_TO_DER_ENABLED = {features["PEM_TO_DER"]}; int DER_TO_PEM_ENABLED = {features["DER_TO_PEM"]}; + int PKCS8_ENABLED = {features["PKCS8"]}; """ return init_source_string @@ -630,6 +635,7 @@ def make_cdef(features): extern int PBKDF2_ENABLED; extern int PEM_TO_DER_ENABLED; extern int DER_TO_PEM_ENABLED; + extern int PKCS8_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1364,8 +1370,7 @@ def make_cdef(features): byte *cipher_info, int type); """ - if features["ASN"] or features["RSA"]: - # This ASN function is used by the RSA binding as well. + if features["PKCS8"]: cdef += """ int wc_GetPkcs8TraditionalOffset(byte* input, word32* inOutIdx, word32 sz); """ @@ -1494,6 +1499,7 @@ def default_features(): "PBKDF2": 1, "PEM_TO_DER": 1, "DER_TO_PEM": 1, + "PKCS8": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 318c9f3..7c7156a 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -52,6 +52,7 @@ "PBKDF2": "PWDBASED", "PEM_TO_DER": "ASN", "DER_TO_PEM": "ASN", + "PKCS8": "ASN", } @@ -305,3 +306,31 @@ def test_pem_der_conversions_follow_their_own_macros(bf): cdef = cdef_for(bf, features) for name in (*PEM_TO_DER_DECLS, "wc_DerToPemEx("): assert name not in cdef, (define, name) + + +def test_pkcs8_offset_needs_pkcs8(bf): + helper = "wc_GetPkcs8TraditionalOffset(" + + features = detect(bf) + assert features["PKCS8"] == 1 + assert helper in cdef_for(bf, features) + + # settings.h defines HAVE_PKCS8 unless both NO_PKCS8 and NO_PKCS12. + for define in ("#define NO_PKCS8", "#define NO_PKCS12"): + features = detect(bf, define) + assert features["PKCS8"] == 1, define + assert helper in cdef_for(bf, features), define + + features = detect(bf, "#define NO_PKCS8", " #define NO_PKCS12 1") + assert features["PKCS8"] == 0 + assert features["RSA"] == 1 + assert helper not in cdef_for(bf, features) + + for define in ("#define HAVE_PKCS8", "#define HAVE_PKCS12"): + assert detect(bf, "#define NO_PKCS8", "#define NO_PKCS12", define)["PKCS8"] == 1, define + + # asn.c builds it only without NO_ASN, even with RSA. + features = detect(bf, "#define NO_ASN") + assert features["RSA"] == 1 + assert features["PKCS8"] == 0 + assert helper not in cdef_for(bf, features) diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 25cdedb..2c88dc7 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -28,6 +28,7 @@ import pytest +from wolfcrypt import ciphers from wolfcrypt._ffi import lib as _lib from wolfcrypt.ciphers import MODE_CBC, MODE_CTR, MODE_ECB, WolfCryptError from wolfcrypt.exceptions import WolfCryptApiError @@ -464,6 +465,7 @@ def test_rsa_encrypt_decrypt_pad_oaep(rsa_private_oaep, rsa_public_oaep): assert plaintext == rsa_private_oaep.decrypt_oaep(ciphertext) + @pytest.mark.skipif(not _lib.PKCS8_ENABLED, reason="PKCS#8 not enabled") def test_rsa_pkcs8_encrypt_decrypt(rsa_private_pkcs8, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -548,6 +550,7 @@ def test_rsa_sign_verify_pem_rng(rsa_private_pem_rng, rsa_public_pem_rng): assert 256 == len(signature) == rsa_private_pem_rng.output_size assert plaintext == rsa_private_pem_rng.verify(signature) + @pytest.mark.skipif(not _lib.PKCS8_ENABLED, reason="PKCS#8 not enabled") def test_rsa_pkcs8_sign_verify(rsa_private_pkcs8, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -1252,3 +1255,21 @@ def test_rsa_from_pem_defined_only_when_enabled(monkeypatch): spec.loader.exec_module(module) for cls in (module.RsaPublic, module.RsaPrivate): assert not hasattr(cls, "from_pem"), cls + + def test_rsa_private_without_pkcs8_offset(monkeypatch, vectors): + """F-12234: RsaPrivate must not need wc_GetPkcs8TraditionalOffset without PKCS#8.""" + class LibWithoutPkcs8: + PKCS8_ENABLED = 0 + + def __getattr__(self, name): + if name == "wc_GetPkcs8TraditionalOffset": + raise AttributeError(name) + return getattr(_lib, name) + + monkeypatch.setattr(ciphers, "_lib", LibWithoutPkcs8()) + with pytest.raises(WolfCryptApiError): + RsaPrivate(vectors[RsaPrivate].key[:-1]) + assert RsaPrivate(vectors[RsaPrivate].key).output_size == 128 + if _lib.PKCS8_ENABLED: + # wc_RsaPrivateKeyDecode skips a PKCS#8 header by itself. + assert RsaPrivate(vectors[RsaPrivate].pkcs8_key).output_size == 128 diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 8eef396..804da8c 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -50,6 +50,7 @@ ML_KEM_ENABLED: int MPAPI_ENABLED: int PBKDF2_ENABLED: int PEM_TO_DER_ENABLED: int +PKCS8_ENABLED: int PWDBASED_ENABLED: int RSA_ENABLED: int RSA_BLINDING_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index ca564c9..0363463 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -993,6 +993,8 @@ def __init__(self, key: BytesOrStr | None = None, hash_type: int | None = None, ret = _lib.wc_RsaPrivateKeyDecode(key, idx, self.native_object, len(key)) if ret < 0: + if not _lib.PKCS8_ENABLED: + raise WolfCryptApiError("Invalid key error", ret) idx[0] = 0 # wc_GetPkcs8TraditionalOffset takes byte* (non-const) per # the wolfSSL public header, so route it through a CFFI- From 78bf17eb0a3ac14a8d347f90ea3dea0a5466b35b Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 19:28:46 +0000 Subject: [PATCH 12/26] Declare RNG functions only when wolfSSL builds its RNG (F-13021) With WC_NO_RNG, random.h turns the RNG API into macros (wc_FreeRng becomes a void expression), and random.c and rsa.c build no DRBG, seed callback or wc_RsaSetRNG functions, so the extension did not compile. Detect RNG from WC_NO_RNG, turn off HASHDRBG, WC_RNG_SEED_CB and RSA_BLINDING without it, and declare the RNG functions only when it is present. Random() raises NotImplementedError on such builds, so RSA keys and default RNG arguments are unusable there; tests that need an RNG are skipped. --- scripts/build_ffi.py | 18 ++++++++++++++--- tests/test_asn.py | 3 ++- tests/test_build_ffi.py | 27 +++++++++++++++++++++++++ tests/test_ciphers.py | 26 ++++++++++++++++++++---- tests/test_delete_descriptor_binding.py | 6 ++++-- tests/test_mldsa.py | 2 ++ tests/test_mlkem.py | 3 +++ tests/test_random.py | 11 ++++++++++ wolfcrypt/_ffi/lib.pyi | 1 + wolfcrypt/random.py | 7 +++++-- 10 files changed, 92 insertions(+), 12 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index fc6e326..12be0a2 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -408,6 +408,13 @@ def defined(name): # Unlike the other fatures, HASHDRBG is enabled by default in random.h, unless WC_NO_HASHDRBG or # CUSTOM_RAND_GENERATE_BLOCK is defined. features["HASHDRBG"] = 0 if ("#define WC_NO_HASHDRBG" in defines or "#define CUSTOM_RAND_GENERATE_BLOCK" in defines) else 1 + # random.h replaces the RNG API with macros under WC_NO_RNG. random.c and + # rsa.c then build no DRBG, seed callback or RSA blinding functions. + features["RNG"] = 0 if defined("WC_NO_RNG") else 1 + if not features["RNG"]: + features["HASHDRBG"] = 0 + features["WC_RNG_SEED_CB"] = 0 + features["RSA_BLINDING"] = 0 # aes.h declares wc_AesCtrEncrypt only with WOLFSSL_AES_COUNTER. features["AES_CTR"] = 1 if features["AES"] and defined("WOLFSSL_AES_COUNTER") else 0 # settings.h derives HAVE_AES_CBC and HAVE_AES_DECRYPT unless NO_AES_CBC/NO_AES_DECRYPT. @@ -582,6 +589,7 @@ def make_source(features): int PEM_TO_DER_ENABLED = {features["PEM_TO_DER"]}; int DER_TO_PEM_ENABLED = {features["DER_TO_PEM"]}; int PKCS8_ENABLED = {features["PKCS8"]}; + int RNG_ENABLED = {features["RNG"]}; """ return init_source_string @@ -636,20 +644,23 @@ def make_cdef(features): extern int PEM_TO_DER_ENABLED; extern int DER_TO_PEM_ENABLED; extern int PKCS8_ENABLED; + extern int RNG_ENABLED; typedef unsigned char byte; typedef unsigned int word32; typedef struct { ...; } WC_RNG; typedef struct { ...; } OS_Seed; - + """ + if features["RNG"]: + cdef += """ int wc_InitRng(WC_RNG*); int wc_InitRngNonce(WC_RNG*, byte*, word32); int wc_InitRngNonce_ex(WC_RNG*, byte*, word32, void*, int); int wc_RNG_GenerateBlock(WC_RNG*, byte*, word32); int wc_RNG_GenerateByte(WC_RNG*, byte*); int wc_FreeRng(WC_RNG*); - """ + """ if features["HASHDRBG"]: cdef += """ int wc_RNG_DRBG_Reseed(WC_RNG*, const byte*, word32); @@ -919,7 +930,7 @@ def make_cdef(features): const char* wc_GetErrorString(int error); """ - if not features["FIPS"] or features["FIPS_VERSION"] > 2: + if features["RNG"] and (not features["FIPS"] or features["FIPS_VERSION"] > 2): cdef += """ int wc_GenerateSeed(OS_Seed* os, byte* seed, word32 sz); """ @@ -1500,6 +1511,7 @@ def default_features(): "PEM_TO_DER": 1, "DER_TO_PEM": 1, "PKCS8": 1, + "RNG": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_asn.py b/tests/test_asn.py index 833a64f..53b9380 100644 --- a/tests/test_asn.py +++ b/tests/test_asn.py @@ -83,7 +83,8 @@ def signature_vectors(): # Signature computed with: # echo -n "wolfcrypt is the best crypto around" | \ # openssl dgst -hex -sha256 -sign tests/certs/server-key.pem - if _lib.PEM_TO_DER_ENABLED and _lib.SHA256_ENABLED and _lib.RSA_ENABLED: + # RSA key objects always create a Random. + if _lib.PEM_TO_DER_ENABLED and _lib.SHA256_ENABLED and _lib.RSA_ENABLED and _lib.RNG_ENABLED: vectors.append(TestVector( data="wolfcrypt is the best crypto around", signature=h2b("1d65f21df8fdc9f3c2351792840423481c6b0f2332105abd9248" diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 7c7156a..07db622 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -102,6 +102,7 @@ def test_reference_configs_enable_all_subcapabilities(bf, config): features = detect(bf, *f.read().splitlines(), fips=config == "fips_ready") for sub, parent in SUBCAPABILITIES.items(): assert features[sub] == features[parent], sub + assert features["RNG"] == 1 def test_detection_matches_indented_defines_with_values(bf): @@ -334,3 +335,29 @@ def test_pkcs8_offset_needs_pkcs8(bf): assert features["RSA"] == 1 assert features["PKCS8"] == 0 assert helper not in cdef_for(bf, features) + + +RNG_DECLS = ("wc_InitRng(", "wc_InitRngNonce(", "wc_InitRngNonce_ex(", "wc_RNG_GenerateBlock(", + "wc_RNG_GenerateByte(", "wc_FreeRng(", "wc_RNG_DRBG_Reseed(", "wc_GenerateSeed(", + "wc_SetSeed_Cb(", "wc_RsaSetRNG(") + + +def test_rng_api_needs_rng(bf): + optional = ("#define WC_RNG_SEED_CB", "#define WC_RSA_BLINDING") + + features = detect(bf, *optional) + assert features["RNG"] == 1 + cdef = cdef_for(bf, features) + for name in RNG_DECLS: + assert name in cdef, name + + # random.h replaces the RNG API with macros under WC_NO_RNG. random.c and + # rsa.c then build no RNG, DRBG, seed or RSA blinding functions. + for define in ("#define WC_NO_RNG", " #define WC_NO_RNG 1"): + features = detect(bf, define, *optional) + for name in ("RNG", "HASHDRBG", "WC_RNG_SEED_CB", "RSA_BLINDING"): + assert features[name] == 0, (define, name) + cdef = cdef_for(bf, features) + assert "WC_RNG;" in cdef, define + for name in RNG_DECLS: + assert name not in cdef, (define, name) diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 2c88dc7..8373083 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -340,6 +340,9 @@ def test_chacha_enc_dec(chacha_obj, vectors): assert plaintext == dec if _lib.RSA_ENABLED: + # RSA key objects always create a Random. + needs_rng = pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") + @pytest.fixture def rng(): return Random() @@ -404,6 +407,7 @@ def rsa_public_pem_rng(vectors, rng): pem = f.read() return RsaPublic.from_pem(pem, rng=rng) # ty: ignore[possibly-missing-attribute] + @needs_rng def test_new_rsa_raises(vectors): with pytest.raises(WolfCryptError): RsaPrivate(vectors[RsaPrivate].key[:-1]) # invalid key length @@ -416,6 +420,7 @@ def test_new_rsa_raises(vectors): RsaPrivate.make_key(16384) # ty: ignore[possibly-missing-attribute] + @needs_rng def test_rsa_encrypt_decrypt(rsa_private, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -432,6 +437,7 @@ def test_rsa_encrypt_decrypt(rsa_private, rsa_public): assert 1024 / 8 == len(ciphertext) == rsa_private.output_size assert plaintext == rsa_private.decrypt(ciphertext) + @needs_rng def test_rsa_encrypt_decrypt_rng(rsa_private_rng, rsa_public_rng): plaintext = t2b("Everyone gets Friday off.") @@ -448,6 +454,7 @@ def test_rsa_encrypt_decrypt_rng(rsa_private_rng, rsa_public_rng): assert 1024 / 8 == len(ciphertext) == rsa_private_rng.output_size assert plaintext == rsa_private_rng.decrypt(ciphertext) + @needs_rng def test_rsa_encrypt_decrypt_pad_oaep(rsa_private_oaep, rsa_public_oaep): plaintext = t2b("Everyone gets Friday off.") @@ -466,6 +473,7 @@ def test_rsa_encrypt_decrypt_pad_oaep(rsa_private_oaep, rsa_public_oaep): @pytest.mark.skipif(not _lib.PKCS8_ENABLED, reason="PKCS#8 not enabled") + @needs_rng def test_rsa_pkcs8_encrypt_decrypt(rsa_private_pkcs8, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -483,6 +491,7 @@ def test_rsa_pkcs8_encrypt_decrypt(rsa_private_pkcs8, rsa_public): assert plaintext == rsa_private_pkcs8.decrypt(ciphertext) + @needs_rng def test_rsa_sign_verify(rsa_private, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -500,6 +509,7 @@ def test_rsa_sign_verify(rsa_private, rsa_public): assert plaintext == rsa_private.verify(signature) if _lib.RSA_PSS_ENABLED: + @needs_rng def test_rsa_pss_sign_verify(rsa_private_pss, rsa_public_pss): plaintext = t2b("Everyone gets Friday off.") @@ -517,6 +527,7 @@ def test_rsa_pss_sign_verify(rsa_private_pss, rsa_public_pss): assert rsa_private_pss.verify_pss(plaintext, signature) is True @pytest.mark.skipif(not _lib.PEM_TO_DER_ENABLED, reason="PEM to DER not enabled") + @needs_rng def test_rsa_sign_verify_pem(rsa_private_pem, rsa_public_pem): plaintext = t2b("Everyone gets Friday off.") @@ -534,6 +545,7 @@ def test_rsa_sign_verify_pem(rsa_private_pem, rsa_public_pem): assert plaintext == rsa_private_pem.verify(signature) @pytest.mark.skipif(not _lib.PEM_TO_DER_ENABLED, reason="PEM to DER not enabled") + @needs_rng def test_rsa_sign_verify_pem_rng(rsa_private_pem_rng, rsa_public_pem_rng): plaintext = t2b("Everyone gets Friday off.") @@ -551,6 +563,7 @@ def test_rsa_sign_verify_pem_rng(rsa_private_pem_rng, rsa_public_pem_rng): assert plaintext == rsa_private_pem_rng.verify(signature) @pytest.mark.skipif(not _lib.PKCS8_ENABLED, reason="PKCS#8 not enabled") + @needs_rng def test_rsa_pkcs8_sign_verify(rsa_private_pkcs8, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -834,8 +847,9 @@ def test_new_ed25519_raises(vectors): with pytest.raises(WolfCryptError): Ed25519Public(vectors[Ed25519Public].key[:-1]) # invalid key length - with pytest.raises(WolfCryptError): # invalid key size - Ed25519Private.make_key(1024) + if _lib.RNG_ENABLED: + with pytest.raises(WolfCryptError): # invalid key size + Ed25519Private.make_key(1024) def test_ed25519_key_encoding(vectors): @@ -885,8 +899,9 @@ def test_new_ed448_raises(vectors): with pytest.raises(WolfCryptError): Ed448Public(vectors[Ed448Public].key[:-1]) # invalid key length - with pytest.raises(WolfCryptError): # invalid key size - Ed448Private.make_key(1024) + if _lib.RNG_ENABLED: + with pytest.raises(WolfCryptError): # invalid key size + Ed448Private.make_key(1024) def test_ed448_key_encoding(vectors): @@ -1233,11 +1248,13 @@ def test_chacha_set_iv_resets_both_directions(): if _lib.RSA_ENABLED: + @needs_rng def test_encrypt_oaep_requires_hash_type(vectors): rsa = RsaPublic(vectors[RsaPublic].key) with pytest.raises(WolfCryptError, match="Hash type not set"): rsa.encrypt_oaep(b"plaintext") + @needs_rng def test_decrypt_oaep_requires_hash_type(vectors): rsa = RsaPrivate(vectors[RsaPrivate].key) with pytest.raises(WolfCryptError, match="Hash type not set"): @@ -1256,6 +1273,7 @@ def test_rsa_from_pem_defined_only_when_enabled(monkeypatch): for cls in (module.RsaPublic, module.RsaPrivate): assert not hasattr(cls, "from_pem"), cls + @needs_rng def test_rsa_private_without_pkcs8_offset(monkeypatch, vectors): """F-12234: RsaPrivate must not need wc_GetPkcs8TraditionalOffset without PKCS#8.""" class LibWithoutPkcs8: diff --git a/tests/test_delete_descriptor_binding.py b/tests/test_delete_descriptor_binding.py index 226c6d3..d9b4db3 100644 --- a/tests/test_delete_descriptor_binding.py +++ b/tests/test_delete_descriptor_binding.py @@ -51,8 +51,9 @@ def __del__(self): def _static_attrs(): """Yield (cls, attr_name) pairs that must be staticmethod-wrapped.""" - from wolfcrypt.random import Random - yield Random, "_delete" + if _lib.RNG_ENABLED: + from wolfcrypt.random import Random + yield Random, "_delete" if _lib.SHA_ENABLED: from wolfcrypt.hashes import Sha # ty: ignore[possibly-missing-import] @@ -151,6 +152,7 @@ def run(self): ) +@pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") def test_random_delete_receives_only_native_object(): """End-to-end behavioral check on the real ``Random`` class. diff --git a/tests/test_mldsa.py b/tests/test_mldsa.py index 5c4b0db..1ecac07 100644 --- a/tests/test_mldsa.py +++ b/tests/test_mldsa.py @@ -31,6 +31,8 @@ @pytest.fixture def rng(): + if not _lib.RNG_ENABLED: + pytest.skip("RNG not enabled") return Random() @pytest.fixture( diff --git a/tests/test_mlkem.py b/tests/test_mlkem.py index 89ed2ba..736ba9a 100644 --- a/tests/test_mlkem.py +++ b/tests/test_mlkem.py @@ -549,6 +549,7 @@ (MlKemType.ML_KEM_1024), ] + @pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") @pytest.mark.parametrize("mlkem_type", mlkem_types) def test_init_pattern_1(mlkem_type): mlkem_priv = MlKemPrivate(mlkem_type) @@ -573,6 +574,7 @@ def test_init_pattern_1(mlkem_type): assert ss_send == ref_ss[mlkem_type] assert ss_send == ss_recv + @pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") @pytest.mark.parametrize("mlkem_type", mlkem_types) def test_init_pattern_2(mlkem_type): mlkem_priv = MlKemPrivate.make_key_with_random( @@ -598,6 +600,7 @@ def test_init_pattern_2(mlkem_type): assert ss_send == ref_ss[mlkem_type] assert ss_send == ss_recv + @pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") @pytest.mark.parametrize("mlkem_type", mlkem_types) def test_init_pattern_3(mlkem_type): mlkem_priv = MlKemPrivate.make_key(mlkem_type) diff --git a/tests/test_random.py b/tests/test_random.py index 4e5eed0..8c7f7fc 100644 --- a/tests/test_random.py +++ b/tests/test_random.py @@ -27,6 +27,8 @@ @pytest.fixture def rng(): + if not _lib.RNG_ENABLED: + pytest.skip("RNG not enabled") return Random() @@ -42,6 +44,8 @@ def test_bytes(rng): @pytest.fixture def rng_nonce(): + if not _lib.RNG_ENABLED: + pytest.skip("RNG not enabled") return Random(b"abcdefghijklmnopqrstuv") @@ -80,3 +84,10 @@ def test_reseed_multiple(rng): # Pull some bytes from the random number generator to test that it still works. rng.bytes(100) + + +def test_random_rejected_when_not_compiled_in(monkeypatch): + """F-13021: Random needs the RNG API in the linked wolfSSL.""" + monkeypatch.setattr(_lib, "RNG_ENABLED", 0) + with pytest.raises(NotImplementedError, match="RNG is not supported"): + Random() diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 804da8c..d427cb7 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -52,6 +52,7 @@ PBKDF2_ENABLED: int PEM_TO_DER_ENABLED: int PKCS8_ENABLED: int PWDBASED_ENABLED: int +RNG_ENABLED: int RSA_ENABLED: int RSA_BLINDING_ENABLED: int RSA_PSS_ENABLED: int diff --git a/wolfcrypt/random.py b/wolfcrypt/random.py index c29844c..1a5a336 100644 --- a/wolfcrypt/random.py +++ b/wolfcrypt/random.py @@ -35,6 +35,8 @@ class Random: def __init__(self, nonce: __builtins__.bytes = b"", device_id: int = -2) -> None: self._native_object: _lib.RNG | None = None + if not _lib.RNG_ENABLED: + raise NotImplementedError("RNG is not supported by this wolfSSL build") self._native_object = _ffi.new("WC_RNG *") ret = _lib.wc_InitRngNonce_ex(self._native_object, nonce, len(nonce), _ffi.NULL, device_id) @@ -42,8 +44,9 @@ def __init__(self, nonce: __builtins__.bytes = b"", device_id: int = -2) -> None self._native_object = None raise WolfCryptApiError("RNG init error", ret) - # making sure _lib.wc_FreeRng outlives WC_RNG instances - _delete = staticmethod(_lib.wc_FreeRng) + if _lib.RNG_ENABLED: + # making sure _lib.wc_FreeRng outlives WC_RNG instances + _delete = staticmethod(_lib.wc_FreeRng) def __del__(self) -> None: if self._native_object is not None: From 6b5f504e700df7140394adfab436a8911c1903a0 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 19:45:41 +0000 Subject: [PATCH 13/26] Declare RSA operations only when wolfSSL builds them (F-12227) wolfSSL leaves out RSA operations with WOLFSSL_RSA_PUBLIC_ONLY (--enable-rsapub), WOLFSSL_RSA_VERIFY_ONLY and WOLFSSL_RSA_VERIFY_INLINE (--enable-rsavfy) and WC_NO_RSA_OAEP (--disable-oaep). Against such a local wolfSSL the extension failed to import with an undefined symbol. Detect RSA_ENCRYPT, RSA_PRIVATE, RSA_SIGN, RSA_VERIFY and RSA_OAEP and declare each operation only when it is built. encrypt, encrypt_oaep, decrypt, decrypt_oaep, make_key and sign_pss are defined only when their operations exist. RsaPrivate.sign() and RsaPublic.verify() raise NotImplementedError instead. --- scripts/build_ffi.py | 87 +++++++++++++++++----- tests/test_asn.py | 3 +- tests/test_build_ffi.py | 49 +++++++++++++ tests/test_ciphers.py | 78 ++++++++++++++++++-- wolfcrypt/_ffi/lib.pyi | 5 ++ wolfcrypt/ciphers.py | 156 +++++++++++++++++++++------------------- 6 files changed, 282 insertions(+), 96 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 12be0a2..68b1419 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -447,6 +447,15 @@ def defined(name): # settings.h defines HAVE_PKCS8 unless both NO_PKCS8 and NO_PKCS12. features["PKCS8"] = 1 if features["ASN"] and (not defined("NO_PKCS8") or not defined("NO_PKCS12") or defined("HAVE_PKCS8") or defined("HAVE_PKCS12")) else 0 + # rsa.c leaves out RSA operations for the subset macros set by + # --enable-rsapub, --enable-rsavfy and --disable-oaep. + rsa_public_only = defined("WOLFSSL_RSA_PUBLIC_ONLY") + rsa_verify_only = defined("WOLFSSL_RSA_VERIFY_ONLY") + features["RSA_ENCRYPT"] = 1 if features["RSA"] and not rsa_verify_only else 0 + features["RSA_PRIVATE"] = 1 if features["RSA"] and not rsa_public_only else 0 + features["RSA_SIGN"] = 1 if features["RSA_PRIVATE"] and not rsa_verify_only else 0 + features["RSA_VERIFY"] = 1 if features["RSA"] and not defined("WOLFSSL_RSA_VERIFY_INLINE") else 0 + features["RSA_OAEP"] = 1 if features["RSA"] and not defined("WC_NO_RSA_OAEP") else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -590,6 +599,11 @@ def make_source(features): int DER_TO_PEM_ENABLED = {features["DER_TO_PEM"]}; int PKCS8_ENABLED = {features["PKCS8"]}; int RNG_ENABLED = {features["RNG"]}; + int RSA_ENCRYPT_ENABLED = {features["RSA_ENCRYPT"]}; + int RSA_PRIVATE_ENABLED = {features["RSA_PRIVATE"]}; + int RSA_SIGN_ENABLED = {features["RSA_SIGN"]}; + int RSA_VERIFY_ENABLED = {features["RSA_VERIFY"]}; + int RSA_OAEP_ENABLED = {features["RSA_OAEP"]}; """ return init_source_string @@ -645,6 +659,11 @@ def make_cdef(features): extern int DER_TO_PEM_ENABLED; extern int PKCS8_ENABLED; extern int RNG_ENABLED; + extern int RSA_ENCRYPT_ENABLED; + extern int RSA_PRIVATE_ENABLED; + extern int RSA_SIGN_ENABLED; + extern int RSA_VERIFY_ENABLED; + extern int RSA_OAEP_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1147,27 +1166,52 @@ def make_cdef(features): int wc_RsaPrivateKeyDecode(const byte*, word32*, RsaKey*, word32); int wc_RsaPublicKeyDecode(const byte*, word32*, RsaKey*, word32); int wc_RsaEncryptSize(RsaKey*); - - int wc_RsaPrivateDecrypt(const byte*, word32, byte*, word32, - RsaKey* key); - int wc_RsaPublicEncrypt(const byte*, word32, byte*, word32, - RsaKey*, WC_RNG*); - int wc_RsaPublicEncrypt_ex(const byte* in, word32 inLen, byte* out, - word32 outLen, RsaKey* key, WC_RNG* rng, int type, - enum wc_HashType hash, int mgf, byte* label, - word32 labelSz); - int wc_RsaPrivateDecrypt_ex(const byte* in, word32 inLen, - byte* out, word32 outLen, RsaKey* key, int type, - enum wc_HashType hash, int mgf, byte* label, - word32 labelSz); - int wc_RsaSSL_Sign(const byte*, word32, byte*, word32, RsaKey*, WC_RNG*); - int wc_RsaSSL_Verify(const byte*, word32, byte*, word32, RsaKey*); """ - if features["RSA_PSS"]: + if features["RSA_ENCRYPT"]: + cdef += """ + int wc_RsaPublicEncrypt(const byte*, word32, byte*, word32, + RsaKey*, WC_RNG*); + """ + if features["RSA_OAEP"]: + cdef += """ + int wc_RsaPublicEncrypt_ex(const byte* in, word32 inLen, byte* out, + word32 outLen, RsaKey* key, WC_RNG* rng, int type, + enum wc_HashType hash, int mgf, byte* label, + word32 labelSz); + """ + + if features["RSA_PRIVATE"]: + cdef += """ + int wc_RsaPrivateDecrypt(const byte*, word32, byte*, word32, + RsaKey* key); + """ + if features["RSA_OAEP"]: + cdef += """ + int wc_RsaPrivateDecrypt_ex(const byte* in, word32 inLen, + byte* out, word32 outLen, RsaKey* key, int type, + enum wc_HashType hash, int mgf, byte* label, + word32 labelSz); + """ + + if features["RSA_SIGN"]: + cdef += """ + int wc_RsaSSL_Sign(const byte*, word32, byte*, word32, RsaKey*, WC_RNG*); + """ + + if features["RSA_VERIFY"]: + cdef += """ + int wc_RsaSSL_Verify(const byte*, word32, byte*, word32, RsaKey*); + """ + + if features["RSA_PSS"] and features["RSA_SIGN"]: cdef += """ int wc_RsaPSS_Sign(const byte* in, word32 inLen, byte* out, word32 outLen, enum wc_HashType hash, int mgf, RsaKey* key, WC_RNG* rng); + """ + + if features["RSA_PSS"]: + cdef += """ int wc_RsaPSS_Verify(const byte* in, word32 inLen, byte* out, word32 outLen, enum wc_HashType hash, int mgf, RsaKey* key); int wc_RsaPSS_CheckPadding(const byte* in, word32 inSz, byte* sig, @@ -1179,9 +1223,13 @@ def make_cdef(features): int wc_RsaSetRNG(RsaKey* key, WC_RNG* rng); """ - if features["KEYGEN"]: + if features["KEYGEN"] and features["RSA_PRIVATE"]: cdef += """ int wc_MakeRsaKey(RsaKey* key, int size, long e, WC_RNG* rng); + """ + + if features["KEYGEN"]: + cdef += """ int wc_RsaKeyToDer(RsaKey* key, byte* output, word32 inLen); int wc_RsaKeyToPublicDer(RsaKey* key, byte* output, word32 inLen); @@ -1512,6 +1560,11 @@ def default_features(): "DER_TO_PEM": 1, "PKCS8": 1, "RNG": 1, + "RSA_ENCRYPT": 1, + "RSA_PRIVATE": 1, + "RSA_SIGN": 1, + "RSA_VERIFY": 1, + "RSA_OAEP": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_asn.py b/tests/test_asn.py index 53b9380..f71ef93 100644 --- a/tests/test_asn.py +++ b/tests/test_asn.py @@ -84,7 +84,8 @@ def signature_vectors(): # echo -n "wolfcrypt is the best crypto around" | \ # openssl dgst -hex -sha256 -sign tests/certs/server-key.pem # RSA key objects always create a Random. - if _lib.PEM_TO_DER_ENABLED and _lib.SHA256_ENABLED and _lib.RSA_ENABLED and _lib.RNG_ENABLED: + if (_lib.PEM_TO_DER_ENABLED and _lib.SHA256_ENABLED and _lib.RSA_ENABLED and _lib.RNG_ENABLED + and _lib.RSA_SIGN_ENABLED and _lib.RSA_VERIFY_ENABLED): vectors.append(TestVector( data="wolfcrypt is the best crypto around", signature=h2b("1d65f21df8fdc9f3c2351792840423481c6b0f2332105abd9248" diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 07db622..91d5ccc 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -53,6 +53,11 @@ "PEM_TO_DER": "ASN", "DER_TO_PEM": "ASN", "PKCS8": "ASN", + "RSA_ENCRYPT": "RSA", + "RSA_PRIVATE": "RSA", + "RSA_SIGN": "RSA", + "RSA_VERIFY": "RSA", + "RSA_OAEP": "RSA", } @@ -361,3 +366,47 @@ def test_rng_api_needs_rng(bf): assert "WC_RNG;" in cdef, define for name in RNG_DECLS: assert name not in cdef, (define, name) + + +RSA_OPS = ("wc_RsaPublicEncrypt(", "wc_RsaPublicEncrypt_ex(", "wc_RsaPrivateDecrypt(", + "wc_RsaPrivateDecrypt_ex(", "wc_RsaSSL_Sign(", "wc_RsaSSL_Verify(", "wc_RsaPSS_Sign(", + "wc_MakeRsaKey(") +RSA_SUBSETS = ("RSA_ENCRYPT", "RSA_PRIVATE", "RSA_SIGN", "RSA_VERIFY", "RSA_OAEP") + + +@pytest.mark.parametrize(("defines", "disabled", "absent"), [ + ((), (), ()), + # --enable-rsapub + (("#define WOLFSSL_RSA_PUBLIC_ONLY",), ("RSA_PRIVATE", "RSA_SIGN"), + ("wc_RsaPrivateDecrypt(", "wc_RsaPrivateDecrypt_ex(", "wc_RsaSSL_Sign(", "wc_RsaPSS_Sign(", + "wc_MakeRsaKey(")), + (("#define WOLFSSL_RSA_VERIFY_ONLY",), ("RSA_ENCRYPT", "RSA_SIGN"), + ("wc_RsaPublicEncrypt(", "wc_RsaPublicEncrypt_ex(", "wc_RsaSSL_Sign(", "wc_RsaPSS_Sign(")), + (("#define WOLFSSL_RSA_VERIFY_INLINE",), ("RSA_VERIFY",), ("wc_RsaSSL_Verify(",)), + # --enable-rsavfy + (("#define WOLFSSL_RSA_PUBLIC_ONLY", "#define WOLFSSL_RSA_VERIFY_ONLY", "#define WOLFSSL_RSA_VERIFY_INLINE"), + ("RSA_ENCRYPT", "RSA_PRIVATE", "RSA_SIGN", "RSA_VERIFY"), RSA_OPS), + # --disable-oaep + (("#define WC_NO_RSA_OAEP",), ("RSA_OAEP",), ("wc_RsaPublicEncrypt_ex(", "wc_RsaPrivateDecrypt_ex(")), + ((" #define WC_NO_RSA_OAEP 1",), ("RSA_OAEP",), ("wc_RsaPublicEncrypt_ex(", "wc_RsaPrivateDecrypt_ex(")), +], ids=["default", "public-only", "verify-only", "verify-inline", "rsavfy", "no-oaep", "no-oaep-indented"]) +def test_rsa_operations_follow_subset_macros(bf, defines, disabled, absent): + features = detect(bf, "#define WOLFSSL_KEY_GEN", "#define WC_RSA_PSS", *defines) + for name in RSA_SUBSETS: + assert features[name] == (name not in disabled), name + cdef = cdef_for(bf, features) + for name in RSA_OPS: + assert (name in cdef) == (name not in absent), name + # Key decoding and encoding and PSS verification stay available. + for name in ("wc_RsaPublicKeyDecode(", "wc_RsaPrivateKeyDecode(", "wc_RsaKeyToDer(", + "wc_RsaKeyToPublicDer(", "wc_RsaPSS_Verify(", "wc_RsaPSS_CheckPadding("): + assert name in cdef, name + + +def test_rsa_subsets_need_rsa(bf): + features = detect(bf, "#define NO_RSA") + for name in RSA_SUBSETS: + assert features[name] == 0, name + cdef = cdef_for(bf, features) + for name in RSA_OPS: + assert name not in cdef, name diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 8373083..a761668 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -37,6 +37,15 @@ certs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "certs") + +def load_fresh_ciphers(): + """Load a fresh copy of wolfcrypt.ciphers, e.g. with _lib flags patched.""" + spec = importlib.util.find_spec("wolfcrypt.ciphers") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + if _lib.DES3_ENABLED: from wolfcrypt.ciphers import Des3 @@ -342,6 +351,11 @@ def test_chacha_enc_dec(chacha_obj, vectors): if _lib.RSA_ENABLED: # RSA key objects always create a Random. needs_rng = pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") + needs_encrypt_decrypt = pytest.mark.skipif(not (_lib.RSA_ENCRYPT_ENABLED and _lib.RSA_PRIVATE_ENABLED), + reason="RSA encryption or decryption not enabled") + needs_oaep = pytest.mark.skipif(not _lib.RSA_OAEP_ENABLED, reason="RSA OAEP not enabled") + needs_sign_verify = pytest.mark.skipif(not (_lib.RSA_SIGN_ENABLED and _lib.RSA_VERIFY_ENABLED), + reason="RSA signing or verification not enabled") @pytest.fixture def rng(): @@ -415,12 +429,13 @@ def test_new_rsa_raises(vectors): with pytest.raises(WolfCryptError): RsaPublic(vectors[RsaPublic].key[:-1]) # invalid key length - if _lib.KEYGEN_ENABLED: + if _lib.KEYGEN_ENABLED and _lib.RSA_PRIVATE_ENABLED: with pytest.raises(WolfCryptError): # invalid key size RsaPrivate.make_key(16384) # ty: ignore[possibly-missing-attribute] @needs_rng + @needs_encrypt_decrypt def test_rsa_encrypt_decrypt(rsa_private, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -438,6 +453,7 @@ def test_rsa_encrypt_decrypt(rsa_private, rsa_public): assert plaintext == rsa_private.decrypt(ciphertext) @needs_rng + @needs_encrypt_decrypt def test_rsa_encrypt_decrypt_rng(rsa_private_rng, rsa_public_rng): plaintext = t2b("Everyone gets Friday off.") @@ -455,6 +471,8 @@ def test_rsa_encrypt_decrypt_rng(rsa_private_rng, rsa_public_rng): assert plaintext == rsa_private_rng.decrypt(ciphertext) @needs_rng + @needs_encrypt_decrypt + @needs_oaep def test_rsa_encrypt_decrypt_pad_oaep(rsa_private_oaep, rsa_public_oaep): plaintext = t2b("Everyone gets Friday off.") @@ -474,6 +492,7 @@ def test_rsa_encrypt_decrypt_pad_oaep(rsa_private_oaep, rsa_public_oaep): @pytest.mark.skipif(not _lib.PKCS8_ENABLED, reason="PKCS#8 not enabled") @needs_rng + @needs_encrypt_decrypt def test_rsa_pkcs8_encrypt_decrypt(rsa_private_pkcs8, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -492,6 +511,7 @@ def test_rsa_pkcs8_encrypt_decrypt(rsa_private_pkcs8, rsa_public): @needs_rng + @needs_sign_verify def test_rsa_sign_verify(rsa_private, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -510,6 +530,7 @@ def test_rsa_sign_verify(rsa_private, rsa_public): if _lib.RSA_PSS_ENABLED: @needs_rng + @pytest.mark.skipif(not _lib.RSA_SIGN_ENABLED, reason="RSA signing not enabled") def test_rsa_pss_sign_verify(rsa_private_pss, rsa_public_pss): plaintext = t2b("Everyone gets Friday off.") @@ -528,6 +549,7 @@ def test_rsa_pss_sign_verify(rsa_private_pss, rsa_public_pss): @pytest.mark.skipif(not _lib.PEM_TO_DER_ENABLED, reason="PEM to DER not enabled") @needs_rng + @needs_sign_verify def test_rsa_sign_verify_pem(rsa_private_pem, rsa_public_pem): plaintext = t2b("Everyone gets Friday off.") @@ -546,6 +568,7 @@ def test_rsa_sign_verify_pem(rsa_private_pem, rsa_public_pem): @pytest.mark.skipif(not _lib.PEM_TO_DER_ENABLED, reason="PEM to DER not enabled") @needs_rng + @needs_sign_verify def test_rsa_sign_verify_pem_rng(rsa_private_pem_rng, rsa_public_pem_rng): plaintext = t2b("Everyone gets Friday off.") @@ -564,6 +587,7 @@ def test_rsa_sign_verify_pem_rng(rsa_private_pem_rng, rsa_public_pem_rng): @pytest.mark.skipif(not _lib.PKCS8_ENABLED, reason="PKCS#8 not enabled") @needs_rng + @needs_sign_verify def test_rsa_pkcs8_sign_verify(rsa_private_pkcs8, rsa_public): plaintext = t2b("Everyone gets Friday off.") @@ -1249,12 +1273,14 @@ def test_chacha_set_iv_resets_both_directions(): if _lib.RSA_ENABLED: @needs_rng + @pytest.mark.skipif(not (_lib.RSA_ENCRYPT_ENABLED and _lib.RSA_OAEP_ENABLED), reason="RSA OAEP encryption not enabled") def test_encrypt_oaep_requires_hash_type(vectors): rsa = RsaPublic(vectors[RsaPublic].key) with pytest.raises(WolfCryptError, match="Hash type not set"): rsa.encrypt_oaep(b"plaintext") @needs_rng + @pytest.mark.skipif(not (_lib.RSA_PRIVATE_ENABLED and _lib.RSA_OAEP_ENABLED), reason="RSA OAEP decryption not enabled") def test_decrypt_oaep_requires_hash_type(vectors): rsa = RsaPrivate(vectors[RsaPrivate].key) with pytest.raises(WolfCryptError, match="Hash type not set"): @@ -1267,9 +1293,7 @@ def test_rsa_from_pem_defined_only_when_enabled(monkeypatch): # Load a fresh copy of the module as if wc_PemToDer were not compiled in. monkeypatch.setattr(_lib, "PEM_TO_DER_ENABLED", 0) - spec = importlib.util.find_spec("wolfcrypt.ciphers") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + module = load_fresh_ciphers() for cls in (module.RsaPublic, module.RsaPrivate): assert not hasattr(cls, "from_pem"), cls @@ -1291,3 +1315,49 @@ def __getattr__(self, name): if _lib.PKCS8_ENABLED: # wc_RsaPrivateKeyDecode skips a PKCS#8 header by itself. assert RsaPrivate(vectors[RsaPrivate].pkcs8_key).output_size == 128 + + # Method -> flags of the wolfSSL operations it needs. + RSA_GATED_METHODS = { + ("RsaPublic", "encrypt"): ("RSA_ENCRYPT_ENABLED",), + ("RsaPublic", "encrypt_oaep"): ("RSA_ENCRYPT_ENABLED", "RSA_OAEP_ENABLED"), + ("RsaPrivate", "decrypt"): ("RSA_PRIVATE_ENABLED",), + ("RsaPrivate", "decrypt_oaep"): ("RSA_PRIVATE_ENABLED", "RSA_OAEP_ENABLED"), + ("RsaPrivate", "make_key"): ("KEYGEN_ENABLED", "RSA_PRIVATE_ENABLED"), + ("RsaPrivate", "sign_pss"): ("RSA_PSS_ENABLED", "RSA_SIGN_ENABLED"), + } + + def test_rsa_methods_defined_only_when_enabled(monkeypatch): + """F-12227: each RSA method needs its wolfSSL operation to be compiled in.""" + for (cls, name), flags in RSA_GATED_METHODS.items(): + enabled = all(getattr(_lib, flag) for flag in flags) + assert hasattr(getattr(ciphers, cls), name) == enabled, (cls, name) + + # Load fresh copies of the module as if one operation were not compiled in. + for disabled in ("RSA_ENCRYPT_ENABLED", "RSA_PRIVATE_ENABLED", "RSA_SIGN_ENABLED", "RSA_OAEP_ENABLED"): + with monkeypatch.context() as m: + m.setattr(_lib, disabled, 0) + module = load_fresh_ciphers() + for (cls, name), flags in RSA_GATED_METHODS.items(): + if disabled in flags: + assert not hasattr(getattr(module, cls), name), (disabled, cls, name) + # sign() and verify() implement the RSA protocols, so they stay. + assert hasattr(module.RsaPrivate, "sign"), disabled + assert hasattr(module.RsaPublic, "verify"), disabled + + @needs_rng + def test_rsa_sign_rejected_when_not_compiled_in(monkeypatch, vectors): + """F-12227: sign() needs RSA signing in the linked wolfSSL.""" + rsa = RsaPrivate(vectors[RsaPrivate].key) + monkeypatch.setattr(_lib, "RSA_SIGN_ENABLED", 0) + with pytest.raises(NotImplementedError, match="RSA signing is not supported"): + rsa.sign(b"Everyone gets Friday off.") + + @needs_rng + @pytest.mark.skipif(not _lib.RSA_SIGN_ENABLED, reason="RSA signing not enabled") + def test_rsa_verify_rejected_when_not_compiled_in(monkeypatch, vectors): + """F-12227: verify() needs wc_RsaSSL_Verify in the linked wolfSSL.""" + signature = RsaPrivate(vectors[RsaPrivate].key).sign(b"Everyone gets Friday off.") + rsa = RsaPublic(vectors[RsaPublic].key) + monkeypatch.setattr(_lib, "RSA_VERIFY_ENABLED", 0) + with pytest.raises(NotImplementedError, match="RSA verification is not supported"): + rsa.verify(signature) diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index d427cb7..0b811a5 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -55,7 +55,12 @@ PWDBASED_ENABLED: int RNG_ENABLED: int RSA_ENABLED: int RSA_BLINDING_ENABLED: int +RSA_ENCRYPT_ENABLED: int +RSA_OAEP_ENABLED: int +RSA_PRIVATE_ENABLED: int RSA_PSS_ENABLED: int +RSA_SIGN_ENABLED: int +RSA_VERIFY_ENABLED: int SHA_ENABLED: int SHA3_ENABLED: int SHA3_224_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 0363463..143877f 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -850,49 +850,51 @@ def from_pem(cls, file: bytes, hash_type: int | None = None, rng: Random | None der = pem_to_der(file, _lib.PUBLICKEY_TYPE) return cls(key=der, hash_type=hash_type, rng=rng) - def encrypt(self, plaintext: BytesOrStr) -> bytes: - """ - Encrypts **plaintext**, using the public key data in the - object. The plaintext's length must not be greater than: + if _lib.RSA_ENCRYPT_ENABLED: + def encrypt(self, plaintext: BytesOrStr) -> bytes: + """ + Encrypts **plaintext**, using the public key data in the + object. The plaintext's length must not be greater than: - **self.output_size - self.RSA_MIN_PAD_SIZE** + **self.output_size - self.RSA_MIN_PAD_SIZE** - Returns a string containing the ciphertext. - """ + Returns a string containing the ciphertext. + """ - plaintext = t2b(plaintext) - ciphertext = _ffi.new(f"byte[{self.output_size}]") + plaintext = t2b(plaintext) + ciphertext = _ffi.new(f"byte[{self.output_size}]") - ret = _lib.wc_RsaPublicEncrypt(plaintext, len(plaintext), - ciphertext, self.output_size, - self.native_object, - self._random.native_object) + ret = _lib.wc_RsaPublicEncrypt(plaintext, len(plaintext), + ciphertext, self.output_size, + self.native_object, + self._random.native_object) - if ret != self.output_size: # pragma: no cover - raise WolfCryptApiError("Encryption error", ret) + if ret != self.output_size: # pragma: no cover + raise WolfCryptApiError("Encryption error", ret) - return _ffi.buffer(ciphertext)[:] + return _ffi.buffer(ciphertext)[:] - def encrypt_oaep(self, plaintext: BytesOrStr, label: BytesOrStr = "") -> bytes: - if not self._hash_type: - raise WolfCryptError("Hash type not set. Cannot use OAEP padding without a hash type.") - plaintext = t2b(plaintext) - label = t2b(label) - ciphertext = _ffi.new(f"byte[{self.output_size}]") - if self._mgf is None: - self._get_mgf() - assert self._mgf is not None - ret = _lib.wc_RsaPublicEncrypt_ex(plaintext, len(plaintext), - ciphertext, self.output_size, - self.native_object, - self._random.native_object, - _lib.WC_RSA_OAEP_PAD, self._hash_type, - self._mgf, label, len(label)) + if _lib.RSA_ENCRYPT_ENABLED and _lib.RSA_OAEP_ENABLED: + def encrypt_oaep(self, plaintext: BytesOrStr, label: BytesOrStr = "") -> bytes: + if not self._hash_type: + raise WolfCryptError("Hash type not set. Cannot use OAEP padding without a hash type.") + plaintext = t2b(plaintext) + label = t2b(label) + ciphertext = _ffi.new(f"byte[{self.output_size}]") + if self._mgf is None: + self._get_mgf() + assert self._mgf is not None + ret = _lib.wc_RsaPublicEncrypt_ex(plaintext, len(plaintext), + ciphertext, self.output_size, + self.native_object, + self._random.native_object, + _lib.WC_RSA_OAEP_PAD, self._hash_type, + self._mgf, label, len(label)) - if ret != self.output_size: # pragma: no cover - raise WolfCryptApiError("Encryption error", ret) + if ret != self.output_size: # pragma: no cover + raise WolfCryptApiError("Encryption error", ret) - return _ffi.buffer(ciphertext)[:] + return _ffi.buffer(ciphertext)[:] @override def verify(self, signature: BytesOrStr) -> bytes: @@ -904,6 +906,8 @@ def verify(self, signature: BytesOrStr) -> bytes: Returns a string containing the plaintext. """ + if not _lib.RSA_VERIFY_ENABLED: + raise NotImplementedError("RSA verification is not supported by this wolfSSL build") signature = t2b(signature) plaintext = _ffi.new(f"byte[{self.output_size}]") @@ -959,7 +963,7 @@ def verify_pss(self, plaintext: BytesOrStr, signature: BytesOrStr) -> bool: class RsaPrivate(RsaPublic, SupportsRsaSign): - if _lib.KEYGEN_ENABLED: + if _lib.KEYGEN_ENABLED and _lib.RSA_PRIVATE_ENABLED: @classmethod def make_key(cls, size: int, rng: Random | None = None, hash_type: int | None = None) -> RsaPrivate: """ @@ -1045,54 +1049,56 @@ def encode_key(self) -> tuple[bytes, bytes]: return _ffi.buffer(priv, privlen)[:], _ffi.buffer(pub, publen)[:] - def decrypt(self, ciphertext: BytesOrStr) -> bytes: - """ - Decrypts **ciphertext**, using the private key data in the - object. The ciphertext's length must be equal to: + if _lib.RSA_PRIVATE_ENABLED: + def decrypt(self, ciphertext: BytesOrStr) -> bytes: + """ + Decrypts **ciphertext**, using the private key data in the + object. The ciphertext's length must be equal to: - **self.output_size** + **self.output_size** - Returns a string containing the plaintext. - """ - ciphertext = t2b(ciphertext) - plaintext = _ffi.new(f"byte[{self.output_size}]") + Returns a string containing the plaintext. + """ + ciphertext = t2b(ciphertext) + plaintext = _ffi.new(f"byte[{self.output_size}]") - ret = _lib.wc_RsaPrivateDecrypt(ciphertext, len(ciphertext), - plaintext, self.output_size, - self.native_object) + ret = _lib.wc_RsaPrivateDecrypt(ciphertext, len(ciphertext), + plaintext, self.output_size, + self.native_object) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("Decryption error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("Decryption error", ret) - return _ffi.buffer(plaintext, ret)[:] + return _ffi.buffer(plaintext, ret)[:] - def decrypt_oaep(self, ciphertext: BytesOrStr, label: BytesOrStr = "") -> bytes: - """ - Decrypts **ciphertext**, using the private key data in the - object. The ciphertext's length must be equal to: + if _lib.RSA_PRIVATE_ENABLED and _lib.RSA_OAEP_ENABLED: + def decrypt_oaep(self, ciphertext: BytesOrStr, label: BytesOrStr = "") -> bytes: + """ + Decrypts **ciphertext**, using the private key data in the + object. The ciphertext's length must be equal to: - **self.output_size** + **self.output_size** - Returns a string containing the plaintext. - """ - if not self._hash_type: - raise WolfCryptError("Hash type not set. Cannot use OAEP padding without a hash type.") - ciphertext = t2b(ciphertext) - label = t2b(label) - plaintext = _ffi.new(f"byte[{self.output_size}]") - if self._mgf is None: - self._get_mgf() - assert self._mgf is not None - ret = _lib.wc_RsaPrivateDecrypt_ex(ciphertext, len(ciphertext), - plaintext, self.output_size, - self.native_object, - _lib.WC_RSA_OAEP_PAD, self._hash_type, - self._mgf, label, len(label)) + Returns a string containing the plaintext. + """ + if not self._hash_type: + raise WolfCryptError("Hash type not set. Cannot use OAEP padding without a hash type.") + ciphertext = t2b(ciphertext) + label = t2b(label) + plaintext = _ffi.new(f"byte[{self.output_size}]") + if self._mgf is None: + self._get_mgf() + assert self._mgf is not None + ret = _lib.wc_RsaPrivateDecrypt_ex(ciphertext, len(ciphertext), + plaintext, self.output_size, + self.native_object, + _lib.WC_RSA_OAEP_PAD, self._hash_type, + self._mgf, label, len(label)) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("Decryption error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("Decryption error", ret) - return _ffi.buffer(plaintext, ret)[:] + return _ffi.buffer(plaintext, ret)[:] @override def sign(self, plaintext: BytesOrStr) -> bytes: @@ -1104,6 +1110,8 @@ def sign(self, plaintext: BytesOrStr) -> bytes: Returns a string containing the signature. """ + if not _lib.RSA_SIGN_ENABLED: + raise NotImplementedError("RSA signing is not supported by this wolfSSL build") plaintext = t2b(plaintext) signature = _ffi.new(f"byte[{self.output_size}]") @@ -1117,7 +1125,7 @@ def sign(self, plaintext: BytesOrStr) -> bytes: return _ffi.buffer(signature, self.output_size)[:] - if _lib.RSA_PSS_ENABLED: + if _lib.RSA_PSS_ENABLED and _lib.RSA_SIGN_ENABLED: def sign_pss(self, plaintext: BytesOrStr) -> bytes: """ Signs **plaintext**, using the private key data in the object. From f9345e9b80cb40f28cbc43d6e0be5cb61d0117a7 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 20:01:45 +0000 Subject: [PATCH 14/26] Declare ECC operations only when wolfSSL builds them (F-12228) wolfSSL leaves out ECC signing, verification, ECDH, key import and key export with NO_ECC_SIGN, NO_ECC_VERIFY, NO_ECC_DHE, NO_ECC_KEY_IMPORT and NO_ECC_KEY_EXPORT. settings.h also drops ECDH, and signing with ECC_TIMING_RESISTANT, when there is no RNG, and key export without SP or big integer math. Against such a local wolfSSL the extension failed to import with an undefined symbol. Detect ECC_SIGN, ECC_VERIFY, ECC_DHE, ECC_KEY_IMPORT and ECC_KEY_EXPORT and declare each operation only when it is built. EccPublic and EccPrivate methods are defined only when their operation exists, and creating a key object from a key raises NotImplementedError without key import. --- scripts/build_ffi.py | 104 +++++++--- tests/test_build_ffi.py | 60 ++++++ tests/test_ciphers.py | 76 ++++++++ wolfcrypt/_ffi/lib.pyi | 5 + wolfcrypt/ciphers.py | 419 +++++++++++++++++++++------------------- 5 files changed, 429 insertions(+), 235 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 68b1419..1571d84 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -456,6 +456,17 @@ def defined(name): features["RSA_SIGN"] = 1 if features["RSA_PRIVATE"] and not rsa_verify_only else 0 features["RSA_VERIFY"] = 1 if features["RSA"] and not defined("WOLFSSL_RSA_VERIFY_INLINE") else 0 features["RSA_OAEP"] = 1 if features["RSA"] and not defined("WC_NO_RSA_OAEP") else 0 + # settings.h derives the ECC operations unless NO_ECC_. Timing + # resistant signing and DHE need the RNG. Key export needs SP or big + # integer math. + ecc = features["ECC"] + features["ECC_SIGN"] = 1 if ecc and not defined("NO_ECC_SIGN") and ( + features["RNG"] or not defined("ECC_TIMING_RESISTANT")) else 0 + features["ECC_VERIFY"] = 1 if ecc and not defined("NO_ECC_VERIFY") else 0 + features["ECC_DHE"] = 1 if ecc and not defined("NO_ECC_DHE") and features["RNG"] else 0 + features["ECC_KEY_IMPORT"] = 1 if ecc and not defined("NO_ECC_KEY_IMPORT") else 0 + features["ECC_KEY_EXPORT"] = 1 if ecc and not defined("NO_ECC_KEY_EXPORT") and ( + defined("WOLFSSL_SP_MATH") or not defined("NO_BIG_INT")) else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -604,6 +615,11 @@ def make_source(features): int RSA_SIGN_ENABLED = {features["RSA_SIGN"]}; int RSA_VERIFY_ENABLED = {features["RSA_VERIFY"]}; int RSA_OAEP_ENABLED = {features["RSA_OAEP"]}; + int ECC_SIGN_ENABLED = {features["ECC_SIGN"]}; + int ECC_VERIFY_ENABLED = {features["ECC_VERIFY"]}; + int ECC_DHE_ENABLED = {features["ECC_DHE"]}; + int ECC_KEY_IMPORT_ENABLED = {features["ECC_KEY_IMPORT"]}; + int ECC_KEY_EXPORT_ENABLED = {features["ECC_KEY_EXPORT"]}; """ return init_source_string @@ -664,6 +680,11 @@ def make_cdef(features): extern int RSA_SIGN_ENABLED; extern int RSA_VERIFY_ENABLED; extern int RSA_OAEP_ENABLED; + extern int ECC_SIGN_ENABLED; + extern int ECC_VERIFY_ENABLED; + extern int ECC_DHE_ENABLED; + extern int ECC_KEY_IMPORT_ENABLED; + extern int ECC_KEY_EXPORT_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1245,45 +1266,61 @@ def make_cdef(features): int wc_ecc_make_key(WC_RNG* rng, int keysize, ecc_key* key); int wc_ecc_size(ecc_key* key); int wc_ecc_sig_size(ecc_key* key); - - int wc_EccPrivateKeyDecode(const byte*, word32*, ecc_key*, word32); - int wc_EccKeyToDer(ecc_key*, byte* output, word32 inLen); - int wc_EccKeyDerSize(ecc_key*, int pub); - - int wc_EccPublicKeyDecode(const byte*, word32*, ecc_key*, word32); - int wc_EccPublicKeyToDer(ecc_key*, byte* output, - word32 inLen, int with_AlgCurve); - - int wc_ecc_export_x963(ecc_key*, byte* out, word32* outLen); - int wc_ecc_import_x963(const byte* in, word32 inLen, ecc_key* key); - int wc_ecc_export_private_raw(ecc_key* key, byte* qx, word32* qxLen, - byte* qy, word32* qyLen, byte* d, word32* dLen); - int wc_ecc_import_unsigned(ecc_key* key, const byte* qx, const byte* qy, - const byte* d, int curve_id); - int wc_ecc_export_public_raw(ecc_key* key, byte* qx, word32* qxLen, - byte* qy, word32* qyLen); int wc_ecc_get_curve_size_from_id(int curve_id); int wc_ecc_check_key(ecc_key* key); + """ + if features["ECC_KEY_IMPORT"]: + cdef += """ + int wc_EccPrivateKeyDecode(const byte*, word32*, ecc_key*, word32); + int wc_EccPublicKeyDecode(const byte*, word32*, ecc_key*, word32); + int wc_ecc_import_x963(const byte* in, word32 inLen, ecc_key* key); + int wc_ecc_import_unsigned(ecc_key* key, const byte* qx, const byte* qy, + const byte* d, int curve_id); + """ - int wc_ecc_shared_secret(ecc_key* private_key, ecc_key* public_key, - byte* out, word32* outlen); + if features["ECC_KEY_EXPORT"]: + cdef += """ + int wc_EccKeyToDer(ecc_key*, byte* output, word32 inLen); + int wc_EccKeyDerSize(ecc_key*, int pub); + int wc_EccPublicKeyToDer(ecc_key*, byte* output, + word32 inLen, int with_AlgCurve); + int wc_ecc_export_x963(ecc_key*, byte* out, word32* outLen); + int wc_ecc_export_private_raw(ecc_key* key, byte* qx, word32* qxLen, + byte* qy, word32* qyLen, byte* d, word32* dLen); + int wc_ecc_export_public_raw(ecc_key* key, byte* qx, word32* qxLen, + byte* qy, word32* qyLen); + """ - int wc_ecc_sign_hash(const byte* in, word32 inlen, - byte* out, word32 *outlen, - WC_RNG* rng, ecc_key* key); - int wc_ecc_verify_hash(const byte* sig, word32 siglen, - const byte* hash, word32 hashlen, - int* stat, ecc_key* key); - """ + if features["ECC_DHE"]: + cdef += """ + int wc_ecc_shared_secret(ecc_key* private_key, ecc_key* public_key, + byte* out, word32* outlen); + """ - if features["MPAPI"]: + if features["ECC_SIGN"]: cdef += """ - int wc_ecc_sign_hash_ex(const byte* in, word32 inlen, WC_RNG* rng, - ecc_key* key, mp_int *r, mp_int *s); - int wc_ecc_verify_hash_ex(mp_int *r, mp_int *s, const byte* hash, - word32 hashlen, int* res, ecc_key* key); + int wc_ecc_sign_hash(const byte* in, word32 inlen, + byte* out, word32 *outlen, + WC_RNG* rng, ecc_key* key); """ + if features["MPAPI"]: + cdef += """ + int wc_ecc_sign_hash_ex(const byte* in, word32 inlen, WC_RNG* rng, + ecc_key* key, mp_int *r, mp_int *s); + """ + + if features["ECC_VERIFY"]: + cdef += """ + int wc_ecc_verify_hash(const byte* sig, word32 siglen, + const byte* hash, word32 hashlen, + int* stat, ecc_key* key); + """ + if features["MPAPI"]: + cdef += """ + int wc_ecc_verify_hash_ex(mp_int *r, mp_int *s, const byte* hash, + word32 hashlen, int* res, ecc_key* key); + """ if features["ECC_TIMING_RESISTANCE"] and (not features["FIPS"] or features["FIPS_VERSION"] > 2): @@ -1565,6 +1602,11 @@ def default_features(): "RSA_SIGN": 1, "RSA_VERIFY": 1, "RSA_OAEP": 1, + "ECC_SIGN": 1, + "ECC_VERIFY": 1, + "ECC_DHE": 1, + "ECC_KEY_IMPORT": 1, + "ECC_KEY_EXPORT": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 91d5ccc..55e7d9f 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -58,6 +58,11 @@ "RSA_SIGN": "RSA", "RSA_VERIFY": "RSA", "RSA_OAEP": "RSA", + "ECC_SIGN": "ECC", + "ECC_VERIFY": "ECC", + "ECC_DHE": "ECC", + "ECC_KEY_IMPORT": "ECC", + "ECC_KEY_EXPORT": "ECC", } @@ -410,3 +415,58 @@ def test_rsa_subsets_need_rsa(bf): cdef = cdef_for(bf, features) for name in RSA_OPS: assert name not in cdef, name + + +ECC_SUBSETS = ("ECC_SIGN", "ECC_VERIFY", "ECC_DHE", "ECC_KEY_IMPORT", "ECC_KEY_EXPORT") +ECC_OPS = { + "ECC_SIGN": ("wc_ecc_sign_hash(", "wc_ecc_sign_hash_ex("), + "ECC_VERIFY": ("wc_ecc_verify_hash(", "wc_ecc_verify_hash_ex("), + "ECC_DHE": ("wc_ecc_shared_secret(",), + "ECC_KEY_IMPORT": ("wc_EccPrivateKeyDecode(", "wc_EccPublicKeyDecode(", "wc_ecc_import_x963(", + "wc_ecc_import_unsigned("), + "ECC_KEY_EXPORT": ("wc_EccKeyToDer(", "wc_EccKeyDerSize(", "wc_EccPublicKeyToDer(", + "wc_ecc_export_x963(", "wc_ecc_export_private_raw(", "wc_ecc_export_public_raw("), +} +ECC_COMMON = ("ecc_key;", "wc_ecc_init(", "wc_ecc_free(", "wc_ecc_make_key(", "wc_ecc_size(", + "wc_ecc_sig_size(", "wc_ecc_get_curve_size_from_id(", "wc_ecc_check_key(") + + +@pytest.mark.parametrize(("defines", "disabled"), [ + ((), ()), + (("#define NO_ECC_SIGN",), ("ECC_SIGN",)), + ((" #define NO_ECC_SIGN 1",), ("ECC_SIGN",)), + (("#define NO_ECC_VERIFY",), ("ECC_VERIFY",)), + (("#define NO_ECC_DHE",), ("ECC_DHE",)), + (("#define NO_ECC_KEY_IMPORT",), ("ECC_KEY_IMPORT",)), + (("#define NO_ECC_KEY_EXPORT",), ("ECC_KEY_EXPORT",)), + # settings.h: key export needs WOLFSSL_SP_MATH or big integer math. + (("#define NO_BIG_INT",), ("ECC_KEY_EXPORT",)), + (("#define NO_BIG_INT", "#define WOLFSSL_SP_MATH"), ()), + # settings.h: DHE and timing resistant signing need the RNG. + (("#define WC_NO_RNG",), ("ECC_DHE",)), + (("#define WC_NO_RNG", "#define ECC_TIMING_RESISTANT"), ("ECC_SIGN", "ECC_DHE")), + (("#define NO_ECC_SIGN", "#define NO_ECC_DHE"), ("ECC_SIGN", "ECC_DHE")), +], ids=["default", "no-sign", "no-sign-indented", "no-verify", "no-dhe", "no-import", "no-export", + "no-big-int", "no-big-int-sp-math", "no-rng", "no-rng-timing-resistant", "no-sign-no-dhe"]) +def test_ecc_operations_follow_subset_macros(bf, defines, disabled): + features = detect(bf, "#define HAVE_ECC", "#define WOLFSSL_PUBLIC_MP", *defines) + assert features["ECC"] == 1 + for name in ECC_SUBSETS: + assert features[name] == (name not in disabled), name + cdef = cdef_for(bf, features) + for subset, names in ECC_OPS.items(): + for name in names: + assert (name in cdef) == (subset not in disabled), name + for name in ECC_COMMON: + assert name in cdef, name + + +def test_ecc_subsets_need_ecc(bf): + features = detect(bf, "#define WOLFSSL_PUBLIC_MP") + assert features["ECC"] == 0 + for name in ECC_SUBSETS: + assert features[name] == 0, name + cdef = cdef_for(bf, features) + for names in ECC_OPS.values(): + for name in names: + assert name not in cdef, name diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index a761668..bcea8af 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -606,6 +606,12 @@ def test_rsa_pkcs8_sign_verify(rsa_private_pkcs8, rsa_public): if _lib.ECC_ENABLED: + needs_ecc_import = pytest.mark.skipif(not _lib.ECC_KEY_IMPORT_ENABLED, reason="ECC key import not enabled") + needs_ecc_export = pytest.mark.skipif(not _lib.ECC_KEY_EXPORT_ENABLED, reason="ECC key export not enabled") + needs_ecc_sign_verify = pytest.mark.skipif(not (_lib.ECC_SIGN_ENABLED and _lib.ECC_VERIFY_ENABLED), + reason="ECC signing or verification not enabled") + needs_ecc_dhe = pytest.mark.skipif(not _lib.ECC_DHE_ENABLED, reason="ECDH not enabled") + @pytest.fixture def ecc_private(vectors): return EccPrivate(vectors[EccPrivate].key) @@ -616,6 +622,7 @@ def ecc_public(vectors): return EccPublic(vectors[EccPublic].key) + @needs_ecc_import def test_new_ecc_raises(vectors): with pytest.raises(WolfCryptError): EccPrivate(vectors[EccPrivate].key[:-1]) # invalid key length @@ -630,6 +637,8 @@ def test_new_ecc_raises(vectors): EccPrivate.make_key(1024) + @needs_ecc_import + @needs_ecc_export def test_key_encoding(vectors): priv = EccPrivate() pub = EccPublic() @@ -668,6 +677,8 @@ def test_key_encoding(vectors): assert qy[0:32] == vectors[EccPublic].raw_key[32:64] + @needs_ecc_import + @needs_ecc_export def test_ecc_encode_key_buffer_not_from_field_size(vectors, monkeypatch): """ F-10070: the private key DER is larger than four times the field @@ -679,6 +690,8 @@ def test_ecc_encode_key_buffer_not_from_field_size(vectors, monkeypatch): @pytest.mark.parametrize("curve_id", [ECC_SECP112R1, ECC_SECP128R1, ECC_SECP160R1, ECC_SECP192R1]) + @needs_ecc_import + @needs_ecc_export def test_ecc_encode_key_small_curves(curve_id): """ F-10070: private key DER encoding round-trips on small curves. @@ -694,6 +707,8 @@ def test_ecc_encode_key_small_curves(curve_id): assert EccPrivate(der).encode_key() == der + @needs_ecc_import + @needs_ecc_export def test_ecc_encode_key_p192_vector(): """ F-10070: an imported P-192 private key re-encodes to the same DER. @@ -710,6 +725,7 @@ def test_ecc_encode_key_p192_vector(): assert EccPrivate(der).encode_key() == der + @needs_ecc_import def test_ecc_decode_key_raw_rejects_wrong_length(vectors): """ wc_ecc_import_unsigned reads exactly curve_size bytes from each @@ -748,6 +764,7 @@ def test_ecc_decode_key_raw_rejects_wrong_length(vectors): @pytest.mark.parametrize("bad", ["off_curve", "x_not_below_p"]) + @needs_ecc_import def test_ecc_decode_key_raw_rejects_invalid_point(vectors, bad): """ F-8277: wc_ecc_import_unsigned does not validate the point, so @@ -764,6 +781,7 @@ def test_ecc_decode_key_raw_rejects_invalid_point(vectors, bad): EccPublic().decode_key_raw(qx, qy) + @needs_ecc_import def test_ecc_import_rejects_off_curve_point(vectors): """ F-8277: import_x963 and decode_key reject a point that is not on @@ -780,10 +798,15 @@ def test_ecc_import_rejects_off_curve_point(vectors): + @needs_ecc_import + @needs_ecc_export def test_x963(ecc_private, ecc_public): assert ecc_private.export_x963() == ecc_public.export_x963() + @needs_ecc_import + @needs_ecc_export + @needs_ecc_sign_verify def test_ecc_sign_verify(ecc_private, ecc_public): plaintext = "Everyone gets Friday off." @@ -814,6 +837,8 @@ def test_ecc_sign_verify(ecc_private, ecc_public): ecc_x963.import_x963(ecc_public.export_x963()[:-1]) if _lib.MPAPI_ENABLED: + @needs_ecc_import + @needs_ecc_sign_verify def test_ecc_sign_verify_raw(ecc_private, ecc_public): plaintext = "Everyone gets Friday off." @@ -832,6 +857,9 @@ def test_ecc_sign_verify_raw(ecc_private, ecc_public): assert ecc_private.verify_raw(r, s, plaintext) + @needs_ecc_import + @needs_ecc_export + @needs_ecc_dhe def test_ecc_make_shared_secret(): a = EccPrivate.make_key(32, rng=Random()) a_pub = EccPublic() @@ -846,6 +874,9 @@ def test_ecc_make_shared_secret(): == a.shared_secret(b_pub) \ == b.shared_secret(a_pub) + @needs_ecc_import + @needs_ecc_export + @needs_ecc_dhe def test_ecc_make_key_no_rng(): key = EccPrivate.make_key(32) pub_key = EccPublic() @@ -1361,3 +1392,48 @@ def test_rsa_verify_rejected_when_not_compiled_in(monkeypatch, vectors): monkeypatch.setattr(_lib, "RSA_VERIFY_ENABLED", 0) with pytest.raises(NotImplementedError, match="RSA verification is not supported"): rsa.verify(signature) + +if _lib.ECC_ENABLED: + # Method -> flags of the wolfSSL operations it needs. + ECC_GATED_METHODS = { + ("EccPublic", "decode_key"): ("ECC_KEY_IMPORT_ENABLED",), + ("EccPublic", "decode_key_raw"): ("ECC_KEY_IMPORT_ENABLED",), + ("EccPublic", "import_x963"): ("ECC_KEY_IMPORT_ENABLED",), + ("EccPublic", "encode_key"): ("ECC_KEY_EXPORT_ENABLED",), + ("EccPublic", "encode_key_raw"): ("ECC_KEY_EXPORT_ENABLED",), + ("EccPublic", "export_x963"): ("ECC_KEY_EXPORT_ENABLED",), + ("EccPublic", "verify"): ("ECC_VERIFY_ENABLED",), + ("EccPublic", "verify_raw"): ("ECC_VERIFY_ENABLED", "MPAPI_ENABLED"), + ("EccPrivate", "decode_key"): ("ECC_KEY_IMPORT_ENABLED",), + ("EccPrivate", "decode_key_raw"): ("ECC_KEY_IMPORT_ENABLED",), + ("EccPrivate", "encode_key"): ("ECC_KEY_EXPORT_ENABLED",), + ("EccPrivate", "encode_key_raw"): ("ECC_KEY_EXPORT_ENABLED",), + ("EccPrivate", "shared_secret"): ("ECC_DHE_ENABLED",), + ("EccPrivate", "sign"): ("ECC_SIGN_ENABLED",), + ("EccPrivate", "sign_raw"): ("ECC_SIGN_ENABLED", "MPAPI_ENABLED"), + } + + def test_ecc_methods_defined_only_when_enabled(monkeypatch): + """F-12228: each ECC method needs its wolfSSL operation to be compiled in.""" + for (cls, name), flags in ECC_GATED_METHODS.items(): + enabled = all(getattr(_lib, flag) for flag in flags) + assert hasattr(getattr(ciphers, cls), name) == enabled, (cls, name) + + # Load fresh copies of the module as if one operation were not compiled in. + for disabled in ("ECC_KEY_IMPORT_ENABLED", "ECC_KEY_EXPORT_ENABLED", "ECC_DHE_ENABLED", + "ECC_SIGN_ENABLED", "ECC_VERIFY_ENABLED"): + with monkeypatch.context() as m: + m.setattr(_lib, disabled, 0) + module = load_fresh_ciphers() + for (cls, name), flags in ECC_GATED_METHODS.items(): + enabled = all(getattr(_lib, flag) for flag in flags) + assert hasattr(getattr(module, cls), name) == enabled, (disabled, cls, name) + assert hasattr(module.EccPrivate, "make_key"), disabled + + def test_ecc_key_rejected_without_key_import(monkeypatch, vectors): + """F-12228: loading a key needs ECC key import in the linked wolfSSL.""" + monkeypatch.setattr(_lib, "ECC_KEY_IMPORT_ENABLED", 0) + for cls in (EccPublic, EccPrivate): + with pytest.raises(NotImplementedError, match="ECC key import is not supported"): + cls(vectors[cls].key) + assert cls().size == 0, cls diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 0b811a5..4551fd1 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -37,6 +37,11 @@ CHACHA20_POLY1305_ENABLED: int DER_TO_PEM_ENABLED: int DES3_ENABLED: int ECC_ENABLED: int +ECC_DHE_ENABLED: int +ECC_KEY_EXPORT_ENABLED: int +ECC_KEY_IMPORT_ENABLED: int +ECC_SIGN_ENABLED: int +ECC_VERIFY_ENABLED: int ED25519_ENABLED: int ED448_ENABLED: int ERROR_STRINGS_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 143877f..9069899 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -1191,6 +1191,8 @@ def __init__(self, key: BytesOrStr | None = None) -> None: _Ecc.__init__(self) if key: + if not _lib.ECC_KEY_IMPORT_ENABLED: + raise NotImplementedError("ECC key import is not supported by this wolfSSL build") self.decode_key(key) def _check_key(self) -> None: @@ -1198,124 +1200,129 @@ def _check_key(self) -> None: if ret != 0: raise WolfCryptApiError("Key check error", ret) - def decode_key(self, key: BytesOrStr) -> None: - """ - Decodes an ECC public key from an ASN sequence. - """ - key = t2b(key) - - idx = _ffi.new("word32*") - idx[0] = 0 + if _lib.ECC_KEY_IMPORT_ENABLED: + def decode_key(self, key: BytesOrStr) -> None: + """ + Decodes an ECC public key from an ASN sequence. + """ + key = t2b(key) - ret = _lib.wc_EccPublicKeyDecode(key, idx, - self.native_object, len(key)) - if ret < 0: - raise WolfCryptApiError("Key decode error", ret) - if self.size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.size})") - if self.max_signature_size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.max_signature_size})") - self._check_key() + idx = _ffi.new("word32*") + idx[0] = 0 - def decode_key_raw(self, qx: BytesOrStr, qy: BytesOrStr, curve_id: int = ECC_SECP256R1) -> None: - """ - Decodes an ECC public key from its raw elements: (Qx,Qy) - """ - qx = t2b(qx) - qy = t2b(qy) - curve_size = _lib.wc_ecc_get_curve_size_from_id(curve_id) - if curve_size <= 0: - raise ValueError(f"Unknown ECC curve_id {curve_id}") - if len(qx) != curve_size or len(qy) != curve_size: - raise ValueError( - f"qx and qy must each be {curve_size} bytes for curve_id {curve_id}, got " - f"qx={len(qx)} qy={len(qy)}") - ret = _lib.wc_ecc_import_unsigned(self.native_object, qx, qy, - _ffi.NULL, curve_id) - if ret != 0: - raise WolfCryptApiError("Key decode error", ret) - self._check_key() + ret = _lib.wc_EccPublicKeyDecode(key, idx, + self.native_object, len(key)) + if ret < 0: + raise WolfCryptApiError("Key decode error", ret) + if self.size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.size})") + if self.max_signature_size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.max_signature_size})") + self._check_key() - def encode_key(self, with_curve: bool = True) -> bytes: - """ - Encodes the ECC public key in an ASN sequence. + def decode_key_raw(self, qx: BytesOrStr, qy: BytesOrStr, curve_id: int = ECC_SECP256R1) -> None: + """ + Decodes an ECC public key from its raw elements: (Qx,Qy) + """ + qx = t2b(qx) + qy = t2b(qy) + curve_size = _lib.wc_ecc_get_curve_size_from_id(curve_id) + if curve_size <= 0: + raise ValueError(f"Unknown ECC curve_id {curve_id}") + if len(qx) != curve_size or len(qy) != curve_size: + raise ValueError( + f"qx and qy must each be {curve_size} bytes for curve_id {curve_id}, got " + f"qx={len(qx)} qy={len(qy)}") + ret = _lib.wc_ecc_import_unsigned(self.native_object, qx, qy, + _ffi.NULL, curve_id) + if ret != 0: + raise WolfCryptApiError("Key decode error", ret) + self._check_key() - Returns the encoded key. - """ - key = _ffi.new(f"byte[{self.size * 4}]") + if _lib.ECC_KEY_EXPORT_ENABLED: + def encode_key(self, with_curve: bool = True) -> bytes: + """ + Encodes the ECC public key in an ASN sequence. - ret = _lib.wc_EccPublicKeyToDer(self.native_object, key, len(key), - with_curve) - if ret <= 0: # pragma: no cover - raise WolfCryptApiError("Key encode error", ret) + Returns the encoded key. + """ + key = _ffi.new(f"byte[{self.size * 4}]") - return _ffi.buffer(key, ret)[:] + ret = _lib.wc_EccPublicKeyToDer(self.native_object, key, len(key), + with_curve) + if ret <= 0: # pragma: no cover + raise WolfCryptApiError("Key encode error", ret) - def encode_key_raw(self) -> tuple[bytes, bytes]: - """ - Encodes the ECC public key in its two raw elements + return _ffi.buffer(key, ret)[:] - Returns (Qx, Qy) - """ - Qx = _ffi.new(f"byte[{self.size}]") - Qy = _ffi.new(f"byte[{self.size}]") - qx_size = _ffi.new("word32[1]") - qy_size = _ffi.new("word32[1]") - qx_size[0] = self.size - qy_size[0] = self.size + def encode_key_raw(self) -> tuple[bytes, bytes]: + """ + Encodes the ECC public key in its two raw elements - ret = _lib.wc_ecc_export_public_raw(self.native_object, Qx, - qx_size, Qy, qy_size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Key encode error", ret) + Returns (Qx, Qy) + """ + Qx = _ffi.new(f"byte[{self.size}]") + Qy = _ffi.new(f"byte[{self.size}]") + qx_size = _ffi.new("word32[1]") + qy_size = _ffi.new("word32[1]") + qx_size[0] = self.size + qy_size[0] = self.size + + ret = _lib.wc_ecc_export_public_raw(self.native_object, Qx, + qx_size, Qy, qy_size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Key encode error", ret) - return _ffi.buffer(Qx, qx_size[0])[:], _ffi.buffer(Qy, - qy_size[0])[:] + return _ffi.buffer(Qx, qx_size[0])[:], _ffi.buffer(Qy, + qy_size[0])[:] - def import_x963(self, x963: bytes) -> None: - """ - Imports an ECC public key in ANSI X9.63 format. - """ - ret = _lib.wc_ecc_import_x963(x963, len(x963), self.native_object) - if ret != 0: - raise WolfCryptApiError("x963 import error", ret) - self._check_key() + if _lib.ECC_KEY_IMPORT_ENABLED: + def import_x963(self, x963: bytes) -> None: + """ + Imports an ECC public key in ANSI X9.63 format. + """ + ret = _lib.wc_ecc_import_x963(x963, len(x963), self.native_object) + if ret != 0: + raise WolfCryptApiError("x963 import error", ret) + self._check_key() - def export_x963(self) -> bytes: - """ - Exports the public key data of the object in ANSI X9.63 format. + if _lib.ECC_KEY_EXPORT_ENABLED: + def export_x963(self) -> bytes: + """ + Exports the public key data of the object in ANSI X9.63 format. - Returns the exported key. - """ - x963 = _ffi.new(f"byte[{self.size * 4}]") - x963_size = _ffi.new("word32[1]") - x963_size[0] = self.size * 4 + Returns the exported key. + """ + x963 = _ffi.new(f"byte[{self.size * 4}]") + x963_size = _ffi.new("word32[1]") + x963_size[0] = self.size * 4 - ret = _lib.wc_ecc_export_x963(self.native_object, x963, x963_size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("x963 export error", ret) + ret = _lib.wc_ecc_export_x963(self.native_object, x963, x963_size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("x963 export error", ret) - return _ffi.buffer(x963, x963_size[0])[:] + return _ffi.buffer(x963, x963_size[0])[:] - def verify(self, signature: bytes, data: BytesOrStr) -> bool: - """ - Verifies **signature**, using the public key data in the object. + if _lib.ECC_VERIFY_ENABLED: + def verify(self, signature: bytes, data: BytesOrStr) -> bool: + """ + Verifies **signature**, using the public key data in the object. - Returns **True** in case of a valid signature, otherwise **False**. - """ - data = t2b(data) - status = _ffi.new("int[1]") + Returns **True** in case of a valid signature, otherwise **False**. + """ + data = t2b(data) + status = _ffi.new("int[1]") - ret = _lib.wc_ecc_verify_hash(signature, len(signature), - data, len(data), - status, self.native_object) + ret = _lib.wc_ecc_verify_hash(signature, len(signature), + data, len(data), + status, self.native_object) - if ret < 0: - raise WolfCryptApiError("Verify error", ret) + if ret < 0: + raise WolfCryptApiError("Verify error", ret) - return status[0] == 1 + return status[0] == 1 - if _lib.MPAPI_ENABLED: + if _lib.ECC_VERIFY_ENABLED and _lib.MPAPI_ENABLED: def verify_raw(self, R: bytes, S: bytes, data: BytesOrStr) -> bool: """ Verifies signature from its raw elements **R** and **S**, using @@ -1387,135 +1394,139 @@ def make_key(cls, size: int, rng: Random | None = None) -> EccPrivate: return ecc - @override - def decode_key(self, key: BytesOrStr) -> None: - """ - Decodes an ECC private key from an ASN sequence. - """ - key = t2b(key) - - idx = _ffi.new("word32*") - idx[0] = 0 + if _lib.ECC_KEY_IMPORT_ENABLED: + @override + def decode_key(self, key: BytesOrStr) -> None: + """ + Decodes an ECC private key from an ASN sequence. + """ + key = t2b(key) - ret = _lib.wc_EccPrivateKeyDecode(key, idx, - self.native_object, len(key)) - if ret < 0: - raise WolfCryptApiError("Key decode error", ret) - if self.size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error {self.size}") - if self.max_signature_size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.max_signature_size})") + idx = _ffi.new("word32*") + idx[0] = 0 - @override - def decode_key_raw(self, qx: BytesOrStr, qy: BytesOrStr, d: BytesOrStr, curve_id: int = ECC_SECP256R1) -> None: - """ - Decodes an ECC private key from its raw elements: public (Qx,Qy) - and private(d) - """ - qx = t2b(qx) - qy = t2b(qy) - d = t2b(d) - curve_size = _lib.wc_ecc_get_curve_size_from_id(curve_id) - if curve_size <= 0: - raise ValueError(f"Unknown ECC curve_id {curve_id}") - if (len(qx) != curve_size or len(qy) != curve_size - or len(d) != curve_size): - raise ValueError( - f"qx, qy and d must each be {curve_size} bytes for curve_id {curve_id}, got " - f"qx={len(qx)} qy={len(qy)} d={len(d)}") - ret = _lib.wc_ecc_import_unsigned(self.native_object, qx, qy, d, - curve_id) - if ret != 0: - raise WolfCryptApiError("Key decode error", ret) + ret = _lib.wc_EccPrivateKeyDecode(key, idx, + self.native_object, len(key)) + if ret < 0: + raise WolfCryptApiError("Key decode error", ret) + if self.size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error {self.size}") + if self.max_signature_size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.max_signature_size})") - @override - def encode_key(self) -> bytes: - """ - Encodes the ECC private key in an ASN sequence. + @override + def decode_key_raw(self, qx: BytesOrStr, qy: BytesOrStr, d: BytesOrStr, curve_id: int = ECC_SECP256R1) -> None: + """ + Decodes an ECC private key from its raw elements: public (Qx,Qy) + and private(d) + """ + qx = t2b(qx) + qy = t2b(qy) + d = t2b(d) + curve_size = _lib.wc_ecc_get_curve_size_from_id(curve_id) + if curve_size <= 0: + raise ValueError(f"Unknown ECC curve_id {curve_id}") + if (len(qx) != curve_size or len(qy) != curve_size + or len(d) != curve_size): + raise ValueError( + f"qx, qy and d must each be {curve_size} bytes for curve_id {curve_id}, got " + f"qx={len(qx)} qy={len(qy)} d={len(d)}") + ret = _lib.wc_ecc_import_unsigned(self.native_object, qx, qy, d, + curve_id) + if ret != 0: + raise WolfCryptApiError("Key decode error", ret) - Returns the encoded key. - """ - size = _lib.wc_EccKeyDerSize(self.native_object, 1) - if size <= 0: # pragma: no cover - raise WolfCryptApiError("Key encode error", size) - key = _ffi.new(f"byte[{size}]") + if _lib.ECC_KEY_EXPORT_ENABLED: + @override + def encode_key(self) -> bytes: + """ + Encodes the ECC private key in an ASN sequence. - ret = _lib.wc_EccKeyToDer(self.native_object, key, size) - if ret <= 0: # pragma: no cover - raise WolfCryptApiError("Key encode error", ret) + Returns the encoded key. + """ + size = _lib.wc_EccKeyDerSize(self.native_object, 1) + if size <= 0: # pragma: no cover + raise WolfCryptApiError("Key encode error", size) + key = _ffi.new(f"byte[{size}]") - return _ffi.buffer(key, ret)[:] + ret = _lib.wc_EccKeyToDer(self.native_object, key, size) + if ret <= 0: # pragma: no cover + raise WolfCryptApiError("Key encode error", ret) - @override - def encode_key_raw(self) -> tuple[bytes, bytes, bytes]: - """ - Encodes the ECC private key in its three raw elements + return _ffi.buffer(key, ret)[:] - Returns (Qx, Qy, d) - """ - Qx = _ffi.new(f"byte[{self.size}]") - Qy = _ffi.new(f"byte[{self.size}]") - d = _ffi.new(f"byte[{self.size}]") - qx_size = _ffi.new("word32[1]") - qy_size = _ffi.new("word32[1]") - d_size = _ffi.new("word32[1]") - qx_size[0] = self.size - qy_size[0] = self.size - d_size[0] = self.size + @override + def encode_key_raw(self) -> tuple[bytes, bytes, bytes]: + """ + Encodes the ECC private key in its three raw elements - ret = _lib.wc_ecc_export_private_raw(self.native_object, Qx, - qx_size, Qy, qy_size, d, d_size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Key encode error", ret) + Returns (Qx, Qy, d) + """ + Qx = _ffi.new(f"byte[{self.size}]") + Qy = _ffi.new(f"byte[{self.size}]") + d = _ffi.new(f"byte[{self.size}]") + qx_size = _ffi.new("word32[1]") + qy_size = _ffi.new("word32[1]") + d_size = _ffi.new("word32[1]") + qx_size[0] = self.size + qy_size[0] = self.size + d_size[0] = self.size + + ret = _lib.wc_ecc_export_private_raw(self.native_object, Qx, + qx_size, Qy, qy_size, d, d_size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Key encode error", ret) - return _ffi.buffer(Qx, qx_size[0])[:], _ffi.buffer(Qy, - qy_size[0])[:], _ffi.buffer(d, d_size[0])[:] + return _ffi.buffer(Qx, qx_size[0])[:], _ffi.buffer(Qy, + qy_size[0])[:], _ffi.buffer(d, d_size[0])[:] - def shared_secret(self, peer: EccPublic) -> bytes: - """ - Generates a new secret key using the private key data in the object - and the peer's public key. + if _lib.ECC_DHE_ENABLED: + def shared_secret(self, peer: EccPublic) -> bytes: + """ + Generates a new secret key using the private key data in the object + and the peer's public key. - Returns the shared secret. - """ - shared_secret = _ffi.new(f"byte[{self.max_signature_size}]") - secret_size = _ffi.new("word32[1]") - secret_size[0] = self.max_signature_size + Returns the shared secret. + """ + shared_secret = _ffi.new(f"byte[{self.max_signature_size}]") + secret_size = _ffi.new("word32[1]") + secret_size[0] = self.max_signature_size - ret = _lib.wc_ecc_shared_secret(self.native_object, - peer.native_object, - shared_secret, secret_size) + ret = _lib.wc_ecc_shared_secret(self.native_object, + peer.native_object, + shared_secret, secret_size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Shared secret error", ret) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Shared secret error", ret) - return _ffi.buffer(shared_secret, secret_size[0])[:] + return _ffi.buffer(shared_secret, secret_size[0])[:] - def sign(self, plaintext: BytesOrStr, rng: Random | None = None) -> bytes: - """ - Signs **plaintext**, using the private key data in the object. + if _lib.ECC_SIGN_ENABLED: + def sign(self, plaintext: BytesOrStr, rng: Random | None = None) -> bytes: + """ + Signs **plaintext**, using the private key data in the object. - Returns the signature. - """ - if rng is None: - rng = Random() - plaintext = t2b(plaintext) - signature = _ffi.new(f"byte[{self.max_signature_size}]") + Returns the signature. + """ + if rng is None: + rng = Random() + plaintext = t2b(plaintext) + signature = _ffi.new(f"byte[{self.max_signature_size}]") - signature_size = _ffi.new("word32[1]") - signature_size[0] = self.max_signature_size + signature_size = _ffi.new("word32[1]") + signature_size[0] = self.max_signature_size - ret = _lib.wc_ecc_sign_hash(plaintext, len(plaintext), - signature, signature_size, - rng.native_object, - self.native_object) + ret = _lib.wc_ecc_sign_hash(plaintext, len(plaintext), + signature, signature_size, + rng.native_object, + self.native_object) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Signature error", ret) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Signature error", ret) - return _ffi.buffer(signature, signature_size[0])[:] + return _ffi.buffer(signature, signature_size[0])[:] - if _lib.MPAPI_ENABLED: + if _lib.ECC_SIGN_ENABLED and _lib.MPAPI_ENABLED: def sign_raw(self, plaintext: BytesOrStr, rng: Random | None = None) -> tuple[bytes, bytes]: """ Signs **plaintext**, using the private key data in the object. From 54ef5c7cb0de6a117ac000e727f1f9bd1651975e Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Mon, 28 Sep 2026 16:05:29 +0000 Subject: [PATCH 15/26] Validate ECC private keys with wc_ecc_check_key on import EccPrivate.decode_key_raw() and decode_key() accepted a public point that is not on the curve, as the EccPublic imports did before F-8277. Check the key after import. decode_key() checks only keys that include the public point, since it is optional in an ECPrivateKey and wc_ecc_check_key() rejects a key without it. --- scripts/build_ffi.py | 3 ++- tests/test_ciphers.py | 25 +++++++++++++++++++++++++ wolfcrypt/_ffi/lib.pyi | 2 ++ wolfcrypt/ciphers.py | 4 ++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 1571d84..4e600b3 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -1258,7 +1258,8 @@ def make_cdef(features): if features["ECC"]: cdef += """ - typedef struct {...; } ecc_key; + typedef struct { int type; ...; } ecc_key; + static const int ECC_PRIVATEKEY; int wc_ecc_init(ecc_key* ecc); void wc_ecc_free(ecc_key* ecc); diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index bcea8af..fca2cbc 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -797,6 +797,31 @@ def test_ecc_import_rejects_off_curve_point(vectors): EccPublic(der[:-1] + bytes([der[-1] ^ 1])) + @needs_ecc_import + def test_ecc_private_import_rejects_off_curve_point(vectors): + """ + decode_key_raw and decode_key of EccPrivate reject a public point + that is not on the curve, as EccPublic does. + """ + key = vectors[EccPrivate].raw_key + qx, qy, d = key[0:32], key[32:64], key[64:96] + with pytest.raises(WolfCryptApiError): + EccPrivate().decode_key_raw(qx, qy[:-1] + bytes([qy[-1] ^ 1]), d) + + der = vectors[EccPrivate].key + with pytest.raises(WolfCryptApiError): + EccPrivate(der[:-1] + bytes([der[-1] ^ 1])) + + + @needs_ecc_import + def test_ecc_private_key_without_public_key(vectors): + """The public key is optional in an ECPrivateKey.""" + der = vectors[EccPrivate].key + # Drop the trailing [1] publicKey field and fix the SEQUENCE length. + assert der[:2] == b"\x30\x77" and der[51] == 0xa1 + assert EccPrivate(b"\x30\x31" + der[2:51]).size == 32 + + @needs_ecc_import @needs_ecc_export diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 4551fd1..ed19ea2 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -298,6 +298,8 @@ MIN_CODE_E: int FIPS_VERSION: int +ECC_PRIVATEKEY: int + WC_MGF1NONE: int WC_MGF1SHA1: int WC_MGF1SHA224: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 9069899..5fc0170 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -1413,6 +1413,9 @@ def decode_key(self, key: BytesOrStr) -> None: raise WolfCryptError(f"Key decode error {self.size}") if self.max_signature_size <= 0: # pragma: no cover raise WolfCryptError(f"Key decode error ({self.max_signature_size})") + # The public key is optional in an ECPrivateKey. + if self.native_object.type == _lib.ECC_PRIVATEKEY: + self._check_key() @override def decode_key_raw(self, qx: BytesOrStr, qy: BytesOrStr, d: BytesOrStr, curve_id: int = ECC_SECP256R1) -> None: @@ -1435,6 +1438,7 @@ def decode_key_raw(self, qx: BytesOrStr, qy: BytesOrStr, d: BytesOrStr, curve_id curve_id) if ret != 0: raise WolfCryptApiError("Key decode error", ret) + self._check_key() if _lib.ECC_KEY_EXPORT_ENABLED: @override From 394cc0db5f70d0ab95adebf47b24fbd95af1a943 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 20:15:11 +0000 Subject: [PATCH 16/26] Declare Ed25519 operations only when wolfSSL builds them (F-12229) wolfSSL leaves out Ed25519 key generation, signing, verification, key import and key export with NO_ED25519_MAKE_KEY, NO_ED25519_SIGN, NO_ED25519_VERIFY, NO_ED25519_KEY_IMPORT and NO_ED25519_KEY_EXPORT. Against such a local wolfSSL the extension failed to import with an undefined symbol. Detect each operation and declare it only when it is built. Ed25519Public and Ed25519Private methods are defined only when their operation exists. Creating a key object from a key raises NotImplementedError without key import, and decoding a private key without its public key raises NotImplementedError without key generation, since the public key is derived with wc_ed25519_make_public. --- scripts/build_ffi.py | 78 ++++++++---- tests/test_build_ffi.py | 57 +++++++++ tests/test_ciphers.py | 59 +++++++++ wolfcrypt/_ffi/lib.pyi | 5 + wolfcrypt/ciphers.py | 268 +++++++++++++++++++++------------------- 5 files changed, 319 insertions(+), 148 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 4e600b3..06de177 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -467,6 +467,9 @@ def defined(name): features["ECC_KEY_IMPORT"] = 1 if ecc and not defined("NO_ECC_KEY_IMPORT") else 0 features["ECC_KEY_EXPORT"] = 1 if ecc and not defined("NO_ECC_KEY_EXPORT") and ( defined("WOLFSSL_SP_MATH") or not defined("NO_BIG_INT")) else 0 + # settings.h derives the Ed25519 operations unless NO_ED25519_. + for op in ("MAKE_KEY", "SIGN", "VERIFY", "KEY_IMPORT", "KEY_EXPORT"): + features[f"ED25519_{op}"] = 1 if features["ED25519"] and not defined(f"NO_ED25519_{op}") else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -620,6 +623,11 @@ def make_source(features): int ECC_DHE_ENABLED = {features["ECC_DHE"]}; int ECC_KEY_IMPORT_ENABLED = {features["ECC_KEY_IMPORT"]}; int ECC_KEY_EXPORT_ENABLED = {features["ECC_KEY_EXPORT"]}; + int ED25519_MAKE_KEY_ENABLED = {features["ED25519_MAKE_KEY"]}; + int ED25519_SIGN_ENABLED = {features["ED25519_SIGN"]}; + int ED25519_VERIFY_ENABLED = {features["ED25519_VERIFY"]}; + int ED25519_KEY_IMPORT_ENABLED = {features["ED25519_KEY_IMPORT"]}; + int ED25519_KEY_EXPORT_ENABLED = {features["ED25519_KEY_EXPORT"]}; """ return init_source_string @@ -685,6 +693,11 @@ def make_cdef(features): extern int ECC_DHE_ENABLED; extern int ECC_KEY_IMPORT_ENABLED; extern int ECC_KEY_EXPORT_ENABLED; + extern int ED25519_MAKE_KEY_ENABLED; + extern int ED25519_SIGN_ENABLED; + extern int ED25519_VERIFY_ENABLED; + extern int ED25519_KEY_IMPORT_ENABLED; + extern int ED25519_KEY_EXPORT_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1337,34 +1350,52 @@ def make_cdef(features): int wc_ed25519_init(ed25519_key* ed25519); void wc_ed25519_free(ed25519_key* ed25519); - int wc_ed25519_make_key(WC_RNG* rng, int keysize, ed25519_key* key); - int wc_ed25519_make_public(ed25519_key* key, unsigned char* pubKey, - word32 pubKeySz); int wc_ed25519_size(ed25519_key* key); int wc_ed25519_sig_size(ed25519_key* key); - int wc_ed25519_sign_msg(const byte* in, word32 inlen, byte* out, - word32 *outlen, ed25519_key* key); - int wc_ed25519_verify_msg(const byte* sig, word32 siglen, const byte* msg, - word32 msglen, int* stat, ed25519_key* key); - int wc_Ed25519PrivateKeyDecode(const byte*, word32*, ed25519_key*, word32); - int wc_Ed25519KeyToDer(ed25519_key*, byte* output, word32 inLen); - - int wc_Ed25519PublicKeyDecode(const byte*, word32*, ed25519_key*, word32); - int wc_Ed25519PublicKeyToDer(ed25519_key*, byte* output, - word32 inLen, int with_AlgCurve); - - int wc_ed25519_import_public(const byte* in, word32 inLen, ed25519_key* key); - int wc_ed25519_import_private_only(const byte* priv, word32 privSz, ed25519_key* key); - int wc_ed25519_import_private_key(const byte* priv, word32 privSz, const byte* pub, word32 pubSz, ed25519_key* key); - int wc_ed25519_export_public(ed25519_key*, byte* out, word32* outLen); - int wc_ed25519_export_private_only(ed25519_key* key, byte* out, word32* outLen); - int wc_ed25519_export_private(ed25519_key* key, byte* out, word32* outLen); - int wc_ed25519_export_key(ed25519_key* key, byte* priv, word32 *privSz, byte* pub, word32 *pubSz); int wc_ed25519_check_key(ed25519_key* key); int wc_ed25519_pub_size(ed25519_key* key); int wc_ed25519_priv_size(ed25519_key* key); """ + if features["ED25519_MAKE_KEY"]: + cdef += """ + int wc_ed25519_make_key(WC_RNG* rng, int keysize, ed25519_key* key); + int wc_ed25519_make_public(ed25519_key* key, unsigned char* pubKey, + word32 pubKeySz); + """ + + if features["ED25519_SIGN"]: + cdef += """ + int wc_ed25519_sign_msg(const byte* in, word32 inlen, byte* out, + word32 *outlen, ed25519_key* key); + """ + + if features["ED25519_VERIFY"]: + cdef += """ + int wc_ed25519_verify_msg(const byte* sig, word32 siglen, const byte* msg, + word32 msglen, int* stat, ed25519_key* key); + """ + + if features["ED25519_KEY_IMPORT"]: + cdef += """ + int wc_Ed25519PrivateKeyDecode(const byte*, word32*, ed25519_key*, word32); + int wc_Ed25519PublicKeyDecode(const byte*, word32*, ed25519_key*, word32); + int wc_ed25519_import_public(const byte* in, word32 inLen, ed25519_key* key); + int wc_ed25519_import_private_only(const byte* priv, word32 privSz, ed25519_key* key); + int wc_ed25519_import_private_key(const byte* priv, word32 privSz, const byte* pub, word32 pubSz, ed25519_key* key); + """ + + if features["ED25519_KEY_EXPORT"]: + cdef += """ + int wc_Ed25519KeyToDer(ed25519_key*, byte* output, word32 inLen); + int wc_Ed25519PublicKeyToDer(ed25519_key*, byte* output, + word32 inLen, int with_AlgCurve); + int wc_ed25519_export_public(ed25519_key*, byte* out, word32* outLen); + int wc_ed25519_export_private_only(ed25519_key* key, byte* out, word32* outLen); + int wc_ed25519_export_private(ed25519_key* key, byte* out, word32* outLen); + int wc_ed25519_export_key(ed25519_key* key, byte* priv, word32 *privSz, byte* pub, word32 *pubSz); + """ + if features["ED448"]: cdef += """ typedef struct {...; } ed448_key; @@ -1608,6 +1639,11 @@ def default_features(): "ECC_DHE": 1, "ECC_KEY_IMPORT": 1, "ECC_KEY_EXPORT": 1, + "ED25519_MAKE_KEY": 1, + "ED25519_SIGN": 1, + "ED25519_VERIFY": 1, + "ED25519_KEY_IMPORT": 1, + "ED25519_KEY_EXPORT": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 55e7d9f..d8bfc7f 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -63,6 +63,11 @@ "ECC_DHE": "ECC", "ECC_KEY_IMPORT": "ECC", "ECC_KEY_EXPORT": "ECC", + "ED25519_MAKE_KEY": "ED25519", + "ED25519_SIGN": "ED25519", + "ED25519_VERIFY": "ED25519", + "ED25519_KEY_IMPORT": "ED25519", + "ED25519_KEY_EXPORT": "ED25519", } @@ -470,3 +475,55 @@ def test_ecc_subsets_need_ecc(bf): for names in ECC_OPS.values(): for name in names: assert name not in cdef, name + + +ED25519_SUBSETS = ("ED25519_MAKE_KEY", "ED25519_SIGN", "ED25519_VERIFY", "ED25519_KEY_IMPORT", + "ED25519_KEY_EXPORT") +ED25519_OPS = { + "ED25519_MAKE_KEY": ("wc_ed25519_make_key(", "wc_ed25519_make_public("), + "ED25519_SIGN": ("wc_ed25519_sign_msg(",), + "ED25519_VERIFY": ("wc_ed25519_verify_msg(",), + "ED25519_KEY_IMPORT": ("wc_Ed25519PrivateKeyDecode(", "wc_Ed25519PublicKeyDecode(", + "wc_ed25519_import_public(", "wc_ed25519_import_private_only(", + "wc_ed25519_import_private_key("), + "ED25519_KEY_EXPORT": ("wc_Ed25519KeyToDer(", "wc_Ed25519PublicKeyToDer(", "wc_ed25519_export_public(", + "wc_ed25519_export_private_only(", "wc_ed25519_export_private(", + "wc_ed25519_export_key("), +} +ED25519_COMMON = ("ed25519_key;", "wc_ed25519_init(", "wc_ed25519_free(", "wc_ed25519_size(", + "wc_ed25519_sig_size(", "wc_ed25519_check_key(", "wc_ed25519_pub_size(", + "wc_ed25519_priv_size(") + + +@pytest.mark.parametrize(("defines", "disabled"), [ + ((), ()), + (("#define NO_ED25519_SIGN", "#define NO_ED25519_MAKE_KEY"), ("ED25519_SIGN", "ED25519_MAKE_KEY")), + (("#define NO_ED25519_SIGN",), ("ED25519_SIGN",)), + ((" #define NO_ED25519_SIGN 1",), ("ED25519_SIGN",)), + (("#define NO_ED25519_VERIFY",), ("ED25519_VERIFY",)), + (("#define NO_ED25519_KEY_IMPORT",), ("ED25519_KEY_IMPORT",)), + (("#define NO_ED25519_KEY_EXPORT",), ("ED25519_KEY_EXPORT",)), +], ids=["default", "no-sign-no-make-key", "no-sign", "no-sign-indented", "no-verify", "no-import", + "no-export"]) +def test_ed25519_operations_follow_subset_macros(bf, defines, disabled): + features = detect(bf, "#define HAVE_ED25519", *defines) + assert features["ED25519"] == 1 + for name in ED25519_SUBSETS: + assert features[name] == (name not in disabled), name + cdef = cdef_for(bf, features) + for subset, names in ED25519_OPS.items(): + for name in names: + assert (name in cdef) == (subset not in disabled), name + for name in ED25519_COMMON: + assert name in cdef, name + + +def test_ed25519_subsets_need_ed25519(bf): + features = detect(bf) + assert features["ED25519"] == 0 + for name in ED25519_SUBSETS: + assert features[name] == 0, name + cdef = cdef_for(bf, features) + for names in ED25519_OPS.values(): + for name in names: + assert name not in cdef, name diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index fca2cbc..c84c7f9 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -910,6 +910,15 @@ def test_ecc_make_key_no_rng(): assert key.shared_secret(pub_key) if _lib.ED25519_ENABLED: + needs_ed25519_make_key = pytest.mark.skipif(not _lib.ED25519_MAKE_KEY_ENABLED, + reason="Ed25519 key generation not enabled") + needs_ed25519_import = pytest.mark.skipif(not _lib.ED25519_KEY_IMPORT_ENABLED, + reason="Ed25519 key import not enabled") + needs_ed25519_export = pytest.mark.skipif(not _lib.ED25519_KEY_EXPORT_ENABLED, + reason="Ed25519 key export not enabled") + needs_ed25519_sign_verify = pytest.mark.skipif(not (_lib.ED25519_SIGN_ENABLED and _lib.ED25519_VERIFY_ENABLED), + reason="Ed25519 signing or verification not enabled") + @pytest.fixture def ed25519_private(vectors): return Ed25519Private(vectors[Ed25519Private].key, vectors[Ed25519Public].key) @@ -920,6 +929,8 @@ def ed25519_public(vectors): return Ed25519Public(vectors[Ed25519Public].key) + @needs_ed25519_import + @needs_ed25519_make_key def test_new_ed25519_raises(vectors): with pytest.raises(WolfCryptError): Ed25519Private(vectors[Ed25519Private].key[:-1]) # invalid key length @@ -932,6 +943,9 @@ def test_new_ed25519_raises(vectors): Ed25519Private.make_key(1024) + @needs_ed25519_import + @needs_ed25519_export + @needs_ed25519_make_key def test_ed25519_key_encoding(vectors): priv = Ed25519Private() pub = Ed25519Public() @@ -944,6 +958,8 @@ def test_ed25519_key_encoding(vectors): assert pub.encode_key() == vectors[Ed25519Public].key + @needs_ed25519_import + @needs_ed25519_sign_verify def test_ed25519_sign_verify(ed25519_private, ed25519_public): plaintext = "Everyone gets Friday off." @@ -1462,3 +1478,46 @@ def test_ecc_key_rejected_without_key_import(monkeypatch, vectors): with pytest.raises(NotImplementedError, match="ECC key import is not supported"): cls(vectors[cls].key) assert cls().size == 0, cls + +if _lib.ED25519_ENABLED: + # Method -> flag of the wolfSSL operation it needs. + ED25519_GATED_METHODS = { + ("Ed25519Public", "decode_key"): "ED25519_KEY_IMPORT_ENABLED", + ("Ed25519Public", "encode_key"): "ED25519_KEY_EXPORT_ENABLED", + ("Ed25519Public", "verify"): "ED25519_VERIFY_ENABLED", + ("Ed25519Private", "make_key"): "ED25519_MAKE_KEY_ENABLED", + ("Ed25519Private", "decode_key"): "ED25519_KEY_IMPORT_ENABLED", + ("Ed25519Private", "encode_key"): "ED25519_KEY_EXPORT_ENABLED", + ("Ed25519Private", "sign"): "ED25519_SIGN_ENABLED", + } + + def test_ed25519_methods_defined_only_when_enabled(monkeypatch): + """F-12229: each Ed25519 method needs its wolfSSL operation to be compiled in.""" + for (cls, name), flag in ED25519_GATED_METHODS.items(): + assert hasattr(getattr(ciphers, cls), name) == bool(getattr(_lib, flag)), (cls, name) + + # Load fresh copies of the module as if one operation were not compiled in. + for disabled in set(ED25519_GATED_METHODS.values()): + with monkeypatch.context() as m: + m.setattr(_lib, disabled, 0) + module = load_fresh_ciphers() + for (cls, name), flag in ED25519_GATED_METHODS.items(): + assert hasattr(getattr(module, cls), name) == bool(getattr(_lib, flag)), (disabled, cls, name) + + def test_ed25519_key_rejected_without_key_import(monkeypatch, vectors): + """F-12229: loading a key needs Ed25519 key import in the linked wolfSSL.""" + monkeypatch.setattr(_lib, "ED25519_KEY_IMPORT_ENABLED", 0) + for cls in (Ed25519Public, Ed25519Private): + with pytest.raises(NotImplementedError, match="Ed25519 key import is not supported"): + cls(vectors[cls].key) + cls() + with pytest.raises(NotImplementedError, match="Ed25519 key import is not supported"): + Ed25519Private(vectors[Ed25519Private].key, vectors[Ed25519Public].key) + + @needs_ed25519_import + def test_ed25519_private_only_key_needs_make_key(monkeypatch, vectors): + """F-12229: deriving the public key needs wc_ed25519_make_public.""" + monkeypatch.setattr(_lib, "ED25519_MAKE_KEY_ENABLED", 0) + with pytest.raises(NotImplementedError, match="Deriving the Ed25519 public key is not supported"): + Ed25519Private(vectors[Ed25519Private].key) + Ed25519Private(vectors[Ed25519Private].key, vectors[Ed25519Public].key) diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index ed19ea2..b792b17 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -43,6 +43,11 @@ ECC_KEY_IMPORT_ENABLED: int ECC_SIGN_ENABLED: int ECC_VERIFY_ENABLED: int ED25519_ENABLED: int +ED25519_KEY_EXPORT_ENABLED: int +ED25519_KEY_IMPORT_ENABLED: int +ED25519_MAKE_KEY_ENABLED: int +ED25519_SIGN_ENABLED: int +ED25519_VERIFY_ENABLED: int ED448_ENABLED: int ERROR_STRINGS_ENABLED: int FIPS_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 5fc0170..f0bbe1c 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -1606,61 +1606,66 @@ def __init__(self, key: BytesOrStr | None = None) -> None: _Ed25519.__init__(self) if key: + if not _lib.ED25519_KEY_IMPORT_ENABLED: + raise NotImplementedError("Ed25519 key import is not supported by this wolfSSL build") self.decode_key(key) - def decode_key(self, key: BytesOrStr) -> None: - """ - Decodes an ED25519 public key - """ - key = t2b(key) - if len(key) < _lib.wc_ed25519_pub_size(self.native_object): - raise WolfCryptError("Key decode error: key too short") + if _lib.ED25519_KEY_IMPORT_ENABLED: + def decode_key(self, key: BytesOrStr) -> None: + """ + Decodes an ED25519 public key + """ + key = t2b(key) + if len(key) < _lib.wc_ed25519_pub_size(self.native_object): + raise WolfCryptError("Key decode error: key too short") - idx = _ffi.new("word32*") - idx[0] = 0 - ret = _lib.wc_ed25519_import_public(key, len(key), - self.native_object) - if ret < 0: - raise WolfCryptApiError("Key decode error", ret) - if self.size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.size})") - if self.max_signature_size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.max_signature_size})") + idx = _ffi.new("word32*") + idx[0] = 0 + ret = _lib.wc_ed25519_import_public(key, len(key), + self.native_object) + if ret < 0: + raise WolfCryptApiError("Key decode error", ret) + if self.size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.size})") + if self.max_signature_size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.max_signature_size})") - def encode_key(self) -> bytes: - """ - Encodes the ED25519 public key + if _lib.ED25519_KEY_EXPORT_ENABLED: + def encode_key(self) -> bytes: + """ + Encodes the ED25519 public key - Returns the encoded key. - """ - key = _ffi.new(f"byte[{self.size * 4}]") - size = _ffi.new("word32[1]") + Returns the encoded key. + """ + key = _ffi.new(f"byte[{self.size * 4}]") + size = _ffi.new("word32[1]") - size[0] = _lib.wc_ed25519_pub_size(self.native_object) + size[0] = _lib.wc_ed25519_pub_size(self.native_object) - ret = _lib.wc_ed25519_export_public(self.native_object, key, size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Key encode error", ret) + ret = _lib.wc_ed25519_export_public(self.native_object, key, size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Key encode error", ret) - return _ffi.buffer(key, size[0])[:] + return _ffi.buffer(key, size[0])[:] - def verify(self, signature: bytes, data: BytesOrStr) -> bool: - """ - Verifies **signature**, using the public key data in the object. + if _lib.ED25519_VERIFY_ENABLED: + def verify(self, signature: bytes, data: BytesOrStr) -> bool: + """ + Verifies **signature**, using the public key data in the object. - Returns **True** in case of a valid signature, otherwise **False**. - """ - data = t2b(data) - status = _ffi.new("int[1]") + Returns **True** in case of a valid signature, otherwise **False**. + """ + data = t2b(data) + status = _ffi.new("int[1]") - ret = _lib.wc_ed25519_verify_msg(signature, len(signature), - data, len(data), - status, self.native_object) + ret = _lib.wc_ed25519_verify_msg(signature, len(signature), + data, len(data), + status, self.native_object) - if ret < 0: - raise WolfCryptApiError("Verify error", ret) + if ret < 0: + raise WolfCryptApiError("Verify error", ret) - return status[0] == 1 + return status[0] == 1 @@ -1670,114 +1675,123 @@ def __init__(self, key: BytesOrStr | None = None, pub: bytes | None = None) -> N self._rng = None + if key and not _lib.ED25519_KEY_IMPORT_ENABLED: + raise NotImplementedError("Ed25519 key import is not supported by this wolfSSL build") if key and not pub: self.decode_key(key) if key and pub: self.decode_key(key,pub) - @classmethod - def make_key(cls, size: int, rng: Random | None = None) -> Ed25519Private: - """ - Generates a new key pair of desired length **size**. - """ - if rng is None: - rng = Random() - ed25519 = cls() + if _lib.ED25519_MAKE_KEY_ENABLED: + @classmethod + def make_key(cls, size: int, rng: Random | None = None) -> Ed25519Private: + """ + Generates a new key pair of desired length **size**. + """ + if rng is None: + rng = Random() + ed25519 = cls() - ret = _lib.wc_ed25519_make_key(rng.native_object, size, - ed25519.native_object) - if ret < 0: - raise WolfCryptApiError("Key generation error", ret) + ret = _lib.wc_ed25519_make_key(rng.native_object, size, + ed25519.native_object) + if ret < 0: + raise WolfCryptApiError("Key generation error", ret) - # Retain RNG reference defensively; wolfSSL may retain a pointer - # internally on some builds. - ed25519._rng = rng + # Retain RNG reference defensively; wolfSSL may retain a pointer + # internally on some builds. + ed25519._rng = rng - return ed25519 + return ed25519 - @override - def decode_key(self, key: BytesOrStr, pub: bytes | None = None) -> None: - """ - Decodes an ED25519 private + pub key - """ - key = t2b(key) + if _lib.ED25519_KEY_IMPORT_ENABLED: + @override + def decode_key(self, key: BytesOrStr, pub: bytes | None = None) -> None: + """ + Decodes an ED25519 private + pub key + """ + key = t2b(key) - if len(key) < _lib.wc_ed25519_priv_size(self.native_object)/2: - raise WolfCryptError("Key decode error: key too short") + if len(key) < _lib.wc_ed25519_priv_size(self.native_object)/2: + raise WolfCryptError("Key decode error: key too short") - idx = _ffi.new("word32*") - idx[0] = 0 - if pub: - ret = _lib.wc_ed25519_import_private_key(key, len(key), pub, - len(pub), self.native_object) - if ret < 0: - raise WolfCryptApiError("Key decode error", ret) - else: - ret = _lib.wc_ed25519_import_private_only(key, len(key), - self.native_object) - if ret < 0: - raise WolfCryptApiError("Key decode error", ret) - pubkey = _ffi.new(f"byte[{self.size * 4}]") - ret = _lib.wc_ed25519_make_public(self.native_object, pubkey, - self.size) - if ret < 0: - raise WolfCryptApiError("Public key generate error", ret) - ret = _lib.wc_ed25519_import_public(pubkey, self.size, - self.native_object) - if ret < 0: - raise WolfCryptApiError("Public key import error", ret) + idx = _ffi.new("word32*") + idx[0] = 0 + if pub: + ret = _lib.wc_ed25519_import_private_key(key, len(key), pub, + len(pub), self.native_object) + if ret < 0: + raise WolfCryptApiError("Key decode error", ret) + else: + # wolfSSL builds wc_ed25519_make_public only with key generation. + if not _lib.ED25519_MAKE_KEY_ENABLED: + raise NotImplementedError("Deriving the Ed25519 public key is not supported by this wolfSSL build") + ret = _lib.wc_ed25519_import_private_only(key, len(key), + self.native_object) + if ret < 0: + raise WolfCryptApiError("Key decode error", ret) + pubkey = _ffi.new(f"byte[{self.size * 4}]") + ret = _lib.wc_ed25519_make_public(self.native_object, pubkey, + self.size) + if ret < 0: + raise WolfCryptApiError("Public key generate error", ret) + ret = _lib.wc_ed25519_import_public(pubkey, self.size, + self.native_object) + if ret < 0: + raise WolfCryptApiError("Public key import error", ret) - if self.size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.size})") - if self.max_signature_size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.max_signature_size})") + if self.size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.size})") + if self.max_signature_size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.max_signature_size})") - @override - def encode_key(self) -> tuple[bytes, bytes]: - """ - Encodes the ED25519 private key. + if _lib.ED25519_KEY_EXPORT_ENABLED: + @override + def encode_key(self) -> tuple[bytes, bytes]: + """ + Encodes the ED25519 private key. - Returns the encoded key. - """ - key = _ffi.new(f"byte[{self.size * 4}]") - pubkey = _ffi.new(f"byte[{self.size * 4}]") - priv_size = _ffi.new("word32[1]") - pub_size = _ffi.new("word32[1]") + Returns the encoded key. + """ + key = _ffi.new(f"byte[{self.size * 4}]") + pubkey = _ffi.new(f"byte[{self.size * 4}]") + priv_size = _ffi.new("word32[1]") + pub_size = _ffi.new("word32[1]") - priv_size[0] = _lib.wc_ed25519_priv_size(self.native_object) - pub_size[0] = _lib.wc_ed25519_pub_size(self.native_object) + priv_size[0] = _lib.wc_ed25519_priv_size(self.native_object) + pub_size[0] = _lib.wc_ed25519_pub_size(self.native_object) - ret = _lib.wc_ed25519_export_private_only(self.native_object, - key, priv_size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Private key encode error", ret) - ret = _lib.wc_ed25519_export_public(self.native_object, pubkey, - pub_size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Public key encode error", ret) + ret = _lib.wc_ed25519_export_private_only(self.native_object, + key, priv_size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Private key encode error", ret) + ret = _lib.wc_ed25519_export_public(self.native_object, pubkey, + pub_size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Public key encode error", ret) - return _ffi.buffer(key, priv_size[0])[:], _ffi.buffer(pubkey, pub_size[0])[:] + return _ffi.buffer(key, priv_size[0])[:], _ffi.buffer(pubkey, pub_size[0])[:] - def sign(self, plaintext: BytesOrStr) -> bytes: - """ - Signs **plaintext**, using the private key data in the object. + if _lib.ED25519_SIGN_ENABLED: + def sign(self, plaintext: BytesOrStr) -> bytes: + """ + Signs **plaintext**, using the private key data in the object. - Returns the signature. - """ - plaintext = t2b(plaintext) - signature = _ffi.new(f"byte[{self.max_signature_size}]") + Returns the signature. + """ + plaintext = t2b(plaintext) + signature = _ffi.new(f"byte[{self.max_signature_size}]") - signature_size = _ffi.new("word32[1]") - signature_size[0] = self.max_signature_size + signature_size = _ffi.new("word32[1]") + signature_size[0] = self.max_signature_size - ret = _lib.wc_ed25519_sign_msg(plaintext, len(plaintext), - signature, signature_size, - self.native_object) + ret = _lib.wc_ed25519_sign_msg(plaintext, len(plaintext), + signature, signature_size, + self.native_object) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Signature error", ret) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Signature error", ret) - return _ffi.buffer(signature, signature_size[0])[:] + return _ffi.buffer(signature, signature_size[0])[:] if _lib.ED448_ENABLED: class _Ed448: # pylint: disable=too-few-public-methods From 652447fd023e50f34fe0e6cb42a5d2dfad78c766 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 20:26:14 +0000 Subject: [PATCH 17/26] Declare Ed448 operations only when wolfSSL builds them (F-12230) wolfSSL leaves out Ed448 signing, verification, key import and key export with NO_ED448_SIGN, NO_ED448_VERIFY, NO_ED448_KEY_IMPORT and NO_ED448_KEY_EXPORT. Against such a local wolfSSL the extension failed to import with an undefined symbol. Detect each operation and declare it only when it is built. Ed448Public and Ed448Private methods are defined only when their operation exists. Creating a key object from a key raises NotImplementedError without key import. Key generation and decoding a private key without its public key still work, since wolfSSL always builds wc_ed448_make_key and wc_ed448_make_public. --- scripts/build_ffi.py | 69 ++++++++--- tests/test_build_ffi.py | 52 ++++++++ tests/test_ciphers.py | 48 +++++++ wolfcrypt/_ffi/lib.pyi | 4 + wolfcrypt/ciphers.py | 268 +++++++++++++++++++++------------------- 5 files changed, 292 insertions(+), 149 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 06de177..273c136 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -470,6 +470,9 @@ def defined(name): # settings.h derives the Ed25519 operations unless NO_ED25519_. for op in ("MAKE_KEY", "SIGN", "VERIFY", "KEY_IMPORT", "KEY_EXPORT"): features[f"ED25519_{op}"] = 1 if features["ED25519"] and not defined(f"NO_ED25519_{op}") else 0 + # settings.h derives the Ed448 operations unless NO_ED448_. + for op in ("SIGN", "VERIFY", "KEY_IMPORT", "KEY_EXPORT"): + features[f"ED448_{op}"] = 1 if features["ED448"] and not defined(f"NO_ED448_{op}") else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -628,6 +631,10 @@ def make_source(features): int ED25519_VERIFY_ENABLED = {features["ED25519_VERIFY"]}; int ED25519_KEY_IMPORT_ENABLED = {features["ED25519_KEY_IMPORT"]}; int ED25519_KEY_EXPORT_ENABLED = {features["ED25519_KEY_EXPORT"]}; + int ED448_SIGN_ENABLED = {features["ED448_SIGN"]}; + int ED448_VERIFY_ENABLED = {features["ED448_VERIFY"]}; + int ED448_KEY_IMPORT_ENABLED = {features["ED448_KEY_IMPORT"]}; + int ED448_KEY_EXPORT_ENABLED = {features["ED448_KEY_EXPORT"]}; """ return init_source_string @@ -698,6 +705,10 @@ def make_cdef(features): extern int ED25519_VERIFY_ENABLED; extern int ED25519_KEY_IMPORT_ENABLED; extern int ED25519_KEY_EXPORT_ENABLED; + extern int ED448_SIGN_ENABLED; + extern int ED448_VERIFY_ENABLED; + extern int ED448_KEY_IMPORT_ENABLED; + extern int ED448_KEY_EXPORT_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1408,31 +1419,45 @@ def make_cdef(features): word32 pubKeySz); int wc_ed448_size(ed448_key* key); int wc_ed448_sig_size(ed448_key* key); - int wc_ed448_sign_msg(const byte* in, word32 inlen, byte* out, - word32 *outlen, ed448_key* key, const byte* ctx, - byte ctx_len); - int wc_ed448_verify_msg(const byte* sig, word32 siglen, const byte* msg, - word32 msglen, int* stat, ed448_key* key, const byte *ctx, - byte ctx_len); - int wc_Ed448PrivateKeyDecode(const byte*, word32*, ed448_key*, word32); - int wc_Ed448KeyToDer(ed448_key*, byte* output, word32 inLen); - - int wc_Ed448PublicKeyDecode(const byte*, word32*, ed448_key*, word32); - int wc_Ed448PublicKeyToDer(ed448_key*, byte* output, - word32 inLen, int with_AlgCurve); - - int wc_ed448_import_public(const byte* in, word32 inLen, ed448_key* key); - int wc_ed448_import_private_only(const byte* priv, word32 privSz, ed448_key* key); - int wc_ed448_import_private_key(const byte* priv, word32 privSz, const byte* pub, word32 pubSz, ed448_key* key); - int wc_ed448_export_public(ed448_key*, byte* out, word32* outLen); - int wc_ed448_export_private_only(ed448_key* key, byte* out, word32* outLen); - int wc_ed448_export_private(ed448_key* key, byte* out, word32* outLen); - int wc_ed448_export_key(ed448_key* key, byte* priv, word32 *privSz, byte* pub, word32 *pubSz); int wc_ed448_check_key(ed448_key* key); int wc_ed448_pub_size(ed448_key* key); int wc_ed448_priv_size(ed448_key* key); """ + if features["ED448_SIGN"]: + cdef += """ + int wc_ed448_sign_msg(const byte* in, word32 inlen, byte* out, + word32 *outlen, ed448_key* key, const byte* ctx, + byte ctx_len); + """ + + if features["ED448_VERIFY"]: + cdef += """ + int wc_ed448_verify_msg(const byte* sig, word32 siglen, const byte* msg, + word32 msglen, int* stat, ed448_key* key, const byte *ctx, + byte ctx_len); + """ + + if features["ED448_KEY_IMPORT"]: + cdef += """ + int wc_Ed448PrivateKeyDecode(const byte*, word32*, ed448_key*, word32); + int wc_Ed448PublicKeyDecode(const byte*, word32*, ed448_key*, word32); + int wc_ed448_import_public(const byte* in, word32 inLen, ed448_key* key); + int wc_ed448_import_private_only(const byte* priv, word32 privSz, ed448_key* key); + int wc_ed448_import_private_key(const byte* priv, word32 privSz, const byte* pub, word32 pubSz, ed448_key* key); + """ + + if features["ED448_KEY_EXPORT"]: + cdef += """ + int wc_Ed448KeyToDer(ed448_key*, byte* output, word32 inLen); + int wc_Ed448PublicKeyToDer(ed448_key*, byte* output, + word32 inLen, int with_AlgCurve); + int wc_ed448_export_public(ed448_key*, byte* out, word32* outLen); + int wc_ed448_export_private_only(ed448_key* key, byte* out, word32* outLen); + int wc_ed448_export_private(ed448_key* key, byte* out, word32* outLen); + int wc_ed448_export_key(ed448_key* key, byte* priv, word32 *privSz, byte* pub, word32 *pubSz); + """ + if features["HKDF"]: cdef += """ int wc_HKDF(int type, const byte* inKey, word32 inKeySz, @@ -1644,6 +1669,10 @@ def default_features(): "ED25519_VERIFY": 1, "ED25519_KEY_IMPORT": 1, "ED25519_KEY_EXPORT": 1, + "ED448_SIGN": 1, + "ED448_VERIFY": 1, + "ED448_KEY_IMPORT": 1, + "ED448_KEY_EXPORT": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index d8bfc7f..2e23878 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -68,6 +68,10 @@ "ED25519_VERIFY": "ED25519", "ED25519_KEY_IMPORT": "ED25519", "ED25519_KEY_EXPORT": "ED25519", + "ED448_SIGN": "ED448", + "ED448_VERIFY": "ED448", + "ED448_KEY_IMPORT": "ED448", + "ED448_KEY_EXPORT": "ED448", } @@ -527,3 +531,51 @@ def test_ed25519_subsets_need_ed25519(bf): for names in ED25519_OPS.values(): for name in names: assert name not in cdef, name + + +ED448_SUBSETS = ("ED448_SIGN", "ED448_VERIFY", "ED448_KEY_IMPORT", "ED448_KEY_EXPORT") +ED448_OPS = { + "ED448_SIGN": ("wc_ed448_sign_msg(",), + "ED448_VERIFY": ("wc_ed448_verify_msg(",), + "ED448_KEY_IMPORT": ("wc_Ed448PrivateKeyDecode(", "wc_Ed448PublicKeyDecode(", "wc_ed448_import_public(", + "wc_ed448_import_private_only(", "wc_ed448_import_private_key("), + "ED448_KEY_EXPORT": ("wc_Ed448KeyToDer(", "wc_Ed448PublicKeyToDer(", "wc_ed448_export_public(", + "wc_ed448_export_private_only(", "wc_ed448_export_private(", "wc_ed448_export_key("), +} +# wolfSSL builds Ed448 key generation unconditionally. +ED448_COMMON = ("ed448_key;", "wc_ed448_init(", "wc_ed448_free(", "wc_ed448_make_key(", "wc_ed448_make_public(", + "wc_ed448_size(", "wc_ed448_sig_size(", "wc_ed448_check_key(", "wc_ed448_pub_size(", + "wc_ed448_priv_size(") + + +@pytest.mark.parametrize(("defines", "disabled"), [ + ((), ()), + (("#define NO_ED448_SIGN",), ("ED448_SIGN",)), + ((" #define NO_ED448_SIGN 1",), ("ED448_SIGN",)), + (("#define NO_ED448_VERIFY",), ("ED448_VERIFY",)), + (("#define NO_ED448_KEY_IMPORT",), ("ED448_KEY_IMPORT",)), + (("#define NO_ED448_KEY_EXPORT",), ("ED448_KEY_EXPORT",)), + (("#define NO_ED448_SIGN", "#define NO_ED448_KEY_EXPORT"), ("ED448_SIGN", "ED448_KEY_EXPORT")), +], ids=["default", "no-sign", "no-sign-indented", "no-verify", "no-import", "no-export", "no-sign-no-export"]) +def test_ed448_operations_follow_subset_macros(bf, defines, disabled): + features = detect(bf, "#define HAVE_ED448", *defines) + assert features["ED448"] == 1 + for name in ED448_SUBSETS: + assert features[name] == (name not in disabled), name + cdef = cdef_for(bf, features) + for subset, names in ED448_OPS.items(): + for name in names: + assert (name in cdef) == (subset not in disabled), name + for name in ED448_COMMON: + assert name in cdef, name + + +def test_ed448_subsets_need_ed448(bf): + features = detect(bf) + assert features["ED448"] == 0 + for name in ED448_SUBSETS: + assert features[name] == 0, name + cdef = cdef_for(bf, features) + for names in ED448_OPS.values(): + for name in names: + assert name not in cdef, name diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index c84c7f9..47b21dc 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -978,6 +978,13 @@ def test_ed25519_sign_verify(ed25519_private, ed25519_public): assert ed25519_private.verify(signature, plaintext) if _lib.ED448_ENABLED: + needs_ed448_import = pytest.mark.skipif(not _lib.ED448_KEY_IMPORT_ENABLED, + reason="Ed448 key import not enabled") + needs_ed448_export = pytest.mark.skipif(not _lib.ED448_KEY_EXPORT_ENABLED, + reason="Ed448 key export not enabled") + needs_ed448_sign_verify = pytest.mark.skipif(not (_lib.ED448_SIGN_ENABLED and _lib.ED448_VERIFY_ENABLED), + reason="Ed448 signing or verification not enabled") + @pytest.fixture def ed448_private(vectors): return Ed448Private(vectors[Ed448Private].key, vectors[Ed448Public].key) @@ -988,6 +995,7 @@ def ed448_public(vectors): return Ed448Public(vectors[Ed448Public].key) + @needs_ed448_import def test_new_ed448_raises(vectors): with pytest.raises(WolfCryptError): Ed448Private(vectors[Ed448Private].key[:-1]) # invalid key length @@ -1000,6 +1008,8 @@ def test_new_ed448_raises(vectors): Ed448Private.make_key(1024) + @needs_ed448_import + @needs_ed448_export def test_ed448_key_encoding(vectors): priv = Ed448Private() pub = Ed448Public() @@ -1012,6 +1022,8 @@ def test_ed448_key_encoding(vectors): assert pub.encode_key() == vectors[Ed448Public].key + @needs_ed448_import + @needs_ed448_sign_verify def test_ed448_sign_verify(ed448_private, ed448_public): plaintext = "Everyone gets Friday off." @@ -1521,3 +1533,39 @@ def test_ed25519_private_only_key_needs_make_key(monkeypatch, vectors): with pytest.raises(NotImplementedError, match="Deriving the Ed25519 public key is not supported"): Ed25519Private(vectors[Ed25519Private].key) Ed25519Private(vectors[Ed25519Private].key, vectors[Ed25519Public].key) + +if _lib.ED448_ENABLED: + # Method -> flag of the wolfSSL operation it needs. + ED448_GATED_METHODS = { + ("Ed448Public", "decode_key"): "ED448_KEY_IMPORT_ENABLED", + ("Ed448Public", "encode_key"): "ED448_KEY_EXPORT_ENABLED", + ("Ed448Public", "verify"): "ED448_VERIFY_ENABLED", + ("Ed448Private", "decode_key"): "ED448_KEY_IMPORT_ENABLED", + ("Ed448Private", "encode_key"): "ED448_KEY_EXPORT_ENABLED", + ("Ed448Private", "sign"): "ED448_SIGN_ENABLED", + } + + def test_ed448_methods_defined_only_when_enabled(monkeypatch): + """F-12230: each Ed448 method needs its wolfSSL operation to be compiled in.""" + for (cls, name), flag in ED448_GATED_METHODS.items(): + assert hasattr(getattr(ciphers, cls), name) == bool(getattr(_lib, flag)), (cls, name) + assert hasattr(ciphers.Ed448Private, "make_key") + + # Load fresh copies of the module as if one operation were not compiled in. + for disabled in set(ED448_GATED_METHODS.values()): + with monkeypatch.context() as m: + m.setattr(_lib, disabled, 0) + module = load_fresh_ciphers() + for (cls, name), flag in ED448_GATED_METHODS.items(): + assert hasattr(getattr(module, cls), name) == bool(getattr(_lib, flag)), (disabled, cls, name) + assert hasattr(module.Ed448Private, "make_key"), disabled + + def test_ed448_key_rejected_without_key_import(monkeypatch, vectors): + """F-12230: loading a key needs Ed448 key import in the linked wolfSSL.""" + monkeypatch.setattr(_lib, "ED448_KEY_IMPORT_ENABLED", 0) + for cls in (Ed448Public, Ed448Private): + with pytest.raises(NotImplementedError, match="Ed448 key import is not supported"): + cls(vectors[cls].key) + cls() + with pytest.raises(NotImplementedError, match="Ed448 key import is not supported"): + Ed448Private(vectors[Ed448Private].key, vectors[Ed448Public].key) diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index b792b17..106314e 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -49,6 +49,10 @@ ED25519_MAKE_KEY_ENABLED: int ED25519_SIGN_ENABLED: int ED25519_VERIFY_ENABLED: int ED448_ENABLED: int +ED448_KEY_EXPORT_ENABLED: int +ED448_KEY_IMPORT_ENABLED: int +ED448_SIGN_ENABLED: int +ED448_VERIFY_ENABLED: int ERROR_STRINGS_ENABLED: int FIPS_ENABLED: int HMAC_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index f0bbe1c..ea2c620 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -1822,69 +1822,74 @@ def __init__(self, key: BytesOrStr | None = None) -> None: _Ed448.__init__(self) if key: + if not _lib.ED448_KEY_IMPORT_ENABLED: + raise NotImplementedError("Ed448 key import is not supported by this wolfSSL build") self.decode_key(key) - def decode_key(self, key: BytesOrStr) -> None: - """ - Decodes an ED448 public key - """ - key = t2b(key) - if len(key) < _lib.wc_ed448_pub_size(self.native_object): - raise WolfCryptError("Key decode error: key too short") - - idx = _ffi.new("word32*") - idx[0] = 0 - ret = _lib.wc_ed448_import_public(key, len(key), - self.native_object) - if ret < 0: - raise WolfCryptApiError("Key decode error", ret) - if self.size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.size})") - if self.max_signature_size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.max_signature_size})") + if _lib.ED448_KEY_IMPORT_ENABLED: + def decode_key(self, key: BytesOrStr) -> None: + """ + Decodes an ED448 public key + """ + key = t2b(key) + if len(key) < _lib.wc_ed448_pub_size(self.native_object): + raise WolfCryptError("Key decode error: key too short") - def encode_key(self) -> bytes: - """ - Encodes the ED448 public key + idx = _ffi.new("word32*") + idx[0] = 0 + ret = _lib.wc_ed448_import_public(key, len(key), + self.native_object) + if ret < 0: + raise WolfCryptApiError("Key decode error", ret) + if self.size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.size})") + if self.max_signature_size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.max_signature_size})") - Returns the encoded key. - """ - key = _ffi.new(f"byte[{self.size * 4}]") - size = _ffi.new("word32[1]") + if _lib.ED448_KEY_EXPORT_ENABLED: + def encode_key(self) -> bytes: + """ + Encodes the ED448 public key - size[0] = _lib.wc_ed448_pub_size(self.native_object) + Returns the encoded key. + """ + key = _ffi.new(f"byte[{self.size * 4}]") + size = _ffi.new("word32[1]") - ret = _lib.wc_ed448_export_public(self.native_object, key, size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Key encode error", ret) + size[0] = _lib.wc_ed448_pub_size(self.native_object) - return _ffi.buffer(key, size[0])[:] + ret = _lib.wc_ed448_export_public(self.native_object, key, size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Key encode error", ret) - def verify(self, signature: bytes, data: BytesOrStr, ctx: BytesOrStr | None = None) -> bool: - """ - Verifies **signature**, using the public key data in the object. + return _ffi.buffer(key, size[0])[:] - Returns **True** in case of a valid signature, otherwise **False**. - """ - data = t2b(data) - status = _ffi.new("int[1]") - ctx_buf = _ffi.NULL - ctx_buf_len = 0 - if ctx is not None: - ctx_buf = t2b(ctx) - ctx_buf_len = len(ctx_buf) - if ctx_buf_len > 255: - raise ValueError(f"Ed448 ctx must be at most 255 bytes, got {ctx_buf_len}") + if _lib.ED448_VERIFY_ENABLED: + def verify(self, signature: bytes, data: BytesOrStr, ctx: BytesOrStr | None = None) -> bool: + """ + Verifies **signature**, using the public key data in the object. - ret = _lib.wc_ed448_verify_msg(signature, len(signature), - data, len(data), status, - self.native_object, ctx_buf, - ctx_buf_len) + Returns **True** in case of a valid signature, otherwise **False**. + """ + data = t2b(data) + status = _ffi.new("int[1]") + ctx_buf = _ffi.NULL + ctx_buf_len = 0 + if ctx is not None: + ctx_buf = t2b(ctx) + ctx_buf_len = len(ctx_buf) + if ctx_buf_len > 255: + raise ValueError(f"Ed448 ctx must be at most 255 bytes, got {ctx_buf_len}") + + ret = _lib.wc_ed448_verify_msg(signature, len(signature), + data, len(data), status, + self.native_object, ctx_buf, + ctx_buf_len) - if ret < 0: - raise WolfCryptApiError("Verify error", ret) + if ret < 0: + raise WolfCryptApiError("Verify error", ret) - return status[0] == 1 + return status[0] == 1 @@ -1893,6 +1898,8 @@ def __init__(self, key: BytesOrStr | None = None, pub: bytes | None = None) -> N _Ed448.__init__(self) self._rng = None + if key and not _lib.ED448_KEY_IMPORT_ENABLED: + raise NotImplementedError("Ed448 key import is not supported by this wolfSSL build") if key and not pub: self.decode_key(key) if key and pub: @@ -1918,97 +1925,100 @@ def make_key(cls, size: int, rng: Random | None = None) -> Ed448Private: return ed448 - @override - def decode_key(self, key: BytesOrStr, pub: bytes | None = None) -> None: - """ - Decodes an ED448 private + pub key - """ - key = t2b(key) - - if len(key) < _lib.wc_ed448_priv_size(self.native_object)/2: - raise WolfCryptError("Key decode error: key too short") + if _lib.ED448_KEY_IMPORT_ENABLED: + @override + def decode_key(self, key: BytesOrStr, pub: bytes | None = None) -> None: + """ + Decodes an ED448 private + pub key + """ + key = t2b(key) - idx = _ffi.new("word32*") - idx[0] = 0 - if pub: - ret = _lib.wc_ed448_import_private_key(key, len(key), pub, - len(pub), self.native_object) - if ret < 0: - raise WolfCryptApiError("Key decode error", ret) - else: - ret = _lib.wc_ed448_import_private_only(key, len(key), - self.native_object) - if ret < 0: - raise WolfCryptApiError("Key decode error", ret) - pubkey = _ffi.new(f"byte[{self.size * 4}]") - ret = _lib.wc_ed448_make_public(self.native_object, pubkey, - self.size) - if ret < 0: - raise WolfCryptApiError("Public key generate error", ret) - ret = _lib.wc_ed448_import_public(pubkey, self.size, - self.native_object) - if ret < 0: - raise WolfCryptApiError("Public key import error", ret) + if len(key) < _lib.wc_ed448_priv_size(self.native_object)/2: + raise WolfCryptError("Key decode error: key too short") - if self.size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.size})") - if self.max_signature_size <= 0: # pragma: no cover - raise WolfCryptError(f"Key decode error ({self.max_signature_size})") + idx = _ffi.new("word32*") + idx[0] = 0 + if pub: + ret = _lib.wc_ed448_import_private_key(key, len(key), pub, + len(pub), self.native_object) + if ret < 0: + raise WolfCryptApiError("Key decode error", ret) + else: + ret = _lib.wc_ed448_import_private_only(key, len(key), + self.native_object) + if ret < 0: + raise WolfCryptApiError("Key decode error", ret) + pubkey = _ffi.new(f"byte[{self.size * 4}]") + ret = _lib.wc_ed448_make_public(self.native_object, pubkey, + self.size) + if ret < 0: + raise WolfCryptApiError("Public key generate error", ret) + ret = _lib.wc_ed448_import_public(pubkey, self.size, + self.native_object) + if ret < 0: + raise WolfCryptApiError("Public key import error", ret) - @override - def encode_key(self) -> tuple[bytes, bytes]: - """ - Encodes the ED448 private key. + if self.size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.size})") + if self.max_signature_size <= 0: # pragma: no cover + raise WolfCryptError(f"Key decode error ({self.max_signature_size})") - Returns the encoded key. - """ - key = _ffi.new(f"byte[{self.size * 4}]") - pubkey = _ffi.new(f"byte[{self.size * 4}]") - priv_size = _ffi.new("word32[1]") - pub_size = _ffi.new("word32[1]") + if _lib.ED448_KEY_EXPORT_ENABLED: + @override + def encode_key(self) -> tuple[bytes, bytes]: + """ + Encodes the ED448 private key. - priv_size[0] = _lib.wc_ed448_priv_size(self.native_object) - pub_size[0] = _lib.wc_ed448_pub_size(self.native_object) + Returns the encoded key. + """ + key = _ffi.new(f"byte[{self.size * 4}]") + pubkey = _ffi.new(f"byte[{self.size * 4}]") + priv_size = _ffi.new("word32[1]") + pub_size = _ffi.new("word32[1]") - ret = _lib.wc_ed448_export_private_only(self.native_object, - key, priv_size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Private key encode error", ret) - ret = _lib.wc_ed448_export_public(self.native_object, pubkey, - pub_size) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Public key encode error", ret) + priv_size[0] = _lib.wc_ed448_priv_size(self.native_object) + pub_size[0] = _lib.wc_ed448_pub_size(self.native_object) - return _ffi.buffer(key, priv_size[0])[:], _ffi.buffer(pubkey, pub_size[0])[:] + ret = _lib.wc_ed448_export_private_only(self.native_object, + key, priv_size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Private key encode error", ret) + ret = _lib.wc_ed448_export_public(self.native_object, pubkey, + pub_size) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Public key encode error", ret) - def sign(self, plaintext: BytesOrStr, ctx : BytesOrStr | None = None) -> bytes: - """ - Signs **plaintext**, using the private key data in the object. + return _ffi.buffer(key, priv_size[0])[:], _ffi.buffer(pubkey, pub_size[0])[:] - Returns the signature. - """ - plaintext = t2b(plaintext) - signature = _ffi.new(f"byte[{self.max_signature_size}]") + if _lib.ED448_SIGN_ENABLED: + def sign(self, plaintext: BytesOrStr, ctx : BytesOrStr | None = None) -> bytes: + """ + Signs **plaintext**, using the private key data in the object. - signature_size = _ffi.new("word32[1]") - signature_size[0] = self.max_signature_size - ctx_buf = _ffi.NULL - ctx_buf_len = 0 - if ctx is not None: - ctx_buf = t2b(ctx) - ctx_buf_len = len(ctx_buf) - if ctx_buf_len > 255: - raise ValueError(f"Ed448 ctx must be at most 255 bytes, got {ctx_buf_len}") + Returns the signature. + """ + plaintext = t2b(plaintext) + signature = _ffi.new(f"byte[{self.max_signature_size}]") - ret = _lib.wc_ed448_sign_msg(plaintext, len(plaintext), - signature, signature_size, - self.native_object, ctx_buf, - ctx_buf_len) + signature_size = _ffi.new("word32[1]") + signature_size[0] = self.max_signature_size + ctx_buf = _ffi.NULL + ctx_buf_len = 0 + if ctx is not None: + ctx_buf = t2b(ctx) + ctx_buf_len = len(ctx_buf) + if ctx_buf_len > 255: + raise ValueError(f"Ed448 ctx must be at most 255 bytes, got {ctx_buf_len}") + + ret = _lib.wc_ed448_sign_msg(plaintext, len(plaintext), + signature, signature_size, + self.native_object, ctx_buf, + ctx_buf_len) - if ret != 0: # pragma: no cover - raise WolfCryptApiError("Signature error", ret) + if ret != 0: # pragma: no cover + raise WolfCryptApiError("Signature error", ret) - return _ffi.buffer(signature, signature_size[0])[:] + return _ffi.buffer(signature, signature_size[0])[:] if _lib.ML_KEM_ENABLED: From 25ef90f246105eb5cfaef24933a068ad6fadfdac Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 20:37:18 +0000 Subject: [PATCH 18/26] Declare ML-KEM operations only when wolfSSL builds them (F-12231) wolfSSL leaves out ML-KEM key generation, encapsulation and decapsulation with WOLFSSL_MLKEM_NO_MAKE_KEY, WOLFSSL_MLKEM_NO_ENCAPSULATE and WOLFSSL_MLKEM_NO_DECAPSULATE (or the legacy WOLFSSL_KYBER_NO_* names), which --enable-mlkem=...,enc and similar configure options set. Against such a local wolfSSL the extension failed to import with an undefined symbol. Detect each operation and declare it only when it is built. MlKemPrivate.make_key(), make_key_with_random() and decapsulate(), and MlKemPublic.encapsulate() and encapsulate_with_random() are defined only when their operation exists. Key setup, sizes, encoding and decoding are always built and stay available. --- scripts/build_ffi.py | 36 ++++++- tests/test_build_ffi.py | 53 +++++++++++ tests/test_ciphers.py | 32 +++++++ tests/test_mlkem.py | 17 ++++ wolfcrypt/_ffi/lib.pyi | 3 + wolfcrypt/ciphers.py | 205 ++++++++++++++++++++-------------------- 6 files changed, 240 insertions(+), 106 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 273c136..1df2e6f 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -473,6 +473,11 @@ def defined(name): # settings.h derives the Ed448 operations unless NO_ED448_. for op in ("SIGN", "VERIFY", "KEY_IMPORT", "KEY_EXPORT"): features[f"ED448_{op}"] = 1 if features["ED448"] and not defined(f"NO_ED448_{op}") else 0 + # wc_mlkem.c builds each ML-KEM operation unless WOLFSSL_MLKEM_NO_. + # wc_mlkem.h maps the legacy WOLFSSL_KYBER_NO_ names to these. + for op in ("MAKE_KEY", "ENCAPSULATE", "DECAPSULATE"): + features[f"ML_KEM_{op}"] = 1 if features["ML_KEM"] and not ( + defined(f"WOLFSSL_MLKEM_NO_{op}") or defined(f"WOLFSSL_KYBER_NO_{op}")) else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -635,6 +640,9 @@ def make_source(features): int ED448_VERIFY_ENABLED = {features["ED448_VERIFY"]}; int ED448_KEY_IMPORT_ENABLED = {features["ED448_KEY_IMPORT"]}; int ED448_KEY_EXPORT_ENABLED = {features["ED448_KEY_EXPORT"]}; + int ML_KEM_MAKE_KEY_ENABLED = {features["ML_KEM_MAKE_KEY"]}; + int ML_KEM_ENCAPSULATE_ENABLED = {features["ML_KEM_ENCAPSULATE"]}; + int ML_KEM_DECAPSULATE_ENABLED = {features["ML_KEM_DECAPSULATE"]}; """ return init_source_string @@ -709,6 +717,9 @@ def make_cdef(features): extern int ED448_VERIFY_ENABLED; extern int ED448_KEY_IMPORT_ENABLED; extern int ED448_KEY_EXPORT_ENABLED; + extern int ML_KEM_MAKE_KEY_ENABLED; + extern int ML_KEM_ENCAPSULATE_ENABLED; + extern int ML_KEM_DECAPSULATE_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1562,17 +1573,29 @@ def make_cdef(features): int wc_KyberKey_PublicKeySize(KyberKey* key, word32* len); int wc_KyberKey_Init(int type, KyberKey* key, void* heap, int devId); void wc_KyberKey_Free(KyberKey* key); - int wc_KyberKey_MakeKey(KyberKey* key, WC_RNG* rng); - int wc_KyberKey_MakeKeyWithRandom(KyberKey* key, const unsigned char* rand, int len); int wc_KyberKey_EncodePublicKey(KyberKey* key, unsigned char* out, word32 len); int wc_KyberKey_DecodePublicKey(KyberKey* key, const unsigned char* in, word32 len); - int wc_KyberKey_Encapsulate(KyberKey* key, unsigned char* ct, unsigned char* ss, WC_RNG* rng); - int wc_KyberKey_EncapsulateWithRandom(KyberKey* key, unsigned char* ct, unsigned char* ss, const unsigned char* rand, int len); - int wc_KyberKey_Decapsulate(KyberKey* key, unsigned char* ss, const unsigned char* ct, word32 len); int wc_KyberKey_EncodePrivateKey(KyberKey* key, unsigned char* out, word32 len); int wc_KyberKey_DecodePrivateKey(KyberKey* key, const unsigned char* in, word32 len); """ + if features["ML_KEM_MAKE_KEY"]: + cdef += """ + int wc_KyberKey_MakeKey(KyberKey* key, WC_RNG* rng); + int wc_KyberKey_MakeKeyWithRandom(KyberKey* key, const unsigned char* rand, int len); + """ + + if features["ML_KEM_ENCAPSULATE"]: + cdef += """ + int wc_KyberKey_Encapsulate(KyberKey* key, unsigned char* ct, unsigned char* ss, WC_RNG* rng); + int wc_KyberKey_EncapsulateWithRandom(KyberKey* key, unsigned char* ct, unsigned char* ss, const unsigned char* rand, int len); + """ + + if features["ML_KEM_DECAPSULATE"]: + cdef += """ + int wc_KyberKey_Decapsulate(KyberKey* key, unsigned char* ss, const unsigned char* ct, word32 len); + """ + if features["ML_DSA"]: cdef += """ static const int DILITHIUM_SEED_SZ; @@ -1673,6 +1696,9 @@ def default_features(): "ED448_VERIFY": 1, "ED448_KEY_IMPORT": 1, "ED448_KEY_EXPORT": 1, + "ML_KEM_MAKE_KEY": 1, + "ML_KEM_ENCAPSULATE": 1, + "ML_KEM_DECAPSULATE": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 2e23878..afca5c6 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -72,6 +72,9 @@ "ED448_VERIFY": "ED448", "ED448_KEY_IMPORT": "ED448", "ED448_KEY_EXPORT": "ED448", + "ML_KEM_MAKE_KEY": "ML_KEM", + "ML_KEM_ENCAPSULATE": "ML_KEM", + "ML_KEM_DECAPSULATE": "ML_KEM", } @@ -579,3 +582,53 @@ def test_ed448_subsets_need_ed448(bf): for names in ED448_OPS.values(): for name in names: assert name not in cdef, name + + +ML_KEM_SUBSETS = ("ML_KEM_MAKE_KEY", "ML_KEM_ENCAPSULATE", "ML_KEM_DECAPSULATE") +ML_KEM_OPS = { + "ML_KEM_MAKE_KEY": ("wc_KyberKey_MakeKey(", "wc_KyberKey_MakeKeyWithRandom("), + "ML_KEM_ENCAPSULATE": ("wc_KyberKey_Encapsulate(", "wc_KyberKey_EncapsulateWithRandom("), + "ML_KEM_DECAPSULATE": ("wc_KyberKey_Decapsulate(",), +} +# wolfSSL builds key setup, sizes and encoding for every operation subset. +ML_KEM_COMMON = ("KyberKey;", "wc_KyberKey_Init(", "wc_KyberKey_Free(", "wc_KyberKey_CipherTextSize(", + "wc_KyberKey_SharedSecretSize(", "wc_KyberKey_PrivateKeySize(", "wc_KyberKey_PublicKeySize(", + "wc_KyberKey_EncodePublicKey(", "wc_KyberKey_DecodePublicKey(", "wc_KyberKey_EncodePrivateKey(", + "wc_KyberKey_DecodePrivateKey(") + + +@pytest.mark.parametrize(("defines", "disabled"), [ + ((), ()), + (("#define WOLFSSL_MLKEM_NO_MAKE_KEY",), ("ML_KEM_MAKE_KEY",)), + ((" #define WOLFSSL_MLKEM_NO_MAKE_KEY 1",), ("ML_KEM_MAKE_KEY",)), + (("#define WOLFSSL_MLKEM_NO_ENCAPSULATE",), ("ML_KEM_ENCAPSULATE",)), + (("#define WOLFSSL_MLKEM_NO_DECAPSULATE",), ("ML_KEM_DECAPSULATE",)), + (("#define WOLFSSL_KYBER_NO_MAKE_KEY",), ("ML_KEM_MAKE_KEY",)), + (("#define WOLFSSL_KYBER_NO_ENCAPSULATE",), ("ML_KEM_ENCAPSULATE",)), + (("#define WOLFSSL_KYBER_NO_DECAPSULATE",), ("ML_KEM_DECAPSULATE",)), + (("#define WOLFSSL_MLKEM_NO_MAKE_KEY", "#define WOLFSSL_MLKEM_NO_DECAPSULATE"), + ("ML_KEM_MAKE_KEY", "ML_KEM_DECAPSULATE")), +], ids=["default", "no-make-key", "no-make-key-indented", "no-encapsulate", "no-decapsulate", + "legacy-no-make-key", "legacy-no-encapsulate", "legacy-no-decapsulate", "encapsulate-only"]) +def test_ml_kem_operations_follow_subset_macros(bf, defines, disabled): + features = detect(bf, "#define WOLFSSL_HAVE_MLKEM", *defines) + assert features["ML_KEM"] == 1 + for name in ML_KEM_SUBSETS: + assert features[name] == (name not in disabled), name + cdef = cdef_for(bf, features) + for subset, names in ML_KEM_OPS.items(): + for name in names: + assert (name in cdef) == (subset not in disabled), name + for name in ML_KEM_COMMON: + assert name in cdef, name + + +def test_ml_kem_subsets_need_ml_kem(bf): + features = detect(bf) + assert features["ML_KEM"] == 0 + for name in ML_KEM_SUBSETS: + assert features[name] == 0, name + cdef = cdef_for(bf, features) + for names in ML_KEM_OPS.values(): + for name in names: + assert name not in cdef, name diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 47b21dc..7782fd8 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -1569,3 +1569,35 @@ def test_ed448_key_rejected_without_key_import(monkeypatch, vectors): cls() with pytest.raises(NotImplementedError, match="Ed448 key import is not supported"): Ed448Private(vectors[Ed448Private].key, vectors[Ed448Public].key) + +if _lib.ML_KEM_ENABLED: + # Method -> flag of the wolfSSL operation it needs. + ML_KEM_GATED_METHODS = { + ("MlKemPublic", "encapsulate"): "ML_KEM_ENCAPSULATE_ENABLED", + ("MlKemPublic", "encapsulate_with_random"): "ML_KEM_ENCAPSULATE_ENABLED", + ("MlKemPrivate", "make_key"): "ML_KEM_MAKE_KEY_ENABLED", + ("MlKemPrivate", "make_key_with_random"): "ML_KEM_MAKE_KEY_ENABLED", + ("MlKemPrivate", "decapsulate"): "ML_KEM_DECAPSULATE_ENABLED", + } + ML_KEM_COMMON_METHODS = ( + ("MlKemPublic", "decode_key"), + ("MlKemPublic", "encode_key"), + ("MlKemPrivate", "decode_key"), + ("MlKemPrivate", "encode_priv_key"), + ("MlKemPrivate", "encode_pub_key"), + ) + + def test_ml_kem_methods_defined_only_when_enabled(monkeypatch): + """F-12231: each ML-KEM method needs its wolfSSL operation to be compiled in.""" + for (cls, name), flag in ML_KEM_GATED_METHODS.items(): + assert hasattr(getattr(ciphers, cls), name) == bool(getattr(_lib, flag)), (cls, name) + + # Load fresh copies of the module as if one operation were not compiled in. + for disabled in set(ML_KEM_GATED_METHODS.values()): + with monkeypatch.context() as m: + m.setattr(_lib, disabled, 0) + module = load_fresh_ciphers() + for (cls, name), flag in ML_KEM_GATED_METHODS.items(): + assert hasattr(getattr(module, cls), name) == bool(getattr(_lib, flag)), (disabled, cls, name) + for cls, name in ML_KEM_COMMON_METHODS: + assert hasattr(getattr(module, cls), name), (disabled, cls, name) diff --git a/tests/test_mlkem.py b/tests/test_mlkem.py index 736ba9a..0fc3a21 100644 --- a/tests/test_mlkem.py +++ b/tests/test_mlkem.py @@ -549,7 +549,16 @@ (MlKemType.ML_KEM_1024), ] + needs_make_key = pytest.mark.skipif(not _lib.ML_KEM_MAKE_KEY_ENABLED, + reason="ML-KEM key generation not enabled") + needs_encapsulate = pytest.mark.skipif(not _lib.ML_KEM_ENCAPSULATE_ENABLED, + reason="ML-KEM encapsulation not enabled") + needs_decapsulate = pytest.mark.skipif(not _lib.ML_KEM_DECAPSULATE_ENABLED, + reason="ML-KEM decapsulation not enabled") + @pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") + @needs_encapsulate + @needs_decapsulate @pytest.mark.parametrize("mlkem_type", mlkem_types) def test_init_pattern_1(mlkem_type): mlkem_priv = MlKemPrivate(mlkem_type) @@ -575,6 +584,9 @@ def test_init_pattern_1(mlkem_type): assert ss_send == ss_recv @pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") + @needs_make_key + @needs_encapsulate + @needs_decapsulate @pytest.mark.parametrize("mlkem_type", mlkem_types) def test_init_pattern_2(mlkem_type): mlkem_priv = MlKemPrivate.make_key_with_random( @@ -601,6 +613,9 @@ def test_init_pattern_2(mlkem_type): assert ss_send == ss_recv @pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") + @needs_make_key + @needs_encapsulate + @needs_decapsulate @pytest.mark.parametrize("mlkem_type", mlkem_types) def test_init_pattern_3(mlkem_type): mlkem_priv = MlKemPrivate.make_key(mlkem_type) @@ -620,12 +635,14 @@ def test_init_pattern_3(mlkem_type): ss_recv = mlkem_priv.decapsulate(ct) assert ss_send == ss_recv + @needs_make_key @pytest.mark.parametrize("mlkem_type", mlkem_types) @pytest.mark.parametrize("rand", [0, "rand"]) def test_make_key_with_random_bad_random_type(mlkem_type, rand: int | str): with pytest.raises(TypeError): MlKemPrivate.make_key_with_random(mlkem_type, rand) + @needs_encapsulate @pytest.mark.parametrize("mlkem_type", mlkem_types) @pytest.mark.parametrize("rand", [0, "rand"]) def test_encapsulate_with_random_bad_random_type(mlkem_type, rand: int | str): diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index 106314e..a75c2ac 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -60,7 +60,10 @@ KEYGEN_ENABLED: int HKDF_ENABLED: int ML_DSA_ENABLED: int ML_DSA_NO_CTX_ENABLED: int +ML_KEM_DECAPSULATE_ENABLED: int ML_KEM_ENABLED: int +ML_KEM_ENCAPSULATE_ENABLED: int +ML_KEM_MAKE_KEY_ENABLED: int MPAPI_ENABLED: int PBKDF2_ENABLED: int PEM_TO_DER_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index ea2c620..5c3b00e 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -2140,105 +2140,107 @@ def decode_key(self, pub_key: BytesOrStr) -> None: if ret < 0: # pragma: no cover raise WolfCryptApiError("wc_KyberKey_DecodePublicKey() error", ret) - def encapsulate(self, rng: Random | None = None) -> tuple[bytes, bytes]: - """ - :param rng: random number generator for an encupsulation - :type rng: Random - :return: tuple of a shared secret (first element) and the cipher text (second element) - :rtype: tuple[bytes, bytes] - """ - if rng is None: - rng = Random() - ct_size = self.ct_size - ss_size = self.ss_size - ct = _ffi.new(f"unsigned char[{ct_size}]") - ss = _ffi.new(f"unsigned char[{ss_size}]") - ret = _lib.wc_KyberKey_Encapsulate( - self.native_object, ct, ss, rng.native_object - ) - - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_KyberKey_Encapsulate() error", ret) - - return _ffi.buffer(ss, ss_size)[:], _ffi.buffer(ct, ct_size)[:] + if _lib.ML_KEM_ENCAPSULATE_ENABLED: + def encapsulate(self, rng: Random | None = None) -> tuple[bytes, bytes]: + """ + :param rng: random number generator for an encupsulation + :type rng: Random + :return: tuple of a shared secret (first element) and the cipher text (second element) + :rtype: tuple[bytes, bytes] + """ + if rng is None: + rng = Random() + ct_size = self.ct_size + ss_size = self.ss_size + ct = _ffi.new(f"unsigned char[{ct_size}]") + ss = _ffi.new(f"unsigned char[{ss_size}]") + ret = _lib.wc_KyberKey_Encapsulate( + self.native_object, ct, ss, rng.native_object + ) - def encapsulate_with_random(self, rand: bytes) -> tuple[bytes, bytes]: - """ - :param rand: random number for an encapsulation - :type rand: bytes - :return: tuple of a shared secret (first element) and the cipher text (second element) - :rtype: tuple[bytes, bytes] - """ + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_KyberKey_Encapsulate() error", ret) - try: - memoryview(rand) - except TypeError as exception: - raise TypeError("rand must support the buffer protocol, such as `bytes` or `bytearray`") from exception + return _ffi.buffer(ss, ss_size)[:], _ffi.buffer(ct, ct_size)[:] - rand = bytes(rand) + def encapsulate_with_random(self, rand: bytes) -> tuple[bytes, bytes]: + """ + :param rand: random number for an encapsulation + :type rand: bytes + :return: tuple of a shared secret (first element) and the cipher text (second element) + :rtype: tuple[bytes, bytes] + """ - ct_size = self.ct_size - ss_size = self.ss_size - ct = _ffi.new(f"unsigned char[{ct_size}]") - ss = _ffi.new(f"unsigned char[{ss_size}]") - ret = _lib.wc_KyberKey_EncapsulateWithRandom( - self.native_object, ct, ss, rand, len(rand) - ) + try: + memoryview(rand) + except TypeError as exception: + raise TypeError("rand must support the buffer protocol, such as `bytes` or `bytearray`") from exception + + rand = bytes(rand) + + ct_size = self.ct_size + ss_size = self.ss_size + ct = _ffi.new(f"unsigned char[{ct_size}]") + ss = _ffi.new(f"unsigned char[{ss_size}]") + ret = _lib.wc_KyberKey_EncapsulateWithRandom( + self.native_object, ct, ss, rand, len(rand) + ) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_KyberKey_EncapsulateWithRandom() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_KyberKey_EncapsulateWithRandom() error", ret) - return _ffi.buffer(ss, ss_size)[:], _ffi.buffer(ct, ct_size)[:] + return _ffi.buffer(ss, ss_size)[:], _ffi.buffer(ct, ct_size)[:] class MlKemPrivate(_MlKemBase): - @classmethod - def make_key(cls, mlkem_type: MlKemType, rng: Random | None = None) -> MlKemPrivate: - """ - :param mlkem_type: ML-KEM type - :type mlkem_type: MlKemType - :param rng: random number generator for a key generation - :type rng: Random - :return: `MlKemPrivate` object - :rtype: MlKemPrivate - """ - if rng is None: - rng = Random() - mlkem_priv = cls(mlkem_type) - ret = _lib.wc_KyberKey_MakeKey(mlkem_priv.native_object, rng.native_object) + if _lib.ML_KEM_MAKE_KEY_ENABLED: + @classmethod + def make_key(cls, mlkem_type: MlKemType, rng: Random | None = None) -> MlKemPrivate: + """ + :param mlkem_type: ML-KEM type + :type mlkem_type: MlKemType + :param rng: random number generator for a key generation + :type rng: Random + :return: `MlKemPrivate` object + :rtype: MlKemPrivate + """ + if rng is None: + rng = Random() + mlkem_priv = cls(mlkem_type) + ret = _lib.wc_KyberKey_MakeKey(mlkem_priv.native_object, rng.native_object) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_KyberKey_MakeKey() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_KyberKey_MakeKey() error", ret) - # Retain RNG reference defensively. - mlkem_priv._rng = rng + # Retain RNG reference defensively. + mlkem_priv._rng = rng - return mlkem_priv + return mlkem_priv - @classmethod - def make_key_with_random(cls, mlkem_type: MlKemType, rand: bytes) -> MlKemPrivate: - """ - :param mlkem_type: ML-KEM type - :type mlkem_type: MlKemType - :param rand: random number for a key generation - :type rand: bytes - :return: `MlKemPrivate` object - :rtype: MlKemPrivate - """ - mlkem_priv = cls(mlkem_type) + @classmethod + def make_key_with_random(cls, mlkem_type: MlKemType, rand: bytes) -> MlKemPrivate: + """ + :param mlkem_type: ML-KEM type + :type mlkem_type: MlKemType + :param rand: random number for a key generation + :type rand: bytes + :return: `MlKemPrivate` object + :rtype: MlKemPrivate + """ + mlkem_priv = cls(mlkem_type) - try: - memoryview(rand) - except TypeError as exception: - raise TypeError("rand must support the buffer protocol, such as `bytes` or `bytearray`") from exception + try: + memoryview(rand) + except TypeError as exception: + raise TypeError("rand must support the buffer protocol, such as `bytes` or `bytearray`") from exception - rand = bytes(rand) + rand = bytes(rand) - ret = _lib.wc_KyberKey_MakeKeyWithRandom(mlkem_priv.native_object, rand, len(rand)) + ret = _lib.wc_KyberKey_MakeKeyWithRandom(mlkem_priv.native_object, rand, len(rand)) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_KyberKey_MakeKeyWithRandom() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_KyberKey_MakeKeyWithRandom() error", ret) - return mlkem_priv + return mlkem_priv @property def pub_key_size(self) -> int: @@ -2300,27 +2302,28 @@ def decode_key(self, priv_key: BytesOrStr) -> None: if ret < 0: # pragma: no cover raise WolfCryptApiError("wc_KyberKey_DecodePrivateKey() error", ret) - def decapsulate(self, ct: BytesOrStr) -> bytes: - """ - :param ct: cipher text - :type ct: bytes or str - :return: shared secret - :rtype: bytes - """ - ss_size = self.ss_size - ss = _ffi.new(f"unsigned char[{ss_size}]") - ct_bytestype = t2b(ct) - ret = _lib.wc_KyberKey_Decapsulate( - self.native_object, - ss, - ct_bytestype, - len(ct_bytestype), - ) + if _lib.ML_KEM_DECAPSULATE_ENABLED: + def decapsulate(self, ct: BytesOrStr) -> bytes: + """ + :param ct: cipher text + :type ct: bytes or str + :return: shared secret + :rtype: bytes + """ + ss_size = self.ss_size + ss = _ffi.new(f"unsigned char[{ss_size}]") + ct_bytestype = t2b(ct) + ret = _lib.wc_KyberKey_Decapsulate( + self.native_object, + ss, + ct_bytestype, + len(ct_bytestype), + ) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_KyberKey_Decapsulate() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_KyberKey_Decapsulate() error", ret) - return _ffi.buffer(ss, ss_size)[:] + return _ffi.buffer(ss, ss_size)[:] if _lib.ML_DSA_ENABLED: From 03466c157a046a9cbcf6d0d89351de70261a194e Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 20:55:14 +0000 Subject: [PATCH 19/26] Declare ML-DSA operations only when wolfSSL builds them (F-10071) wolfSSL leaves out ML-DSA key generation, signing and verification with WOLFSSL_MLDSA_NO_MAKE_KEY, WOLFSSL_MLDSA_NO_SIGN, WOLFSSL_MLDSA_NO_VERIFY and WOLFSSL_MLDSA_VERIFY_ONLY (or the legacy WOLFSSL_DILITHIUM_* names), which --enable-mldsa=...,verify-only and similar configure options set. Public and private key import and export follow from these. Against such a local wolfSSL the extension failed to compile or import with an undefined symbol. Detect each operation and key part and declare it only when it is built. MlDsaPrivate and MlDsaPublic methods are defined only when the operations they need exist. MlDsaPrivate.decode_key() raises NotImplementedError when given a public key that the build cannot import. --- scripts/build_ffi.py | 95 +++++- tests/test_build_ffi.py | 85 ++++++ tests/test_ciphers.py | 50 ++++ tests/test_mldsa.py | 25 ++ wolfcrypt/_ffi/lib.pyi | 5 + wolfcrypt/ciphers.py | 636 ++++++++++++++++++++-------------------- 6 files changed, 568 insertions(+), 328 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 1df2e6f..d3c2f6c 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -478,6 +478,23 @@ def defined(name): for op in ("MAKE_KEY", "ENCAPSULATE", "DECAPSULATE"): features[f"ML_KEM_{op}"] = 1 if features["ML_KEM"] and not ( defined(f"WOLFSSL_MLKEM_NO_{op}") or defined(f"WOLFSSL_KYBER_NO_{op}")) else 0 + # dilithium.h derives the ML-DSA operations and key parts from + # WOLFSSL_MLDSA_ or the legacy WOLFSSL_DILITHIUM_ names. + def mldsa_defined(gate): + return defined(f"WOLFSSL_MLDSA_{gate}") or defined(f"WOLFSSL_DILITHIUM_{gate}") + + mldsa_verify_only = mldsa_defined("VERIFY_ONLY") + mldsa_no_make_key = mldsa_verify_only or mldsa_defined("NO_MAKE_KEY") + mldsa_no_sign = mldsa_verify_only or mldsa_defined("NO_SIGN") + mldsa_no_verify = mldsa_defined("NO_VERIFY") + ml_dsa = features["ML_DSA"] + features["ML_DSA_MAKE_KEY"] = 1 if ml_dsa and not mldsa_no_make_key else 0 + features["ML_DSA_SIGN"] = 1 if ml_dsa and not mldsa_no_sign else 0 + features["ML_DSA_VERIFY"] = 1 if ml_dsa and not mldsa_no_verify else 0 + features["ML_DSA_PUBLIC_KEY"] = 1 if ml_dsa and (not mldsa_no_make_key or not mldsa_no_verify + or mldsa_defined("PUBLIC_KEY")) else 0 + features["ML_DSA_PRIVATE_KEY"] = 1 if ml_dsa and (not mldsa_no_make_key or not mldsa_no_sign + or mldsa_defined("PRIVATE_KEY")) else 0 if '#define HAVE_FIPS' in defines: if not fips: @@ -643,6 +660,11 @@ def make_source(features): int ML_KEM_MAKE_KEY_ENABLED = {features["ML_KEM_MAKE_KEY"]}; int ML_KEM_ENCAPSULATE_ENABLED = {features["ML_KEM_ENCAPSULATE"]}; int ML_KEM_DECAPSULATE_ENABLED = {features["ML_KEM_DECAPSULATE"]}; + int ML_DSA_MAKE_KEY_ENABLED = {features["ML_DSA_MAKE_KEY"]}; + int ML_DSA_SIGN_ENABLED = {features["ML_DSA_SIGN"]}; + int ML_DSA_VERIFY_ENABLED = {features["ML_DSA_VERIFY"]}; + int ML_DSA_PUBLIC_KEY_ENABLED = {features["ML_DSA_PUBLIC_KEY"]}; + int ML_DSA_PRIVATE_KEY_ENABLED = {features["ML_DSA_PRIVATE_KEY"]}; """ return init_source_string @@ -720,6 +742,11 @@ def make_cdef(features): extern int ML_KEM_MAKE_KEY_ENABLED; extern int ML_KEM_ENCAPSULATE_ENABLED; extern int ML_KEM_DECAPSULATE_ENABLED; + extern int ML_DSA_MAKE_KEY_ENABLED; + extern int ML_DSA_SIGN_ENABLED; + extern int ML_DSA_VERIFY_ENABLED; + extern int ML_DSA_PUBLIC_KEY_ENABLED; + extern int ML_DSA_PRIVATE_KEY_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -1606,27 +1633,58 @@ def make_cdef(features): int wc_dilithium_init_ex(dilithium_key* key, void* heap, int devId); int wc_dilithium_set_level(dilithium_key* key, byte level); void wc_dilithium_free(dilithium_key* key); - int wc_dilithium_make_key(dilithium_key* key, WC_RNG* rng); - int wc_dilithium_make_key_from_seed(dilithium_key* key, const byte* seed); - int wc_dilithium_export_private(dilithium_key* key, byte* out, word32* outLen); - int wc_dilithium_import_private(const byte* priv, word32 privSz, dilithium_key* key); - int wc_dilithium_export_public(dilithium_key* key, byte* out, word32* outLen); - int wc_dilithium_import_public(const byte* in, word32 inLen, dilithium_key* key); - int wc_dilithium_sign_ctx_msg(const byte* ctx, byte ctxLen, const byte* msg, word32 msgLen, byte* sig, word32* sigLen, dilithium_key* key, WC_RNG* rng); - int wc_dilithium_sign_ctx_msg_with_seed(const byte* ctx, byte ctxLen, const byte* msg, word32 msgLen, byte* sig, word32* sigLen, dilithium_key* key, const byte* seed); - int wc_dilithium_verify_ctx_msg(const byte* sig, word32 sigLen, const byte* ctx, byte ctxLen, const byte* msg, word32 msgLen, int* res, dilithium_key* key); typedef dilithium_key MlDsaKey; - int wc_MlDsaKey_GetPrivLen(MlDsaKey* key, int* len); - int wc_MlDsaKey_GetPubLen(MlDsaKey* key, int* len); - int wc_MlDsaKey_GetSigLen(MlDsaKey* key, int* len); """ - if features["ML_DSA_NO_CTX"]: + + if features["ML_DSA_MAKE_KEY"]: + cdef += """ + int wc_dilithium_make_key(dilithium_key* key, WC_RNG* rng); + int wc_dilithium_make_key_from_seed(dilithium_key* key, const byte* seed); + """ + + if features["ML_DSA_PRIVATE_KEY"]: + cdef += """ + int wc_dilithium_export_private(dilithium_key* key, byte* out, word32* outLen); + int wc_dilithium_import_private(const byte* priv, word32 privSz, dilithium_key* key); + """ + + if features["ML_DSA_PUBLIC_KEY"]: + cdef += """ + int wc_dilithium_export_public(dilithium_key* key, byte* out, word32* outLen); + int wc_dilithium_import_public(const byte* in, word32 inLen, dilithium_key* key); + int wc_MlDsaKey_GetPubLen(MlDsaKey* key, int* len); + """ + + if features["ML_DSA_PRIVATE_KEY"] and features["ML_DSA_PUBLIC_KEY"]: cdef += """ - int wc_dilithium_sign_msg(const byte* msg, word32 msgLen, byte* sig, word32* sigLen, dilithium_key* key, WC_RNG* rng); - int wc_dilithium_sign_msg_with_seed(const byte* msg, word32 msgLen, byte* sig, word32* sigLen, dilithium_key* key, const byte* seed); - int wc_dilithium_verify_msg(const byte* sig, word32 sigLen, const byte* msg, word32 msgLen, int* res, dilithium_key* key); + int wc_MlDsaKey_GetPrivLen(MlDsaKey* key, int* len); """ + if features["ML_DSA_SIGN"] or features["ML_DSA_VERIFY"]: + cdef += """ + int wc_MlDsaKey_GetSigLen(MlDsaKey* key, int* len); + """ + + if features["ML_DSA_SIGN"]: + cdef += """ + int wc_dilithium_sign_ctx_msg(const byte* ctx, byte ctxLen, const byte* msg, word32 msgLen, byte* sig, word32* sigLen, dilithium_key* key, WC_RNG* rng); + int wc_dilithium_sign_ctx_msg_with_seed(const byte* ctx, byte ctxLen, const byte* msg, word32 msgLen, byte* sig, word32* sigLen, dilithium_key* key, const byte* seed); + """ + if features["ML_DSA_NO_CTX"]: + cdef += """ + int wc_dilithium_sign_msg(const byte* msg, word32 msgLen, byte* sig, word32* sigLen, dilithium_key* key, WC_RNG* rng); + int wc_dilithium_sign_msg_with_seed(const byte* msg, word32 msgLen, byte* sig, word32* sigLen, dilithium_key* key, const byte* seed); + """ + + if features["ML_DSA_VERIFY"]: + cdef += """ + int wc_dilithium_verify_ctx_msg(const byte* sig, word32 sigLen, const byte* ctx, byte ctxLen, const byte* msg, word32 msgLen, int* res, dilithium_key* key); + """ + if features["ML_DSA_NO_CTX"]: + cdef += """ + int wc_dilithium_verify_msg(const byte* sig, word32 sigLen, const byte* msg, word32 msgLen, int* res, dilithium_key* key); + """ + return cdef def default_features(): @@ -1699,6 +1757,11 @@ def default_features(): "ML_KEM_MAKE_KEY": 1, "ML_KEM_ENCAPSULATE": 1, "ML_KEM_DECAPSULATE": 1, + "ML_DSA_MAKE_KEY": 1, + "ML_DSA_SIGN": 1, + "ML_DSA_VERIFY": 1, + "ML_DSA_PUBLIC_KEY": 1, + "ML_DSA_PRIVATE_KEY": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index afca5c6..dbbc71f 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -75,6 +75,11 @@ "ML_KEM_MAKE_KEY": "ML_KEM", "ML_KEM_ENCAPSULATE": "ML_KEM", "ML_KEM_DECAPSULATE": "ML_KEM", + "ML_DSA_MAKE_KEY": "ML_DSA", + "ML_DSA_SIGN": "ML_DSA", + "ML_DSA_VERIFY": "ML_DSA", + "ML_DSA_PUBLIC_KEY": "ML_DSA", + "ML_DSA_PRIVATE_KEY": "ML_DSA", } @@ -632,3 +637,83 @@ def test_ml_kem_subsets_need_ml_kem(bf): for names in ML_KEM_OPS.values(): for name in names: assert name not in cdef, name + + +ML_DSA_SUBSETS = ("ML_DSA_MAKE_KEY", "ML_DSA_SIGN", "ML_DSA_VERIFY", "ML_DSA_PUBLIC_KEY", "ML_DSA_PRIVATE_KEY") +# Function -> sub-capabilities that must all be enabled for it. +ML_DSA_OPS = { + "wc_dilithium_make_key(": ("ML_DSA_MAKE_KEY",), + "wc_dilithium_make_key_from_seed(": ("ML_DSA_MAKE_KEY",), + "wc_dilithium_export_public(": ("ML_DSA_PUBLIC_KEY",), + "wc_dilithium_import_public(": ("ML_DSA_PUBLIC_KEY",), + "wc_MlDsaKey_GetPubLen(": ("ML_DSA_PUBLIC_KEY",), + "wc_dilithium_export_private(": ("ML_DSA_PRIVATE_KEY",), + "wc_dilithium_import_private(": ("ML_DSA_PRIVATE_KEY",), + "wc_MlDsaKey_GetPrivLen(": ("ML_DSA_PRIVATE_KEY", "ML_DSA_PUBLIC_KEY"), + "wc_dilithium_sign_ctx_msg(": ("ML_DSA_SIGN",), + "wc_dilithium_sign_ctx_msg_with_seed(": ("ML_DSA_SIGN",), + "wc_dilithium_verify_ctx_msg(": ("ML_DSA_VERIFY",), +} +ML_DSA_COMMON = ("dilithium_key;", "wc_dilithium_init_ex(", "wc_dilithium_set_level(", "wc_dilithium_free(", + "DILITHIUM_SEED_SZ;", "WC_ML_DSA_44;") +MLDSA_VERIFY_ONLY = ("ML_DSA_MAKE_KEY", "ML_DSA_SIGN", "ML_DSA_PRIVATE_KEY") + + +@pytest.mark.parametrize(("defines", "disabled"), [ + ((), ()), + (("#define WOLFSSL_MLDSA_VERIFY_ONLY",), MLDSA_VERIFY_ONLY), + ((" #define WOLFSSL_MLDSA_VERIFY_ONLY 1",), MLDSA_VERIFY_ONLY), + (("#define WOLFSSL_MLDSA_VERIFY_ONLY", "#define WOLFSSL_MLDSA_NO_MAKE_KEY", "#define WOLFSSL_MLDSA_NO_SIGN"), + MLDSA_VERIFY_ONLY), + (("#define WOLFSSL_MLDSA_NO_MAKE_KEY", "#define WOLFSSL_MLDSA_NO_SIGN"), MLDSA_VERIFY_ONLY), + (("#define WOLFSSL_DILITHIUM_VERIFY_ONLY",), MLDSA_VERIFY_ONLY), + (("#define WOLFSSL_MLDSA_NO_MAKE_KEY",), ("ML_DSA_MAKE_KEY",)), + (("#define WOLFSSL_MLDSA_NO_SIGN",), ("ML_DSA_SIGN",)), + (("#define WOLFSSL_DILITHIUM_NO_SIGN",), ("ML_DSA_SIGN",)), + (("#define WOLFSSL_MLDSA_NO_VERIFY",), ("ML_DSA_VERIFY",)), + (("#define WOLFSSL_DILITHIUM_NO_VERIFY",), ("ML_DSA_VERIFY",)), + (("#define WOLFSSL_MLDSA_NO_MAKE_KEY", "#define WOLFSSL_MLDSA_NO_VERIFY"), + ("ML_DSA_MAKE_KEY", "ML_DSA_VERIFY", "ML_DSA_PUBLIC_KEY")), + (("#define WOLFSSL_MLDSA_NO_MAKE_KEY", "#define WOLFSSL_MLDSA_NO_VERIFY", "#define WOLFSSL_MLDSA_PUBLIC_KEY"), + ("ML_DSA_MAKE_KEY", "ML_DSA_VERIFY")), + (("#define WOLFSSL_MLDSA_VERIFY_ONLY", "#define WOLFSSL_DILITHIUM_PRIVATE_KEY"), + ("ML_DSA_MAKE_KEY", "ML_DSA_SIGN")), +], ids=["default", "verify-only", "verify-only-indented", "verify-only-configure", "verify-only-derived", + "legacy-verify-only", "no-make-key", "no-sign", "legacy-no-sign", "no-verify", "legacy-no-verify", + "sign-only", "sign-only-public-key", "verify-only-private-key"]) +@pytest.mark.parametrize("parent", ["#define WOLFSSL_HAVE_MLDSA", "#define HAVE_DILITHIUM"]) +def test_ml_dsa_operations_follow_subset_macros(bf, parent, defines, disabled): + features = detect(bf, parent, *defines) + assert features["ML_DSA"] == 1 + for name in ML_DSA_SUBSETS: + assert features[name] == (name not in disabled), name + cdef = cdef_for(bf, features) + for name, needs in ML_DSA_OPS.items(): + assert (name in cdef) == all(features[n] for n in needs), name + assert ("wc_MlDsaKey_GetSigLen(" in cdef) == bool(features["ML_DSA_SIGN"] or features["ML_DSA_VERIFY"]) + for name in ML_DSA_COMMON: + assert name in cdef, name + + +@pytest.mark.parametrize(("defines", "sign", "verify"), [ + ((), True, True), + (("#define WOLFSSL_MLDSA_VERIFY_ONLY",), False, True), + (("#define WOLFSSL_MLDSA_NO_VERIFY",), True, False), +], ids=["default", "verify-only", "no-verify"]) +def test_ml_dsa_no_ctx_operations_follow_subset_macros(bf, defines, sign, verify): + features = detect(bf, "#define WOLFSSL_MLDSA_NO_CTX", "#define WOLFSSL_HAVE_MLDSA", *defines) + assert features["ML_DSA_NO_CTX"] == 1 + cdef = cdef_for(bf, features) + assert ("wc_dilithium_sign_msg(" in cdef) == sign + assert ("wc_dilithium_sign_msg_with_seed(" in cdef) == sign + assert ("wc_dilithium_verify_msg(" in cdef) == verify + + +def test_ml_dsa_subsets_need_ml_dsa(bf): + features = detect(bf) + assert features["ML_DSA"] == 0 + for name in ML_DSA_SUBSETS: + assert features[name] == 0, name + cdef = cdef_for(bf, features) + for name in (*ML_DSA_OPS, "wc_MlDsaKey_GetSigLen("): + assert name not in cdef, name diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 7782fd8..dc337bf 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -1601,3 +1601,53 @@ def test_ml_kem_methods_defined_only_when_enabled(monkeypatch): assert hasattr(getattr(module, cls), name) == bool(getattr(_lib, flag)), (disabled, cls, name) for cls, name in ML_KEM_COMMON_METHODS: assert hasattr(getattr(module, cls), name), (disabled, cls, name) + +if _lib.ML_DSA_ENABLED: + # Method -> whether the wolfSSL operations it needs are compiled in. + ML_DSA_GATED_METHODS = { + ("MlDsaPublic", "key_size"): lambda: _lib.ML_DSA_PUBLIC_KEY_ENABLED, + ("MlDsaPublic", "decode_key"): lambda: _lib.ML_DSA_PUBLIC_KEY_ENABLED, + ("MlDsaPublic", "encode_key"): lambda: _lib.ML_DSA_PUBLIC_KEY_ENABLED, + ("MlDsaPublic", "sig_size"): lambda: _lib.ML_DSA_SIGN_ENABLED or _lib.ML_DSA_VERIFY_ENABLED, + ("MlDsaPublic", "verify"): lambda: _lib.ML_DSA_VERIFY_ENABLED, + ("MlDsaPrivate", "make_key"): lambda: _lib.ML_DSA_MAKE_KEY_ENABLED, + ("MlDsaPrivate", "make_key_from_seed"): lambda: _lib.ML_DSA_MAKE_KEY_ENABLED, + ("MlDsaPrivate", "pub_key_size"): lambda: _lib.ML_DSA_PUBLIC_KEY_ENABLED, + ("MlDsaPrivate", "encode_pub_key"): lambda: _lib.ML_DSA_PUBLIC_KEY_ENABLED, + ("MlDsaPrivate", "priv_key_size"): lambda: _lib.ML_DSA_PRIVATE_KEY_ENABLED and _lib.ML_DSA_PUBLIC_KEY_ENABLED, + ("MlDsaPrivate", "encode_priv_key"): lambda: _lib.ML_DSA_PRIVATE_KEY_ENABLED and _lib.ML_DSA_PUBLIC_KEY_ENABLED, + ("MlDsaPrivate", "decode_key"): lambda: _lib.ML_DSA_PRIVATE_KEY_ENABLED, + ("MlDsaPrivate", "sig_size"): lambda: _lib.ML_DSA_SIGN_ENABLED or _lib.ML_DSA_VERIFY_ENABLED, + ("MlDsaPrivate", "sign"): lambda: _lib.ML_DSA_SIGN_ENABLED, + ("MlDsaPrivate", "sign_with_seed"): lambda: _lib.ML_DSA_SIGN_ENABLED, + ("MlDsaPrivate", "verify"): lambda: _lib.ML_DSA_VERIFY_ENABLED, + } + + @pytest.mark.parametrize("disabled", [ + (), + ("ML_DSA_MAKE_KEY_ENABLED",), + ("ML_DSA_SIGN_ENABLED",), + ("ML_DSA_VERIFY_ENABLED",), + ("ML_DSA_PUBLIC_KEY_ENABLED",), + ("ML_DSA_PRIVATE_KEY_ENABLED",), + ("ML_DSA_SIGN_ENABLED", "ML_DSA_VERIFY_ENABLED"), + ("ML_DSA_MAKE_KEY_ENABLED", "ML_DSA_SIGN_ENABLED", "ML_DSA_PRIVATE_KEY_ENABLED"), + ]) + def test_ml_dsa_methods_defined_only_when_enabled(monkeypatch, disabled): + """F-10071: each ML-DSA method needs its wolfSSL operations to be compiled in.""" + for flag in disabled: + monkeypatch.setattr(_lib, flag, 0) + # Load a fresh copy of the module as if the operations were not compiled in. + module = load_fresh_ciphers() + for (cls, name), enabled in ML_DSA_GATED_METHODS.items(): + assert hasattr(getattr(module, cls), name) == bool(enabled()), (disabled, cls, name) + + def test_ml_dsa_private_decode_needs_public_key_import(monkeypatch): + """F-10071: importing the public key part needs ML-DSA public key support.""" + if not _lib.ML_DSA_PRIVATE_KEY_ENABLED: + pytest.skip("ML-DSA private key support not enabled") + monkeypatch.setattr(_lib, "ML_DSA_PUBLIC_KEY_ENABLED", 0) + module = load_fresh_ciphers() + key = module.MlDsaPrivate(module.MlDsaType.ML_DSA_44) + with pytest.raises(NotImplementedError, match="ML-DSA public key import is not supported"): + key.decode_key(b"\x00" * 16, b"\x00" * 16) diff --git a/tests/test_mldsa.py b/tests/test_mldsa.py index 1ecac07..6163138 100644 --- a/tests/test_mldsa.py +++ b/tests/test_mldsa.py @@ -41,6 +41,15 @@ def rng(): def mldsa_type(request): return request.param + needs_make_key = pytest.mark.skipif(not _lib.ML_DSA_MAKE_KEY_ENABLED, + reason="ML-DSA key generation not enabled") + needs_sign = pytest.mark.skipif(not _lib.ML_DSA_SIGN_ENABLED, reason="ML-DSA signing not enabled") + needs_verify = pytest.mark.skipif(not _lib.ML_DSA_VERIFY_ENABLED, reason="ML-DSA verification not enabled") + needs_sizes = pytest.mark.skipif( + not (_lib.ML_DSA_PUBLIC_KEY_ENABLED and _lib.ML_DSA_PRIVATE_KEY_ENABLED + and (_lib.ML_DSA_SIGN_ENABLED or _lib.ML_DSA_VERIFY_ENABLED)), + reason="ML-DSA key or signature sizes not available") + def test_init_base(mldsa_type): mldsa_priv = MlDsaPrivate(mldsa_type) assert isinstance(mldsa_priv, MlDsaPrivate) @@ -48,6 +57,7 @@ def test_init_base(mldsa_type): mldsa_pub = MlDsaPublic(mldsa_type) assert isinstance(mldsa_pub, MlDsaPublic) + @needs_sizes def test_size_properties(mldsa_type): refvals = { MlDsaType.ML_DSA_44: { @@ -76,6 +86,7 @@ def test_size_properties(mldsa_type): assert mldsa_priv.pub_key_size == refvals[mldsa_type]["pub_key_size"] assert mldsa_priv.priv_key_size == refvals[mldsa_type]["priv_key_size"] + @needs_make_key def test_initializations(mldsa_type, rng): mldsa_priv = MlDsaPrivate.make_key(mldsa_type, rng) assert type(mldsa_priv) is MlDsaPrivate @@ -86,6 +97,7 @@ def test_initializations(mldsa_type, rng): mldsa_pub = MlDsaPublic(mldsa_type) assert type(mldsa_pub) is MlDsaPublic + @needs_make_key def test_key_import_export(mldsa_type, rng): # Generate key pair and export keys mldsa_priv = MlDsaPrivate.make_key(mldsa_type, rng) @@ -114,6 +126,9 @@ def test_key_import_export(mldsa_type, rng): pub_key3 = mldsa_pub.encode_key() assert pub_key == pub_key3 + @needs_make_key + @needs_sign + @needs_verify def test_sign_verify(mldsa_type, rng): # Generate a key pair and export public key mldsa_priv = MlDsaPrivate.make_key(mldsa_type, rng) @@ -178,6 +193,9 @@ def test_sign_verify(mldsa_type, rng): assert mldsa_pub.verify(signature, message, ctx=b"") @pytest.mark.skipif(not _lib.ML_DSA_NO_CTX_ENABLED, reason="Requires support for signing without context") + @needs_make_key + @needs_sign + @needs_verify def test_sign_with_seed(mldsa_type, rng): signature_seed = rng.bytes(ML_DSA_SIGNATURE_SEED_LENGTH) mldsa_priv = MlDsaPrivate.make_key(mldsa_type, rng) @@ -207,6 +225,8 @@ def test_sign_with_seed(mldsa_type, rng): with pytest.raises(TypeError): _ = mldsa_priv.sign_with_seed(message, "") # ty: ignore[invalid-argument-type] + @needs_make_key + @needs_sign def test_sign_with_seed_and_context(mldsa_type, rng): signature_seed = rng.bytes(ML_DSA_SIGNATURE_SEED_LENGTH) mldsa_priv = MlDsaPrivate.make_key(mldsa_type, rng) @@ -228,6 +248,8 @@ def test_sign_with_seed_and_context(mldsa_type, rng): signature_from_same_seed = mldsa_priv.sign_with_seed(message, signature_seed, ctx=context) assert signature == signature_from_same_seed + @needs_make_key + @needs_sign @pytest.mark.parametrize("seed", [0, "seed"]) def test_sign_with_seed_bad_type(mldsa_type, rng, seed: int | str): mldsa_priv = MlDsaPrivate.make_key(mldsa_type, rng) @@ -236,10 +258,12 @@ def test_sign_with_seed_bad_type(mldsa_type, rng, seed: int | str): with pytest.raises(TypeError): mldsa_priv.sign_with_seed(message, seed, ctx=context) + @needs_make_key def test_make_key_from_seed(mldsa_type): seed = bytes(MlDsaPrivate.ML_DSA_KEYGEN_SEED_LENGTH) assert MlDsaPrivate.make_key_from_seed(mldsa_type, seed) + @needs_make_key @pytest.mark.parametrize( "seed_length", [MlDsaPrivate.ML_DSA_KEYGEN_SEED_LENGTH - 1, MlDsaPrivate.ML_DSA_KEYGEN_SEED_LENGTH + 1] ) @@ -248,6 +272,7 @@ def test_make_key_from_seed_bad_length(mldsa_type, seed_length): with pytest.raises(ValueError): MlDsaPrivate.make_key_from_seed(mldsa_type, seed) + @needs_make_key @pytest.mark.parametrize("seed", [0, "seed"]) def test_make_key_from_seed_bad_type(mldsa_type, seed: int | str): with pytest.raises(TypeError): diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index a75c2ac..e1f1cac 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -59,7 +59,12 @@ HMAC_ENABLED: int KEYGEN_ENABLED: int HKDF_ENABLED: int ML_DSA_ENABLED: int +ML_DSA_MAKE_KEY_ENABLED: int ML_DSA_NO_CTX_ENABLED: int +ML_DSA_PRIVATE_KEY_ENABLED: int +ML_DSA_PUBLIC_KEY_ENABLED: int +ML_DSA_SIGN_ENABLED: int +ML_DSA_VERIFY_ENABLED: int ML_KEM_DECAPSULATE_ENABLED: int ML_KEM_ENABLED: int ML_KEM_ENCAPSULATE_ENABLED: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 5c3b00e..3d8b394 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -2373,375 +2373,387 @@ def __del__(self) -> None: if self._init_done: _lib.wc_dilithium_free(self.native_object) - @property - def _pub_key_size(self) -> int: - size = _ffi.new("int *") - ret = _lib.wc_MlDsaKey_GetPubLen(self.native_object, size) + if _lib.ML_DSA_PUBLIC_KEY_ENABLED: + @property + def _pub_key_size(self) -> int: + size = _ffi.new("int *") + ret = _lib.wc_MlDsaKey_GetPubLen(self.native_object, size) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_MlDsaKey_GetPubLen() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_MlDsaKey_GetPubLen() error", ret) - return size[0] + return size[0] - @property - def sig_size(self) -> int: - """ - :return: signature size in bytes - :rtype: int - """ - size = _ffi.new("int *") - ret = _lib.wc_MlDsaKey_GetSigLen(self.native_object, size) + def _decode_pub_key(self, pub_key: BytesOrStr) -> None: + pub_key_bytestype = t2b(pub_key) + ret = _lib.wc_dilithium_import_public( + pub_key_bytestype, + len(pub_key_bytestype), + self.native_object, + ) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_MlDsaKey_GetSigLen() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_import_public() error", ret) - return size[0] + def _encode_pub_key(self) -> bytes: + in_size = self._pub_key_size + pub_key = _ffi.new(f"byte[{in_size}]") + out_size = _ffi.new("word32 *") + out_size[0] = in_size + ret = _lib.wc_dilithium_export_public(self.native_object, pub_key, out_size) - def _decode_pub_key(self, pub_key: BytesOrStr) -> None: - pub_key_bytestype = t2b(pub_key) - ret = _lib.wc_dilithium_import_public( - pub_key_bytestype, - len(pub_key_bytestype), - self.native_object, - ) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_export_public() error", ret) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_import_public() error", ret) + if in_size != out_size[0]: + raise WolfCryptError(f"{in_size=} and {out_size[0]=} don't match") - def _encode_pub_key(self) -> bytes: - in_size = self._pub_key_size - pub_key = _ffi.new(f"byte[{in_size}]") - out_size = _ffi.new("word32 *") - out_size[0] = in_size - ret = _lib.wc_dilithium_export_public(self.native_object, pub_key, out_size) + return _ffi.buffer(pub_key, out_size[0])[:] - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_export_public() error", ret) + if _lib.ML_DSA_SIGN_ENABLED or _lib.ML_DSA_VERIFY_ENABLED: + @property + def sig_size(self) -> int: + """ + :return: signature size in bytes + :rtype: int + """ + size = _ffi.new("int *") + ret = _lib.wc_MlDsaKey_GetSigLen(self.native_object, size) - if in_size != out_size[0]: - raise WolfCryptError(f"{in_size=} and {out_size[0]=} don't match") + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_MlDsaKey_GetSigLen() error", ret) - return _ffi.buffer(pub_key, out_size[0])[:] + return size[0] - def verify(self, signature: BytesOrStr, message: BytesOrStr, ctx: BytesOrStr | None = None) -> bool: - """ - :param signature: signature to be verified - :type signature: bytes or str - :param message: message to be verified - :type message: bytes or str - :param ctx: context, maximum 255 bytes (optional by default but that requires support for no-context - signing/verification compiled in; pass empty string "" for FIPS-204 empty-context verification). - :type ctx: bytes or str. None for no-context verification. - :return: True if the verification is successful, False otherwise - :rtype: bool - """ - if ctx is None and not _lib.ML_DSA_NO_CTX_ENABLED: - raise WolfCryptError("support for verifying without context is disabled") + if _lib.ML_DSA_VERIFY_ENABLED: + def verify(self, signature: BytesOrStr, message: BytesOrStr, ctx: BytesOrStr | None = None) -> bool: + """ + :param signature: signature to be verified + :type signature: bytes or str + :param message: message to be verified + :type message: bytes or str + :param ctx: context, maximum 255 bytes (optional by default but that requires support for no-context + signing/verification compiled in; pass empty string "" for FIPS-204 empty-context verification). + :type ctx: bytes or str. None for no-context verification. + :return: True if the verification is successful, False otherwise + :rtype: bool + """ + if ctx is None and not _lib.ML_DSA_NO_CTX_ENABLED: + raise WolfCryptError("support for verifying without context is disabled") - sig_bytestype = t2b(signature) - msg_bytestype = t2b(message) - res = _ffi.new("int *") + sig_bytestype = t2b(signature) + msg_bytestype = t2b(message) + res = _ffi.new("int *") - if ctx is not None: - ctx_bytestype = t2b(ctx) - if len(ctx_bytestype) > 255: - raise ValueError( - f"context length {len(ctx_bytestype)} too large: must be 255 or less" + if ctx is not None: + ctx_bytestype = t2b(ctx) + if len(ctx_bytestype) > 255: + raise ValueError( + f"context length {len(ctx_bytestype)} too large: must be 255 or less" + ) + ret = _lib.wc_dilithium_verify_ctx_msg( + sig_bytestype, + len(sig_bytestype), + ctx_bytestype, + len(ctx_bytestype), + msg_bytestype, + len(msg_bytestype), + res, + self.native_object, ) - ret = _lib.wc_dilithium_verify_ctx_msg( - sig_bytestype, - len(sig_bytestype), - ctx_bytestype, - len(ctx_bytestype), - msg_bytestype, - len(msg_bytestype), - res, - self.native_object, - ) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_verify_ctx_msg() error", ret) - else: - ret = _lib.wc_dilithium_verify_msg( - sig_bytestype, - len(sig_bytestype), - msg_bytestype, - len(msg_bytestype), - res, - self.native_object, - ) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_verify_msg() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_verify_ctx_msg() error", ret) + else: + ret = _lib.wc_dilithium_verify_msg( + sig_bytestype, + len(sig_bytestype), + msg_bytestype, + len(msg_bytestype), + res, + self.native_object, + ) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_verify_msg() error", ret) - return res[0] == 1 + return res[0] == 1 class MlDsaPrivate(_MlDsaBase): - @classmethod - def make_key(cls, mldsa_type: MlDsaType, rng: Random | None = None) -> MlDsaPrivate: - """ - :param mldsa_type: ML-DSA type - :type mldsa_type: MlDsaType - :param rng: random number generator for a key generation - :type rng: Random - :return: `MlDsaPrivate` object - :rtype: MlDsaPrivate - """ - if rng is None: - rng = Random() - mldsa_priv = cls(mldsa_type) - ret = _lib.wc_dilithium_make_key( - mldsa_priv.native_object, rng.native_object - ) - - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_make_key() error", ret) - - # Retain RNG reference defensively. - mldsa_priv._rng = rng - - return mldsa_priv + if _lib.ML_DSA_MAKE_KEY_ENABLED: + @classmethod + def make_key(cls, mldsa_type: MlDsaType, rng: Random | None = None) -> MlDsaPrivate: + """ + :param mldsa_type: ML-DSA type + :type mldsa_type: MlDsaType + :param rng: random number generator for a key generation + :type rng: Random + :return: `MlDsaPrivate` object + :rtype: MlDsaPrivate + """ + if rng is None: + rng = Random() + mldsa_priv = cls(mldsa_type) + ret = _lib.wc_dilithium_make_key( + mldsa_priv.native_object, rng.native_object + ) - @classmethod - def make_key_from_seed(cls, mldsa_type: MlDsaType, seed: bytes) -> MlDsaPrivate: - """ - Deterministically generate the key from a seed. + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_make_key() error", ret) - :param mldsa_type: ML-DSA type - :type mldsa_type: MlDsaType - :param seed: the (32 byte) seed from which to deterministically create the key - :type seed: bytes - """ - mldsa_priv = cls(mldsa_type) + # Retain RNG reference defensively. + mldsa_priv._rng = rng - try: - memoryview(seed) - except TypeError as exception: - raise TypeError("seed must support the buffer protocol, such as `bytes` or `bytearray`") from exception + return mldsa_priv - seed = bytes(seed) + @classmethod + def make_key_from_seed(cls, mldsa_type: MlDsaType, seed: bytes) -> MlDsaPrivate: + """ + Deterministically generate the key from a seed. - if len(seed) != cls.ML_DSA_KEYGEN_SEED_LENGTH: - raise ValueError(f"Seed for generating ML-DSA key must be {cls.ML_DSA_KEYGEN_SEED_LENGTH} bytes") + :param mldsa_type: ML-DSA type + :type mldsa_type: MlDsaType + :param seed: the (32 byte) seed from which to deterministically create the key + :type seed: bytes + """ + mldsa_priv = cls(mldsa_type) - ret = _lib.wc_dilithium_make_key_from_seed(mldsa_priv.native_object, seed) + try: + memoryview(seed) + except TypeError as exception: + raise TypeError("seed must support the buffer protocol, such as `bytes` or `bytearray`") from exception - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_make_key_from_seed() error", ret) + seed = bytes(seed) - return mldsa_priv + if len(seed) != cls.ML_DSA_KEYGEN_SEED_LENGTH: + raise ValueError(f"Seed for generating ML-DSA key must be {cls.ML_DSA_KEYGEN_SEED_LENGTH} bytes") - @property - def pub_key_size(self) -> int: - """ - :return: public key size in bytes - :rtype: int - """ - return self._pub_key_size + ret = _lib.wc_dilithium_make_key_from_seed(mldsa_priv.native_object, seed) - @property - def priv_key_size(self) -> int: - """ - :return: private key size in bytes - :rtype: int - """ - size = _ffi.new("int *") - ret = _lib.wc_MlDsaKey_GetPrivLen(self.native_object, size) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_make_key_from_seed() error", ret) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_MlDsaKey_GetPrivLen() error", ret) + return mldsa_priv - return size[0] - self.pub_key_size + if _lib.ML_DSA_PUBLIC_KEY_ENABLED: + @property + def pub_key_size(self) -> int: + """ + :return: public key size in bytes + :rtype: int + """ + return self._pub_key_size - def encode_pub_key(self) -> bytes: - """ - :return: exported public key - :rtype: bytes - """ - return self._encode_pub_key() + def encode_pub_key(self) -> bytes: + """ + :return: exported public key + :rtype: bytes + """ + return self._encode_pub_key() - def encode_priv_key(self) -> bytes: - """ - :return: exported private key - :rtype: bytes - """ - in_size = self.priv_key_size - priv_key = _ffi.new(f"byte[{in_size}]") - out_size = _ffi.new("word32 *") - out_size[0] = in_size - ret = _lib.wc_dilithium_export_private( - self.native_object, priv_key, out_size - ) + if _lib.ML_DSA_PRIVATE_KEY_ENABLED and _lib.ML_DSA_PUBLIC_KEY_ENABLED: + @property + def priv_key_size(self) -> int: + """ + :return: private key size in bytes + :rtype: int + """ + size = _ffi.new("int *") + ret = _lib.wc_MlDsaKey_GetPrivLen(self.native_object, size) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_export_private() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_MlDsaKey_GetPrivLen() error", ret) - if in_size != out_size[0]: - raise WolfCryptError(f"{in_size=} and {out_size[0]=} don't match") + return size[0] - self.pub_key_size - return _ffi.buffer(priv_key, out_size[0])[:] + def encode_priv_key(self) -> bytes: + """ + :return: exported private key + :rtype: bytes + """ + in_size = self.priv_key_size + priv_key = _ffi.new(f"byte[{in_size}]") + out_size = _ffi.new("word32 *") + out_size[0] = in_size + ret = _lib.wc_dilithium_export_private( + self.native_object, priv_key, out_size + ) - def decode_key(self, priv_key: BytesOrStr, pub_key: BytesOrStr | None = None) -> None: - """ - :param priv_key: private key to be imported - :type priv_key: bytes or str - :param pub_key: public key to be imported - :type pub_key: bytes or str or None - """ - priv_key_bytestype = t2b(priv_key) - ret = _lib.wc_dilithium_import_private( - priv_key_bytestype, - len(priv_key_bytestype), - self.native_object, - ) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_export_private() error", ret) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_import_private() error", ret) + if in_size != out_size[0]: + raise WolfCryptError(f"{in_size=} and {out_size[0]=} don't match") - if pub_key is not None: - self._decode_pub_key(pub_key) + return _ffi.buffer(priv_key, out_size[0])[:] - def sign(self, message: BytesOrStr, rng: Random | None = None, ctx: BytesOrStr | None = None) -> bytes: - """ - :param message: message to be signed - :type message: bytes or str - :param rng: random number generator for sign - :type rng: Random - :param ctx: context, maximum 255 bytes (optional by default but that requires support for no-context - signing/verification compiled in; pass empty string "" for FIPS-204 empty-context signing). - :type ctx: bytes or str. None for no-context signing. - :return: signature - :rtype: bytes - """ - if ctx is None and not _lib.ML_DSA_NO_CTX_ENABLED: - raise WolfCryptError("support for signing without context is disabled") + if _lib.ML_DSA_PRIVATE_KEY_ENABLED: + def decode_key(self, priv_key: BytesOrStr, pub_key: BytesOrStr | None = None) -> None: + """ + :param priv_key: private key to be imported + :type priv_key: bytes or str + :param pub_key: public key to be imported + :type pub_key: bytes or str or None + """ + if pub_key is not None and not _lib.ML_DSA_PUBLIC_KEY_ENABLED: + raise NotImplementedError("ML-DSA public key import is not supported by this wolfSSL build") - if rng is None: - rng = Random() - msg_bytestype = t2b(message) - in_size = self.sig_size - signature = _ffi.new(f"byte[{in_size}]") - out_size = _ffi.new("word32 *") - out_size[0] = in_size - - if ctx is not None: - ctx_bytestype = t2b(ctx) - if len(ctx_bytestype) > 255: - raise ValueError(f"context length {len(ctx_bytestype)} too large: must be 255 bytes or less") - ret = _lib.wc_dilithium_sign_ctx_msg( - ctx_bytestype, - len(ctx_bytestype), # length must be < 256 bytes - msg_bytestype, - len(msg_bytestype), - signature, - out_size, - self.native_object, - rng.native_object, - ) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_sign_ctx_msg() error", ret) - else: - ret = _lib.wc_dilithium_sign_msg( - msg_bytestype, - len(msg_bytestype), - signature, - out_size, + priv_key_bytestype = t2b(priv_key) + ret = _lib.wc_dilithium_import_private( + priv_key_bytestype, + len(priv_key_bytestype), self.native_object, - rng.native_object, ) + if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_sign_msg() error", ret) + raise WolfCryptApiError("wc_dilithium_import_private() error", ret) - if in_size != out_size[0]: - raise WolfCryptError(f"{in_size=} and {out_size[0]=} don't match") + if pub_key is not None: + self._decode_pub_key(pub_key) - return _ffi.buffer(signature, out_size[0])[:] + if _lib.ML_DSA_SIGN_ENABLED: + def sign(self, message: BytesOrStr, rng: Random | None = None, ctx: BytesOrStr | None = None) -> bytes: + """ + :param message: message to be signed + :type message: bytes or str + :param rng: random number generator for sign + :type rng: Random + :param ctx: context, maximum 255 bytes (optional by default but that requires support for no-context + signing/verification compiled in; pass empty string "" for FIPS-204 empty-context signing). + :type ctx: bytes or str. None for no-context signing. + :return: signature + :rtype: bytes + """ + if ctx is None and not _lib.ML_DSA_NO_CTX_ENABLED: + raise WolfCryptError("support for signing without context is disabled") - def sign_with_seed(self, message: BytesOrStr, seed: bytes, ctx: BytesOrStr | None = None) -> bytes: - """ - :param message: message to be signed - :type message: bytes or str - :param seed: 32-byte seed for deterministic signature generation. - :type seed: bytes - :param ctx: context, maximum 255 bytes (optional by default but that requires support for no-context - signing/verification compiled in; pass empty string "" for FIPS-204 empty-context signing). - :type ctx: bytes or str. None for no-context signing. - :return: signature - :rtype: bytes - """ - if ctx is None and not _lib.ML_DSA_NO_CTX_ENABLED: - raise WolfCryptError("support for signing without context is disabled") + if rng is None: + rng = Random() + msg_bytestype = t2b(message) + in_size = self.sig_size + signature = _ffi.new(f"byte[{in_size}]") + out_size = _ffi.new("word32 *") + out_size[0] = in_size + + if ctx is not None: + ctx_bytestype = t2b(ctx) + if len(ctx_bytestype) > 255: + raise ValueError(f"context length {len(ctx_bytestype)} too large: must be 255 bytes or less") + ret = _lib.wc_dilithium_sign_ctx_msg( + ctx_bytestype, + len(ctx_bytestype), # length must be < 256 bytes + msg_bytestype, + len(msg_bytestype), + signature, + out_size, + self.native_object, + rng.native_object, + ) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_sign_ctx_msg() error", ret) + else: + ret = _lib.wc_dilithium_sign_msg( + msg_bytestype, + len(msg_bytestype), + signature, + out_size, + self.native_object, + rng.native_object, + ) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_sign_msg() error", ret) - msg_bytestype = t2b(message) - in_size = self.sig_size - signature = _ffi.new(f"byte[{in_size}]") - out_size = _ffi.new("word32 *") - out_size[0] = in_size + if in_size != out_size[0]: + raise WolfCryptError(f"{in_size=} and {out_size[0]=} don't match") - try: - memoryview(seed) - except TypeError as exception: - raise TypeError("seed must support the buffer protocol, such as `bytes` or `bytearray`") from exception + return _ffi.buffer(signature, out_size[0])[:] - seed = bytes(seed) + def sign_with_seed(self, message: BytesOrStr, seed: bytes, ctx: BytesOrStr | None = None) -> bytes: + """ + :param message: message to be signed + :type message: bytes or str + :param seed: 32-byte seed for deterministic signature generation. + :type seed: bytes + :param ctx: context, maximum 255 bytes (optional by default but that requires support for no-context + signing/verification compiled in; pass empty string "" for FIPS-204 empty-context signing). + :type ctx: bytes or str. None for no-context signing. + :return: signature + :rtype: bytes + """ + if ctx is None and not _lib.ML_DSA_NO_CTX_ENABLED: + raise WolfCryptError("support for signing without context is disabled") - if len(seed) != ML_DSA_SIGNATURE_SEED_LENGTH: - raise ValueError(f"Seed for generating a signature must be {ML_DSA_SIGNATURE_SEED_LENGTH} bytes.") + msg_bytestype = t2b(message) + in_size = self.sig_size + signature = _ffi.new(f"byte[{in_size}]") + out_size = _ffi.new("word32 *") + out_size[0] = in_size - if ctx is not None: - ctx_bytestype = t2b(ctx) - if len(ctx_bytestype) > 255: - raise ValueError( - f"context length {len(ctx_bytestype)} too large: must be 255 or less" + try: + memoryview(seed) + except TypeError as exception: + raise TypeError("seed must support the buffer protocol, such as `bytes` or `bytearray`") from exception + + seed = bytes(seed) + + if len(seed) != ML_DSA_SIGNATURE_SEED_LENGTH: + raise ValueError(f"Seed for generating a signature must be {ML_DSA_SIGNATURE_SEED_LENGTH} bytes.") + + if ctx is not None: + ctx_bytestype = t2b(ctx) + if len(ctx_bytestype) > 255: + raise ValueError( + f"context length {len(ctx_bytestype)} too large: must be 255 or less" + ) + ret = _lib.wc_dilithium_sign_ctx_msg_with_seed( + ctx_bytestype, + len(ctx_bytestype), # length must be < 256 bytes + msg_bytestype, + len(msg_bytestype), + signature, + out_size, + self.native_object, + seed, ) - ret = _lib.wc_dilithium_sign_ctx_msg_with_seed( - ctx_bytestype, - len(ctx_bytestype), # length must be < 256 bytes - msg_bytestype, - len(msg_bytestype), - signature, - out_size, - self.native_object, - seed, - ) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_sign_ctx_msg_with_seed() error", ret) - else: - ret = _lib.wc_dilithium_sign_msg_with_seed( - msg_bytestype, - len(msg_bytestype), - signature, - out_size, - self.native_object, - seed, - ) - if ret < 0: # pragma: no cover - raise WolfCryptApiError("wc_dilithium_sign_msg_with_seed() error", ret) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_sign_ctx_msg_with_seed() error", ret) + else: + ret = _lib.wc_dilithium_sign_msg_with_seed( + msg_bytestype, + len(msg_bytestype), + signature, + out_size, + self.native_object, + seed, + ) + if ret < 0: # pragma: no cover + raise WolfCryptApiError("wc_dilithium_sign_msg_with_seed() error", ret) - if in_size != out_size[0]: - raise WolfCryptError(f"{in_size=} and {out_size[0]=} don't match") + if in_size != out_size[0]: + raise WolfCryptError(f"{in_size=} and {out_size[0]=} don't match") - return _ffi.buffer(signature, out_size[0])[:] + return _ffi.buffer(signature, out_size[0])[:] class MlDsaPublic(_MlDsaBase): - @property - def key_size(self) -> int: - """ - :return: public key size in bytes - :rtype: int - """ - return self._pub_key_size + if _lib.ML_DSA_PUBLIC_KEY_ENABLED: + @property + def key_size(self) -> int: + """ + :return: public key size in bytes + :rtype: int + """ + return self._pub_key_size - def decode_key(self, pub_key: BytesOrStr) -> None: - """ - :param pub_key: public key to be imported - :type pub_key: bytes or str - """ - self._decode_pub_key(pub_key) + def decode_key(self, pub_key: BytesOrStr) -> None: + """ + :param pub_key: public key to be imported + :type pub_key: bytes or str + """ + self._decode_pub_key(pub_key) - def encode_key(self) -> bytes: - """ - :return: exported public key - :rtype: bytes - """ - return self._encode_pub_key() + def encode_key(self) -> bytes: + """ + :return: exported public key + :rtype: bytes + """ + return self._encode_pub_key() From e0938336c14acb48cb1bb0ba96ced555bf179287 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Mon, 28 Sep 2026 16:06:41 +0000 Subject: [PATCH 20/26] Detect ML-DSA no-context macros on the last line (F-13023) The no-context ML-DSA macros were matched with a regex that required whitespace after the name, so a macro on the last line of the parsed options.h or user_settings.h was missed and ML_DSA_NO_CTX stayed 0. Use the defined() helper, which ends the name at a word boundary. --- scripts/build_ffi.py | 3 +-- tests/test_build_ffi.py | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index d3c2f6c..6e3a13a 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -400,8 +400,7 @@ def defined(name): "WOLFSSL_DILITHIUM_NO_CTX", "WOLFSSL_DILITHIUM_FIPS204_DRAFT", ] - have_mldsa_no_context_support = re.search(r'#define\s+(' + '|'.join(mldsa_no_context_defines) + r')\s+', '\n'.join(defines)) - features["ML_DSA_NO_CTX"] = 1 if have_mldsa_no_context_support else 0 + features["ML_DSA_NO_CTX"] = 1 if any(defined(name) for name in mldsa_no_context_defines) else 0 features["ML_KEM"] = 1 if '#define WOLFSSL_HAVE_MLKEM' in defines else 0 # hmac.h and hmac.c provide HKDF only without NO_HMAC. features["HKDF"] = 1 if "#define HAVE_HKDF" in defines and features["HMAC"] else 0 diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index dbbc71f..a0ce0e1 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -709,6 +709,14 @@ def test_ml_dsa_no_ctx_operations_follow_subset_macros(bf, defines, sign, verify assert ("wc_dilithium_verify_msg(" in cdef) == verify +@pytest.mark.parametrize("name", ["WOLFSSL_MLDSA_NO_CTX", "WOLFSSL_MLDSA_FIPS204_DRAFT", + "WOLFSSL_DILITHIUM_NO_CTX", "WOLFSSL_DILITHIUM_FIPS204_DRAFT"]) +def test_ml_dsa_no_ctx_macro_on_last_line(bf, name): + """F-13023: the no-context macro is found when nothing follows it.""" + assert detect(bf, "#define WOLFSSL_HAVE_MLDSA", f"#define {name}")["ML_DSA_NO_CTX"] == 1 + assert detect(bf, "#define WOLFSSL_HAVE_MLDSA", f"#define {name}_X")["ML_DSA_NO_CTX"] == 0 + + def test_ml_dsa_subsets_need_ml_dsa(bf): features = detect(bf) assert features["ML_DSA"] == 0 From c9da5a678097ca4a0e7f2ea7dffd4aef4ccfc9e5 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Mon, 28 Sep 2026 16:08:28 +0000 Subject: [PATCH 21/26] Detect feature macros regardless of indentation or value (F-8281) detect_features() found most features by comparing whole lines with '#define NAME', so a define that was indented, had a value or had a trailing comment was missed. For WC_RSA_BLINDING and ECC_TIMING_RESISTANT, the RSA keys and EccPrivate.make_key() then did not call wc_RsaSetRNG() and wc_ecc_set_rng(). Use the defined() helper for all of them. The bundled options.h and both Windows user_settings.h files give the same features as before. --- scripts/build_ffi.py | 60 ++++++++++++++++++++--------------------- tests/test_build_ffi.py | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 30 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 6e3a13a..9b08f9e 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -363,35 +363,35 @@ def detect_features(defines, features, fips=False): def defined(name): return re.search(rf"^\s*#\s*define\s+{name}\b", text, re.MULTILINE) is not None - features["MPAPI"] = 1 if '#define WOLFSSL_PUBLIC_MP' in defines else 0 - features["SHA"] = 0 if '#define NO_SHA' in defines else 1 - features["SHA256"] = 0 if '#define NO_SHA256' in defines else 1 - features["SHA384"] = 1 if '#define WOLFSSL_SHA384' in defines else 0 - features["SHA512"] = 1 if '#define WOLFSSL_SHA512' in defines else 0 - features["SHA3"] = 1 if '#define WOLFSSL_SHA3' in defines else 0 - features["DES3"] = 0 if '#define NO_DES3' in defines else 1 - features["AES"] = 0 if '#define NO_AES' in defines else 1 - features["AES_SIV"] = 1 if '#define WOLFSSL_AES_SIV' in defines else 0 - features["CHACHA"] = 1 if '#define HAVE_CHACHA' in defines else 0 - features["HMAC"] = 0 if '#define NO_HMAC' in defines else 1 - features["RSA"] = 0 if '#define NO_RSA' in defines else 1 - features["ECC_TIMING_RESISTANCE"] = 1 if '#define ECC_TIMING_RESISTANT' in defines else 0 - features["RSA_BLINDING"] = 1 if '#define WC_RSA_BLINDING' in defines else 0 - features["ECC"] = 1 if '#define HAVE_ECC' in defines else 0 - features["ED25519"] = 1 if '#define HAVE_ED25519' in defines else 0 - features["ED448"] = 1 if '#define HAVE_ED448' in defines else 0 - features["KEYGEN"] = 1 if '#define WOLFSSL_KEY_GEN' in defines else 0 - features["PWDBASED"] = 0 if '#define NO_PWDBASED' in defines else 1 - features["ERROR_STRINGS"] = 0 if '#define NO_ERROR_STRINGS' in defines else 1 - features["ASN"] = 0 if '#define NO_ASN' in defines else 1 - features["WC_RNG_SEED_CB"] = 1 if '#define WC_RNG_SEED_CB' in defines else 0 - features["AESGCM_STREAM"] = 1 if '#define WOLFSSL_AESGCM_STREAM' in defines else 0 + features["MPAPI"] = 1 if defined("WOLFSSL_PUBLIC_MP") else 0 + features["SHA"] = 0 if defined("NO_SHA") else 1 + features["SHA256"] = 0 if defined("NO_SHA256") else 1 + features["SHA384"] = 1 if defined("WOLFSSL_SHA384") else 0 + features["SHA512"] = 1 if defined("WOLFSSL_SHA512") else 0 + features["SHA3"] = 1 if defined("WOLFSSL_SHA3") else 0 + features["DES3"] = 0 if defined("NO_DES3") else 1 + features["AES"] = 0 if defined("NO_AES") else 1 + features["AES_SIV"] = 1 if defined("WOLFSSL_AES_SIV") else 0 + features["CHACHA"] = 1 if defined("HAVE_CHACHA") else 0 + features["HMAC"] = 0 if defined("NO_HMAC") else 1 + features["RSA"] = 0 if defined("NO_RSA") else 1 + features["ECC_TIMING_RESISTANCE"] = 1 if defined("ECC_TIMING_RESISTANT") else 0 + features["RSA_BLINDING"] = 1 if defined("WC_RSA_BLINDING") else 0 + features["ECC"] = 1 if defined("HAVE_ECC") else 0 + features["ED25519"] = 1 if defined("HAVE_ED25519") else 0 + features["ED448"] = 1 if defined("HAVE_ED448") else 0 + features["KEYGEN"] = 1 if defined("WOLFSSL_KEY_GEN") else 0 + features["PWDBASED"] = 0 if defined("NO_PWDBASED") else 1 + features["ERROR_STRINGS"] = 0 if defined("NO_ERROR_STRINGS") else 1 + features["ASN"] = 0 if defined("NO_ASN") else 1 + features["WC_RNG_SEED_CB"] = 1 if defined("WC_RNG_SEED_CB") else 0 + features["AESGCM_STREAM"] = 1 if defined("WOLFSSL_AESGCM_STREAM") else 0 # Try to read minimum AESGCM authentication tag size from settings, else use default. min_auth_tag_sz = re.search(r'#define\s+WOLFSSL_MIN_AUTH_TAG_SZ\s+(\d+)', '\n'.join(defines)) features["MIN_AUTH_TAG_SZ"] = int(min_auth_tag_sz.group(1)) if min_auth_tag_sz else 12 - features["RSA_PSS"] = 1 if '#define WC_RSA_PSS' in defines else 0 - features["CHACHA20_POLY1305"] = 1 if ('#define HAVE_CHACHA' in defines and '#define HAVE_POLY1305' in defines) else 0 - features["ML_DSA"] = 1 if ('#define HAVE_DILITHIUM' in defines or '#define WOLFSSL_HAVE_MLDSA' in defines) else 0 + features["RSA_PSS"] = 1 if defined("WC_RSA_PSS") else 0 + features["CHACHA20_POLY1305"] = 1 if (defined("HAVE_CHACHA") and defined("HAVE_POLY1305")) else 0 + features["ML_DSA"] = 1 if (defined("HAVE_DILITHIUM") or defined("WOLFSSL_HAVE_MLDSA")) else 0 # Determine if support for ML-DSA signing & verification without context has been enabled. mldsa_no_context_defines = [ "WOLFSSL_MLDSA_NO_CTX", @@ -401,12 +401,12 @@ def defined(name): "WOLFSSL_DILITHIUM_FIPS204_DRAFT", ] features["ML_DSA_NO_CTX"] = 1 if any(defined(name) for name in mldsa_no_context_defines) else 0 - features["ML_KEM"] = 1 if '#define WOLFSSL_HAVE_MLKEM' in defines else 0 + features["ML_KEM"] = 1 if defined("WOLFSSL_HAVE_MLKEM") else 0 # hmac.h and hmac.c provide HKDF only without NO_HMAC. - features["HKDF"] = 1 if "#define HAVE_HKDF" in defines and features["HMAC"] else 0 + features["HKDF"] = 1 if defined("HAVE_HKDF") and features["HMAC"] else 0 # Unlike the other fatures, HASHDRBG is enabled by default in random.h, unless WC_NO_HASHDRBG or # CUSTOM_RAND_GENERATE_BLOCK is defined. - features["HASHDRBG"] = 0 if ("#define WC_NO_HASHDRBG" in defines or "#define CUSTOM_RAND_GENERATE_BLOCK" in defines) else 1 + features["HASHDRBG"] = 0 if (defined("WC_NO_HASHDRBG") or defined("CUSTOM_RAND_GENERATE_BLOCK")) else 1 # random.h replaces the RNG API with macros under WC_NO_RNG. random.c and # rsa.c then build no DRBG, seed callback or RSA blinding functions. features["RNG"] = 0 if defined("WC_NO_RNG") else 1 @@ -495,7 +495,7 @@ def mldsa_defined(gate): features["ML_DSA_PRIVATE_KEY"] = 1 if ml_dsa and (not mldsa_no_make_key or not mldsa_no_sign or mldsa_defined("PRIVATE_KEY")) else 0 - if '#define HAVE_FIPS' in defines: + if defined("HAVE_FIPS"): if not fips: e = "fips.c empty but HAVE_FIPS defined." raise RuntimeError(e) diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index a0ce0e1..ac60912 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -132,6 +132,57 @@ def test_reference_configs_enable_all_subcapabilities(bf, config): assert features["RNG"] == 1 +# options.h macro -> (feature, its value when the macro is defined) +BASE_FEATURE_MACROS = { + "WOLFSSL_PUBLIC_MP": ("MPAPI", 1), + "NO_SHA": ("SHA", 0), + "NO_SHA256": ("SHA256", 0), + "WOLFSSL_SHA384": ("SHA384", 1), + "WOLFSSL_SHA512": ("SHA512", 1), + "WOLFSSL_SHA3": ("SHA3", 1), + "NO_DES3": ("DES3", 0), + "NO_AES": ("AES", 0), + "WOLFSSL_AES_SIV": ("AES_SIV", 1), + "HAVE_CHACHA": ("CHACHA", 1), + "NO_HMAC": ("HMAC", 0), + "NO_RSA": ("RSA", 0), + "ECC_TIMING_RESISTANT": ("ECC_TIMING_RESISTANCE", 1), + "WC_RSA_BLINDING": ("RSA_BLINDING", 1), + "HAVE_ECC": ("ECC", 1), + "HAVE_ED25519": ("ED25519", 1), + "HAVE_ED448": ("ED448", 1), + "WOLFSSL_KEY_GEN": ("KEYGEN", 1), + "NO_PWDBASED": ("PWDBASED", 0), + "NO_ERROR_STRINGS": ("ERROR_STRINGS", 0), + "NO_ASN": ("ASN", 0), + "WC_RNG_SEED_CB": ("WC_RNG_SEED_CB", 1), + "WOLFSSL_AESGCM_STREAM": ("AESGCM_STREAM", 1), + "WC_RSA_PSS": ("RSA_PSS", 1), + "HAVE_DILITHIUM": ("ML_DSA", 1), + "WOLFSSL_HAVE_MLDSA": ("ML_DSA", 1), + "WOLFSSL_HAVE_MLKEM": ("ML_KEM", 1), + "HAVE_HKDF": ("HKDF", 1), + "WC_NO_HASHDRBG": ("HASHDRBG", 0), + "CUSTOM_RAND_GENERATE_BLOCK": ("HASHDRBG", 0), +} + + +@pytest.mark.parametrize("form", ["#define {}", " # define {}", "#define {} 1", "#define {} /* on */"], + ids=["plain", "indented", "value", "comment"]) +def test_base_features_match_any_define_form(bf, form): + """F-8281: a feature macro counts whatever its indentation, value or comment.""" + for macro, (feature, value) in BASE_FEATURE_MACROS.items(): + assert detect(bf)[feature] == 1 - value, macro + assert detect(bf, form.format(macro))[feature] == value, macro + assert detect(bf, form.format(macro + "_X"))[feature] == 1 - value, macro + assert detect(bf, "/* " + form.format(macro) + " */")[feature] == 1 - value, macro + lines = (form.format("HAVE_CHACHA"), form.format("HAVE_POLY1305")) + assert detect(bf, *lines)["CHACHA20_POLY1305"] == 1 + features = detect(bf, form.format("HAVE_FIPS"), "#define HAVE_FIPS_VERSION 5", fips=True) + assert features["FIPS"] == 1 + assert features["FIPS_VERSION"] == 5 + + def test_detection_matches_indented_defines_with_values(bf): assert detect(bf, " # define WOLFSSL_AES_COUNTER 1 /* CTR */")["AES_CTR"] == 1 assert detect(bf, "/* #define WOLFSSL_AES_COUNTER */")["AES_CTR"] == 0 From 5415734ab47a336855579fec3f1a65eefbce9edd Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 21:16:21 +0000 Subject: [PATCH 22/26] Check the digest size in ECDSA sign() and verify() (F-8279) EccPrivate.sign() and EccPublic.verify() pass their input to wc_ecc_sign_hash() and wc_ecc_verify_hash(), which expect a message digest. Raise ValueError when the input is not the size of a SHA-1 or SHA-2 digest that this wolfSSL build accepts, as bounded by WC_MIN_DIGEST_SIZE and WC_MAX_DIGEST_SIZE. Callers keep passing the digest as before. build_ffi.py declares both bounds for every build, including ones without RSA. --- scripts/build_ffi.py | 5 ++++ tests/test_build_ffi.py | 10 +++++++ tests/test_ciphers.py | 64 ++++++++++++++++++++++++++++++++++++----- wolfcrypt/_ffi/lib.pyi | 3 ++ wolfcrypt/ciphers.py | 18 ++++++++++-- 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 9b08f9e..1652f19 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -1210,6 +1210,11 @@ def make_cdef(features): void wc_HmacFree(Hmac*); """ + cdef += """ + #define WC_MIN_DIGEST_SIZE ... + #define WC_MAX_DIGEST_SIZE ... + """ + if features["RSA"]: cdef += """ static const int WC_RSA_PKCSV15_PAD; diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index ac60912..8efb2c1 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -540,6 +540,16 @@ def test_ecc_subsets_need_ecc(bf): assert name not in cdef, name +def test_ecc_digest_bounds_without_rsa(bf): + """F-8279: ECDSA checks digest sizes against wolfSSL's bounds, also without RSA.""" + features = detect(bf, "#define HAVE_ECC", "#define NO_RSA") + assert features["ECC"] == 1 + assert features["RSA"] == 0 + cdef = cdef_for(bf, features) + for name in ("WC_MIN_DIGEST_SIZE", "WC_MAX_DIGEST_SIZE"): + assert name in cdef, name + + ED25519_SUBSETS = ("ED25519_MAKE_KEY", "ED25519_SIGN", "ED25519_VERIFY", "ED25519_KEY_IMPORT", "ED25519_KEY_EXPORT") ED25519_OPS = { diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index dc337bf..45c7448 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -21,6 +21,7 @@ # pylint: disable=redefined-outer-name # ty: ignore[possibly-missing-import] +import hashlib import importlib.util import os import random @@ -610,6 +611,7 @@ def test_rsa_pkcs8_sign_verify(rsa_private_pkcs8, rsa_public): needs_ecc_export = pytest.mark.skipif(not _lib.ECC_KEY_EXPORT_ENABLED, reason="ECC key export not enabled") needs_ecc_sign_verify = pytest.mark.skipif(not (_lib.ECC_SIGN_ENABLED and _lib.ECC_VERIFY_ENABLED), reason="ECC signing or verification not enabled") + needs_ecc_verify = pytest.mark.skipif(not _lib.ECC_VERIFY_ENABLED, reason="ECC verification not enabled") needs_ecc_dhe = pytest.mark.skipif(not _lib.ECC_DHE_ENABLED, reason="ECDH not enabled") @pytest.fixture @@ -833,29 +835,29 @@ def test_x963(ecc_private, ecc_public): @needs_ecc_export @needs_ecc_sign_verify def test_ecc_sign_verify(ecc_private, ecc_public): - plaintext = "Everyone gets Friday off." + digest = hashlib.sha256(b"Everyone gets Friday off.").digest() # normal usage, sign with private, verify with public - signature = ecc_private.sign(plaintext) + signature = ecc_private.sign(digest) assert len(signature) <= ecc_private.max_signature_size - assert ecc_public.verify(signature, plaintext) + assert ecc_public.verify(signature, digest) # invalid signature with pytest.raises(WolfCryptError): - ecc_public.verify(signature[:-1], plaintext) + ecc_public.verify(signature[:-1], digest) # private object holds both private and public info, so it can also verify # using the known public key. - assert ecc_private.verify(signature, plaintext) + assert ecc_private.verify(signature, digest) ecc_x963 = EccPublic() ecc_x963.import_x963(ecc_public.export_x963()) - assert ecc_x963.verify(signature, plaintext) + assert ecc_x963.verify(signature, digest) ecc_x963 = EccPublic() ecc_x963.import_x963(ecc_private.export_x963()) - assert ecc_x963.verify(signature, plaintext) + assert ecc_x963.verify(signature, digest) ecc_x963 = EccPublic() with pytest.raises(WolfCryptError): @@ -882,6 +884,54 @@ def test_ecc_sign_verify_raw(ecc_private, ecc_public): assert ecc_private.verify_raw(r, s, plaintext) + # Made with `openssl dgst -sha256 -sign` and the vectors[EccPrivate] key. + ECC_OPENSSL_MESSAGE = b"Everyone gets Friday off. This message is longer than one digest." + ECC_OPENSSL_SHA256_SIGNATURE = h2b( + "3045022100b2314357b468577038b0eb8fc7e051e40eb729e6e3319d5bfd3b4cbc78be73cb" + "022076c30495ee4e21edf2ac2086d4fb426e413ee26f009f09c3daa312ba7065c35f") + + + @needs_ecc_import + @needs_ecc_verify + def test_ecc_verify_openssl_signature(ecc_public): + """ + F-8279: verify() takes the digest of the message, as signed by + `openssl dgst -sha256 -sign`. + """ + digest = hashlib.sha256(ECC_OPENSSL_MESSAGE).digest() + other = hashlib.sha256(ECC_OPENSSL_MESSAGE[:-1]).digest() + assert ecc_public.verify(ECC_OPENSSL_SHA256_SIGNATURE, digest) + assert not ecc_public.verify(ECC_OPENSSL_SHA256_SIGNATURE, other) + + + @pytest.mark.parametrize("length", [0, 16, 25, 33, 65, 100]) + @needs_ecc_import + @needs_ecc_sign_verify + def test_ecc_sign_rejects_non_digest_length(ecc_private, ecc_public, length): + """ + F-8279: sign() and verify() take a digest, so an input that is not + the size of a SHA-1 or SHA-2 digest raises ValueError. + """ + signature = ecc_private.sign(hashlib.sha256(b"message").digest()) + with pytest.raises(ValueError, match="digest"): + ecc_private.sign(b"\x01" * length) + with pytest.raises(ValueError, match="digest"): + ecc_public.verify(signature, b"\x01" * length) + + + @pytest.mark.parametrize("hash_name", ["sha1", "sha224", "sha256", "sha384", "sha512"]) + @needs_ecc_import + @needs_ecc_sign_verify + def test_ecc_sign_digest_sizes(ecc_private, ecc_public, hash_name): + """ + F-8279: SHA-1 and SHA-2 digest sizes are accepted. + """ + digest = hashlib.new(hash_name, b"message").digest() + if not _lib.WC_MIN_DIGEST_SIZE <= len(digest) <= _lib.WC_MAX_DIGEST_SIZE: + pytest.skip("digest size not accepted by this wolfSSL build") + assert ecc_public.verify(ecc_private.sign(digest), digest) + + @needs_ecc_import @needs_ecc_export @needs_ecc_dhe diff --git a/wolfcrypt/_ffi/lib.pyi b/wolfcrypt/_ffi/lib.pyi index e1f1cac..67ec7c6 100644 --- a/wolfcrypt/_ffi/lib.pyi +++ b/wolfcrypt/_ffi/lib.pyi @@ -341,6 +341,9 @@ WC_HASH_TYPE_SHA3_512: int WC_HASH_TYPE_BLAKE2B: int WC_HASH_TYPE_BLAKE2S: int +WC_MIN_DIGEST_SIZE: int +WC_MAX_DIGEST_SIZE: int + WC_ML_KEM_512: int WC_ML_KEM_768: int WC_ML_KEM_1024: int diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 3d8b394..f78b804 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -1185,6 +1185,17 @@ def size(self) -> int: def max_signature_size(self) -> int: return _lib.wc_ecc_sig_size(self.native_object) + # SHA-1 and SHA-2 digest sizes that this wolfSSL build accepts. + _DIGEST_SIZES = tuple(size for size in (20, 28, 32, 48, 64) + if _lib.WC_MIN_DIGEST_SIZE <= size <= _lib.WC_MAX_DIGEST_SIZE) + + @classmethod + def _check_digest(cls, digest: BytesOrStr) -> bytes: + digest = t2b(digest) + if len(digest) not in cls._DIGEST_SIZES: + raise ValueError(f"digest must be one of {cls._DIGEST_SIZES} bytes, got {len(digest)}") + return digest + class EccPublic(_Ecc): def __init__(self, key: BytesOrStr | None = None) -> None: @@ -1307,10 +1318,11 @@ def export_x963(self) -> bytes: def verify(self, signature: bytes, data: BytesOrStr) -> bool: """ Verifies **signature**, using the public key data in the object. + **data** is the message digest, as in EccPrivate.sign(). Returns **True** in case of a valid signature, otherwise **False**. """ - data = t2b(data) + data = self._check_digest(data) status = _ffi.new("int[1]") ret = _lib.wc_ecc_verify_hash(signature, len(signature), @@ -1509,12 +1521,14 @@ def shared_secret(self, peer: EccPublic) -> bytes: def sign(self, plaintext: BytesOrStr, rng: Random | None = None) -> bytes: """ Signs **plaintext**, using the private key data in the object. + **plaintext** is the message digest: a SHA-1 or SHA-2 hash of + the message, computed by the caller. Returns the signature. """ if rng is None: rng = Random() - plaintext = t2b(plaintext) + plaintext = self._check_digest(plaintext) signature = _ffi.new(f"byte[{self.max_signature_size}]") signature_size = _ffi.new("word32[1]") From 7d88511542474f7df1e873a2ecd80f6f8e7bdc38 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 24 Sep 2026 21:26:20 +0000 Subject: [PATCH 23/26] Check the digest size in ECDSA sign_raw() and verify_raw() (F-8280) sign_raw() and verify_raw() pass their input to wc_ecc_sign_hash_ex() and wc_ecc_verify_hash_ex() as a digest. Raise ValueError when it is not the size of a SHA-1 or SHA-2 digest, as sign() and verify() do. --- tests/test_ciphers.py | 59 +++++++++++++++++++++++++++++++++++++++---- wolfcrypt/ciphers.py | 8 +++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 45c7448..36a9d7f 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -867,21 +867,21 @@ def test_ecc_sign_verify(ecc_private, ecc_public): @needs_ecc_import @needs_ecc_sign_verify def test_ecc_sign_verify_raw(ecc_private, ecc_public): - plaintext = "Everyone gets Friday off." + digest = hashlib.sha256(b"Everyone gets Friday off.").digest() # normal usage, sign with private, verify with public - r,s = ecc_private.sign_raw(plaintext) + r,s = ecc_private.sign_raw(digest) assert len(r) + len(s) <= 2 * ecc_private.size - assert ecc_public.verify_raw(r, s, plaintext) + assert ecc_public.verify_raw(r, s, digest) # invalid signature - ret = ecc_public.verify_raw(r, s[:-1], plaintext) + ret = ecc_public.verify_raw(r, s[:-1], digest) assert not ret # private object holds both private and public info, so it can also verify # using the known public key. - assert ecc_private.verify_raw(r, s, plaintext) + assert ecc_private.verify_raw(r, s, digest) # Made with `openssl dgst -sha256 -sign` and the vectors[EccPrivate] key. @@ -932,6 +932,55 @@ def test_ecc_sign_digest_sizes(ecc_private, ecc_public, hash_name): assert ecc_public.verify(ecc_private.sign(digest), digest) + if _lib.MPAPI_ENABLED: + # r and s of ECC_OPENSSL_SHA256_SIGNATURE. + ECC_OPENSSL_SHA256_R = h2b("b2314357b468577038b0eb8fc7e051e40eb729e6e3319d5bfd3b4cbc78be73cb") + ECC_OPENSSL_SHA256_S = h2b("76c30495ee4e21edf2ac2086d4fb426e413ee26f009f09c3daa312ba7065c35f") + + + @needs_ecc_import + @needs_ecc_verify + def test_ecc_verify_raw_openssl_signature(ecc_public): + """ + F-8280: verify_raw() takes the digest of the message, as signed by + `openssl dgst -sha256 -sign`. + """ + r, s = ECC_OPENSSL_SHA256_R, ECC_OPENSSL_SHA256_S + digest = hashlib.sha256(ECC_OPENSSL_MESSAGE).digest() + other = hashlib.sha256(ECC_OPENSSL_MESSAGE[:-1]).digest() + assert ecc_public.verify_raw(r, s, digest) + assert not ecc_public.verify_raw(r, s, other) + + + @pytest.mark.parametrize("length", [0, 16, 25, 33, 65, 100]) + @needs_ecc_import + @needs_ecc_sign_verify + def test_ecc_sign_raw_rejects_non_digest_length(ecc_private, ecc_public, length): + """ + F-8280: sign_raw() and verify_raw() take a digest, so an input that + is not the size of a SHA-1 or SHA-2 digest raises ValueError. + """ + r, s = ecc_private.sign_raw(hashlib.sha256(b"message").digest()) + with pytest.raises(ValueError, match="digest"): + ecc_private.sign_raw(b"\x01" * length) + with pytest.raises(ValueError, match="digest"): + ecc_public.verify_raw(r, s, b"\x01" * length) + + + @pytest.mark.parametrize("hash_name", ["sha1", "sha224", "sha256", "sha384", "sha512"]) + @needs_ecc_import + @needs_ecc_sign_verify + def test_ecc_sign_raw_digest_sizes(ecc_private, ecc_public, hash_name): + """ + F-8280: SHA-1 and SHA-2 digest sizes are accepted. + """ + digest = hashlib.new(hash_name, b"message").digest() + if not _lib.WC_MIN_DIGEST_SIZE <= len(digest) <= _lib.WC_MAX_DIGEST_SIZE: + pytest.skip("digest size not accepted by this wolfSSL build") + r, s = ecc_private.sign_raw(digest) + assert ecc_public.verify_raw(r, s, digest) + + @needs_ecc_import @needs_ecc_export @needs_ecc_dhe diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index f78b804..191f76b 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -1338,12 +1338,13 @@ def verify(self, signature: bytes, data: BytesOrStr) -> bool: def verify_raw(self, R: bytes, S: bytes, data: BytesOrStr) -> bool: """ Verifies signature from its raw elements **R** and **S**, using - the public key data in the object. + the public key data in the object. **data** is the message + digest, as in EccPrivate.sign(). Returns **True** in case of a valid signature, otherwise **False**. """ - data = t2b(data) + data = self._check_digest(data) status = _ffi.new("int[1]") mpR = _ffi.new("mp_int[1]") mpS = _ffi.new("mp_int[1]") @@ -1548,12 +1549,13 @@ def sign(self, plaintext: BytesOrStr, rng: Random | None = None) -> bytes: def sign_raw(self, plaintext: BytesOrStr, rng: Random | None = None) -> tuple[bytes, bytes]: """ Signs **plaintext**, using the private key data in the object. + **plaintext** is the message digest, as in sign(). Returns the signature in its two raw components r, s """ if rng is None: rng = Random() - plaintext = t2b(plaintext) + plaintext = self._check_digest(plaintext) R = _ffi.new("mp_int[1]") S = _ffi.new("mp_int[1]") From ed5cc034cea4bf787b2a8736c9dcfea51dc3291b Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Fri, 25 Sep 2026 16:14:53 +0000 Subject: [PATCH 24/26] Build bundled wolfSSL with -fPIC on every Linux platform The static library is linked into the shared CFFI extension. Only x86 Linux got -fPIC, so linking failed on other architectures such as aarch64. --- scripts/build_ffi.py | 3 ++- tests/test_build_ffi.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 1652f19..ad747f4 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -188,7 +188,8 @@ def make_flags(prefix, fips): else: flags = [] - if get_platform() in ["linux-x86_64", "linux-i686"]: + # The static lib is linked into the shared extension. + if get_platform().startswith("linux"): flags.append("CFLAGS=-fPIC") # install location diff --git a/tests/test_build_ffi.py b/tests/test_build_ffi.py index 8efb2c1..c0efe2d 100644 --- a/tests/test_build_ffi.py +++ b/tests/test_build_ffi.py @@ -28,6 +28,8 @@ import importlib.util import os import re +import shlex +import sys import pytest from cffi import FFI @@ -189,6 +191,17 @@ def test_detection_matches_indented_defines_with_values(bf): assert detect(bf, "#define WOLFSSL_AES_COUNTER_X")["AES_CTR"] == 0 +@pytest.mark.skipif(sys.platform == "win32", reason="configure flags are not used on Windows") +@pytest.mark.parametrize("platform", [ + "linux-x86_64", "linux-i686", "linux-aarch64", "linux-armv7l", + "linux-ppc64le", "linux-s390x", "linux-riscv64"]) +def test_make_flags_builds_pic_on_linux(bf, monkeypatch, platform): + """The bundled static wolfSSL is linked into the shared extension, so it + must be position independent on every Linux architecture.""" + monkeypatch.setattr(bf, "get_platform", lambda: platform) + assert "CFLAGS=-fPIC" in shlex.split(bf.make_flags("/prefix", False)) + + def test_aes_ctr_needs_aes_counter(bf): features = detect(bf) assert features["AES_CTR"] == 0 From d1f4fdbff7fb8e711a5d97e76bbf37debb4a1ea7 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Fri, 25 Sep 2026 16:15:21 +0000 Subject: [PATCH 25/26] Run CI on arm64 Linux too The build job builds the bundled static wolfSSL and links it into the CFFI extension, so on ubuntu-24.04-arm it fails unless wolfSSL is compiled with -fPIC. --- .github/workflows/python-app.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 3e0c0f3..f9227ea 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -15,7 +15,12 @@ permissions: jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, ubuntu-24.04-arm ] + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 From cccacb719630baae092c2b70ab8b2eed9e82ccc8 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Tue, 29 Sep 2026 05:29:39 +0000 Subject: [PATCH 26/26] Address review: create RSA RNG on first use RsaPublic/RsaPrivate no longer build a Random in __init__ unless blinding needs it. Verify now works in WC_NO_RNG builds; encrypt and sign still raise NotImplementedError there. --- tests/test_ciphers.py | 19 ++++++++++++++++++- wolfcrypt/ciphers.py | 12 ++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/test_ciphers.py b/tests/test_ciphers.py index 36a9d7f..6ad34d5 100644 --- a/tests/test_ciphers.py +++ b/tests/test_ciphers.py @@ -350,7 +350,7 @@ def test_chacha_enc_dec(chacha_obj, vectors): assert plaintext == dec if _lib.RSA_ENABLED: - # RSA key objects always create a Random. + # RSA encrypt and sign create a Random on first use. needs_rng = pytest.mark.skipif(not _lib.RNG_ENABLED, reason="RNG not enabled") needs_encrypt_decrypt = pytest.mark.skipif(not (_lib.RSA_ENCRYPT_ENABLED and _lib.RSA_PRIVATE_ENABLED), reason="RSA encryption or decryption not enabled") @@ -529,6 +529,23 @@ def test_rsa_sign_verify(rsa_private, rsa_public): assert 1024 / 8 == len(signature) == rsa_private.output_size assert plaintext == rsa_private.verify(signature) + @needs_rng + @needs_sign_verify + def test_rsa_verify_without_rng(rsa_private, vectors, monkeypatch): + plaintext = t2b("Everyone gets Friday off.") + signature = rsa_private.sign(plaintext) + + def no_rng(): + raise NotImplementedError("RNG is not supported by this wolfSSL build") + + # Verify must not need an RNG. Blinding always needs one. + monkeypatch.setattr(ciphers, "Random", no_rng) + monkeypatch.setattr(_lib, "RSA_BLINDING_ENABLED", 0) + rsa_public = RsaPublic(vectors[RsaPublic].key) + assert plaintext == rsa_public.verify(signature) + with pytest.raises(NotImplementedError): + _ = rsa_public._random + if _lib.RSA_PSS_ENABLED: @needs_rng @pytest.mark.skipif(not _lib.RSA_SIGN_ENABLED, reason="RSA signing not enabled") diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 191f76b..c357120 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -784,15 +784,12 @@ class _Rsa: # pylint: disable=too-few-public-methods _hash_type = None def __init__(self, rng: Random | None = None) -> None: - if rng is None: - rng = Random() - self.native_object = _ffi.new("RsaKey *") ret = _lib.wc_InitRsaKey(self.native_object, _ffi.NULL) if ret < 0: # pragma: no cover raise WolfCryptApiError("Invalid key error", ret) - self._random = rng + self._rng = rng if _lib.RSA_BLINDING_ENABLED: ret = _lib.wc_RsaSetRNG(self.native_object, self._random.native_object) @@ -806,6 +803,13 @@ def __del__(self) -> None: if self.native_object: self._delete(self.native_object) + # Created on first use so verify works in builds without an RNG. + @property + def _random(self) -> Random: + if self._rng is None: + self._rng = Random() + return self._rng + def set_mgf(self, mgf: int) -> None: self._mgf = mgf