Skip to content

Commit b5a9d0e

Browse files
feat: add SslCredentials class for mTLS ADC (#448)
feat: add SslCredentials class for mTLS ADC (linux)
1 parent 046fcf7 commit b5a9d0e

File tree

5 files changed

+723
-56
lines changed

5 files changed

+723
-56
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Copyright 2020 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+
"""Helper functions for getting mTLS cert and key, for internal use only."""
16+
17+
import json
18+
import logging
19+
from os import path
20+
import re
21+
import subprocess
22+
23+
CONTEXT_AWARE_METADATA_PATH = "~/.secureConnect/context_aware_metadata.json"
24+
_CERT_PROVIDER_COMMAND = "cert_provider_command"
25+
_CERT_REGEX = re.compile(
26+
b"-----BEGIN CERTIFICATE-----.+-----END CERTIFICATE-----\r?\n?", re.DOTALL
27+
)
28+
29+
# support various format of key files, e.g.
30+
# "-----BEGIN PRIVATE KEY-----...",
31+
# "-----BEGIN EC PRIVATE KEY-----...",
32+
# "-----BEGIN RSA PRIVATE KEY-----..."
33+
_KEY_REGEX = re.compile(
34+
b"-----BEGIN [A-Z ]*PRIVATE KEY-----.+-----END [A-Z ]*PRIVATE KEY-----\r?\n?",
35+
re.DOTALL,
36+
)
37+
38+
_LOGGER = logging.getLogger(__name__)
39+
40+
41+
def _check_dca_metadata_path(metadata_path):
42+
"""Checks for context aware metadata. If it exists, returns the absolute path;
43+
otherwise returns None.
44+
45+
Args:
46+
metadata_path (str): context aware metadata path.
47+
48+
Returns:
49+
str: absolute path if exists and None otherwise.
50+
"""
51+
metadata_path = path.expanduser(metadata_path)
52+
if not path.exists(metadata_path):
53+
_LOGGER.debug("%s is not found, skip client SSL authentication.", metadata_path)
54+
return None
55+
return metadata_path
56+
57+
58+
def _read_dca_metadata_file(metadata_path):
59+
"""Loads context aware metadata from the given path.
60+
61+
Args:
62+
metadata_path (str): context aware metadata path.
63+
64+
Returns:
65+
Dict[str, str]: The metadata.
66+
67+
Raises:
68+
ValueError: If failed to parse metadata as JSON.
69+
"""
70+
with open(metadata_path) as f:
71+
metadata = json.load(f)
72+
73+
return metadata
74+
75+
76+
def get_client_ssl_credentials(metadata_json):
77+
"""Returns the client side mTLS cert and key.
78+
79+
Args:
80+
metadata_json (Dict[str, str]): metadata JSON file which contains the cert
81+
provider command.
82+
83+
Returns:
84+
Tuple[bytes, bytes]: client certificate and key, both in PEM format.
85+
86+
Raises:
87+
OSError: If the cert provider command failed to run.
88+
RuntimeError: If the cert provider command has a runtime error.
89+
ValueError: If the metadata json file doesn't contain the cert provider command or if the command doesn't produce both the client certificate and client key.
90+
"""
91+
# TODO: implement an in-memory cache of cert and key so we don't have to
92+
# run cert provider command every time.
93+
94+
# Check the cert provider command existence in the metadata json file.
95+
if _CERT_PROVIDER_COMMAND not in metadata_json:
96+
raise ValueError("Cert provider command is not found")
97+
98+
# Execute the command. It throws OsError in case of system failure.
99+
command = metadata_json[_CERT_PROVIDER_COMMAND]
100+
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
101+
stdout, stderr = process.communicate()
102+
103+
# Check cert provider command execution error.
104+
if process.returncode != 0:
105+
raise RuntimeError(
106+
"Cert provider command returns non-zero status code %s" % process.returncode
107+
)
108+
109+
# Extract certificate (chain) and key.
110+
cert_match = re.findall(_CERT_REGEX, stdout)
111+
if len(cert_match) != 1:
112+
raise ValueError("Client SSL certificate is missing or invalid")
113+
key_match = re.findall(_KEY_REGEX, stdout)
114+
if len(key_match) != 1:
115+
raise ValueError("Client SSL key is missing or invalid")
116+
return cert_match[0], key_match[0]

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

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
from __future__ import absolute_import
1818

1919
from concurrent import futures
20+
import logging
2021

2122
import six
2223

24+
from google.auth.transport import _mtls_helper
25+
2326
try:
2427
import grpc
2528
except ImportError as caught_exc: # pragma: NO COVER
@@ -31,6 +34,8 @@
3134
caught_exc,
3235
)
3336

37+
_LOGGER = logging.getLogger(__name__)
38+
3439

3540
class AuthMetadataPlugin(grpc.AuthMetadataPlugin):
3641
"""A `gRPC AuthMetadataPlugin`_ that inserts the credentials into each
@@ -92,7 +97,12 @@ def __del__(self):
9297

9398

9499
def secure_authorized_channel(
95-
credentials, request, target, ssl_credentials=None, **kwargs
100+
credentials,
101+
request,
102+
target,
103+
ssl_credentials=None,
104+
client_cert_callback=None,
105+
**kwargs
96106
):
97107
"""Creates a secure authorized gRPC channel.
98108
@@ -114,11 +124,86 @@ def secure_authorized_channel(
114124
115125
# Create a channel.
116126
channel = google.auth.transport.grpc.secure_authorized_channel(
117-
credentials, 'speech.googleapis.com:443', request)
127+
credentials, regular_endpoint, request,
128+
ssl_credentials=grpc.ssl_channel_credentials())
118129
119130
# Use the channel to create a stub.
120131
cloud_speech.create_Speech_stub(channel)
121132
133+
Usage:
134+
135+
There are actually a couple of options to create a channel, depending on if
136+
you want to create a regular or mutual TLS channel.
137+
138+
First let's list the endpoints (regular vs mutual TLS) to choose from::
139+
140+
regular_endpoint = 'speech.googleapis.com:443'
141+
mtls_endpoint = 'speech.mtls.googleapis.com:443'
142+
143+
Option 1: create a regular (non-mutual) TLS channel by explicitly setting
144+
the ssl_credentials::
145+
146+
regular_ssl_credentials = grpc.ssl_channel_credentials()
147+
148+
channel = google.auth.transport.grpc.secure_authorized_channel(
149+
credentials, regular_endpoint, request,
150+
ssl_credentials=regular_ssl_credentials)
151+
152+
Option 2: create a mutual TLS channel by calling a callback which returns
153+
the client side certificate and the key::
154+
155+
def my_client_cert_callback():
156+
code_to_load_client_cert_and_key()
157+
if loaded:
158+
return (pem_cert_bytes, pem_key_bytes)
159+
raise MyClientCertFailureException()
160+
161+
try:
162+
channel = google.auth.transport.grpc.secure_authorized_channel(
163+
credentials, mtls_endpoint, request,
164+
client_cert_callback=my_client_cert_callback)
165+
except MyClientCertFailureException:
166+
# handle the exception
167+
168+
Option 3: use application default SSL credentials. It searches and uses
169+
the command in a context aware metadata file, which is available on devices
170+
with endpoint verification support.
171+
See https://cloud.google.com/endpoint-verification/docs/overview::
172+
173+
try:
174+
default_ssl_credentials = SslCredentials()
175+
except:
176+
# Exception can be raised if the context aware metadata is malformed.
177+
# See :class:`SslCredentials` for the possible exceptions.
178+
179+
# Choose the endpoint based on the SSL credentials type.
180+
if default_ssl_credentials.is_mtls:
181+
endpoint_to_use = mtls_endpoint
182+
else:
183+
endpoint_to_use = regular_endpoint
184+
channel = google.auth.transport.grpc.secure_authorized_channel(
185+
credentials, endpoint_to_use, request,
186+
ssl_credentials=default_ssl_credentials)
187+
188+
Option 4: not setting ssl_credentials and client_cert_callback. For devices
189+
without endpoint verification support, a regular TLS channel is created;
190+
otherwise, a mutual TLS channel is created, however, the call should be
191+
wrapped in a try/except block in case of malformed context aware metadata.
192+
193+
The following code uses regular_endpoint, it works the same no matter the
194+
created channle is regular or mutual TLS. Regular endpoint ignores client
195+
certificate and key::
196+
197+
channel = google.auth.transport.grpc.secure_authorized_channel(
198+
credentials, regular_endpoint, request)
199+
200+
The following code uses mtls_endpoint, if the created channle is regular,
201+
and API mtls_endpoint is confgured to require client SSL credentials, API
202+
calls using this channel will be rejected::
203+
204+
channel = google.auth.transport.grpc.secure_authorized_channel(
205+
credentials, mtls_endpoint, request)
206+
122207
Args:
123208
credentials (google.auth.credentials.Credentials): The credentials to
124209
add to requests.
@@ -129,23 +214,118 @@ def secure_authorized_channel(
129214
target (str): The host and port of the service.
130215
ssl_credentials (grpc.ChannelCredentials): Optional SSL channel
131216
credentials. This can be used to specify different certificates.
217+
This argument is mutually exclusive with client_cert_callback;
218+
providing both will raise an exception.
219+
If ssl_credentials and client_cert_callback are None, application
220+
default SSL credentials will be used.
221+
client_cert_callback (Callable[[], (bytes, bytes)]): Optional
222+
callback function to obtain client certicate and key for mutual TLS
223+
connection. This argument is mutually exclusive with
224+
ssl_credentials; providing both will raise an exception.
225+
If ssl_credentials and client_cert_callback are None, application
226+
default SSL credentials will be used.
132227
kwargs: Additional arguments to pass to :func:`grpc.secure_channel`.
133228
134229
Returns:
135230
grpc.Channel: The created gRPC channel.
231+
232+
Raises:
233+
OSError: If the cert provider command launch fails during the application
234+
default SSL credentials loading process on devices with endpoint
235+
verification support.
236+
RuntimeError: If the cert provider command has a runtime error during the
237+
application default SSL credentials loading process on devices with
238+
endpoint verification support.
239+
ValueError:
240+
If the context aware metadata file is malformed or if the cert provider
241+
command doesn't produce both client certificate and key during the
242+
application default SSL credentials loading process on devices with
243+
endpoint verification support.
136244
"""
137245
# Create the metadata plugin for inserting the authorization header.
138246
metadata_plugin = AuthMetadataPlugin(credentials, request)
139247

140248
# Create a set of grpc.CallCredentials using the metadata plugin.
141249
google_auth_credentials = grpc.metadata_call_credentials(metadata_plugin)
142250

143-
if ssl_credentials is None:
144-
ssl_credentials = grpc.ssl_channel_credentials()
251+
if ssl_credentials and client_cert_callback:
252+
raise ValueError(
253+
"Received both ssl_credentials and client_cert_callback; "
254+
"these are mutually exclusive."
255+
)
256+
257+
# If SSL credentials are not explicitly set, try client_cert_callback and ADC.
258+
if not ssl_credentials:
259+
if client_cert_callback:
260+
# Use the callback if provided.
261+
cert, key = client_cert_callback()
262+
ssl_credentials = grpc.ssl_channel_credentials(
263+
certificate_chain=cert, private_key=key
264+
)
265+
else:
266+
# Use application default SSL credentials.
267+
adc_ssl_credentils = SslCredentials()
268+
ssl_credentials = adc_ssl_credentils.ssl_credentials
145269

146270
# Combine the ssl credentials and the authorization credentials.
147271
composite_credentials = grpc.composite_channel_credentials(
148272
ssl_credentials, google_auth_credentials
149273
)
150274

151275
return grpc.secure_channel(target, composite_credentials, **kwargs)
276+
277+
278+
class SslCredentials:
279+
"""Class for application default SSL credentials.
280+
281+
For devices with endpoint verification support, a device certificate will be
282+
automatically loaded and mutual TLS will be established.
283+
See https://cloud.google.com/endpoint-verification/docs/overview.
284+
"""
285+
286+
def __init__(self):
287+
# Load client SSL credentials.
288+
self._context_aware_metadata_path = _mtls_helper._check_dca_metadata_path(
289+
_mtls_helper.CONTEXT_AWARE_METADATA_PATH
290+
)
291+
if self._context_aware_metadata_path:
292+
self._is_mtls = True
293+
else:
294+
self._is_mtls = False
295+
296+
@property
297+
def ssl_credentials(self):
298+
"""Get the created SSL channel credentials.
299+
300+
For devices with endpoint verification support, if the device certificate
301+
loading has any problems, corresponding exceptions will be raised. For
302+
a device without endpoint verification support, no exceptions will be
303+
raised.
304+
305+
Returns:
306+
grpc.ChannelCredentials: The created grpc channel credentials.
307+
308+
Raises:
309+
OSError: If the cert provider command launch fails.
310+
RuntimeError: If the cert provider command has a runtime error.
311+
ValueError:
312+
If the context aware metadata file is malformed or if the cert provider
313+
command doesn't produce both the client certificate and key.
314+
"""
315+
if self._context_aware_metadata_path:
316+
metadata = _mtls_helper._read_dca_metadata_file(
317+
self._context_aware_metadata_path
318+
)
319+
cert, key = _mtls_helper.get_client_ssl_credentials(metadata)
320+
self._ssl_credentials = grpc.ssl_channel_credentials(
321+
certificate_chain=cert, private_key=key
322+
)
323+
else:
324+
self._ssl_credentials = grpc.ssl_channel_credentials()
325+
326+
return self._ssl_credentials
327+
328+
@property
329+
def is_mtls(self):
330+
"""Indicates if the created SSL channel credentials is mutual TLS."""
331+
return self._is_mtls
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"cert_provider_command":[
3+
"/opt/google/endpoint-verification/bin/SecureConnectHelper",
4+
"--print_certificate"],
5+
"device_resource_ids":["11111111-1111-1111"]
6+
}

0 commit comments

Comments
 (0)