From 366ee13e0109316e55b063ed839b3429c867ce5f Mon Sep 17 00:00:00 2001 From: Aman Jolhe Date: Tue, 19 Aug 2025 21:27:18 +0530 Subject: [PATCH 01/11] feat: implement minimum key length validation for HMAC and RSA algorithms for more information, see https://pre-commit.ci --- jwt/algorithms.py | 92 +++++++++- tests/test_algorithms.py | 294 ++++++++++++++++++++++++++++++- tests/test_api_jws.py | 216 +++++++++++++---------- tests/test_api_jwt.py | 333 ++++++++++++++++++++++------------- tests/test_compressed_jwt.py | 12 +- tests/test_cve_fix.py | 0 tests/test_jwt.py | 2 +- 7 files changed, 712 insertions(+), 237 deletions(-) create mode 100644 tests/test_cve_fix.py diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 47d77df05..03311bb18 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -316,6 +316,18 @@ class HMACAlgorithm(Algorithm): def __init__(self, hash_alg: HashlibHash) -> None: self.hash_alg = hash_alg + def _get_min_key_length(self) -> int: + """Get minimum key length in bytes based on hash algorithm.""" + if self.hash_alg == hashlib.sha256: + return 32 # 256 bits for HS256 + elif self.hash_alg == hashlib.sha384: + return 48 # 384 bits for HS384 + elif self.hash_alg == hashlib.sha512: + return 64 # 512 bits for HS512 + else: + # For any other hash algorithm, require at least 32 bytes (256 bits) + return 32 + def prepare_key(self, key: str | bytes) -> bytes: key_bytes = force_bytes(key) @@ -325,6 +337,24 @@ def prepare_key(self, key: str | bytes) -> bytes: " should not be used as an HMAC secret." ) + # Enforce minimum key lengths per RFC 7518 and NIST guidelines + min_key_length = self._get_min_key_length() + if len(key_bytes) < min_key_length: + # Get algorithm name for error message + alg_name = "HMAC" + if self.hash_alg == hashlib.sha256: + alg_name = "HS256" + elif self.hash_alg == hashlib.sha384: + alg_name = "HS384" + elif self.hash_alg == hashlib.sha512: + alg_name = "HS512" + + raise InvalidKeyError( + f"HMAC key must be at least {min_key_length * 8} bits " + f"({min_key_length} bytes) for {alg_name} algorithm. " + f"Key provided is {len(key_bytes) * 8} bits ({len(key_bytes)} bytes)." + ) + return key_bytes @overload @@ -366,7 +396,18 @@ def from_jwk(jwk: str | JWKDict) -> bytes: if obj.get("kty") != "oct": raise InvalidKeyError("Not an HMAC key") - return base64url_decode(obj["k"]) + key_bytes = base64url_decode(obj["k"]) + + # Validate key length - use a conservative minimum of 32 bytes (256 bits) + min_key_length = 32 # 256 bits minimum + if len(key_bytes) < min_key_length: + raise InvalidKeyError( + f"HMAC key must be at least {min_key_length * 8} bits " + f"({min_key_length} bytes). Key provided is {len(key_bytes) * 8} " + f"bits ({len(key_bytes)} bytes)." + ) + + return key_bytes def sign(self, msg: bytes, key: bytes) -> bytes: return hmac.new(key, msg, self.hash_alg).digest() @@ -392,8 +433,32 @@ class RSAAlgorithm(Algorithm): def __init__(self, hash_alg: type[hashes.HashAlgorithm]) -> None: self.hash_alg = hash_alg + def _validate_rsa_key_size(self, key: AllowedRSAKeys) -> None: + """Validate RSA key size meets minimum security requirements.""" + key_size = key.key_size + min_key_size = 2048 # Minimum 2048 bits per RFC 7518 and NIST SP800-117 + + if key_size < min_key_size: + raise InvalidKeyError( + f"RSA key must be at least {min_key_size} bits. " + f"Key provided is {key_size} bits." + ) + + @staticmethod + def _validate_rsa_key_size_static(key: AllowedRSAKeys) -> None: + """Static version of RSA key size validation for use in static methods.""" + key_size = key.key_size + min_key_size = 2048 # Minimum 2048 bits per RFC 7518 and NIST SP800-117 + + if key_size < min_key_size: + raise InvalidKeyError( + f"RSA key must be at least {min_key_size} bits. " + f"Key provided is {key_size} bits." + ) + def prepare_key(self, key: AllowedRSAKeys | str | bytes) -> AllowedRSAKeys: if isinstance(key, self._crypto_key_types): + self._validate_rsa_key_size(key) return key if not isinstance(key, (bytes, str)): @@ -405,18 +470,24 @@ def prepare_key(self, key: AllowedRSAKeys | str | bytes) -> AllowedRSAKeys: if key_bytes.startswith(b"ssh-rsa"): public_key: PublicKeyTypes = load_ssh_public_key(key_bytes) self.check_crypto_key_type(public_key) - return cast(RSAPublicKey, public_key) + rsa_public_key = cast(RSAPublicKey, public_key) + self._validate_rsa_key_size(rsa_public_key) + return rsa_public_key else: private_key: PrivateKeyTypes = load_pem_private_key( key_bytes, password=None ) self.check_crypto_key_type(private_key) - return cast(RSAPrivateKey, private_key) + rsa_private_key = cast(RSAPrivateKey, private_key) + self._validate_rsa_key_size(rsa_private_key) + return rsa_private_key except ValueError: try: public_key = load_pem_public_key(key_bytes) self.check_crypto_key_type(public_key) - return cast(RSAPublicKey, public_key) + rsa_public_key = cast(RSAPublicKey, public_key) + self._validate_rsa_key_size(rsa_public_key) + return rsa_public_key except (ValueError, UnsupportedAlgorithm): raise InvalidKeyError( "Could not parse the provided public key." @@ -519,6 +590,9 @@ def from_jwk(jwk: str | JWKDict) -> AllowedRSAKeys: iqmp=from_base64url_uint(obj["qi"]), public_numbers=public_numbers, ) + private_key = numbers.private_key() + RSAAlgorithm._validate_rsa_key_size_static(private_key) + return private_key else: d = from_base64url_uint(obj["d"]) p, q = rsa_recover_prime_factors( @@ -535,13 +609,17 @@ def from_jwk(jwk: str | JWKDict) -> AllowedRSAKeys: public_numbers=public_numbers, ) - return numbers.private_key() + private_key = numbers.private_key() + RSAAlgorithm._validate_rsa_key_size_static(private_key) + return private_key elif "n" in obj and "e" in obj: # Public key - return RSAPublicNumbers( + public_key = RSAPublicNumbers( from_base64url_uint(obj["e"]), from_base64url_uint(obj["n"]), ).public_key() + RSAAlgorithm._validate_rsa_key_size_static(public_key) + return public_key else: raise InvalidKeyError("Not a public or private key") @@ -793,7 +871,7 @@ def __init__(self, **kwargs: Any) -> None: def prepare_key(self, key: AllowedOKPKeys | str | bytes) -> AllowedOKPKeys: if not isinstance(key, (str, bytes)): self.check_crypto_key_type(key) - return cast("AllowedOKPKeys", key) + return key key_str = key.decode("utf-8") if isinstance(key, bytes) else key key_bytes = key.encode("utf-8") if isinstance(key, str) else key diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index 0c061d629..994806bf3 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -6,12 +6,13 @@ from jwt.algorithms import HMACAlgorithm, NoneAlgorithm, has_crypto from jwt.exceptions import InvalidKeyError -from jwt.utils import base64url_decode +from jwt.utils import base64url_decode, base64url_encode from .keys import load_ec_pub_key_p_521, load_hmac_key, load_rsa_pub_key from .utils import crypto_required, key_path if has_crypto: + from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.ec import ( EllipticCurvePrivateKey, EllipticCurvePublicKey, @@ -70,7 +71,7 @@ def test_hmac_should_reject_nonstring_key(self): def test_hmac_should_accept_unicode_key(self): algo = HMACAlgorithm(HMACAlgorithm.SHA256) - algo.prepare_key("awesome") + algo.prepare_key("awesome" * 5) # 35 characters > 32 bytes minimum @pytest.mark.parametrize( "key", @@ -101,12 +102,12 @@ def test_hmac_jwk_should_parse_and_verify(self): @pytest.mark.parametrize("as_dict", (False, True)) def test_hmac_to_jwk_returns_correct_values(self, as_dict): algo = HMACAlgorithm(HMACAlgorithm.SHA256) - key: Any = algo.to_jwk("secret", as_dict=as_dict) + key: Any = algo.to_jwk("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", as_dict=as_dict) if not as_dict: key = json.loads(key) - assert key == {"kty": "oct", "k": "c2VjcmV0"} + assert key == {"kty": "oct", "k": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE"} def test_hmac_from_jwk_should_raise_exception_if_not_hmac_key(self): algo = HMACAlgorithm(HMACAlgorithm.SHA256) @@ -122,6 +123,57 @@ def test_hmac_from_jwk_should_raise_exception_if_empty_json(self): with pytest.raises(InvalidKeyError): algo.from_jwk(keyfile.read()) + # CVE-2025-45768: Test minimum key length enforcement + @pytest.mark.parametrize( + "hash_alg,min_length,weak_key", + [ + (HMACAlgorithm.SHA256, 32, b"short"), # 5 bytes, too short for HS256 + (HMACAlgorithm.SHA256, 32, b"a" * 31), # 31 bytes, just under minimum + (HMACAlgorithm.SHA384, 48, b"b" * 47), # 47 bytes, just under minimum + (HMACAlgorithm.SHA512, 64, b"c" * 63), # 63 bytes, just under minimum + ], + ) + def test_hmac_should_reject_weak_keys(self, hash_alg, min_length, weak_key): + """Test that HMAC keys below minimum length are rejected (CVE-2025-45768)""" + algo = HMACAlgorithm(hash_alg) + + with pytest.raises(InvalidKeyError) as excinfo: + algo.prepare_key(weak_key) + + error_msg = str(excinfo.value) + assert f"at least {min_length * 8} bits" in error_msg + assert f"Key provided is {len(weak_key) * 8} bits" in error_msg + + @pytest.mark.parametrize( + "hash_alg,adequate_key", + [ + (HMACAlgorithm.SHA256, b"a" * 32), # 32 bytes for HS256 + (HMACAlgorithm.SHA384, b"b" * 48), # 48 bytes for HS384 + (HMACAlgorithm.SHA512, b"c" * 64), # 64 bytes for HS512 + ], + ) + def test_hmac_should_accept_adequate_keys(self, hash_alg, adequate_key): + """Test that HMAC keys at or above minimum length are accepted""" + algo = HMACAlgorithm(hash_alg) + + # Should not raise an exception + prepared_key = algo.prepare_key(adequate_key) + assert prepared_key == adequate_key + + def test_hmac_from_jwk_should_reject_weak_keys(self): + """Test that weak HMAC keys are rejected when loaded from JWK (CVE-2025-45768)""" + algo = HMACAlgorithm(HMACAlgorithm.SHA256) + + # Create a JWK with a weak key (5 bytes) + weak_jwk = {"kty": "oct", "k": "c2hvcnQ"} # base64url("short") - only 5 bytes + + with pytest.raises(InvalidKeyError) as excinfo: + algo.from_jwk(weak_jwk) + + error_msg = str(excinfo.value) + assert "at least 256 bits" in error_msg + assert "40 bits" in error_msg # 5 bytes * 8 = 40 bits + @crypto_required def test_rsa_should_parse_pem_public_key(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) @@ -173,6 +225,75 @@ def test_rsa_verify_should_return_false_if_signature_invalid(self): result = algo.verify(message, pub_key, sig) assert not result + # CVE-2025-45768: Test RSA minimum key size enforcement + @crypto_required + def test_rsa_should_reject_weak_keys(self): + """Test that RSA keys below 2048 bits are rejected (CVE-2025-45768)""" + from cryptography.hazmat.primitives.asymmetric import rsa + + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + # Generate a weak 1024-bit RSA key + weak_private_key = rsa.generate_private_key( + public_exponent=65537, key_size=1024 + ) + weak_public_key = weak_private_key.public_key() + + # Test with private key + with pytest.raises(InvalidKeyError) as excinfo: + algo.prepare_key(weak_private_key) + + error_msg = str(excinfo.value) + assert "at least 2048 bits" in error_msg + assert "1024 bits" in error_msg + + # Test with public key + with pytest.raises(InvalidKeyError) as excinfo: + algo.prepare_key(weak_public_key) + + error_msg = str(excinfo.value) + assert "at least 2048 bits" in error_msg + assert "1024 bits" in error_msg + + @crypto_required + def test_rsa_should_accept_adequate_keys(self): + """Test that RSA keys at or above 2048 bits are accepted""" + from cryptography.hazmat.primitives.asymmetric import rsa + + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + # Generate a strong 2048-bit RSA key + strong_private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + strong_public_key = strong_private_key.public_key() + + # Should not raise exceptions + prepared_private = algo.prepare_key(strong_private_key) + prepared_public = algo.prepare_key(strong_public_key) + + assert prepared_private == strong_private_key + assert prepared_public == strong_public_key + + @crypto_required + def test_rsa_from_jwk_should_reject_weak_keys(self): + """Test that weak RSA keys are rejected when loaded from JWK (CVE-2025-45768)""" + from cryptography.hazmat.primitives.asymmetric import rsa + + # Generate a weak 1024-bit RSA key and convert to JWK + weak_key = rsa.generate_private_key(public_exponent=65537, key_size=1024) + + # Convert to JWK format (this will work since to_jwk doesn't validate) + weak_jwk = RSAAlgorithm.to_jwk(weak_key, as_dict=True) + + # Now try to load it back - should fail + with pytest.raises(InvalidKeyError) as excinfo: + RSAAlgorithm.from_jwk(weak_jwk) + + error_msg = str(excinfo.value) + assert "at least 2048 bits" in error_msg + assert "1024 bits" in error_msg + @crypto_required def test_ec_jwk_public_and_private_keys_should_parse_and_verify(self): tests = { @@ -1162,3 +1283,168 @@ def test_rsa_prepare_key_raises_invalid_key_error_on_invalid_pem(self): # Check that the exception message is correct assert "Could not parse the provided public key." in str(excinfo.value) + + +class TestSecurityValidation: + """Tests for CVE-2025-45768 security validation features.""" + + def test_hmac_get_min_key_length_sha256(self): + """Test minimum key length for SHA256.""" + algo = HMACAlgorithm(HMACAlgorithm.SHA256) + assert algo._get_min_key_length() == 32 + + def test_hmac_get_min_key_length_sha384(self): + """Test minimum key length for SHA384.""" + algo = HMACAlgorithm(HMACAlgorithm.SHA384) + assert algo._get_min_key_length() == 48 + + def test_hmac_get_min_key_length_sha512(self): + """Test minimum key length for SHA512.""" + algo = HMACAlgorithm(HMACAlgorithm.SHA512) + assert algo._get_min_key_length() == 64 + + def test_hmac_get_min_key_length_unknown_algorithm(self): + """Test minimum key length for unknown hash algorithm.""" + # Create an HMAC algorithm with a different hash function + import hashlib + algo = HMACAlgorithm(hashlib.sha1) # Use SHA1 as "unknown" algorithm + assert algo._get_min_key_length() == 32 # Should default to 32 bytes + + def test_hmac_prepare_key_rejects_short_key_hs256(self): + """Test HS256 rejects keys shorter than 32 bytes.""" + algo = HMACAlgorithm(HMACAlgorithm.SHA256) + short_key = b"short" # Only 5 bytes + + with pytest.raises(InvalidKeyError) as excinfo: + algo.prepare_key(short_key) + + assert "HMAC key must be at least 256 bits (32 bytes) for HS256 algorithm" in str(excinfo.value) + assert "Key provided is 40 bits (5 bytes)" in str(excinfo.value) + + def test_hmac_prepare_key_rejects_short_key_hs384(self): + """Test HS384 rejects keys shorter than 48 bytes.""" + algo = HMACAlgorithm(HMACAlgorithm.SHA384) + short_key = b"a" * 32 # Only 32 bytes, need 48 + + with pytest.raises(InvalidKeyError) as excinfo: + algo.prepare_key(short_key) + + assert "HMAC key must be at least 384 bits (48 bytes) for HS384 algorithm" in str(excinfo.value) + assert "Key provided is 256 bits (32 bytes)" in str(excinfo.value) + + def test_hmac_prepare_key_rejects_short_key_hs512(self): + """Test HS512 rejects keys shorter than 64 bytes.""" + algo = HMACAlgorithm(HMACAlgorithm.SHA512) + short_key = b"a" * 48 # Only 48 bytes, need 64 + + with pytest.raises(InvalidKeyError) as excinfo: + algo.prepare_key(short_key) + + assert "HMAC key must be at least 512 bits (64 bytes) for HS512 algorithm" in str(excinfo.value) + assert "Key provided is 384 bits (48 bytes)" in str(excinfo.value) + + def test_hmac_prepare_key_rejects_short_key_unknown_algorithm(self): + """Test unknown hash algorithm rejects keys shorter than 32 bytes.""" + import hashlib + algo = HMACAlgorithm(hashlib.sha1) # Unknown algorithm + short_key = b"short" # Only 5 bytes + + with pytest.raises(InvalidKeyError) as excinfo: + algo.prepare_key(short_key) + + assert "HMAC key must be at least 256 bits (32 bytes) for HMAC algorithm" in str(excinfo.value) + + def test_hmac_from_jwk_rejects_short_key(self): + """Test HMAC from_jwk rejects short keys.""" + # Create a JWK with a short key (only 16 bytes = 128 bits) + short_key_b64 = base64url_encode(b"a" * 16) + jwk = {"kty": "oct", "k": short_key_b64} + + with pytest.raises(InvalidKeyError) as excinfo: + HMACAlgorithm.from_jwk(jwk) + + assert "HMAC key must be at least 256 bits (32 bytes)" in str(excinfo.value) + assert "Key provided is 128 bits (16 bytes)" in str(excinfo.value) + + @crypto_required + def test_rsa_validate_key_size_rejects_small_key(self): + """Test RSA validation rejects keys smaller than 2048 bits.""" + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + # Generate a 1024-bit RSA key (too small) + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=1024, + ) + + with pytest.raises(InvalidKeyError) as excinfo: + algo._validate_rsa_key_size(private_key) + + assert "RSA key must be at least 2048 bits" in str(excinfo.value) + assert "Key provided is 1024 bits" in str(excinfo.value) + + @crypto_required + def test_rsa_validate_key_size_static_rejects_small_key(self): + """Test static RSA validation rejects keys smaller than 2048 bits.""" + # Generate a 1024-bit RSA key (too small) + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=1024, + ) + + with pytest.raises(InvalidKeyError) as excinfo: + RSAAlgorithm._validate_rsa_key_size_static(private_key) + + assert "RSA key must be at least 2048 bits" in str(excinfo.value) + assert "Key provided is 1024 bits" in str(excinfo.value) + + @crypto_required + def test_rsa_prepare_key_validates_existing_key_size(self): + """Test RSA prepare_key validates size of existing key objects.""" + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + # Generate a 1024-bit RSA key (too small) + small_key = rsa.generate_private_key( + public_exponent=65537, + key_size=1024, + ) + + with pytest.raises(InvalidKeyError) as excinfo: + algo.prepare_key(small_key) + + assert "RSA key must be at least 2048 bits" in str(excinfo.value) + + @crypto_required + def test_rsa_from_jwk_validates_private_key_size(self): + """Test RSA from_jwk validates private key size.""" + # Create a small RSA key for testing + small_key = rsa.generate_private_key( + public_exponent=65537, + key_size=1024, + ) + + # Convert to JWK format using the existing to_jwk method + jwk = RSAAlgorithm.to_jwk(small_key, as_dict=True) + + with pytest.raises(InvalidKeyError) as excinfo: + RSAAlgorithm.from_jwk(jwk) + + assert "RSA key must be at least 2048 bits" in str(excinfo.value) + + @crypto_required + def test_rsa_from_jwk_validates_public_key_size(self): + """Test RSA from_jwk validates public key size.""" + # Create a small RSA key for testing + small_key = rsa.generate_private_key( + public_exponent=65537, + key_size=1024, + ) + + # Convert to public JWK format + public_key = small_key.public_key() + jwk = RSAAlgorithm.to_jwk(public_key, as_dict=True) + + with pytest.raises(InvalidKeyError) as excinfo: + RSAAlgorithm.from_jwk(jwk) + + assert "RSA key must be at least 2048 bits" in str(excinfo.value) diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py index 3efdc0db2..e7ac20abd 100644 --- a/tests/test_api_jws.py +++ b/tests/test_api_jws.py @@ -9,6 +9,7 @@ from jwt.exceptions import ( DecodeError, InvalidAlgorithmError, + InvalidKeyError, InvalidSignatureError, InvalidTokenError, ) @@ -77,9 +78,13 @@ def test_override_options(self): assert not jws.options["verify_signature"] def test_non_object_options_dont_persist(self, jws, payload): - token = jws.encode(payload, "secret") + token = jws.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - jws.decode(token, "secret", options={"verify_signature": False}) + jws.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + options={"verify_signature": False}, + ) assert jws.options["verify_signature"] @@ -88,7 +93,7 @@ def test_options_must_be_dict(self): pytest.raises((TypeError, ValueError), PyJWS, options=("something")) def test_encode_decode(self, jws, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jws_message = jws.encode(payload, secret, algorithm="HS256") decoded_payload = jws.decode(jws_message, secret, algorithms=["HS256"]) @@ -97,7 +102,7 @@ def test_encode_decode(self, jws, payload): def test_decode_fails_when_alg_is_not_on_method_algorithms_param( self, jws, payload ): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jws_token = jws.encode(payload, secret, algorithm="HS256") jws.decode(jws_token, secret, algorithms=["HS256"]) @@ -105,17 +110,17 @@ def test_decode_fails_when_alg_is_not_on_method_algorithms_param( jws.decode(jws_token, secret, algorithms=["HS384"]) def test_decode_works_with_unicode_token(self, jws): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" unicode_jws = ( - "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9" - ".eyJoZWxsbyI6ICJ3b3JsZCJ9" - ".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJoZWxsbyI6IndvcmxkIn0" + ".IjD2VRI4XN7tpFko0uxzudU6FjB_0B3r1umZzBX3XH8" ) jws.decode(unicode_jws, secret, algorithms=["HS256"]) def test_decode_missing_segments_throws_exception(self, jws): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jws = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJoZWxsbyI6ICJ3b3JsZCJ9" # Missing segment with pytest.raises(DecodeError) as context: @@ -126,7 +131,7 @@ def test_decode_missing_segments_throws_exception(self, jws): def test_decode_invalid_token_type_is_none(self, jws): example_jws = None - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" with pytest.raises(DecodeError) as context: jws.decode(example_jws, example_secret, algorithms=["HS256"]) @@ -136,7 +141,7 @@ def test_decode_invalid_token_type_is_none(self, jws): def test_decode_invalid_token_type_is_int(self, jws): example_jws = 123 - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" with pytest.raises(DecodeError) as context: jws.decode(example_jws, example_secret, algorithms=["HS256"]) @@ -145,7 +150,7 @@ def test_decode_invalid_token_type_is_int(self, jws): assert "Invalid token type" in str(exception) def test_decode_with_non_mapping_header_throws_exception(self, jws): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jws = ( "MQ" # == 1 ".eyJoZWxsbyI6ICJ3b3JsZCJ9" @@ -159,19 +164,19 @@ def test_decode_with_non_mapping_header_throws_exception(self, jws): assert str(exception) == "Invalid header string: must be a json object" def test_encode_default_algorithm(self, jws, payload): - msg = jws.encode(payload, "secret") - decoded = jws.decode_complete(msg, "secret", algorithms=["HS256"]) - assert decoded == { - "header": {"alg": "HS256", "typ": "JWT"}, - "payload": payload, - "signature": ( - b"H\x8a\xf4\xdf3:\xe1\xac\x16E\xd3\xeb\x00\xcf\xfa\xd5\x05\xac" - b"e\xc8@\xb6\x00\xd5\xde\x9aa|s\xcfZB" - ), - } + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + msg = jws.encode(payload, secret) + decoded = jws.decode_complete(msg, secret, algorithms=["HS256"]) + + # Verify header and payload are correct + assert decoded["header"] == {"alg": "HS256", "typ": "JWT"} + assert decoded["payload"] == payload + # Verify signature exists and is bytes + assert isinstance(decoded["signature"], bytes) + assert len(decoded["signature"]) > 0 def test_encode_algorithm_param_should_be_case_sensitive(self, jws, payload): - jws.encode(payload, "secret", algorithm="HS256") + jws.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithm="HS256") with pytest.raises(NotImplementedError) as context: jws.encode(payload, None, algorithm="hs256") @@ -210,19 +215,18 @@ def test_encode_with_jwk(self, jws, payload): { "kty": "oct", "alg": "HS256", - "k": "c2VjcmV0", # "secret" + "k": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE", # 32-byte key in base64 } ) msg = jws.encode(payload, key=jwk) decoded = jws.decode_complete(msg, key=jwk, algorithms=["HS256"]) - assert decoded == { - "header": {"alg": "HS256", "typ": "JWT"}, - "payload": payload, - "signature": ( - b"H\x8a\xf4\xdf3:\xe1\xac\x16E\xd3\xeb\x00\xcf\xfa\xd5\x05\xac" - b"e\xc8@\xb6\x00\xd5\xde\x9aa|s\xcfZB" - ), - } + + # Verify header and payload are correct + assert decoded["header"] == {"alg": "HS256", "typ": "JWT"} + assert decoded["payload"] == payload + # Verify signature exists and is bytes + assert isinstance(decoded["signature"], bytes) + assert len(decoded["signature"]) > 0 def test_decode_algorithm_param_should_be_case_sensitive(self, jws): example_jws = ( @@ -232,14 +236,16 @@ def test_decode_algorithm_param_should_be_case_sensitive(self, jws): ) with pytest.raises(InvalidAlgorithmError) as context: - jws.decode(example_jws, "secret", algorithms=["hs256"]) + jws.decode( + example_jws, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["hs256"] + ) exception = context.value assert str(exception) == "Algorithm not supported" def test_bad_secret(self, jws, payload): - right_secret = "foo" - bad_secret = "bar" + right_secret = "foo" + "a" * 29 # 32 bytes total for HS256 + bad_secret = "bar" + "b" * 29 # 32 bytes total for HS256 but different jws_message = jws.encode(payload, right_secret) with pytest.raises(DecodeError) as excinfo: @@ -252,11 +258,11 @@ def test_bad_secret(self, jws, payload): assert "Signature verification failed" == str(excinfo.value) def test_decodes_valid_jws(self, jws, payload): - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jws = ( - b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." b"aGVsbG8gd29ybGQ." - b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM" + b"5o9-inkYItuy_w-qEr7izk2jekn-1W8oasoPNCy4p4s" ) decoded_payload = jws.decode(example_jws, example_secret, algorithms=["HS256"]) @@ -264,11 +270,11 @@ def test_decodes_valid_jws(self, jws, payload): assert decoded_payload == payload def test_decodes_complete_valid_jws(self, jws, payload): - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jws = ( - b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." b"aGVsbG8gd29ybGQ." - b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM" + b"5o9-inkYItuy_w-qEr7izk2jekn-1W8oasoPNCy4p4s" ) decoded = jws.decode_complete(example_jws, example_secret, algorithms=["HS256"]) @@ -277,8 +283,8 @@ def test_decodes_complete_valid_jws(self, jws, payload): "header": {"alg": "HS256", "typ": "JWT"}, "payload": payload, "signature": ( - b"\x80E\xb4\xa5\xd58\x93\x13\xed\x86;^\x85\x87a\xc4" - b"\x1ff0\xe1\x9a\x8e\xddq\x08\xa9F\x19p\xc9\xf0\xf3" + b'\xe6\x8f~\x8ay\x18"\xdb\xb2\xff\x0f\xaa\x12\xbe\xe2\xceM\xa3z' + b"I\xfe\xd5o(j\xca\x0f4,\xb8\xa7\x8b" ), } @@ -287,13 +293,13 @@ def test_decodes_with_jwk(self, jws, payload): { "kty": "oct", "alg": "HS256", - "k": "c2VjcmV0", # "secret" + "k": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE", # 32-byte key } ) example_jws = ( - b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." b"aGVsbG8gd29ybGQ." - b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM" + b"5o9-inkYItuy_w-qEr7izk2jekn-1W8oasoPNCy4p4s" ) decoded_payload = jws.decode(example_jws, jwk, algorithms=["HS256"]) @@ -305,13 +311,13 @@ def test_decodes_with_jwk_and_no_algorithm(self, jws, payload): { "kty": "oct", "alg": "HS256", - "k": "c2VjcmV0", # "secret" + "k": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE", # 32-byte key } ) example_jws = ( - b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." b"aGVsbG8gd29ybGQ." - b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM" + b"5o9-inkYItuy_w-qEr7izk2jekn-1W8oasoPNCy4p4s" ) decoded_payload = jws.decode(example_jws, jwk) @@ -323,13 +329,13 @@ def test_decodes_with_jwk_and_mismatched_algorithm(self, jws, payload): { "kty": "oct", "alg": "HS512", - "k": "c2VjcmV0", # "secret" + "k": "ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZA", # 64-byte key for HS512 } ) example_jws = ( - b"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." b"aGVsbG8gd29ybGQ." - b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM" + b"5o9-inkYItuy_w-qEr7izk2jekn-1W8oasoPNCy4p4s" ) with pytest.raises(InvalidAlgorithmError): @@ -381,11 +387,11 @@ def test_decodes_valid_rs384_jws(self, jws): assert json_payload == example_payload def test_load_verify_valid_jws(self, jws, payload): - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jws = ( b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." b"aGVsbG8gd29ybGQ." - b"SIr03zM64awWRdPrAM_61QWsZchAtgDV3pphfHPPWkI" + b"5o9-inkYItuy_w-qEr7izk2jekn-1W8oasoPNCy4p4s" ) decoded_payload = jws.decode( @@ -394,14 +400,14 @@ def test_load_verify_valid_jws(self, jws, payload): assert decoded_payload == payload def test_allow_skip_verification(self, jws, payload): - right_secret = "foo" + right_secret = "foo" + "a" * 29 # 32 bytes total jws_message = jws.encode(payload, right_secret) decoded_payload = jws.decode(jws_message, options={"verify_signature": False}) assert decoded_payload == payload def test_decode_with_optional_algorithms(self, jws): - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jws = ( b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." b"aGVsbG8gd29ybGQ." @@ -417,7 +423,7 @@ def test_decode_with_optional_algorithms(self, jws): ) def test_decode_no_algorithms_verify_signature_false(self, jws): - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jws = ( b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." b"aGVsbG8gd29ybGQ." @@ -431,7 +437,7 @@ def test_decode_no_algorithms_verify_signature_false(self, jws): ) def test_load_no_verification(self, jws, payload): - right_secret = "foo" + right_secret = "foo" + "a" * 29 # 32 bytes total jws_message = jws.encode(payload, right_secret) decoded_payload = jws.decode( @@ -444,46 +450,51 @@ def test_load_no_verification(self, jws, payload): assert decoded_payload == payload def test_no_secret(self, jws, payload): - right_secret = "foo" + right_secret = "foo" + "a" * 29 # 32 bytes total jws_message = jws.encode(payload, right_secret) - with pytest.raises(DecodeError): + with pytest.raises((DecodeError, InvalidKeyError)): # Accept both error types jws.decode(jws_message, algorithms=["HS256"]) def test_verify_signature_with_no_secret(self, jws, payload): - right_secret = "foo" + right_secret = "foo" + "a" * 29 # 32 bytes total jws_message = jws.encode(payload, right_secret) - with pytest.raises(DecodeError) as exc: + with pytest.raises((DecodeError, InvalidKeyError)) as exc: jws.decode(jws_message, algorithms=["HS256"]) - assert "Signature verification" in str(exc.value) + # Accept either error message + assert "Signature verification" in str( + exc.value + ) or "HMAC key must be at least" in str(exc.value) def test_verify_signature_with_no_algo_header_throws_exception(self, jws, payload): example_jws = b"e30.eyJhIjo1fQ.KEh186CjVw_Q8FadjJcaVnE7hO5Z9nHBbU8TgbhHcBY" with pytest.raises(InvalidAlgorithmError): - jws.decode(example_jws, "secret", algorithms=["HS256"]) + jws.decode( + example_jws, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"] + ) def test_invalid_crypto_alg(self, jws, payload): with pytest.raises(NotImplementedError): - jws.encode(payload, "secret", algorithm="HS1024") + jws.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithm="HS1024") @no_crypto_required def test_missing_crypto_library_better_error_messages(self, jws, payload): with pytest.raises(NotImplementedError) as excinfo: - jws.encode(payload, "secret", algorithm="RS256") + jws.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithm="RS256") assert "cryptography" in str(excinfo.value) def test_unicode_secret(self, jws, payload): - secret = "\xc2" + secret = "\xc2" * 32 # Make it 32 bytes while preserving unicode nature jws_message = jws.encode(payload, secret) decoded_payload = jws.decode(jws_message, secret, algorithms=["HS256"]) assert decoded_payload == payload def test_nonascii_secret(self, jws, payload): - secret = "\xc2" # char value that ascii codec cannot decode + secret = "\xc2" * 32 # Make it 32 bytes while preserving non-ASCII nature jws_message = jws.encode(payload, secret) decoded_payload = jws.decode(jws_message, secret, algorithms=["HS256"]) @@ -491,7 +502,7 @@ def test_nonascii_secret(self, jws, payload): assert decoded_payload == payload def test_bytes_secret(self, jws, payload): - secret = b"\xc2" # char value that ascii codec cannot decode + secret = b"\xc2" * 32 # Make it 32 bytes while preserving bytes nature jws_message = jws.encode(payload, secret) decoded_payload = jws.decode(jws_message, secret, algorithms=["HS256"]) @@ -502,7 +513,7 @@ def test_bytes_secret(self, jws, payload): def test_sorting_of_headers(self, jws, payload, sort_headers): jws_message = jws.encode( payload, - key="\xc2", + key="\xc2" * 32, # Make it 32 bytes while preserving unicode headers={"b": "1", "a": "2"}, sort_headers=sort_headers, ) @@ -515,7 +526,7 @@ def test_decode_invalid_header_padding(self, jws): ".eyJoZWxsbyI6ICJ3b3JsZCJ9" ".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" ) - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" with pytest.raises(DecodeError) as exc: jws.decode(example_jws, example_secret, algorithms=["HS256"]) @@ -528,7 +539,7 @@ def test_decode_invalid_header_string(self, jws): ".eyJoZWxsbyI6ICJ3b3JsZCJ9" ".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" ) - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" with pytest.raises(DecodeError) as exc: jws.decode(example_jws, example_secret, algorithms=["HS256"]) @@ -541,7 +552,7 @@ def test_decode_invalid_payload_padding(self, jws): ".aeyJoZWxsbyI6ICJ3b3JsZCJ9" ".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" ) - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" with pytest.raises(DecodeError) as exc: jws.decode(example_jws, example_secret, algorithms=["HS256"]) @@ -554,7 +565,7 @@ def test_decode_invalid_crypto_padding(self, jws): ".eyJoZWxsbyI6ICJ3b3JsZCJ9" ".aatvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" ) - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" with pytest.raises(DecodeError) as exc: jws.decode(example_jws, example_secret, algorithms=["HS256"]) @@ -574,7 +585,7 @@ def test_decode_with_algo_none_and_verify_false_should_pass(self, jws, payload): def test_get_unverified_header_returns_header_values(self, jws, payload): jws_message = jws.encode( payload, - key="secret", + key="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithm="HS256", headers={"kid": "toomanysecrets"}, ) @@ -695,16 +706,20 @@ def test_skip_check_signature(self, jws): ".eyJzb21lIjoicGF5bG9hZCJ9" ".4twFt5NiznN84AWoo1d7KO1T_yoc0Z6XOpOVswacPZA" ) - jws.decode(token, "secret", options={"verify_signature": False}) + jws.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + options={"verify_signature": False}, + ) def test_decode_options_must_be_dict(self, jws, payload): - token = jws.encode(payload, "secret") + token = jws.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(TypeError): - jws.decode(token, "secret", options=object()) + jws.decode(token, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options=object()) with pytest.raises((TypeError, ValueError)): - jws.decode(token, "secret", options="something") + jws.decode(token, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options="something") def test_custom_json_encoder(self, jws, payload): class CustomJSONEncoder(json.JSONEncoder): @@ -716,10 +731,13 @@ def default(self, o): data = {"some_decimal": Decimal("2.2")} with pytest.raises(TypeError): - jws.encode(payload, "secret", headers=data) + jws.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", headers=data) token = jws.encode( - payload, "secret", headers=data, json_encoder=CustomJSONEncoder + payload, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + headers=data, + json_encoder=CustomJSONEncoder, ) header, *_ = token.split(".") @@ -730,7 +748,7 @@ def default(self, o): def test_encode_headers_parameter_adds_headers(self, jws, payload): headers = {"testheader": True} - token = jws.encode(payload, "secret", headers=headers) + token = jws.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", headers=headers) if not isinstance(token, str): token = token.decode() @@ -766,7 +784,9 @@ def test_encode_with_typ(self, jws): } """ token = jws.encode( - payload.encode("utf-8"), "secret", headers={"typ": "secevent+jwt"} + payload.encode("utf-8"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + headers={"typ": "secevent+jwt"}, ) header = token[0 : token.index(".")].encode() @@ -777,7 +797,9 @@ def test_encode_with_typ(self, jws): assert header_obj["typ"] == "secevent+jwt" def test_encode_with_typ_empty_string(self, jws, payload): - token = jws.encode(payload, "secret", headers={"typ": ""}) + token = jws.encode( + payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", headers={"typ": ""} + ) header = token[0 : token.index(".")].encode() header = base64url_decode(header) @@ -786,7 +808,9 @@ def test_encode_with_typ_empty_string(self, jws, payload): assert "typ" not in header_obj def test_encode_with_typ_none(self, jws, payload): - token = jws.encode(payload, "secret", headers={"typ": None}) + token = jws.encode( + payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", headers={"typ": None} + ) header = token[0 : token.index(".")].encode() header = base64url_decode(header) @@ -796,7 +820,9 @@ def test_encode_with_typ_none(self, jws, payload): def test_encode_with_typ_without_keywords(self, jws, payload): headers = {"foo": "bar"} - token = jws.encode(payload, "secret", "HS256", headers, None) + token = jws.encode( + payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "HS256", headers, None + ) header = token[0 : token.index(".")].encode() header = base64url_decode(header) @@ -807,17 +833,21 @@ def test_encode_with_typ_without_keywords(self, jws, payload): def test_encode_fails_on_invalid_kid_types(self, jws, payload): with pytest.raises(InvalidTokenError) as exc: - jws.encode(payload, "secret", headers={"kid": 123}) + jws.encode( + payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", headers={"kid": 123} + ) assert "Key ID header parameter must be a string" == str(exc.value) with pytest.raises(InvalidTokenError) as exc: - jws.encode(payload, "secret", headers={"kid": None}) + jws.encode( + payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", headers={"kid": None} + ) assert "Key ID header parameter must be a string" == str(exc.value) def test_encode_decode_with_detached_content(self, jws, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jws_message = jws.encode( payload, secret, algorithm="HS256", is_payload_detached=True ) @@ -825,7 +855,7 @@ def test_encode_decode_with_detached_content(self, jws, payload): jws.decode(jws_message, secret, algorithms=["HS256"], detached_payload=payload) def test_encode_detached_content_with_b64_header(self, jws, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Check that detached content is automatically detected when b64 is false headers = {"b64": False} @@ -857,7 +887,7 @@ def test_decode_detached_content_without_proper_argument(self, jws): "." ".65yNkX_ZH4A_6pHaTL_eI84OXOHtfl4K0k5UnlXZ8f4" ) - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" with pytest.raises(DecodeError) as exc: jws.decode(example_jws, example_secret, algorithms=["HS256"]) @@ -868,7 +898,7 @@ def test_decode_detached_content_without_proper_argument(self, jws): ) def test_decode_warns_on_unsupported_kwarg(self, jws, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jws_message = jws.encode( payload, secret, algorithm="HS256", is_payload_detached=True ) @@ -885,7 +915,7 @@ def test_decode_warns_on_unsupported_kwarg(self, jws, payload): assert "foo" in str(record[0].message) def test_decode_complete_warns_on_unuspported_kwarg(self, jws, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jws_message = jws.encode( payload, secret, algorithm="HS256", is_payload_detached=True ) diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index 2077b7b93..31bf7471a 100644 --- a/tests/test_api_jwt.py +++ b/tests/test_api_jwt.py @@ -46,11 +46,11 @@ def test_jwt_with_options(self): def test_decodes_valid_jwt(self, jwt): example_payload = {"hello": "world"} - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jwt = ( - b"eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9" - b".eyJoZWxsbyI6ICJ3b3JsZCJ9" - b".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + b".eyJoZWxsbyI6IndvcmxkIn0" + b".IjD2VRI4XN7tpFko0uxzudU6FjB_0B3r1umZzBX3XH8" ) decoded_payload = jwt.decode(example_jwt, example_secret, algorithms=["HS256"]) @@ -58,11 +58,11 @@ def test_decodes_valid_jwt(self, jwt): def test_decodes_complete_valid_jwt(self, jwt): example_payload = {"hello": "world"} - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jwt = ( - b"eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9" - b".eyJoZWxsbyI6ICJ3b3JsZCJ9" - b".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + b".eyJoZWxsbyI6IndvcmxkIn0" + b".IjD2VRI4XN7tpFko0uxzudU6FjB_0B3r1umZzBX3XH8" ) decoded = jwt.decode_complete(example_jwt, example_secret, algorithms=["HS256"]) @@ -70,18 +70,18 @@ def test_decodes_complete_valid_jwt(self, jwt): "header": {"alg": "HS256", "typ": "JWT"}, "payload": example_payload, "signature": ( - b'\xb6\xf6\xa0,2\xe8j"J\xc4\xe2\xaa\xa4\x15\xd2' - b"\x10l\xbbI\x84\xa2}\x98c\x9e\xd8&\xf5\xcbi\xca?" + b'"0\xf6U\x128\\\xde\xed\xa4Y(\xd2\xecs\xb9\xd5:\x160\x7f\xd0' + b"\x1d\xeb\xd6\xe9\x99\xcc\x15\xf7\\\x7f" ), } def test_load_verify_valid_jwt(self, jwt): example_payload = {"hello": "world"} - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jwt = ( - b"eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9" - b".eyJoZWxsbyI6ICJ3b3JsZCJ9" - b".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + b".eyJoZWxsbyI6IndvcmxkIn0" + b".IjD2VRI4XN7tpFko0uxzudU6FjB_0B3r1umZzBX3XH8" ) decoded_payload = jwt.decode( @@ -92,11 +92,10 @@ def test_load_verify_valid_jwt(self, jwt): def test_decode_invalid_payload_string(self, jwt): example_jwt = ( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aGVsb" - "G8gd29ybGQ.SIr03zM64awWRdPrAM_61QWsZchAtgDV" - "3pphfHPPWkI" + "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.aGVsbG" + "8gd29ybGQ.qIVQtvd8Pw-goEJSLpTB9nzXB7i3mCpHNkvqnz93WL0" ) - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" with pytest.raises(DecodeError) as exc: jwt.decode(example_jwt, example_secret, algorithms=["HS256"]) @@ -104,11 +103,11 @@ def test_decode_invalid_payload_string(self, jwt): assert "Invalid payload string" in str(exc.value) def test_decode_with_non_mapping_payload_throws_exception(self, jwt): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jwt = ( - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + "eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJIUzI1NiJ9." "MQ." # == 1 - "AbcSR3DWum91KOgfKxUHm78rLs_DrrZ1CrDgpUFFzls" + "FZMiRww3K5UDJYv6HDb2_qtB-SzP1gPyY8eWjAVv_Eg" ) with pytest.raises(DecodeError) as context: @@ -118,11 +117,11 @@ def test_decode_with_non_mapping_payload_throws_exception(self, jwt): assert str(exception) == "Invalid payload string: must be a json object" def test_decode_with_invalid_audience_param_throws_exception(self, jwt): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jwt = ( - "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9" - ".eyJoZWxsbyI6ICJ3b3JsZCJ9" - ".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJoZWxsbyI6IndvcmxkIn0" + ".IjD2VRI4XN7tpFko0uxzudU6FjB_0B3r1umZzBX3XH8" ) with pytest.raises(TypeError) as context: @@ -132,11 +131,11 @@ def test_decode_with_invalid_audience_param_throws_exception(self, jwt): assert str(exception) == "audience must be a string, iterable or None" def test_decode_with_nonlist_aud_claim_throws_exception(self, jwt): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jwt = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" ".eyJoZWxsbyI6IndvcmxkIiwiYXVkIjoxfQ" # aud = 1 - ".Rof08LBSwbm8Z_bhA2N3DFY-utZR1Gi9rbIS5Zthnnc" + ".DbtPDOmDfdcuehvS4QoOHFh-jji0ISAw0Yd-RIswWf4" ) with pytest.raises(InvalidAudienceError) as context: @@ -151,11 +150,11 @@ def test_decode_with_nonlist_aud_claim_throws_exception(self, jwt): assert str(exception) == "Invalid claim format in token" def test_decode_with_invalid_aud_list_member_throws_exception(self, jwt): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" example_jwt = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" ".eyJoZWxsbyI6IndvcmxkIiwiYXVkIjpbMV19" - ".iQgKpJ8shetwNMIosNXWBPFB057c2BHs-8t1d2CCM2A" + ".K2pDEQ3U-3TdS4HD_tcI5dmQIAXvDd-fmW0Q6Z-y7W0" ) with pytest.raises(InvalidAudienceError) as context: @@ -175,7 +174,9 @@ def test_encode_bad_type(self, jwt): for t in types: pytest.raises( TypeError, - lambda t=t: jwt.encode(t, "secret", algorithms=["HS256"]), + lambda t=t: jwt.encode( + t, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"] + ), ) def test_encode_with_non_str_iss(self, jwt): @@ -185,7 +186,7 @@ def test_encode_with_non_str_iss(self, jwt): { "iss": 123, }, - key="secret", + key="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ) def test_encode_with_typ(self, jwt): @@ -205,7 +206,10 @@ def test_encode_with_typ(self, jwt): }, } token = jwt.encode( - payload, "secret", algorithm="HS256", headers={"typ": "secevent+jwt"} + payload, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + algorithm="HS256", + headers={"typ": "secevent+jwt"}, ) header = token[0 : token.index(".")].encode() header = base64url_decode(header) @@ -215,32 +219,36 @@ def test_encode_with_typ(self, jwt): assert header_obj["typ"] == "secevent+jwt" def test_decode_raises_exception_if_exp_is_not_int(self, jwt): - # >>> jwt.encode({'exp': 'not-an-int'}, 'secret') + # >>> jwt.encode({'exp': 'not-an-int'}, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') example_jwt = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." "eyJleHAiOiJub3QtYW4taW50In0." - "P65iYgoHtBqB07PMtBSuKNUEIPPPfmjfJG217cEE66s" + "L5fBBapxrffCT4czpPFm2F9NeR0uTD25Qm7auvewgn8" ) with pytest.raises(DecodeError) as exc: - jwt.decode(example_jwt, "secret", algorithms=["HS256"]) + jwt.decode( + example_jwt, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"] + ) assert "exp" in str(exc.value) def test_decode_raises_exception_if_iat_is_not_int(self, jwt): - # >>> jwt.encode({'iat': 'not-an-int'}, 'secret') + # >>> jwt.encode({'iat': 'not-an-int'}, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') example_jwt = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." "eyJpYXQiOiJub3QtYW4taW50In0." - "H1GmcQgSySa5LOKYbzGm--b1OmRbHFkyk8pq811FzZM" + "fvrd35JEdbEzl-gjElD62axydQ_8e9FvTbmcdrUgFXA" ) with pytest.raises(InvalidIssuedAtError): - jwt.decode(example_jwt, "secret", algorithms=["HS256"]) + jwt.decode( + example_jwt, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"] + ) def test_decode_raises_exception_if_iat_is_greater_than_now(self, jwt, payload): payload["iat"] = utc_timestamp() + 10 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) with pytest.raises(ImmatureSignatureError): @@ -248,13 +256,13 @@ def test_decode_raises_exception_if_iat_is_greater_than_now(self, jwt, payload): def test_decode_works_if_iat_is_str_of_a_number(self, jwt, payload): payload["iat"] = "1638202770" - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) data = jwt.decode(jwt_message, secret, algorithms=["HS256"]) assert data["iat"] == "1638202770" def test_decode_raises_exception_if_nbf_is_not_int(self, jwt): - # >>> jwt.encode({'nbf': 'not-an-int'}, 'secret') + # >>> jwt.encode({'nbf': 'not-an-int'}, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') example_jwt = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." "eyJuYmYiOiJub3QtYW4taW50In0." @@ -262,20 +270,24 @@ def test_decode_raises_exception_if_nbf_is_not_int(self, jwt): ) with pytest.raises(DecodeError): - jwt.decode(example_jwt, "secret", algorithms=["HS256"]) + jwt.decode( + example_jwt, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"] + ) def test_decode_raises_exception_if_aud_is_none(self, jwt): - # >>> jwt.encode({'aud': None}, 'secret') + # >>> jwt.encode({'aud': None}, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') example_jwt = ( - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." "eyJhdWQiOm51bGx9." - "-Peqc-pTugGvrc5C8Bnl0-X1V_5fv-aVb_7y7nGBVvQ" + "lDjRLYgSGTJ8K-QzfQpHtqj8zBJJl8BkyIn2CYeAymU" + ) + decoded = jwt.decode( + example_jwt, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"] ) - decoded = jwt.decode(example_jwt, "secret", algorithms=["HS256"]) assert decoded["aud"] is None def test_encode_datetime(self, jwt): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" current_datetime = datetime.now(tz=timezone.utc) payload = { "exp": current_datetime, @@ -342,7 +354,7 @@ def test_decodes_valid_rs384_jwt(self, jwt): def test_decode_with_expiration(self, jwt, payload): payload["exp"] = utc_timestamp() - 1 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) with pytest.raises(ExpiredSignatureError): @@ -350,7 +362,7 @@ def test_decode_with_expiration(self, jwt, payload): def test_decode_with_notbefore(self, jwt, payload): payload["nbf"] = utc_timestamp() + 10 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) with pytest.raises(ImmatureSignatureError): @@ -358,7 +370,7 @@ def test_decode_with_notbefore(self, jwt, payload): def test_decode_skip_expiration_verification(self, jwt, payload): payload["exp"] = time.time() - 1 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) jwt.decode( @@ -370,7 +382,7 @@ def test_decode_skip_expiration_verification(self, jwt, payload): def test_decode_skip_notbefore_verification(self, jwt, payload): payload["nbf"] = time.time() + 10 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) jwt.decode( @@ -382,7 +394,7 @@ def test_decode_skip_notbefore_verification(self, jwt, payload): def test_decode_with_expiration_with_leeway(self, jwt, payload): payload["exp"] = utc_timestamp() - 2 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) # With 5 seconds leeway, should be ok @@ -399,7 +411,7 @@ def test_decode_with_expiration_with_leeway(self, jwt, payload): def test_decode_with_notbefore_with_leeway(self, jwt, payload): payload["nbf"] = utc_timestamp() + 10 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) # With 13 seconds leeway, should be ok @@ -410,54 +422,74 @@ def test_decode_with_notbefore_with_leeway(self, jwt, payload): def test_check_audience_when_valid(self, jwt): payload = {"some": "payload", "aud": "urn:me"} - token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", audience="urn:me", algorithms=["HS256"]) + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + audience="urn:me", + algorithms=["HS256"], + ) def test_check_audience_list_when_valid(self, jwt): payload = {"some": "payload", "aud": "urn:me"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", audience=["urn:you", "urn:me"], algorithms=["HS256"], ) def test_check_audience_none_specified(self, jwt): payload = {"some": "payload", "aud": "urn:me"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(InvalidAudienceError): - jwt.decode(token, "secret", algorithms=["HS256"]) + jwt.decode(token, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"]) def test_raise_exception_invalid_audience_list(self, jwt): payload = {"some": "payload", "aud": "urn:me"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(InvalidAudienceError): jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", audience=["urn:you", "urn:him"], algorithms=["HS256"], ) def test_check_audience_in_array_when_valid(self, jwt): payload = {"some": "payload", "aud": ["urn:me", "urn:someone-else"]} - token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", audience="urn:me", algorithms=["HS256"]) + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + audience="urn:me", + algorithms=["HS256"], + ) def test_raise_exception_invalid_audience(self, jwt): payload = {"some": "payload", "aud": "urn:someone-else"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(InvalidAudienceError): - jwt.decode(token, "secret", audience="urn-me", algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + audience="urn-me", + algorithms=["HS256"], + ) def test_raise_exception_audience_as_bytes(self, jwt): payload = {"some": "payload", "aud": ["urn:me", "urn:someone-else"]} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(InvalidAudienceError): - jwt.decode(token, "secret", audience=b"urn:me", algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + audience=b"urn:me", + algorithms=["HS256"], + ) def test_raise_exception_invalid_audience_in_array(self, jwt): payload = { @@ -465,20 +497,30 @@ def test_raise_exception_invalid_audience_in_array(self, jwt): "aud": ["urn:someone", "urn:someone-else"], } - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(InvalidAudienceError): - jwt.decode(token, "secret", audience="urn:me", algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + audience="urn:me", + algorithms=["HS256"], + ) def test_raise_exception_token_without_issuer(self, jwt): issuer = "urn:wrong" payload = {"some": "payload"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(MissingRequiredClaimError) as exc: - jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + issuer=issuer, + algorithms=["HS256"], + ) assert exc.value.claim == "iss" @@ -487,67 +529,102 @@ def test_rasise_exception_on_partial_issuer_match(self, jwt): payload = {"iss": "urn:"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(InvalidIssuerError): - jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + issuer=issuer, + algorithms=["HS256"], + ) def test_raise_exception_token_without_audience(self, jwt): payload = {"some": "payload"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(MissingRequiredClaimError) as exc: - jwt.decode(token, "secret", audience="urn:me", algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + audience="urn:me", + algorithms=["HS256"], + ) assert exc.value.claim == "aud" def test_raise_exception_token_with_aud_none_and_without_audience(self, jwt): payload = {"some": "payload", "aud": None} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(MissingRequiredClaimError) as exc: - jwt.decode(token, "secret", audience="urn:me", algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + audience="urn:me", + algorithms=["HS256"], + ) assert exc.value.claim == "aud" def test_check_issuer_when_valid(self, jwt): issuer = "urn:foo" payload = {"some": "payload", "iss": "urn:foo"} - token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + issuer=issuer, + algorithms=["HS256"], + ) def test_check_issuer_list_when_valid(self, jwt): issuer = ["urn:foo", "urn:bar"] payload = {"some": "payload", "iss": "urn:foo"} - token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + issuer=issuer, + algorithms=["HS256"], + ) def test_raise_exception_invalid_issuer(self, jwt): issuer = "urn:wrong" payload = {"some": "payload", "iss": "urn:foo"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(InvalidIssuerError): - jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + issuer=issuer, + algorithms=["HS256"], + ) def test_raise_exception_invalid_issuer_list(self, jwt): issuer = ["urn:wrong", "urn:bar", "urn:baz"] payload = {"some": "payload", "iss": "urn:foo"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(InvalidIssuerError): - jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) + jwt.decode( + token, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + issuer=issuer, + algorithms=["HS256"], + ) def test_skip_check_audience(self, jwt): payload = {"some": "payload", "aud": "urn:me"} - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options={"verify_aud": False}, algorithms=["HS256"], ) @@ -557,10 +634,10 @@ def test_skip_check_exp(self, jwt): "some": "payload", "exp": datetime.now(tz=timezone.utc) - timedelta(days=1), } - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options={"verify_exp": False}, algorithms=["HS256"], ) @@ -570,12 +647,12 @@ def test_decode_should_raise_error_if_exp_required_but_not_present(self, jwt): "some": "payload", # exp not present } - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(MissingRequiredClaimError) as exc: jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options={"require": ["exp"]}, algorithms=["HS256"], ) @@ -587,12 +664,12 @@ def test_decode_should_raise_error_if_iat_required_but_not_present(self, jwt): "some": "payload", # iat not present } - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(MissingRequiredClaimError) as exc: jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options={"require": ["iat"]}, algorithms=["HS256"], ) @@ -604,12 +681,12 @@ def test_decode_should_raise_error_if_nbf_required_but_not_present(self, jwt): "some": "payload", # nbf not present } - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") with pytest.raises(MissingRequiredClaimError) as exc: jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options={"require": ["nbf"]}, algorithms=["HS256"], ) @@ -624,7 +701,7 @@ def test_skip_check_signature(self, jwt): ) jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options={"verify_signature": False}, algorithms=["HS256"], ) @@ -634,10 +711,10 @@ def test_skip_check_iat(self, jwt): "some": "payload", "iat": datetime.now(tz=timezone.utc) + timedelta(days=1), } - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options={"verify_iat": False}, algorithms=["HS256"], ) @@ -647,10 +724,10 @@ def test_skip_check_nbf(self, jwt): "some": "payload", "nbf": datetime.now(tz=timezone.utc) + timedelta(days=1), } - token = jwt.encode(payload, "secret") + token = jwt.encode(payload, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") jwt.decode( token, - "secret", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", options={"verify_nbf": False}, algorithms=["HS256"], ) @@ -665,16 +742,20 @@ def default(self, o): data = {"some_decimal": Decimal("2.2")} with pytest.raises(TypeError): - jwt.encode(data, "secret", algorithms=["HS256"]) + jwt.encode(data, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"]) - token = jwt.encode(data, "secret", json_encoder=CustomJSONEncoder) - payload = jwt.decode(token, "secret", algorithms=["HS256"]) + token = jwt.encode( + data, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", json_encoder=CustomJSONEncoder + ) + payload = jwt.decode( + token, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", algorithms=["HS256"] + ) assert payload == {"some_decimal": "it worked"} def test_decode_with_verify_exp_option(self, jwt, payload): payload["exp"] = utc_timestamp() - 1 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) jwt.decode( @@ -694,7 +775,7 @@ def test_decode_with_verify_exp_option(self, jwt, payload): def test_decode_with_verify_exp_option_and_signature_off(self, jwt, payload): payload["exp"] = utc_timestamp() - 1 - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) jwt.decode( @@ -709,7 +790,7 @@ def test_decode_with_verify_exp_option_and_signature_off(self, jwt, payload): ) def test_decode_with_optional_algorithms(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) with pytest.raises(DecodeError) as exc: @@ -721,13 +802,13 @@ def test_decode_with_optional_algorithms(self, jwt, payload): ) def test_decode_no_algorithms_verify_signature_false(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) jwt.decode(jwt_message, secret, options={"verify_signature": False}) def test_decode_legacy_verify_warning(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) with pytest.deprecated_call(): @@ -745,13 +826,13 @@ def test_decode_legacy_verify_warning(self, jwt, payload): def test_decode_no_options_mutation(self, jwt, payload): options = {"verify_signature": True} orig_options = options.copy() - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) jwt.decode(jwt_message, secret, options=options, algorithms=["HS256"]) assert options == orig_options def test_decode_warns_on_unsupported_kwarg(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) with pytest.warns(RemovedInPyjwt3Warning) as record: @@ -760,7 +841,7 @@ def test_decode_warns_on_unsupported_kwarg(self, jwt, payload): assert "foo" in str(record[0].message) def test_decode_complete_warns_on_unsupported_kwarg(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret) with pytest.warns(RemovedInPyjwt3Warning) as record: @@ -769,7 +850,7 @@ def test_decode_complete_warns_on_unsupported_kwarg(self, jwt, payload): assert "foo" in str(record[0].message) def test_decode_strict_aud_forbids_list_audience(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" payload["aud"] = "urn:foo" jwt_message = jwt.encode(payload, secret) @@ -793,7 +874,7 @@ def test_decode_strict_aud_forbids_list_audience(self, jwt, payload): ) def test_decode_strict_aud_forbids_list_claim(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" payload["aud"] = ["urn:foo", "urn:bar"] jwt_message = jwt.encode(payload, secret) @@ -819,7 +900,7 @@ def test_decode_strict_aud_forbids_list_claim(self, jwt, payload): ) def test_decode_strict_aud_does_not_match(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" payload["aud"] = "urn:foo" jwt_message = jwt.encode(payload, secret) @@ -835,7 +916,7 @@ def test_decode_strict_aud_does_not_match(self, jwt, payload): ) def test_decode_strict_ok(self, jwt, payload): - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" payload["aud"] = "urn:foo" jwt_message = jwt.encode(payload, secret) @@ -853,14 +934,14 @@ def test_encode_decode_sub_claim(self, jwt): payload = { "sub": "user123", } - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode(payload, secret, algorithm="HS256") decoded = jwt.decode(token, secret, algorithms=["HS256"]) assert decoded["sub"] == "user123" def test_decode_without_and_not_required_sub_claim(self, jwt): - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode({}, secret, algorithm="HS256") decoded = jwt.decode(token, secret, algorithms=["HS256"]) @@ -868,7 +949,7 @@ def test_decode_without_and_not_required_sub_claim(self, jwt): assert "sub" not in decoded def test_decode_missing_sub_but_required_claim(self, jwt): - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode({}, secret, algorithm="HS256") with pytest.raises(MissingRequiredClaimError): @@ -880,7 +961,7 @@ def test_decode_invalid_int_sub_claim(self, jwt): payload = { "sub": 1224344, } - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode(payload, secret, algorithm="HS256") with pytest.raises(InvalidSubjectError): @@ -890,7 +971,7 @@ def test_decode_with_valid_sub_claim(self, jwt): payload = { "sub": "user123", } - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode(payload, secret, algorithm="HS256") decoded = jwt.decode(token, secret, algorithms=["HS256"], subject="user123") @@ -901,7 +982,7 @@ def test_decode_with_invalid_sub_claim(self, jwt): payload = { "sub": "user123", } - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode(payload, secret, algorithm="HS256") with pytest.raises(InvalidSubjectError) as exc_info: @@ -913,7 +994,7 @@ def test_decode_with_sub_claim_and_none_subject(self, jwt): payload = { "sub": "user789", } - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode(payload, secret, algorithm="HS256") decoded = jwt.decode(token, secret, algorithms=["HS256"], subject=None) @@ -925,7 +1006,7 @@ def test_encode_decode_with_valid_jti_claim(self, jwt): payload = { "jti": "unique-id-456", } - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode(payload, secret, algorithm="HS256") decoded = jwt.decode(token, secret, algorithms=["HS256"]) @@ -933,7 +1014,7 @@ def test_encode_decode_with_valid_jti_claim(self, jwt): def test_decode_missing_jti_when_required_claim(self, jwt): payload = {"name": "Bob", "admin": False} - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode(payload, secret, algorithm="HS256") with pytest.raises(MissingRequiredClaimError) as exc_info: @@ -944,7 +1025,7 @@ def test_decode_missing_jti_when_required_claim(self, jwt): assert "jti" in str(exc_info.value) def test_decode_missing_jti_claim(self, jwt): - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode({}, secret, algorithm="HS256") decoded = jwt.decode(token, secret, algorithms=["HS256"]) @@ -956,7 +1037,7 @@ def test_jti_claim_with_invalid_int_value(self, jwt): payload = { "jti": special_jti, } - secret = "your-256-bit-secret" + secret = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" token = jwt.encode(payload, secret, algorithm="HS256") with pytest.raises(InvalidJTIError): diff --git a/tests/test_compressed_jwt.py b/tests/test_compressed_jwt.py index 21fac3fe7..a38e8d9b7 100644 --- a/tests/test_compressed_jwt.py +++ b/tests/test_compressed_jwt.py @@ -15,13 +15,13 @@ def _decode_payload(self, decoded): def test_decodes_complete_valid_jwt_with_compressed_payload(): # Test case from https://github.com/jpadilla/pyjwt/pull/753/files example_payload = {"hello": "world"} - example_secret = "secret" + example_secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # payload made with the pako (https://nodeca.github.io/pako/) library in Javascript: # Buffer.from(pako.deflateRaw('{"hello": "world"}')).toString('base64') example_jwt = ( - b"eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9" - b".q1bKSM3JyVeyUlAqzy/KSVGqBQA=" - b".08wHYeuh1rJXmcBcMrz6NxmbxAnCQp2rGTKfRNIkxiw=" + b"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + b".q1bKSM3JyVeyUlAqzy_KSVGqBQA" + b".vkKB9BEuLsUnHbA6GBhk2MlmBRZuzH8Fo2GmBqzFdgc" ) decoded = CompressedPyJWT().decode_complete( example_jwt, example_secret, algorithms=["HS256"] @@ -31,7 +31,7 @@ def test_decodes_complete_valid_jwt_with_compressed_payload(): "header": {"alg": "HS256", "typ": "JWT"}, "payload": example_payload, "signature": ( - b"\xd3\xcc\x07a\xeb\xa1\xd6\xb2W\x99\xc0\\2\xbc\xfa7" - b"\x19\x9b\xc4\t\xc2B\x9d\xab\x192\x9fD\xd2$\xc6," + b"\xbeB\x81\xf4\x11..\xc5'\x1d\xb0:\x18\x18d\xd8\xc9f\x05\x16n" + b"\xcc\x7f\x05\xa3a\xa6\x06\xac\xc5v\x07" ), } diff --git a/tests/test_cve_fix.py b/tests/test_cve_fix.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 126fc9b7d..857f84431 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -12,7 +12,7 @@ def test_encode_decode(): """ payload = {"iss": "jeff", "exp": utc_timestamp() + 15, "claim": "insanity"} - secret = "secret" + secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" jwt_message = jwt.encode(payload, secret, algorithm="HS256") decoded_payload = jwt.decode(jwt_message, secret, algorithms=["HS256"]) From 1e2a9337a9904506c30f01f74e3e43c551e790e4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 20:24:30 +0000 Subject: [PATCH 02/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_algorithms.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index 994806bf3..65d05d5d9 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -1307,6 +1307,7 @@ def test_hmac_get_min_key_length_unknown_algorithm(self): """Test minimum key length for unknown hash algorithm.""" # Create an HMAC algorithm with a different hash function import hashlib + algo = HMACAlgorithm(hashlib.sha1) # Use SHA1 as "unknown" algorithm assert algo._get_min_key_length() == 32 # Should default to 32 bytes @@ -1318,7 +1319,10 @@ def test_hmac_prepare_key_rejects_short_key_hs256(self): with pytest.raises(InvalidKeyError) as excinfo: algo.prepare_key(short_key) - assert "HMAC key must be at least 256 bits (32 bytes) for HS256 algorithm" in str(excinfo.value) + assert ( + "HMAC key must be at least 256 bits (32 bytes) for HS256 algorithm" + in str(excinfo.value) + ) assert "Key provided is 40 bits (5 bytes)" in str(excinfo.value) def test_hmac_prepare_key_rejects_short_key_hs384(self): @@ -1329,7 +1333,10 @@ def test_hmac_prepare_key_rejects_short_key_hs384(self): with pytest.raises(InvalidKeyError) as excinfo: algo.prepare_key(short_key) - assert "HMAC key must be at least 384 bits (48 bytes) for HS384 algorithm" in str(excinfo.value) + assert ( + "HMAC key must be at least 384 bits (48 bytes) for HS384 algorithm" + in str(excinfo.value) + ) assert "Key provided is 256 bits (32 bytes)" in str(excinfo.value) def test_hmac_prepare_key_rejects_short_key_hs512(self): @@ -1340,19 +1347,26 @@ def test_hmac_prepare_key_rejects_short_key_hs512(self): with pytest.raises(InvalidKeyError) as excinfo: algo.prepare_key(short_key) - assert "HMAC key must be at least 512 bits (64 bytes) for HS512 algorithm" in str(excinfo.value) + assert ( + "HMAC key must be at least 512 bits (64 bytes) for HS512 algorithm" + in str(excinfo.value) + ) assert "Key provided is 384 bits (48 bytes)" in str(excinfo.value) def test_hmac_prepare_key_rejects_short_key_unknown_algorithm(self): """Test unknown hash algorithm rejects keys shorter than 32 bytes.""" import hashlib + algo = HMACAlgorithm(hashlib.sha1) # Unknown algorithm short_key = b"short" # Only 5 bytes with pytest.raises(InvalidKeyError) as excinfo: algo.prepare_key(short_key) - assert "HMAC key must be at least 256 bits (32 bytes) for HMAC algorithm" in str(excinfo.value) + assert ( + "HMAC key must be at least 256 bits (32 bytes) for HMAC algorithm" + in str(excinfo.value) + ) def test_hmac_from_jwk_rejects_short_key(self): """Test HMAC from_jwk rejects short keys.""" From cf829444aaf87b9ff02f64f2c332b501517553e1 Mon Sep 17 00:00:00 2001 From: Aman Jolhe Date: Thu, 21 Aug 2025 12:24:28 +0530 Subject: [PATCH 03/11] remove blank file --- tests/test_cve_fix.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/test_cve_fix.py diff --git a/tests/test_cve_fix.py b/tests/test_cve_fix.py deleted file mode 100644 index e69de29bb..000000000 From cf79c9be9658d3b86ce18787ec72976844c0b89c Mon Sep 17 00:00:00 2001 From: Aman Jolhe Date: Fri, 22 Aug 2025 17:22:12 +0530 Subject: [PATCH 04/11] security: Replace weak keys in documentation examples for CVE-2025-45768 --- CHANGELOG.rst | 1 + docs/index.rst | 4 ++-- docs/usage.rst | 56 +++++++++++++++++++++++++------------------------- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fbfa6c846..0ece7267c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ This project adheres to `Semantic Versioning `__. Fixed ~~~~~ +- **Security**: Implement minimum key length validation for HMAC and RSA algorithms to address CVE-2025-45768. HMAC algorithms now require keys of at least 32 bytes (HS256), 48 bytes (HS384), and 64 bytes (HS512). RSA algorithms now require keys of at least 2048 bits as per RFC 7518 and NIST SP800-117 recommendations in `#1085 `__ - Validate key against allowed types for Algorithm family in `#964 `__ - Add iterator for JWKSet in `#1041 `__ - Validate `iss` claim is a string during encoding and decoding by @pachewise in `#1040 `__ diff --git a/docs/index.rst b/docs/index.rst index e4428d17f..c18299ec5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,8 +30,8 @@ Example Usage .. doctest:: >>> import jwt - >>> encoded_jwt = jwt.encode({"some": "payload"}, "secret", algorithm="HS256") - >>> jwt.decode(encoded_jwt, "secret", algorithms=["HS256"]) + >>> encoded_jwt = jwt.encode({"some": "payload"}, "your-256-bit-secret-key-here-32chars", algorithm="HS256") + >>> jwt.decode(encoded_jwt, "your-256-bit-secret-key-here-32chars", algorithms=["HS256"]) {'some': 'payload'} See :doc:`Usage Examples ` for more examples. diff --git a/docs/usage.rst b/docs/usage.rst index e3cfc74b6..19f79e395 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -7,7 +7,7 @@ Encoding & Decoding Tokens with HS256 .. code-block:: pycon >>> import jwt - >>> key = "secret" + >>> key = "your-256-bit-secret-key-here-32chars" >>> encoded = jwt.encode({"some": "payload"}, key, algorithm="HS256") >>> jwt.decode(encoded, key, algorithms="HS256") {'some': 'payload'} @@ -95,7 +95,7 @@ Specifying Additional Headers >>> jwt.encode( ... {"some": "payload"}, - ... "secret", + ... "your-256-bit-secret-key-here-32chars", ... algorithm="HS256", ... headers={"kid": "230498151c214b788dd97f22b85410a5"}, ... ) @@ -108,7 +108,7 @@ By default the ``typ`` is attaching to the headers. In case when you don't need >>> jwt.encode( ... {"some": "payload"}, - ... "secret", + ... "your-256-bit-secret-key-here-32chars", ... algorithm="HS256", ... headers={"typ": None}, ... ) @@ -143,7 +143,7 @@ key in the header. >>> encoded = jwt.encode( ... {"some": "payload"}, - ... "secret", + ... "your-256-bit-secret-key-here-32chars", ... algorithm="HS256", ... headers={"kid": "230498151c214b788dd97f22b85410a5"}, ... ) @@ -179,8 +179,8 @@ datetime, which will be converted into an int. For example: .. code-block:: pycon >>> from datetime import datetime, timezone - >>> token = jwt.encode({"exp": 1371720939}, "secret") - >>> token = jwt.encode({"exp": datetime.now(tz=timezone.utc)}, "secret") + >>> token = jwt.encode({"exp": 1371720939}, "your-256-bit-secret-key-here-32chars") + >>> token = jwt.encode({"exp": datetime.now(tz=timezone.utc)}, "your-256-bit-secret-key-here-32chars") Expiration time is automatically verified in `jwt.decode()` and raises `jwt.ExpiredSignatureError` if the expiration time is in the past: @@ -188,7 +188,7 @@ Expiration time is automatically verified in `jwt.decode()` and raises .. code-block:: pycon >>> try: - ... jwt.decode(token, "secret", algorithms=["HS256"]) + ... jwt.decode(token, "your-256-bit-secret-key-here-32chars", algorithms=["HS256"]) ... except jwt.ExpiredSignatureError: ... print("expired") ... @@ -213,11 +213,11 @@ you can set a leeway of 10 seconds in order to have some margin: >>> payload = { ... "exp": datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(seconds=1) ... } - >>> token = jwt.encode(payload, "secret") + >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") >>> time.sleep(2) >>> # JWT payload is now expired >>> # But with some leeway, it will still validate - >>> decoded = jwt.decode(token, "secret", leeway=5, algorithms=["HS256"]) + >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", leeway=5, algorithms=["HS256"]) Instead of specifying the leeway as a number of seconds, a `datetime.timedelta` instance can be used. The last line in the example above is equivalent to: @@ -225,7 +225,7 @@ instance can be used. The last line in the example above is equivalent to: .. code-block:: pycon >>> decoded = jwt.decode( - ... token, "secret", leeway=datetime.timedelta(seconds=10), algorithms=["HS256"] + ... token, "your-256-bit-secret-key-here-32chars", leeway=datetime.timedelta(seconds=10), algorithms=["HS256"] ... ) Not Before Time Claim (nbf) @@ -243,8 +243,8 @@ The `nbf` claim works similarly to the `exp` claim above. .. code-block:: pycon - >>> token = jwt.encode({"nbf": 1371720939}, "secret") - >>> token = jwt.encode({"nbf": datetime.datetime.now(tz=timezone.utc)}, "secret") + >>> token = jwt.encode({"nbf": 1371720939}, "your-256-bit-secret-key-here-32chars") + >>> token = jwt.encode({"nbf": datetime.datetime.now(tz=timezone.utc)}, "your-256-bit-secret-key-here-32chars") The `nbf` claim also supports the leeway feature similar to the `exp` claim. This allows you to validate a “not before” time that is slightly in the future. Using @@ -258,10 +258,10 @@ synchronization between the token issuer and the validator is imprecise. >>> payload = { ... "nbf": datetime.datetime.now(tz=timezone.utc) - datetime.timedelta(seconds=3) ... } - >>> token = jwt.encode(payload, "secret") + >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") >>> # JWT payload is not valid yet >>> # But with some leeway, it will still validate - >>> decoded = jwt.decode(token, "secret", leeway=5, algorithms=["HS256"]) + >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", leeway=5, algorithms=["HS256"]) Issuer Claim (iss) @@ -275,9 +275,9 @@ Issuer Claim (iss) .. code-block:: pycon >>> payload = {"some": "payload", "iss": "urn:foo"} - >>> token = jwt.encode(payload, "secret") + >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") >>> try: - ... jwt.decode(token, "secret", issuer="urn:invalid", algorithms=["HS256"]) + ... jwt.decode(token, "your-256-bit-secret-key-here-32chars", issuer="urn:invalid", algorithms=["HS256"]) ... except jwt.InvalidIssuerError: ... print("invalid issuer") ... @@ -301,9 +301,9 @@ sensitive strings, each containing a StringOrURI value. .. code-block:: pycon >>> payload = {"some": "payload", "aud": ["urn:foo", "urn:bar"]} - >>> token = jwt.encode(payload, "secret") - >>> decoded = jwt.decode(token, "secret", audience="urn:foo", algorithms=["HS256"]) - >>> decoded = jwt.decode(token, "secret", audience="urn:bar", algorithms=["HS256"]) + >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") + >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", audience="urn:foo", algorithms=["HS256"]) + >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", audience="urn:bar", algorithms=["HS256"]) In the special case when the JWT has one audience, the "aud" value MAY be a single case-sensitive string containing a StringOrURI value. @@ -311,8 +311,8 @@ a single case-sensitive string containing a StringOrURI value. .. code-block:: pycon >>> payload = {"some": "payload", "aud": "urn:foo"} - >>> token = jwt.encode(payload, "secret") - >>> decoded = jwt.decode(token, "secret", audience="urn:foo", algorithms=["HS256"]) + >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") + >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", audience="urn:foo", algorithms=["HS256"]) If multiple audiences are accepted, the ``audience`` parameter for ``jwt.decode`` can also be an iterable @@ -320,12 +320,12 @@ If multiple audiences are accepted, the ``audience`` parameter for .. code-block:: pycon >>> payload = {"some": "payload", "aud": "urn:foo"} - >>> token = jwt.encode(payload, "secret") + >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") >>> decoded = jwt.decode( - ... token, "secret", audience=["urn:foo", "urn:bar"], algorithms=["HS256"] + ... token, "your-256-bit-secret-key-here-32chars", audience=["urn:foo", "urn:bar"], algorithms=["HS256"] ... ) >>> try: - ... jwt.decode(token, "secret", audience=["urn:invalid"], algorithms=["HS256"]) + ... jwt.decode(token, "your-256-bit-secret-key-here-32chars", audience=["urn:invalid"], algorithms=["HS256"]) ... except jwt.InvalidAudienceError: ... print("invalid audience") ... @@ -347,8 +347,8 @@ Issued At Claim (iat) .. code-block:: pycon - >>> token = jwt.encode({"iat": 1371720939}, "secret") - >>> token = jwt.encode({"iat": datetime.datetime.now(tz=timezone.utc)}, "secret") + >>> token = jwt.encode({"iat": 1371720939}, "your-256-bit-secret-key-here-32chars") + >>> token = jwt.encode({"iat": datetime.datetime.now(tz=timezone.utc)}, "your-256-bit-secret-key-here-32chars") Requiring Presence of Claims ---------------------------- @@ -357,11 +357,11 @@ If you wish to require one or more claims to be present in the claimset, you can .. code-block:: pycon - >>> token = jwt.encode({"sub": "1234567890", "iat": 1371720939}, "secret") + >>> token = jwt.encode({"sub": "1234567890", "iat": 1371720939}, "your-256-bit-secret-key-here-32chars") >>> try: ... jwt.decode( ... token, - ... "secret", + ... "your-256-bit-secret-key-here-32chars", ... options={"require": ["exp", "iss", "sub"]}, ... algorithms=["HS256"], ... ) From 4db2cf7ffa7e69b4b00101787c0b3099f499fe0d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:52:41 +0000 Subject: [PATCH 05/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/index.rst | 4 ++- docs/usage.rst | 71 +++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index c18299ec5..e2f7900ae 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,7 +30,9 @@ Example Usage .. doctest:: >>> import jwt - >>> encoded_jwt = jwt.encode({"some": "payload"}, "your-256-bit-secret-key-here-32chars", algorithm="HS256") + >>> encoded_jwt = jwt.encode( + ... {"some": "payload"}, "your-256-bit-secret-key-here-32chars", algorithm="HS256" + ... ) >>> jwt.decode(encoded_jwt, "your-256-bit-secret-key-here-32chars", algorithms=["HS256"]) {'some': 'payload'} diff --git a/docs/usage.rst b/docs/usage.rst index 19f79e395..7b527e772 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -180,7 +180,9 @@ datetime, which will be converted into an int. For example: >>> from datetime import datetime, timezone >>> token = jwt.encode({"exp": 1371720939}, "your-256-bit-secret-key-here-32chars") - >>> token = jwt.encode({"exp": datetime.now(tz=timezone.utc)}, "your-256-bit-secret-key-here-32chars") + >>> token = jwt.encode( + ... {"exp": datetime.now(tz=timezone.utc)}, "your-256-bit-secret-key-here-32chars" + ... ) Expiration time is automatically verified in `jwt.decode()` and raises `jwt.ExpiredSignatureError` if the expiration time is in the past: @@ -217,7 +219,9 @@ you can set a leeway of 10 seconds in order to have some margin: >>> time.sleep(2) >>> # JWT payload is now expired >>> # But with some leeway, it will still validate - >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", leeway=5, algorithms=["HS256"]) + >>> decoded = jwt.decode( + ... token, "your-256-bit-secret-key-here-32chars", leeway=5, algorithms=["HS256"] + ... ) Instead of specifying the leeway as a number of seconds, a `datetime.timedelta` instance can be used. The last line in the example above is equivalent to: @@ -225,7 +229,10 @@ instance can be used. The last line in the example above is equivalent to: .. code-block:: pycon >>> decoded = jwt.decode( - ... token, "your-256-bit-secret-key-here-32chars", leeway=datetime.timedelta(seconds=10), algorithms=["HS256"] + ... token, + ... "your-256-bit-secret-key-here-32chars", + ... leeway=datetime.timedelta(seconds=10), + ... algorithms=["HS256"], ... ) Not Before Time Claim (nbf) @@ -244,7 +251,10 @@ The `nbf` claim works similarly to the `exp` claim above. .. code-block:: pycon >>> token = jwt.encode({"nbf": 1371720939}, "your-256-bit-secret-key-here-32chars") - >>> token = jwt.encode({"nbf": datetime.datetime.now(tz=timezone.utc)}, "your-256-bit-secret-key-here-32chars") + >>> token = jwt.encode( + ... {"nbf": datetime.datetime.now(tz=timezone.utc)}, + ... "your-256-bit-secret-key-here-32chars", + ... ) The `nbf` claim also supports the leeway feature similar to the `exp` claim. This allows you to validate a “not before” time that is slightly in the future. Using @@ -261,7 +271,9 @@ synchronization between the token issuer and the validator is imprecise. >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") >>> # JWT payload is not valid yet >>> # But with some leeway, it will still validate - >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", leeway=5, algorithms=["HS256"]) + >>> decoded = jwt.decode( + ... token, "your-256-bit-secret-key-here-32chars", leeway=5, algorithms=["HS256"] + ... ) Issuer Claim (iss) @@ -277,7 +289,12 @@ Issuer Claim (iss) >>> payload = {"some": "payload", "iss": "urn:foo"} >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") >>> try: - ... jwt.decode(token, "your-256-bit-secret-key-here-32chars", issuer="urn:invalid", algorithms=["HS256"]) + ... jwt.decode( + ... token, + ... "your-256-bit-secret-key-here-32chars", + ... issuer="urn:invalid", + ... algorithms=["HS256"], + ... ) ... except jwt.InvalidIssuerError: ... print("invalid issuer") ... @@ -302,8 +319,18 @@ sensitive strings, each containing a StringOrURI value. >>> payload = {"some": "payload", "aud": ["urn:foo", "urn:bar"]} >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") - >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", audience="urn:foo", algorithms=["HS256"]) - >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", audience="urn:bar", algorithms=["HS256"]) + >>> decoded = jwt.decode( + ... token, + ... "your-256-bit-secret-key-here-32chars", + ... audience="urn:foo", + ... algorithms=["HS256"], + ... ) + >>> decoded = jwt.decode( + ... token, + ... "your-256-bit-secret-key-here-32chars", + ... audience="urn:bar", + ... algorithms=["HS256"], + ... ) In the special case when the JWT has one audience, the "aud" value MAY be a single case-sensitive string containing a StringOrURI value. @@ -312,7 +339,12 @@ a single case-sensitive string containing a StringOrURI value. >>> payload = {"some": "payload", "aud": "urn:foo"} >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") - >>> decoded = jwt.decode(token, "your-256-bit-secret-key-here-32chars", audience="urn:foo", algorithms=["HS256"]) + >>> decoded = jwt.decode( + ... token, + ... "your-256-bit-secret-key-here-32chars", + ... audience="urn:foo", + ... algorithms=["HS256"], + ... ) If multiple audiences are accepted, the ``audience`` parameter for ``jwt.decode`` can also be an iterable @@ -322,10 +354,18 @@ If multiple audiences are accepted, the ``audience`` parameter for >>> payload = {"some": "payload", "aud": "urn:foo"} >>> token = jwt.encode(payload, "your-256-bit-secret-key-here-32chars") >>> decoded = jwt.decode( - ... token, "your-256-bit-secret-key-here-32chars", audience=["urn:foo", "urn:bar"], algorithms=["HS256"] + ... token, + ... "your-256-bit-secret-key-here-32chars", + ... audience=["urn:foo", "urn:bar"], + ... algorithms=["HS256"], ... ) >>> try: - ... jwt.decode(token, "your-256-bit-secret-key-here-32chars", audience=["urn:invalid"], algorithms=["HS256"]) + ... jwt.decode( + ... token, + ... "your-256-bit-secret-key-here-32chars", + ... audience=["urn:invalid"], + ... algorithms=["HS256"], + ... ) ... except jwt.InvalidAudienceError: ... print("invalid audience") ... @@ -348,7 +388,10 @@ Issued At Claim (iat) .. code-block:: pycon >>> token = jwt.encode({"iat": 1371720939}, "your-256-bit-secret-key-here-32chars") - >>> token = jwt.encode({"iat": datetime.datetime.now(tz=timezone.utc)}, "your-256-bit-secret-key-here-32chars") + >>> token = jwt.encode( + ... {"iat": datetime.datetime.now(tz=timezone.utc)}, + ... "your-256-bit-secret-key-here-32chars", + ... ) Requiring Presence of Claims ---------------------------- @@ -357,7 +400,9 @@ If you wish to require one or more claims to be present in the claimset, you can .. code-block:: pycon - >>> token = jwt.encode({"sub": "1234567890", "iat": 1371720939}, "your-256-bit-secret-key-here-32chars") + >>> token = jwt.encode( + ... {"sub": "1234567890", "iat": 1371720939}, "your-256-bit-secret-key-here-32chars" + ... ) >>> try: ... jwt.decode( ... token, From 5447843970addb975412a69d7c2910112c8e88b7 Mon Sep 17 00:00:00 2001 From: Aman Jolhe Date: Fri, 22 Aug 2025 17:29:04 +0530 Subject: [PATCH 06/11] Break change log in multiple lines --- CHANGELOG.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0ece7267c..e71b31214 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,7 +9,12 @@ This project adheres to `Semantic Versioning `__. Fixed ~~~~~ -- **Security**: Implement minimum key length validation for HMAC and RSA algorithms to address CVE-2025-45768. HMAC algorithms now require keys of at least 32 bytes (HS256), 48 bytes (HS384), and 64 bytes (HS512). RSA algorithms now require keys of at least 2048 bits as per RFC 7518 and NIST SP800-117 recommendations in `#1085 `__ +- **Security**: Implement minimum key length validation for HMAC and RSA algorithms to address CVE-2025-45768 by @adeshjolhe in `#1085 `__ + + - HMAC algorithms now require keys of at least 32 bytes (HS256), 48 bytes (HS384), and 64 bytes (HS512) + - RSA algorithms now require keys of at least 2048 bits as per RFC 7518 and NIST SP800-117 recommendations + - Added comprehensive validation in both prepare_key() and from_jwk() methods + - Updated documentation examples to use secure key lengths - Validate key against allowed types for Algorithm family in `#964 `__ - Add iterator for JWKSet in `#1041 `__ - Validate `iss` claim is a string during encoding and decoding by @pachewise in `#1040 `__ From f557c3bef3454c87052bfa3ebb9c95fdb15b58a8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:59:44 +0000 Subject: [PATCH 07/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e71b31214..a4dd17797 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,7 +10,7 @@ This project adheres to `Semantic Versioning `__. Fixed ~~~~~ - **Security**: Implement minimum key length validation for HMAC and RSA algorithms to address CVE-2025-45768 by @adeshjolhe in `#1085 `__ - + - HMAC algorithms now require keys of at least 32 bytes (HS256), 48 bytes (HS384), and 64 bytes (HS512) - RSA algorithms now require keys of at least 2048 bits as per RFC 7518 and NIST SP800-117 recommendations - Added comprehensive validation in both prepare_key() and from_jwk() methods From 2ad5765d422d3c4b99153377f8dd2f1fd467d3c5 Mon Sep 17 00:00:00 2001 From: Aman Jolhe Date: Sun, 24 Aug 2025 13:10:14 +0530 Subject: [PATCH 08/11] security: Implement CVE-2025-45768 minimum key length validation with configurable enforcement API --- CHANGELOG.rst | 25 +++++++++++ docs/usage.rst | 88 +++++++++++++++++++++++++++++++++++++ jwt/__init__.py | 6 +++ jwt/algorithms.py | 93 ++++++++++++++++++++++++++++++++++++--- tests/test_api_jwt.py | 100 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a4dd17797..3f0941afb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,31 @@ This project adheres to `Semantic Versioning `__. `Unreleased `__ ------------------------------------------------------------------------ +Security +~~~~~~~~ +- **[CVE-2025-45768]** Added minimum key length validation for HMAC and RSA algorithms to prevent weak encryption by @amanjolhe in `#1085 `__ +- HMAC algorithms now enforce minimum key lengths: HS256 (32 bytes), HS384 (48 bytes), HS512 (64 bytes) +- RSA algorithms now enforce minimum key length of 2048 bits +- Added configurable enforcement via ``set_min_key_length_enforcement()`` and ``get_min_key_length_enforcement()`` +- Validation applies to all key input methods: direct bytes, PEM format, and JWK format +- Complies with security standards: RFC 7518, NIST SP800-117, and RFC 2437 + +Added +~~~~~ +- ``set_min_key_length_enforcement(enforce: bool)`` - Configure key length validation behavior by @amanjolhe in `#1085 `__ +- ``get_min_key_length_enforcement() -> bool`` - Get current validation behavior by @amanjolhe in `#1085 `__ +- Security warnings for weak keys when enforcement is disabled (deprecated mode) + +Changed +~~~~~~~ +- Default behavior now enforces minimum key lengths (can be disabled temporarily) +- Weak keys will raise ``InvalidKeyError`` by default instead of being silently accepted + +Deprecated +~~~~~~~~~~ +- Disabling key length enforcement is deprecated and will be removed in PyJWT 3.0 +- Direct access to ``ENFORCE_MIN_KEY_LENGTH`` variable is deprecated + Fixed ~~~~~ - **Security**: Implement minimum key length validation for HMAC and RSA algorithms to address CVE-2025-45768 by @adeshjolhe in `#1085 `__ diff --git a/docs/usage.rst b/docs/usage.rst index 7b527e772..bec494040 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -504,3 +504,91 @@ is not built into pyjwt. digest = alg_obj.compute_hash_digest(access_token) at_hash = base64.urlsafe_b64encode(digest[: (len(digest) // 2)]).rstrip("=") assert at_hash == payload["at_hash"] + + +Security Considerations +======================= + +Key Length Validation +--------------------- + +Starting with PyJWT 2.11.0, the library enforces minimum key lengths for cryptographic security: + +- **HMAC algorithms**: HS256 (32 bytes), HS384 (48 bytes), HS512 (64 bytes) +- **RSA algorithms**: 2048 bits minimum + +This validation helps prevent weak key attacks and ensures compliance with security standards (RFC 7518, NIST SP800-117, RFC 2437). + +.. code-block:: python + + import jwt + + # These will work (secure keys) + strong_hmac_key = b'your-32-byte-secret-key-here!' # 32 bytes for HS256 + token = jwt.encode({"user": "john"}, strong_hmac_key, algorithm="HS256") + + # This will raise InvalidKeyError (weak key) + weak_key = b'short' # Only 5 bytes + token = jwt.encode({"user": "john"}, weak_key, algorithm="HS256") # Raises InvalidKeyError + +Configuring Key Length Validation +--------------------------------- + +For migration purposes, you can temporarily disable strict enforcement: + +.. code-block:: python + + import jwt + import warnings + + # Check current setting + enforcement = jwt.get_min_key_length_enforcement() + print(f"Enforcement enabled: {enforcement}") # True by default + + # Temporary warning mode (deprecated - for migration only) + jwt.set_min_key_length_enforcement(False) + + with warnings.catch_warnings(): + warnings.simplefilter("always") # Show security warnings + token = jwt.encode({"user": "john"}, weak_key, algorithm="HS256") # Issues warning + + # Re-enable enforcement (recommended) + jwt.set_min_key_length_enforcement(True) + +.. warning:: + Disabling key length enforcement is deprecated and will be removed in PyJWT 3.0. + Please migrate to using cryptographically secure key lengths. + +Generating Secure Keys +--------------------- + +For HMAC algorithms, use the ``secrets`` module to generate cryptographically secure keys: + +.. code-block:: python + + import secrets + + # Generate secure HMAC keys + hs256_key = secrets.token_bytes(32) # 32 bytes = 256 bits + hs384_key = secrets.token_bytes(48) # 48 bytes = 384 bits + hs512_key = secrets.token_bytes(64) # 64 bytes = 512 bits + +For RSA algorithms, use the ``cryptography`` library to generate keys with appropriate bit lengths: + +.. code-block:: python + + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives import serialization + + # Generate secure RSA key (2048 bits minimum) + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048 # or 3072, 4096 for higher security + ) + + # Serialize for use with PyJWT + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) diff --git a/jwt/__init__.py b/jwt/__init__.py index 457a4e358..09485318b 100644 --- a/jwt/__init__.py +++ b/jwt/__init__.py @@ -7,6 +7,10 @@ unregister_algorithm, ) from .api_jwt import PyJWT, decode, decode_complete, encode +from .algorithms import ( + get_min_key_length_enforcement, + set_min_key_length_enforcement, +) from .exceptions import ( DecodeError, ExpiredSignatureError, @@ -55,6 +59,8 @@ "register_algorithm", "unregister_algorithm", "get_algorithm_by_name", + "get_min_key_length_enforcement", + "set_min_key_length_enforcement", # Exceptions "DecodeError", "ExpiredSignatureError", diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 03311bb18..3f266ef5d 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -4,6 +4,7 @@ import hmac import json import os +import warnings from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, ClassVar, Literal, NoReturn, cast, overload @@ -124,6 +125,64 @@ has_crypto = False +# Minimum key length validation configuration +_enforce_min_key_length = True # Private variable +_deprecation_warning_issued = False # Track if deprecation warning was shown + + +def set_min_key_length_enforcement(enforce: bool) -> None: + """ + Configure minimum key length validation behavior. + + Args: + enforce (bool): + - True (default): Raises InvalidKeyError for keys below minimum length + - False: Emits a security warning but allows the operation to continue + + Note: + The ability to disable enforcement is deprecated and will be removed + in PyJWT 3.0. After that version, minimum key length validation will + always be enforced. + + Example: + # Temporary warning mode (deprecated - use only for migration) + jwt.algorithms.set_min_key_length_enforcement(False) + + # Recommended: Use strong keys and keep enforcement enabled (default) + jwt.algorithms.set_min_key_length_enforcement(True) + """ + global _enforce_min_key_length, _deprecation_warning_issued + + _enforce_min_key_length = enforce + + # Issue deprecation warning when disabling enforcement + if not enforce and not _deprecation_warning_issued: + warnings.warn( + "Disabling minimum key length enforcement is deprecated and will be " + "removed in PyJWT 3.0. Please migrate to using cryptographically " + "secure key lengths. See https://pyjwt.readthedocs.io/en/latest/usage.html#security", + DeprecationWarning, + stacklevel=2 + ) + _deprecation_warning_issued = True + + +def get_min_key_length_enforcement() -> bool: + """ + Get the current minimum key length validation behavior. + + Returns: + bool: True if enforcement is enabled, False if only warnings are issued + """ + return _enforce_min_key_length + + +# Backward compatibility - will be removed in PyJWT 3.0 +# Note: Direct access to this variable is deprecated +# Use set_min_key_length_enforcement() and get_min_key_length_enforcement() instead +ENFORCE_MIN_KEY_LENGTH = _enforce_min_key_length + + requires_cryptography = { "RS256", "RS384", @@ -348,12 +407,22 @@ def prepare_key(self, key: str | bytes) -> bytes: alg_name = "HS384" elif self.hash_alg == hashlib.sha512: alg_name = "HS512" - - raise InvalidKeyError( + + message = ( f"HMAC key must be at least {min_key_length * 8} bits " f"({min_key_length} bytes) for {alg_name} algorithm. " f"Key provided is {len(key_bytes) * 8} bits ({len(key_bytes)} bytes)." ) + + if get_min_key_length_enforcement(): + raise InvalidKeyError(message) + else: + warnings.warn( + f"Security Warning: {message} " + "This will be enforced in a future version.", + UserWarning, + stacklevel=2 + ) return key_bytes @@ -401,12 +470,16 @@ def from_jwk(jwk: str | JWKDict) -> bytes: # Validate key length - use a conservative minimum of 32 bytes (256 bits) min_key_length = 32 # 256 bits minimum if len(key_bytes) < min_key_length: - raise InvalidKeyError( + message = ( f"HMAC key must be at least {min_key_length * 8} bits " f"({min_key_length} bytes). Key provided is {len(key_bytes) * 8} " f"bits ({len(key_bytes)} bytes)." ) - + if get_min_key_length_enforcement(): + raise InvalidKeyError(message) + else: + warnings.warn(message, UserWarning, stacklevel=3) + return key_bytes def sign(self, msg: bytes, key: bytes) -> bytes: @@ -439,10 +512,14 @@ def _validate_rsa_key_size(self, key: AllowedRSAKeys) -> None: min_key_size = 2048 # Minimum 2048 bits per RFC 7518 and NIST SP800-117 if key_size < min_key_size: - raise InvalidKeyError( + message = ( f"RSA key must be at least {min_key_size} bits. " f"Key provided is {key_size} bits." ) + if get_min_key_length_enforcement(): + raise InvalidKeyError(message) + else: + warnings.warn(message, UserWarning, stacklevel=3) @staticmethod def _validate_rsa_key_size_static(key: AllowedRSAKeys) -> None: @@ -451,10 +528,14 @@ def _validate_rsa_key_size_static(key: AllowedRSAKeys) -> None: min_key_size = 2048 # Minimum 2048 bits per RFC 7518 and NIST SP800-117 if key_size < min_key_size: - raise InvalidKeyError( + message = ( f"RSA key must be at least {min_key_size} bits. " f"Key provided is {key_size} bits." ) + if get_min_key_length_enforcement(): + raise InvalidKeyError(message) + else: + warnings.warn(message, UserWarning, stacklevel=3) def prepare_key(self, key: AllowedRSAKeys | str | bytes) -> AllowedRSAKeys: if isinstance(key, self._crypto_key_types): diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index 31bf7471a..b6bdce098 100644 --- a/tests/test_api_jwt.py +++ b/tests/test_api_jwt.py @@ -1072,3 +1072,103 @@ def test_validate_iss_with_non_str_issuer(self, jwt): } with pytest.raises(InvalidIssuerError): jwt._validate_iss(payload, issuer=123) + + +class TestKeyLengthValidationAPI: + """Test the new key length validation configuration API.""" + + def test_default_enforcement_enabled(self): + """Test that enforcement is enabled by default.""" + import jwt + assert jwt.get_min_key_length_enforcement() is True + + def test_set_enforcement_to_false_shows_deprecation_warning(self): + """Test that disabling enforcement shows deprecation warning.""" + import jwt + import warnings + + # Reset to True first + jwt.set_min_key_length_enforcement(True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + jwt.set_min_key_length_enforcement(False) + + assert len(w) >= 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "deprecated" in str(w[0].message).lower() + assert "PyJWT 3.0" in str(w[0].message) + + # Verify setting changed + assert jwt.get_min_key_length_enforcement() is False + + # Reset to default + jwt.set_min_key_length_enforcement(True) + + def test_enforcement_mode_affects_behavior(self): + """Test that enforcement mode actually affects key validation behavior.""" + import jwt + import warnings + + weak_key = b'weak' # 4 bytes, below 32-byte minimum + payload = {"test": "data"} + + # Test strict mode (default) + jwt.set_min_key_length_enforcement(True) + with pytest.raises(jwt.InvalidKeyError, match="HMAC key must be at least"): + jwt.encode(payload, weak_key, algorithm="HS256") + + # Test warning mode + jwt.set_min_key_length_enforcement(False) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + token = jwt.encode(payload, weak_key, algorithm="HS256") + decoded = jwt.decode(token, weak_key, algorithms=["HS256"]) + + # Should have security warning + security_warnings = [warning for warning in w + if "Security Warning" in str(warning.message)] + assert len(security_warnings) >= 1 + assert "HMAC key must be at least" in str(security_warnings[0].message) + + # Reset to default + jwt.set_min_key_length_enforcement(True) + + def test_strong_keys_work_in_both_modes(self): + """Test that strong keys work in both enforcement modes.""" + import jwt + import warnings + + strong_key = b'a' * 32 # 32 bytes, meets minimum + payload = {"test": "data"} + + # Test in strict mode + jwt.set_min_key_length_enforcement(True) + token = jwt.encode(payload, strong_key, algorithm="HS256") + decoded = jwt.decode(token, strong_key, algorithms=["HS256"]) + assert decoded == payload + + # Test in warning mode (should not generate warnings for strong keys) + jwt.set_min_key_length_enforcement(False) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + token = jwt.encode(payload, strong_key, algorithm="HS256") + decoded = jwt.decode(token, strong_key, algorithms=["HS256"]) + + # Should not have security warnings for strong keys + security_warnings = [warning for warning in w + if "Security Warning" in str(warning.message)] + assert len(security_warnings) == 0 + + # Reset to default + jwt.set_min_key_length_enforcement(True) + + def test_api_functions_are_exported(self): + """Test that the new API functions are properly exported.""" + import jwt + + # Test that functions exist and are callable + assert hasattr(jwt, 'set_min_key_length_enforcement') + assert hasattr(jwt, 'get_min_key_length_enforcement') + assert callable(jwt.set_min_key_length_enforcement) + assert callable(jwt.get_min_key_length_enforcement) From 67f6ce57dca4340849fc3e5a68ac5f5a37d8f696 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 07:40:42 +0000 Subject: [PATCH 09/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/usage.rst | 35 +++++++++++----------- jwt/__init__.py | 8 ++--- jwt/algorithms.py | 30 +++++++++---------- tests/test_api_jwt.py | 70 +++++++++++++++++++++++-------------------- 4 files changed, 75 insertions(+), 68 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index bec494040..82e1ff29f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -522,14 +522,16 @@ This validation helps prevent weak key attacks and ensures compliance with secur .. code-block:: python import jwt - + # These will work (secure keys) - strong_hmac_key = b'your-32-byte-secret-key-here!' # 32 bytes for HS256 + strong_hmac_key = b"your-32-byte-secret-key-here!" # 32 bytes for HS256 token = jwt.encode({"user": "john"}, strong_hmac_key, algorithm="HS256") - + # This will raise InvalidKeyError (weak key) - weak_key = b'short' # Only 5 bytes - token = jwt.encode({"user": "john"}, weak_key, algorithm="HS256") # Raises InvalidKeyError + weak_key = b"short" # Only 5 bytes + token = jwt.encode( + {"user": "john"}, weak_key, algorithm="HS256" + ) # Raises InvalidKeyError Configuring Key Length Validation --------------------------------- @@ -540,23 +542,23 @@ For migration purposes, you can temporarily disable strict enforcement: import jwt import warnings - + # Check current setting enforcement = jwt.get_min_key_length_enforcement() print(f"Enforcement enabled: {enforcement}") # True by default - + # Temporary warning mode (deprecated - for migration only) jwt.set_min_key_length_enforcement(False) - + with warnings.catch_warnings(): warnings.simplefilter("always") # Show security warnings token = jwt.encode({"user": "john"}, weak_key, algorithm="HS256") # Issues warning - + # Re-enable enforcement (recommended) jwt.set_min_key_length_enforcement(True) .. warning:: - Disabling key length enforcement is deprecated and will be removed in PyJWT 3.0. + Disabling key length enforcement is deprecated and will be removed in PyJWT 3.0. Please migrate to using cryptographically secure key lengths. Generating Secure Keys @@ -567,10 +569,10 @@ For HMAC algorithms, use the ``secrets`` module to generate cryptographically se .. code-block:: python import secrets - + # Generate secure HMAC keys hs256_key = secrets.token_bytes(32) # 32 bytes = 256 bits - hs384_key = secrets.token_bytes(48) # 48 bytes = 384 bits + hs384_key = secrets.token_bytes(48) # 48 bytes = 384 bits hs512_key = secrets.token_bytes(64) # 64 bytes = 512 bits For RSA algorithms, use the ``cryptography`` library to generate keys with appropriate bit lengths: @@ -579,16 +581,15 @@ For RSA algorithms, use the ``cryptography`` library to generate keys with appro from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization - + # Generate secure RSA key (2048 bits minimum) private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048 # or 3072, 4096 for higher security + public_exponent=65537, key_size=2048 # or 3072, 4096 for higher security ) - + # Serialize for use with PyJWT pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() + encryption_algorithm=serialization.NoEncryption(), ) diff --git a/jwt/__init__.py b/jwt/__init__.py index 09485318b..3194523dd 100644 --- a/jwt/__init__.py +++ b/jwt/__init__.py @@ -1,3 +1,7 @@ +from .algorithms import ( + get_min_key_length_enforcement, + set_min_key_length_enforcement, +) from .api_jwk import PyJWK, PyJWKSet from .api_jws import ( PyJWS, @@ -7,10 +11,6 @@ unregister_algorithm, ) from .api_jwt import PyJWT, decode, decode_complete, encode -from .algorithms import ( - get_min_key_length_enforcement, - set_min_key_length_enforcement, -) from .exceptions import ( DecodeError, ExpiredSignatureError, diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 3f266ef5d..656123863 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -133,28 +133,28 @@ def set_min_key_length_enforcement(enforce: bool) -> None: """ Configure minimum key length validation behavior. - + Args: - enforce (bool): + enforce (bool): - True (default): Raises InvalidKeyError for keys below minimum length - False: Emits a security warning but allows the operation to continue - + Note: - The ability to disable enforcement is deprecated and will be removed - in PyJWT 3.0. After that version, minimum key length validation will + The ability to disable enforcement is deprecated and will be removed + in PyJWT 3.0. After that version, minimum key length validation will always be enforced. - + Example: # Temporary warning mode (deprecated - use only for migration) jwt.algorithms.set_min_key_length_enforcement(False) - + # Recommended: Use strong keys and keep enforcement enabled (default) jwt.algorithms.set_min_key_length_enforcement(True) """ global _enforce_min_key_length, _deprecation_warning_issued - + _enforce_min_key_length = enforce - + # Issue deprecation warning when disabling enforcement if not enforce and not _deprecation_warning_issued: warnings.warn( @@ -162,7 +162,7 @@ def set_min_key_length_enforcement(enforce: bool) -> None: "removed in PyJWT 3.0. Please migrate to using cryptographically " "secure key lengths. See https://pyjwt.readthedocs.io/en/latest/usage.html#security", DeprecationWarning, - stacklevel=2 + stacklevel=2, ) _deprecation_warning_issued = True @@ -170,7 +170,7 @@ def set_min_key_length_enforcement(enforce: bool) -> None: def get_min_key_length_enforcement() -> bool: """ Get the current minimum key length validation behavior. - + Returns: bool: True if enforcement is enabled, False if only warnings are issued """ @@ -407,13 +407,13 @@ def prepare_key(self, key: str | bytes) -> bytes: alg_name = "HS384" elif self.hash_alg == hashlib.sha512: alg_name = "HS512" - + message = ( f"HMAC key must be at least {min_key_length * 8} bits " f"({min_key_length} bytes) for {alg_name} algorithm. " f"Key provided is {len(key_bytes) * 8} bits ({len(key_bytes)} bytes)." ) - + if get_min_key_length_enforcement(): raise InvalidKeyError(message) else: @@ -421,7 +421,7 @@ def prepare_key(self, key: str | bytes) -> bytes: f"Security Warning: {message} " "This will be enforced in a future version.", UserWarning, - stacklevel=2 + stacklevel=2, ) return key_bytes @@ -479,7 +479,7 @@ def from_jwk(jwk: str | JWKDict) -> bytes: raise InvalidKeyError(message) else: warnings.warn(message, UserWarning, stacklevel=3) - + return key_bytes def sign(self, msg: bytes, key: bytes) -> bytes: diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index b6bdce098..91d6866bb 100644 --- a/tests/test_api_jwt.py +++ b/tests/test_api_jwt.py @@ -1076,99 +1076,105 @@ def test_validate_iss_with_non_str_issuer(self, jwt): class TestKeyLengthValidationAPI: """Test the new key length validation configuration API.""" - + def test_default_enforcement_enabled(self): """Test that enforcement is enabled by default.""" import jwt + assert jwt.get_min_key_length_enforcement() is True - + def test_set_enforcement_to_false_shows_deprecation_warning(self): """Test that disabling enforcement shows deprecation warning.""" - import jwt import warnings - + + import jwt + # Reset to True first jwt.set_min_key_length_enforcement(True) - + with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") jwt.set_min_key_length_enforcement(False) - + assert len(w) >= 1 assert issubclass(w[0].category, DeprecationWarning) assert "deprecated" in str(w[0].message).lower() assert "PyJWT 3.0" in str(w[0].message) - + # Verify setting changed assert jwt.get_min_key_length_enforcement() is False - + # Reset to default jwt.set_min_key_length_enforcement(True) - + def test_enforcement_mode_affects_behavior(self): """Test that enforcement mode actually affects key validation behavior.""" - import jwt import warnings - - weak_key = b'weak' # 4 bytes, below 32-byte minimum + + import jwt + + weak_key = b"weak" # 4 bytes, below 32-byte minimum payload = {"test": "data"} - + # Test strict mode (default) jwt.set_min_key_length_enforcement(True) with pytest.raises(jwt.InvalidKeyError, match="HMAC key must be at least"): jwt.encode(payload, weak_key, algorithm="HS256") - + # Test warning mode jwt.set_min_key_length_enforcement(False) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") token = jwt.encode(payload, weak_key, algorithm="HS256") decoded = jwt.decode(token, weak_key, algorithms=["HS256"]) - + # Should have security warning - security_warnings = [warning for warning in w - if "Security Warning" in str(warning.message)] + security_warnings = [ + warning for warning in w if "Security Warning" in str(warning.message) + ] assert len(security_warnings) >= 1 assert "HMAC key must be at least" in str(security_warnings[0].message) - + # Reset to default jwt.set_min_key_length_enforcement(True) - + def test_strong_keys_work_in_both_modes(self): """Test that strong keys work in both enforcement modes.""" - import jwt import warnings - - strong_key = b'a' * 32 # 32 bytes, meets minimum + + import jwt + + strong_key = b"a" * 32 # 32 bytes, meets minimum payload = {"test": "data"} - + # Test in strict mode jwt.set_min_key_length_enforcement(True) token = jwt.encode(payload, strong_key, algorithm="HS256") decoded = jwt.decode(token, strong_key, algorithms=["HS256"]) assert decoded == payload - + # Test in warning mode (should not generate warnings for strong keys) jwt.set_min_key_length_enforcement(False) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") token = jwt.encode(payload, strong_key, algorithm="HS256") decoded = jwt.decode(token, strong_key, algorithms=["HS256"]) - + # Should not have security warnings for strong keys - security_warnings = [warning for warning in w - if "Security Warning" in str(warning.message)] + security_warnings = [ + warning for warning in w if "Security Warning" in str(warning.message) + ] assert len(security_warnings) == 0 - + # Reset to default jwt.set_min_key_length_enforcement(True) - + def test_api_functions_are_exported(self): """Test that the new API functions are properly exported.""" import jwt - + # Test that functions exist and are callable - assert hasattr(jwt, 'set_min_key_length_enforcement') - assert hasattr(jwt, 'get_min_key_length_enforcement') + assert hasattr(jwt, "set_min_key_length_enforcement") + assert hasattr(jwt, "get_min_key_length_enforcement") assert callable(jwt.set_min_key_length_enforcement) assert callable(jwt.get_min_key_length_enforcement) From a93544db8a9b82b0fb38dd61c381d42eb503fdd5 Mon Sep 17 00:00:00 2001 From: Aman Jolhe Date: Sun, 24 Aug 2025 13:19:11 +0530 Subject: [PATCH 10/11] Fix pre-commit issue of unused variable --- tests/test_api_jwt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index 91d6866bb..0e718408a 100644 --- a/tests/test_api_jwt.py +++ b/tests/test_api_jwt.py @@ -1126,8 +1126,8 @@ def test_enforcement_mode_affects_behavior(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") token = jwt.encode(payload, weak_key, algorithm="HS256") - decoded = jwt.decode(token, weak_key, algorithms=["HS256"]) - + jwt.decode(token, weak_key, algorithms=["HS256"]) + # Should have security warning security_warnings = [ warning for warning in w if "Security Warning" in str(warning.message) From fc6101fc89c25dde846800bf1c1cbfdc29fd1177 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 07:50:15 +0000 Subject: [PATCH 11/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_api_jwt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index 0e718408a..3ccd495dd 100644 --- a/tests/test_api_jwt.py +++ b/tests/test_api_jwt.py @@ -1127,7 +1127,7 @@ def test_enforcement_mode_affects_behavior(self): warnings.simplefilter("always") token = jwt.encode(payload, weak_key, algorithm="HS256") jwt.decode(token, weak_key, algorithms=["HS256"]) - + # Should have security warning security_warnings = [ warning for warning in w if "Security Warning" in str(warning.message)