Skip to content

Commit 96ae356

Browse files
committed
feat: add minimum key length validation for HMAC and RSA
1 parent 5b86227 commit 96ae356

File tree

15 files changed

+413
-19
lines changed

15 files changed

+413
-19
lines changed

CHANGELOG.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,21 @@ This project adheres to `Semantic Versioning <https://semver.org/>`__.
77
`Unreleased <https://github.com/jpadilla/pyjwt/compare/2.10.1...HEAD>`__
88
------------------------------------------------------------------------
99

10+
Added
11+
~~~~~
12+
13+
- Add minimum key length validation for HMAC and RSA keys (CWE-326).
14+
Warns by default via ``InsecureKeyLengthWarning`` when keys are below
15+
minimum recommended lengths per RFC 7518 Section 3.2 (HMAC) and
16+
NIST SP 800-131A (RSA). Pass ``enforce_minimum_key_length=True`` in
17+
options to ``PyJWT`` or ``PyJWS`` to raise ``InvalidKeyError`` instead.
18+
- Refactor ``PyJWT`` to own an internal ``PyJWS`` instance instead of
19+
calling global ``api_jws`` functions.
20+
1021
Fixed
1122
~~~~~
1223

24+
- Enforce ECDSA curve validation per RFC 7518 Section 3.4.
1325
- Fix build system warnings by @kurtmckee in `#1105 <https://github.com/jpadilla/pyjwt/pull/1105>`__
1426
- Validate key against allowed types for Algorithm family in `#964 <https://github.com/jpadilla/pyjwt/pull/964>`__
1527
- Add iterator for JWKSet in `#1041 <https://github.com/jpadilla/pyjwt/pull/1041>`__

docs/algorithms.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,38 @@ This library currently supports:
1919
* PS512 - RSASSA-PSS signature using SHA-512 and MGF1 padding with SHA-512
2020
* EdDSA - Both Ed25519 signature using SHA-512 and Ed448 signature using SHA-3 are supported. Ed25519 and Ed448 provide 128-bit and 224-bit security respectively.
2121

22+
Minimum Key Length Requirements
23+
-------------------------------
24+
25+
PyJWT enforces minimum key lengths per industry standards. Keys below these
26+
minimums will trigger an ``InsecureKeyLengthWarning`` by default, or raise
27+
``InvalidKeyError`` if ``enforce_minimum_key_length`` is enabled.
28+
29+
.. list-table::
30+
:header-rows: 1
31+
:widths: auto
32+
33+
* - Algorithm
34+
- Minimum Key Length
35+
- Standard
36+
* - HS256
37+
- 32 bytes (256 bits)
38+
- RFC 7518 Section 3.2
39+
* - HS384
40+
- 48 bytes (384 bits)
41+
- RFC 7518 Section 3.2
42+
* - HS512
43+
- 64 bytes (512 bits)
44+
- RFC 7518 Section 3.2
45+
* - RS256/384/512
46+
- 2048 bits
47+
- NIST SP 800-131A
48+
* - PS256/384/512
49+
- 2048 bits
50+
- NIST SP 800-131A
51+
52+
See :ref:`key-length-validation` for configuration details.
53+
2254
Asymmetric (Public-key) Algorithms
2355
----------------------------------
2456
Usage of RSA (RS\*) and EC (EC\*) algorithms require a basic understanding

docs/api.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ Types
5050
:members:
5151
:undoc-members:
5252

53+
Warnings
54+
----------
55+
56+
.. automodule:: jwt.warnings
57+
:members:
58+
:show-inheritance:
59+
5360
Exceptions
5461
----------
5562

docs/usage.rst

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,56 @@ By storing the `jti` of every token you've already processed in a database or ca
404404
If a token with a previously-seen `jti` shows up, you can reject the request, stopping the attack.
405405

406406

407+
.. _key-length-validation:
408+
409+
Key Length Validation
410+
---------------------
411+
412+
PyJWT validates that cryptographic keys meet minimum recommended lengths.
413+
By default, a warning (``InsecureKeyLengthWarning``) is emitted when a key
414+
is too short. You can configure PyJWT to raise an ``InvalidKeyError`` instead.
415+
416+
The minimum key lengths are:
417+
418+
* **HMAC** (HS256, HS384, HS512): Key must be at least as long as the hash
419+
output (32, 48, or 64 bytes respectively), per `RFC 7518 Section 3.2
420+
<https://www.rfc-editor.org/rfc/rfc7518#section-3.2>`_.
421+
* **RSA** (RS256, RS384, RS512, PS256, PS384, PS512): Key must be at least
422+
2048 bits, per `NIST SP 800-131A
423+
<https://csrc.nist.gov/publications/detail/sp/800-131a/rev-2/final>`_.
424+
425+
By default, short keys produce a warning:
426+
427+
.. code-block:: pycon
428+
429+
>>> import jwt
430+
>>> jwt.encode({"some": "payload"}, "short", algorithm="HS256") # doctest: +SKIP
431+
# InsecureKeyLengthWarning: The HMAC key is 5 bytes long, which is below
432+
# the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
433+
434+
To enforce minimum key lengths (raise ``InvalidKeyError`` on short keys),
435+
pass ``enforce_minimum_key_length=True`` in the options when creating a
436+
``PyJWT`` or ``PyJWS`` instance:
437+
438+
.. code-block:: pycon
439+
440+
>>> from jwt import PyJWT
441+
>>> jwt = PyJWT(options={"enforce_minimum_key_length": True})
442+
>>> jwt.encode({"some": "payload"}, "short", algorithm="HS256")
443+
Traceback (most recent call last):
444+
...
445+
jwt.exceptions.InvalidKeyError: The HMAC key is 5 bytes long, ...
446+
447+
To suppress the warning without enforcing, use Python's standard
448+
``warnings`` module:
449+
450+
.. code-block:: python
451+
452+
import warnings
453+
import jwt
454+
455+
warnings.filterwarnings("ignore", category=jwt.InsecureKeyLengthWarning)
456+
407457
Requiring Presence of Claims
408458
----------------------------
409459

jwt/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
PyJWTError,
2727
)
2828
from .jwks_client import PyJWKClient
29+
from .warnings import InsecureKeyLengthWarning
2930

3031
__version__ = "2.10.1"
3132

@@ -55,6 +56,8 @@
5556
"register_algorithm",
5657
"unregister_algorithm",
5758
"get_algorithm_by_name",
59+
# Warnings
60+
"InsecureKeyLengthWarning",
5861
# Exceptions
5962
"DecodeError",
6063
"ExpiredSignatureError",

jwt/algorithms.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,13 @@ def from_jwk(jwk: str | JWKDict) -> Any:
288288
Deserializes a given key from JWK back into a key object
289289
"""
290290

291+
def check_key_length(self, key: Any) -> str | None:
292+
"""
293+
Return a warning message if the key is below the minimum
294+
recommended length for this algorithm, or None if adequate.
295+
"""
296+
return None
297+
291298

292299
class NoneAlgorithm(Algorithm):
293300
"""
@@ -380,6 +387,17 @@ def from_jwk(jwk: str | JWKDict) -> bytes:
380387

381388
return base64url_decode(obj["k"])
382389

390+
def check_key_length(self, key: bytes) -> str | None:
391+
min_length = self.hash_alg().digest_size
392+
if len(key) < min_length:
393+
return (
394+
f"The HMAC key is {len(key)} bytes long, which is below "
395+
f"the minimum recommended length of {min_length} bytes for "
396+
f"{self.hash_alg().name.upper()}. "
397+
f"See RFC 7518 Section 3.2."
398+
)
399+
return None
400+
383401
def sign(self, msg: bytes, key: bytes) -> bytes:
384402
return hmac.new(key, msg, self.hash_alg).digest()
385403

@@ -400,10 +418,20 @@ class RSAAlgorithm(Algorithm):
400418
SHA512: ClassVar[type[hashes.HashAlgorithm]] = hashes.SHA512
401419

402420
_crypto_key_types = ALLOWED_RSA_KEY_TYPES
421+
_MIN_KEY_SIZE: ClassVar[int] = 2048
403422

404423
def __init__(self, hash_alg: type[hashes.HashAlgorithm]) -> None:
405424
self.hash_alg = hash_alg
406425

426+
def check_key_length(self, key: AllowedRSAKeys) -> str | None:
427+
if key.key_size < self._MIN_KEY_SIZE:
428+
return (
429+
f"The RSA key is {key.key_size} bits long, which is below "
430+
f"the minimum recommended size of {self._MIN_KEY_SIZE} bits. "
431+
f"See NIST SP 800-131A."
432+
)
433+
return None
434+
407435
def prepare_key(self, key: AllowedRSAKeys | str | bytes) -> AllowedRSAKeys:
408436
if isinstance(key, self._crypto_key_types):
409437
return key

jwt/api_jws.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616
from .exceptions import (
1717
DecodeError,
1818
InvalidAlgorithmError,
19+
InvalidKeyError,
1920
InvalidSignatureError,
2021
InvalidTokenError,
2122
)
2223
from .utils import base64url_decode, base64url_encode
23-
from .warnings import RemovedInPyjwt3Warning
24+
from .warnings import InsecureKeyLengthWarning, RemovedInPyjwt3Warning
2425

2526
if TYPE_CHECKING:
2627
from .algorithms import AllowedPrivateKeys, AllowedPublicKeys
@@ -51,7 +52,7 @@ def __init__(
5152

5253
@staticmethod
5354
def _get_default_options() -> SigOptions:
54-
return {"verify_signature": True}
55+
return {"verify_signature": True, "enforce_minimum_key_length": False}
5556

5657
def register_algorithm(self, alg_id: str, alg_obj: Algorithm) -> None:
5758
"""
@@ -180,6 +181,14 @@ def encode(
180181
if isinstance(key, PyJWK):
181182
key = key.key
182183
key = alg_obj.prepare_key(key)
184+
185+
key_length_msg = alg_obj.check_key_length(key)
186+
if key_length_msg:
187+
if self.options.get("enforce_minimum_key_length", False):
188+
raise InvalidKeyError(key_length_msg)
189+
else:
190+
warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=2)
191+
183192
signature = alg_obj.sign(signing_input, key)
184193

185194
segments.append(base64url_encode(signature))
@@ -339,6 +348,13 @@ def _verify_signature(
339348
raise InvalidAlgorithmError("Algorithm not supported") from e
340349
prepared_key = alg_obj.prepare_key(key)
341350

351+
key_length_msg = alg_obj.check_key_length(prepared_key)
352+
if key_length_msg:
353+
if self.options.get("enforce_minimum_key_length", False):
354+
raise InvalidKeyError(key_length_msg)
355+
else:
356+
warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4)
357+
342358
if not alg_obj.verify(signing_input, prepared_key, signature):
343359
raise InvalidSignatureError("Signature verification failed")
344360

jwt/api_jwt.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from datetime import datetime, timedelta, timezone
99
from typing import TYPE_CHECKING, Any, Union, cast
1010

11-
from . import api_jws
11+
from .api_jws import PyJWS, _jws_global_obj
1212
from .exceptions import (
1313
DecodeError,
1414
ExpiredSignatureError,
@@ -52,6 +52,8 @@ def __init__(self, options: Options | None = None) -> None:
5252
if options is not None:
5353
self.options = self._merge_options(options)
5454

55+
self._jws = PyJWS(options=self._get_sig_options())
56+
5557
@staticmethod
5658
def _get_default_options() -> FullOptions:
5759
return {
@@ -65,6 +67,15 @@ def _get_default_options() -> FullOptions:
6567
"verify_jti": True,
6668
"require": [],
6769
"strict_aud": False,
70+
"enforce_minimum_key_length": False,
71+
}
72+
73+
def _get_sig_options(self) -> SigOptions:
74+
return {
75+
"verify_signature": self.options["verify_signature"],
76+
"enforce_minimum_key_length": self.options.get(
77+
"enforce_minimum_key_length", False
78+
),
6879
}
6980

7081
def _merge_options(self, options: Options | None = None) -> FullOptions:
@@ -139,7 +150,7 @@ def encode(
139150
json_encoder=json_encoder,
140151
)
141152

142-
return api_jws.encode(
153+
return self._jws.encode(
143154
json_payload,
144155
key,
145156
algorithm,
@@ -249,8 +260,12 @@ def decode_complete(
249260
stacklevel=2,
250261
)
251262

252-
sig_options: SigOptions = {"verify_signature": verify_signature}
253-
decoded = api_jws.decode_complete(
263+
merged_options = self._merge_options(options)
264+
265+
sig_options: SigOptions = {
266+
"verify_signature": verify_signature,
267+
}
268+
decoded = self._jws.decode_complete(
254269
jwt,
255270
key=key,
256271
algorithms=algorithms,
@@ -260,7 +275,6 @@ def decode_complete(
260275

261276
payload = self._decode_payload(decoded)
262277

263-
merged_options = self._merge_options(options)
264278
self._validate_claims(
265279
payload,
266280
merged_options,
@@ -576,6 +590,7 @@ def _validate_iss(
576590

577591

578592
_jwt_global_obj = PyJWT()
593+
_jwt_global_obj._jws = _jws_global_obj
579594
encode = _jwt_global_obj.encode
580595
decode_complete = _jwt_global_obj.decode_complete
581596
decode = _jwt_global_obj.decode

jwt/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class SigOptions(TypedDict):
1111

1212
verify_signature: bool
1313
"""verify the JWT cryptographic signature"""
14+
enforce_minimum_key_length: bool
15+
"""Default: ``False``. Raise :py:class:`jwt.exceptions.InvalidKeyError` instead of warning when keys are below minimum recommended length."""
1416

1517

1618
class Options(TypedDict, total=False):
@@ -47,6 +49,8 @@ class Options(TypedDict, total=False):
4749
"""Default: ``verify_signature``. Check that ``nbf`` (not before) claim value is in the past (if present in payload). """
4850
verify_sub: bool
4951
"""Default: ``verify_signature``. Check that ``sub`` (subject) claim is a string and matches ``subject`` (if present in payload). """
52+
enforce_minimum_key_length: bool
53+
"""Default: ``False``. Raise :py:class:`jwt.exceptions.InvalidKeyError` instead of warning when keys are below minimum recommended length."""
5054

5155

5256
# The only difference between Options and FullOptions is that FullOptions
@@ -62,3 +66,4 @@ class FullOptions(TypedDict):
6266
verify_jti: bool
6367
verify_nbf: bool
6468
verify_sub: bool
69+
enforce_minimum_key_length: bool

jwt/warnings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
11
class RemovedInPyjwt3Warning(DeprecationWarning):
2+
"""Warning for features that will be removed in PyJWT 3."""
3+
4+
pass
5+
6+
7+
class InsecureKeyLengthWarning(UserWarning):
8+
"""Warning emitted when a cryptographic key is shorter than the minimum
9+
recommended length. See :ref:`key-length-validation` for details."""
10+
211
pass

0 commit comments

Comments
 (0)