diff --git a/google/auth/crypt/__init__.py b/google/auth/crypt/__init__.py index 59519b475..e56bc7b82 100644 --- a/google/auth/crypt/__init__.py +++ b/google/auth/crypt/__init__.py @@ -38,35 +38,14 @@ """ from google.auth.crypt import base +from google.auth.crypt import es +from google.auth.crypt import es256 from google.auth.crypt import rsa -# google.auth.crypt.es depends on the crytpography module which may not be -# successfully imported depending on the system. -try: - from google.auth.crypt import es - from google.auth.crypt import es256 -except ImportError: # pragma: NO COVER - es = None # type: ignore - es256 = None # type: ignore - -if es is not None and es256 is not None: # pragma: NO COVER - __all__ = [ - "EsSigner", - "EsVerifier", - "ES256Signer", - "ES256Verifier", - "RSASigner", - "RSAVerifier", - "Signer", - "Verifier", - ] - - EsSigner = es.EsSigner - EsVerifier = es.EsVerifier - ES256Signer = es256.ES256Signer - ES256Verifier = es256.ES256Verifier -else: # pragma: NO COVER - __all__ = ["RSASigner", "RSAVerifier", "Signer", "Verifier"] +EsSigner = es.EsSigner +EsVerifier = es.EsVerifier +ES256Signer = es256.ES256Signer +ES256Verifier = es256.ES256Verifier # Aliases to maintain the v1.0.0 interface, as the crypt module was split @@ -103,3 +82,15 @@ class to use for verification. This can be used to select different if verifier.verify(message, signature): return True return False + + +__all__ = [ + "EsSigner", + "EsVerifier", + "ES256Signer", + "ES256Verifier", + "RSASigner", + "RSAVerifier", + "Signer", + "Verifier", +] diff --git a/google/auth/crypt/_python_rsa.py b/google/auth/crypt/_python_rsa.py index c4b35c5ce..d9305e835 100644 --- a/google/auth/crypt/_python_rsa.py +++ b/google/auth/crypt/_python_rsa.py @@ -40,13 +40,9 @@ _PKCS8_MARKER = ("-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----") _PKCS8_SPEC = PrivateKeyInfo() -warnings.warn( - ( - "The 'rsa' library is deprecated and will be removed in a future release. " - "Please migrate to 'cryptography'." - ), - category=DeprecationWarning, - stacklevel=2, +_warning_msg = ( + "The 'rsa' library is deprecated and will be removed in a future release. " + "Please migrate to 'cryptography'." ) @@ -84,6 +80,11 @@ class RSAVerifier(base.Verifier): """ def __init__(self, public_key): + warnings.warn( + _warning_msg, + category=DeprecationWarning, + stacklevel=2, + ) self._pubkey = public_key @_helpers.copy_docstring(base.Verifier) @@ -142,6 +143,11 @@ class RSASigner(base.Signer, base.FromServiceAccountMixin): """ def __init__(self, private_key, key_id=None): + warnings.warn( + _warning_msg, + category=DeprecationWarning, + stacklevel=2, + ) self._key = private_key self._key_id = key_id diff --git a/google/auth/crypt/es.py b/google/auth/crypt/es.py index f9466af3c..dbbe56b3f 100644 --- a/google/auth/crypt/es.py +++ b/google/auth/crypt/es.py @@ -102,7 +102,7 @@ def verify(self, message: bytes, signature: bytes) -> bool: @classmethod def from_string(cls, public_key: Union[str, bytes]) -> "EsVerifier": - """Construct an Verifier instance from a public key or public + """Construct a Verifier instance from a public key or public certificate string. Args: @@ -110,7 +110,7 @@ def from_string(cls, public_key: Union[str, bytes]) -> "EsVerifier": x509 public key certificate. Returns: - Verifier: The constructed verifier. + google.auth.crypt.Verifier: The constructed verifier. Raises: ValueError: If the public key can't be parsed. diff --git a/google/auth/crypt/rsa.py b/google/auth/crypt/rsa.py index ed842d1eb..4b2fb39ff 100644 --- a/google/auth/crypt/rsa.py +++ b/google/auth/crypt/rsa.py @@ -12,19 +12,116 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""RSA cryptography signer and verifier.""" +""" +RSA cryptography signer and verifier. +This file provides a shared wrapper, that defers to _python_rsa or _cryptography_rsa +for implmentations using different third party libraries +""" -try: - # Prefer cryptograph-based RSA implementation. - from google.auth.crypt import _cryptography_rsa +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey - RSASigner = _cryptography_rsa.RSASigner - RSAVerifier = _cryptography_rsa.RSAVerifier -except ImportError: # pragma: NO COVER - # Fallback to pure-python RSA implementation if cryptography is - # unavailable. - from google.auth.crypt import _python_rsa +from google.auth import _helpers +from google.auth.crypt import _cryptography_rsa +from google.auth.crypt import _python_rsa +from google.auth.crypt import base - RSASigner = _python_rsa.RSASigner # type: ignore - RSAVerifier = _python_rsa.RSAVerifier # type: ignore +RSA_KEY_MODULE_PREFIX = "rsa.key" + + +class RSAVerifier(base.Verifier): + """Verifies RSA cryptographic signatures using public keys. + + Args: + public_key (Union["rsa.key.PublicKey", cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey]): + The public key used to verify signatures. + Raises: + ValueError: if an unrecognized public key is provided + """ + + def __init__(self, public_key): + module_str = public_key.__class__.__module__ + if isinstance(public_key, RSAPublicKey): + impl_lib = _cryptography_rsa + elif module_str.startswith(RSA_KEY_MODULE_PREFIX): + impl_lib = _python_rsa + else: + raise ValueError(f"unrecognized public key type: {type(public_key)}") + self._impl = impl_lib.RSAVerifier(public_key) + + @_helpers.copy_docstring(base.Verifier) + def verify(self, message, signature): + return self._impl.verify(message, signature) + + @classmethod + def from_string(cls, public_key): + """Construct a Verifier instance from a public key or public + certificate string. + + Args: + public_key (Union[str, bytes]): The public key in PEM format or the + x509 public key certificate. + + Returns: + google.auth.crypt.Verifier: The constructed verifier. + + Raises: + ValueError: If the public_key can't be parsed. + """ + instance = cls.__new__(cls) + instance._impl = _cryptography_rsa.RSAVerifier.from_string(public_key) + return instance + + +class RSASigner(base.Signer, base.FromServiceAccountMixin): + """Signs messages with an RSA private key. + + Args: + private_key (Union["rsa.key.PrivateKey", cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey]): + The private key to sign with. + key_id (str): Optional key ID used to identify this private key. This + can be useful to associate the private key with its associated + public key or certificate. + + Raises: + ValueError: if an unrecognized public key is provided + """ + + def __init__(self, private_key, key_id=None): + module_str = private_key.__class__.__module__ + if isinstance(private_key, RSAPrivateKey): + impl_lib = _cryptography_rsa + elif module_str.startswith(RSA_KEY_MODULE_PREFIX): + impl_lib = _python_rsa + else: + raise ValueError(f"unrecognized private key type: {type(private_key)}") + self._impl = impl_lib.RSASigner(private_key, key_id=key_id) + + @property # type: ignore + @_helpers.copy_docstring(base.Signer) + def key_id(self): + return self._impl.key_id + + @_helpers.copy_docstring(base.Signer) + def sign(self, message): + return self._impl.sign(message) + + @classmethod + def from_string(cls, key, key_id=None): + """Construct a Signer instance from a private key in PEM format. + + Args: + key (str): Private key in PEM format. + key_id (str): An optional key id used to identify the private key. + + Returns: + google.auth.crypt.Signer: The constructed signer. + + Raises: + ValueError: If the key cannot be parsed as PKCS#1 or PKCS#8 in + PEM format. + """ + instance = cls.__new__(cls) + instance._impl = _cryptography_rsa.RSASigner.from_string(key, key_id=key_id) + return instance diff --git a/setup.py b/setup.py index 74036339a..ba9e214b1 100644 --- a/setup.py +++ b/setup.py @@ -18,30 +18,30 @@ from setuptools import find_namespace_packages from setuptools import setup +cryptography_base_require = [ + "cryptography >= 38.0.3", +] DEPENDENCIES = ( "pyasn1-modules>=0.2.1", - # rsa==4.5 is the last version to support 2.7 - # https://github.com/sybrenstuvel/python-rsa/issues/152#issuecomment-643470233 + cryptography_base_require, + # TODO: remove rsa from dependencies in next release (replaced with cryptography)i + # https://github.com/googleapis/google-auth-library-python/issues/1810 "rsa>=3.1.4,<5", ) -cryptography_base_require = [ - "cryptography >= 38.0.3", -] - requests_extra_require = ["requests >= 2.20.0, < 3.0.0"] aiohttp_extra_require = ["aiohttp >= 3.6.2, < 4.0.0", *requests_extra_require] -pyjwt_extra_require = ["pyjwt>=2.0", *cryptography_base_require] +pyjwt_extra_require = ["pyjwt>=2.0"] reauth_extra_require = ["pyu2f>=0.1.5"] -# TODO(https://github.com/googleapis/google-auth-library-python/issues/1738): Add bounds for cryptography and pyopenssl dependencies. -enterprise_cert_extra_require = ["cryptography", "pyopenssl"] +# TODO(https://github.com/googleapis/google-auth-library-python/issues/1738): Add bounds for pyopenssl dependency. +enterprise_cert_extra_require = ["pyopenssl"] -pyopenssl_extra_require = ["pyopenssl>=20.0.0", cryptography_base_require] +pyopenssl_extra_require = ["pyopenssl>=20.0.0"] # TODO(https://github.com/googleapis/google-auth-library-python/issues/1739): Add bounds for urllib3 and packaging dependencies. urllib3_extra_require = ["urllib3", "packaging"] @@ -76,6 +76,7 @@ ] extras = { + # Note: cryptography was made into a required dependency. Extra is kept for backwards compatibility "cryptography": cryptography_base_require, "aiohttp": aiohttp_extra_require, "enterprise_cert": enterprise_cert_extra_require, diff --git a/tests/crypt/test__python_rsa.py b/tests/crypt/test__python_rsa.py index e3662a959..43539900f 100644 --- a/tests/crypt/test__python_rsa.py +++ b/tests/crypt/test__python_rsa.py @@ -195,8 +195,9 @@ def test_from_service_account_file(self): class TestModule(object): def test_import_warning(self): - import importlib from google.auth.crypt import _python_rsa with pytest.warns(DeprecationWarning, match="The 'rsa' library is deprecated"): - importlib.reload(_python_rsa) + _python_rsa.RSAVerifier(None) + with pytest.warns(DeprecationWarning, match="The 'rsa' library is deprecated"): + _python_rsa.RSASigner(None) diff --git a/tests/crypt/test_rsa.py b/tests/crypt/test_rsa.py new file mode 100644 index 000000000..6f7aa2691 --- /dev/null +++ b/tests/crypt/test_rsa.py @@ -0,0 +1,175 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from unittest import mock + +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import serialization +import pytest +import rsa as rsa_lib + +from google.auth.crypt import _cryptography_rsa +from google.auth.crypt import _python_rsa +from google.auth.crypt import rsa + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + + +@pytest.fixture +def private_key_bytes(): + with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + return fh.read() + + +@pytest.fixture +def public_key_bytes(): + with open(os.path.join(DATA_DIR, "privatekey.pub"), "rb") as fh: + return fh.read() + + +@pytest.fixture +def cryptography_private_key(private_key_bytes): + return serialization.load_pem_private_key( + private_key_bytes, password=None, backend=backends.default_backend() + ) + + +@pytest.fixture +def rsa_private_key(private_key_bytes): + return rsa_lib.PrivateKey.load_pkcs1(private_key_bytes) + + +@pytest.fixture +def cryptography_public_key(public_key_bytes): + return serialization.load_pem_public_key( + public_key_bytes, backend=backends.default_backend() + ) + + +@pytest.fixture +def rsa_public_key(public_key_bytes): + return rsa_lib.PublicKey.load_pkcs1(public_key_bytes) + + +class TestRSAVerifier: + def test_init_with_cryptography_key(self, cryptography_public_key): + verifier = rsa.RSAVerifier(cryptography_public_key) + assert isinstance(verifier._impl, _cryptography_rsa.RSAVerifier) + assert verifier._impl._pubkey == cryptography_public_key + + def test_init_with_rsa_key(self, rsa_public_key): + verifier = rsa.RSAVerifier(rsa_public_key) + assert isinstance(verifier._impl, _python_rsa.RSAVerifier) + assert verifier._impl._pubkey == rsa_public_key + + def test_warning_with_rsa(self, rsa_public_key): + with pytest.warns(DeprecationWarning, match="The 'rsa' library is deprecated"): + rsa.RSAVerifier(rsa_public_key) + + def test_init_with_unknown_key(self): + unknown_key = object() + + with pytest.raises(ValueError): + rsa.RSAVerifier(unknown_key) + + def test_verify_delegates(self, cryptography_public_key): + verifier = rsa.RSAVerifier(cryptography_public_key) + + # Mock the implementation's verify method + with mock.patch.object( + verifier._impl, "verify", return_value=True + ) as mock_verify: + result = verifier.verify(b"message", b"signature") + assert result is True + mock_verify.assert_called_once_with(b"message", b"signature") + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa") + def test_from_string_cryptography(self, mock_crypto, public_key_bytes): + expected_verifier = mock.Mock() + mock_crypto.RSAVerifier.from_string.return_value = expected_verifier + + result = rsa.RSAVerifier.from_string(public_key_bytes) + + assert result._impl == expected_verifier + mock_crypto.RSAVerifier.from_string.assert_called_once_with(public_key_bytes) + + +class TestRSASigner: + def test_init_with_cryptography_key(self, cryptography_private_key): + signer = rsa.RSASigner(cryptography_private_key, key_id="123") + assert isinstance(signer._impl, _cryptography_rsa.RSASigner) + assert signer._impl._key == cryptography_private_key + assert signer._impl.key_id == "123" + + def test_init_with_rsa_key(self, rsa_private_key): + signer = rsa.RSASigner(rsa_private_key, key_id="123") + assert isinstance(signer._impl, _python_rsa.RSASigner) + assert signer._impl._key == rsa_private_key + assert signer._impl.key_id == "123" + + def test_warning_with_rsa(self, rsa_private_key): + with pytest.warns(DeprecationWarning, match="The 'rsa' library is deprecated"): + rsa.RSASigner(rsa_private_key, key_id="123") + + def test_init_with_unknown_key(self): + unknown_key = object() + + with pytest.raises(ValueError): + rsa.RSASigner(unknown_key) + + def test_sign_delegates(self, rsa_private_key): + signer = rsa.RSASigner(rsa_private_key) + + with mock.patch.object( + signer._impl, "sign", return_value=b"signature" + ) as mock_sign: + result = signer.sign(b"message") + assert result == b"signature" + mock_sign.assert_called_once_with(b"message") + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa") + def test_from_string_delegates_to_cryptography( + self, mock_crypto, private_key_bytes + ): + expected_signer = mock.Mock() + mock_crypto.RSASigner.from_string.return_value = expected_signer + + result = rsa.RSASigner.from_string(private_key_bytes, key_id="123") + + assert result._impl == expected_signer + mock_crypto.RSASigner.from_string.assert_called_once_with( + private_key_bytes, key_id="123" + ) + + def test_end_to_end_cryptography_lib(self, private_key_bytes, public_key_bytes): + signer = rsa.RSASigner.from_string(private_key_bytes) + message = b"Hello World" + sig = signer.sign(message) + verifier = rsa.RSAVerifier.from_string(public_key_bytes) + result = verifier.verify(message, sig) + assert result is True + assert isinstance(verifier._impl, _cryptography_rsa.RSAVerifier) + assert isinstance(signer._impl, _cryptography_rsa.RSASigner) + + def test_end_to_end_rsa_lib(self, rsa_private_key, rsa_public_key): + signer = rsa.RSASigner(rsa_private_key) + message = b"Hello World" + sig = signer.sign(message) + verifier = rsa.RSAVerifier(rsa_public_key) + result = verifier.verify(message, sig) + assert bool(result) is True + assert isinstance(verifier._impl, _python_rsa.RSAVerifier) + assert isinstance(signer._impl, _python_rsa.RSASigner) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 9502bc32e..4c5988469 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -129,7 +129,7 @@ def factory( # False is specified to remove the signer's key id for testing # headers without key ids. if key_id is False: - signer._key_id = None + signer._impl._key_id = None key_id = None if use_es256_signer: