diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b36e523b..1fa4f657 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,29 +15,27 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "pypy3.9"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9"] os: [ubuntu-latest, macos-latest, windows-latest] exclude: - os: macos-latest - python-version: "pypy3.9" - - os: macos-latest - python-version: "3.7" + python-version: "pypy3.9" - os: windows-latest python-version: "pypy3.9" runs-on: ${{ matrix.os }} name: "${{ matrix.os }} Python: ${{ matrix.python-version }}" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - pip install -U "pip>=23.1.2" - pip install -U "tox-gh-actions==3.1.0" coverage + pip install -U "pip>=25.1.1" + pip install -U "tox-gh-actions==3.3.0" coverage - name: Log python & pip versions run: | python --version @@ -53,23 +51,23 @@ jobs: linting: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" - name: Install dependencies run: | pip install -U setuptools - pip install -U "tox>=4.5.1,<5" + pip install -U "tox>=4.26.0,<5" - run: tox -e lint package: name: Build & verify package runs-on: "ubuntu-latest" steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.12" - name: Install build, check-wheel-content, and twine run: "python -m pip install build twine check-wheel-contents" - name: Build package diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..b468076c --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,35 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + # You can also specify other tool versions: + # nodejs: "20" + # rust: "1.70" + # golang: "1.20" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + # builder: "dirhtml" + # Fail on all warnings to avoid broken references + # fail_on_warning: true + +# Optionally build your docs in additional formats such as PDF and ePub +# formats: +# - pdf +# - epub + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3c90d2..892b5c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog # +## 3.5.0 -- 2025-05-28 ## + +### News ### + +* Remove support for Python 3.8 +* Added support for Python 3.12 & 3.13 +* Upgrade to pyasn1 0.5.1+ +* Upgrade to pytest and other dependencies +* Add RTD config file to silence emailed deprecation warnings + +### Bug fixes and Improvements ### + +* Remove get_random_bytes from cryptography backend +* Do not use `utc_now` on module level +* Remove key data (sensitive information) from JWKError exceptions +* Added possibility to call jwk.construct() with a private RSA key + ## 3.4.0 -- 2025-02-14 ## ### News ### @@ -8,12 +25,13 @@ * Added support for Python 3.10 and 3.11 ### Bug fixes and Improvements ### + * Updating `CryptographyAESKey::encrypt` to generate 96 bit IVs for GCM block cipher mode * Fix for PEM key comparisons caused by line lengths and new lines * Fix for CVE-2024-33664 - JWE limited to 250KiB * Fix for CVE-2024-33663 - signing JWT with public key is now forbidden -* Replace usage of deprecated datetime.utcnow() with datetime.now(UTC) +* Replace usage of deprecated datetime.utcnow() with datetime.now(UTC) ### Housekeeping ### @@ -69,14 +87,14 @@ This is a greatly overdue release. * Improve `JWT.decode()` #76 (fixes #75) * Sort headers when serializing to allow for headless JWT #136 (fixes #80) * Adjust dependency handling - - Use PyCryptodome instead of PyCrypto #83 - - Update package dependencies #124 (fixes #158) + * Use PyCryptodome instead of PyCrypto #83 + * Update package dependencies #124 (fixes #158) * Avoid using deprecated methods #85 * Support X509 certificates #107 * Isolate and flesh out cryptographic backends to enable independent operation #129 (fixes #114) - - Remove pyca/cryptography backend's dependency on python-ecdsa #117 - - Remove pycrypto/dome backends' dependency on python-rsa #121 - - Make pyca/cryptography backend the preferred backend if multiple backends are present #122 + * Remove pyca/cryptography backend's dependency on python-ecdsa #117 + * Remove pycrypto/dome backends' dependency on python-rsa #121 + * Make pyca/cryptography backend the preferred backend if multiple backends are present #122 ### Bugfixes/Improvements ### diff --git a/README.rst b/README.rst index cfdaa1e5..9b9d03dc 100644 --- a/README.rst +++ b/README.rst @@ -87,8 +87,8 @@ This library was originally based heavily on the work of the folks over at PyJWT .. |pypi| image:: https://img.shields.io/pypi/v/python-jose?style=flat-square :target: https://pypi.org/project/python-jose/ :alt: PyPI -.. |Github Actions CI Status| image:: https://github.com/mpdavis/python-jose/workflows/main/badge.svg?branch=master - :target: https://github.com/mpdavis/python-jose/actions?workflow=main +.. |Github Actions CI Status| image:: https://github.com/mpdavis/python-jose/actions/workflows/ci.yml/badge.svg + :target: https://github.com/mpdavis/python-jose/actions/workflows/ci.yml :alt: Github Actions CI Status .. |Coverage Status| image:: http://codecov.io/github/mpdavis/python-jose/coverage.svg?branch=master :target: http://codecov.io/github/mpdavis/python-jose?branch=master diff --git a/TODO.md b/TODO.md index dd643a1e..779a15a6 100644 --- a/TODO.md +++ b/TODO.md @@ -14,6 +14,6 @@ * Refactor Algorithm logic with set Exceptions to return * Add HTML documentation * Implement ECSDA signing -* Refactor JWT claims verifcation +* Refactor JWT claims verification * Add actual exceptions instead of using the base exception * Audit JWT claims tests and rectify against the spec diff --git a/docs/jwk/index.rst b/docs/jwk/index.rst index ba82c985..544b3888 100644 --- a/docs/jwk/index.rst +++ b/docs/jwk/index.rst @@ -26,7 +26,7 @@ Verifying token signatures >>> key = jwk.construct(hmac_key) >>> >>> message, encoded_sig = token.rsplit('.', 1) - >>> decoded_sig = base64url_decode(encoded_sig) + >>> decoded_sig = base64url_decode(encoded_sig.encode()) >>> key.verify(message, decoded_sig) diff --git a/jose/__init__.py b/jose/__init__.py index 10bd7cdf..7e53b60c 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.4.0" +__version__ = "3.5.0" __author__ = "Michael Davis" __license__ = "MIT" __copyright__ = "Copyright 2016 Michael Davis" diff --git a/jose/backends/__init__.py b/jose/backends/__init__.py index e7bba690..99189691 100644 --- a/jose/backends/__init__.py +++ b/jose/backends/__init__.py @@ -1,10 +1,4 @@ -try: - from jose.backends.cryptography_backend import get_random_bytes # noqa: F401 -except ImportError: - try: - from jose.backends.pycrypto_backend import get_random_bytes # noqa: F401 - except ImportError: - from jose.backends.native import get_random_bytes # noqa: F401 +from jose.backends.native import get_random_bytes # noqa: F401 try: from jose.backends.cryptography_backend import CryptographyRSAKey as RSAKey # noqa: F401 diff --git a/jose/backends/cryptography_backend.py b/jose/backends/cryptography_backend.py index 1525cf26..ec836b4c 100644 --- a/jose/backends/cryptography_backend.py +++ b/jose/backends/cryptography_backend.py @@ -3,7 +3,6 @@ from cryptography.exceptions import InvalidSignature, InvalidTag from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.bindings.openssl.binding import Binding from cryptography.hazmat.primitives import hashes, hmac, serialization from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature @@ -25,34 +24,12 @@ is_ssh_key, long_to_base64, ) +from . import get_random_bytes from .base import Key _binding = None -def get_random_bytes(num_bytes): - """ - Get random bytes - - Currently, Cryptography returns OS random bytes. If you want OpenSSL - generated random bytes, you'll have to switch the RAND engine after - initializing the OpenSSL backend - Args: - num_bytes (int): Number of random bytes to generate and return - Returns: - bytes: Random bytes - """ - global _binding - - if _binding is None: - _binding = Binding() - - buf = _binding.ffi.new("char[]", num_bytes) - _binding.lib.RAND_bytes(buf, num_bytes) - rand_bytes = _binding.ffi.buffer(buf, num_bytes)[:] - return rand_bytes - - class CryptographyECKey(Key): SHA256 = hashes.SHA256 SHA384 = hashes.SHA384 @@ -251,8 +228,8 @@ def __init__(self, key, algorithm, cryptography_backend=default_backend): self.cryptography_backend = cryptography_backend - # if it conforms to RSAPublicKey interface - if hasattr(key, "public_bytes") and hasattr(key, "public_numbers"): + # if it conforms to RSAPublicKey or RSAPrivateKey interface + if (hasattr(key, "public_bytes") and hasattr(key, "public_numbers")) or hasattr(key, "private_bytes"): self.prepared_key = key return diff --git a/jose/jwe.py b/jose/jwe.py index c1bb52b1..09e5c329 100644 --- a/jose/jwe.py +++ b/jose/jwe.py @@ -12,7 +12,7 @@ def encrypt(plaintext, key, encryption=ALGORITHMS.A256GCM, algorithm=ALGORITHMS.DIR, zip=None, cty=None, kid=None): - """Encrypts plaintext and returns a JWE cmpact serialization string. + """Encrypts plaintext and returns a JWE compact serialization string. Args: plaintext (bytes): A bytes object to encrypt @@ -542,7 +542,7 @@ def _get_key_wrap_cek(enc, key): def _get_random_cek_bytes_for_enc(enc): """ - Get the random cek bytes based on the encryptionn algorithm + Get the random cek bytes based on the encryption algorithm Args: enc (str): Encryption algorithm diff --git a/jose/jwk.py b/jose/jwk.py index 7afc0547..2a318475 100644 --- a/jose/jwk.py +++ b/jose/jwk.py @@ -71,9 +71,9 @@ def construct(key_data, algorithm=None): algorithm = key_data.get("alg", None) if not algorithm: - raise JWKError("Unable to find an algorithm for key: %s" % key_data) + raise JWKError("Unable to find an algorithm for key") key_class = get_key(algorithm) if not key_class: - raise JWKError("Unable to find an algorithm for key: %s" % key_data) + raise JWKError("Unable to find an algorithm for key") return key_class(key_data, algorithm) diff --git a/jose/jwt.py b/jose/jwt.py index 80565f56..f47e4ddf 100644 --- a/jose/jwt.py +++ b/jose/jwt.py @@ -1,5 +1,6 @@ import json from calendar import timegm +from datetime import datetime, timedelta try: from collections.abc import Mapping @@ -7,14 +8,11 @@ from collections import Mapping try: - from datetime import UTC, datetime, timedelta - - utc_now = datetime.now(UTC) # Preferred in Python 3.13+ + from datetime import UTC # Preferred in Python 3.13+ except ImportError: - from datetime import datetime, timedelta, timezone + from datetime import timezone - utc_now = datetime.now(timezone.utc) # Preferred in Python 3.12 and below - UTC = timezone.utc + UTC = timezone.utc # Preferred in Python 3.12 and below from jose import jws @@ -70,8 +68,15 @@ def decode(token, key, algorithms=None, options=None, audience=None, issuer=None Args: token (str): A signed JWS to be verified. - key (str or dict): A key to attempt to verify the payload with. Can be - individual JWK or JWK set. + key (str or iterable): A key to attempt to verify the payload with. + This can be simple string with an individual key (e.g. "a1234"), + a tuple or list of keys (e.g. ("a1234...", "b3579"), + a JSON string, (e.g. '["a1234", "b3579"]'), + a dict with the 'keys' key that gives a tuple or list of keys (e.g {'keys': [...]} ) or + a dict or JSON string for a JWK set as defined by RFC 7517 (e.g. + {'keys': [{'kty': 'oct', 'k': 'YTEyMzQ'}, {'kty': 'oct', 'k':'YjM1Nzk'}]} or + '{"keys": [{"kty":"oct","k":"YTEyMzQ"},{"kty":"oct","k":"YjM1Nzk"}]}' + ) in which case the keys must be base64 url safe encoded (with optional padding). algorithms (str or list): Valid algorithms that should be used to verify the JWS. audience (str): The intended audience of the token. If the "aud" claim is included in the claim set, then the audience must be included and must equal diff --git a/jose/utils.py b/jose/utils.py index 8cc0f991..d62cafb0 100644 --- a/jose/utils.py +++ b/jose/utils.py @@ -67,7 +67,7 @@ def base64url_decode(input): """Helper method to base64url_decode a string. Args: - input (str): A base64url_encoded string to decode. + input (bytes): A base64url_encoded string (bytes) to decode. """ rem = len(input) % 4 @@ -82,7 +82,7 @@ def base64url_encode(input): """Helper method to base64url_encode a string. Args: - input (str): A base64url_encoded string to encode. + input (bytes): A base64url_encoded string (bytes) to encode. """ return base64.urlsafe_b64encode(input).replace(b"=", b"") diff --git a/pyproject.toml b/pyproject.toml index 02dd722f..312cf888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,5 @@ [build-system] -requires = [ - "setuptools >= 39.2.0", - "wheel >= 0.29.0", -] +requires = ["setuptools >= 39.2.0", "wheel >= 0.29.0"] build-backend = 'setuptools.build_meta' @@ -18,4 +15,4 @@ line_length = 120 [tool.black] line-length = 120 -target-version = ["py38"] \ No newline at end of file +target-version = ["py311", "py312", "py313"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 3d748803..15202178 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,9 @@ PyYAML==5.4.1 cov-core==1.15.0 -coverage==5.5 -coveralls==1.5.1 -cryptography==43.0.1 +coverage==7.8.2 +coveralls==4.0.1 +cryptography==45.0.3 docopt==0.6.2 -pytest==6.2.3 -pytest-cov==2.11.1 +pytest==8.3.5 +pytest-cov==6.1.1 -r requirements.txt \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 34bca8be..e4e3d192 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,22 +20,23 @@ classifiers = License :: OSI Approved :: MIT License Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 Programming Language :: Python :: Implementation :: PyPy Topic :: Utilities [options] packages = find: +python_requires = >=3.9 install_requires = ecdsa != 0.15 rsa >=4.0, <5.0, !=4.4, !=4.1.1 - pyasn1 >=0.4.1 + pyasn1 >=0.5.0 [options.extras_require] test = diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 33798d00..f9d54cd1 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -1,17 +1,14 @@ import base64 import json +from datetime import datetime, timedelta try: - from datetime import UTC, datetime, timedelta - - utc_now = datetime.now(UTC) # Preferred in Python 3.13+ + from datetime import UTC # Preferred in Python 3.13+ except ImportError: - from datetime import datetime, timedelta, timezone + from datetime import timezone # Preferred in Python 3.12 and below - utc_now = datetime.now(timezone.utc) # Preferred in Python 3.12 and below UTC = timezone.utc - import pytest from jose import jws, jwt @@ -514,14 +511,16 @@ def test_unverified_claims_object(self, claims, key): [ ("aud", "aud"), ("ait", "ait"), - ("exp", utc_now + timedelta(seconds=3600)), - ("nbf", utc_now - timedelta(seconds=5)), + ("exp", lambda: datetime.now(UTC) + timedelta(seconds=3600)), + ("nbf", lambda: datetime.now(UTC) - timedelta(seconds=5)), ("iss", "iss"), ("sub", "sub"), ("jti", "jti"), ], ) def test_require(self, claims, key, claim, value): + if callable(value): + value = value() options = {"require_" + claim: True, "verify_" + claim: False} token = jwt.encode(claims, key) diff --git a/tox.ini b/tox.ini index d8862a8b..8f68f76e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,17 @@ [tox] min_version = 4.4 envlist = - py{37,38,39,310,311,py3}-{base,cryptography-only,pycryptodome-norsa,compatibility}, + py{39,310,311,312,313,py3}-{base,cryptography-only,pycryptodome-norsa,compatibility}, lint skip_missing_interpreters = True [gh-actions] -python = - 3.7: py37-{base,cryptography-only,pycryptodome-norsa,compatibility} - 3.8: py38-{base,cryptography-only,pycryptodome-norsa,compatibility} +python = 3.9: py39-{base,cryptography-only,pycryptodome-norsa,compatibility} 3.10: py310-{base,cryptography-only,pycryptodome-norsa,compatibility} 3.11: py311-{base,cryptography-only,pycryptodome-norsa,compatibility} + 3.12: py312-{base,cryptography-only,pycryptodome-norsa,compatibility} + 3.13: py313-{base,cryptography-only,pycryptodome-norsa,compatibility} pypy-3.9: pypy3-{base,cryptography-only,pycryptodome-norsa,compatibility} [testenv:basecommand] @@ -56,7 +56,7 @@ extras = compatibility: {[testenv:compatibility]extras} [testenv:lint] -basepython = python3.10 +basepython = python3.12 skip_install= True deps = flake8 @@ -69,7 +69,7 @@ commands = [testenv:lintfix] -basepython = python3.10 +basepython = python3.12 skip_install= True deps = isort