Repository: pyca/cryptography

Issue:
User prompt on calling `load_pem_public_key` with private key
## Issue description

When deserializing a private key using `load_pem_public_key()`, a prompt is displayed:

```none
Enter PEM pass phrase:
```

This happens with `cryptography` version `37.0.0`. Testing with `cryptography` version `36.0.2` shows no such prompt.

## Steps to reproduce bug

```python
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization as ser

priv_key = rsa.generate_private_key(65537, 1024)
priv_key_serialized = priv_key.private_bytes(
    ser.Encoding.PEM,
    ser.PrivateFormat.PKCS8,
    ser.BestAvailableEncryption(b"helloworld"),
)

pub_key = ser.load_pem_public_key(priv_key_serialized)
```

## `cryptography` installation

`cryptography` is installed via `poetry` with version constraint `>=35.0.0`:

```toml
[tool.poetry.dependencies]
python = "^3.8"
cryptography = ">=35.0.0"
```

## Required Version numbers

- `pip` - 21.3.1
- `cffi` - 1.15.0
- `cryptography` - 37.0.0
- `setuptools` - 60.1.0




Discussion:
Confirmed, thanks for the easy reproducer. This is due to the switch from OpenSSL 1.1.1 to 3.0.2 in our wheels, where clearly OpenSSL has some extraordinarily undesirable behavior in its APIs.

Resolving patch:
diff --git a/src/cryptography/hazmat/backends/openssl/backend.py b/src/cryptography/hazmat/backends/openssl/backend.py
--- a/src/cryptography/hazmat/backends/openssl/backend.py
+++ b/src/cryptography/hazmat/backends/openssl/backend.py
@@ -906,8 +906,20 @@ def load_pem_private_key(
 
     def load_pem_public_key(self, data: bytes) -> PUBLIC_KEY_TYPES:
         mem_bio = self._bytes_to_bio(data)
+        # In OpenSSL 3.0.x the PEM_read_bio_PUBKEY function will invoke
+        # the default password callback if you pass an encrypted private
+        # key. This is very, very, very bad as the default callback can
+        # trigger an interactive console prompt, which will hang the
+        # Python process. We therefore provide our own callback to
+        # catch this and error out properly.
+        userdata = self._ffi.new("CRYPTOGRAPHY_PASSWORD_DATA *")
         evp_pkey = self._lib.PEM_read_bio_PUBKEY(
-            mem_bio.bio, self._ffi.NULL, self._ffi.NULL, self._ffi.NULL
+            mem_bio.bio,
+            self._ffi.NULL,
+            self._ffi.addressof(
+                self._lib._original_lib, "Cryptography_pem_password_cb"
+            ),
+            userdata,
         )
         if evp_pkey != self._ffi.NULL:
             evp_pkey = self._ffi.gc(evp_pkey, self._lib.EVP_PKEY_free)
@@ -920,7 +932,12 @@ def load_pem_public_key(self, data: bytes) -> PUBLIC_KEY_TYPES:
             res = self._lib.BIO_reset(mem_bio.bio)
             self.openssl_assert(res == 1)
             rsa_cdata = self._lib.PEM_read_bio_RSAPublicKey(
-                mem_bio.bio, self._ffi.NULL, self._ffi.NULL, self._ffi.NULL
+                mem_bio.bio,
+                self._ffi.NULL,
+                self._ffi.addressof(
+                    self._lib._original_lib, "Cryptography_pem_password_cb"
+                ),
+                userdata,
             )
             if rsa_cdata != self._ffi.NULL:
                 rsa_cdata = self._ffi.gc(rsa_cdata, self._lib.RSA_free)


Test patch:
diff --git a/tests/hazmat/primitives/test_serialization.py b/tests/hazmat/primitives/test_serialization.py
--- a/tests/hazmat/primitives/test_serialization.py
+++ b/tests/hazmat/primitives/test_serialization.py
@@ -39,6 +39,7 @@
 )
 
 
+from .fixtures_rsa import RSA_KEY_2048
 from .test_ec import _skip_curve_unsupported
 from .utils import (
     _check_dsa_private_numbers,
@@ -513,6 +514,20 @@ def test_load_pem_rsa_public_key(self, key_file, backend):
         numbers = key.public_numbers()
         assert numbers.e == 65537
 
+    def test_load_priv_key_with_public_key_api_fails(self, backend):
+        # In OpenSSL 3.0.x the PEM_read_bio_PUBKEY function will invoke
+        # the default password callback if you pass an encrypted private
+        # key. This is very, very, very bad as the default callback can
+        # trigger an interactive console prompt, which will hang the
+        # Python process. This test makes sure we don't do that.
+        priv_key_serialized = RSA_KEY_2048.private_key().private_bytes(
+            Encoding.PEM,
+            PrivateFormat.PKCS8,
+            BestAvailableEncryption(b"password"),
+        )
+        with pytest.raises(ValueError):
+            load_pem_public_key(priv_key_serialized)
+
     @pytest.mark.supported(
         only_if=lambda backend: backend.dsa_supported(),
         skip_message="Does not support DSA.",