Skip to content

Commit bb9131a

Browse files
feat: add experimental enterprise cert support (#1052)
* feat: add experimental enterprise cert support * fix test issue * resolve comments
1 parent 466f9f9 commit bb9131a

File tree

9 files changed

+582
-1
lines changed

9 files changed

+582
-1
lines changed

packages/google-auth/.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,6 @@ pytype_output/
4545
.python-version
4646
.DS_Store
4747
cert_path
48-
key_path
48+
key_path
49+
env/
50+
.vscode/
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Code for configuring client side TLS to offload the signing operation to
17+
signing libraries.
18+
"""
19+
20+
import ctypes
21+
import json
22+
import logging
23+
import os
24+
import sys
25+
26+
import cffi # type: ignore
27+
import six
28+
29+
from google.auth import exceptions
30+
31+
_LOGGER = logging.getLogger(__name__)
32+
33+
# C++ offload lib requires google-auth lib to provide the following callback:
34+
# using SignFunc = int (*)(unsigned char *sig, size_t *sig_len,
35+
# const unsigned char *tbs, size_t tbs_len)
36+
# The bytes to be signed and the length are provided via `tbs` and `tbs_len`,
37+
# the callback computes the signature, and write the signature and its length
38+
# into `sig` and `sig_len`.
39+
# If the signing is successful, the callback returns 1, otherwise it returns 0.
40+
SIGN_CALLBACK_CTYPE = ctypes.CFUNCTYPE(
41+
ctypes.c_int, # return type
42+
ctypes.POINTER(ctypes.c_ubyte), # sig
43+
ctypes.POINTER(ctypes.c_size_t), # sig_len
44+
ctypes.POINTER(ctypes.c_ubyte), # tbs
45+
ctypes.c_size_t, # tbs_len
46+
)
47+
48+
49+
# Cast SSL_CTX* to void*
50+
def _cast_ssl_ctx_to_void_p(ssl_ctx):
51+
return ctypes.cast(int(cffi.FFI().cast("intptr_t", ssl_ctx)), ctypes.c_void_p)
52+
53+
54+
# Load offload library and set up the function types.
55+
def load_offload_lib(offload_lib_path):
56+
_LOGGER.debug("loading offload library from %s", offload_lib_path)
57+
58+
# winmode parameter is only available for python 3.8+.
59+
lib = (
60+
ctypes.CDLL(offload_lib_path, winmode=0)
61+
if sys.version_info >= (3, 8) and os.name == "nt"
62+
else ctypes.CDLL(offload_lib_path)
63+
)
64+
65+
# Set up types for:
66+
# int ConfigureSslContext(SignFunc sign_func, const char *cert, SSL_CTX *ctx)
67+
lib.ConfigureSslContext.argtypes = [
68+
SIGN_CALLBACK_CTYPE,
69+
ctypes.c_char_p,
70+
ctypes.c_void_p,
71+
]
72+
lib.ConfigureSslContext.restype = ctypes.c_int
73+
74+
return lib
75+
76+
77+
# Load signer library and set up the function types.
78+
# See: https://github.com/googleapis/enterprise-certificate-proxy/blob/main/cshared/main.go
79+
def load_signer_lib(signer_lib_path):
80+
_LOGGER.debug("loading signer library from %s", signer_lib_path)
81+
82+
# winmode parameter is only available for python 3.8+.
83+
lib = (
84+
ctypes.CDLL(signer_lib_path, winmode=0)
85+
if sys.version_info >= (3, 8) and os.name == "nt"
86+
else ctypes.CDLL(signer_lib_path)
87+
)
88+
89+
# Set up types for:
90+
# func GetCertPemForPython(configFilePath *C.char, certHolder *byte, certHolderLen int)
91+
lib.GetCertPemForPython.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
92+
# Returns: certLen
93+
lib.GetCertPemForPython.restype = ctypes.c_int
94+
95+
# Set up types for:
96+
# func SignForPython(configFilePath *C.char, digest *byte, digestLen int,
97+
# sigHolder *byte, sigHolderLen int)
98+
lib.SignForPython.argtypes = [
99+
ctypes.c_char_p,
100+
ctypes.c_char_p,
101+
ctypes.c_int,
102+
ctypes.c_char_p,
103+
ctypes.c_int,
104+
]
105+
# Returns: the signature length
106+
lib.SignForPython.restype = ctypes.c_int
107+
108+
return lib
109+
110+
111+
# Computes SHA256 hash.
112+
def _compute_sha256_digest(to_be_signed, to_be_signed_len):
113+
from cryptography.hazmat.primitives import hashes
114+
115+
data = ctypes.string_at(to_be_signed, to_be_signed_len)
116+
hash = hashes.Hash(hashes.SHA256())
117+
hash.update(data)
118+
return hash.finalize()
119+
120+
121+
# Create the signing callback. The actual signing work is done by the
122+
# `SignForPython` method from the signer lib.
123+
def get_sign_callback(signer_lib, config_file_path):
124+
def sign_callback(sig, sig_len, tbs, tbs_len):
125+
_LOGGER.debug("calling sign callback...")
126+
127+
digest = _compute_sha256_digest(tbs, tbs_len)
128+
digestArray = ctypes.c_char * len(digest)
129+
130+
# reserve 2000 bytes for the signature, shoud be more then enough.
131+
# RSA signature is 256 bytes, EC signature is 70~72.
132+
sig_holder_len = 2000
133+
sig_holder = ctypes.create_string_buffer(sig_holder_len)
134+
135+
signature_len = signer_lib.SignForPython(
136+
config_file_path.encode(), # configFilePath
137+
digestArray.from_buffer(bytearray(digest)), # digest
138+
len(digest), # digestLen
139+
sig_holder, # sigHolder
140+
sig_holder_len, # sigHolderLen
141+
)
142+
143+
if signature_len == 0:
144+
# signing failed, return 0
145+
return 0
146+
147+
sig_len[0] = signature_len
148+
bs = bytearray(sig_holder)
149+
for i in range(signature_len):
150+
sig[i] = bs[i]
151+
152+
return 1
153+
154+
return SIGN_CALLBACK_CTYPE(sign_callback)
155+
156+
157+
# Obtain the certificate bytes by calling the `GetCertPemForPython` method from
158+
# the signer lib. The method is called twice, the first time is to compute the
159+
# cert length, then we create a buffer to hold the cert, and call it again to
160+
# fill the buffer.
161+
def get_cert(signer_lib, config_file_path):
162+
# First call to calculate the cert length
163+
cert_len = signer_lib.GetCertPemForPython(
164+
config_file_path.encode(), # configFilePath
165+
None, # certHolder
166+
0, # certHolderLen
167+
)
168+
if cert_len == 0:
169+
raise exceptions.MutualTLSChannelError("failed to get certificate")
170+
171+
# Then we create an array to hold the cert, and call again to fill the cert
172+
cert_holder = ctypes.create_string_buffer(cert_len)
173+
signer_lib.GetCertPemForPython(
174+
config_file_path.encode(), # configFilePath
175+
cert_holder, # certHolder
176+
cert_len, # certHolderLen
177+
)
178+
return bytes(cert_holder)
179+
180+
181+
class CustomTlsSigner(object):
182+
def __init__(self, enterprise_cert_file_path):
183+
"""
184+
This class loads the offload and signer library, and calls APIs from
185+
these libraries to obtain the cert and a signing callback, and attach
186+
them to SSL context. The cert and the signing callback will be used
187+
for client authentication in TLS handshake.
188+
189+
Args:
190+
enterprise_cert_file_path (str): the path to a enterprise cert JSON
191+
file. The file should contain the following field:
192+
193+
{
194+
"libs": {
195+
"signer_library": "...",
196+
"offload_library": "..."
197+
}
198+
}
199+
"""
200+
self._enterprise_cert_file_path = enterprise_cert_file_path
201+
self._cert = None
202+
self._sign_callback = None
203+
204+
def load_libraries(self):
205+
try:
206+
with open(self._enterprise_cert_file_path, "r") as f:
207+
enterprise_cert_json = json.load(f)
208+
libs = enterprise_cert_json["libs"]
209+
signer_library = libs["signer_library"]
210+
offload_library = libs["offload_library"]
211+
except (KeyError, ValueError) as caught_exc:
212+
new_exc = exceptions.MutualTLSChannelError(
213+
"enterprise cert file is invalid", caught_exc
214+
)
215+
six.raise_from(new_exc, caught_exc)
216+
self._offload_lib = load_offload_lib(offload_library)
217+
self._signer_lib = load_signer_lib(signer_library)
218+
219+
def set_up_custom_key(self):
220+
# We need to keep a reference of the cert and sign callback so it won't
221+
# be garbage collected, otherwise it will crash when used by signer lib.
222+
self._cert = get_cert(self._signer_lib, self._enterprise_cert_file_path)
223+
self._sign_callback = get_sign_callback(
224+
self._signer_lib, self._enterprise_cert_file_path
225+
)
226+
227+
def attach_to_ssl_context(self, ctx):
228+
# In the TLS handshake, the signing operation will be done by the
229+
# sign_callback.
230+
if not self._offload_lib.ConfigureSslContext(
231+
self._sign_callback,
232+
ctypes.c_char_p(self._cert),
233+
_cast_ssl_ctx_to_void_p(ctx._ctx._context),
234+
):
235+
raise exceptions.MutualTLSChannelError("failed to configure SSL context")

packages/google-auth/google/auth/transport/requests.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,65 @@ def proxy_manager_for(self, *args, **kwargs):
245245
return super(_MutualTlsAdapter, self).proxy_manager_for(*args, **kwargs)
246246

247247

248+
class _MutualTlsOffloadAdapter(requests.adapters.HTTPAdapter):
249+
"""
250+
A TransportAdapter that enables mutual TLS and offloads the client side
251+
signing operation to the signing library.
252+
253+
Args:
254+
enterprise_cert_file_path (str): the path to a enterprise cert JSON
255+
file. The file should contain the following field:
256+
257+
{
258+
"libs": {
259+
"signer_library": "...",
260+
"offload_library": "..."
261+
}
262+
}
263+
264+
Raises:
265+
ImportError: if certifi or pyOpenSSL is not installed
266+
google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
267+
creation failed for any reason.
268+
"""
269+
270+
def __init__(self, enterprise_cert_file_path):
271+
import certifi
272+
import urllib3.contrib.pyopenssl
273+
274+
from google.auth.transport import _custom_tls_signer
275+
276+
# Call inject_into_urllib3 to activate certificate checking. See the
277+
# following links for more info:
278+
# (1) doc: https://github.com/urllib3/urllib3/blob/cb9ebf8aac5d75f64c8551820d760b72b619beff/src/urllib3/contrib/pyopenssl.py#L31-L32
279+
# (2) mTLS example: https://github.com/urllib3/urllib3/issues/474#issuecomment-253168415
280+
urllib3.contrib.pyopenssl.inject_into_urllib3()
281+
282+
self.signer = _custom_tls_signer.CustomTlsSigner(enterprise_cert_file_path)
283+
self.signer.load_libraries()
284+
self.signer.set_up_custom_key()
285+
286+
poolmanager = create_urllib3_context()
287+
poolmanager.load_verify_locations(cafile=certifi.where())
288+
self.signer.attach_to_ssl_context(poolmanager)
289+
self._ctx_poolmanager = poolmanager
290+
291+
proxymanager = create_urllib3_context()
292+
proxymanager.load_verify_locations(cafile=certifi.where())
293+
self.signer.attach_to_ssl_context(proxymanager)
294+
self._ctx_proxymanager = proxymanager
295+
296+
super(_MutualTlsOffloadAdapter, self).__init__()
297+
298+
def init_poolmanager(self, *args, **kwargs):
299+
kwargs["ssl_context"] = self._ctx_poolmanager
300+
super(_MutualTlsOffloadAdapter, self).init_poolmanager(*args, **kwargs)
301+
302+
def proxy_manager_for(self, *args, **kwargs):
303+
kwargs["ssl_context"] = self._ctx_proxymanager
304+
return super(_MutualTlsOffloadAdapter, self).proxy_manager_for(*args, **kwargs)
305+
306+
248307
class AuthorizedSession(requests.Session):
249308
"""A Requests Session class with credentials.
250309

packages/google-auth/noxfile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def unit_prev_versions(session):
118118
"--cov=google.oauth2",
119119
"--cov=tests",
120120
"tests",
121+
"--ignore=tests/transport/test__custom_tls_signer.py", # enterprise cert is for python 3.6+
121122
)
122123

123124

packages/google-auth/setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
],
3939
"pyopenssl": "pyopenssl>=20.0.0",
4040
"reauth": "pyu2f>=0.1.5",
41+
# Enterprise cert only works for OpenSSL 1.1.1. Newer versions of these
42+
# dependencies are built with OpenSSL 3.0 so we need to fix the version.
43+
"enterprise_cert": ["cryptography==36.0.2", "pyopenssl==22.0.0"],
4144
}
4245

4346
with io.open("README.rst", "r") as fh:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"libs": {}
3+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"libs": {
3+
"signer_library": "/path/to/signer/lib",
4+
"offload_library": "/path/to/offload/lib"
5+
}
6+
}

0 commit comments

Comments
 (0)