From 1c243a063c8748764087a677ae82c65ebe34f49d Mon Sep 17 00:00:00 2001 From: zepto-gaurav Date: Wed, 16 Sep 2026 13:14:37 +0530 Subject: [PATCH 1/2] Reject DER-encoded asymmetric keys as HMAC secrets (CVE-2026-85394) The guard added for CVE-2024-33663 identified asymmetric keys by their text format only, matching PEM armor and SSH prefixes. A DER-encoded key is binary and has neither, so it was accepted as an HMAC secret: an attacker holding the service's public key could sign an HS256 token with its DER bytes and have it verify, when the verifying algorithms were not restricted. DER carries nothing to match on, so is_der_format() recognizes it by its ASN.1 structure instead. The check is pure Python rather than deferring to cryptography's key loaders, because the native backend is the one used when cryptography is not installed. Co-authored-by: Cursor --- jose/backends/cryptography_backend.py | 3 +- jose/backends/native.py | 4 +- jose/utils.py | 67 +++++++++++++++++++++++++++ tests/algorithms/test_HMAC.py | 30 ++++++++++++ tests/test_utils.py | 41 ++++++++++++++++ 5 files changed, 142 insertions(+), 3 deletions(-) diff --git a/jose/backends/cryptography_backend.py b/jose/backends/cryptography_backend.py index ec836b4c..26309db4 100644 --- a/jose/backends/cryptography_backend.py +++ b/jose/backends/cryptography_backend.py @@ -20,6 +20,7 @@ base64url_decode, base64url_encode, ensure_binary, + is_der_format, is_pem_format, is_ssh_key, long_to_base64, @@ -540,7 +541,7 @@ def __init__(self, key, algorithm): if isinstance(key, str): key = key.encode("utf-8") - if is_pem_format(key) or is_ssh_key(key): + if is_pem_format(key) or is_ssh_key(key) or is_der_format(key): raise JWKError( "The specified key is an asymmetric key or x509 certificate and" " should not be used as an HMAC secret." diff --git a/jose/backends/native.py b/jose/backends/native.py index 8cc77dab..304b9939 100644 --- a/jose/backends/native.py +++ b/jose/backends/native.py @@ -5,7 +5,7 @@ from jose.backends.base import Key from jose.constants import ALGORITHMS from jose.exceptions import JWKError -from jose.utils import base64url_decode, base64url_encode, is_pem_format, is_ssh_key +from jose.utils import base64url_decode, base64url_encode, is_der_format, is_pem_format, is_ssh_key def get_random_bytes(num_bytes): @@ -36,7 +36,7 @@ def __init__(self, key, algorithm): if isinstance(key, str): key = key.encode("utf-8") - if is_pem_format(key) or is_ssh_key(key): + if is_pem_format(key) or is_ssh_key(key) or is_der_format(key): raise JWKError( "The specified key is an asymmetric key or x509 certificate and" " should not be used as an HMAC secret." diff --git a/jose/utils.py b/jose/utils.py index d62cafb0..d0463884 100644 --- a/jose/utils.py +++ b/jose/utils.py @@ -163,3 +163,70 @@ def is_ssh_key(key: bytes) -> bool: if _CERT_SUFFIX == key_type[-len(_CERT_SUFFIX) :]: return True return False + + +_DER_INTEGER = 0x02 +_DER_SEQUENCE = 0x30 +_DER_CONSTRUCTED = 0x20 + + +def _read_der_element(key: bytes, pos: int): + """Parse the DER element at ``pos``. + + Returns its ``(tag, content_start, end)`` offsets, or ``None`` if the + element is not well-formed DER. + """ + if pos + 2 > len(key): + return None + tag = key[pos] + length = key[pos + 1] + content_start = pos + 2 + if length & 0x80: + # Long form: the low bits hold the number of subsequent length bytes. + # Zero of them means the indefinite length that DER forbids. + length_bytes = length & 0x7F + if not 1 <= length_bytes <= 4 or content_start + length_bytes > len(key): + return None + length = int.from_bytes(key[content_start : content_start + length_bytes], "big") + content_start += length_bytes + end = content_start + length + if end > len(key): + return None + return tag, content_start, end + + +def _are_der_elements_well_formed(key: bytes, pos: int, end: int) -> bool: + """Whether ``key[pos:end]`` is a sequence of DER elements that tile it exactly.""" + while pos < end: + element = _read_der_element(key, pos) + if element is None: + return False + tag, content_start, element_end = element + if element_end > end: + return False + # Only constructed elements hold further elements; the contents of a + # primitive one (a BIT STRING wrapping a key, say) are opaque bytes. + if tag & _DER_CONSTRUCTED and not _are_der_elements_well_formed(key, content_start, element_end): + return False + pos = element_end + return True + + +def is_der_format(key: bytes) -> bool: + """Whether ``key`` is a DER-encoded asymmetric key or x509 certificate. + + DER has no armor or prefix to match on, so it is recognized by its ASN.1 + structure instead: a single SEQUENCE spanning the whole input, opening with + the SEQUENCE (an AlgorithmIdentifier or TBSCertificate) or INTEGER (a + version or modulus) that every such encoding starts with. + """ + element = _read_der_element(key, 0) + if element is None: + return False + tag, content_start, end = element + if tag != _DER_SEQUENCE or end != len(key): + return False + first_child = _read_der_element(key, content_start) + if first_child is None or first_child[0] not in (_DER_SEQUENCE, _DER_INTEGER): + return False + return _are_der_elements_well_formed(key, content_start, end) diff --git a/tests/algorithms/test_HMAC.py b/tests/algorithms/test_HMAC.py index 2b0859ec..e7edb08e 100644 --- a/tests/algorithms/test_HMAC.py +++ b/tests/algorithms/test_HMAC.py @@ -1,11 +1,32 @@ +import base64 import json import pytest +try: + from jose.backends.cryptography_backend import CryptographyHMACKey +except ImportError: + CryptographyHMACKey = None + from jose.backends.native import HMACKey from jose.constants import ALGORITHMS from jose.exceptions import JOSEError +HMAC_KEY_CLASSES = [pytest.param(HMACKey, id="native")] +if CryptographyHMACKey is not None: + HMAC_KEY_CLASSES.append(pytest.param(CryptographyHMACKey, id="pyca/cryptography")) + +# An EC P-256 key, as a DER SubjectPublicKeyInfo and a DER PKCS#8 private key. +der_public_key = base64.b64decode( + b"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERxw+dYxJBChbun5TEY7Q9SSt6wdX0lvS+Oew1236" + b"cUzdUg96yoqLkXrMN/Ud6PDJu+OthYOC5wLcJaEtCfeoWA==" +) +der_private_key = base64.b64decode( + b"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEjRWeJCrze8SNFZ4kKvN7xI0VniQ" + b"q83vEjRWeJCrze+hRANCAARHHD51jEkEKFu6flMRjtD1JK3rB1fSW9L457DXbfpxTN1SD3rKiouR" + b"esw39R3o8Mm7462Fg4LnAtwloS0J96hY" +) + class TestHMACAlgorithm: def test_non_string_key(self): @@ -29,6 +50,15 @@ def test_RSA_key(self): with pytest.raises(JOSEError): HMACKey(key, ALGORITHMS.HS256) + @pytest.mark.parametrize("key_class", HMAC_KEY_CLASSES) + @pytest.mark.parametrize( + "key", + (pytest.param(der_public_key, id="public"), pytest.param(der_private_key, id="private")), + ) + def test_DER_key(self, key_class, key): + with pytest.raises(JOSEError): + key_class(key, ALGORITHMS.HS256) + def test_to_dict(self): passphrase = "The quick brown fox jumps over the lazy dog" encoded = "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw" diff --git a/tests/test_utils.py b/tests/test_utils.py index 2fbb08dc..ecf9f8ee 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,21 @@ +import base64 from datetime import timedelta +import pytest + from jose import utils +# An EC P-256 key, as a DER SubjectPublicKeyInfo and a DER PKCS#8 private key. +der_public_key = base64.b64decode( + b"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAERxw+dYxJBChbun5TEY7Q9SSt6wdX0lvS+Oew1236" + b"cUzdUg96yoqLkXrMN/Ud6PDJu+OthYOC5wLcJaEtCfeoWA==" +) +der_private_key = base64.b64decode( + b"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEjRWeJCrze8SNFZ4kKvN7xI0VniQ" + b"q83vEjRWeJCrze+hRANCAARHHD51jEkEKFu6flMRjtD1JK3rB1fSW9L457DXbfpxTN1SD3rKiouR" + b"esw39R3o8Mm7462Fg4LnAtwloS0J96hY" +) + class TestUtils: def test_total_seconds(self): @@ -12,3 +26,30 @@ def test_total_seconds(self): def test_long_to_base64(self): assert utils.long_to_base64(0xDEADBEEF) == b"3q2-7w" assert utils.long_to_base64(0xCAFED00D, size=10) == b"AAAAAAAAyv7QDQ" + + @pytest.mark.parametrize( + "key", + (pytest.param(der_public_key, id="public"), pytest.param(der_private_key, id="private")), + ) + def test_is_der_format(self, key): + assert utils.is_der_format(key) + + @pytest.mark.parametrize( + "key", + ( + b"", + b"0", + b"00", + b"secret", + b"0123456789", + b"The quick brown fox jumps over the lazy dog", + # A SEQUENCE tag (0x30 is also ASCII "0") does not make a secret DER. + b"0" + b"x" * 121, + b"0" * 64, + # Truncated and over-long DER are both malformed. + der_public_key[:-1], + der_public_key + b"\x00", + ), + ) + def test_is_not_der_format(self, key): + assert not utils.is_der_format(key) From 83b6b7352622124167e530da3cfafe45a5192692 Mon Sep 17 00:00:00 2001 From: zepto-gaurav Date: Wed, 16 Sep 2026 13:14:42 +0530 Subject: [PATCH 2/2] Fix curve argument in test_incorrect_public_key_hmac_signing generate_private_key() requires an EllipticCurve instance, so passing the SECP256R1 class raised a TypeError before the test could assert anything, leaving the CVE-2024-33663 regression uncovered. Co-authored-by: Cursor --- tests/algorithms/test_EC.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/algorithms/test_EC.py b/tests/algorithms/test_EC.py index d8602a2b..100006b2 100644 --- a/tests/algorithms/test_EC.py +++ b/tests/algorithms/test_EC.py @@ -235,7 +235,7 @@ def test_incorrect_public_key_hmac_signing(): def b64(x): return base64.urlsafe_b64encode(x).replace(b"=", b"") - KEY = CryptographyEc.generate_private_key(CryptographyEc.SECP256R1) + KEY = CryptographyEc.generate_private_key(CryptographyEc.SECP256R1()) PUBKEY = KEY.public_key().public_bytes( encoding=serialization.Encoding.OpenSSH, format=serialization.PublicFormat.OpenSSH,