Skip to content

Commit 8166325

Browse files
authored
fix(auth): use an unencrypted key for mTLS certificate rotation (#18709)
`call_client_cert_callback()` in `google/auth/transport/_mtls_helper.py` called `get_client_ssl_credentials(generate_encrypted_key=True)` and then dropped the passphrase. With SecureConnect (`context_aware_metadata.json`) that adds `--with_passphrase` to the cert provider command, so the key it returns is encrypted. Its only caller is `check_parameters_for_unauthorized_response()`, which the 401 certificate rotation in `AuthorizedSession.request()` and `AuthorizedHttp.urlopen()` uses. Both pass the key to `configure_mtls_channel()` without a passphrase, so `load_cert_chain()` gets an encrypted key with `password=None`. This passes `generate_encrypted_key=False`, as `get_client_cert_and_key()` already does for the initial mTLS setup, and updates `test_call_client_cert_callback` to expect it. A new test drives the SecureConnect path through a fake cert provider that returns an encrypted key only for `--with_passphrase`, and checks that the returned key loads without a passphrase. Workload certificates from `certificate_config.json` are returned before the flag is read, so they are not affected, and the async rotation path does not use this function. The change does not touch `requests.py` or `urllib3.py`, so it is independent of #18381. The rotation paths added there also go through `check_parameters_for_unauthorized_response()` and get the unencrypted key too. Testing (Python 3.13 on Windows, Python 3.12 on Linux): - The updated `test_call_client_cert_callback` and the new `test_call_client_cert_callback_secure_connect_unencrypted_key` fail against `_mtls_helper.py` from `main` (the first with `Expected: get_client_ssl_credentials(generate_encrypted_key=False)`, `Actual: ...(generate_encrypted_key=True)`; the second gets the encrypted key back) and pass with the change. - `pytest tests/transport/test__mtls_helper.py tests/transport/test_requests.py tests/transport/test_urllib3.py tests/transport/test_mtls.py`: 371 passed. - `pytest tests tests_async` on Linux: 2263 passed, 7 skipped. - `ruff check --select I` and `ruff format --check` (ruff 0.14.14 with the flags from the `lint` session) and `flake8` on the two changed files: no findings. - On Linux, passing an encrypted key with no passphrase to `_MutualTlsAdapter` with no terminal attached printed `Enter PEM pass phrase:` and raised `MutualTLSChannelError` (caused by `OSError(22, 'Invalid argument')`); with a terminal it waits for input. The same key unencrypted loads fine. - [x] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/google-cloud-python/issues) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [x] Ensure the tests and linter pass - [x] Code coverage does not decrease (if any source code was changed) - [x] Appropriate docs were updated (if necessary) Fixes #18467 🦕. Closes #18538
1 parent e801bb0 commit 8166325

2 files changed

Lines changed: 40 additions & 4 deletions

File tree

‎packages/google-auth/google/auth/transport/_mtls_helper.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -911,8 +911,10 @@ def check_parameters_for_unauthorized_response(cached_cert):
911911

912912
def call_client_cert_callback():
913913
"""Calls the client cert callback and returns the certificate and key."""
914-
_, cert_bytes, key_bytes, passphrase = get_client_ssl_credentials(
915-
generate_encrypted_key=True
914+
# Callers load the key without a passphrase, so request an unencrypted
915+
# key, as get_client_cert_and_key() does.
916+
_, cert_bytes, key_bytes, _ = get_client_ssl_credentials(
917+
generate_encrypted_key=False
916918
)
917919
return cert_bytes, key_bytes
918920

‎packages/google-auth/tests/transport/test__mtls_helper.py‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,17 +1332,51 @@ def test_call_client_cert_callback(self, mock_get_client_ssl_credentials):
13321332
True,
13331333
b"cert_bytes",
13341334
b"key_bytes",
1335-
b"passphrase",
1335+
None,
13361336
)
13371337

13381338
cert, key = _mtls_helper.call_client_cert_callback()
13391339

13401340
assert cert == b"cert_bytes"
13411341
assert key == b"key_bytes"
13421342
mock_get_client_ssl_credentials.assert_called_once_with(
1343-
generate_encrypted_key=True
1343+
generate_encrypted_key=False
13441344
)
13451345

1346+
@mock.patch("subprocess.Popen", autospec=True)
1347+
@mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True)
1348+
@mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True)
1349+
@mock.patch(
1350+
"google.auth.transport._mtls_helper._get_workload_cert_and_key", autospec=True
1351+
)
1352+
def test_call_client_cert_callback_secure_connect_unencrypted_key(
1353+
self,
1354+
mock_get_workload_cert_and_key,
1355+
mock_check_config_path,
1356+
mock_load_json_file,
1357+
mock_popen,
1358+
):
1359+
mock_get_workload_cert_and_key.return_value = (None, None)
1360+
mock_check_config_path.return_value = "/path/to/config"
1361+
mock_load_json_file.return_value = {"cert_provider_command": ["command"]}
1362+
1363+
process = mock.Mock()
1364+
process.configure_mock(
1365+
**{
1366+
"communicate.return_value": (
1367+
pytest.public_cert_bytes + pytest.private_key_bytes,
1368+
b"",
1369+
),
1370+
"returncode": 0,
1371+
}
1372+
)
1373+
mock_popen.return_value = process
1374+
1375+
cert, key = _mtls_helper.call_client_cert_callback()
1376+
1377+
assert cert == pytest.public_cert_bytes
1378+
assert key == pytest.private_key_bytes
1379+
13461380

13471381
class TestSecureCertKeyPaths(object):
13481382
def test_tier1_pass_through(self):

0 commit comments

Comments
 (0)