1# Copyright 2017 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
16import base64
17import binascii
18import collections
19import datetime
20import hashlib
21import json
22
23import six
24
25import google.auth.credentials
26
27from google.auth import exceptions
28from google.auth.transport import requests
29from google.cloud import _helpers
30
31
32NOW = datetime.datetime.utcnow  # To be replaced by tests.
33
34SERVICE_ACCOUNT_URL = (
35    "https://googleapis.dev/python/google-api-core/latest/"
36    "auth.html#setting-up-a-service-account"
37)
38
39
40def ensure_signed_credentials(credentials):
41    """Raise AttributeError if the credentials are unsigned.
42
43    :type credentials: :class:`google.auth.credentials.Signing`
44    :param credentials: The credentials used to create a private key
45                        for signing text.
46
47    :raises: :exc:`AttributeError` if credentials is not an instance
48            of :class:`google.auth.credentials.Signing`.
49    """
50    if not isinstance(credentials, google.auth.credentials.Signing):
51        raise AttributeError(
52            "you need a private key to sign credentials."
53            "the credentials you are currently using {} "
54            "just contains a token. see {} for more "
55            "details.".format(type(credentials), SERVICE_ACCOUNT_URL)
56        )
57
58
59def get_signed_query_params_v2(credentials, expiration, string_to_sign):
60    """Gets query parameters for creating a signed URL.
61
62    :type credentials: :class:`google.auth.credentials.Signing`
63    :param credentials: The credentials used to create a private key
64                        for signing text.
65
66    :type expiration: int or long
67    :param expiration: When the signed URL should expire.
68
69    :type string_to_sign: str
70    :param string_to_sign: The string to be signed by the credentials.
71
72    :raises: :exc:`AttributeError` if credentials is not an instance
73            of :class:`google.auth.credentials.Signing`.
74
75    :rtype: dict
76    :returns: Query parameters matching the signing credentials with a
77              signed payload.
78    """
79    ensure_signed_credentials(credentials)
80    signature_bytes = credentials.sign_bytes(string_to_sign.encode("ascii"))
81    signature = base64.b64encode(signature_bytes)
82    service_account_name = credentials.signer_email
83    return {
84        "GoogleAccessId": service_account_name,
85        "Expires": expiration,
86        "Signature": signature,
87    }
88
89
90def get_expiration_seconds_v2(expiration):
91    """Convert 'expiration' to a number of seconds in the future.
92
93    :type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
94    :param expiration: Point in time when the signed URL should expire. If
95                       a ``datetime`` instance is passed without an explicit
96                       ``tzinfo`` set,  it will be assumed to be ``UTC``.
97
98    :raises: :exc:`TypeError` when expiration is not a valid type.
99
100    :rtype: int
101    :returns: a timestamp as an absolute number of seconds since epoch.
102    """
103    # If it's a timedelta, add it to `now` in UTC.
104    if isinstance(expiration, datetime.timedelta):
105        now = NOW().replace(tzinfo=_helpers.UTC)
106        expiration = now + expiration
107
108    # If it's a datetime, convert to a timestamp.
109    if isinstance(expiration, datetime.datetime):
110        micros = _helpers._microseconds_from_datetime(expiration)
111        expiration = micros // 10 ** 6
112
113    if not isinstance(expiration, six.integer_types):
114        raise TypeError(
115            "Expected an integer timestamp, datetime, or "
116            "timedelta. Got %s" % type(expiration)
117        )
118    return expiration
119
120
121_EXPIRATION_TYPES = six.integer_types + (datetime.datetime, datetime.timedelta)
122
123
124def get_expiration_seconds_v4(expiration):
125    """Convert 'expiration' to a number of seconds offset from the current time.
126
127    :type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
128    :param expiration: Point in time when the signed URL should expire. If
129                       a ``datetime`` instance is passed without an explicit
130                       ``tzinfo`` set,  it will be assumed to be ``UTC``.
131
132    :raises: :exc:`TypeError` when expiration is not a valid type.
133    :raises: :exc:`ValueError` when expiration is too large.
134    :rtype: Integer
135    :returns: seconds in the future when the signed URL will expire
136    """
137    if not isinstance(expiration, _EXPIRATION_TYPES):
138        raise TypeError(
139            "Expected an integer timestamp, datetime, or "
140            "timedelta. Got %s" % type(expiration)
141        )
142
143    now = NOW().replace(tzinfo=_helpers.UTC)
144
145    if isinstance(expiration, six.integer_types):
146        seconds = expiration
147
148    if isinstance(expiration, datetime.datetime):
149
150        if expiration.tzinfo is None:
151            expiration = expiration.replace(tzinfo=_helpers.UTC)
152
153        expiration = expiration - now
154
155    if isinstance(expiration, datetime.timedelta):
156        seconds = int(expiration.total_seconds())
157
158    if seconds > SEVEN_DAYS:
159        raise ValueError(
160            "Max allowed expiration interval is seven days {}".format(SEVEN_DAYS)
161        )
162
163    return seconds
164
165
166def get_canonical_headers(headers):
167    """Canonicalize headers for signing.
168
169    See:
170    https://cloud.google.com/storage/docs/access-control/signed-urls#about-canonical-extension-headers
171
172    :type headers: Union[dict|List(Tuple(str,str))]
173    :param headers:
174        (Optional) Additional HTTP headers to be included as part of the
175        signed URLs.  See:
176        https://cloud.google.com/storage/docs/xml-api/reference-headers
177        Requests using the signed URL *must* pass the specified header
178        (name and value) with each request for the URL.
179
180    :rtype: str
181    :returns: List of headers, normalized / sortted per the URL refernced above.
182    """
183    if headers is None:
184        headers = []
185    elif isinstance(headers, dict):
186        headers = list(headers.items())
187
188    if not headers:
189        return [], []
190
191    normalized = collections.defaultdict(list)
192    for key, val in headers:
193        key = key.lower().strip()
194        val = " ".join(val.split())
195        normalized[key].append(val)
196
197    ordered_headers = sorted((key, ",".join(val)) for key, val in normalized.items())
198
199    canonical_headers = ["{}:{}".format(*item) for item in ordered_headers]
200    return canonical_headers, ordered_headers
201
202
203_Canonical = collections.namedtuple(
204    "_Canonical", ["method", "resource", "query_parameters", "headers"]
205)
206
207
208def canonicalize_v2(method, resource, query_parameters, headers):
209    """Canonicalize method, resource per the V2 spec.
210
211    :type method: str
212    :param method: The HTTP verb that will be used when requesting the URL.
213                   Defaults to ``'GET'``. If method is ``'RESUMABLE'`` then the
214                   signature will additionally contain the `x-goog-resumable`
215                   header, and the method changed to POST. See the signed URL
216                   docs regarding this flow:
217                   https://cloud.google.com/storage/docs/access-control/signed-urls
218
219    :type resource: str
220    :param resource: A pointer to a specific resource
221                     (typically, ``/bucket-name/path/to/blob.txt``).
222
223    :type query_parameters: dict
224    :param query_parameters:
225        (Optional) Additional query parameters to be included as part of the
226        signed URLs.  See:
227        https://cloud.google.com/storage/docs/xml-api/reference-headers#query
228
229    :type headers: Union[dict|List(Tuple(str,str))]
230    :param headers:
231        (Optional) Additional HTTP headers to be included as part of the
232        signed URLs.  See:
233        https://cloud.google.com/storage/docs/xml-api/reference-headers
234        Requests using the signed URL *must* pass the specified header
235        (name and value) with each request for the URL.
236
237    :rtype: :class:_Canonical
238    :returns: Canonical method, resource, query_parameters, and headers.
239    """
240    headers, _ = get_canonical_headers(headers)
241
242    if method == "RESUMABLE":
243        method = "POST"
244        headers.append("x-goog-resumable:start")
245
246    if query_parameters is None:
247        return _Canonical(method, resource, [], headers)
248
249    normalized_qp = sorted(
250        (key.lower(), value and value.strip() or "")
251        for key, value in query_parameters.items()
252    )
253    encoded_qp = six.moves.urllib.parse.urlencode(normalized_qp)
254    canonical_resource = "{}?{}".format(resource, encoded_qp)
255    return _Canonical(method, canonical_resource, normalized_qp, headers)
256
257
258def generate_signed_url_v2(
259    credentials,
260    resource,
261    expiration,
262    api_access_endpoint="",
263    method="GET",
264    content_md5=None,
265    content_type=None,
266    response_type=None,
267    response_disposition=None,
268    generation=None,
269    headers=None,
270    query_parameters=None,
271    service_account_email=None,
272    access_token=None,
273):
274    """Generate a V2 signed URL to provide query-string auth'n to a resource.
275
276    .. note::
277
278        Assumes ``credentials`` implements the
279        :class:`google.auth.credentials.Signing` interface. Also assumes
280        ``credentials`` has a ``signer_email`` property which
281        identifies the credentials.
282
283    .. note::
284
285        If you are on Google Compute Engine, you can't generate a signed URL.
286        Follow `Issue 922`_ for updates on this. If you'd like to be able to
287        generate a signed URL from GCE, you can use a standard service account
288        from a JSON file rather than a GCE service account.
289
290    See headers `reference`_ for more details on optional arguments.
291
292    .. _Issue 922: https://github.com/GoogleCloudPlatform/\
293                   google-cloud-python/issues/922
294    .. _reference: https://cloud.google.com/storage/docs/reference-headers
295
296    :type credentials: :class:`google.auth.credentials.Signing`
297    :param credentials: Credentials object with an associated private key to
298                        sign text.
299
300    :type resource: str
301    :param resource: A pointer to a specific resource
302                     (typically, ``/bucket-name/path/to/blob.txt``).
303                     Caller should have already URL-encoded the value.
304
305    :type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
306    :param expiration: Point in time when the signed URL should expire. If
307                       a ``datetime`` instance is passed without an explicit
308                       ``tzinfo`` set,  it will be assumed to be ``UTC``.
309
310    :type api_access_endpoint: str
311    :param api_access_endpoint: (Optional) URI base. Defaults to empty string.
312
313    :type method: str
314    :param method: The HTTP verb that will be used when requesting the URL.
315                   Defaults to ``'GET'``. If method is ``'RESUMABLE'`` then the
316                   signature will additionally contain the `x-goog-resumable`
317                   header, and the method changed to POST. See the signed URL
318                   docs regarding this flow:
319                   https://cloud.google.com/storage/docs/access-control/signed-urls
320
321
322    :type content_md5: str
323    :param content_md5: (Optional) The MD5 hash of the object referenced by
324                        ``resource``.
325
326    :type content_type: str
327    :param content_type: (Optional) The content type of the object referenced
328                         by ``resource``.
329
330    :type response_type: str
331    :param response_type: (Optional) Content type of responses to requests for
332                          the signed URL. Ignored if content_type is set on
333                          object/blob metadata.
334
335    :type response_disposition: str
336    :param response_disposition: (Optional) Content disposition of responses to
337                                 requests for the signed URL.
338
339    :type generation: str
340    :param generation: (Optional) A value that indicates which generation of
341                       the resource to fetch.
342
343    :type headers: Union[dict|List(Tuple(str,str))]
344    :param headers:
345        (Optional) Additional HTTP headers to be included as part of the
346        signed URLs.  See:
347        https://cloud.google.com/storage/docs/xml-api/reference-headers
348        Requests using the signed URL *must* pass the specified header
349        (name and value) with each request for the URL.
350
351    :type service_account_email: str
352    :param service_account_email: (Optional) E-mail address of the service account.
353
354    :type access_token: str
355    :param access_token: (Optional) Access token for a service account.
356
357    :type query_parameters: dict
358    :param query_parameters:
359        (Optional) Additional query parameters to be included as part of the
360        signed URLs.  See:
361        https://cloud.google.com/storage/docs/xml-api/reference-headers#query
362
363    :raises: :exc:`TypeError` when expiration is not a valid type.
364    :raises: :exc:`AttributeError` if credentials is not an instance
365            of :class:`google.auth.credentials.Signing`.
366
367    :rtype: str
368    :returns: A signed URL you can use to access the resource
369              until expiration.
370    """
371    expiration_stamp = get_expiration_seconds_v2(expiration)
372
373    canonical = canonicalize_v2(method, resource, query_parameters, headers)
374
375    # Generate the string to sign.
376    elements_to_sign = [
377        canonical.method,
378        content_md5 or "",
379        content_type or "",
380        str(expiration_stamp),
381    ]
382    elements_to_sign.extend(canonical.headers)
383    elements_to_sign.append(canonical.resource)
384    string_to_sign = "\n".join(elements_to_sign)
385
386    # Set the right query parameters.
387    if access_token and service_account_email:
388        signature = _sign_message(string_to_sign, access_token, service_account_email)
389        signed_query_params = {
390            "GoogleAccessId": service_account_email,
391            "Expires": expiration_stamp,
392            "Signature": signature,
393        }
394    else:
395        signed_query_params = get_signed_query_params_v2(
396            credentials, expiration_stamp, string_to_sign
397        )
398
399    if response_type is not None:
400        signed_query_params["response-content-type"] = response_type
401    if response_disposition is not None:
402        signed_query_params["response-content-disposition"] = response_disposition
403    if generation is not None:
404        signed_query_params["generation"] = generation
405
406    signed_query_params.update(canonical.query_parameters)
407    sorted_signed_query_params = sorted(signed_query_params.items())
408
409    # Return the built URL.
410    return "{endpoint}{resource}?{querystring}".format(
411        endpoint=api_access_endpoint,
412        resource=resource,
413        querystring=six.moves.urllib.parse.urlencode(sorted_signed_query_params),
414    )
415
416
417SEVEN_DAYS = 7 * 24 * 60 * 60  # max age for V4 signed URLs.
418DEFAULT_ENDPOINT = "https://storage.googleapis.com"
419
420
421def generate_signed_url_v4(
422    credentials,
423    resource,
424    expiration,
425    api_access_endpoint=DEFAULT_ENDPOINT,
426    method="GET",
427    content_md5=None,
428    content_type=None,
429    response_type=None,
430    response_disposition=None,
431    generation=None,
432    headers=None,
433    query_parameters=None,
434    service_account_email=None,
435    access_token=None,
436    _request_timestamp=None,  # for testing only
437):
438    """Generate a V4 signed URL to provide query-string auth'n to a resource.
439
440    .. note::
441
442        Assumes ``credentials`` implements the
443        :class:`google.auth.credentials.Signing` interface. Also assumes
444        ``credentials`` has a ``signer_email`` property which
445        identifies the credentials.
446
447    .. note::
448
449        If you are on Google Compute Engine, you can't generate a signed URL.
450        Follow `Issue 922`_ for updates on this. If you'd like to be able to
451        generate a signed URL from GCE, you can use a standard service account
452        from a JSON file rather than a GCE service account.
453
454    See headers `reference`_ for more details on optional arguments.
455
456    .. _Issue 922: https://github.com/GoogleCloudPlatform/\
457                   google-cloud-python/issues/922
458    .. _reference: https://cloud.google.com/storage/docs/reference-headers
459
460
461    :type credentials: :class:`google.auth.credentials.Signing`
462    :param credentials: Credentials object with an associated private key to
463                        sign text. That credentials must provide signer_email
464                        only if service_account_email and access_token are not
465                        passed.
466
467    :type resource: str
468    :param resource: A pointer to a specific resource
469                     (typically, ``/bucket-name/path/to/blob.txt``).
470                     Caller should have already URL-encoded the value.
471
472    :type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
473    :param expiration: Point in time when the signed URL should expire. If
474                       a ``datetime`` instance is passed without an explicit
475                       ``tzinfo`` set,  it will be assumed to be ``UTC``.
476
477    :type api_access_endpoint: str
478    :param api_access_endpoint: (Optional) URI base. Defaults to
479                                "https://storage.googleapis.com/"
480
481    :type method: str
482    :param method: The HTTP verb that will be used when requesting the URL.
483                   Defaults to ``'GET'``. If method is ``'RESUMABLE'`` then the
484                   signature will additionally contain the `x-goog-resumable`
485                   header, and the method changed to POST. See the signed URL
486                   docs regarding this flow:
487                   https://cloud.google.com/storage/docs/access-control/signed-urls
488
489
490    :type content_md5: str
491    :param content_md5: (Optional) The MD5 hash of the object referenced by
492                        ``resource``.
493
494    :type content_type: str
495    :param content_type: (Optional) The content type of the object referenced
496                         by ``resource``.
497
498    :type response_type: str
499    :param response_type: (Optional) Content type of responses to requests for
500                          the signed URL. Ignored if content_type is set on
501                          object/blob metadata.
502
503    :type response_disposition: str
504    :param response_disposition: (Optional) Content disposition of responses to
505                                 requests for the signed URL.
506
507    :type generation: str
508    :param generation: (Optional) A value that indicates which generation of
509                       the resource to fetch.
510
511    :type headers: dict
512    :param headers:
513        (Optional) Additional HTTP headers to be included as part of the
514        signed URLs.  See:
515        https://cloud.google.com/storage/docs/xml-api/reference-headers
516        Requests using the signed URL *must* pass the specified header
517        (name and value) with each request for the URL.
518
519    :type query_parameters: dict
520    :param query_parameters:
521        (Optional) Additional query parameters to be included as part of the
522        signed URLs.  See:
523        https://cloud.google.com/storage/docs/xml-api/reference-headers#query
524
525    :type service_account_email: str
526    :param service_account_email: (Optional) E-mail address of the service account.
527
528    :type access_token: str
529    :param access_token: (Optional) Access token for a service account.
530
531    :raises: :exc:`TypeError` when expiration is not a valid type.
532    :raises: :exc:`AttributeError` if credentials is not an instance
533            of :class:`google.auth.credentials.Signing`.
534
535    :rtype: str
536    :returns: A signed URL you can use to access the resource
537              until expiration.
538    """
539    expiration_seconds = get_expiration_seconds_v4(expiration)
540
541    if _request_timestamp is None:
542        request_timestamp, datestamp = get_v4_now_dtstamps()
543    else:
544        request_timestamp = _request_timestamp
545        datestamp = _request_timestamp[:8]
546
547    client_email = service_account_email
548    if not access_token or not service_account_email:
549        ensure_signed_credentials(credentials)
550        client_email = credentials.signer_email
551
552    credential_scope = "{}/auto/storage/goog4_request".format(datestamp)
553    credential = "{}/{}".format(client_email, credential_scope)
554
555    if headers is None:
556        headers = {}
557
558    if content_type is not None:
559        headers["Content-Type"] = content_type
560
561    if content_md5 is not None:
562        headers["Content-MD5"] = content_md5
563
564    header_names = [key.lower() for key in headers]
565    if "host" not in header_names:
566        headers["Host"] = six.moves.urllib.parse.urlparse(api_access_endpoint).netloc
567
568    if method.upper() == "RESUMABLE":
569        method = "POST"
570        headers["x-goog-resumable"] = "start"
571
572    canonical_headers, ordered_headers = get_canonical_headers(headers)
573    canonical_header_string = (
574        "\n".join(canonical_headers) + "\n"
575    )  # Yes, Virginia, the extra newline is part of the spec.
576    signed_headers = ";".join([key for key, _ in ordered_headers])
577
578    if query_parameters is None:
579        query_parameters = {}
580    else:
581        query_parameters = {key: value or "" for key, value in query_parameters.items()}
582
583    query_parameters["X-Goog-Algorithm"] = "GOOG4-RSA-SHA256"
584    query_parameters["X-Goog-Credential"] = credential
585    query_parameters["X-Goog-Date"] = request_timestamp
586    query_parameters["X-Goog-Expires"] = expiration_seconds
587    query_parameters["X-Goog-SignedHeaders"] = signed_headers
588
589    if response_type is not None:
590        query_parameters["response-content-type"] = response_type
591
592    if response_disposition is not None:
593        query_parameters["response-content-disposition"] = response_disposition
594
595    if generation is not None:
596        query_parameters["generation"] = generation
597
598    canonical_query_string = _url_encode(query_parameters)
599
600    lowercased_headers = dict(ordered_headers)
601
602    if "x-goog-content-sha256" in lowercased_headers:
603        payload = lowercased_headers["x-goog-content-sha256"]
604    else:
605        payload = "UNSIGNED-PAYLOAD"
606
607    canonical_elements = [
608        method,
609        resource,
610        canonical_query_string,
611        canonical_header_string,
612        signed_headers,
613        payload,
614    ]
615    canonical_request = "\n".join(canonical_elements)
616
617    canonical_request_hash = hashlib.sha256(
618        canonical_request.encode("ascii")
619    ).hexdigest()
620
621    string_elements = [
622        "GOOG4-RSA-SHA256",
623        request_timestamp,
624        credential_scope,
625        canonical_request_hash,
626    ]
627    string_to_sign = "\n".join(string_elements)
628
629    if access_token and service_account_email:
630        signature = _sign_message(string_to_sign, access_token, service_account_email)
631        signature_bytes = base64.b64decode(signature)
632        signature = binascii.hexlify(signature_bytes).decode("ascii")
633    else:
634        signature_bytes = credentials.sign_bytes(string_to_sign.encode("ascii"))
635        signature = binascii.hexlify(signature_bytes).decode("ascii")
636
637    return "{}{}?{}&X-Goog-Signature={}".format(
638        api_access_endpoint, resource, canonical_query_string, signature
639    )
640
641
642def get_v4_now_dtstamps():
643    """Get current timestamp and datestamp in V4 valid format.
644
645    :rtype: str, str
646    :returns: Current timestamp, datestamp.
647    """
648    now = NOW()
649    timestamp = now.strftime("%Y%m%dT%H%M%SZ")
650    datestamp = now.date().strftime("%Y%m%d")
651    return timestamp, datestamp
652
653
654def _sign_message(message, access_token, service_account_email):
655
656    """Signs a message.
657
658    :type message: str
659    :param message: The message to be signed.
660
661    :type access_token: str
662    :param access_token: Access token for a service account.
663
664
665    :type service_account_email: str
666    :param service_account_email: E-mail address of the service account.
667
668    :raises: :exc:`TransportError` if an `access_token` is unauthorized.
669
670    :rtype: str
671    :returns: The signature of the message.
672
673    """
674    message = _helpers._to_bytes(message)
675
676    method = "POST"
677    url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:signBlob?alt=json".format(
678        service_account_email
679    )
680    headers = {
681        "Authorization": "Bearer " + access_token,
682        "Content-type": "application/json",
683    }
684    body = json.dumps({"payload": base64.b64encode(message).decode("utf-8")})
685
686    request = requests.Request()
687    response = request(url=url, method=method, body=body, headers=headers)
688
689    if response.status != six.moves.http_client.OK:
690        raise exceptions.TransportError(
691            "Error calling the IAM signBytes API: {}".format(response.data)
692        )
693
694    data = json.loads(response.data.decode("utf-8"))
695    return data["signedBlob"]
696
697
698def _url_encode(query_params):
699    """Encode query params into URL.
700
701    :type query_params: dict
702    :param query_params: Query params to be encoded.
703
704    :rtype: str
705    :returns: URL encoded query params.
706    """
707    params = [
708        "{}={}".format(_quote_param(name), _quote_param(value))
709        for name, value in query_params.items()
710    ]
711
712    return "&".join(sorted(params))
713
714
715def _quote_param(param):
716    """Quote query param.
717
718    :type param: Any
719    :param param: Query param to be encoded.
720
721    :rtype: str
722    :returns: URL encoded query param.
723    """
724    if not isinstance(param, bytes):
725        param = str(param)
726    return six.moves.urllib.parse.quote(param, safe="~")
727