diff --git a/sdk/identity/azure-identity/HISTORY.md b/sdk/identity/azure-identity/HISTORY.md index 35122e3b983a..3cc1b34ec5ee 100644 --- a/sdk/identity/azure-identity/HISTORY.md +++ b/sdk/identity/azure-identity/HISTORY.md @@ -6,6 +6,8 @@ ([#8945](https://github.com/Azure/azure-sdk-for-python/pull/8945)) - Async credentials are async context managers and have an async `close` method ([#9090](https://github.com/Azure/azure-sdk-for-python/pull/9090)) +- `CertificateCredential` supports password-protected private keys +([#9434](https://github.com/Azure/azure-sdk-for-python/pull/9434)) ## 1.1.0 (2019-11-27) diff --git a/sdk/identity/azure-identity/README.md b/sdk/identity/azure-identity/README.md index e44003aaeb44..8be7c426e2ef 100644 --- a/sdk/identity/azure-identity/README.md +++ b/sdk/identity/azure-identity/README.md @@ -176,10 +176,14 @@ This example demonstrates authenticating the `SecretClient` from the from azure.identity import CertificateCredential from azure.keyvault.secrets import SecretClient -# requires a PEM-encoded certificate with private key, not protected with a password +# requires a PEM-encoded certificate with private key cert_path = "/app/certs/certificate.pem" credential = CertificateCredential(tenant_id, client_id, cert_path) +# if the private key is password protected, provide a 'password' keyword argument +credential = CertificateCredential(tenant_id, client_id, cert_path, password="cert-password") + + client = SecretClient("https://my-vault.vault.azure.net", credential) ``` diff --git a/sdk/identity/azure-identity/azure/identity/_base.py b/sdk/identity/azure-identity/azure/identity/_base.py index 98850e5f7231..bf37df00fdbe 100644 --- a/sdk/identity/azure-identity/azure/identity/_base.py +++ b/sdk/identity/azure-identity/azure/identity/_base.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: # pylint:disable=unused-import - from typing import Any, Mapping + from typing import Any, Optional, Union class ClientSecretCredentialBase(object): @@ -50,16 +50,19 @@ def __init__(self, tenant_id, client_id, certificate_path, **kwargs): # pylint: # type: (str, str, str, **Any) -> None if not certificate_path: raise ValueError( - "certificate_path must be the path to a PEM file containing an " - "x509 certificate and its private key, not protected with a password" + "'certificate_path' must be the path to a PEM file containing an x509 certificate and its private key" ) super(CertificateCredentialBase, self).__init__() + password = kwargs.pop("password", None) + if isinstance(password, six.text_type): + password = password.encode(encoding="utf-8") + with open(certificate_path, "rb") as f: pem_bytes = f.read() - private_key = serialization.load_pem_private_key(pem_bytes, password=None, backend=default_backend()) + private_key = serialization.load_pem_private_key(pem_bytes, password=password, backend=default_backend()) cert = x509.load_pem_x509_certificate(pem_bytes, default_backend()) fingerprint = cert.fingerprint(hashes.SHA1()) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/client_credential.py b/sdk/identity/azure-identity/azure/identity/_credentials/client_credential.py index 10eae06c2b2a..57372f86322f 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/client_credential.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/client_credential.py @@ -56,11 +56,13 @@ class CertificateCredential(CertificateCredentialBase): :param str tenant_id: ID of the service principal's tenant. Also called its 'directory' ID. :param str client_id: the service principal's client ID :param str certificate_path: path to a PEM-encoded certificate file including the private key. - This file must not be password-protected. :keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com', the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities` defines authorities for other clouds. + :keyword password: The certificate's password. If a unicode string, it will be encoded as UTF-8. If the certificate + requires a different encoding, pass appropriately encoded bytes instead. + :paramtype password: str or bytes """ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/client_credential.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/client_credential.py index 79ea22931960..816941dd09b2 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/client_credential.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/client_credential.py @@ -60,11 +60,13 @@ class CertificateCredential(CertificateCredentialBase, AsyncCredentialBase): :param str tenant_id: ID of the service principal's tenant. Also called its 'directory' ID. :param str client_id: the service principal's client ID :param str certificate_path: path to a PEM-encoded certificate file including the private key - This file must not be password-protected. :keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com', the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities` defines authorities for other clouds. + :keyword password: The certificate's password. If a unicode string, it will be encoded as UTF-8. If the certificate + requires a different encoding, pass appropriately encoded bytes instead. + :paramtype password: str or bytes """ async def __aenter__(self): diff --git a/sdk/identity/azure-identity/conftest.py b/sdk/identity/azure-identity/conftest.py index ed71382fbb00..cb35f368240f 100644 --- a/sdk/identity/azure-identity/conftest.py +++ b/sdk/identity/azure-identity/conftest.py @@ -63,13 +63,11 @@ def live_service_principal(): # pylint:disable=inconsistent-return-statements @pytest.fixture() def live_certificate(live_service_principal): # pylint:disable=inconsistent-return-statements,redefined-outer-name - """Fixture for live tests needing a certificate. - Skips them when environment configuration is incomplete. - """ + """Provides a path to a PEM-encoded certificate with no password""" pem_content = os.environ.get("PEM_CONTENT") if not pem_content: - pytest.skip("Environment has no value for 'PEM_CONTENT'") + pytest.skip("Expected PEM content in environment variable 'PEM_CONTENT'") return pem_path = os.path.join(os.path.dirname(__file__), "certificate.pem") @@ -81,6 +79,28 @@ def live_certificate(live_service_principal): # pylint:disable=inconsistent-ret pytest.skip("Failed to write file '{}': {}".format(pem_path, ex)) +@pytest.fixture() +def live_certificate_with_password(live_service_principal): + """Provides a path to a PEM-encoded, password-protected certificate, and its password""" + + pem_content = os.environ.get("PEM_CONTENT_PASSWORD_PROTECTED") + password = os.environ.get("CERTIFICATE_PASSWORD") + if not (pem_content and password): + pytest.skip( + "Expected password-protected PEM content in environment variable 'PEM_CONTENT_PASSWORD_PROTECTED'" + + " and the password in 'CERTIFICATE_PASSWORD'" + ) + return + + pem_path = os.path.join(os.path.dirname(__file__), "certificate-with-password.pem") + try: + with open(pem_path, "w") as pem_file: + pem_file.write(pem_content) + return dict(live_service_principal, cert_path=pem_path, password=password) + except IOError as ex: + pytest.skip("Failed to write file '{}': {}".format(pem_path, ex)) + + @pytest.fixture() def live_user_details(): user_details = { diff --git a/sdk/identity/azure-identity/tests/certificate-with-password.pem b/sdk/identity/azure-identity/tests/certificate-with-password.pem new file mode 100644 index 000000000000..bca1bb581766 --- /dev/null +++ b/sdk/identity/azure-identity/tests/certificate-with-password.pem @@ -0,0 +1,51 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIpDOLr9sNuTwCAggA +MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECIWEFMkTyS60BIIEyPvMPGyf1shr +ql0UnZzMWDz/9bqdRIZe5N7F1qYjgZNms/QXVzZOQ2J9YwaLHbwpEv2QigfHXq/3 +nLyTm4HFyg3qpCqWa33A0r/v5B6WtjgYbuPePqpM2UV34CMGylkmhMVUUbs1X6j7 +ezwmipq/paOslokC0RYl16cQD/uLTD0usTDtWoEs3S4gbcGUj/b9Ll2urG9zBYwI +faSWQcwcRgfYk/OZSbv6zNT74dYMAOW3mjsS/ackc/+h2XWFQSVjN7BBXamfCQ3C +qE27Py85PP8Qt7MbHuoj8rMuoaQ3NIi++1RkW6cyFo5n+HBi7YYCP74loG1OCTN8 +H5StIi2aLbls9ZbQJHLM2+J1tBwJqIR/UogITESV++17ZVHfDLk8uaad7i6Kj+u5 +6vbruehnFqo5P80lZRuqHfGf/5v8Hbsve/zL24wdQ+tFDHaC6v+kiz9unnO/+k86 +9gph/WTly4N4wJhdxhoYxJLMdPcWk6AxA7ZsJ/mI9+t8iHdSOZY91FyN3sDlDB4C +yLi8t1WP+VB9KfMjSN0AuULWrwwQ1YGRUsKaS9pxTy8MbXQ1OgXGGHzHKDm6vqyp +Jow9wD8Ql+V7zPsNgBpeRWXzA5VS6nEyIuOolkJnNoC5d69/LtDaBn58TZQ4z1Ja +wGXG6n9BeFrgwgH5X5kGslLDXZ71V/aT0HHoBbiAPWf90teccGJ5nVXv3kMaC/zc +klzNCrQ2koFphQgW1bU/FZ46yd2rvlFo8wbxAwRieldZpkwRcFVKz+cRh4/QsTpl +uPKtPpI0c2jgiSReNXi2kRlkOPg1UVHnapvv2yRUoq4NvzaOegSVJG1oe/XdLS02 +5JoDNajEcIvHQpLZL1ecQSwpge843mW4F5twm8/1MKY8G2CTXjKif77n3WVR2Tvp +RdOm86TbIB4FpbLAqiN11A/8cLNfVmioQkNdULLELfKiOeQZABSMPJMVGVxgODN0 +nP31BMibPayqApLLEFIQbSLhZvWIJ5ircZ5XKPHeuqpnxFoeIwrGQuqHo6gvSp5J +CPv4Pul43y0s3vxRpJAqmXO4aAzsPsrYGJiNckbD43OxRV9ZDDeD1Wrc4u82zQxo +frSy5XhVPsKH1lFZ7l6te4Tkro4vMxRVu+W2acToJI6QZct0xrlp+ScmD/9CEOGU +Wj5SN4Y8YW+UfeYhDAhntzruRx4HjdocbvwYsY9gAkim4P9AdiG6eeRUyU9GCUwd +MrjMt5HUzsTQiIyN9jnv9yWNdYmzgJ2V0ZOwVHaEZhZnkgYoK0O/NXSg0FsPo5LU +qdYncK+BMAGnEl/riaJRmnsIH29jKPjZDOvfo1+0UJM3+zPjYy4985+CH7xWGWnb +NQFBtwiPyWzlDyV3119T4rY+Ad6z90vG4hgvpvue1Qcuaure8NwkUEEh1/d8PN20 +kP4vWhpDeHbO5R5byXlJMNzmgVm3mc2t6mA/ouUcMmUOTvjdYALXqgw9RgOsqkob +DNjEK4VZUu3Vd6AsK+s796KTLgQvZhrcahoer/88j7Nu0PyQGrVN202IfmbjIwer +NNcieLmok6r2k8GvyUYP51hpdkXO5j3BsrtBeq4xn3qxzOtEUL8ITZ/BU20+xJq6 +GoGDjvCSBpzesnQFlvUtEw== +-----END ENCRYPTED PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDbTCCAlWgAwIBAgIUHBH8mppwjLI2dFOQ7haLnd6iRjQwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yMDAxMDMxODE2MzlaGA8yMTk5 +MDYwODE4MTYzOVowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx +ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBANMXamdgXR+0B2b6zt2nURcYcwC0YrqjvTH/ofF3 +MjUzZ1uKziPNxxAYrUY0O0zIcZWo9Aqfi10vS5oNya/aDrKoWxVRCsLltAV9dbLJ +65zF7wbVE7ZnZ7Nknop+ytd1t1VNTlpbxgWdT6z/WTn4ydqH7Hlh0Ucu2Q3QGQL3 +G9He0kOMog4Y0myxP2xNGjLoig2kh60KEwtxbudOxVN4rLpqhT/1n/L5s+7rznKc +cB4MRqPJMdycIYhTD2mfp/E9hDWRcVJY+9GlqzyxXFTsDsO1SzGgpMEjdO5mtc6N +A0dd8fZQLt1BHLFJlpsuk5Fk40y7HtT3kYKUcD55Xd0pd6ECAwEAAaNTMFEwHQYD +VR0OBBYEFKG65qd+cChhFLB8y4po+vL3HwxuMB8GA1UdIwQYMBaAFKG65qd+cChh +FLB8y4po+vL3HwxuMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB +AAlEqWO8tGKprQrqfdZDsMyKyne+WYYA7RqRxYzKJ5j1zsPa4f7RKhLYzx6TqWK3 +keBc6aw9aeNYNqTBeDLGHRHRRwdHwwU5HdqowhE0sEQzOIJvs2JK5L+LcoSkTViy +PzidZ0qoAHRluuFw8Ag9sahcQfao6rqJOFY/16KEjDthATGo/4mHRsuAM+xza+2m +GbqJH/iO/q0lsPb3culm8aoJNxULTHrU5YWhuGvRypSYrfdL7RBkzW4VEt5LcRK6 +KcfmfHMrjPl/XxSSvrBmly7nYNH80DGSMRP/lnrQ8OS+hSiDy1KBaCcNhja5Dyzn +K0dXlMGmWrnDMs8m+4cUoIM= +-----END CERTIFICATE----- diff --git a/sdk/identity/azure-identity/tests/test_certificate_credential.py b/sdk/identity/azure-identity/tests/test_certificate_credential.py index da554ecd52a4..ce280e4ef066 100644 --- a/sdk/identity/azure-identity/tests/test_certificate_credential.py +++ b/sdk/identity/azure-identity/tests/test_certificate_credential.py @@ -12,16 +12,20 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding +import pytest from six.moves.urllib_parse import urlparse from helpers import build_aad_response, urlsafeb64_decode, mock_response, Request, validating_transport try: - from unittest.mock import Mock, patch + from unittest.mock import Mock except ImportError: # python < 3.3 - from mock import Mock, patch # type: ignore + from mock import Mock # type: ignore CERT_PATH = os.path.join(os.path.dirname(__file__), "certificate.pem") +CERT_WITH_PASSWORD_PATH = os.path.join(os.path.dirname(__file__), "certificate-with-password.pem") +CERT_PASSWORD = "password" +BOTH_CERTS = ((CERT_PATH, None), (CERT_WITH_PASSWORD_PATH, CERT_PASSWORD)) def test_policies_configurable(): @@ -50,7 +54,8 @@ def test_user_agent(): credential.get_token("scope") -def test_request_url(): +@pytest.mark.parametrize("cert_path,cert_password", BOTH_CERTS) +def test_request_url(cert_path, cert_password): authority = "authority.com" tenant_id = "expected_tenant" access_token = "***" @@ -65,12 +70,15 @@ def mock_send(request, **kwargs): validate_url(request.url) return mock_response(json_payload={"token_type": "Bearer", "expires_in": 42, "access_token": access_token}) - cred = CertificateCredential(tenant_id, "client_id", CERT_PATH, transport=Mock(send=mock_send), authority=authority) + cred = CertificateCredential( + tenant_id, "client-id", cert_path, password=cert_password, transport=Mock(send=mock_send), authority=authority + ) token = cred.get_token("scope") assert token.token == access_token -def test_request_body(): +@pytest.mark.parametrize("cert_path,cert_password", BOTH_CERTS) +def test_request_body(cert_path, cert_password): access_token = "***" authority = "authority.com" client_id = "client-id" @@ -81,14 +89,15 @@ def mock_send(request, **kwargs): assert request.body["grant_type"] == "client_credentials" assert request.body["scope"] == expected_scope - with open(CERT_PATH, "rb") as cert_file: + with open(cert_path, "rb") as cert_file: validate_jwt(request, client_id, cert_file.read()) return mock_response(json_payload={"token_type": "Bearer", "expires_in": 42, "access_token": access_token}) - cred = CertificateCredential(tenant_id, client_id, CERT_PATH, transport=Mock(send=mock_send), authority=authority) + cred = CertificateCredential( + tenant_id, client_id, cert_path, password=cert_password, transport=Mock(send=mock_send), authority=authority + ) token = cred.get_token(expected_scope) - assert token.token == access_token diff --git a/sdk/identity/azure-identity/tests/test_certificate_credential_async.py b/sdk/identity/azure-identity/tests/test_certificate_credential_async.py index d14e929693b3..01af51a454a3 100644 --- a/sdk/identity/azure-identity/tests/test_certificate_credential_async.py +++ b/sdk/identity/azure-identity/tests/test_certificate_credential_async.py @@ -2,9 +2,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -import json -import os -from unittest.mock import Mock, patch +from unittest.mock import Mock from urllib.parse import urlparse from azure.core.pipeline.policies import ContentDecodePolicy, SansIOHTTPPolicy @@ -15,10 +13,7 @@ from helpers import build_aad_response, urlsafeb64_decode, mock_response, Request from helpers_async import async_validating_transport, AsyncMockTransport -from test_certificate_credential import validate_jwt - - -CERT_PATH = os.path.join(os.path.dirname(__file__), "certificate.pem") +from test_certificate_credential import BOTH_CERTS, CERT_PATH, validate_jwt @pytest.mark.asyncio @@ -72,28 +67,32 @@ async def test_user_agent(): @pytest.mark.asyncio -async def test_request_url(): +@pytest.mark.parametrize("cert_path,cert_password", BOTH_CERTS) +async def test_request_url(cert_path, cert_password): authority = "authority.com" tenant_id = "expected_tenant" access_token = "***" def validate_url(url): - scheme, netloc, path, _, _, _ = urlparse(url) - assert scheme == "https" - assert netloc == authority - assert path.startswith("/" + tenant_id) + parsed = urlparse(url) + assert parsed.scheme == "https" + assert parsed.netloc == authority + assert parsed.path.startswith("/" + tenant_id) async def mock_send(request, **kwargs): validate_url(request.url) return mock_response(json_payload={"token_type": "Bearer", "expires_in": 42, "access_token": access_token}) - cred = CertificateCredential(tenant_id, "client_id", CERT_PATH, transport=Mock(send=mock_send), authority=authority) + cred = CertificateCredential( + tenant_id, "client-id", cert_path, password=cert_password, transport=Mock(send=mock_send), authority=authority + ) token = await cred.get_token("scope") assert token.token == access_token @pytest.mark.asyncio -async def test_request_body(): +@pytest.mark.parametrize("cert_path,cert_password", BOTH_CERTS) +async def test_request_body(cert_path, cert_password): access_token = "***" authority = "authority.com" client_id = "client-id" @@ -104,12 +103,14 @@ async def mock_send(request, **kwargs): assert request.body["grant_type"] == "client_credentials" assert request.body["scope"] == expected_scope - with open(CERT_PATH, "rb") as cert_file: + with open(cert_path, "rb") as cert_file: validate_jwt(request, client_id, cert_file.read()) return mock_response(json_payload={"token_type": "Bearer", "expires_in": 42, "access_token": access_token}) - cred = CertificateCredential(tenant_id, client_id, CERT_PATH, transport=Mock(send=mock_send), authority=authority) + cred = CertificateCredential( + tenant_id, client_id, cert_path, password=cert_password, transport=Mock(send=mock_send), authority=authority + ) token = await cred.get_token("scope") assert token.token == access_token diff --git a/sdk/identity/azure-identity/tests/test_live.py b/sdk/identity/azure-identity/tests/test_live.py index 63548951ee5f..7139883a8321 100644 --- a/sdk/identity/azure-identity/tests/test_live.py +++ b/sdk/identity/azure-identity/tests/test_live.py @@ -37,6 +37,16 @@ def test_certificate_credential(live_certificate): get_token(credential) +def test_certificate_credential_with_password(live_certificate_with_password): + credential = CertificateCredential( + live_certificate_with_password["tenant_id"], + live_certificate_with_password["client_id"], + live_certificate_with_password["cert_path"], + password=live_certificate_with_password["password"], + ) + get_token(credential) + + def test_client_secret_credential(live_service_principal): credential = ClientSecretCredential( live_service_principal["tenant_id"], diff --git a/sdk/identity/azure-identity/tests/test_live_async.py b/sdk/identity/azure-identity/tests/test_live_async.py index 13b26a721734..548f035c26bb 100644 --- a/sdk/identity/azure-identity/tests/test_live_async.py +++ b/sdk/identity/azure-identity/tests/test_live_async.py @@ -28,6 +28,17 @@ async def test_certificate_credential(live_certificate): await get_token(credential) +@pytest.mark.asyncio +async def test_certificate_credential_with_password(live_certificate_with_password): + credential = CertificateCredential( + live_certificate_with_password["tenant_id"], + live_certificate_with_password["client_id"], + live_certificate_with_password["cert_path"], + password=live_certificate_with_password["password"], + ) + await get_token(credential) + + @pytest.mark.asyncio async def test_client_secret_credential(live_service_principal): credential = ClientSecretCredential( diff --git a/sdk/identity/tests.yml b/sdk/identity/tests.yml index d92a67ec91c0..0ce1a0fc39a1 100644 --- a/sdk/identity/tests.yml +++ b/sdk/identity/tests.yml @@ -15,4 +15,6 @@ jobs: AZURE_CLIENT_ID: $(python-identity-client-id) AZURE_CLIENT_SECRET: $(python-identity-client-secret) AZURE_TENANT_ID: $(aad-azure-sdk-test-tenant-id) + CERTIFICATE_PASSWORD: $(python-identity-certificate-password) PEM_CONTENT: $(python-identity-certificate) + PEM_CONTENT_PASSWORD_PROTECTED: $(python-identity-certificate-with-password)