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