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/rsa.py b/google/auth/crypt/rsa.py index ed842d1eb..b315c6661 100644 --- a/google/auth/crypt/rsa.py +++ b/google/auth/crypt/rsa.py @@ -12,19 +12,152 @@ # 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 +""" + +from google.auth import _helpers +from google.auth.crypt import base +from google.auth.exceptions import MissingOptionalDependencyError try: - # Prefer cryptograph-based RSA implementation. + # Attempt import of module that requires optional `cryptography` dependency from google.auth.crypt import _cryptography_rsa - - RSASigner = _cryptography_rsa.RSASigner - RSAVerifier = _cryptography_rsa.RSAVerifier except ImportError: # pragma: NO COVER - # Fallback to pure-python RSA implementation if cryptography is - # unavailable. + _cryptography_rsa = None + +try: + # Attempt import of module that requires optional (deprecated) `rsa` dependency from google.auth.crypt import _python_rsa +except ImportError: # pragma: NO COVER + _python_rsa = None + +RSA_NOTE = "(Note: `rsa` is also supported for legacy compatibility, but is deprecated)" + + +class RSAVerifier(base.Verifier): + """Verifies RSA cryptographic signatures using public keys. + + Requires installation of `cryptography` optional dependency. + + .. deprecated:: + The `rsa` library has been archived. Please migrate to + `cryptography` for public keys. + + Args: + public_key (Union[rsa.key.PublicKey, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey]): + The public key used to verify signatures. + Raises: + ImportError: if neither `cryptograhy` or `rsa` is installed + ValueError: if an unrecognized public key is provided + """ + + def __init__(self, public_key): + module_str = public_key.__class__.__module__ + if "rsa.key" in module_str: + impl_lib = _python_rsa + elif "cryptography." in module_str: + impl_lib = _cryptography_rsa + else: + raise ValueError(f"unrecognized public key type: {public_key}") + if impl_lib is None: + raise MissingOptionalDependencyError.create(self, "cryptography", RSA_NOTE) + else: + 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 an 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.RSAVerifier: The constructed verifier. + + Raises: + ValueError: If the public_key can't be parsed. + ImportError: if neither `cryptograhy` or `rsa` is installe + """ + if _cryptography_rsa: + return _cryptography_rsa.RSAVerifier.from_string(public_key) + elif _python_rsa: + return _python_rsa.RSAVerifier.from_string(public_key) + else: + raise MissingOptionalDependencyError.create(cls, "cryptography", RSA_NOTE) + + +class RSASigner(base.Signer, base.FromServiceAccountMixin): + """Signs messages with an RSA private key. + + Requires installation of `cryptography` optional dependency. + + .. deprecated:: + The `rsa` library has been archived. Please migrate to + `cryptography` for public keys. + + 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: + ImportError: if neither `cryptograhy` or `rsa` is installed + ValueError: if an unrecognized public key is provided + """ + + def __init__(self, private_key, key_id=None): + module_str = private_key.__class__.__module__ + if "rsa.key" in module_str: + impl_lib = _python_rsa + elif "cryptography." in module_str: + impl_lib = _cryptography_rsa + else: + raise ValueError(f"unrecognized private key type: {private_key}") + if impl_lib is None: + raise MissingOptionalDependencyError.create(self, "cryptography", RSA_NOTE) + else: + 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 an 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. - RSASigner = _python_rsa.RSASigner # type: ignore - RSAVerifier = _python_rsa.RSAVerifier # type: ignore + Raises: + ValueError: If the key cannot be parsed as PKCS#1 or PKCS#8 in + PEM format. + ImportError: if neither `cryptograhy` or `rsa` is installe + """ + if _cryptography_rsa: + return _cryptography_rsa.RSASigner.from_string(key, key_id=key_id) + elif _python_rsa: + return _python_rsa.RSASigner.from_string(key, key_id=key_id) + else: + raise MissingOptionalDependencyError.create(cls, "cryptography", RSA_NOTE) diff --git a/google/auth/exceptions.py b/google/auth/exceptions.py index feb9f7411..8dbce2bc1 100644 --- a/google/auth/exceptions.py +++ b/google/auth/exceptions.py @@ -106,3 +106,18 @@ class TimeoutError(GoogleAuthError): class ResponseError(GoogleAuthError): """Used to indicate an error occurred when reading an HTTP response.""" + + +class MissingOptionalDependencyError(ImportError): + """Raised when a user attempts to use a class that requires an optional dependency""" + + @classmethod + def create(cls, caller, dependency_name, suffix_str=None): + """Creates an instance referencing the required dependency and the triggering class""" + caller_cls = caller if isinstance(caller, type) else type(caller) + msg_str = ( + f"{caller_cls.__name__} requires `{dependency_name}` optional dependency." + ) + if suffix_str: + msg_str = f"{msg_str} {suffix_str}" + return cls(msg_str) diff --git a/setup.py b/setup.py index 74036339a..6c3d22102 100644 --- a/setup.py +++ b/setup.py @@ -19,17 +19,15 @@ from setuptools import setup -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 - "rsa>=3.1.4,<5", -) +DEPENDENCIES = ("pyasn1-modules>=0.2.1",) cryptography_base_require = [ "cryptography >= 38.0.3", ] +# TODO: rsa is archived. Remove optional dependency in future release +rsa_extra_require = ["rsa>=3.1.4,<5"] + requests_extra_require = ["requests >= 2.20.0, < 3.0.0"] aiohttp_extra_require = ["aiohttp >= 3.6.2, < 4.0.0", *requests_extra_require] @@ -73,10 +71,12 @@ # TODO(https://github.com/googleapis/google-auth-library-python/issues/1722): `test_aiohttp_requests` depend on # aiohttp < 3.10.0 which is a bug. Investigate and remove the pinned aiohttp version. "aiohttp < 3.10.0", + *rsa_extra_require, ] extras = { "cryptography": cryptography_base_require, + "rsa": rsa_extra_require, "aiohttp": aiohttp_extra_require, "enterprise_cert": enterprise_cert_extra_require, "pyopenssl": pyopenssl_extra_require, diff --git a/tests/crypt/test__python_rsa.py b/tests/crypt/test__python_rsa.py index e3662a959..a63b665d1 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) \ No newline at end of file diff --git a/tests/crypt/test_rsa.py b/tests/crypt/test_rsa.py new file mode 100644 index 000000000..91cd951d1 --- /dev/null +++ b/tests/crypt/test_rsa.py @@ -0,0 +1,226 @@ +# 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 import exceptions +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") + +with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + CRYPTOGRAPHY_PRIVATE_KEY = serialization.load_pem_private_key( + PRIVATE_KEY_BYTES, password=None, backend=backends.default_backend() + ) + RSA_PRIVATE_KEY = rsa_lib.PrivateKey.load_pkcs1(PRIVATE_KEY_BYTES) + +with open(os.path.join(DATA_DIR, "privatekey.pub"), "rb") as fh: + PUBLIC_KEY_BYTES = fh.read() + CRYPTOGRAPHY_PUBLIC_KEY = serialization.load_pem_public_key( + PUBLIC_KEY_BYTES, backend=backends.default_backend() + ) + RSA_PUBLIC_KEY = rsa_lib.PublicKey.load_pkcs1(PUBLIC_KEY_BYTES) + + +class TestRSAVerifier: + def test_init_with_cryptography_key(self): + 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): + 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): + 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) + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa", None) + @mock.patch("google.auth.crypt.rsa._python_rsa", None) + def test_init_with_missing_dependencies(self): + with pytest.raises(exceptions.MissingOptionalDependencyError) as e: + rsa.RSAVerifier(RSA_PUBLIC_KEY) + assert "RSAVerifier requires `cryptography`" in str(e) + assert ( + "`rsa` is also supported for legacy compatibility, but is deprecated" + in str(e) + ) + + def test_verify_delegates(self): + 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") + @mock.patch("google.auth.crypt.rsa._python_rsa", None) + def test_from_string_cryptography(self, mock_crypto): + expected_verifier = mock.Mock() + mock_crypto.RSAVerifier.from_string.return_value = expected_verifier + + result = rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + + assert result == expected_verifier + mock_crypto.RSAVerifier.from_string.assert_called_once_with(PUBLIC_KEY_BYTES) + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa", None) + @mock.patch("google.auth.crypt.rsa._python_rsa") + def test_from_string_python_rsa(self, mock_python_rsa): + expected_verifier = mock.Mock() + mock_python_rsa.RSAVerifier.from_string.return_value = expected_verifier + + result = rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + + assert result == expected_verifier + mock_python_rsa.RSAVerifier.from_string.assert_called_once_with( + PUBLIC_KEY_BYTES + ) + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa", None) + @mock.patch("google.auth.crypt.rsa._python_rsa", None) + def test_from_string_missing_deps(self): + with pytest.raises(exceptions.MissingOptionalDependencyError) as e: + rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES) + assert "RSAVerifier requires `cryptography`" in str(e) + assert ( + "`rsa` is also supported for legacy compatibility, but is deprecated" + in str(e) + ) + + +class TestRSASigner: + def test_init_with_cryptography_key(self): + 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): + 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): + 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) + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa", None) + @mock.patch("google.auth.crypt.rsa._python_rsa", None) + def test_init_with_missing_dependencies(self): + with pytest.raises(exceptions.MissingOptionalDependencyError) as e: + rsa.RSASigner(RSA_PRIVATE_KEY) + assert "RSASigner requires `cryptography`" in str(e) + assert ( + "`rsa` is also supported for legacy compatibility, but is deprecated" + in str(e) + ) + + def test_sign_delegates(self): + 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") + @mock.patch("google.auth.crypt.rsa._python_rsa", None) + def test_from_string_delegates_to_cryptography(self, mock_crypto): + 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 == expected_signer + mock_crypto.RSASigner.from_string.assert_called_once_with( + PRIVATE_KEY_BYTES, key_id="123" + ) + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa", None) + @mock.patch("google.auth.crypt.rsa._python_rsa") + def test_from_string_delegates_to_python_rsa(self, mock_python_rsa): + expected_signer = mock.Mock() + mock_python_rsa.RSASigner.from_string.return_value = expected_signer + + result = rsa.RSASigner.from_string(PRIVATE_KEY_BYTES, key_id="123") + + assert result == expected_signer + mock_python_rsa.RSASigner.from_string.assert_called_once_with( + PRIVATE_KEY_BYTES, key_id="123" + ) + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa", None) + @mock.patch("google.auth.crypt.rsa._python_rsa", None) + def test_from_string_missing_deps(self): + with pytest.raises(exceptions.MissingOptionalDependencyError) as e: + rsa.RSASigner.from_string(PRIVATE_KEY_BYTES) + assert "RSASigner requires `cryptography`" in str(e) + assert ( + "`rsa` is also supported for legacy compatibility, but is deprecated" + in str(e) + ) + + @mock.patch("google.auth.crypt.rsa._python_rsa", None) + def test_end_to_end_cryptography_lib(self): + 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, _cryptography_rsa.RSAVerifier) + assert isinstance(signer, _cryptography_rsa.RSASigner) + + @mock.patch("google.auth.crypt.rsa._cryptography_rsa", None) + def test_end_to_end_rsa_lib(self): + 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 bool(result) is True + assert isinstance(verifier, _python_rsa.RSAVerifier) + assert isinstance(signer, _python_rsa.RSASigner)