Skip to content

Add typing: asn, exceptions, hashes, pwdbased, utils.#125

Open
roberthdevries wants to merge 3 commits into
wolfSSL:masterfrom
roberthdevries:add-more-typing
Open

Add typing: asn, exceptions, hashes, pwdbased, utils.#125
roberthdevries wants to merge 3 commits into
wolfSSL:masterfrom
roberthdevries:add-more-typing

Conversation

@roberthdevries

Copy link
Copy Markdown
Contributor

No description provided.

@roberthdevries roberthdevries force-pushed the add-more-typing branch 2 times, most recently from 65d14db to 1c2b082 Compare May 23, 2026 14:02
@Trooper-X

Copy link
Copy Markdown

This also adds the ruff and ty dependencies.
Would be nice if this MR gets merged.

@dgarske dgarske left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skoll Code Review

Scan type: reviewOverall recommendation: REQUEST_CHANGES
Findings: 7 total — 7 posted, 0 skipped
6 finding(s) posted as inline comments (see file-level comments below)

Posted findings

  • [High] AES-SIV single-block associated-data length uses char count of original input, not encoded byte lengthwolfcrypt/ciphers.py:397-400
  • [Medium] sign_with_seed no longer accepts bytearray/memoryview seeds (regression)wolfcrypt/ciphers.py:2513-2546
  • [Medium] make_key_from_seed now silently UTF-8-encodes a str seed instead of rejecting itwolfcrypt/ciphers.py:2360-2367
  • [Low] ChaCha init renamed size to _size, breaking the documented backward-compatible keywordwolfcrypt/ciphers.py:544
  • [Low] HKDF helpers annotate hash_cls as instance type instead of class typewolfcrypt/hkdf.py:33,78,105
  • [Low] Random no longer nulls native_object on init failure; del frees an uninitialized RNGwolfcrypt/random.py:37-52
  • [Low] RsaPublic.init made key a required positional argumentwolfcrypt/ciphers.py:771-774

Review generated by Skoll

Comment thread wolfcrypt/ciphers.py
# A single block is provided.
# Make sure we have bytes.
associated_data = t2b(associated_data)
associated_data_bytes = t2b(associated_data)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [High] AES-SIV single-block associated-data length uses char count of original input, not encoded byte length

The refactor renamed the local variable but left the assocSz assignment pointing at the original input. associated_data_bytes = t2b(associated_data) creates a NEW bytes object, while line 400 still does result[0].assocSz = len(associated_data) against the ORIGINAL associated_data. Before this PR the code reassigned associated_data = t2b(associated_data), so len(associated_data) was the encoded byte length and was correct. Now, when associated_data is a str (an explicitly documented single-block input type — see the docstring "a single str, bytes, bytearray, or memoryview") containing non-ASCII/multibyte UTF-8 characters, len(associated_data) is the character count, which is smaller than len(associated_data_bytes) (the UTF-8 byte count). result[0].assoc points at the full encoded byte buffer but assocSz is set too small, so wc_AesSivEncrypt_ex/wc_AesSivDecrypt_ex authenticate only a truncated prefix of the associated data. The library's own encrypt/decrypt round-trip will still pass (both sides truncate identically), masking the defect, but the tag will not match any spec-correct implementation and part of the AD goes unauthenticated. The list branch (line 412) correctly uses len(block) over associated_data_bytes, so only the single-block branch is affected.

Fix: Change line 400 to use len(associated_data_bytes) so the authenticated length matches the buffer actually pointed to. Add a test with a non-ASCII str single-block associated data (e.g. compare a str AD against the equivalent UTF-8 bytes AD) to lock in the fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment thread wolfcrypt/ciphers.py
"seed must support the buffer protocol, such as `bytes` or `bytearray`"
) from exception
if len(seed_view) != ML_DSA_SIGNATURE_SEED_LENGTH:
if len(seed) != ML_DSA_SIGNATURE_SEED_LENGTH:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [Medium] sign_with_seed no longer accepts bytearray/memoryview seeds (regression)

The PR removed the memoryview(seed) + _ffi.from_buffer(seed_view) handling and now passes the raw seed object straight into wc_dilithium_sign_msg_with_seed/wc_dilithium_sign_ctx_msg_with_seed. The rest of this codebase consistently converts inputs to bytes (via t2b) or wraps them with _ffi.from_buffer before handing them to cffi, which strongly implies cffi accepts a plain bytes object for the const byte* argument but not a bytearray/memoryview. Under the old code a bytearray or memoryview seed of the correct length worked; now it will raise a raw cffi TypeError. This is a behavioral regression for buffer-protocol seed inputs, and the previous friendly error message ("seed must support the buffer protocol, such as bytes or bytearray") is also lost. The updated test only verifies that a str raises TypeError; there is no test for bytearray/memoryview seeds.

Fix: Restore buffer-protocol handling so bytes/bytearray/memoryview seeds all work while still rejecting str (e.g. bytes(memoryview(seed))). Add a parametrized test covering bytearray and memoryview seeds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a bit of reverse engineering the set of accepted types are actually bytes, list/tuple of int (where each int should be in the range of values that a byte can have).
This is actually checked by the automatically generated bindings of cffi, and those bindings will perform the checks and will raise a TypeError if you pass a string for example.
This removes the need to check explicitly and aligns the accepted types of this method with the accepted types of the underlying call to wc_dilithium_sign_ctx_msg_with_seed

Comment thread wolfcrypt/ciphers.py
@@ -2316,35 +2358,29 @@ def make_key_from_seed(cls, mldsa_type, seed):
:type seed: bytes
"""
mldsa_priv = cls(mldsa_type)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [Medium] make_key_from_seed now silently UTF-8-encodes a str seed instead of rejecting it

The PR replaced the memoryview(seed) buffer-protocol check with seed_bytes = t2b(seed). t2b accepts str and encodes it as UTF-8, so a str seed (previously rejected with a TypeError because memoryview(str) fails) is now silently accepted and used as deterministic key material. This is a footgun for deterministic key generation and is inconsistent with the sibling sign_with_seed, which (per the updated test) now rejects str seeds with a TypeError. The two seed-consuming APIs should agree on whether str is a valid seed type.

Fix: Decide on one seed-type policy and apply it to both make_key_from_seed and sign_with_seed (recommend: accept bytes-like, reject str). Add a test asserting str seeds are rejected in make_key_from_seed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a bit of reverse engineering the set of accepted types are actually bytes, list/tuple of int (where each int should be in the range of values that a byte can have).
This is actually checked by the automatically generated bindings of cffi, and those bindings will perform the checks and will raise a TypeError if you pass a string for example.
This removes the need to check explicitly and aligns the accepted types of this method with the accepted types of the underlying call to wc_dilithium_make_key_from_seed

There is not yet an automated test. But manually testing shows that str is rejected.

Comment thread wolfcrypt/ciphers.py Outdated
_IV_counter = 0

def __init__(self, key="", size=32): # pylint: disable=unused-argument
def __init__(self, key: BytesOrStr="", _size: int= 32) -> None: # pylint: disable=unused-argument

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] ChaCha init renamed size to _size, breaking the documented backward-compatible keyword

The parameter size was renamed to _size, yet the in-code comment immediately below still states "size is kept for backwards compatibility". Renaming to _size breaks any caller that used the keyword form ChaCha(key, size=32) (now raises TypeError: unexpected keyword argument 'size'). Positional callers (e.g. the test at tests/test_ciphers.py:317 ChaCha(vectors[ChaCha].key, 32)) are unaffected, which is why the test suite still passes, but the public keyword is a contract change that contradicts the stated intent.

Fix: Keep the public keyword name size (the ANN/unused-arg lint can be silenced with the existing pylint disable / a leading-underscore rename only if the public keyword truly does not matter). If the keyword is genuinely deprecated, document that rather than silently renaming it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted the change.

Comment thread wolfcrypt/random.py Outdated

def __init__(self, nonce: __builtins__.bytes = b"", device_id: int = -2) -> None:
self.native_object: _lib.RNG | None = _ffi.new("WC_RNG *")
self.native_object: _lib.RNG = _ffi.new("WC_RNG *")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] Random no longer nulls native_object on init failure; del frees an uninitialized RNG

The PR changed the attribute type to non-optional _lib.RNG and removed self.native_object = None from the wc_InitRngNonce_ex failure path. Because __init__ assigns native_object before the init call, if init fails the object still has a truthy cdata, so __del__ (which runs even when __init__ raises) will call wc_FreeRng on a WC_RNG that failed to initialize. This is almost certainly benign (_ffi.new zero-fills and wc_FreeRng is null-safe on the DRBG), and the path is # pragma: no cover, but the removed assignment was a defensive guard. Note the failure path is the only place the old | None value could occur, so dropping the Optional type is otherwise fine.

Fix: Restore a null-out on the init-failure path (or allocate after a successful init) so __del__ does not free an uninitialized RNG.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original None value of native_object has been restored with a minor twist.
There is now an internal _native_object that is initialized to None during init.
If _ffi.new throws a MemoryError the __del__ method now correctly detects that there is no native object to delete.
Also if wc_InitRngNonce_ex fails, the underlying native object is set to None.
But to prevent all users of Random.native_object to have to check for None which is a bit fragile, a property is introduced that checks for None and will raise a WolfCryptError exception.

Comment thread wolfcrypt/ciphers.py

class RsaPublic(_Rsa):
def __init__(self, key=None, hash_type=None, rng=None):
def __init__(self, key: BytesOrStr, hash_type: int | None = None, rng: Random | None = None) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] RsaPublic.init made key a required positional argument

The signature changed from def __init__(self, key=None, hash_type=None, rng=None) to def __init__(self, key: BytesOrStr, ...), dropping the key=None default and making key required, while the body still contains if key is not None:. In practice RsaPublic() already failed previously (with key=None, len(key) raised TypeError), so usable behavior is unchanged — but this is a public-API signature change and the residual if key is not None check is now effectively dead for the default path. RsaPrivate is unaffected since it does not call RsaPublic.__init__.

Fix: Confirm the API change is intentional; if so, remove the dead if key is not None branch, otherwise restore the default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API change is intentional, as the method requires the key to be defined for wc_RsaPublicKeyDecode Passing a NULL pointer will return BAD_FUNC_ARG and None will raise a TypeError, so from that point of view it makes sense to not have a default None parameter.
The redundant check for None for the key is removed.

This has some fallout in random.py to simplify checks.
Also one test is slightly adapted to produced the desired failure.
@roberthdevries roberthdevries force-pushed the add-more-typing branch 2 times, most recently from 8f9280c to 1af1a55 Compare June 10, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants