Repository: googleapis/python-storage

Issue:
[Storage] V4 Signature: Formatting Inconsistencies
Tracking issue for work defined in companion document.

[New Client Request - GCS SigV4 Formatting Inconsistencies](https://docs.google.com/document/d/11OpYYj2FMFsWe-slIKK-JQC97CUh4CYOWeraR0wuImo).


Discussion:

Patch:
diff --git a/google/cloud/storage/_signing.py b/google/cloud/storage/_signing.py
index e7c8e33..0da075d 100644
--- a/google/cloud/storage/_signing.py
+++ b/google/cloud/storage/_signing.py
@@ -18,7 +18,6 @@ import binascii
 import collections
 import datetime
 import hashlib
-import re
 import json
 
 import six
@@ -31,8 +30,6 @@ from google.cloud import _helpers
 
 
 NOW = datetime.datetime.utcnow  # To be replaced by tests.
-MULTIPLE_SPACES_RE = r"\s+"
-MULTIPLE_SPACES = re.compile(MULTIPLE_SPACES_RE)
 
 SERVICE_ACCOUNT_URL = (
     "https://googleapis.dev/python/google-api-core/latest/"
@@ -192,7 +189,7 @@ def get_canonical_headers(headers):
     normalized = collections.defaultdict(list)
     for key, val in headers:
         key = key.lower().strip()
-        val = MULTIPLE_SPACES.sub(" ", val.strip())
+        val = " ".join(val.split())
         normalized[key].append(val)
 
     ordered_headers = sorted((key, ",".join(val)) for key, val in normalized.items())
@@ -206,8 +203,8 @@ _Canonical = collections.namedtuple(
 )
 
 
-def canonicalize(method, resource, query_parameters, headers):
-    """Canonicalize method, resource
+def canonicalize_v2(method, resource, query_parameters, headers):
+    """Canonicalize method, resource per the V2 spec.
 
     :type method: str
     :param method: The HTTP verb that will be used when requesting the URL.
@@ -301,6 +298,7 @@ def generate_signed_url_v2(
     :type resource: str
     :param resource: A pointer to a specific resource
                      (typically, ``/bucket-name/path/to/blob.txt``).
+                     Caller should have already URL-encoded the value.
 
     :type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
     :param expiration: Point in time when the signed URL should expire.
@@ -368,7 +366,7 @@ def generate_signed_url_v2(
     """
     expiration_stamp = get_expiration_seconds_v2(expiration)
 
-    canonical = canonicalize(method, resource, query_parameters, headers)
+    canonical = canonicalize_v2(method, resource, query_parameters, headers)
 
     # Generate the string to sign.
     elements_to_sign = [
@@ -462,6 +460,7 @@ def generate_signed_url_v4(
     :type resource: str
     :param resource: A pointer to a specific resource
                      (typically, ``/bucket-name/path/to/blob.txt``).
+                     Caller should have already URL-encoded the value.
 
     :type expiration: Union[Integer, datetime.datetime, datetime.timedelta]
     :param expiration: Point in time when the signed URL should expire.
@@ -589,13 +588,20 @@ def generate_signed_url_v4(
     ordered_query_parameters = sorted(query_parameters.items())
     canonical_query_string = six.moves.urllib.parse.urlencode(ordered_query_parameters)
 
+    lowercased_headers = dict(ordered_headers)
+
+    if "x-goog-content-sha256" in lowercased_headers:
+        payload = lowercased_headers["x-goog-content-sha256"]
+    else:
+        payload = "UNSIGNED-PAYLOAD"
+
     canonical_elements = [
         method,
         resource,
         canonical_query_string,
         canonical_header_string,
         signed_headers,
-        "UNSIGNED-PAYLOAD",
+        payload,
     ]
     canonical_request = "\n".join(canonical_elements)
 


Test patch:
diff --git a/tests/unit/test__signing.py b/tests/unit/test__signing.py
index ebd7f9c..c7231b5 100644
--- a/tests/unit/test__signing.py
+++ b/tests/unit/test__signing.py
@@ -309,12 +309,12 @@ class Test_get_canonical_headers(unittest.TestCase):
         self.assertEqual(ordered, expected_ordered)
 
 
-class Test_canonicalize(unittest.TestCase):
+class Test_canonicalize_v2(unittest.TestCase):
     @staticmethod
     def _call_fut(*args, **kwargs):
-        from google.cloud.storage._signing import canonicalize
+        from google.cloud.storage._signing import canonicalize_v2
 
-        return canonicalize(*args, **kwargs)
+        return canonicalize_v2(*args, **kwargs)
 
     def test_wo_headers_or_query_parameters(self):
         method = "GET"
@@ -650,6 +650,9 @@ class Test_generate_signed_url_v4(unittest.TestCase):
     def test_w_custom_headers(self):
         self._generate_helper(headers={"x-goog-foo": "bar"})
 
+    def test_w_custom_payload_hash_goog(self):
+        self._generate_helper(headers={"x-goog-content-sha256": "DEADBEEF"})
+
     def test_w_custom_query_parameters_w_string_value(self):
         self._generate_helper(query_parameters={"bar": "/"})