1# -*- coding: utf-8 -*- 2# Copyright 2014 Google Inc. All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15"""Implementation of Url Signing workflow. 16 17see: https://developers.google.com/storage/docs/accesscontrol#Signed-URLs) 18""" 19 20from __future__ import absolute_import 21 22import base64 23import calendar 24from datetime import datetime 25from datetime import timedelta 26import getpass 27import re 28import time 29import urllib 30 31from apitools.base.py.exceptions import HttpError 32from apitools.base.py.http_wrapper import MakeRequest 33from apitools.base.py.http_wrapper import Request 34 35from gslib.command import Command 36from gslib.command_argument import CommandArgument 37from gslib.cs_api_map import ApiSelector 38from gslib.exception import CommandException 39from gslib.storage_url import ContainsWildcard 40from gslib.storage_url import StorageUrlFromString 41from gslib.util import GetNewHttp 42from gslib.util import NO_MAX 43from gslib.util import UTF8 44 45try: 46 # Check for openssl. 47 # pylint: disable=C6204 48 from OpenSSL.crypto import load_pkcs12 49 from OpenSSL.crypto import sign 50 HAVE_OPENSSL = True 51except ImportError: 52 load_pkcs12 = None 53 sign = None 54 HAVE_OPENSSL = False 55 56 57_SYNOPSIS = """ 58 gsutil signurl [-c] [-d] [-m] [-p] pkcs12-file url... 59""" 60 61_DETAILED_HELP_TEXT = (""" 62<B>SYNOPSIS</B> 63""" + _SYNOPSIS + """ 64 65 66<B>DESCRIPTION</B> 67 The signurl command will generate signed urls that can be used to access 68 the specified objects without authentication for a specific period of time. 69 70 Please see the `Signed URLs documentation 71 <https://developers.google.com/storage/docs/accesscontrol#Signed-URLs>`_ for 72 background about signed URLs. 73 74 Multiple gs:// urls may be provided and may contain wildcards. A signed url 75 will be produced for each provided url, authorized 76 for the specified HTTP method and valid for the given duration. 77 78 Note: Unlike the gsutil ls command, the signurl command does not support 79 operations on sub-directories. For example, if you run the command: 80 81 gsutil signurl <private-key-file> gs://some-bucket/some-object/ 82 83 The signurl command uses the private key for a service account (the 84 '<private-key-file>' argument) to generate the cryptographic 85 signature for the generated URL. The private key file must be in PKCS12 86 format. The signurl command will prompt for the passphrase used to protect 87 the private key file (default 'notasecret'). For more information 88 regarding generating a private key for use with the signurl command please 89 see the `Authentication documentation. 90 <https://developers.google.com/storage/docs/authentication#generating-a-private-key>`_ 91 92 gsutil will look up information about the object "some-object/" (with a 93 trailing slash) inside bucket "some-bucket", as opposed to operating on 94 objects nested under gs://some-bucket/some-object. Unless you actually 95 have an object with that name, the operation will fail. 96 97<B>OPTIONS</B> 98 -m Specifies the HTTP method to be authorized for use 99 with the signed url, default is GET. 100 101 -d Specifies the duration that the signed url should be valid 102 for, default duration is 1 hour. 103 104 Times may be specified with no suffix (default hours), or 105 with s = seconds, m = minutes, h = hours, d = days. 106 107 This option may be specified multiple times, in which case 108 the duration the link remains valid is the sum of all the 109 duration options. 110 111 -c Specifies the content type for which the signed url is 112 valid for. 113 114 -p Specify the keystore password instead of prompting. 115 116<B>USAGE</B> 117 118 Create a signed url for downloading an object valid for 10 minutes: 119 120 gsutil signurl -d 10m <private-key-file> gs://<bucket>/<object> 121 122 Create a signed url for uploading a plain text file via HTTP PUT: 123 124 gsutil signurl -m PUT -d 1h -c text/plain <private-key-file> \\ 125 gs://<bucket>/<obj> 126 127 To construct a signed URL that allows anyone in possession of 128 the URL to PUT to the specified bucket for one day, creating 129 any object of Content-Type image/jpg, run: 130 131 gsutil signurl -m PUT -d 1d -c image/jpg <private-key-file> \\ 132 gs://<bucket>/<obj> 133 134 135""") 136 137 138def _DurationToTimeDelta(duration): 139 r"""Parses the given duration and returns an equivalent timedelta.""" 140 141 match = re.match(r'^(\d+)([dDhHmMsS])?$', duration) 142 if not match: 143 raise CommandException('Unable to parse duration string') 144 145 duration, modifier = match.groups('h') 146 duration = int(duration) 147 modifier = modifier.lower() 148 149 if modifier == 'd': 150 ret = timedelta(days=duration) 151 elif modifier == 'h': 152 ret = timedelta(hours=duration) 153 elif modifier == 'm': 154 ret = timedelta(minutes=duration) 155 elif modifier == 's': 156 ret = timedelta(seconds=duration) 157 158 return ret 159 160 161def _GenSignedUrl(key, client_id, method, md5, 162 content_type, expiration, gcs_path): 163 """Construct a string to sign with the provided key and returns \ 164 the complete url.""" 165 166 tosign = ('{0}\n{1}\n{2}\n{3}\n/{4}' 167 .format(method, md5, content_type, 168 expiration, gcs_path)) 169 signature = base64.b64encode(sign(key, tosign, 'RSA-SHA256')) 170 171 final_url = ('https://storage.googleapis.com/{0}?' 172 'GoogleAccessId={1}&Expires={2}&Signature={3}' 173 .format(gcs_path, client_id, expiration, 174 urllib.quote_plus(str(signature)))) 175 176 return final_url 177 178 179def _ReadKeystore(ks_contents, passwd): 180 ks = load_pkcs12(ks_contents, passwd) 181 client_id = (ks.get_certificate() 182 .get_subject() 183 .CN.replace('.apps.googleusercontent.com', 184 '@developer.gserviceaccount.com')) 185 186 return ks, client_id 187 188 189class UrlSignCommand(Command): 190 """Implementation of gsutil url_sign command.""" 191 192 # Command specification. See base class for documentation. 193 command_spec = Command.CreateCommandSpec( 194 'signurl', 195 command_name_aliases=['signedurl', 'queryauth'], 196 usage_synopsis=_SYNOPSIS, 197 min_args=2, 198 max_args=NO_MAX, 199 supported_sub_args='m:d:c:p:', 200 file_url_ok=False, 201 provider_url_ok=False, 202 urls_start_arg=1, 203 gs_api_support=[ApiSelector.XML, ApiSelector.JSON], 204 gs_default_api=ApiSelector.JSON, 205 argparse_arguments=[ 206 CommandArgument.MakeNFileURLsArgument(1), 207 CommandArgument.MakeZeroOrMoreCloudURLsArgument() 208 ] 209 ) 210 # Help specification. See help_provider.py for documentation. 211 help_spec = Command.HelpSpec( 212 help_name='signurl', 213 help_name_aliases=['signedurl', 'queryauth'], 214 help_type='command_help', 215 help_one_line_summary='Create a signed url', 216 help_text=_DETAILED_HELP_TEXT, 217 subcommand_help_text={}, 218 ) 219 220 def _ParseAndCheckSubOpts(self): 221 # Default argument values 222 delta = None 223 method = 'GET' 224 content_type = '' 225 passwd = None 226 227 for o, v in self.sub_opts: 228 if o == '-d': 229 if delta is not None: 230 delta += _DurationToTimeDelta(v) 231 else: 232 delta = _DurationToTimeDelta(v) 233 elif o == '-m': 234 method = v 235 elif o == '-c': 236 content_type = v 237 elif o == '-p': 238 passwd = v 239 else: 240 self.RaiseInvalidArgumentException() 241 242 if delta is None: 243 delta = timedelta(hours=1) 244 245 expiration = calendar.timegm((datetime.utcnow() + delta).utctimetuple()) 246 if method not in ['GET', 'PUT', 'DELETE', 'HEAD']: 247 raise CommandException('HTTP method must be one of [GET|HEAD|PUT|DELETE]') 248 249 return method, expiration, content_type, passwd 250 251 def _ProbeObjectAccessWithClient(self, key, client_id, gcs_path): 252 """Performs a head request against a signed url to check for read access.""" 253 254 signed_url = _GenSignedUrl(key, client_id, 'HEAD', '', '', 255 int(time.time()) + 10, gcs_path) 256 257 try: 258 h = GetNewHttp() 259 req = Request(signed_url, 'HEAD') 260 response = MakeRequest(h, req) 261 262 if response.status_code not in [200, 403, 404]: 263 raise HttpError(response) 264 265 return response.status_code 266 except HttpError as e: 267 raise CommandException('Unexpected response code while querying' 268 'object readability ({0})'.format(e.message)) 269 270 def _EnumerateStorageUrls(self, in_urls): 271 ret = [] 272 273 for url_str in in_urls: 274 if ContainsWildcard(url_str): 275 ret.extend([blr.storage_url for blr in self.WildcardIterator(url_str)]) 276 else: 277 ret.append(StorageUrlFromString(url_str)) 278 279 return ret 280 281 def RunCommand(self): 282 """Command entry point for signurl command.""" 283 if not HAVE_OPENSSL: 284 raise CommandException( 285 'The signurl command requires the pyopenssl library (try pip ' 286 'install pyopenssl or easy_install pyopenssl)') 287 288 method, expiration, content_type, passwd = self._ParseAndCheckSubOpts() 289 storage_urls = self._EnumerateStorageUrls(self.args[1:]) 290 291 if not passwd: 292 passwd = getpass.getpass('Keystore password:') 293 294 ks, client_id = _ReadKeystore(open(self.args[0], 'rb').read(), passwd) 295 296 print 'URL\tHTTP Method\tExpiration\tSigned URL' 297 for url in storage_urls: 298 if url.scheme != 'gs': 299 raise CommandException('Can only create signed urls from gs:// urls') 300 if url.IsBucket(): 301 gcs_path = url.bucket_name 302 else: 303 # Need to url encode the object name as Google Cloud Storage does when 304 # computing the string to sign when checking the signature. 305 gcs_path = '{0}/{1}'.format(url.bucket_name, 306 urllib.quote(url.object_name.encode(UTF8))) 307 308 final_url = _GenSignedUrl(ks.get_privatekey(), client_id, 309 method, '', content_type, expiration, 310 gcs_path) 311 312 expiration_dt = datetime.fromtimestamp(expiration) 313 314 print '{0}\t{1}\t{2}\t{3}'.format(url.url_string.encode(UTF8), method, 315 (expiration_dt 316 .strftime('%Y-%m-%d %H:%M:%S')), 317 final_url.encode(UTF8)) 318 319 response_code = self._ProbeObjectAccessWithClient(ks.get_privatekey(), 320 client_id, gcs_path) 321 322 if response_code == 404 and method != 'PUT': 323 if url.IsBucket(): 324 msg = ('Bucket {0} does not exist. Please create a bucket with ' 325 'that name before a creating signed URL to access it.' 326 .format(url)) 327 else: 328 msg = ('Object {0} does not exist. Please create/upload an object ' 329 'with that name before a creating signed URL to access it.' 330 .format(url)) 331 332 raise CommandException(msg) 333 elif response_code == 403: 334 self.logger.warn( 335 '%s does not have permissions on %s, using this link will likely ' 336 'result in a 403 error until at least READ permissions are granted', 337 client_id, url) 338 339 return 0 340