From 53b3f6cef959c140762b685c9c19abdadf708681 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Tue, 23 Jun 2026 11:30:00 +0000 Subject: [PATCH 1/4] Remove contradictory cipher mode validation (F-4015) _FEEDBACK_MODES advertised MODE_ECB/MODE_CFB/MODE_OFB as supported, but _Cipher.__init__ then rejected every mode other than CBC/CTR with a contradictory 'not supported by this cipher' error after they had already passed the 'is supported' check. Prune _FEEDBACK_MODES to the modes the cipher actually implements (CBC, CTR) so unsupported modes get a single, accurate rejection, and drop the now-dead else branch. --- tests/test_cipher_modes.py | 59 ++++++++++++++++++++++++++++++++++++++ wolfcrypt/ciphers.py | 12 ++++---- 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/test_cipher_modes.py diff --git a/tests/test_cipher_modes.py b/tests/test_cipher_modes.py new file mode 100644 index 0000000..b74c483 --- /dev/null +++ b/tests/test_cipher_modes.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# test_cipher_modes.py +# +# Copyright (C) 2006-2022 wolfSSL Inc. +# +# This file is part of wolfSSL. (formerly known as CyaSSL) +# +# 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 2 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-1301, USA + +# pylint: disable=missing-docstring, import-error, protected-access + +import pytest +from wolfcrypt.ciphers import ( + _FEEDBACK_MODES, + MODE_CBC, MODE_CTR, MODE_ECB, MODE_CFB, MODE_OFB, +) +from wolfcrypt._ffi import lib as _lib + + +def test_feedback_modes_only_advertises_supported(): + """ + F-4015: _FEEDBACK_MODES is used as the "supported modes" gate in + _Cipher.__init__. It must not advertise modes that the constructor + then turns around and rejects. + """ + assert MODE_CBC in _FEEDBACK_MODES + assert MODE_CTR in _FEEDBACK_MODES + for unsupported in (MODE_ECB, MODE_CFB, MODE_OFB): + assert unsupported not in _FEEDBACK_MODES + + +@pytest.mark.skipif(not _lib.AES_ENABLED, reason="AES not enabled") +def test_unsupported_mode_gives_single_consistent_error(): + """ + F-4015: previously MODE_ECB passed the first "is supported" check and + then hit a contradictory "not supported by this cipher" branch. The + rejection must now be a single, consistent message. + """ + from wolfcrypt.ciphers import Aes + + key = b"0" * 16 + iv = b"0" * 16 + with pytest.raises(ValueError) as exc_info: + Aes.new(key, MODE_ECB, iv) + + assert "by this cipher" not in str(exc_info.value) diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 562aabd..df550ce 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -43,7 +43,9 @@ MODE_OFB = 5 # Output Feedback MODE_CTR = 6 # Counter -_FEEDBACK_MODES = [MODE_ECB, MODE_CBC, MODE_CFB, MODE_OFB, MODE_CTR] +# Only the modes the generic _Cipher actually supports. MODE_ECB/MODE_CFB/ +# MODE_OFB are defined above for PEP 272 completeness but are not implemented. +_FEEDBACK_MODES = [MODE_CBC, MODE_CTR] # ECC curve id ECC_CURVE_INVALID = -1 @@ -120,11 +122,9 @@ def __init__(self, key, mode, IV=None): if mode not in _FEEDBACK_MODES: raise ValueError("this mode is not supported") - if mode == MODE_CBC or mode == MODE_CTR: - if IV is None: - raise ValueError("this mode requires an 'IV' string") - else: - raise ValueError("this mode is not supported by this cipher") + # Both supported modes (CBC, CTR) require an IV / initial counter. + if IV is None: + raise ValueError("this mode requires an 'IV' string") self.mode = mode From 39acd415fcd63891f1345adf5027b581b7e2515d Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Tue, 23 Jun 2026 11:35:58 +0000 Subject: [PATCH 2/4] Reject ChaCha encrypt/decrypt before set_iv (F-4463) ChaCha.__init__ leaves _IV_nonce empty and requires set_iv() before use, but encrypt()/decrypt() (inherited from _Cipher) did not check this. The first call ran _set_key(), which passed the empty nonce to wc_Chacha_SetIV() - a function that unconditionally reads 12 bytes - reading past the buffer and silently producing output with an undefined IV. Track an _iv_set flag and override encrypt()/decrypt() to raise WolfCryptError until set_iv() has been called. --- tests/test_chacha_iv.py | 66 +++++++++++++++++++++++++++++++++++++++++ wolfcrypt/ciphers.py | 17 +++++++++++ 2 files changed, 83 insertions(+) create mode 100644 tests/test_chacha_iv.py diff --git a/tests/test_chacha_iv.py b/tests/test_chacha_iv.py new file mode 100644 index 0000000..564dabe --- /dev/null +++ b/tests/test_chacha_iv.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# +# test_chacha_iv.py +# +# Copyright (C) 2006-2022 wolfSSL Inc. +# +# This file is part of wolfSSL. (formerly known as CyaSSL) +# +# 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 2 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-1301, USA + +# pylint: disable=missing-docstring, import-error + +import pytest +from wolfcrypt._ffi import lib as _lib +from wolfcrypt.exceptions import WolfCryptError + +pytestmark = pytest.mark.skipif( + not _lib.CHACHA_ENABLED, reason="ChaCha not enabled") + +KEY = b"\x01" * 32 +NONCE = b"\x02" * 12 + + +def test_encrypt_before_set_iv_raises(): + """ + F-4463: encrypt() before set_iv() must not feed an empty IV buffer to + wc_Chacha_SetIV (which unconditionally reads 12 bytes). It must raise. + """ + from wolfcrypt.ciphers import ChaCha + + cipher = ChaCha(KEY) + with pytest.raises(WolfCryptError): + cipher.encrypt(b"A" * 16) + + +def test_decrypt_before_set_iv_raises(): + from wolfcrypt.ciphers import ChaCha + + cipher = ChaCha(KEY) + with pytest.raises(WolfCryptError): + cipher.decrypt(b"A" * 16) + + +def test_encrypt_decrypt_after_set_iv_roundtrips(): + from wolfcrypt.ciphers import ChaCha + + enc = ChaCha(KEY) + 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 df550ce..bc49b68 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -523,6 +523,22 @@ def __init__(self, key="", size=32): # pylint: disable=unused-argument self.key_size = len(self._key) self._IV_nonce = b"" self._IV_counter = 0 + # ChaCha takes no IV at construction; set_iv() must be called + # before any encrypt()/decrypt() so a real nonce is available. + self._iv_set = False + + def encrypt(self, string): + self._require_iv() + return super().encrypt(string) + + def decrypt(self, string): + self._require_iv() + return super().decrypt(string) + + def _require_iv(self): + if not self._iv_set: + raise WolfCryptError( + "set_iv() must be called before encrypt()/decrypt()") # Sentinel for "rekey both contexts" used by set_iv. Must not # collide with _ENCRYPTION (0) or _DECRYPTION (1). @@ -567,6 +583,7 @@ def set_iv(self, nonce, counter = 0): 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 = True ret = self._set_key(self._REKEY_BOTH) if ret < 0: raise WolfCryptApiError("ChaCha set_iv error", ret) From 2ecf72169535c2b333ce9e709a62b9dd05046bf3 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Tue, 23 Jun 2026 12:43:03 +0000 Subject: [PATCH 3/4] Reject unsafe HMAC copy() (F-5428) _Hmac inherited _Hash.copy(), which - lacking a wolfCrypt copy function for Hmac - fell back to a byte-level memmove and returned an object marked _shallow_copy that aliases the original's internal C state. In async or hardware-accelerated builds those internal pointers are shared, so freeing the original leaves the copy with stale state (use-after-free, wrong MACs, or corruption). wolfCrypt exposes no safe public Hmac copy, so override copy() to raise NotImplementedError. digest()/hexdigest() are unaffected. Update the shared hash tests to expect this for HMAC. --- tests/test_hashes.py | 23 +++++++++++----- tests/test_hmac_copy.py | 59 +++++++++++++++++++++++++++++++++++++++++ wolfcrypt/hashes.py | 17 +++++++----- 3 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 tests/test_hmac_copy.py diff --git a/tests/test_hashes.py b/tests/test_hashes.py index 3fcf78d..2efb3cf 100644 --- a/tests/test_hashes.py +++ b/tests/test_hashes.py @@ -172,18 +172,24 @@ def test_hash(hash_cls, vectors): assert hash_obj.hexdigest() == digest # copy - hash_obj = hash_new(hash_cls) - copy = hash_obj.copy() + if hash_cls in hmac_params: + # HMAC copy is intentionally unsupported (see F-5428): wolfCrypt has + # no safe copy and byte-copying would alias the original's C state. + with pytest.raises(NotImplementedError): + hash_new(hash_cls).copy() + else: + hash_obj = hash_new(hash_cls) + copy = hash_obj.copy() - assert hash_obj.hexdigest() == copy.hexdigest() + assert hash_obj.hexdigest() == copy.hexdigest() - hash_obj.update("wolfcrypt") + hash_obj.update("wolfcrypt") - assert hash_obj.hexdigest() != copy.hexdigest() + assert hash_obj.hexdigest() != copy.hexdigest() - copy.update("wolfcrypt") + copy.update("wolfcrypt") - assert hash_obj.hexdigest() == copy.hexdigest() == digest + assert hash_obj.hexdigest() == copy.hexdigest() == digest def test_hash_repeated_construction_destruction(hash_cls, vectors): @@ -198,6 +204,9 @@ def test_hash_repeated_construction_destruction(hash_cls, vectors): def test_hash_copy_destroy_lifecycle(hash_cls, vectors): import gc + if hash_cls in hmac_params: + # HMAC does not support copy() (see F-5428). + pytest.skip("HMAC copy is not supported") digest = vectors[hash_cls].digest for _ in range(100): h = hash_new(hash_cls, "wolfcrypt") diff --git a/tests/test_hmac_copy.py b/tests/test_hmac_copy.py new file mode 100644 index 0000000..1446868 --- /dev/null +++ b/tests/test_hmac_copy.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# test_hmac_copy.py +# +# Copyright (C) 2006-2022 wolfSSL Inc. +# +# This file is part of wolfSSL. (formerly known as CyaSSL) +# +# 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 2 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-1301, USA + +# pylint: disable=missing-docstring, import-error + +import pytest +from wolfcrypt._ffi import lib as _lib + +pytestmark = pytest.mark.skipif( + not (_lib.HMAC_ENABLED and _lib.SHA256_ENABLED), + reason="HMAC-SHA256 not enabled") + +KEY = b"wolfCrypt is the best crypto around" + + +def test_hmac_copy_raises_not_implemented(): + """ + F-5428: _Hmac inherited _Hash.copy(), which for HMAC fell back to a + byte-level memmove and returned an object aliasing the original's C + state (use-after-free risk in async/HW builds). wolfCrypt has no safe + public copy, so HMAC copy() must refuse rather than alias. + """ + from wolfcrypt.hashes import HmacSha256 + + hmac = HmacSha256.new(KEY, b"some message") + with pytest.raises(NotImplementedError): + hmac.copy() + + +def test_hmac_digest_unaffected_by_copy_removal(): + """digest()/hexdigest() must keep working and remain repeatable.""" + from wolfcrypt.hashes import HmacSha256 + + hmac = HmacSha256.new(KEY, b"some message") + first = hmac.hexdigest() + second = hmac.hexdigest() + assert first == second + + hmac.update(b" more") + assert hmac.hexdigest() != first diff --git a/wolfcrypt/hashes.py b/wolfcrypt/hashes.py index eac0f22..59f1bf5 100644 --- a/wolfcrypt/hashes.py +++ b/wolfcrypt/hashes.py @@ -392,19 +392,22 @@ class _Hmac(_Hash): A **PEP 247: Cryptographic Hash Functions** compliant **Keyed Hash Function Interface**. - Note: wolfSSL does not provide a `wc_HmacCopy` equivalent, so - `copy()` falls back to a byte-level memmove. In default builds the - Hmac struct is self-contained and this is safe. In async or - hardware-accelerated builds where the struct contains internal - pointers, the copy shares those pointers with the original; the - copy must not outlive the original or be used after the original - is freed. + Note: wolfCrypt provides no safe public copy for an `Hmac` object. + Byte-copying the struct would alias the original's internal state + (e.g. async/hardware-accelerated builds keep pointers there), so + `copy()` is not supported and raises `NotImplementedError`. """ digest_size = None _native_type = "Hmac *" _native_size = _ffi.sizeof("Hmac") _delete = staticmethod(_lib.wc_HmacFree) + def copy(self): + raise NotImplementedError( + "HMAC objects cannot be safely copied: wolfCrypt has no " + "wc_HmacCopy and byte-copying the state would alias the " + "original's internal C resources") + def __del__(self): if hasattr(self, '_native_object') and not getattr(self, '_shallow_copy', False): self._delete(self._native_object) From b13aeea62b6a6d010e8d1c102694774d4a43135a Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Tue, 23 Jun 2026 13:39:24 +0000 Subject: [PATCH 4/4] Address Copilot review on wolfcrypt-py (F-4015, F-4463) - ChaCha.set_iv(): only mark _iv_set after _set_key() succeeds, and clear it first, so a failed re-key cannot leave encrypt()/decrypt() unblocked with a stale or partially-applied IV. Add a regression test. - Update _Cipher.new()/encrypt()/decrypt() docstrings that still referred to CFB/segment-size behavior to match the actually supported modes (MODE_CBC, MODE_CTR) and their IV requirements. --- tests/test_chacha_iv.py | 25 +++++++++++++++++++++++++ wolfcrypt/ciphers.py | 22 ++++++++++++---------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/tests/test_chacha_iv.py b/tests/test_chacha_iv.py index 564dabe..571f016 100644 --- a/tests/test_chacha_iv.py +++ b/tests/test_chacha_iv.py @@ -64,3 +64,28 @@ def test_encrypt_decrypt_after_set_iv_roundtrips(): dec = ChaCha(KEY) dec.set_iv(NONCE) assert dec.decrypt(ciphertext) == plaintext + + +def test_failed_set_iv_keeps_encrypt_blocked(monkeypatch): + """ + If re-keying fails inside set_iv(), the IV must be treated as not set so + encrypt()/decrypt() stay blocked rather than running with a stale or + partially-applied IV. + """ + from wolfcrypt.ciphers import ChaCha + + cipher = ChaCha(KEY) + # First, establish a valid IV so a later failure would otherwise leave + # _iv_set True under the old ordering. + cipher.set_iv(NONCE) + + monkeypatch.setattr(cipher, "_set_key", lambda direction: -1) + with pytest.raises(WolfCryptError): + cipher.set_iv(NONCE) + monkeypatch.undo() # restore real _set_key + + # The failed re-key must have cleared the "IV is set" state, so encrypt() + # refuses here. Under the old ordering _iv_set stayed True and this + # encrypt() would instead run with a stale IV. + with pytest.raises(WolfCryptError): + cipher.encrypt(b"A" * 16) diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index bc49b68..599d8b8 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -159,12 +159,11 @@ def new(cls, key, mode, IV=None, **kwargs): # pylint: disable=W0613 """ Returns a ciphering object, using the secret key contained in the string **key**, and using the feedback mode **mode**, which - must be one of MODE_* defined in this module. + must be one of the supported MODE_* values (MODE_CBC, MODE_CTR). - If **mode** is MODE_CBC or MODE_CFB, **IV** must be provided and - must be a string of the same length as the block size. Not - providing a value of **IV** will result in a ValueError exception - being raised. + Both supported modes require **IV** to be provided as a string of + the same length as the block size. Not providing a value of **IV** + will result in a ValueError exception being raised. """ return cls(key, mode, IV) @@ -173,8 +172,9 @@ def encrypt(self, string): Encrypts a non-empty string, using the key-dependent data in the object, and with the appropriate feedback mode. - The string's length must be an exact multiple of the algorithm's - block size or, in CFB mode, of the segment size. + In MODE_CBC the string's length must be an exact multiple of the + algorithm's block size. MODE_CTR is a stream mode and imposes no + length restriction. Returns a string containing the ciphertext. """ @@ -205,8 +205,9 @@ def decrypt(self, string): Decrypts **string**, using the key-dependent data in the object and with the appropriate feedback mode. - The string's length must be an exact multiple of the algorithm's - block size or, in CFB mode, of the segment size. + In MODE_CBC the string's length must be an exact multiple of the + algorithm's block size. MODE_CTR is a stream mode and imposes no + length restriction. Returns a string containing the plaintext. """ @@ -583,10 +584,11 @@ def set_iv(self, nonce, counter = 0): 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 = True + self._iv_set = False ret = self._set_key(self._REKEY_BOTH) if ret < 0: raise WolfCryptApiError("ChaCha set_iv error", ret) + self._iv_set = True if _lib.CHACHA20_POLY1305_ENABLED: class ChaCha20Poly1305: