diff --git a/gcloud/storage/blob.py b/gcloud/storage/blob.py index 92c11e412320e..e3c1d615475f0 100644 --- a/gcloud/storage/blob.py +++ b/gcloud/storage/blob.py @@ -14,7 +14,9 @@ """Create / interact with Google Cloud Storage blobs.""" +import base64 import copy +import hashlib from io import BytesIO import json import mimetypes @@ -109,6 +111,22 @@ def path_helper(bucket_path, blob_name): """ return bucket_path + '/o/' + quote(blob_name, safe='') + @staticmethod + def _get_customer_encryption_headers(key): + """Builds customer encyrption key headers + + :type key: str + :param key: 32 byte key to build request key and hash. + """ + headers = {} + key_hash = base64.encodestring(hashlib.sha256(key).digest()) + encoded_key = base64.encodestring(key) + headers['X-Goog-Encryption-Algorithm'] = 'AES256' + headers['X-Goog-Encryption-Key'] = encoded_key.rstrip() + headers['X-Goog-Encryption-Key-Sha256'] = key_hash.rstrip() + + return headers + @property def acl(self): """Create our ACL on demand.""" @@ -276,7 +294,7 @@ def delete(self, client=None): """ return self.bucket.delete_blob(self.name, client=client) - def download_to_file(self, file_obj, client=None): + def download_to_file(self, file_obj, key=None, client=None): """Download the contents of this blob into a file-like object. .. note:: @@ -284,9 +302,26 @@ def download_to_file(self, file_obj, client=None): If the server-set property, :attr:`media_link`, is not yet initialized, makes an additional API request to load it. + Downloading a file that has been `customer-supplied + `_ + encryption:: + + >>> from gcloud import storage + >>> from gcloud.storage import Blob + + >>> sc = storage.Client(project='my-project') + >>> bucket = sc.get_bucket('my-bucket') + >>> key = 'aa426195405adee2c8081bb9e7e74b19' + >>> blob = Blob('secure-data', bucket) + >>> with open('/tmp/my-secure-file', 'w') as file_obj: + >>> blob.download_to_file(file_obj, key=key) + :type file_obj: file :param file_obj: A file handle to which to write the blob's data. + :type key: str + :param key: Optional 32 byte key for customer-supplied encryption. + :type client: :class:`gcloud.storage.client.Client` or ``NoneType`` :param client: Optional. The client to use. If not passed, falls back to the ``client`` stored on the blob's bucket. @@ -305,7 +340,11 @@ def download_to_file(self, file_obj, client=None): if self.chunk_size is not None: download.chunksize = self.chunk_size - request = Request(download_url, 'GET') + headers = {} + if key: + headers.update(self._get_customer_encryption_headers(key)) + + request = Request(download_url, 'GET', headers) # Use the private ``_connection`` rather than the public # ``.connection``, since the public connection may be a batch. A @@ -315,12 +354,15 @@ def download_to_file(self, file_obj, client=None): # it has all three (http, API_BASE_URL and build_api_url). download.initialize_download(request, client._connection.http) - def download_to_filename(self, filename, client=None): + def download_to_filename(self, filename, key=None, client=None): """Download the contents of this blob into a named file. :type filename: string :param filename: A filename to be passed to ``open``. + :type key: str + :param key: Optional 32 byte key for customer-supplied encryption. + :type client: :class:`gcloud.storage.client.Client` or ``NoneType`` :param client: Optional. The client to use. If not passed, falls back to the ``client`` stored on the blob's bucket. @@ -328,14 +370,30 @@ def download_to_filename(self, filename, client=None): :raises: :class:`gcloud.exceptions.NotFound` """ with open(filename, 'wb') as file_obj: - self.download_to_file(file_obj, client=client) + self.download_to_file(file_obj, key=key, client=client) mtime = time.mktime(self.updated.timetuple()) os.utime(file_obj.name, (mtime, mtime)) - def download_as_string(self, client=None): + def download_as_string(self, key=None, client=None): """Download the contents of this blob as a string. + Downloading a blob that has been `customer-supplied + `_ + encryption:: + + >>> from gcloud import storage + >>> from gcloud.storage import Blob + + >>> sc = storage.Client(project='my-project') + >>> bucket = sc.get_bucket('my-bucket') + >>> key = 'aa426195405adee2c8081bb9e7e74b19' + >>> blob = Blob('secure-data', bucket) + >>> data = blob.download_as_string(file_obj, key=key) + + :type key: str + :param key: Optional 32 byte key for customer-supplied encryption. + :type client: :class:`gcloud.storage.client.Client` or ``NoneType`` :param client: Optional. The client to use. If not passed, falls back to the ``client`` stored on the blob's bucket. @@ -345,7 +403,7 @@ def download_as_string(self, client=None): :raises: :class:`gcloud.exceptions.NotFound` """ string_buffer = BytesIO() - self.download_to_file(string_buffer, client=client) + self.download_to_file(string_buffer, key=key, client=client) return string_buffer.getvalue() @staticmethod @@ -358,7 +416,8 @@ def _check_response_error(request, http_response): raise make_exception(faux_response, http_response.content, error_info=request.url) - def upload_from_file(self, file_obj, rewind=False, size=None, + # pylint: disable=too-many-arguments,too-many-locals + def upload_from_file(self, file_obj, rewind=False, size=None, key=None, content_type=None, num_retries=6, client=None): """Upload the contents of this blob from a file-like object. @@ -391,6 +450,9 @@ def upload_from_file(self, file_obj, rewind=False, size=None, :func:`os.fstat`. (If the file handle is not from the filesystem this won't be possible.) + :type key: str + :param key: Optional 32 byte key for customer-supplied encryption. + :type content_type: string or ``NoneType`` :param content_type: Optional type of content being uploaded. @@ -434,6 +496,9 @@ def upload_from_file(self, file_obj, rewind=False, size=None, 'User-Agent': connection.USER_AGENT, } + if key: + headers.update(self._get_customer_encryption_headers(key)) + upload = Upload(file_obj, content_type, total_bytes, auto_transfer=False) @@ -474,7 +539,7 @@ def upload_from_file(self, file_obj, rewind=False, size=None, response_content = response_content.decode('utf-8') self._set_properties(json.loads(response_content)) - def upload_from_filename(self, filename, content_type=None, + def upload_from_filename(self, filename, content_type=None, key=None, client=None): """Upload this blob's contents from the content of a named file. @@ -500,6 +565,9 @@ def upload_from_filename(self, filename, content_type=None, :type content_type: string or ``NoneType`` :param content_type: Optional type of content being uploaded. + :type key: str + :param key: Optional 32 byte key for customer-supplied encryption. + :type client: :class:`gcloud.storage.client.Client` or ``NoneType`` :param client: Optional. The client to use. If not passed, falls back to the ``client`` stored on the blob's bucket. @@ -509,10 +577,10 @@ def upload_from_filename(self, filename, content_type=None, content_type, _ = mimetypes.guess_type(filename) with open(filename, 'rb') as file_obj: - self.upload_from_file(file_obj, content_type=content_type, + self.upload_from_file(file_obj, content_type=content_type, key=key, client=client) - def upload_from_string(self, data, content_type='text/plain', + def upload_from_string(self, data, content_type='text/plain', key=None, client=None): """Upload contents of this blob from the provided string. @@ -527,6 +595,19 @@ def upload_from_string(self, data, content_type='text/plain', `lifecycle `_ API documents for details. + Uploading a string that with `customer-supplied + `_ + encryption:: + + >>> from gcloud import storage + >>> from gcloud.storage import Blob + + >>> sc = storage.Client(project='my-project') + >>> bucket = sc.get_bucket('my-bucket') + >>> key = 'aa426195405adee2c8081bb9e7e74b19' + >>> blob = Blob('secure-data', bucket) + >>> b.upload_from_string('my secure string', key=key) + :type data: bytes or text :param data: The data to store in this blob. If the value is text, it will be encoded as UTF-8. @@ -535,6 +616,9 @@ def upload_from_string(self, data, content_type='text/plain', :param content_type: Optional type of content being uploaded. Defaults to ``'text/plain'``. + :type key: str + :param key: Optional 32 byte key for customer-supplied encryption. + :type client: :class:`gcloud.storage.client.Client` or ``NoneType`` :param client: Optional. The client to use. If not passed, falls back to the ``client`` stored on the blob's bucket. @@ -545,7 +629,7 @@ def upload_from_string(self, data, content_type='text/plain', string_buffer.write(data) self.upload_from_file(file_obj=string_buffer, rewind=True, size=len(data), content_type=content_type, - client=client) + key=key, client=client) def make_public(self, client=None): """Make this blob public giving all users read access. diff --git a/gcloud/storage/test_blob.py b/gcloud/storage/test_blob.py index a116114843ed7..f4e7d3cf0f22a 100644 --- a/gcloud/storage/test_blob.py +++ b/gcloud/storage/test_blob.py @@ -383,6 +383,51 @@ def test_download_to_filename(self): self.assertEqual(wrote, b'abcdef') self.assertEqual(mtime, updatedTime) + def test_download_to_filename_w_key(self): + import os + import time + from six.moves.http_client import OK + from six.moves.http_client import PARTIAL_CONTENT + from gcloud._testing import _NamedTemporaryFile + + BLOB_NAME = 'blob-name' + KEY = 'aa426195405adee2c8081bb9e7e74b19' + HEADER_KEY_VALUE = 'YWE0MjYxOTU0MDVhZGVlMmM4MDgxYmI5ZTdlNzRiMTk=' + HEADER_KEY_HASH_VALUE = 'V3Kwe46nKc3xLv96+iJ707YfZfFvlObta8TQcx2gpm0=' + chunk1_response = {'status': PARTIAL_CONTENT, + 'content-range': 'bytes 0-2/6'} + chunk2_response = {'status': OK, + 'content-range': 'bytes 3-5/6'} + connection = _Connection( + (chunk1_response, b'abc'), + (chunk2_response, b'def'), + ) + client = _Client(connection) + bucket = _Bucket(client) + MEDIA_LINK = 'http://example.com/media/' + properties = {'mediaLink': MEDIA_LINK, + 'updated': '2014-12-06T13:13:50.690Z'} + blob = self._makeOne(BLOB_NAME, bucket=bucket, properties=properties) + blob._CHUNK_SIZE_MULTIPLE = 1 + blob.chunk_size = 3 + + with _NamedTemporaryFile() as temp: + blob.download_to_filename(temp.name, key=KEY) + with open(temp.name, 'rb') as file_obj: + wrote = file_obj.read() + mtime = os.path.getmtime(temp.name) + updatedTime = time.mktime(blob.updated.timetuple()) + + rq = connection.http._requested + headers = dict( + [(x.title(), str(y)) for x, y in rq[0]['headers'].items()]) + self.assertEqual(headers['X-Goog-Encryption-Algorithm'], 'AES256') + self.assertEqual(headers['X-Goog-Encryption-Key'], HEADER_KEY_VALUE) + self.assertEqual(headers['X-Goog-Encryption-Key-Sha256'], + HEADER_KEY_HASH_VALUE) + self.assertEqual(wrote, b'abcdef') + self.assertEqual(mtime, updatedTime) + def test_download_as_string(self): from six.moves.http_client import OK from six.moves.http_client import PARTIAL_CONTENT @@ -685,6 +730,63 @@ def test_upload_from_file_w_slash_in_name(self): self.assertEqual(headers['Content-Length'], '6') self.assertEqual(headers['Content-Type'], 'application/octet-stream') + def test_upload_from_filename_w_key(self): + from six.moves.http_client import OK + from six.moves.urllib.parse import parse_qsl + from six.moves.urllib.parse import urlsplit + from gcloud._testing import _NamedTemporaryFile + from gcloud.streaming import http_wrapper + + BLOB_NAME = 'blob-name' + UPLOAD_URL = 'http://example.com/upload/name/key' + DATA = b'ABCDEF' + KEY = 'aa426195405adee2c8081bb9e7e74b19' + HEADER_KEY_VALUE = 'YWE0MjYxOTU0MDVhZGVlMmM4MDgxYmI5ZTdlNzRiMTk=' + HEADER_KEY_HASH_VALUE = 'V3Kwe46nKc3xLv96+iJ707YfZfFvlObta8TQcx2gpm0=' + EXPECTED_CONTENT_TYPE = 'foo/bar' + properties = {'contentType': EXPECTED_CONTENT_TYPE} + loc_response = {'status': OK, 'location': UPLOAD_URL} + chunk1_response = {'status': http_wrapper.RESUME_INCOMPLETE, + 'range': 'bytes 0-4'} + chunk2_response = {'status': OK} + connection = _Connection( + (loc_response, '{}'), + (chunk1_response, ''), + (chunk2_response, ''), + ) + client = _Client(connection) + bucket = _Bucket(client) + blob = self._makeOne(BLOB_NAME, bucket=bucket, + properties=properties) + blob._CHUNK_SIZE_MULTIPLE = 1 + blob.chunk_size = 5 + + with _NamedTemporaryFile(suffix='.jpeg') as temp: + with open(temp.name, 'wb') as file_obj: + file_obj.write(DATA) + blob.upload_from_filename(temp.name, + content_type=EXPECTED_CONTENT_TYPE, + key=KEY) + + rq = connection.http._requested + self.assertEqual(len(rq), 1) + self.assertEqual(rq[0]['method'], 'POST') + uri = rq[0]['uri'] + scheme, netloc, path, qs, _ = urlsplit(uri) + self.assertEqual(scheme, 'http') + self.assertEqual(netloc, 'example.com') + self.assertEqual(path, '/b/name/o') + self.assertEqual(dict(parse_qsl(qs)), + {'uploadType': 'media', 'name': BLOB_NAME}) + headers = dict( + [(x.title(), str(y)) for x, y in rq[0]['headers'].items()]) + self.assertEqual(headers['X-Goog-Encryption-Algorithm'], 'AES256') + self.assertEqual(headers['X-Goog-Encryption-Key'], HEADER_KEY_VALUE) + self.assertEqual(headers['X-Goog-Encryption-Key-Sha256'], + HEADER_KEY_HASH_VALUE) + self.assertEqual(headers['Content-Length'], '6') + self.assertEqual(headers['Content-Type'], 'foo/bar') + def _upload_from_filename_test_helper(self, properties=None, content_type_arg=None, expected_content_type=None): @@ -837,6 +939,54 @@ def test_upload_from_string_w_text(self): self.assertEqual(headers['Content-Type'], 'text/plain') self.assertEqual(rq[0]['body'], ENCODED) + def test_upload_from_string_text_w_key(self): + from six.moves.http_client import OK + from six.moves.urllib.parse import parse_qsl + from six.moves.urllib.parse import urlsplit + from gcloud.streaming import http_wrapper + BLOB_NAME = 'blob-name' + KEY = 'aa426195405adee2c8081bb9e7e74b19' + HEADER_KEY_VALUE = 'YWE0MjYxOTU0MDVhZGVlMmM4MDgxYmI5ZTdlNzRiMTk=' + HEADER_KEY_HASH_VALUE = 'V3Kwe46nKc3xLv96+iJ707YfZfFvlObta8TQcx2gpm0=' + UPLOAD_URL = 'http://example.com/upload/name/key' + DATA = u'ABCDEF\u1234' + ENCODED = DATA.encode('utf-8') + loc_response = {'status': OK, 'location': UPLOAD_URL} + chunk1_response = {'status': http_wrapper.RESUME_INCOMPLETE, + 'range': 'bytes 0-4'} + chunk2_response = {'status': OK} + connection = _Connection( + (loc_response, '{}'), + (chunk1_response, ''), + (chunk2_response, ''), + ) + client = _Client(connection) + bucket = _Bucket(client=client) + blob = self._makeOne(BLOB_NAME, bucket=bucket) + blob._CHUNK_SIZE_MULTIPLE = 1 + blob.chunk_size = 5 + blob.upload_from_string(DATA, key=KEY) + rq = connection.http._requested + self.assertEqual(len(rq), 1) + self.assertEqual(rq[0]['method'], 'POST') + uri = rq[0]['uri'] + scheme, netloc, path, qs, _ = urlsplit(uri) + self.assertEqual(scheme, 'http') + self.assertEqual(netloc, 'example.com') + self.assertEqual(path, '/b/name/o') + self.assertEqual(dict(parse_qsl(qs)), + {'uploadType': 'media', 'name': BLOB_NAME}) + headers = dict( + [(x.title(), str(y)) for x, y in rq[0]['headers'].items()]) + + self.assertEqual(headers['X-Goog-Encryption-Algorithm'], 'AES256') + self.assertEqual(headers['X-Goog-Encryption-Key'], HEADER_KEY_VALUE) + self.assertEqual(headers['X-Goog-Encryption-Key-Sha256'], + HEADER_KEY_HASH_VALUE) + self.assertEqual(headers['Content-Length'], str(len(ENCODED))) + self.assertEqual(headers['Content-Type'], 'text/plain') + self.assertEqual(rq[0]['body'], ENCODED) + def test_make_public(self): from six.moves.http_client import OK from gcloud.storage.acl import _ACLEntity