diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 4260f88..22f7b0f 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -376,6 +376,7 @@ def get_features(local_wolfssl, features): features["CHACHA20_POLY1305"] = 1 if '#define HAVE_CHACHA' and '#define HAVE_POLY1305' in defines else 0 features["ML_DSA"] = 1 if '#define HAVE_DILITHIUM' in defines 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 if '#define HAVE_FIPS' in defines: if not fips: @@ -491,6 +492,7 @@ def build_ffi(local_wolfssl, features): int CHACHA20_POLY1305_ENABLED = """ + str(features["CHACHA20_POLY1305"]) + """; int ML_KEM_ENABLED = """ + str(features["ML_KEM"]) + """; int ML_DSA_ENABLED = """ + str(features["ML_DSA"]) + """; + int HKDF_ENABLED = """ + str(features["HKDF"]) + """; """ ffibuilder.set_source( "wolfcrypt._ffi", init_source_string, @@ -528,6 +530,7 @@ def build_ffi(local_wolfssl, features): extern int CHACHA20_POLY1305_ENABLED; extern int ML_KEM_ENABLED; extern int ML_DSA_ENABLED; + extern int HKDF_ENABLED; typedef unsigned char byte; typedef unsigned int word32; @@ -884,6 +887,26 @@ def build_ffi(local_wolfssl, features): int wc_ed448_priv_size(ed448_key* key); """ + if features["HKDF"]: + cdef += """ + int wc_HKDF(int type, const byte* inKey, word32 inKeySz, + const byte* salt, word32 saltSz, + const byte* info, word32 infoSz, + byte* out, word32 outSz); + int wc_HKDF_Extract(int type, const byte* salt, word32 saltSz, + const byte* inKey, word32 inKeySz, byte* out); + int wc_HKDF_Extract_ex(int type, const byte* salt, word32 saltSz, + const byte* inKey, word32 inKeySz, byte* out, + void* heap, int devId); + int wc_HKDF_Expand(int type, const byte* inKey, word32 inKeySz, + const byte* info, word32 infoSz, + byte* out, word32 outSz); + int wc_HKDF_Expand_ex(int type, const byte* inKey, word32 inKeySz, + const byte* info, word32 infoSz, + byte* out, word32 outSz, + void* heap, int devId); + """ + if features["PWDBASED"]: cdef += """ int wc_PBKDF2(byte* output, const byte* passwd, int pLen, @@ -1018,7 +1041,8 @@ def main(ffibuilder): "RSA_PSS": 1, "CHACHA20_POLY1305": 1, "ML_KEM": 1, - "ML_DSA": 1 + "ML_DSA": 1, + "HKDF": 1, } # Ed448 requires SHAKE256, which isn't part of the Windows build, yet. diff --git a/tests/test_hkdf.py b/tests/test_hkdf.py new file mode 100644 index 0000000..7c215c9 --- /dev/null +++ b/tests/test_hkdf.py @@ -0,0 +1,260 @@ +# test_hkdf.py +# +# Copyright (C) 2025 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=redefined-outer-name + +import pytest + +from wolfcrypt._ffi import lib as _lib +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( + not (_lib.HKDF_ENABLED and _lib.SHA256_ENABLED and _lib.HMAC_ENABLED), + reason="HKDF/SHA256/HMAC not enabled in the underlying wolfCrypt library", +) + + +def test_hkdf_rfc5869_case1_full(): + """ + RFC 5869 Test Case 1 (SHA-256). + """ + ikm = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b") + salt = bytes.fromhex("000102030405060708090a0b0c") + info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9") + length = 42 + + expected_okm = bytes.fromhex( + "3cb25f25faacd57a90434f64d0362f2a" + "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" + "34007208d5b887185865" + ) + + okm = HKDF(HmacSha256, ikm, salt=salt, info=info, out_len=length) + assert isinstance(okm, bytes) + assert len(okm) == length + assert okm == expected_okm + + +def test_hkdf_rfc5869_case1_split_extract_expand(): + """ + Same vector as above but exercised via HKDF_Extract and HKDF_Expand. + Verifies the PRK (pseudorandom key) and the final OKM. + """ + ikm = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b") + salt = bytes.fromhex("000102030405060708090a0b0c") + info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9") + length = 42 + + expected_prk = bytes.fromhex( + "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5" + ) + expected_okm = bytes.fromhex( + "3cb25f25faacd57a90434f64d0362f2a" + "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" + "34007208d5b887185865" + ) + + prk = HKDF_Extract(HmacSha256, salt, ikm) + assert isinstance(prk, bytes) + assert prk == expected_prk + + okm = HKDF_Expand(HmacSha256, prk, info, length) + assert isinstance(okm, bytes) + assert len(okm) == length + assert okm == expected_okm + + +def test_hkdf_rfc5869_case2_full_and_split(): + """ + RFC 5869 Test Case 2 (SHA-256) - longer inputs/outputs + """ + ikm = bytes(range(0x00, 0x00 + 80)) + salt = bytes(range(0x60, 0x60 + 80)) + info = bytes(range(0xB0, 0xB0 + 80)) + length = 82 + + expected_prk = bytes.fromhex( + "06a6b88c5853361a06104c9ceb35b45c" + "ef760014904671014a193f40c15fc244" + ) + expected_okm = bytes.fromhex( + "b11e398dc80327a1c8e7f78c596a4934" + "4f012eda2d4efad8a050cc4c19afa97c" + "59045a99cac7827271cb41c65e590e09" + "da3275600c2f09b8367793a9aca3db71" + "cc30c58179ec3e87c14c01d5c1f3434f" + "1d87" + ) + + # Full + okm = HKDF(HmacSha256, ikm, salt=salt, info=info, out_len=length) + assert isinstance(okm, bytes) + assert len(okm) == length + assert okm == expected_okm + + # Split: check PRK then expand + prk = HKDF_Extract(HmacSha256, salt, ikm) + assert prk == expected_prk + + okm2 = HKDF_Expand(HmacSha256, prk, info, length) + assert okm2 == expected_okm + + +def test_hkdf_rfc5869_case3_full_and_split(): + """ + RFC 5869 Test Case 3 (SHA-256) - zero-length salt/info + """ + ikm = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b") + salt = b"" + info = b"" + length = 42 + + expected_prk = bytes.fromhex( + "19ef24a32c717b167f33a91d6f648bdf" + "96596776afdb6377ac434c1c293ccb04" + ) + expected_okm = bytes.fromhex( + "8da4e775a563c18f715f802a063c5a31" + "b8a11f5c5ee1879ec3454e5f3c738d2d" + "9d201395faa4b61a96c8" + ) + + okm = HKDF(HmacSha256, ikm, salt=salt, info=info, out_len=length) + assert okm == expected_okm + + prk = HKDF_Extract(HmacSha256, salt, ikm) + assert prk == expected_prk + + okm2 = HKDF_Expand(HmacSha256, prk, info, length) + assert okm2 == expected_okm + + +def test_hkdf_rfc5869_case4_sha1_full_and_split(): + """ + RFC 5869 Test Case 4 (SHA-1) - basic test + """ + ikm = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b") + salt = bytes.fromhex("000102030405060708090a0b0c") + info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9") + length = 42 + + expected_prk = bytes.fromhex("9b6c18c432a7bf8f0e71c8eb88f4b30baa2ba243") + expected_okm = bytes.fromhex( + "085a01ea1b10f36933068b56efa5ad81" + "a4f14b822f5b091568a9cdd4f155fda2" + "c22e422478d305f3f896" + ) + + okm = HKDF(HmacSha, ikm, salt=salt, info=info, out_len=length) + assert okm == expected_okm + + prk = HKDF_Extract(HmacSha, salt, ikm) + assert prk == expected_prk + + okm2 = HKDF_Expand(HmacSha, prk, info, length) + assert okm2 == expected_okm + + +def test_hkdf_rfc5869_case5_sha1_long_full_and_split(): + """ + RFC 5869 Test Case 5 (SHA-1) - longer inputs/outputs + """ + ikm = bytes(range(0x00, 0x00 + 80)) + salt = bytes(range(0x60, 0x60 + 80)) + info = bytes(range(0xB0, 0xB0 + 80)) + length = 82 + + expected_prk = bytes.fromhex("8adae09a2a307059478d309b26c4115a224cfaf6") + expected_okm = bytes.fromhex( + "0bd770a74d1160f7c9f12cd5912a06eb" + "ff6adcae899d92191fe4305673ba2ffe" + "8fa3f1a4e5ad79f3f334b3b202b2173c" + "486ea37ce3d397ed034c7f9dfeb15c5e" + "927336d0441f4c4300e2cff0d0900b52" + "d3b4" + ) + + okm = HKDF(HmacSha, ikm, salt=salt, info=info, out_len=length) + assert okm == expected_okm + + prk = HKDF_Extract(HmacSha, salt, ikm) + assert prk == expected_prk + + okm2 = HKDF_Expand(HmacSha, prk, info, length) + assert okm2 == expected_okm + + +def test_hkdf_rfc5869_case6_sha1_zero_salt_info(): + """ + RFC 5869 Test Case 6 (SHA-1) - zero-length salt/info + """ + ikm = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b") + salt = b"" + info = b"" + length = 42 + + expected_prk = bytes.fromhex("da8c8a73c7fa77288ec6f5e7c297786aa0d32d01") + expected_okm = bytes.fromhex( + "0ac1af7002b3d761d1e55298da9d0506" + "b9ae52057220a306e07b6b87e8df21d0" + "ea00033de03984d34918" + ) + + prk = HKDF_Extract(HmacSha, salt, ikm) + assert prk == expected_prk + + okm = HKDF(HmacSha, ikm, salt=salt, info=info, out_len=length) + assert okm == expected_okm + + okm2 = HKDF_Expand(HmacSha, prk, info, length) + assert okm2 == expected_okm + + +def test_hkdf_rfc5869_case7_sha1_salt_not_provided(): + """ + RFC 5869 Test Case 7 (SHA-1) - salt not provided (defaults to zeros), + zero-length info. + """ + ikm = bytes.fromhex("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c") + info = b"" + length = 42 + + expected_prk = bytes.fromhex("2adccada18779e7c2077ad2eb19d3f3e731385dd") + expected_okm = bytes.fromhex( + "2c91117204d745f3500d636a62f64f0a" + "b3bae548aa53d423b0d1f27ebba6f5e5" + "673a081d70cce7acfc48" + ) + + # For Extract: when salt is not provided, pass b"" (wc_HKDF_Extract treats + # empty salt as zeros). + # Some implementations treat "not provided" as explicit None; + # wc_HKDF_Extract expects salt pointer and length, so passing empty salt + # (length 0) is equivalent to RFC specification (salt = HashLen zeros). + prk = HKDF_Extract(HmacSha, None, ikm) + assert prk == expected_prk + + okm = HKDF(HmacSha, ikm, salt=None, info=info, out_len=length) + assert okm == expected_okm + + okm2 = HKDF_Expand(HmacSha, prk, info, length) + assert okm2 == expected_okm diff --git a/wolfcrypt/hkdf.py b/wolfcrypt/hkdf.py new file mode 100644 index 0000000..6bcf27e --- /dev/null +++ b/wolfcrypt/hkdf.py @@ -0,0 +1,133 @@ +# hkdf.py +# +# Copyright (C) 2025 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=no-member,no-name-in-module + +from wolfcrypt._ffi import ffi as _ffi +from wolfcrypt._ffi import lib as _lib + +from wolfcrypt.exceptions import WolfCryptError +from wolfcrypt.utils import t2b + + +if _lib.HKDF_ENABLED: + + def HKDF(hash_cls, in_key, salt=None, info=None, out_len=None): + """ + Perform HKDF Extract-and-Expand in one call (wraps wc_HKDF). + + Parameters: + - hash_cls: hash class, see `wolfcrypt.hashes`. + - in_key: input key material (IKM) as bytes or str. + - salt: optional salt value (bytes or str). If None, treated as empty. + - info: optional context/application info (bytes or str). If None, + treated as empty. + - out_len: desired length of output keying material (bytes). If None, + defaults to the digest size of the hash. + + Returns: + - bytes object containing the derived key of length `out_len`. + + Raises: + - WolfCryptError on failure. + - ValueError for invalid arguments. + """ + in_key = t2b(in_key) + salt = b"" if salt is None else t2b(salt) + info = b"" if info is None else t2b(info) + + if out_len is None: + out_len = hash_cls.digest_size + + out = _ffi.new("byte[%d]" % out_len) + ret = _lib.wc_HKDF( + hash_cls._type, + in_key, + len(in_key), + salt, + len(salt), + info, + len(info), + out, + out_len, + ) + if ret != 0: + raise WolfCryptError("HKDF error (%d)" % ret) + + return _ffi.buffer(out, out_len)[:] + + def HKDF_Extract(hash_cls, salt, in_key): + """ + HKDF-Extract: PRK = HMAC-Hash(salt, IKM) + Wraps wc_HKDF_Extract. + + Parameters: + - hash_cls: hash class, see `wolfcrypt.hashes`. + - salt: bytes/str (can be None -> treated as empty). + - in_key: input key material (IKM) as bytes/str. + + Returns: + - PRK as bytes (length == hash digest size). + + Raises WolfCryptError on failure. + """ + salt = b"" if salt is None else t2b(salt) + in_key = t2b(in_key) + + out_len = hash_cls.digest_size + out = _ffi.new("byte[%d]" % out_len) + + ret = _lib.wc_HKDF_Extract(hash_cls._type, salt, len(salt), in_key, len(in_key), out) + if ret != 0: + raise WolfCryptError("HKDF_Extract error (%d)" % ret) + + return _ffi.buffer(out, out_len)[:] + + def HKDF_Expand(hash_cls, prk, info, out_len): + """ + HKDF-Expand: OKM = HKDF-Expand(PRK, info, L) + Wraps wc_HKDF_Expand. + + Parameters: + - hash_cls: hash class, see `wolfcrypt.hashes`. + - prk: pseudorandom key (output from HKDF-Extract) as bytes/str. + - info: optional context/application info (bytes/str). If None, treated as empty. + - out_len: length of output keying material in bytes. + + Returns: + - OKM as bytes of length `out_len`. + + Raises WolfCryptError on failure. + """ + prk = t2b(prk) + info = b"" if info is None else t2b(info) + + if out_len is None or out_len <= 0: + raise ValueError("out_len must be a positive integer") + + out = _ffi.new("byte[%d]" % out_len) + + ret = _lib.wc_HKDF_Expand( + hash_cls._type, prk, len(prk), info, len(info), out, out_len + ) + if ret != 0: + raise WolfCryptError("HKDF_Expand error (%d)" % ret) + + return _ffi.buffer(out, out_len)[:]