Skip to content

Commit 3c6628f

Browse files
committed
Support reading CCA cert from a pfx file. Tested.
1 parent c5cc6e0 commit 3c6628f

File tree

6 files changed

+86
-37
lines changed

6 files changed

+86
-37
lines changed

.github/workflows/python-package.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ jobs:
1818
TRAVIS: true
1919
LAB_APP_CLIENT_ID: ${{ secrets.LAB_APP_CLIENT_ID }}
2020
LAB_APP_CLIENT_SECRET: ${{ secrets.LAB_APP_CLIENT_SECRET }}
21+
LAB_APP_CLIENT_CERT_BASE64: ${{ secrets.LAB_APP_CLIENT_CERT_BASE64 }}
22+
LAB_APP_CLIENT_CERT_PFX_PATH: lab_cert.pfx
2123
LAB_OBO_CLIENT_SECRET: ${{ secrets.LAB_OBO_CLIENT_SECRET }}
2224
LAB_OBO_CONFIDENTIAL_CLIENT_ID: ${{ secrets.LAB_OBO_CONFIDENTIAL_CLIENT_ID }}
2325
LAB_OBO_PUBLIC_CLIENT_ID: ${{ secrets.LAB_OBO_PUBLIC_CLIENT_ID }}
@@ -43,6 +45,9 @@ jobs:
4345
python -m pip install --upgrade pip
4446
python -m pip install flake8 pytest
4547
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
48+
- name: Populate lab cert.pfx
49+
# https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions#storing-base64-binary-blobs-as-secrets
50+
run: echo $LAB_APP_CLIENT_CERT_BASE64 | base64 -d > $LAB_APP_CLIENT_CERT_PFX_PATH
4651
- name: Test with pytest
4752
run: pytest --benchmark-skip
4853
- name: Lint with flake8

msal/application.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@ def _str2bytes(raw):
6565
return raw
6666

6767

68+
def _load_private_key_from_pfx_path(pfx_path, passphrase_bytes):
69+
# Cert concepts https://security.stackexchange.com/a/226758/125264
70+
from cryptography.hazmat.primitives import hashes
71+
from cryptography.hazmat.primitives.serialization import pkcs12
72+
with open(pfx_path, 'rb') as f:
73+
private_key, cert, _ = pkcs12.load_key_and_certificates( # cryptography 2.5+
74+
# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/serialization/#cryptography.hazmat.primitives.serialization.pkcs12.load_key_and_certificates
75+
f.read(), passphrase_bytes)
76+
sha1_thumbprint = cert.fingerprint(hashes.SHA1()).hex() # cryptography 0.7+
77+
# https://cryptography.io/en/latest/x509/reference/#x-509-certificate-object
78+
return private_key, sha1_thumbprint
79+
80+
81+
def _load_private_key_from_pem_str(private_key_pem_str, passphrase_bytes):
82+
from cryptography.hazmat.primitives import serialization
83+
from cryptography.hazmat.backends import default_backend
84+
return serialization.load_pem_private_key( # cryptography 0.6+
85+
_str2bytes(private_key_pem_str),
86+
passphrase_bytes,
87+
backend=default_backend(), # It was a required param until 2020
88+
)
89+
90+
6891
def _pii_less_home_account_id(home_account_id):
6992
parts = home_account_id.split(".") # It could contain one or two parts
7093
parts[0] = "********"
@@ -254,6 +277,16 @@ def __init__(
254277
"client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..."
255278
}
256279
280+
.. admonition:: Supporting reading client cerficates from PFX files
281+
282+
*Added in version 1.29.0*:
283+
Feed in a dictionary containing the path to a PFX file::
284+
285+
{
286+
"private_key_pfx_path": "/path/to/your.pfx",
287+
"passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)",
288+
}
289+
257290
:type client_credential: Union[dict, str]
258291
259292
:param dict client_claims:
@@ -651,29 +684,37 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
651684
default_headers['x-app-ver'] = self.app_version
652685
default_body = {"client_info": 1}
653686
if isinstance(client_credential, dict):
654-
assert (("private_key" in client_credential
655-
and "thumbprint" in client_credential) or
656-
"client_assertion" in client_credential)
657687
client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT
658-
if 'client_assertion' in client_credential:
688+
# Use client_credential.get("...") rather than "..." in client_credential
689+
# so that we can ignore an empty string came from an empty ENV VAR.
690+
if client_credential.get("client_assertion"):
659691
client_assertion = client_credential['client_assertion']
660692
else:
661693
headers = {}
662-
if 'public_certificate' in client_credential:
694+
if client_credential.get('public_certificate'):
663695
headers["x5c"] = extract_certs(client_credential['public_certificate'])
664-
if not client_credential.get("passphrase"):
665-
unencrypted_private_key = client_credential['private_key']
696+
passphrase_bytes = _str2bytes(
697+
client_credential["passphrase"]
698+
) if client_credential.get("passphrase") else None
699+
if client_credential.get("private_key_pfx_path"):
700+
private_key, sha1_thumbprint = _load_private_key_from_pfx_path(
701+
client_credential["private_key_pfx_path"], passphrase_bytes)
702+
elif (
703+
client_credential.get("private_key") # PEM blob
704+
and client_credential.get("thumbprint")):
705+
sha1_thumbprint = client_credential["thumbprint"]
706+
if passphrase_bytes:
707+
private_key = _load_private_key_from_pem_str(
708+
client_credential['private_key'], passphrase_bytes)
709+
else: # PEM without passphrase
710+
private_key = client_credential['private_key']
666711
else:
667-
from cryptography.hazmat.primitives import serialization
668-
from cryptography.hazmat.backends import default_backend
669-
unencrypted_private_key = serialization.load_pem_private_key(
670-
_str2bytes(client_credential["private_key"]),
671-
_str2bytes(client_credential["passphrase"]),
672-
backend=default_backend(), # It was a required param until 2020
673-
)
712+
raise ValueError(
713+
"client_credential needs to follow this format "
714+
"https://msal-python.readthedocs.io/en/latest/#msal.ClientApplication.params.client_credential")
674715
assertion = JwtAssertionCreator(
675-
unencrypted_private_key, algorithm="RS256",
676-
sha1_thumbprint=client_credential.get("thumbprint"), headers=headers)
716+
private_key, algorithm="RS256",
717+
sha1_thumbprint=sha1_thumbprint, headers=headers)
677718
client_assertion = assertion.create_regenerative_assertion(
678719
audience=authority.token_endpoint, issuer=self.client_id,
679720
additional_claims=self.client_claims or {})

setup.cfg

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ install_requires =
4646
# therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+
4747
PyJWT[crypto]>=1.0.0,<3
4848

49-
# load_pem_private_key() is available since 0.6
50-
# https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29
49+
# load_key_and_certificates() is available since 2.5
50+
# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/serialization/#cryptography.hazmat.primitives.serialization.pkcs12.load_key_and_certificates
5151
#
5252
# And we will use the cryptography (X+3).0.0 as the upper bound,
5353
# based on their latest deprecation policy
5454
# https://cryptography.io/en/latest/api-stability/#deprecation
55-
cryptography>=0.6,<45
55+
cryptography>=2.5,<45
5656

5757

5858
[options.extras_require]
2.43 KB
Binary file not shown.

tests/test_cryptography.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import requests
99

10-
from msal.application import _str2bytes
10+
from msal.application import (
11+
_str2bytes, _load_private_key_from_pem_str, _load_private_key_from_pfx_path)
1112

1213

1314
latest_cryptography_version = ET.fromstring(
@@ -26,6 +27,10 @@ def get_current_ceiling():
2627
raise RuntimeError("Unable to find cryptography info from setup.cfg")
2728

2829

30+
def sibling(filename):
31+
return os.path.join(os.path.dirname(__file__), filename)
32+
33+
2934
class CryptographyTestCase(TestCase):
3035

3136
def test_should_be_run_with_latest_version_of_cryptography(self):
@@ -37,18 +42,13 @@ def test_should_be_run_with_latest_version_of_cryptography(self):
3742
cryptography.__version__, latest_cryptography_version))
3843

3944
def test_latest_cryptography_should_support_our_usage_without_warnings(self):
40-
with open(os.path.join(
41-
os.path.dirname(__file__), "certificate-with-password.pem")) as f:
42-
cert = f.read()
45+
passphrase_bytes = _str2bytes("password")
4346
with warnings.catch_warnings(record=True) as encountered_warnings:
44-
# The usage was copied from application.py
45-
from cryptography.hazmat.primitives import serialization
46-
from cryptography.hazmat.backends import default_backend
47-
unencrypted_private_key = serialization.load_pem_private_key(
48-
_str2bytes(cert),
49-
_str2bytes("password"),
50-
backend=default_backend(), # It was a required param until 2020
51-
)
47+
with open(sibling("certificate-with-password.pem")) as f:
48+
_load_private_key_from_pem_str(f.read(), passphrase_bytes)
49+
pfx = sibling("certificate-with-password.pfx") # Created by:
50+
# openssl pkcs12 -export -inkey test/certificate-with-password.pem -in tests/certificate-with-password.pem -out tests/certificate-with-password.pfx
51+
_load_private_key_from_pfx_path(pfx, passphrase_bytes)
5252
self.assertEqual(0, len(encountered_warnings),
5353
"Did cryptography deprecate the functions that we used?")
5454

tests/test_e2e.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ def test_device_flow(self):
446446
def get_lab_app(
447447
env_client_id="LAB_APP_CLIENT_ID",
448448
env_name2="LAB_APP_CLIENT_SECRET", # A var name that hopefully avoids false alarm
449+
env_client_cert_path="LAB_APP_CLIENT_CERT_PFX_PATH",
449450
authority="https://login.microsoftonline.com/"
450451
"72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft tenant ID
451452
timeout=None,
@@ -458,21 +459,23 @@ def get_lab_app(
458459
"Reading ENV variables %s and %s for lab app defined at "
459460
"https://docs.msidlab.com/accounts/confidentialclient.html",
460461
env_client_id, env_name2)
461-
if os.getenv(env_client_id) and os.getenv(env_name2):
462-
# A shortcut mainly for running tests on developer's local development machine
463-
# or it could be setup on Travis CI
464-
# https://docs.travis-ci.com/user/environment-variables/#defining-variables-in-repository-settings
462+
if os.getenv(env_client_id) and os.getenv(env_client_cert_path):
463+
# id came from https://docs.msidlab.com/accounts/confidentialclient.html
464+
client_id = os.getenv(env_client_id)
465+
# Cert came from https://ms.portal.azure.com/#@microsoft.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Certificate/https://msidlabs.vault.azure.net/certificates/LabVaultAccessCert
466+
client_credential = {"private_key_pfx_path": os.getenv(env_client_cert_path)}
467+
elif os.getenv(env_client_id) and os.getenv(env_name2):
465468
# Data came from here
466469
# https://docs.msidlab.com/accounts/confidentialclient.html
467470
client_id = os.getenv(env_client_id)
468-
client_secret = os.getenv(env_name2)
471+
client_credential = os.getenv(env_name2)
469472
else:
470473
logger.info("ENV variables are not defined. Fall back to MSI.")
471474
# See also https://microsoft.sharepoint-df.com/teams/MSIDLABSExtended/SitePages/Programmatically-accessing-LAB-API's.aspx
472475
raise unittest.SkipTest("MSI-based mechanism has not been implemented yet")
473476
return msal.ConfidentialClientApplication(
474477
client_id,
475-
client_credential=client_secret,
478+
client_credential=client_credential,
476479
authority=authority,
477480
http_client=MinimalHttpClient(timeout=timeout),
478481
**kwargs)

0 commit comments

Comments
 (0)